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
This commit is contained in:
@@ -529,6 +529,9 @@ class StreamingFileEditTracker:
|
|||||||
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
||||||
used_canonicals: set[str] = set()
|
used_canonicals: set[str] = set()
|
||||||
for tool_call in final_tool_calls:
|
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)
|
canonical = self.canonical_call_id_for(tool_call)
|
||||||
if canonical and canonical not in used_canonicals:
|
if canonical and canonical not in used_canonicals:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -412,6 +412,41 @@ def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Pat
|
|||||||
asyncio.run(run())
|
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:
|
def test_streaming_tracker_does_not_restore_duplicate_canonical_ids(tmp_path: Path) -> None:
|
||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user