From 278affc25e461b6235708798ab9dd5ec946ae064 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 13 May 2026 07:33:52 +0000 Subject: [PATCH] fix(webui): hydrate reasoning and tool traces from history Live reasoning/tool frames were rendering correctly, but refreshing WebUI replayed only role/content/media from `/api/sessions/:key/messages`. Assistant `reasoning_content` / `thinking_blocks` and `tool_calls` were already persisted by the backend and returned by the history endpoint, but useSessionHistory discarded them. Hydrate persisted assistant reasoning into `UIMessage.reasoning` and reconstruct assistant tool calls as `kind: "trace"` rows so the replayed thread keeps the same Thinking bubble and Used tools block as the live stream. Tool result rows remain hidden from the conversation view to avoid replaying raw tool output as chat text. Adds regression coverage for both persisted reasoning and historical tool call trace hydration. Co-authored-by: Cursor --- webui/src/hooks/useSessions.ts | 66 +++++++++++++++++++-- webui/src/lib/api.ts | 2 + webui/src/tests/useSessions.test.tsx | 86 ++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index e05e16a20..d1be437b7 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -14,6 +14,48 @@ import type { ChatSummary, UIMessage } from "@/lib/types"; const EMPTY_MESSAGES: UIMessage[] = []; +type HistoryMessage = Awaited>["messages"][number]; + +function reasoningFromHistory(message: HistoryMessage): string | undefined { + if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) { + return message.reasoning_content; + } + if (!Array.isArray(message.thinking_blocks)) return undefined; + const parts = message.thinking_blocks + .map((block) => { + if (!block || typeof block !== "object") return ""; + const thinking = (block as { thinking?: unknown }).thinking; + return typeof thinking === "string" ? thinking.trim() : ""; + }) + .filter(Boolean); + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + +function formatToolCallTrace(call: unknown): string | null { + if (!call || typeof call !== "object") return null; + const item = call as { + name?: unknown; + function?: { name?: unknown; arguments?: unknown }; + }; + const name = + typeof item.function?.name === "string" + ? item.function.name + : typeof item.name === "string" + ? item.name + : ""; + if (!name) return null; + const args = item.function?.arguments; + if (typeof args === "string" && args.trim()) return `${name}(${args})`; + return `${name}()`; +} + +function toolTracesFromHistory(message: HistoryMessage): string[] { + if (!Array.isArray(message.tool_calls)) return []; + return message.tool_calls + .map(formatToolCallTrace) + .filter((trace): trace is string => !!trace); +} + /** Sidebar state: fetches the full session list and exposes create / delete actions. */ export function useSessions(): { sessions: ChatSummary[]; @@ -143,14 +185,28 @@ export function useSessionHistory(key: string | null): { m.role === "user" && media?.every((item) => item.kind === "image") ? media.map((item) => ({ url: item.url, name: item.name })) : undefined; + const row: UIMessage = { + id: `hist-${idx}`, + role: m.role, + content: m.content, + createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(), + ...(images ? { images } : {}), + ...(media ? { media } : {}), + ...(m.role === "assistant" && reasoningFromHistory(m) + ? { reasoning: reasoningFromHistory(m), reasoningStreaming: false } + : {}), + }; + const traces = m.role === "assistant" ? toolTracesFromHistory(m) : []; + if (traces.length === 0) return [row]; return [ + ...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []), { - id: `hist-${idx}`, - role: m.role, - content: m.content, + id: `hist-${idx}-tools`, + role: "tool" as const, + kind: "trace" as const, + content: traces[traces.length - 1], + traces, createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(), - ...(images ? { images } : {}), - ...(media ? { media } : {}), }, ]; }); diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 23a8c2a67..c27ebd3d6 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -89,6 +89,8 @@ export async function fetchSessionMessages( content: string; timestamp?: string; tool_calls?: unknown; + reasoning_content?: string | null; + thinking_blocks?: unknown; tool_call_id?: string; name?: string; /** Present on ``user`` turns that attached images. Paths have already diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 4805c6567..988b97252 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -170,6 +170,92 @@ describe("useSessions", () => { ]); }); + it("hydrates persisted assistant reasoning into the replayed message", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-reasoning", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "final answer", + timestamp: "2026-04-20T10:00:01Z", + reasoning_content: "hidden but persisted reasoning", + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-reasoning"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].role).toBe("assistant"); + expect(result.current.messages[0].content).toBe("final answer"); + expect(result.current.messages[0].reasoning).toBe("hidden but persisted reasoning"); + expect(result.current.messages[0].reasoningStreaming).toBe(false); + }); + + it("hydrates historical assistant tool calls into a replay trace row", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-tools", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "user", + content: "research this", + timestamp: "2026-04-20T10:00:00Z", + }, + { + role: "assistant", + content: "", + timestamp: "2026-04-20T10:00:01Z", + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "web_search", arguments: "{\"query\":\"agents\"}" }, + }, + { + id: "call-2", + type: "function", + function: { name: "web_fetch", arguments: "{\"url\":\"https://example.com\"}" }, + }, + ], + }, + { + role: "tool", + content: "tool output that should not render directly", + timestamp: "2026-04-20T10:00:02Z", + tool_call_id: "call-1", + }, + { + role: "assistant", + content: "summary", + timestamp: "2026-04-20T10:00:03Z", + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-tools"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.messages.map((m) => m.role)).toEqual(["user", "tool", "assistant"]); + const trace = result.current.messages[1]; + expect(trace.kind).toBe("trace"); + expect(trace.traces).toEqual([ + "web_search({\"query\":\"agents\"})", + "web_fetch({\"url\":\"https://example.com\"})", + ]); + expect(result.current.messages[2].content).toBe("summary"); + }); + it("flags history with trailing assistant tool calls as still pending", async () => { vi.mocked(api.fetchSessionMessages).mockResolvedValue({ key: "websocket:chat-pending",