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

Resolved 💬 2 comments Opened May 29, 2026 by ww2283 Closed Jul 12, 2026

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 blocks retries.

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 modified 400s + the strip-and-retry storm.
  • ToolSearch OFF: 0 such 400s across 145 API requests; no stripping signed blocks retries; Dynamic tool loading absent. (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 --debug log, 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).

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗