nanobot/webui/src/tests/useNanobotStream.test.tsx
Xubin Ren cda1de863e Merge remote-tracking branch 'origin/main' into codex/review-pr-3894
# Conflicts:
#	tests/utils/test_webui_transcript.py
2026-05-19 23:19:33 +08:00

1351 lines
39 KiB
TypeScript

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<string, Set<(ev: InboundEvent) => void>>();
const runStartedAtByChatId = new Map<string, number>();
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
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<typeof fakeClient>["client"]) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ClientProvider
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
token="tok"
>
{children}
</ClientProvider>
);
};
}
async function flushStreamFrame() {
await act(async () => {
await new Promise<void>((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 });
});
});