From 695f511f415bab196f4d95adbcc3a856eca4b790 Mon Sep 17 00:00:00 2001 From: "yu.xia" Date: Wed, 28 Oct 2026 00:58:01 +0800 Subject: [PATCH 1/2] fix(ag-ui): start each replayed declaration-only tool call once Guard TOOL_CALL_START with the started-call set instead of the last-id cursor: with two declaration-only calls in one response, the cursor sits on the second when the function loop replays the pauses, so both calls re-emitted START and duplicated in the thread snapshot. Fixes #9638 --- .../agent_framework_ag_ui/_run_common.py | 5 ++++- .../ag-ui/tests/ag_ui/test_run_common.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 8afb2e8002e..b07ed4a4de5 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -732,7 +732,10 @@ def _emit_tool_call( tool_call_id = content.call_id or flow.tool_call_id or generate_event_id() - if content.name and tool_call_id != flow.tool_call_id: + # Why a started-set, not the last-id cursor: declaration-only calls replay after the + # function loop pauses; with two calls the cursor sits on the second, so the replay of + # the first (and then the second) re-emitted TOOL_CALL_START (#9638). + if content.name and tool_call_id not in flow.tool_calls_by_id: flow.tool_call_id = tool_call_id flow.tool_call_name = content.name if predictive_handler: diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index 69822014420..de7c83206a5 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -27,6 +27,7 @@ from agent_framework_ag_ui._run_common import ( FlowState, _build_run_finished_event, + _emit_content, _close_reasoning_block, _emit_mcp_tool_result, _emit_text_reasoning, @@ -1108,3 +1109,24 @@ def test_new_turn_gets_new_reasoning_id(self): assert id1 is not None assert id2 is not None assert id1 != id2, "New turn should get a new reasoning message_id" + + +def test_replayed_declaration_only_calls_start_each_tool_call_once() -> None: + """Two declaration-only calls replayed after streaming must not re-emit START (#9638).""" + flow = FlowState() + streamed = [ + Content.from_function_call(call_id="call_a", name="get_weather", arguments='{"city": "NYC"}'), + Content.from_function_call(call_id="call_b", name="get_time", arguments='{"zone": "UTC"}'), + ] + replayed = [ + Content.from_function_call(call_id="call_a", name="get_weather"), + Content.from_function_call(call_id="call_b", name="get_time"), + ] + events = [] + for content in [*streamed, *replayed]: + events.extend(_emit_content(content, flow)) + + starts = [e for e in events if e.type == EventType.TOOL_CALL_START] + assert sorted(e.tool_call_id for e in starts) == ["call_a", "call_b"] + ids = [entry["id"] for entry in flow.pending_tool_calls] + assert sorted(ids) == ["call_a", "call_b"] From 803db775b58b4f71b2ad32480b98e1ce550d41e6 Mon Sep 17 00:00:00 2001 From: "yu.xia" Date: Wed, 28 Oct 2026 01:26:00 +0800 Subject: [PATCH 2/2] fix(ag-ui): let a reused provider call id start a new occurrence Guard TOOL_CALL_START on whether the call is still open instead of started-id membership: a completed id reused by a later distinct call STARTs again with a fresh snapshot entry and becomes endable again, while declaration-only replay of an open call stays deduplicated. --- .../agent_framework_ag_ui/_run_common.py | 13 +++++++---- .../ag-ui/tests/ag_ui/test_run_common.py | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index b07ed4a4de5..c349945879e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -732,10 +732,13 @@ def _emit_tool_call( tool_call_id = content.call_id or flow.tool_call_id or generate_event_id() - # Why a started-set, not the last-id cursor: declaration-only calls replay after the - # function loop pauses; with two calls the cursor sits on the second, so the replay of - # the first (and then the second) re-emitted TOOL_CALL_START (#9638). - if content.name and tool_call_id not in flow.tool_calls_by_id: + # Why an open-call check, not the last-id cursor: declaration-only calls replay after + # the function loop pauses, and with two calls the cursor sits on the second, so the + # replay re-emitted TOOL_CALL_START for both (#9638). An id that already ENDED is not a + # replay: provider call_ids may be legally reused by a later distinct call (spec 004), + # which must START again with a fresh snapshot entry. + call_is_open = tool_call_id in flow.tool_calls_by_id and tool_call_id not in flow.tool_calls_ended + if content.name and not call_is_open: flow.tool_call_id = tool_call_id flow.tool_call_name = content.name if predictive_handler: @@ -757,6 +760,8 @@ def _emit_tool_call( } flow.pending_tool_calls.append(tool_entry) flow.tool_calls_by_id[tool_call_id] = tool_entry + # A reused provider id starting a fresh occurrence must be endable again. + flow.tool_calls_ended.discard(tool_call_id) elif tool_call_id: flow.tool_call_id = tool_call_id diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index de7c83206a5..43c4f8cca05 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -1130,3 +1130,25 @@ def test_replayed_declaration_only_calls_start_each_tool_call_once() -> None: assert sorted(e.tool_call_id for e in starts) == ["call_a", "call_b"] ids = [entry["id"] for entry in flow.pending_tool_calls] assert sorted(ids) == ["call_a", "call_b"] + + +def test_reused_provider_call_id_starts_a_new_occurrence() -> None: + """A later distinct call reusing a completed provider call_id must START again (#9638).""" + flow = FlowState() + streamed = [ + Content.from_function_call(call_id="call_x", name="get_weather", arguments='{"city": "NYC"}'), + ] + for content in streamed: + _emit_content(content, flow) + _emit_content(Content.from_function_result(call_id="call_x", result="72F"), flow) + second = [ + Content.from_function_call(call_id="call_x", name="get_weather", arguments='{"city": "LA"}'), + ] + events = [] + for content in second: + events.extend(_emit_content(content, flow)) + + starts = [e for e in events if e.type == EventType.TOOL_CALL_START] + assert [e.tool_call_id for e in starts] == ["call_x"] + assert flow.tool_calls_by_id["call_x"]["function"]["arguments"] == '{"city": "LA"}' + assert "call_x" not in flow.tool_calls_ended