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>
This commit is contained in:
Xubin Ren 2026-05-13 07:49:44 +00:00
parent 278affc25e
commit 521aaa5ecf
2 changed files with 44 additions and 1 deletions

View File

@ -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

View File

@ -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), {