diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 3c433e20..67be9669 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -1087,9 +1087,20 @@ def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[di def _file_edit_key(edit: dict[str, Any]) -> str: call_id = str(edit.get("call_id") or "") tool = str(edit.get("tool") or "") + path = str(edit.get("path") or "") + if call_id and path: + return f"{call_id}|{tool}|{path}" if call_id: return f"{call_id}|{tool}" - return f"{tool}|{edit.get('path') or ''}" + return f"{tool}|{path}" + + +def _file_edit_tool_event_key(edit: dict[str, Any]) -> str: + call_id = str(edit.get("call_id") or "") + tool = str(edit.get("tool") or "") + if call_id: + return f"{call_id}|{tool}" + return _file_edit_key(edit) def _message_has_file_edit_for_tool_event( @@ -1102,7 +1113,10 @@ def _message_has_file_edit_for_tool_event( edits = message.get("fileEdits") if not isinstance(edits, list): return False - return any(isinstance(edit, dict) and _file_edit_key(edit) == key for edit in edits) + return any( + isinstance(edit, dict) and _file_edit_tool_event_key(edit) == key + for edit in edits + ) def _filter_covered_file_edit_tool_events( @@ -1123,7 +1137,7 @@ def _strip_covered_file_edit_tool_hints( edits: list[dict[str, Any]], ) -> dict[str, Any]: incoming_keys = { - _file_edit_key(edit) + _file_edit_tool_event_key(edit) for edit in edits if isinstance(edit, dict) } @@ -1460,6 +1474,11 @@ def replay_transcript_to_ui_messages( edits: list[dict[str, Any]], ) -> int | None: incoming_keys = {_file_edit_key(edit) for edit in edits if isinstance(edit, dict)} + incoming_tool_event_keys = { + _file_edit_tool_event_key(edit) + for edit in edits + if isinstance(edit, dict) + } for i in range(len(messages) - 1, -1, -1): candidate = messages[i] if candidate.get("role") == "user": @@ -1471,7 +1490,16 @@ def replay_transcript_to_ui_messages( existing_edits = candidate.get("fileEdits") if isinstance(existing_edits, list): for existing in existing_edits: - if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys: + if not isinstance(existing, dict): + continue + if ( + _file_edit_key(existing) in incoming_keys + or ( + not existing.get("path") + and existing.get("pending") + and _file_edit_tool_event_key(existing) in incoming_tool_event_keys + ) + ): return i existing_tool_events = candidate.get("toolEvents") if isinstance(existing_tool_events, list): @@ -1479,7 +1507,7 @@ def replay_transcript_to_ui_messages( if not isinstance(event, dict): continue key = _tool_event_file_edit_key(event) - if key and key in incoming_keys: + if key and key in incoming_tool_event_keys: return i return None @@ -1535,12 +1563,24 @@ def replay_transcript_to_ui_messages( if not isinstance(edit, dict): continue key = _file_edit_key(edit) - if key in index_by_key: - pos = index_by_key[key] + pos = index_by_key.get(key) + if pos is None and edit.get("path"): + event_key = _file_edit_tool_event_key(edit) + for existing_pos, existing_edit in enumerate(existing): + if ( + isinstance(existing_edit, dict) + and not existing_edit.get("path") + and existing_edit.get("pending") + and _file_edit_tool_event_key(existing_edit) == event_key + ): + pos = existing_pos + break + if pos is not None: merged = {**existing[pos], **edit} if edit.get("path") and not edit.get("pending"): merged.pop("pending", None) existing[pos] = merged + index_by_key[key] = pos else: index_by_key[key] = len(existing) existing.append(dict(edit)) diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 4f017340..f79cb319 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -863,6 +863,58 @@ def test_replay_file_edit_absorbs_matching_write_tool_event() -> None: ] +def test_replay_keeps_every_file_from_one_apply_patch_call() -> None: + msgs = replay_transcript_to_ui_messages([ + { + "event": "message", + "chat_id": "t-file", + "text": "apply_patch()", + "kind": "tool_hint", + "tool_events": [ + { + "phase": "start", + "call_id": "call-patch", + "name": "apply_patch", + "arguments": {"edits": []}, + }, + ], + }, + { + "event": "file_edit", + "chat_id": "t-file", + "edits": [ + { + "version": 1, + "call_id": "call-patch", + "tool": "apply_patch", + "path": "USER.md", + "phase": "end", + "added": 0, + "deleted": 3, + "approximate": False, + "status": "done", + }, + { + "version": 1, + "call_id": "call-patch", + "tool": "apply_patch", + "path": "MEMORY.md", + "phase": "end", + "added": 0, + "deleted": 4, + "approximate": False, + "status": "done", + }, + ], + }, + ]) + + assert len(msgs) == 1 + assert msgs[0]["traces"] == [] + assert "toolEvents" not in msgs[0] + assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"] + + def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None: msgs = replay_transcript_to_ui_messages([ {"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."}, diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index 47e36ebc..7a848b95 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -1523,6 +1523,7 @@ function fileActivityManySummaryKey(editing: boolean, failed: boolean, deleted: } function fileEditCallKey(edit: UIFileEdit): string { + if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`; if (edit.call_id) return `${edit.call_id}|${edit.tool}`; return `${edit.tool}|${edit.path}`; } diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index bac44ad0..cc54a339 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -272,10 +272,16 @@ function absorbCompleteAssistantMessage( } function fileEditKey(edit: Pick): string { + if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`; if (edit.call_id) return `${edit.call_id}|${edit.tool}`; return `${edit.tool}|${edit.path}`; } +function fileEditToolEventKey(edit: Pick): string { + if (edit.call_id) return `${edit.call_id}|${edit.tool}`; + return fileEditKey(edit); +} + function toolEventFileEditKey(event: ToolProgressEvent): string | null { const fn = (event as { function?: { name?: unknown } }).function; const name = typeof event.name === "string" @@ -292,7 +298,7 @@ function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent const key = toolEventFileEditKey(event); if (!key) return false; return messages.some((message) => - message.fileEdits?.some((edit) => fileEditKey(edit) === key), + message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key), ); } @@ -305,7 +311,7 @@ function filterCoveredFileEditToolEvents( } function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage { - const incomingKeys = new Set(edits.map(fileEditKey)); + const incomingKeys = new Set(edits.map(fileEditToolEventKey)); const events = message.toolEvents ?? []; if (!events.length || incomingKeys.size === 0) return message; @@ -367,7 +373,14 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit const edit = normalizeFileEdit(raw); if (!edit) continue; const key = fileEditKey(edit); - const existingIndex = indexByKey.get(key); + let existingIndex = indexByKey.get(key); + if (existingIndex === undefined && edit.path) { + const eventKey = fileEditToolEventKey(edit); + const pendingIndex = next.findIndex((existing) => + !existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey, + ); + if (pendingIndex >= 0) existingIndex = pendingIndex; + } if (existingIndex === undefined) { indexByKey.set(key, next.length); next.push(edit); @@ -376,6 +389,7 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit const merged = { ...next[existingIndex], ...edit }; if (edit.path && !edit.pending) delete merged.pending; next[existingIndex] = merged; + indexByKey.set(key, existingIndex); } return next; } @@ -386,17 +400,25 @@ function findFileEditTraceIndex( incoming: UIFileEdit[], ): number | null { const incomingKeys = new Set(incoming.map(fileEditKey)); + const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey)); for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; if (candidate.role === "user") break; if (candidate.kind !== "trace") continue; if (segmentId && candidate.activitySegmentId === segmentId) return i; for (const existing of candidate.fileEdits ?? []) { - if (incomingKeys.has(fileEditKey(existing))) return i; + if ( + incomingKeys.has(fileEditKey(existing)) + || ( + !existing.path + && existing.pending + && incomingToolEventKeys.has(fileEditToolEventKey(existing)) + ) + ) return i; } for (const event of candidate.toolEvents ?? []) { const key = toolEventFileEditKey(event); - if (key && incomingKeys.has(key)) return i; + if (key && incomingToolEventKeys.has(key)) return i; } } return null; diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx index 58449a95..776f6dcd 100644 --- a/webui/src/tests/agent-activity-cluster.test.tsx +++ b/webui/src/tests/agent-activity-cluster.test.tsx @@ -513,6 +513,50 @@ describe("AgentActivityCluster", () => { expect(screen.getByText("-3")).toBeInTheDocument(); }); + it("renders every file from one apply_patch call", () => { + render( + , + ); + + const fileRefs = screen.getAllByTestId("activity-file-reference"); + expect(fileRefs).toHaveLength(2); + expect(fileRefs[0]).toHaveTextContent("USER.md"); + expect(fileRefs[1]).toHaveTextContent("MEMORY.md"); + }); + it("renders CLI app runs as dedicated activity rows", () => { const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})'; render( diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index b983fc19..f67e1a55 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -596,6 +596,62 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].toolEvents).toBeUndefined(); }); + it("keeps every file from one apply_patch call", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-apply-patch-many", { + event: "message", + chat_id: "chat-apply-patch-many", + text: "apply_patch()", + kind: "tool_hint", + tool_events: [{ + phase: "start", + call_id: "call-patch", + name: "apply_patch", + arguments: { edits: [] }, + }], + }); + fake.emit("chat-apply-patch-many", { + event: "file_edit", + chat_id: "chat-apply-patch-many", + edits: [ + { + call_id: "call-patch", + tool: "apply_patch", + path: "USER.md", + phase: "end", + added: 0, + deleted: 3, + approximate: false, + status: "done", + }, + { + call_id: "call-patch", + tool: "apply_patch", + path: "MEMORY.md", + phase: "end", + added: 0, + deleted: 4, + approximate: false, + status: "done", + }, + ], + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].traces).toEqual([]); + expect(result.current.messages[0].toolEvents).toBeUndefined(); + expect(result.current.messages[0].fileEdits?.map((edit) => edit.path)).toEqual([ + "USER.md", + "MEMORY.md", + ]); + }); + it("upgrades pending file_edit placeholders when the path arrives", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), {