Skip to content

[genai-fork] anthropic: preserve per-block thinking text on stream ca… - #6

Merged
orual merged 1 commit into
mainfrom
fix/thinking-signature-roundtrip
Apr 29, 2026
Merged

[genai-fork] anthropic: preserve per-block thinking text on stream ca…#6
orual merged 1 commit into
mainfrom
fix/thinking-signature-roundtrip

Conversation

@orual

@orual orual commented Apr 29, 2026

Copy link
Copy Markdown
Owner

…pture + outbound

The streaming capture path discarded per-block reasoning text (let _ = reasoning; at anthropic/streamer.rs) and reconstructed ThinkingBlock parts with empty text paired to real signatures. The outbound assistant-message serializer compounded this with a reasoning_texts.join("") + zip-by-index strategy that concatenated all reasoning into block[0]'s text and left block[1..] empty, scrambling the text<->signature pairing.

Anthropic validates thinking-block signatures byte-exact against the paired thinking text on replay. Both bugs silently invalidated every signature on multi-block thinking turns; the single-block case worked by coincidence because the "combined" text equalled the "per-block" text.

Fix:

  • Widen StreamerCapturedData::thought_signatures (Vec) to thought_blocks: Option<Vec>. push_thought_block takes (text, sig, provenance) and preserves the pairing through reconstruction.
  • Anthropic streamer pushes (reasoning, signature) together at content_block_stop instead of dropping reasoning.
  • chat_stream::StreamEnd::from rebuilds ContentPart::ThinkingBlock with real per-block text. ToolCall.thought_signatures (Gemini-shaped opaque-signatures-on-tool-call semantic) is backfilled with signature strings only -- correct for Gemini's wire format which has no paired thinking text.
  • Anthropic outbound (adapter_impl) walks ContentPart::ThinkingBlock parts in order and emits one {thinking, signature} per block 1:1; non-Anthropic-provenance blocks are dropped silently.
  • Gemini and openai_resp streamers pass empty text via push_thought_block (their signatures are opaque with no paired text). Gemini's outbound adapter already ignores block.text and emits just {thoughtSignature: sig}, so cross-provider round-trip stays correct.

Tests:

  • New tests/tests_signature_roundtrip.rs asserts inbound multi-block fixture produces two ThinkingBlocks with correct per-block text and signatures.
  • New unit test in anthropic/adapter_impl replaces the previous one that codified the broken "concat into block[0], empty block[1..]" behaviour; new test asserts 1:1 text<->signature pairing on outbound.
  • tests/data/yakbak/anthropic/thinking_multi_block_stream/ new fixture with two distinct thinking blocks and a trailing tool_use.

78 unit tests + yakbak (anthropic + gemini) + signature roundtrip integration pass. No behavioural changes for single-block thinking.

…pture + outbound

The streaming capture path discarded per-block reasoning text
(`let _ = reasoning;` at anthropic/streamer.rs) and reconstructed
ThinkingBlock parts with empty text paired to real signatures. The
outbound assistant-message serializer compounded this with a
`reasoning_texts.join("")` + zip-by-index strategy that concatenated
all reasoning into block[0]'s text and left block[1..] empty, scrambling
the text<->signature pairing.

Anthropic validates thinking-block signatures byte-exact against the
paired `thinking` text on replay. Both bugs silently invalidated every
signature on multi-block thinking turns; the single-block case worked
by coincidence because the "combined" text equalled the "per-block"
text.

Fix:
- Widen StreamerCapturedData::thought_signatures (Vec<String>) to
  thought_blocks: Option<Vec<ThinkingBlock>>. push_thought_block
  takes (text, sig, provenance) and preserves the pairing through
  reconstruction.
- Anthropic streamer pushes (reasoning, signature) together at
  content_block_stop instead of dropping reasoning.
- chat_stream::StreamEnd::from rebuilds ContentPart::ThinkingBlock
  with real per-block text. ToolCall.thought_signatures (Gemini-shaped
  opaque-signatures-on-tool-call semantic) is backfilled with
  signature strings only -- correct for Gemini's wire format which
  has no paired thinking text.
- Anthropic outbound (adapter_impl) walks ContentPart::ThinkingBlock
  parts in order and emits one {thinking, signature} per block 1:1;
  non-Anthropic-provenance blocks are dropped silently.
- Gemini and openai_resp streamers pass empty text via push_thought_block
  (their signatures are opaque with no paired text). Gemini's outbound
  adapter already ignores block.text and emits just {thoughtSignature:
  sig}, so cross-provider round-trip stays correct.

Tests:
- New tests/tests_signature_roundtrip.rs asserts inbound multi-block
  fixture produces two ThinkingBlocks with correct per-block text and
  signatures.
- New unit test in anthropic/adapter_impl replaces the previous one
  that codified the broken "concat into block[0], empty block[1..]"
  behaviour; new test asserts 1:1 text<->signature pairing on
  outbound.
- tests/data/yakbak/anthropic/thinking_multi_block_stream/ new fixture
  with two distinct thinking blocks and a trailing tool_use.

78 unit tests + yakbak (anthropic + gemini) + signature roundtrip
integration pass. No behavioural changes for single-block thinking.
@orual
orual merged commit eefbf07 into main Apr 29, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant