@@ -1094,12 +1094,57 @@ def test_code_execution_result_error_output(self):
10941094 assert result .code_execution_result .output == 'Error: division by zero'
10951095 assert result .code_execution_result .outcome == types .Outcome .OUTCOME_FAILED
10961096
1097- def test_thought_output_returns_empty (self ):
1098- """Test that thought output returns empty list (not exposed as Part) ."""
1099- output = ThoughtStep (type = 'thought' , signature = 'thinking...' )
1097+ def test_thought_output_without_signature_returns_empty (self ):
1098+ """A thought step with no signature contributes no parts ."""
1099+ output = ThoughtStep (type = 'thought' )
11001100 result = interactions_utils ._convert_interaction_step_to_parts (output )
11011101 assert result == []
11021102
1103+ def test_thought_output_with_signature_becomes_thought_part (self ):
1104+ """A thought step's signature is decoded onto a thought part."""
1105+ output = ThoughtStep (
1106+ type = 'thought' ,
1107+ signature = base64 .b64encode (b'sig-bytes' ).decode ('utf-8' ),
1108+ )
1109+
1110+ result = interactions_utils ._convert_interaction_step_to_parts (output )
1111+
1112+ assert len (result ) == 1
1113+ assert result [0 ].thought is True
1114+ assert result [0 ].thought_signature == b'sig-bytes'
1115+
1116+ def test_thought_output_signature_survives_the_round_trip_back (self ):
1117+ """The signature part re-encodes as a thought step carrying the signature.
1118+
1119+ Gemini 3 only accepts the follow-up request if the signature comes back
1120+ verbatim, so the part has to convert back to a step that still has one.
1121+ """
1122+ signature = base64 .b64encode (b'sig-bytes' ).decode ('utf-8' )
1123+ output = ThoughtStep (type = 'thought' , signature = signature )
1124+
1125+ part = interactions_utils ._convert_interaction_step_to_parts (output )[0 ]
1126+
1127+ assert interactions_utils ._convert_part_to_interaction_content (part ) == {
1128+ 'type' : 'thought' ,
1129+ 'signature' : signature ,
1130+ }
1131+
1132+ def test_thought_output_summary_is_not_carried_onto_the_part (self ):
1133+ """The signature part stays text-free.
1134+
1135+ A part that also carries text converts back to a text step, which has no
1136+ signature field, so the signature would be dropped on the next request.
1137+ """
1138+ output = ThoughtStep (
1139+ type = 'thought' ,
1140+ signature = base64 .b64encode (b'sig-bytes' ).decode ('utf-8' ),
1141+ summary = [TextContent (type = 'text' , text = 'Let me think...' )],
1142+ )
1143+
1144+ result = interactions_utils ._convert_interaction_step_to_parts (output )
1145+
1146+ assert result [0 ].text is None
1147+
11031148 def test_no_type_attribute (self ):
11041149 """Test handling output without type attribute."""
11051150 output = MagicMock (spec = []) # No 'type' attribute
@@ -1422,6 +1467,93 @@ def test_full_conversation(self):
14221467 assert result [0 ].parts [0 ].text == 'Great'
14231468 assert result [1 ].parts [0 ].text == 'Tell me more'
14241469
1470+ def test_signature_from_preceding_model_turn_is_carried_over (self ):
1471+ """A tool call's thought signature leads the returned contents.
1472+
1473+ The signature sits on the model turn that requested the tool call, one
1474+ step outside the trailing user window, but Gemini 3 rejects the request
1475+ that carries the tool result unless the signature comes back with it.
1476+ """
1477+ contents = [
1478+ types .Content (role = 'user' , parts = [types .Part (text = 'Weather?' )]),
1479+ types .Content (
1480+ role = 'model' ,
1481+ parts = [
1482+ types .Part (
1483+ function_call = types .FunctionCall (name = 'get_weather' ),
1484+ thought_signature = b'sig-bytes' ,
1485+ )
1486+ ],
1487+ ),
1488+ types .Content (
1489+ role = 'user' ,
1490+ parts = [
1491+ types .Part (
1492+ function_response = types .FunctionResponse (
1493+ name = 'get_weather' , response = {'temp' : 20 }
1494+ )
1495+ )
1496+ ],
1497+ ),
1498+ ]
1499+
1500+ result = interactions_utils ._get_latest_user_contents (contents )
1501+
1502+ assert len (result ) == 2
1503+ assert result [0 ].role == 'model'
1504+ assert result [0 ].parts [0 ].thought_signature == b'sig-bytes'
1505+ assert result [1 ].role == 'user'
1506+
1507+ def test_signature_free_parts_of_that_turn_are_left_behind (self ):
1508+ """Only the signature-bearing parts of the model turn come across.
1509+
1510+ The rest of that turn is already server-side under
1511+ previous_interaction_id, so re-sending it would duplicate it.
1512+ """
1513+ contents = [
1514+ types .Content (
1515+ role = 'model' ,
1516+ parts = [
1517+ types .Part (text = 'Let me check the forecast.' ),
1518+ types .Part (thought = True , thought_signature = b'sig-bytes' ),
1519+ ],
1520+ ),
1521+ types .Content (role = 'user' , parts = [types .Part (text = 'Thanks' )]),
1522+ ]
1523+
1524+ result = interactions_utils ._get_latest_user_contents (contents )
1525+
1526+ assert len (result [0 ].parts ) == 1
1527+ assert result [0 ].parts [0 ].thought_signature == b'sig-bytes'
1528+
1529+ def test_signature_carried_across_several_trailing_user_messages (self ):
1530+ """The signature part stays ahead of every message in the user window."""
1531+ contents = [
1532+ types .Content (
1533+ role = 'model' ,
1534+ parts = [types .Part (thought = True , thought_signature = b'sig-bytes' )],
1535+ ),
1536+ types .Content (role = 'user' , parts = [types .Part (text = 'First' )]),
1537+ types .Content (role = 'user' , parts = [types .Part (text = 'Second' )]),
1538+ ]
1539+
1540+ result = interactions_utils ._get_latest_user_contents (contents )
1541+
1542+ assert [content .role for content in result ] == ['model' , 'user' , 'user' ]
1543+ assert result [0 ].parts [0 ].thought_signature == b'sig-bytes'
1544+
1545+ def test_partless_preceding_model_turn_is_skipped (self ):
1546+ """A model turn with no parts contributes nothing and does not raise."""
1547+ contents = [
1548+ types .Content (role = 'model' ),
1549+ types .Content (role = 'user' , parts = [types .Part (text = 'Hello' )]),
1550+ ]
1551+
1552+ result = interactions_utils ._get_latest_user_contents (contents )
1553+
1554+ assert len (result ) == 1
1555+ assert result [0 ].parts [0 ].text == 'Hello'
1556+
14251557
14261558class TestConvertInteractionEventToLlmResponse :
14271559 """Tests for convert_interaction_event_to_llm_response."""
@@ -1514,6 +1646,79 @@ def test_thought_signature_delta_attaches_to_last_thought(self):
15141646 assert result is None
15151647 assert state .parts [- 1 ].thought_signature == b'sig-bytes'
15161648
1649+ def test_thought_signature_delta_alone_becomes_its_own_part (self ):
1650+ """A signature with no preceding thought summary still reaches the stream.
1651+
1652+ The model routinely emits a signature without a summary, and dropping it
1653+ would make the next request fail.
1654+ """
1655+ state = interactions_utils ._StreamState ()
1656+
1657+ result = interactions_utils .convert_interaction_event_to_llm_response (
1658+ StepDelta (
1659+ event_type = 'step.delta' ,
1660+ index = 0 ,
1661+ delta = {
1662+ 'type' : 'thought_signature' ,
1663+ 'signature' : base64 .b64encode (b'lone-sig' ).decode ('utf-8' ),
1664+ },
1665+ ),
1666+ state ,
1667+ interaction_id = 'int_ts' ,
1668+ )
1669+
1670+ assert result is None
1671+ assert len (state .parts ) == 1
1672+ assert state .parts [0 ].thought is True
1673+ assert state .parts [0 ].thought_signature == b'lone-sig'
1674+
1675+ def test_thought_signature_delta_does_not_attach_to_a_text_part (self ):
1676+ """A signature never lands on a text part, which cannot carry one."""
1677+ state = interactions_utils ._StreamState ()
1678+ interactions_utils .convert_interaction_event_to_llm_response (
1679+ StepDelta (
1680+ event_type = 'step.delta' ,
1681+ index = 0 ,
1682+ delta = {'type' : 'text' , 'text' : 'The weather is sunny.' },
1683+ ),
1684+ state ,
1685+ interaction_id = 'int_ts' ,
1686+ )
1687+
1688+ interactions_utils .convert_interaction_event_to_llm_response (
1689+ StepDelta (
1690+ event_type = 'step.delta' ,
1691+ index = 0 ,
1692+ delta = {
1693+ 'type' : 'thought_signature' ,
1694+ 'signature' : base64 .b64encode (b'sig-bytes' ).decode ('utf-8' ),
1695+ },
1696+ ),
1697+ state ,
1698+ interaction_id = 'int_ts' ,
1699+ )
1700+
1701+ assert state .parts [0 ].text == 'The weather is sunny.'
1702+ assert state .parts [0 ].thought_signature is None
1703+ assert state .parts [1 ].thought_signature == b'sig-bytes'
1704+
1705+ def test_thought_signature_delta_without_signature_adds_no_part (self ):
1706+ """An empty signature delta leaves the stream state untouched."""
1707+ state = interactions_utils ._StreamState ()
1708+
1709+ result = interactions_utils .convert_interaction_event_to_llm_response (
1710+ StepDelta (
1711+ event_type = 'step.delta' ,
1712+ index = 0 ,
1713+ delta = {'type' : 'thought_signature' , 'signature' : '' },
1714+ ),
1715+ state ,
1716+ interaction_id = 'int_ts' ,
1717+ )
1718+
1719+ assert result is None
1720+ assert state .parts == []
1721+
15171722 def test_audio_delta_with_data (self ):
15181723 """audio delta becomes an inline_data part via the shared media handler."""
15191724 event = StepDelta (
0 commit comments