Files
nanobot/webui/src/tests/useNanobotStream.test.tsx
T
Xubin RenandCursor 521aaa5ecf fix(webui): split reasoning at tool trace boundaries
Live rendering merged reasoning chunks by scanning backward to the latest
assistant row. That fixed late reasoning, but the scan skipped trace rows,
so reasoning after a tool call crossed the Used tools block and attached to
the previous assistant iteration. Refresh looked correct because persisted
history reconstructs assistant/tool boundaries.

Treat trace rows as hard phase boundaries, just like user messages. A
reasoning_delta after Used tools now starts a fresh assistant placeholder,
so live rendering matches replay: Thinking -> Used tools -> Thinking ->
Used tools / answer.

Add a regression for reasoning_delta -> reasoning_end -> tool_hint ->
reasoning_delta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 07:49:44 +00:00

497 lines
14 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 } 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>>();
return {
client: {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onError: () => () => {},
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) {
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>
);
};
}
describe("useNanobotStream", () => {
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("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
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.",
});
});
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", () => {
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.",
});
});
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", () => {
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.",
});
});
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("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", () => {
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",
});
});
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("refreshes session metadata when the server reports a session update", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-title", {
event: "session_updated",
chat_id: "chat-title",
});
});
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
});