diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 5f64636f2ffb..6885a762a3d9 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -638,12 +638,19 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices: args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False, sort_keys=True) except (TypeError, ValueError): args_str = "{}" - thought_signature = part.get("thoughtSignature") if isinstance(part.get("thoughtSignature"), str) else "" + # Dedup slots by (part_index, name) only — NOT by thought_signature. + # Gemini may send the signature on only some chunks of a tool call + # (e.g., on a later args chunk, or only on the first chunk). Including + # it in the key splits a single tool call into multiple slots when + # the signature changes between chunks, leaving the slot that did + # not receive it without a signature on replay → Gemini 3 thinking + # models then 400 with "Function call is missing a thought_signature" + # because the model that originally emitted the call expects the + # signature back. call_key = json.dumps( { "part_index": part_index, "name": name, - "thought_signature": thought_signature, }, sort_keys=True, ) diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index 4b066b4f454c..f68235b19156 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -4,6 +4,7 @@ import json from types import SimpleNamespace +from typing import Any, Dict import pytest @@ -326,3 +327,58 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts(): assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0 assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1 assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id + + +def test_stream_event_translation_does_not_split_slot_when_signature_arrives_late(): + """Regression: Gemini 3 thinking models may send `thoughtSignature` on a + later chunk of the same tool call (or only on one chunk). The dedup key + must be (part_index, name) only — never including the signature — so a + single tool call stays in a single slot regardless of which chunks carry + the signature. Otherwise the slot that lacks the signature gets replayed + without one and Gemini rejects the request with HTTP 400. + """ + from agent.gemini_native_adapter import translate_stream_event + + tool_call_indices: Dict[str, Dict[str, Any]] = {} + + # Chunk 1 — no signature yet. + event_a = { + "candidates": [ + { + "content": { + "parts": [ + {"functionCall": {"name": "terminal", "args": {}}} + ] + }, + } + ] + } + # Chunk 2 — same tool call, more args, signature now present. + event_b = { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": {"name": "terminal", "args": {"cmd": "ls"}}, + "thoughtSignature": "sig-late", + } + ] + }, + "finishReason": "STOP", + } + ] + } + + chunks_a = translate_stream_event(event_a, model="gemini-3.1-flash", tool_call_indices=tool_call_indices) + chunks_b = translate_stream_event(event_b, model="gemini-3.1-flash", tool_call_indices=tool_call_indices) + + a_tool = [c for c in chunks_a if c.choices[0].delta.tool_calls][0].choices[0].delta.tool_calls[0] + b_tool = [c for c in chunks_b if c.choices[0].delta.tool_calls][0].choices[0].delta.tool_calls[0] + + # Both chunks must address the SAME slot (same index, same id) — otherwise + # the consumer accumulates two tool calls for what is logically one. + assert a_tool.index == b_tool.index == 0 + assert a_tool.id == b_tool.id + # The chunk that carried the signature surfaces it via extra_content. + assert b_tool.extra_content == {"google": {"thought_signature": "sig-late"}}