@@ -4774,15 +4774,25 @@ def test_convert_reasoning_value_to_parts_skips_redacted_blocks():
47744774 assert parts [0 ].text == "visible"
47754775
47764776
4777- def test_convert_reasoning_value_to_parts_skips_empty_thinking ():
4778- """Blocks with empty thinking text are excluded."""
4777+ def test_convert_reasoning_value_to_parts_preserves_signature_only_blocks ():
4778+ """Signature-only blocks (empty text) are preserved for streaming aggregation.
4779+
4780+ Anthropic emits the block_stop signature as a delta with empty thinking text.
4781+ Dropping it would lose the signature, breaking multi-turn thinking continuity.
4782+ Blocks with neither text nor signature are still skipped.
4783+ """
47794784 thinking_blocks = [
47804785 {"type" : "thinking" , "thinking" : "" , "signature" : "sig1" },
47814786 {"type" : "thinking" , "thinking" : "real thought" , "signature" : "sig2" },
4787+ {"type" : "thinking" , "thinking" : "" , "signature" : "" }, # fully empty: drop
47824788 ]
47834789 parts = _convert_reasoning_value_to_parts (thinking_blocks )
4784- assert len (parts ) == 1
4785- assert parts [0 ].text == "real thought"
4790+ assert len (parts ) == 2
4791+ assert parts [0 ].text == ""
4792+ assert parts [0 ].thought is True
4793+ assert parts [0 ].thought_signature == b"sig1"
4794+ assert parts [1 ].text == "real thought"
4795+ assert parts [1 ].thought_signature == b"sig2"
47864796
47874797
47884798def test_convert_reasoning_value_to_parts_flat_string_unchanged ():
@@ -4894,3 +4904,71 @@ async def test_content_to_message_param_anthropic_no_signature_falls_back():
48944904 # Falls back to reasoning_content when no signatures present
48954905 assert result .get ("reasoning_content" ) == "thinking without sig"
48964906 assert "thinking_blocks" not in result
4907+
4908+
4909+ @pytest .mark .asyncio
4910+ async def test_content_to_message_param_anthropic_aggregates_streaming_split_thinking ():
4911+ """Streaming splits one Anthropic thinking block across many parts:
4912+ text-only chunks followed by a signature-only chunk at block_stop.
4913+ _content_to_message_param must re-join them into one thinking_block.
4914+ """
4915+ content = types .Content (
4916+ role = "model" ,
4917+ parts = [
4918+ # Text-only chunks from streaming deltas (no signature)
4919+ types .Part (text = "The user wants " , thought = True ),
4920+ types .Part (text = "GST research " , thought = True ),
4921+ types .Part (text = "on secondment." , thought = True ),
4922+ # Final signature-only chunk (empty text, signature carries the whole block)
4923+ types .Part (text = "" , thought = True , thought_signature = b"ErEDClsIDBACGAIfull" ),
4924+ # Non-thought response content
4925+ types .Part .from_function_call (name = "create_plan" , args = {"q" : "test" }),
4926+ ],
4927+ )
4928+ result = await _content_to_message_param (
4929+ content , model = "anthropic/claude-4-sonnet"
4930+ )
4931+ # One aggregated thinking block with combined text and the block's signature
4932+ blocks = result ["thinking_blocks" ]
4933+ assert len (blocks ) == 1
4934+ assert blocks [0 ]["type" ] == "thinking"
4935+ assert blocks [0 ]["thinking" ] == "The user wants GST research on secondment."
4936+ assert blocks [0 ]["signature" ] == "ErEDClsIDBACGAIfull"
4937+ # Legacy reasoning_content is not set when the Anthropic branch takes
4938+ assert result .get ("reasoning_content" ) is None
4939+
4940+
4941+ def test_model_response_to_chunk_preserves_signature_only_delta ():
4942+ """Anthropic streams a final thinking delta where content and
4943+ reasoning_content are empty but thinking_blocks carries the signature.
4944+ _has_meaningful_signal must recognize thinking_blocks as signal so the
4945+ signature survives into a ReasoningChunk.
4946+ """
4947+ stream = ModelResponseStream (
4948+ id = "x" ,
4949+ created = 0 ,
4950+ model = "claude" ,
4951+ choices = [
4952+ StreamingChoices (
4953+ index = 0 ,
4954+ delta = Delta (
4955+ role = None ,
4956+ content = "" ,
4957+ reasoning_content = "" ,
4958+ thinking_blocks = [{
4959+ "type" : "thinking" ,
4960+ "thinking" : "" ,
4961+ "signature" : "SignatureOnlyChunk" ,
4962+ }],
4963+ ),
4964+ )
4965+ ],
4966+ )
4967+ chunks = list (_model_response_to_chunk (stream ))
4968+ reasoning_chunks = [c for c , _ in chunks if isinstance (c , ReasoningChunk )]
4969+ assert len (reasoning_chunks ) == 1
4970+ parts = reasoning_chunks [0 ].parts
4971+ assert len (parts ) == 1
4972+ assert parts [0 ].text == ""
4973+ assert parts [0 ].thought is True
4974+ assert parts [0 ].thought_signature == b"SignatureOnlyChunk"
0 commit comments