fix(webui): keep slash commands out of streaming state
This commit is contained in:
@@ -1480,7 +1480,17 @@ export function ThreadComposer({
|
||||
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
|
||||
}
|
||||
: undefined;
|
||||
onSend(content, payload, options);
|
||||
const commandName = content.split(/\s+/, 1)[0];
|
||||
const isSlashSideChannel =
|
||||
payload === undefined
|
||||
&& attachedCliApps.length === 0
|
||||
&& attachedMcpPresets.length === 0
|
||||
&& visibleSlashCommands.some((command) => command.command === commandName);
|
||||
onSend(
|
||||
content,
|
||||
payload,
|
||||
isSlashSideChannel ? { ...options, sideChannel: true } : options,
|
||||
);
|
||||
setQueuedPrompts([]);
|
||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||
// preview here without affecting the rendered message.
|
||||
@@ -1497,6 +1507,7 @@ export function ThreadComposer({
|
||||
onSend,
|
||||
readyImages,
|
||||
value,
|
||||
visibleSlashCommands,
|
||||
]);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
||||
@@ -455,6 +455,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
measureComposerDock();
|
||||
}, [composer, hasMessages, measureComposerDock]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hasMessages || userReadingHistoryRef.current) return;
|
||||
const promptId = activeTurnPromptRef.current;
|
||||
if (promptId && scrollToPromptTopNow(promptId)) return;
|
||||
scrollToBottom(false, 2);
|
||||
}, [composerDockHeight, hasMessages, scrollToBottom, scrollToPromptTopNow]);
|
||||
|
||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -42,6 +42,7 @@ type PendingStreamEvent =
|
||||
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
||||
|
||||
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
|
||||
const STREAM_END_IDLE_DELAY_MS = 1000;
|
||||
|
||||
function turnFieldsFromEvent(
|
||||
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
|
||||
@@ -446,6 +447,32 @@ export interface SendOptions {
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
sideChannel?: boolean;
|
||||
}
|
||||
|
||||
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||
if (
|
||||
ev.event === "delta"
|
||||
|| ev.event === "reasoning_delta"
|
||||
|| ev.event === "file_edit"
|
||||
) return true;
|
||||
return ev.event === "message"
|
||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
||||
}
|
||||
|
||||
function finalizeStreamedTurn(
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
): UIMessage[] {
|
||||
return prev.map((m) =>
|
||||
m.isStreaming && matchesTurn(m, turn)
|
||||
? { ...m, isStreaming: false, reasoningStreaming: false }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
|
||||
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||
}
|
||||
|
||||
export function useNanobotStream(
|
||||
@@ -489,6 +516,7 @@ export function useNanobotStream(
|
||||
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
|
||||
const streamFrameRef = useRef<number | null>(null);
|
||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
|
||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||
*
|
||||
* When the model finishes a text segment and calls a tool, the server
|
||||
@@ -512,6 +540,26 @@ export function useNanobotStream(
|
||||
pendingStreamEventsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const cancelStreamEndTimer = useCallback(() => {
|
||||
if (streamEndTimerRef.current === null) return;
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const isSideChannelEvent = useCallback((ev: InboundEvent) => {
|
||||
const turnId = eventTurnId(ev);
|
||||
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId);
|
||||
}, []);
|
||||
|
||||
const scheduleStreamEndTimer = useCallback((turn: UIMessageTurnFields = {}) => {
|
||||
cancelStreamEndTimer();
|
||||
streamEndTimerRef.current = setTimeout(() => {
|
||||
streamEndTimerRef.current = null;
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
||||
}, STREAM_END_IDLE_DELAY_MS);
|
||||
}, [cancelStreamEndTimer]);
|
||||
|
||||
const createActivitySegmentId = useCallback((activate = true) => {
|
||||
activitySegmentCounterRef.current += 1;
|
||||
const id = `activity-${activitySegmentCounterRef.current}`;
|
||||
@@ -723,13 +771,11 @@ export function useNanobotStream(
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
sideChannelTurnIdsRef.current.clear();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
cancelStreamEndTimer();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||
}, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingToolCalls) setIsStreaming(true);
|
||||
@@ -739,13 +785,12 @@ export function useNanobotStream(
|
||||
if (!chatId) return;
|
||||
|
||||
const handle = (ev: InboundEvent) => {
|
||||
// Any incoming event while the debounce timer is alive means the model
|
||||
// is still working (e.g. tool result arrived, more text to stream).
|
||||
// Cancel the pending "stream ended" timer so we don't hide the spinner.
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
const sideChannelEvent = isSideChannelEvent(ev);
|
||||
if (
|
||||
streamEndTimerRef.current !== null
|
||||
&& !sideChannelEvent
|
||||
&& eventExtendsModelActivity(ev)
|
||||
) cancelStreamEndTimer();
|
||||
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
@@ -778,15 +823,14 @@ export function useNanobotStream(
|
||||
}
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
const turn = turnFieldsFromEvent(ev, "answer");
|
||||
flushPendingStreamEvents({
|
||||
closeAnswerSegment: true,
|
||||
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
|
||||
turn: turnFieldsFromEvent(ev, "answer"),
|
||||
turn,
|
||||
});
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
// stream_end only means the text segment finished — the model may
|
||||
// still be executing tools. Do NOT reset isStreaming here; the
|
||||
// definitive "turn is complete" signal is ``turn_end``.
|
||||
scheduleStreamEndTimer(turn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -825,10 +869,7 @@ export function useNanobotStream(
|
||||
setRunStartedAt(null);
|
||||
// Definitive signal that the turn is fully complete. Cancel any
|
||||
// pending debounce timer and stop the loading indicator immediately.
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
cancelStreamEndTimer();
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) => {
|
||||
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
@@ -943,11 +984,21 @@ export function useNanobotStream(
|
||||
? ev.media_urls.map((m) => toMediaAttachment(m))
|
||||
: ev.media?.map((url) => toMediaAttachment({ url }));
|
||||
const hasMedia = !!media && media.length > 0;
|
||||
if (sideChannelEvent) {
|
||||
setMessages((prev) => absorbCompleteAssistantMessage(prev, {
|
||||
content: ev.text,
|
||||
...(hasMedia ? { media } : {}),
|
||||
...(ev.source ? { source: ev.source } : {}),
|
||||
...turnFieldsFromEvent(ev, "answer"),
|
||||
}));
|
||||
if (typeof ev.turn_id === "string") sideChannelTurnIdsRef.current.delete(ev.turn_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// A complete (non-streamed) assistant message. If a stream was in
|
||||
// flight, drop the placeholder so we don't render the text twice.
|
||||
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||
// the full turn (all tool calls + final text) is complete.
|
||||
// Streaming state is closed by ``stream_end`` when present, or by
|
||||
// ``turn_end`` for non-streamed and tool-heavy turns.
|
||||
clearActivitySegment();
|
||||
setMessages((prev) => {
|
||||
const activeId = buffer.current?.messageId;
|
||||
@@ -1034,12 +1085,10 @@ export function useNanobotStream(
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
cancelStreamEndTimer();
|
||||
};
|
||||
}, [
|
||||
cancelStreamEndTimer,
|
||||
chatId,
|
||||
client,
|
||||
clearActivitySegment,
|
||||
@@ -1047,8 +1096,10 @@ export function useNanobotStream(
|
||||
detachedActivitySegmentId,
|
||||
ensureActivitySegmentId,
|
||||
flushPendingStreamEvents,
|
||||
isSideChannelEvent,
|
||||
onTurnEnd,
|
||||
schedulePendingStreamFlush,
|
||||
scheduleStreamEndTimer,
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
@@ -1059,16 +1110,20 @@ export function useNanobotStream(
|
||||
// the image blocks via ``media`` paths.
|
||||
if (!hasImages && !content.trim()) return;
|
||||
|
||||
const sideChannel = options?.sideChannel === true;
|
||||
flushPendingStreamEvents();
|
||||
const turnId = crypto.randomUUID();
|
||||
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
if (!sideChannel) {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
}
|
||||
return [
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
...(sideChannel ? prev : pruneReasoningOnlyPlaceholders(prev)),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
@@ -1083,11 +1138,11 @@ export function useNanobotStream(
|
||||
},
|
||||
];
|
||||
});
|
||||
// Mark streaming immediately so the UI shows the loading indicator
|
||||
// right away, before the first delta arrives from the server.
|
||||
setIsStreaming(true);
|
||||
if (!sideChannel) setIsStreaming(true);
|
||||
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
|
||||
client.sendMessage(chatId, content, wireMedia, { ...options, turnId });
|
||||
const wireOptions = { ...options, turnId };
|
||||
delete wireOptions.sideChannel;
|
||||
client.sendMessage(chatId, content, wireMedia, wireOptions);
|
||||
},
|
||||
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
);
|
||||
|
||||
@@ -1312,6 +1312,23 @@ describe("ThreadComposer", () => {
|
||||
expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined);
|
||||
});
|
||||
|
||||
it("marks known slash commands as side-channel sends", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
slashCommands={COMMANDS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "/history" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true });
|
||||
});
|
||||
|
||||
it("shows a stop button while streaming", () => {
|
||||
const onStop = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -82,6 +82,30 @@ function stubVisualViewport({
|
||||
};
|
||||
}
|
||||
|
||||
function stubResizeObserver() {
|
||||
const original = globalThis.ResizeObserver;
|
||||
const observers: ResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect = vi.fn();
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
return {
|
||||
observers,
|
||||
restore: () => vi.stubGlobal("ResizeObserver", original),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLongMessages(count: number): UIMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
@@ -362,23 +386,7 @@ describe("ThreadViewport", () => {
|
||||
});
|
||||
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect = vi.fn();
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
const resizeObserver = stubResizeObserver();
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
@@ -418,7 +426,7 @@ describe("ThreadViewport", () => {
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
const composerObserver = resizeObservers.find(
|
||||
const composerObserver = resizeObserver.observers.find(
|
||||
(observer) => observer.element === composerDock,
|
||||
);
|
||||
expect(composerObserver).toBeDefined();
|
||||
@@ -429,7 +437,85 @@ describe("ThreadViewport", () => {
|
||||
|
||||
expect(buttonPositioner).toHaveStyle({ bottom: "256px" });
|
||||
} finally {
|
||||
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
||||
resizeObserver.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the active prompt visible when the composer grows", async () => {
|
||||
const resizeObserver = stubResizeObserver();
|
||||
|
||||
try {
|
||||
const threaded: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "new question", createdAt: 3 },
|
||||
];
|
||||
const scrollTo = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1200 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||
expect(prompt).not.toBeNull();
|
||||
Object.defineProperty(prompt, "offsetTop", {
|
||||
configurable: true,
|
||||
value: 420,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
scrollToLatestUserPromptSignal={1}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
scrollTo.mockClear();
|
||||
const composerDock = screen.getByTestId("thread-composer-dock");
|
||||
composerDock.getBoundingClientRect = () =>
|
||||
({
|
||||
height: 240,
|
||||
width: 800,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 240,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
const composerObserver = resizeObserver.observers.find(
|
||||
(observer) => observer.element === composerDock,
|
||||
);
|
||||
expect(composerObserver).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 404,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
resizeObserver.restore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1622,7 +1622,89 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
});
|
||||
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
|
||||
it("does not mark side-channel slash commands as streaming", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-status", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.send("/status", undefined, { sideChannel: true });
|
||||
});
|
||||
|
||||
const call = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||
const turnId = call[3]?.turnId;
|
||||
expect(call[3]).not.toHaveProperty("sideChannel");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-status", {
|
||||
event: "message",
|
||||
chat_id: "chat-status",
|
||||
text: "status reply",
|
||||
turn_id: turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||
"/status",
|
||||
"status reply",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-status-loop", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.send("write normally");
|
||||
});
|
||||
const promptTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId;
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-status-loop", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-status-loop",
|
||||
text: "done",
|
||||
turn_id: promptTurnId,
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.send("/status", undefined, { sideChannel: true });
|
||||
});
|
||||
const statusTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId;
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-status-loop", {
|
||||
event: "message",
|
||||
chat_id: "chat-status-loop",
|
||||
text: "status reply",
|
||||
turn_id: statusTurnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
|
||||
isStreaming: false,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps streaming alive across stream_end when tool activity follows", async () => {
|
||||
const fake = fakeClient();
|
||||
const onTurnEnd = vi.fn();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
||||
@@ -1660,14 +1742,15 @@ describe("useNanobotStream", () => {
|
||||
fake.emit("chat-s", {
|
||||
event: "message",
|
||||
chat_id: "chat-s",
|
||||
text: "Hello world",
|
||||
kind: "progress",
|
||||
text: "Calling tool",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello world",
|
||||
role: "tool",
|
||||
content: "Calling tool",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
||||
Reference in New Issue
Block a user