Opus 4.8 + dynamic tool loading (ToolSearch) re-modifies signed thinking blocks → "messages.N.content.M ... cannot be modified" 400 storm → strip-and-retry every turn
Summary
On Claude Code 2.1.156 with Opus 4.8 and extended thinking enabled, dynamic/on-demand tool loading (ToolSearch) appears to mutate already-signed thinking/redacted_thinking blocks in earlier assistant turns. The server rejects the request with a 400, Claude Code strips the signed blocks and retries — costing a full extra API round-trip on every subsequent turn once the session is "poisoned," which compounds into multi-minute first-byte stalls.
The 2.1.156 changelog notes a thinking-block fix for Opus 4.8; this appears to be an incomplete fix — the dynamic-tool-loading path still triggers it.
Environment
- Claude Code 2.1.156
- Model: Opus 4.8 (1M context)
- Extended thinking: on
ENABLE_TOOL_SEARCH: unset (defaults to ON / optimistic)
Symptoms (from --debug log)
- Repeated API 400s:
400 invalid_request_error: messages.N.content.M thinking/redacted_thinking blocks ... cannot be modified - Each one followed by:
[WARN] [thinking] server rejected ... stripping signed blocks and retrying - The 400 consistently cites the same two historical assistant messages (large past turns), and the log line immediately preceding the first failure is
Dynamic tool loading: found N discovered tools in message history. - Once it starts, every later turn re-sends and re-fails, doubling first-byte latency and cascading into 15–30s+ stalls (worst case observed: a single turn stalled ~4.5 minutes). One affected session logged ~126
stripping signed blocksretries.
Root-cause hypothesis
On-demand tool loading changes the available tool set mid-session, causing Claude Code to re-serialize prior messages; that perturbs their signed thinking-block signatures, which the server then rejects as modified.
Workaround (confirmed effective)
Setting ENABLE_TOOL_SEARCH=false eliminates it entirely. Across two comparable long sessions on the same machine/model:
- ToolSearch ON: 86
... cannot be modified400s + the strip-and-retry storm. - ToolSearch OFF: 0 such 400s across 145 API requests; no
stripping signed blocksretries;Dynamic tool loadingabsent. (Residual ~15s server stalls remain but are ordinary 1M-context variance, not the compounding loop.)
Impact (latency confirmed; cost is a hypothesis)
- Latency: clearly the dominant, evidenced harm — an extra round-trip per turn, cascading to multi-minute stalls.
- Token cost (hypothesis, not measured): the retry works by modifying historical message content (the signed blocks), which would break the prompt-cache breakpoint at that point — forcing the prefix up to the modified message to be re-read as uncached input on the retry and likely on subsequent turns. Since uncached input is far more expensive than a cache read, this could materially increase billed input cost / subscription-usage consumption on large-context sessions, even though the per-turn token count looks similar. (The rejected 400 attempts themselves should not be billed, since they fail validation before inference.) We could not quantify this from the
--debuglog, which carries timing but not cache/token fields — flagging it as a plausible secondary cost for triage.
Expected
Dynamic tool loading should not invalidate previously-signed thinking blocks. If the tool set must change mid-session, prior signed blocks should remain valid (or be handled without a strip-and-retry on every subsequent turn, and without breaking prompt-cache reuse).
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗