From 070aed8ade4b8903900ef08454dc6407ce21e011 Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:12:40 +0800 Subject: [PATCH] fix(streaming): skip non-file-edit tools in apply_final_call_ids to prevent id corruption apply_final_call_ids iterated over all final tool calls, including non-file-edit tools like read_file. The greedy path-match in matches_final_tool_call could overwrite a correct unique id with a stale one from a different streaming state, producing duplicate tool_use ids that poison the persisted session. Guard the loop with is_file_edit_tool() so only tracked file-edit tools (write_file, edit_file, apply_patch) are subject to canonical id remapping. Non-file-edit tools keep their authoritative id from get_final_message(). Fixes #4595 --- nanobot/utils/file_edit_events.py | 3 +++ tests/utils/test_file_edit_events.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py index c1885128..f5be540a 100644 --- a/nanobot/utils/file_edit_events.py +++ b/nanobot/utils/file_edit_events.py @@ -529,6 +529,9 @@ class StreamingFileEditTracker: """Keep final start/end events keyed to any earlier streamed placeholder.""" used_canonicals: set[str] = set() for tool_call in final_tool_calls: + name = getattr(tool_call, "name", None) + if not is_file_edit_tool(name): + continue canonical = self.canonical_call_id_for(tool_call) if canonical and canonical not in used_canonicals: try: diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py index 93240cf9..39759aca 100644 --- a/tests/utils/test_file_edit_events.py +++ b/tests/utils/test_file_edit_events.py @@ -412,6 +412,41 @@ def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Pat asyncio.run(run()) +def test_streaming_tracker_does_not_remap_non_file_edit_final_tool(tmp_path: Path) -> None: + events: list[dict] = [] + + async def emit(batch: list[dict]) -> None: + events.extend(batch) + + async def run() -> None: + tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit) + await tracker.update({ + "index": 0, + "name": "read_file", + "arguments_delta": '{"path":"matched.md"}', + }) + await tracker.update({ + "index": 1, + "name": "write_file", + "arguments_delta": '{"path":"matched.md","content":"one\\n', + }) + read_final = SimpleNamespace( + id="read-unique", + name="read_file", + arguments={"path": "matched.md"}, + ) + write_final = SimpleNamespace( + id="write-final", + name="write_file", + arguments={"path": "matched.md", "content": "one\n"}, + ) + tracker.apply_final_call_ids([read_final, write_final]) + assert read_final.id == "read-unique" + assert write_final.id == "idx:1" + + asyncio.run(run()) + + def test_streaming_tracker_does_not_restore_duplicate_canonical_ids(tmp_path: Path) -> None: events: list[dict] = []