fix(agent): keep the request prefix a Claude 5.x thinking block is bound to

TH
40s ago
+680-54

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 auto after the provider refused or downgraded a forced tool choice, it appended the tool-calls-only rule to the system prompt. Recent @ai-sdk/anthropic versions 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 to auto changes only tool_choice, which the check ignores. prepareTurn compares 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 custom prepareMessages edits 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: afterEdit removes the reasoning parts 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/anthropic provider over a fake fetch, 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's between_tools, and hides the edit instead of removing it. The activeTools narrowing 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) and effort: 'high' (at medium Opus 5.5 produced no thinking on these pages), all six tests-agent files, --no-cache:
modelmainthis branch
claude-opus-5-53 of 29 fail with the 400 (keypad flow on model call 2, both drawn wizards)29 pass
claude-sonnet-5-52 of 29 fail with the 400 (both drawn wizards)29 pass
claude-fable-5-14 of 29 fail with the 400, 1 model-behavior failure29 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 6 on 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-fast with 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 enabled thinking accept a last assistant turn with its thinking stripped.
  • pnpm bench:ab on 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.