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 <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-13 07:33:52 +00:00
parent 0033a8a185
commit 278affc25e
3 changed files with 149 additions and 5 deletions

View File

@ -14,6 +14,48 @@ import type { ChatSummary, UIMessage } from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = [];
type HistoryMessage = Awaited<ReturnType<typeof fetchSessionMessages>>["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 } : {}),
},
];
});

View File

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

View File

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