diff --git a/nanobot/utils/webui_transcript.py b/nanobot/utils/webui_transcript.py index 31d10380b..819804a09 100644 --- a/nanobot/utils/webui_transcript.py +++ b/nanobot/utils/webui_transcript.py @@ -106,9 +106,9 @@ def tool_trace_lines_from_events(events: Any) -> list[str]: if event.get("phase") not in {"start", "end", "error"}: continue call_id = event.get("call_id") - if call_id is not None and call_id in seen: - continue - if call_id is not None: + if isinstance(call_id, str) and call_id: + if call_id in seen: + continue seen.add(call_id) t = _format_tool_call_trace(event) if t: @@ -116,6 +116,22 @@ def tool_trace_lines_from_events(events: Any) -> list[str]: return lines +def _merge_unique_tool_trace_lines( + previous_traces: list[str], + lines: list[str], +) -> tuple[list[str], bool]: + seen_lines = set(previous_traces) + traces = list(previous_traces) + added = False + for line in lines: + if line in seen_lines: + continue + seen_lines.add(line) + traces.append(line) + added = True + return traces, added + + def replay_transcript_to_ui_messages( lines: list[dict[str, Any]], *, @@ -448,13 +464,19 @@ def replay_transcript_to_ui_messages( and (last.get("activitySegmentId") in (None, segment)) ): prev_traces = list(last.get("traces") or [last.get("content")]) - merged_traces = prev_traces + trace_lines - messages[-1] = { + if structured: + merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured) + if not added: + continue + else: + merged_traces = prev_traces + trace_lines + merged = { **last, "traces": merged_traces, - "content": trace_lines[-1], + "content": merged_traces[-1], "activitySegmentId": last.get("activitySegmentId") or segment, } + messages[-1] = merged else: messages.append( { diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index f13380f46..4ed9c132d 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -98,6 +98,53 @@ def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"] +def test_replay_tool_events_dedupes_finish_after_start() -> None: + msgs = replay_transcript_to_ui_messages([ + { + "event": "message", + "chat_id": "t-tool", + "text": 'exec({"cmd":"ls"})', + "kind": "tool_hint", + "tool_events": [ + { + "phase": "start", + "call_id": "call-exec", + "name": "exec", + "arguments": {"cmd": "ls"}, + }, + ], + }, + { + "event": "message", + "chat_id": "t-tool", + "text": "", + "kind": "progress", + "tool_events": [ + { + "phase": "end", + "call_id": "call-exec", + "name": "exec", + "arguments": {"cmd": "ls"}, + "result": "ok", + }, + { + "phase": "end", + "call_id": "call-read", + "name": "read_file", + "arguments": {"path": "notes.md"}, + "result": "done", + }, + ], + }, + ]) + + assert len(msgs) == 1 + assert msgs[0]["traces"] == [ + 'exec({"cmd": "ls"})', + 'read_file({"path": "notes.md"})', + ] + + def test_build_response_schema(monkeypatch, tmp_path) -> None: from nanobot.utils.webui_transcript import build_webui_thread_response diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 2ee113227..6ed236422 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useClient } from "@/providers/ClientProvider"; import { toMediaAttachment } from "@/lib/media"; -import { toolTraceLinesFromEvents } from "@/lib/tool-traces"; +import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces"; import type { StreamError } from "@/lib/nanobot-client"; import type { InboundEvent, @@ -652,10 +652,16 @@ export function useNanobotStream( : last.content ? [last.content] : []; + const mergedLines = structuredLines.length > 0 + ? mergeUniqueToolTraceLines(previousTraces, structuredLines) + : null; + if (mergedLines && !mergedLines.added) return prev; const merged: UIMessage = { ...last, - traces: [...previousTraces, ...lines], - content: lines[lines.length - 1], + traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines], + content: mergedLines + ? mergedLines.traces[mergedLines.traces.length - 1] + : lines[lines.length - 1], activitySegmentId: last.activitySegmentId ?? segmentId, }; return [...prev.slice(0, -1), merged]; diff --git a/webui/src/lib/tool-traces.ts b/webui/src/lib/tool-traces.ts index a3949caae..a9272b6e9 100644 --- a/webui/src/lib/tool-traces.ts +++ b/webui/src/lib/tool-traces.ts @@ -44,20 +44,35 @@ const VALID_PHASES = new Set(["start", "end", "error"]); export function toolTraceLinesFromEvents(events: unknown): string[] { if (!Array.isArray(events)) return []; const seen = new Set(); - return events - .filter((event) => { - if (!event || typeof event !== "object") return false; - const phase = (event as { phase?: unknown }).phase; - if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) { - return false; - } - const callId = (event as { call_id?: unknown }).call_id; - if (callId && typeof callId === "string") { - if (seen.has(callId)) return false; - seen.add(callId); - } - return true; - }) - .map(formatToolCallTrace) - .filter((trace): trace is string => !!trace); + const lines: string[] = []; + for (const event of events) { + if (!event || typeof event !== "object") continue; + const phase = (event as { phase?: unknown }).phase; + if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue; + const callId = (event as { call_id?: unknown }).call_id; + if (callId && typeof callId === "string") { + if (seen.has(callId)) continue; + seen.add(callId); + } + const line = formatToolCallTrace(event); + if (!line) continue; + lines.push(line); + } + return lines; +} + +export function mergeUniqueToolTraceLines( + previousTraces: string[], + lines: string[], +): { traces: string[]; added: boolean } { + const seen = new Set(previousTraces); + const traces = [...previousTraces]; + let added = false; + for (const line of lines) { + if (seen.has(line)) continue; + seen.add(line); + traces.push(line); + added = true; + } + return { traces, added }; } diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 925102dad..b221a6552 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -308,6 +308,56 @@ describe("useNanobotStream", () => { ); }); + it("dedupes finish-phase tool events after their start trace", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-tool-finish", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-tool-finish", { + event: "message", + chat_id: "chat-tool-finish", + text: 'exec({"cmd":"ls"})', + kind: "tool_hint", + tool_events: [{ + phase: "start", + call_id: "call-exec", + name: "exec", + arguments: { cmd: "ls" }, + }], + }); + fake.emit("chat-tool-finish", { + event: "message", + chat_id: "chat-tool-finish", + text: "", + kind: "progress", + tool_events: [ + { + phase: "end", + call_id: "call-exec", + name: "exec", + arguments: { cmd: "ls" }, + result: "ok", + }, + { + phase: "error", + call_id: "call-read", + name: "read_file", + arguments: { path: "notes.md" }, + error: "missing", + }, + ], + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].traces).toEqual([ + 'exec({"cmd":"ls"})', + 'read_file({"path":"notes.md"})', + ]); + }); + it("renders live file_edit events as their own activity trace", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {