diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index d2a229730..10f1e2400 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -33,7 +33,11 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { // A user turn is a hard boundary: reasoning after it belongs to the new // assistant turn, never to an earlier assistant reply. if (candidate.role === "user") break; - if (candidate.role !== "assistant" || candidate.kind === "trace") continue; + // A trace row (e.g. Used tools) is also a phase boundary. Reasoning after + // tools belongs to the next assistant iteration, not the assistant turn + // that produced those tool calls. + if (candidate.kind === "trace") break; + if (candidate.role !== "assistant") continue; const hasAnswer = candidate.content.length > 0; if ( candidate.reasoningStreaming diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 41e6ca3cf..0aa069cfb 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -276,6 +276,45 @@ describe("useNanobotStream", () => { 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), {