fix(agent): keep the request prefix a Claude 5.x thinking block is bound to
TH
40s ago+680+680+012345678901234567890123456789-54-54-01234567890123456789
Closes ENG-984.
Claude Opus 5.5, Sonnet 5.5, and Fable 5.1 sign every thinking block over the request prefix that produced it, the system prompt and the messages before the block, and answer a replay after any edit to that prefix with:
messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation.
The act loop edited that prefix in two places:
- On the switch to
autoafter the provider refused or downgraded a forced tool choice, it appended the tool-calls-only rule to the system prompt. Recent@ai-sdk/anthropicversions and the AI Gateway downgrade with a warning instead of a 400, so turn 1 succeeds with a thinking block and turn 2 replays it under a changed prompt. This fires on the first step of every worker and is what the reporter hit; the issue text names only the second cause. - Screen and screenshot elision (
transcript-compaction.ts) rewrites earlier messages once superseded screens pass their budget. Every block signed before the edit is invalid from then on.
flowchart LR
A[turn n: history prepared] --> B{a message differs from<br/>the last request's object?}
B -- no --> C[send as is]
B -- yes --> D[afterEdit: drop reasoning parts<br/>from that message on<br/>Anthropic only]
D --> E[note it on the turn] --> C
packages/e2e/src/agent/tool-loop.ts: the tool-calls-only rule is part of every step's instructions from turn 1, so the switch toautochanges onlytool_choice, which the check ignores.prepareTurncompares what each message holds with what the last request sent (message-shape.ts: by value with provider options left out, binary payloads copied at the snapshot and compared by bytes), so a replaced message and one a customprepareMessagesedits in place both show up while a moved cache breakpoint or a cloned history does not, and hands the first rewritten index to the provider hints. The overflow shrink now returns unchanged messages as the same objects for the same reason.packages/e2e/src/agent/model/provider-hints.ts:afterEditremoves thereasoningparts of assistant messages from the rewritten one on, and drops a reply that was only reasoning. Anthropic allows removing thinking from the end of a history; a block produced after the elision stays valid because the elided message does not change again. Other providers get the history untouched, since OpenAI Responses needs its reasoning items beside their function calls. The Anthropic detection now also recognizes Claude on Bedrock (eu.anthropic.claude-…) and Vertex AI (vertex.anthropic.messages), where the same check runs; before, only the direct provider and the gateway were matched.tests/unit/tool-loop-prefix.test.ts: the real@ai-sdk/anthropicprovider over a fakefetch, asserting on the request bodies that the system prompt is byte-identical through the switch, that no thinking is replayed after an elided message while later thinking is, and that a non-Anthropic model keeps every reasoning part.- Not done:
prefix_mismatch_behavior: drop_block. It needs a beta header, a provider version and a gateway that forward it, is rejected under Sonnet 5.5'sbetween_tools, and hides the edit instead of removing it. TheactiveToolsnarrowing on forced-conclusion turns stays: Anthropic's docs list the tool set in the bound prefix, but a narrowed and a reordered list both passed the check when probed live, and main's forced turn on Fable 5.1 passed it under enforcement.
Verified
Ran it locally: yes
- testbed, built CLI through the AI Gateway, binding check opted in as
error(our account is not enforced by default) andeffort: 'high'(atmediumOpus 5.5 produced no thinking on these pages), all sixtests-agentfiles,--no-cache:
| model | main | this branch |
|---|---|---|
| claude-opus-5-5 | 3 of 29 fail with the 400 (keypad flow on model call 2, both drawn wizards) | 29 pass |
| claude-sonnet-5-5 | 2 of 29 fail with the 400 (both drawn wizards) | 29 pass |
| claude-fable-5-1 | 4 of 29 fail with the 400, 1 model-behavior failure | 29 pass |
main, claude-opus-5-5:
× act enters a ten-digit code on a keypad painted on a canvas > agent.act "on the drawn keypad enter the code 3141592653 …" 24.99s · 2 model calls failed
FAIL |web| tests-agent/canvas-flow.e2e.ts > act enters a ten-digit code on a keypad painted on a canvas
MODEL_PROVIDER_FAILED: the model provider failed: messages.1.content.2: Invalid `signature` in `thinking` block. The block is bound to a different conversation. Remove the block, or set `thinking.block_binding.prefix_mismatch_behavior` to "drop_block".
FAIL |web| tests-agent/pixel-tools.e2e.ts > walks the drawn wizard through points
MODEL_PROVIDER_FAILED: the model provider failed: messages.3.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation. …
Test Files 3 failed | 3 passed (6)
Tests 3 failed | 26 passed (29)
this branch, claude-opus-5-5:
Test Files 6 passed (6)
Tests 29 passed (29)
AI 954.1k tokens · 83% cached · $1.16 · 103 model calls · gateway/anthropic/claude-opus-5-5
- The branch runs recorded 4, 9, and 3 reasoning drops at elisions respectively, so the elision path ran under enforcement; the final Opus 5.5 run on the committed code passed 29 of 29 with 4 drops.
e2e explore --max-steps 6on Opus 5.5 completed 6 of 6 steps on the branch. A step driven into its forced-conclusion turns with a three-call budget passed the check on both builds. - Control: the testbed's default
openai/gpt-6-luna-fastwith the unmodified agent config, 28 of 29 passed and the keypad flow passed on two reruns; the web benchmark's agent suite live on the same model with--no-cache, 33 of 33 with the committed recordings untouched. - Live probes through the gateway: an edited first message is rejected on Opus 5.5 with the opt-in and accepted without it; a narrowed or reordered tool list is accepted; Haiku 4.5 and Sonnet 4.6 with
enabledthinking accept a last assistant turn with its thinking stripped. pnpm bench:abon the web benchmark's control inventory, 2 pairs: identical behavior and counters on every run of both builds, timings same.- Not run: Claude through Bedrock or Vertex AI; the wider detection is covered by unit cases only.
Fresh-context review: one finding rejected, that narrowing tools on forced turns breaks the check, since the live probe and main's forced turn under enforcement show it does not; one finding fixed, the Bedrock and Vertex detection above.