From 3e07080ed594812a2846e943e2ced63b14898c8a Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 13 Jul 2026 13:31:47 -0700 Subject: [PATCH 1/4] Python: preserve Gemini 3 thought_signature across function-call replays Gemini 3 requires the opaque thought_signature attached to each functionCall part to be echoed back on every replay of that call, or the request is rejected with 400 INVALID_ARGUMENT. The signature previously survived only via raw_representation, so any layer that reconstructs a FunctionCallContent (e.g. harness tool approval) dropped it and broke the next step of the tool loop. Capture the signature into additional_properties on parse and replay it when building the Gemini Part, independent of raw_representation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2 --- .../agent_framework_gemini/_chat_client.py | 15 +++- .../gemini/tests/test_gemini_client.py | 75 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index bfb4f67005c..cf51ba43590 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -691,10 +691,16 @@ def _convert_message_contents( name=content.name or "", args=content.parse_arguments() or {}, ) + # Echo back Gemini 3's opaque thought_signature (required on every replay of a + # functionCall part) even when the original raw_representation Part was dropped. + thought_signature = content.additional_properties.get("thought_signature") if isinstance(raw_part, types.Part) and raw_part.function_call is not None: - parts.append(raw_part.model_copy(update={"function_call": function_call}, deep=True)) + replayed_part = raw_part.model_copy(update={"function_call": function_call}, deep=True) + if replayed_part.thought_signature is None and thought_signature is not None: + replayed_part.thought_signature = thought_signature + parts.append(replayed_part) else: - parts.append(types.Part(function_call=function_call)) + parts.append(types.Part(function_call=function_call, thought_signature=thought_signature)) case "function_result": raw_part = content.raw_representation if isinstance(raw_part, types.Part) and raw_part.tool_response is not None: @@ -1122,11 +1128,16 @@ def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]: else: call_id = self._generate_tool_call_id() logger.debug("function_call missing id; generated fallback call_id=%r", call_id) + # Capture Gemini 3's thought_signature + additional_properties = ( + {"thought_signature": part.thought_signature} if part.thought_signature is not None else None + ) contents.append( Content.from_function_call( call_id=call_id, name=function_call.name or "", arguments=function_call.args or {}, + additional_properties=additional_properties, raw_representation=part, ) ) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index d3ad53c5abb..90e7c347129 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -46,6 +46,7 @@ def _make_part( text: str | None = None, thought: bool = False, function_call: tuple[str | None, str, dict[str, Any]] | None = None, + thought_signature: bytes | None = None, tool_call: tuple[str | None, types.ToolType, dict[str, Any]] | None = None, tool_response: tuple[str | None, types.ToolType, dict[str, Any]] | None = None, executable_code: str | None = None, @@ -57,6 +58,7 @@ def _make_part( text: Text content of the part. thought: Whether this is a thinking/reasoning part. function_call: Tuple of (id, name, args) if this is a function call part. + thought_signature: Opaque Gemini 3 thought signature attached to the part, if any. tool_call: Tuple of (id, tool_type, args) if this is a server-side tool call part. tool_response: Tuple of (id, tool_type, response) if this is a server-side tool response part. executable_code: Source code string for a code execution part. @@ -65,6 +67,7 @@ def _make_part( part = MagicMock() part.text = text part.thought = thought + part.thought_signature = thought_signature part.function_response = None part.tool_call = None part.tool_response = None @@ -741,6 +744,78 @@ def test_function_call_part_preserves_thought_signature_from_raw_part() -> None: assert parts[0].function_call.args == {"location": "Paris"} +def test_function_call_part_captures_thought_signature_on_parse() -> None: + """Parsing a functionCall part stores its thought_signature in additional_properties.""" + client, _ = _make_gemini_client() + + contents = client._parse_parts([ + _make_part(function_call=("call-1", "get_weather", {"location": "Paris"}), thought_signature=b"sig-123") + ]) + + assert len(contents) == 1 + assert contents[0].type == "function_call" + assert contents[0].additional_properties["thought_signature"] == b"sig-123" + + +def test_function_call_part_without_thought_signature_stores_nothing() -> None: + """A functionCall part without a signature leaves additional_properties empty.""" + client, _ = _make_gemini_client() + + contents = client._parse_parts([_make_part(function_call=("call-1", "get_weather", {"location": "Paris"}))]) + + assert len(contents) == 1 + assert "thought_signature" not in contents[0].additional_properties + + +def test_reconstructed_function_call_replays_thought_signature_from_additional_properties() -> None: + """A function call rebuilt without its raw Part still replays the signature (harness approval path).""" + client, _ = _make_gemini_client() + content = Content.from_function_call( + call_id="call-1", + name="get_weather", + arguments={"location": "Paris"}, + additional_properties={"thought_signature": b"sig-123"}, + ) + + parts = client._convert_message_contents([content], {}) + + assert len(parts) == 1 + assert parts[0].thought_signature == b"sig-123" + assert parts[0].function_call is not None + assert parts[0].function_call.id == "call-1" + + +def test_function_call_without_thought_signature_replays_without_one() -> None: + """A function call lacking any signature produces a part with no thought_signature.""" + client, _ = _make_gemini_client() + content = Content.from_function_call(call_id="call-1", name="get_weather", arguments={"location": "Paris"}) + + parts = client._convert_message_contents([content], {}) + + assert len(parts) == 1 + assert parts[0].thought_signature is None + + +def test_reconstructed_function_call_signature_survives_round_trip() -> None: + """Parse captures the signature and a rebuilt call (raw Part dropped) replays it end to end.""" + client, _ = _make_gemini_client() + + parsed = client._parse_parts([ + _make_part(function_call=("call-1", "get_weather", {"location": "Paris"}), thought_signature=b"sig-123") + ]) + # Simulate a layer that reconstructs the call from call_id/name/arguments, dropping raw_representation. + rebuilt = Content.from_function_call( + call_id=parsed[0].call_id, + name=parsed[0].name, + arguments=parsed[0].arguments, + additional_properties=dict(parsed[0].additional_properties), + ) + + parts = client._convert_message_contents([rebuilt], {}) + + assert parts[0].thought_signature == b"sig-123" + + def test_server_side_tool_call_part_is_informational_only() -> None: """Server-side Gemini tool calls are transcript content, not local function invocation requests.""" client, _ = _make_gemini_client() From e6fe6ec34698118ffd50225c0af66cf919041655 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 13 Jul 2026 14:04:33 -0700 Subject: [PATCH 2/4] Store Gemini thought_signature as base64 for JSON-safe persistence Content.additional_properties is serialized via json.dumps(message.to_dict()) by history providers (e.g. RedisHistoryProvider), which fails on raw bytes. Store the thought_signature as a base64 string on parse and decode it back to bytes when building the Gemini Part. Also narrow call_id/name in the round-trip test to satisfy the type checkers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2 --- .../agent_framework_gemini/_chat_client.py | 13 ++++--- .../gemini/tests/test_gemini_client.py | 34 +++++++++++++++---- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index cf51ba43590..4573b8c3fec 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json import logging import sys @@ -692,8 +693,9 @@ def _convert_message_contents( args=content.parse_arguments() or {}, ) # Echo back Gemini 3's opaque thought_signature (required on every replay of a - # functionCall part) even when the original raw_representation Part was dropped. - thought_signature = content.additional_properties.get("thought_signature") + # functionCall part), decoding it from the base64 form stored in additional_properties. + encoded_signature = content.additional_properties.get("thought_signature") + thought_signature = base64.b64decode(encoded_signature) if encoded_signature else None if isinstance(raw_part, types.Part) and raw_part.function_call is not None: replayed_part = raw_part.model_copy(update={"function_call": function_call}, deep=True) if replayed_part.thought_signature is None and thought_signature is not None: @@ -1128,9 +1130,12 @@ def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]: else: call_id = self._generate_tool_call_id() logger.debug("function_call missing id; generated fallback call_id=%r", call_id) - # Capture Gemini 3's thought_signature + # Capture Gemini 3's thought_signature (on the Part, not the FunctionCall) as a base64 + # string so it stays JSON-serializable when messages are persisted and can be replayed. additional_properties = ( - {"thought_signature": part.thought_signature} if part.thought_signature is not None else None + {"thought_signature": base64.b64encode(part.thought_signature).decode("utf-8")} + if part.thought_signature is not None + else None ) contents.append( Content.from_function_call( diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 90e7c347129..783128fcf9a 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -2,7 +2,9 @@ from __future__ import annotations +import base64 import datetime +import json import logging import os from typing import Any, cast @@ -745,7 +747,7 @@ def test_function_call_part_preserves_thought_signature_from_raw_part() -> None: def test_function_call_part_captures_thought_signature_on_parse() -> None: - """Parsing a functionCall part stores its thought_signature in additional_properties.""" + """Parsing a functionCall part stores its thought_signature as a base64 string.""" client, _ = _make_gemini_client() contents = client._parse_parts([ @@ -754,7 +756,22 @@ def test_function_call_part_captures_thought_signature_on_parse() -> None: assert len(contents) == 1 assert contents[0].type == "function_call" - assert contents[0].additional_properties["thought_signature"] == b"sig-123" + assert contents[0].additional_properties["thought_signature"] == base64.b64encode(b"sig-123").decode("utf-8") + + +def test_captured_thought_signature_is_json_serializable() -> None: + """The captured signature must survive json.dumps(message.to_dict()) used by history providers.""" + client, _ = _make_gemini_client() + contents = client._parse_parts([ + _make_part(function_call=("call-1", "get_weather", {"location": "Paris"}), thought_signature=b"sig-123") + ]) + message = Message(role="assistant", contents=contents) + + serialized = json.dumps(message.to_dict()) + + restored = Message.from_dict(json.loads(serialized)) + parts = client._convert_message_contents(restored.contents, {}) + assert parts[0].thought_signature == b"sig-123" def test_function_call_part_without_thought_signature_stores_nothing() -> None: @@ -774,7 +791,7 @@ def test_reconstructed_function_call_replays_thought_signature_from_additional_p call_id="call-1", name="get_weather", arguments={"location": "Paris"}, - additional_properties={"thought_signature": b"sig-123"}, + additional_properties={"thought_signature": base64.b64encode(b"sig-123").decode("utf-8")}, ) parts = client._convert_message_contents([content], {}) @@ -803,12 +820,15 @@ def test_reconstructed_function_call_signature_survives_round_trip() -> None: parsed = client._parse_parts([ _make_part(function_call=("call-1", "get_weather", {"location": "Paris"}), thought_signature=b"sig-123") ]) + fc = parsed[0] + assert fc.call_id is not None + assert fc.name is not None # Simulate a layer that reconstructs the call from call_id/name/arguments, dropping raw_representation. rebuilt = Content.from_function_call( - call_id=parsed[0].call_id, - name=parsed[0].name, - arguments=parsed[0].arguments, - additional_properties=dict(parsed[0].additional_properties), + call_id=fc.call_id, + name=fc.name, + arguments=fc.arguments, + additional_properties=dict(fc.additional_properties), ) parts = client._convert_message_contents([rebuilt], {}) From 351e6e041dc4318c46c4a9061c586e16e4e14546 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 13 Jul 2026 14:17:15 -0700 Subject: [PATCH 3/4] Harden Gemini thought_signature decode against corrupted history Guard the untyped additional_properties value with an isinstance(str) check and decode with validate=True, degrading gracefully (warn + drop the signature) on malformed data instead of raising binascii.Error mid tool loop. Matches the defensive base64 handling already used for data URIs in this file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2 --- .../agent_framework_gemini/_chat_client.py | 9 ++++++- .../gemini/tests/test_gemini_client.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 4573b8c3fec..1e276f10389 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -694,8 +694,15 @@ def _convert_message_contents( ) # Echo back Gemini 3's opaque thought_signature (required on every replay of a # functionCall part), decoding it from the base64 form stored in additional_properties. + # Guard the untyped value and degrade gracefully on corrupted history rather than + # crashing the tool loop. encoded_signature = content.additional_properties.get("thought_signature") - thought_signature = base64.b64decode(encoded_signature) if encoded_signature else None + thought_signature: bytes | None = None + if isinstance(encoded_signature, str) and encoded_signature: + try: + thought_signature = base64.b64decode(encoded_signature, validate=True) + except ValueError: # binascii.Error subclasses ValueError + logger.warning("Ignoring malformed thought_signature on function_call content") if isinstance(raw_part, types.Part) and raw_part.function_call is not None: replayed_part = raw_part.model_copy(update={"function_call": function_call}, deep=True) if replayed_part.thought_signature is None and thought_signature is not None: diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 783128fcf9a..5bc427e916c 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -813,6 +813,31 @@ def test_function_call_without_thought_signature_replays_without_one() -> None: assert parts[0].thought_signature is None +def test_malformed_thought_signature_is_ignored_gracefully(caplog: pytest.LogCaptureFixture) -> None: + """A corrupted or non-string signature degrades to no signature instead of crashing the tool loop.""" + client, _ = _make_gemini_client() + corrupted = Content.from_function_call( + call_id="call-1", + name="get_weather", + arguments={"location": "Paris"}, + additional_properties={"thought_signature": "not valid base64!!!"}, + ) + non_string = Content.from_function_call( + call_id="call-2", + name="get_weather", + arguments={"location": "Paris"}, + additional_properties={"thought_signature": 123}, + ) + + with caplog.at_level(logging.WARNING): + parts = client._convert_message_contents([corrupted, non_string], {}) + + assert len(parts) == 2 + assert parts[0].thought_signature is None + assert parts[1].thought_signature is None + assert "malformed thought_signature" in caplog.text + + def test_reconstructed_function_call_signature_survives_round_trip() -> None: """Parse captures the signature and a rebuilt call (raw Part dropped) replays it end to end.""" client, _ = _make_gemini_client() From 4d9ecd05be7aabbad8ce93e643bc177dddced2cd Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Tue, 14 Jul 2026 08:43:43 -0700 Subject: [PATCH 4/4] Carry Gemini thought_signature on reasoning content via protected_data Represent the signature as a text_reasoning content's protected_data (base64) immediately preceding the function call, instead of a bespoke additional_properties key. This uses the framework's first-class opaque-signature field (as Anthropic does), survives streaming accumulation, and stays intact when the harness reconstructs the function call. Replay correlates the signature by adjacency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33233834-bc6e-4ad2-a6f3-6f1d6e57b1d2 --- .../agent_framework_gemini/_chat_client.py | 44 ++++++----- .../gemini/tests/test_gemini_client.py | 73 ++++++++----------- 2 files changed, 56 insertions(+), 61 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 1e276f10389..232f8e8ea2f 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -664,7 +664,22 @@ def _convert_message_contents( A list of Gemini Part objects representing the message contents. """ parts: list[types.Part] = [] + pending_signature: bytes | None = None for content in message_contents: + if content.type == "text_reasoning": + # Gemini 3's thought_signature travels as base64 protected_data on reasoning content; + # hold it for the function call it precedes (reasoning is not sent back as a Part). + pending_signature = None + encoded_signature = content.protected_data + if isinstance(encoded_signature, str) and encoded_signature: + try: + pending_signature = base64.b64decode(encoded_signature, validate=True) + except ValueError: + logger.warning("Ignoring malformed thought_signature on reasoning content") + continue + # A signature applies only to a function call immediately following its reasoning content. + thought_signature = pending_signature + pending_signature = None match content.type: case "text": parts.append(types.Part(text=content.text or "")) @@ -692,17 +707,8 @@ def _convert_message_contents( name=content.name or "", args=content.parse_arguments() or {}, ) - # Echo back Gemini 3's opaque thought_signature (required on every replay of a - # functionCall part), decoding it from the base64 form stored in additional_properties. - # Guard the untyped value and degrade gracefully on corrupted history rather than - # crashing the tool loop. - encoded_signature = content.additional_properties.get("thought_signature") - thought_signature: bytes | None = None - if isinstance(encoded_signature, str) and encoded_signature: - try: - thought_signature = base64.b64decode(encoded_signature, validate=True) - except ValueError: # binascii.Error subclasses ValueError - logger.warning("Ignoring malformed thought_signature on function_call content") + # Echo the signature from the preceding reasoning content, backfilling only when + # the raw Part lacks one. if isinstance(raw_part, types.Part) and raw_part.function_call is not None: replayed_part = raw_part.model_copy(update={"function_call": function_call}, deep=True) if replayed_part.thought_signature is None and thought_signature is not None: @@ -1137,19 +1143,19 @@ def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]: else: call_id = self._generate_tool_call_id() logger.debug("function_call missing id; generated fallback call_id=%r", call_id) - # Capture Gemini 3's thought_signature (on the Part, not the FunctionCall) as a base64 - # string so it stays JSON-serializable when messages are persisted and can be replayed. - additional_properties = ( - {"thought_signature": base64.b64encode(part.thought_signature).decode("utf-8")} - if part.thought_signature is not None - else None - ) + # Surface Gemini 3's thought_signature as reasoning content preceding the call so it + # survives when the call is later reconstructed without its raw Part. + if part.thought_signature is not None: + contents.append( + Content.from_text_reasoning( + protected_data=base64.b64encode(part.thought_signature).decode("utf-8") + ) + ) contents.append( Content.from_function_call( call_id=call_id, name=function_call.name or "", arguments=function_call.args or {}, - additional_properties=additional_properties, raw_representation=part, ) ) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 5bc427e916c..f2af3cfd7ae 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -746,17 +746,19 @@ def test_function_call_part_preserves_thought_signature_from_raw_part() -> None: assert parts[0].function_call.args == {"location": "Paris"} -def test_function_call_part_captures_thought_signature_on_parse() -> None: - """Parsing a functionCall part stores its thought_signature as a base64 string.""" +def test_function_call_part_captures_thought_signature_as_reasoning_content() -> None: + """Parsing a functionCall part surfaces its thought_signature as preceding reasoning content.""" client, _ = _make_gemini_client() contents = client._parse_parts([ _make_part(function_call=("call-1", "get_weather", {"location": "Paris"}), thought_signature=b"sig-123") ]) - assert len(contents) == 1 - assert contents[0].type == "function_call" - assert contents[0].additional_properties["thought_signature"] == base64.b64encode(b"sig-123").decode("utf-8") + assert len(contents) == 2 + assert contents[0].type == "text_reasoning" + assert contents[0].protected_data == base64.b64encode(b"sig-123").decode("utf-8") + assert contents[1].type == "function_call" + assert contents[1].call_id == "call-1" def test_captured_thought_signature_is_json_serializable() -> None: @@ -771,30 +773,28 @@ def test_captured_thought_signature_is_json_serializable() -> None: restored = Message.from_dict(json.loads(serialized)) parts = client._convert_message_contents(restored.contents, {}) + # Reasoning content is not sent back as a part; only the function call is, carrying the signature. + assert len(parts) == 1 assert parts[0].thought_signature == b"sig-123" -def test_function_call_part_without_thought_signature_stores_nothing() -> None: - """A functionCall part without a signature leaves additional_properties empty.""" +def test_function_call_part_without_thought_signature_emits_no_reasoning_content() -> None: + """A functionCall part without a signature produces only the function_call content.""" client, _ = _make_gemini_client() contents = client._parse_parts([_make_part(function_call=("call-1", "get_weather", {"location": "Paris"}))]) assert len(contents) == 1 - assert "thought_signature" not in contents[0].additional_properties + assert contents[0].type == "function_call" -def test_reconstructed_function_call_replays_thought_signature_from_additional_properties() -> None: - """A function call rebuilt without its raw Part still replays the signature (harness approval path).""" +def test_reconstructed_function_call_replays_thought_signature_from_reasoning_content() -> None: + """A function call rebuilt without its raw Part still replays the signature from reasoning content.""" client, _ = _make_gemini_client() - content = Content.from_function_call( - call_id="call-1", - name="get_weather", - arguments={"location": "Paris"}, - additional_properties={"thought_signature": base64.b64encode(b"sig-123").decode("utf-8")}, - ) + reasoning = Content.from_text_reasoning(protected_data=base64.b64encode(b"sig-123").decode("utf-8")) + call = Content.from_function_call(call_id="call-1", name="get_weather", arguments={"location": "Paris"}) - parts = client._convert_message_contents([content], {}) + parts = client._convert_message_contents([reasoning, call], {}) assert len(parts) == 1 assert parts[0].thought_signature == b"sig-123" @@ -814,23 +814,15 @@ def test_function_call_without_thought_signature_replays_without_one() -> None: def test_malformed_thought_signature_is_ignored_gracefully(caplog: pytest.LogCaptureFixture) -> None: - """A corrupted or non-string signature degrades to no signature instead of crashing the tool loop.""" + """A corrupted or non-string reasoning signature degrades to no signature instead of crashing.""" client, _ = _make_gemini_client() - corrupted = Content.from_function_call( - call_id="call-1", - name="get_weather", - arguments={"location": "Paris"}, - additional_properties={"thought_signature": "not valid base64!!!"}, - ) - non_string = Content.from_function_call( - call_id="call-2", - name="get_weather", - arguments={"location": "Paris"}, - additional_properties={"thought_signature": 123}, - ) + corrupted = Content.from_text_reasoning(protected_data="not valid base64!!!") + call1 = Content.from_function_call(call_id="call-1", name="get_weather", arguments={"location": "Paris"}) + non_string = Content("text_reasoning", protected_data=cast(Any, 123)) + call2 = Content.from_function_call(call_id="call-2", name="get_weather", arguments={"location": "Paris"}) with caplog.at_level(logging.WARNING): - parts = client._convert_message_contents([corrupted, non_string], {}) + parts = client._convert_message_contents([corrupted, call1, non_string, call2], {}) assert len(parts) == 2 assert parts[0].thought_signature is None @@ -839,26 +831,23 @@ def test_malformed_thought_signature_is_ignored_gracefully(caplog: pytest.LogCap def test_reconstructed_function_call_signature_survives_round_trip() -> None: - """Parse captures the signature and a rebuilt call (raw Part dropped) replays it end to end.""" + """Parse yields reasoning+call; a rebuilt call (raw Part dropped) still replays the signature.""" client, _ = _make_gemini_client() parsed = client._parse_parts([ _make_part(function_call=("call-1", "get_weather", {"location": "Paris"}), thought_signature=b"sig-123") ]) - fc = parsed[0] + reasoning = parsed[0] + fc = parsed[1] assert fc.call_id is not None assert fc.name is not None - # Simulate a layer that reconstructs the call from call_id/name/arguments, dropping raw_representation. - rebuilt = Content.from_function_call( - call_id=fc.call_id, - name=fc.name, - arguments=fc.arguments, - additional_properties=dict(fc.additional_properties), - ) + # Simulate a layer that reconstructs the call from call_id/name/arguments, dropping raw_representation, + # while the sibling reasoning content is preserved in history. + rebuilt_call = Content.from_function_call(call_id=fc.call_id, name=fc.name, arguments=fc.arguments) - parts = client._convert_message_contents([rebuilt], {}) + parts = client._convert_message_contents([reasoning, rebuilt_call], {}) - assert parts[0].thought_signature == b"sig-123" + assert parts[-1].thought_signature == b"sig-123" def test_server_side_tool_call_part_is_informational_only() -> None: