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