From 521aaa5ecfb1a65f1f7d203ad1913575734028d1 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 13 May 2026 07:49:44 +0000 Subject: [PATCH] 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 --- webui/src/hooks/useNanobotStream.ts | 6 +++- webui/src/tests/useNanobotStream.test.tsx | 39 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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), {