mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
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:
parent
0033a8a185
commit
278affc25e
@ -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 } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user