import { act, renderHook } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import { useNanobotStream } from "@/hooks/useNanobotStream"; import type { InboundEvent, GoalStateWsPayload } from "@/lib/types"; import { ClientProvider } from "@/providers/ClientProvider"; const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = []; function fakeClient() { const handlers = new Map void>>(); const runStartedAtByChatId = new Map(); const goalStateByChatId = new Map(); function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) { if (ev.event !== "goal_status") return; if (ev.status === "running" && typeof ev.started_at === "number") { runStartedAtByChatId.set(chatId, ev.started_at); } else { runStartedAtByChatId.delete(chatId); } } function recordGoalStateSnapshot(chatId: string, ev: InboundEvent) { if (ev.event === "goal_state") { goalStateByChatId.set(chatId, ev.goal_state); return; } if (ev.event === "turn_end" && ev.goal_state != null && typeof ev.goal_state === "object") { goalStateByChatId.set(chatId, ev.goal_state); } } return { client: { status: "open" as const, defaultChatId: null as string | null, onStatus: () => () => {}, onError: () => () => {}, getRunStartedAt(chatId: string) { const v = runStartedAtByChatId.get(chatId); return v === undefined ? null : v; }, getGoalState(chatId: string) { return goalStateByChatId.get(chatId); }, onChat(chatId: string, h: (ev: InboundEvent) => void) { let set = handlers.get(chatId); if (!set) { set = new Set(); handlers.set(chatId, set); } set.add(h); return () => set!.delete(h); }, sendMessage: vi.fn(), newChat: vi.fn(), attach: vi.fn(), connect: vi.fn(), close: vi.fn(), updateUrl: vi.fn(), }, emit(chatId: string, ev: InboundEvent) { recordGoalStatusForRunStrip(chatId, ev); recordGoalStateSnapshot(chatId, ev); const set = handlers.get(chatId); set?.forEach((h) => h(ev)); }, }; } function wrap(client: ReturnType["client"]) { return function Wrapper({ children }: { children: ReactNode }) { return ( {children} ); }; } async function flushStreamFrame() { await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => resolve()); }); }); } describe("useNanobotStream", () => { it("batches answer deltas into one animation-frame update", async () => { const fake = fakeClient(); const requestFrame = vi.spyOn(window, "requestAnimationFrame"); const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-batch", { event: "delta", chat_id: "chat-batch", text: "Hello", }); fake.emit("chat-batch", { event: "delta", chat_id: "chat-batch", text: " world", }); }); expect(requestFrame).toHaveBeenCalledTimes(1); expect(result.current.messages).toHaveLength(0); await flushStreamFrame(); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]).toMatchObject({ role: "assistant", content: "Hello world", isStreaming: true, }); requestFrame.mockRestore(); }); it("flushes pending delta text before turn_end finalizes the turn", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-flush", { event: "delta", chat_id: "chat-flush", text: "final chunk", }); fake.emit("chat-flush", { event: "turn_end", chat_id: "chat-flush", }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]).toMatchObject({ role: "assistant", content: "final chunk", isStreaming: false, }); expect(result.current.isStreaming).toBe(false); }); it("drops pending stream work when switching chats", async () => { const fake = fakeClient(); const { result, rerender } = renderHook( ({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES), { wrapper: wrap(fake.client), initialProps: { chatId: "chat-old" }, }, ); act(() => { fake.emit("chat-old", { event: "delta", chat_id: "chat-old", text: "stale", }); }); rerender({ chatId: "chat-new" }); act(() => { fake.emit("chat-new", { event: "delta", chat_id: "chat-new", text: "fresh", }); }); await flushStreamFrame(); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]).toMatchObject({ role: "assistant", content: "fresh", }); }); it("starts in streaming mode when history shows pending tool calls", () => { const fake = fakeClient(); const initialMessages = [{ id: "m1", role: "assistant" as const, content: "Using tools", createdAt: Date.now(), }]; const { result } = renderHook( () => useNanobotStream("chat-p", initialMessages, true), { wrapper: wrap(fake.client), }, ); expect(result.current.isStreaming).toBe(true); }); it("collapses consecutive tool_hint frames into one trace row", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-t", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-t", { event: "message", chat_id: "chat-t", text: 'weather("get")', kind: "tool_hint", }); fake.emit("chat-t", { event: "message", chat_id: "chat-t", text: 'search "hk weather"', kind: "tool_hint", }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].kind).toBe("trace"); expect(result.current.messages[0].role).toBe("tool"); expect(result.current.messages[0].traces).toEqual([ 'weather("get")', 'search "hk weather"', ]); act(() => { fake.emit("chat-t", { event: "message", chat_id: "chat-t", text: "## Summary", }); }); expect(result.current.messages).toHaveLength(2); expect(result.current.messages[1].role).toBe("assistant"); expect(result.current.messages[1].kind).toBeUndefined(); }); it("treats progress with arbitrary agent_ui like ordinary trace text", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-au", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-au", { event: "message", chat_id: "chat-au", text: "progress · panel tick", kind: "progress", agent_ui: { kind: "panel", data: { version: 1, event: "tick", id: "x1" }, }, }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].kind).toBe("trace"); expect(result.current.messages[0].content).toContain("panel tick"); }); it("renders live tool traces from structured tool events", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-tool-events", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-tool-events", { event: "message", chat_id: "chat-tool-events", text: 'search "hermes"', kind: "tool_hint", tool_events: [ { phase: "start", name: "web_search", arguments: { query: "NousResearch hermes-agent", count: 8 }, }, { phase: "start", name: "web_search", arguments: { query: "hermes-agent GitHub stars", count: 8 }, }, ], }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].traces).toEqual([ 'web_search({"query":"NousResearch hermes-agent","count":8})', 'web_search({"query":"hermes-agent GitHub stars","count":8})', ]); expect(result.current.messages[0].content).toBe( 'web_search({"query":"hermes-agent GitHub stars","count":8})', ); }); it("dedupes finish-phase tool events after their start trace", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-tool-finish", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-tool-finish", { event: "message", chat_id: "chat-tool-finish", text: 'exec({"cmd":"ls"})', kind: "tool_hint", tool_events: [{ phase: "start", call_id: "call-exec", name: "exec", arguments: { cmd: "ls" }, }], }); fake.emit("chat-tool-finish", { event: "message", chat_id: "chat-tool-finish", text: "", kind: "progress", tool_events: [ { phase: "end", call_id: "call-exec", name: "exec", arguments: { cmd: "ls" }, result: "ok", }, { phase: "error", call_id: "call-read", name: "read_file", arguments: { path: "notes.md" }, error: "missing", }, ], }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].traces).toEqual([ 'exec({"cmd":"ls"})', 'read_file({"path":"notes.md"})', ]); }); it("renders live file_edit events as their own activity trace", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-file-edit", { event: "message", chat_id: "chat-file-edit", text: 'write_file({"path":"foo.txt"})', kind: "tool_hint", }); fake.emit("chat-file-edit", { event: "file_edit", chat_id: "chat-file-edit", edits: [{ call_id: "call-write", tool: "write_file", path: "foo.txt", phase: "start", added: 1, deleted: 0, approximate: true, status: "editing", }], }); fake.emit("chat-file-edit", { event: "file_edit", chat_id: "chat-file-edit", edits: [{ call_id: "call-write", tool: "write_file", path: "foo.txt", phase: "end", added: 3, deleted: 1, approximate: false, status: "done", }], }); }); expect(result.current.messages).toHaveLength(2); expect(result.current.messages[0]).toMatchObject({ role: "tool", kind: "trace", traces: ['write_file({"path":"foo.txt"})'], }); expect(result.current.messages[1]).toMatchObject({ role: "tool", kind: "trace", fileEdits: [{ call_id: "call-write", status: "done", added: 3, deleted: 1, approximate: false, }], }); expect(result.current.messages[1].activitySegmentId).toBeTruthy(); expect(result.current.messages[1].activitySegmentId).not.toBe( result.current.messages[0].activitySegmentId, ); }); it("upgrades pending file_edit placeholders when the path arrives", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-file-edit-pending", { event: "file_edit", chat_id: "chat-file-edit-pending", edits: [{ call_id: "call-write", tool: "write_file", path: "", phase: "start", added: 1, deleted: 0, approximate: true, status: "editing", pending: true, }], }); fake.emit("chat-file-edit-pending", { event: "file_edit", chat_id: "chat-file-edit-pending", edits: [{ call_id: "call-write", tool: "write_file", path: "foo.txt", phase: "start", added: 12, deleted: 0, approximate: true, status: "editing", }], }); }); const fileEditMessages = result.current.messages.filter((message) => message.fileEdits?.length); expect(fileEditMessages).toHaveLength(1); expect(fileEditMessages[0].fileEdits).toEqual([{ call_id: "call-write", tool: "write_file", path: "foo.txt", phase: "start", added: 12, deleted: 0, approximate: true, status: "editing", }]); }); it("merges file_edit updates after interleaved progress events", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-edit-progress", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-file-edit-progress", { event: "message", chat_id: "chat-file-edit-progress", text: 'write_file({"path":"foo.txt"})', kind: "tool_hint", }); fake.emit("chat-file-edit-progress", { event: "file_edit", chat_id: "chat-file-edit-progress", edits: [{ call_id: "call-write", tool: "write_file", path: "foo.txt", phase: "start", added: 12, deleted: 0, approximate: true, status: "editing", }], }); fake.emit("chat-file-edit-progress", { event: "message", chat_id: "chat-file-edit-progress", text: "still working", kind: "progress", }); fake.emit("chat-file-edit-progress", { event: "file_edit", chat_id: "chat-file-edit-progress", edits: [{ call_id: "call-write", tool: "write_file", path: "foo.txt", phase: "end", added: 30, deleted: 0, approximate: false, status: "done", }], }); }); const fileEditMessages = result.current.messages.filter((message) => message.fileEdits?.length); expect(fileEditMessages).toHaveLength(1); expect(fileEditMessages[0].fileEdits).toEqual([{ call_id: "call-write", tool: "write_file", path: "foo.txt", phase: "end", added: 30, deleted: 0, approximate: false, status: "done", }]); }); it("starts a new assistant bubble for deltas after stream_end and activity", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-stream-segments", { event: "delta", chat_id: "chat-stream-segments", text: "I created the files.", }); fake.emit("chat-stream-segments", { event: "stream_end", chat_id: "chat-stream-segments", }); fake.emit("chat-stream-segments", { event: "message", chat_id: "chat-stream-segments", text: 'write_file({"path":"minecraft-fps/options.txt"})', kind: "tool_hint", }); fake.emit("chat-stream-segments", { event: "delta", chat_id: "chat-stream-segments", text: "Now I will summarize the edits.", }); }); await flushStreamFrame(); expect(result.current.messages).toHaveLength(3); expect(result.current.messages[0]).toMatchObject({ role: "assistant", content: "I created the files.", }); expect(result.current.messages[1]).toMatchObject({ role: "tool", kind: "trace", traces: ['write_file({"path":"minecraft-fps/options.txt"})'], }); expect(result.current.messages[2]).toMatchObject({ role: "assistant", content: "Now I will summarize the edits.", }); }); it("opens a new activity segment for reasoning after file edit activity", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-file-segments", { event: "reasoning_delta", chat_id: "chat-file-segments", text: "Plan.", }); fake.emit("chat-file-segments", { event: "reasoning_end", chat_id: "chat-file-segments", }); fake.emit("chat-file-segments", { event: "message", chat_id: "chat-file-segments", text: 'edit_file({"path":"foo.txt"})', kind: "tool_hint", }); fake.emit("chat-file-segments", { event: "file_edit", chat_id: "chat-file-segments", edits: [{ call_id: "call-edit", tool: "edit_file", path: "foo.txt", phase: "start", added: 1, deleted: 1, approximate: true, status: "editing", }], }); fake.emit("chat-file-segments", { event: "reasoning_delta", chat_id: "chat-file-segments", text: "Review result.", }); }); await flushStreamFrame(); expect(result.current.messages).toHaveLength(4); const firstSegment = result.current.messages[0].activitySegmentId; expect(firstSegment).toBeTruthy(); expect(result.current.messages[1].activitySegmentId).toBe(firstSegment); expect(result.current.messages[2].activitySegmentId).toBeTruthy(); expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment); expect(result.current.messages[3].activitySegmentId).toBeTruthy(); expect(result.current.messages[3].activitySegmentId).not.toBe(result.current.messages[2].activitySegmentId); }); it("keeps file edit blocks ordered across a new reasoning phase", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-order", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-file-order", { event: "file_edit", chat_id: "chat-file-order", edits: [{ call_id: "call-one", tool: "write_file", path: "one.txt", phase: "start", added: 10, deleted: 0, approximate: true, status: "editing", }], }); fake.emit("chat-file-order", { event: "reasoning_delta", chat_id: "chat-file-order", text: "Check the next file.", }); }); await flushStreamFrame(); act(() => { fake.emit("chat-file-order", { event: "file_edit", chat_id: "chat-file-order", edits: [{ call_id: "call-two", tool: "write_file", path: "two.txt", phase: "start", added: 20, deleted: 0, approximate: true, status: "editing", }], }); }); expect(result.current.messages.map((message) => message.fileEdits?.[0]?.path ?? message.reasoning)).toEqual([ "one.txt", "Check the next file.", "two.txt", ]); const fileEditSegments = result.current.messages .filter((message) => message.fileEdits?.length) .map((message) => message.activitySegmentId); expect(fileEditSegments).toHaveLength(2); expect(fileEditSegments[0]).not.toBe(fileEditSegments[1]); }); it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-r", { event: "reasoning_delta", chat_id: "chat-r", text: "Let me think ", }); fake.emit("chat-r", { event: "reasoning_delta", chat_id: "chat-r", text: "step by step.", }); }); await flushStreamFrame(); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].role).toBe("assistant"); expect(result.current.messages[0].reasoning).toBe("Let me think step by step."); expect(result.current.messages[0].reasoningStreaming).toBe(true); act(() => { fake.emit("chat-r", { event: "reasoning_end", chat_id: "chat-r" }); }); expect(result.current.messages[0].reasoningStreaming).toBe(false); expect(result.current.messages[0].reasoning).toBe("Let me think step by step."); }); it("absorbs a streaming reasoning placeholder into the answer turn that follows", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r2", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-r2", { event: "reasoning_delta", chat_id: "chat-r2", text: "Plan first.", }); fake.emit("chat-r2", { event: "reasoning_end", chat_id: "chat-r2" }); fake.emit("chat-r2", { event: "delta", chat_id: "chat-r2", text: "The answer is 42.", }); fake.emit("chat-r2", { event: "stream_end", chat_id: "chat-r2" }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].content).toBe("The answer is 42."); expect(result.current.messages[0].reasoning).toBe("Plan first."); expect(result.current.messages[0].reasoningStreaming).toBe(false); }); it("ignores empty reasoning_delta frames", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r3", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-r3", { event: "reasoning_delta", chat_id: "chat-r3", text: "", }); }); expect(result.current.messages).toHaveLength(0); }); it("treats legacy kind=reasoning messages as a complete delta + end pair", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r4", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-r4", { event: "message", chat_id: "chat-r4", text: "one-shot reasoning", kind: "reasoning", }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].reasoning).toBe("one-shot reasoning"); expect(result.current.messages[0].reasoningStreaming).toBe(false); }); it("attaches post-hoc reasoning to the same assistant turn above the answer", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-r5", { event: "delta", chat_id: "chat-r5", text: "hi~", }); fake.emit("chat-r5", { event: "stream_end", chat_id: "chat-r5" }); fake.emit("chat-r5", { event: "reasoning_delta", chat_id: "chat-r5", text: "This reasoning arrived after the answer stream.", }); fake.emit("chat-r5", { event: "reasoning_end", chat_id: "chat-r5" }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].content).toBe("hi~"); expect(result.current.messages[0].reasoning).toBe( "This reasoning arrived after the answer stream.", ); expect(result.current.messages[0].reasoningStreaming).toBe(false); }); it("does not attach a new turn's reasoning across the latest user boundary", async () => { const fake = fakeClient(); const initialMessages = [ { id: "a-prev", role: "assistant" as const, content: "Previous answer.", reasoning: "Previous thought.", createdAt: Date.now(), }, { id: "u-next", role: "user" as const, content: "Next question", createdAt: Date.now(), }, ]; const { result } = renderHook( () => useNanobotStream("chat-r6", initialMessages), { wrapper: wrap(fake.client) }, ); act(() => { fake.emit("chat-r6", { event: "reasoning_delta", chat_id: "chat-r6", text: "New turn thinking.", }); }); await flushStreamFrame(); expect(result.current.messages).toHaveLength(3); expect(result.current.messages[0].reasoning).toBe("Previous thought."); expect(result.current.messages[2].role).toBe("assistant"); expect(result.current.messages[2].content).toBe(""); expect(result.current.messages[2].reasoning).toBe("New turn thinking."); expect(result.current.messages[2].reasoningStreaming).toBe(true); }); it("does not attach reasoning across a tool trace boundary", async () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-r7", { event: "reasoning_delta", chat_id: "chat-r7", text: "First reasoning.", }); fake.emit("chat-r7", { event: "reasoning_end", chat_id: "chat-r7" }); fake.emit("chat-r7", { event: "message", chat_id: "chat-r7", text: "web_search({\"query\":\"OpenClaw\"})", kind: "tool_hint", }); fake.emit("chat-r7", { event: "reasoning_delta", chat_id: "chat-r7", text: "Second reasoning.", }); }); await flushStreamFrame(); expect(result.current.messages).toHaveLength(3); expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([ "message", "trace", "message", ]); expect(result.current.messages[0].reasoning).toBe("First reasoning."); expect(result.current.messages[1].traces).toEqual([ "web_search({\"query\":\"OpenClaw\"})", ]); expect(result.current.messages[2].reasoning).toBe("Second reasoning."); }); it("keeps tool-call reasoning before the matching live tool trace", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-tool-reasoning", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-tool-reasoning", { event: "reasoning_delta", chat_id: "chat-tool-reasoning", text: "I should search first.", }); fake.emit("chat-tool-reasoning", { event: "reasoning_end", chat_id: "chat-tool-reasoning", }); fake.emit("chat-tool-reasoning", { event: "message", chat_id: "chat-tool-reasoning", text: "web_search({\"query\":\"hermes\"})", kind: "tool_hint", }); fake.emit("chat-tool-reasoning", { event: "turn_end", chat_id: "chat-tool-reasoning", }); }); expect(result.current.messages).toHaveLength(2); expect(result.current.messages[0]).toMatchObject({ role: "assistant", content: "", reasoning: "I should search first.", reasoningStreaming: false, isStreaming: false, }); expect(result.current.messages[1]).toMatchObject({ role: "tool", kind: "trace", traces: ["web_search({\"query\":\"hermes\"})"], }); }); it("absorbs non-streamed final answers into the preceding reasoning placeholder", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-final-reasoning", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-final-reasoning", { event: "message", chat_id: "chat-final-reasoning", text: "web_search({\"query\":\"hermes\"})", kind: "tool_hint", }); fake.emit("chat-final-reasoning", { event: "reasoning_delta", chat_id: "chat-final-reasoning", text: "Got results; now summarize.", }); fake.emit("chat-final-reasoning", { event: "reasoning_end", chat_id: "chat-final-reasoning", }); fake.emit("chat-final-reasoning", { event: "message", chat_id: "chat-final-reasoning", text: "Hermes is an open-source agent project.", }); fake.emit("chat-final-reasoning", { event: "turn_end", chat_id: "chat-final-reasoning", }); }); expect(result.current.messages).toHaveLength(2); expect(result.current.messages[0]).toMatchObject({ role: "tool", kind: "trace", }); expect(result.current.messages[1]).toMatchObject({ role: "assistant", content: "Hermes is an open-source agent project.", reasoning: "Got results; now summarize.", reasoningStreaming: false, isStreaming: false, }); }); it("prunes reasoning-only placeholders when a turn ends without an answer", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-empty-thinking", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-empty-thinking", { event: "reasoning_delta", chat_id: "chat-empty-thinking", text: "thinking without final text", }); fake.emit("chat-empty-thinking", { event: "reasoning_end", chat_id: "chat-empty-thinking", }); fake.emit("chat-empty-thinking", { event: "turn_end", chat_id: "chat-empty-thinking", }); }); expect(result.current.messages).toHaveLength(0); expect(result.current.isStreaming).toBe(false); }); it("drops stale reasoning-only placeholders before sending the next user turn", () => { const fake = fakeClient(); const initialMessages = [ { id: "stale-thinking", role: "assistant" as const, content: "", reasoning: "leftover thinking", reasoningStreaming: false, createdAt: Date.now(), }, ]; const { result } = renderHook( () => useNanobotStream("chat-stale-thinking", initialMessages), { wrapper: wrap(fake.client) }, ); act(() => { result.current.send("fine"); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].role).toBe("user"); expect(result.current.messages[0].content).toBe("fine"); }); it("attaches assistant media_urls to complete messages", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-m", { event: "message", chat_id: "chat-m", text: "video ready", media_urls: [{ url: "/api/media/sig/payload", name: "demo.mp4" }], }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].media).toEqual([ { kind: "video", url: "/api/media/sig/payload", name: "demo.mp4" }, ]); }); it("suppresses redundant stream confirmation after assistant media", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-img-result", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-img-result", { event: "message", chat_id: "chat-img-result", text: "image ready", media_urls: [{ url: "/api/media/sig/image", name: "generated.png" }], }); fake.emit("chat-img-result", { event: "message", chat_id: "chat-img-result", text: "message()", kind: "tool_hint", }); fake.emit("chat-img-result", { event: "delta", chat_id: "chat-img-result", text: "发送成功", }); fake.emit("chat-img-result", { event: "stream_end", chat_id: "chat-img-result", }); fake.emit("chat-img-result", { event: "turn_end", chat_id: "chat-img-result", }); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].content).toBe("image ready"); expect(result.current.messages[0].media).toHaveLength(1); }); it("passes image generation options to the websocket client", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-img", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { result.current.send( "draw a square icon", undefined, { imageGeneration: { enabled: true, aspect_ratio: "1:1" } }, ); }); expect(fake.client.sendMessage).toHaveBeenCalledWith( "chat-img", "draw a square icon", undefined, { imageGeneration: { enabled: true, aspect_ratio: "1:1" } }, ); }); it("stops the active turn without adding a user slash command bubble", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-stop", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { result.current.send("long task"); }); expect(result.current.messages).toHaveLength(1); expect(result.current.isStreaming).toBe(true); act(() => { result.current.stop(); }); expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop"); expect(result.current.isStreaming).toBe(false); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].content).toBe("long task"); }); it("keeps streaming alive across stream_end and completes on turn_end", async () => { const fake = fakeClient(); const onTurnEnd = vi.fn(); const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-s", { event: "delta", chat_id: "chat-s", text: "Hello", }); }); await flushStreamFrame(); expect(result.current.isStreaming).toBe(true); expect(result.current.messages[0]).toMatchObject({ role: "assistant", content: "Hello", isStreaming: true, }); act(() => { fake.emit("chat-s", { event: "stream_end", chat_id: "chat-s", }); }); expect(result.current.isStreaming).toBe(true); expect(result.current.messages[0].isStreaming).toBe(true); act(() => { fake.emit("chat-s", { event: "message", chat_id: "chat-s", text: "Hello world", }); }); expect(result.current.isStreaming).toBe(true); expect(result.current.messages.at(-1)).toMatchObject({ role: "assistant", content: "Hello world", }); act(() => { fake.emit("chat-s", { event: "turn_end", chat_id: "chat-s", }); }); expect(result.current.isStreaming).toBe(false); expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true); expect(onTurnEnd).toHaveBeenCalledTimes(1); }); it("stamps latency on the last assistant bubble from turn_end", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); act(() => { fake.emit("chat-lat", { event: "delta", chat_id: "chat-lat", text: "Hi", }); }); act(() => { fake.emit("chat-lat", { event: "turn_end", chat_id: "chat-lat", latency_ms: 2400, }); }); const lastAssistant = [...result.current.messages].reverse().find((m) => m.role === "assistant"); expect(lastAssistant?.latencyMs).toBe(2400); }); it("tracks goal_status running and clears on idle", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); expect(result.current.runStartedAt).toBeNull(); act(() => { fake.emit("chat-g", { event: "goal_status", chat_id: "chat-g", status: "running", started_at: 1700, }); }); expect(result.current.runStartedAt).toBe(1700); act(() => { fake.emit("chat-g", { event: "goal_status", chat_id: "chat-g", status: "idle", }); }); expect(result.current.runStartedAt).toBeNull(); }); it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => { const fake = fakeClient(); const { result, rerender } = renderHook( ({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES), { wrapper: wrap(fake.client), initialProps: { chatId: "chat-a" }, }, ); act(() => { fake.emit("chat-a", { event: "goal_status", chat_id: "chat-a", status: "running", started_at: 4242, }); }); expect(result.current.runStartedAt).toBe(4242); rerender({ chatId: "chat-b" }); expect(result.current.runStartedAt).toBeNull(); act(() => { fake.emit("chat-a", { event: "goal_status", chat_id: "chat-a", status: "running", started_at: 9001, }); }); rerender({ chatId: "chat-a" }); expect(result.current.runStartedAt).toBe(9001); }); it("tracks goal_state per chat and restores after switching sessions", () => { const fake = fakeClient(); const { result, rerender } = renderHook( ({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES), { wrapper: wrap(fake.client), initialProps: { chatId: "chat-a" }, }, ); act(() => { fake.emit("chat-a", { event: "goal_state", chat_id: "chat-a", goal_state: { active: true, ui_summary: "Alpha" }, }); }); expect(result.current.goalState).toEqual({ active: true, ui_summary: "Alpha" }); act(() => { fake.emit("chat-b", { event: "goal_state", chat_id: "chat-b", goal_state: { active: true, objective: "Beta task" }, }); }); rerender({ chatId: "chat-b" }); expect(result.current.goalState).toEqual({ active: true, objective: "Beta task" }); rerender({ chatId: "chat-a" }); expect(result.current.goalState).toEqual({ active: true, ui_summary: "Alpha" }); act(() => { fake.emit("chat-a", { event: "goal_state", chat_id: "chat-a", goal_state: { active: false }, }); }); expect(result.current.goalState).toEqual({ active: false }); }); });