Skip to content

Commit 6f2f9fa

Browse files
LEDazzio01eavanvalkenburg
authored andcommitted
fix: move duplicate check before event emission + add test
Address Copilot review feedback: 1. Move duplicate full-arguments replay detection BEFORE emitting ToolCallArgsEvent, for consistency with _emit_text() which returns early without emitting any events on replay detection. 2. Add test_emit_tool_call_skips_duplicate_full_arguments_replay() to verify the duplicate detection behavior for tool call arguments, matching the existing test pattern for text content.
1 parent d62efc0 commit 6f2f9fa

2 files changed

Lines changed: 47 additions & 5 deletions

File tree

python/packages/ag-ui/agent_framework_ag_ui/_run_common.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,22 +195,27 @@ def _emit_tool_call(
195195
delta = (
196196
content.arguments if isinstance(content.arguments, str) else json.dumps(make_json_safe(content.arguments))
197197
)
198-
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=delta))
199198

200199
if tool_call_id in flow.tool_calls_by_id:
201200
accumulated = flow.tool_calls_by_id[tool_call_id]["function"]["arguments"]
202201
# Guard against full-argument replay: if the accumulated arguments
203202
# already equal the incoming delta, this is a non-delta replay of
204203
# the complete arguments string (some providers send the full
205-
# arguments again after streaming deltas). Skip the append to
206-
# prevent doubling in MESSAGES_SNAPSHOT. (Fixes #4194)
204+
# arguments again after streaming deltas). Skip the event emission
205+
# and accumulation to prevent doubling in MESSAGES_SNAPSHOT.
206+
# This mirrors the early-return behaviour of _emit_text().
207+
# (Fixes #4194)
207208
if accumulated and delta == accumulated:
208209
logger.debug(
209210
"Skipping duplicate full-arguments replay for tool_call_id=%s",
210211
tool_call_id,
211212
)
212-
else:
213-
flow.tool_calls_by_id[tool_call_id]["function"]["arguments"] += delta
213+
return events
214+
215+
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=delta))
216+
217+
if tool_call_id in flow.tool_calls_by_id:
218+
flow.tool_calls_by_id[tool_call_id]["function"]["arguments"] += delta
214219

215220
if predictive_handler and flow.tool_call_name:
216221
delta_events = predictive_handler.emit_streaming_deltas(flow.tool_call_name, delta)

python/packages/ag-ui/tests/ag_ui/test_run.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ag_ui.core import (
77
TextMessageEndEvent,
88
TextMessageStartEvent,
9+
ToolCallArgsEvent,
910
)
1011
from agent_framework import AgentResponseUpdate, Content, Message, ResponseStream
1112
from agent_framework.exceptions import AgentInvalidResponseException
@@ -416,6 +417,42 @@ def test_emit_tool_call_generates_id():
416417
assert flow.tool_call_id is not None # ID should be generated
417418

418419

420+
def test_emit_tool_call_skips_duplicate_full_arguments_replay():
421+
"""Test _emit_tool_call skips replayed full-arguments on an existing tool call.
422+
423+
This is a regression test for issue #4194 where some streaming providers
424+
send the full arguments string again after streaming deltas, causing the
425+
arguments to be doubled in MESSAGES_SNAPSHOT events.
426+
427+
Mirrors test_emit_text_skips_duplicate_full_message_delta for consistency.
428+
"""
429+
flow = FlowState()
430+
full_args = '{"city": "Seattle"}'
431+
432+
# Step 1: Initial tool call with name + arguments (normal start)
433+
content_start = Content.from_function_call(
434+
call_id="call_dup",
435+
name="get_weather",
436+
arguments=full_args,
437+
)
438+
events_start = _emit_tool_call(content_start, flow)
439+
440+
# Should emit ToolCallStartEvent + ToolCallArgsEvent
441+
assert any(isinstance(e, ToolCallArgsEvent) for e in events_start)
442+
assert flow.tool_calls_by_id["call_dup"]["function"]["arguments"] == full_args
443+
444+
# Step 2: Provider replays the full arguments (duplicate)
445+
content_replay = Content(type="function_call", call_id="call_dup", arguments=full_args)
446+
events_replay = _emit_tool_call(content_replay, flow)
447+
448+
# Should NOT emit any ToolCallArgsEvent (early return on replay)
449+
args_events = [e for e in events_replay if isinstance(e, ToolCallArgsEvent)]
450+
assert args_events == [], "Duplicate full-arguments replay should not emit ToolCallArgsEvent"
451+
452+
# Accumulated arguments should remain unchanged
453+
assert flow.tool_calls_by_id["call_dup"]["function"]["arguments"] == full_args
454+
455+
419456
def test_emit_tool_result_closes_open_message():
420457
"""Test _emit_tool_result emits TextMessageEndEvent for open text message.
421458

0 commit comments

Comments
 (0)