fix(anthropic): fix duplicate ToolCallStartEvent in streaming tool calls (#3051)

When processing `input_json_delta` events, the Anthropic client was
passing the tool name from the previous `tool_use` event. This caused
ag-ui's `_handle_function_call_content` to emit a `ToolCallStartEvent`
for every streaming chunk (since it triggers on `if content.name:`).

This fix changes the behavior to pass an empty string for `name` in
`input_json_delta` events, matching OpenAI's behavior where streaming
argument chunks have `name=""`. The initial `tool_use` event still
provides the tool name, so only one `ToolCallStartEvent` is emitted.

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
This commit is contained in:
Ao Chen
2026-01-12 08:14:49 +00:00
committed by GitHub
co-authored by Evan Mattson
parent 551c2c3abe
commit 6e3bc219e0
3 changed files with 90 additions and 2 deletions
@@ -832,11 +832,16 @@ class AnthropicClient(BaseChatClient):
)
)
case "input_json_delta":
call_id, name = self._last_call_id_name if self._last_call_id_name else ("", "")
# For streaming argument deltas, only pass call_id and arguments.
# Pass empty string for name - it causes ag-ui to emit duplicate ToolCallStartEvents
# since it triggers on `if content.name:`. The initial tool_use event already
# provides the name, so deltas should only carry incremental arguments.
# This matches OpenAI's behavior where streaming chunks have name="".
call_id, _ = self._last_call_id_name if self._last_call_id_name else ("", "")
contents.append(
FunctionCallContent(
call_id=call_id,
name=name,
name="",
arguments=content_block.partial_json,
raw_representation=content_block,
)
@@ -595,6 +595,53 @@ def test_parse_contents_from_anthropic_tool_use(mock_anthropic_client: MagicMock
assert result[0].name == "get_weather"
def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_anthropic_client: MagicMock) -> None:
"""Test that input_json_delta events have empty name to prevent duplicate ToolCallStartEvents.
When streaming tool calls, the initial tool_use event provides the name,
and subsequent input_json_delta events should have name="" to prevent
ag-ui from emitting duplicate ToolCallStartEvents.
"""
chat_client = create_test_anthropic_client(mock_anthropic_client)
# First, simulate a tool_use event that sets _last_call_id_name
tool_use_content = MagicMock()
tool_use_content.type = "tool_use"
tool_use_content.id = "call_123"
tool_use_content.name = "get_weather"
tool_use_content.input = {}
result = chat_client._parse_contents_from_anthropic([tool_use_content])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].call_id == "call_123"
assert result[0].name == "get_weather" # Initial event has name
# Now simulate input_json_delta events (argument streaming)
delta_content_1 = MagicMock()
delta_content_1.type = "input_json_delta"
delta_content_1.partial_json = '{"location":'
result = chat_client._parse_contents_from_anthropic([delta_content_1])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].call_id == "call_123"
assert result[0].name == "" # Delta events should have empty name
assert result[0].arguments == '{"location":'
# Another delta
delta_content_2 = MagicMock()
delta_content_2.type = "input_json_delta"
delta_content_2.partial_json = '"San Francisco"}'
result = chat_client._parse_contents_from_anthropic([delta_content_2])
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].call_id == "call_123"
assert result[0].name == "" # Still empty name for subsequent deltas
assert result[0].arguments == '"San Francisco"}'
# Stream Processing Tests