Resuming an extended-thinking session fails permanently with 400 "thinking blocks cannot be modified" (transcript stores thinking text as empty but keeps signature)

Open 💬 59 comments Opened May 28, 2026 by jdrolls

Environment

  • Claude Code: 2.1.153
  • OS: macOS (Darwin 25.3.0)
  • Auth: subscription, extended thinking enabled

Summary

Resuming/continuing a session that used extended thinking together with tool calls can put the session into a permanently broken state. Every subsequent turn returns the same error:

API Error: 400 messages.1.content.5: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.

Once it starts, the session can never be continued again — continue, any new prompt, even a no-op, all return the identical 400.

Root cause

Claude Code persists extended-thinking blocks to the session transcript (projects/<slug>/<id>.jsonl) with the thinking text emptied to "" but the signature field retained. A thinking content block on disk looks like:

{ "type": "thinking", "thinking": "", "signature": "<base64 signature, ~600–4000 chars>" }

When the session is rebuilt from this transcript (on resume/continue, and apparently on other history-reconstruction paths), Claude Code sends these blocks back to the API as { "type": "thinking", "thinking": "", "signature": "<original>" }. The API validates the signature against the thinking text; since the text is now empty but the signature was computed over the original non-empty text, validation fails → the 400 above.

Because the original thinking text is gone from disk, the request can never be reconstructed into a valid form — the session is permanently poisoned.

Evidence

In a broken session, every thinking block has empty text but a present signature (lengths shown — thinking-len, signature-len):

$ jq -rc 'select(.type=="assistant")|.message.content[]?|select(.type=="thinking")|[(.thinking|length),(.signature|length)]|@tsv' broken.jsonl
0    3932
0    1196
0    1632
0    620
0    1172

Line 1 of the broken transcript is {"type":"last-prompt","leafUuid":...}, indicating the session was resumed/forked.

This is not isolated: across many healthy sessions the trailing thinking block is also frequently stored empty-but-signed, so any such session becomes a landmine if it is later resumed.

Reproduction

  1. Start a session with extended thinking enabled.
  2. Do work that produces thinking blocks interleaved with tool calls.
  3. Exit, then resume the session (--resume / --continue / pick from history).
  4. Send any prompt → 400 "thinking blocks … cannot be modified", repeating on every subsequent turn.

Impact

  • Resumed extended-thinking + tool sessions can become permanently unusable.
  • The failure is silent until resume, so users lose the ability to continue long working sessions with no warning and no recovery path.

Suggested fix

When rebuilding the request from the transcript, Claude Code should never send a thinking block whose text was not preserved. Options:

  • Persist the full signed thinking text so the signed block round-trips intact; or
  • Drop thinking blocks entirely from reconstructed prior turns (the API permits omitting earlier-turn thinking) instead of sending thinking:"" + signature; or
  • Defensive guard: during request build, detect empty-text-with-signature thinking blocks and strip them before sending.

View original on GitHub ↗

61 Comments

github-actions[bot] · 1 month ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/63143
  2. https://github.com/anthropics/claude-code/issues/41992
  3. https://github.com/anthropics/claude-code/issues/10199

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

mrz1836 · 1 month ago

I'm getting the same error
"API Error: 400 messages.1.content.14: thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."

Then my session/chat becomes unusable after that state. Just started happening today on VS plugin 2.1.153

Rabusek · 1 month ago

Same error

georgiandinca · 1 month ago

Confirming this on a second setup — same permanent failure.

Environment

  • Claude Code: 2.1.153 (latest published)
  • OS: macOS (Darwin 24.6.0)
  • Model: Opus 4.7, subscription, extended thinking enabled

Observations

  • Hit the identical 400 ... \thinking\ or \redacted_thinking\ blocks in the latest assistant message cannot be modified across two separate resumed sessions.
  • Both occurred specifically on resume/continue of sessions that had used extended thinking together with tool calls.
  • Once triggered, the session is unrecoverable — every subsequent prompt (including no-ops) returns the same 400, matching the root-cause analysis above (transcript persists thinking: "" while keeping the original signature).

The diagnosis in the issue body matches what I'm seeing. +1 on prioritizing — on 2.1.153 this makes any sufficiently long thinking session a write-off after the first resume.

cnighswonger · 1 month ago

I filed #63172, which the duplicate-detector flagged against this issue — agreed, same bug, consolidating here. Your "transcript stores thinking text as empty but keeps signature" detail matches exactly what I observed. Adding the distinctive evidence from my report so it isn't lost when I close mine:

Scale appears to be the precondition, and it governs severity. My session was at the far end when it became permanently broken:

  • ~382K-token live context (1M-context variant)
  • ~6,850 thinking blocks accumulated over the session
  • ~7 weeks of continuous session age
  • ~99% cache-read share in steady state, right up until the trip

It did not occur at modest sizes. Smaller/shorter sessions either don't hit it or recover for much longer between occurrences; how quickly it recurs after a rewind scales with context size and accumulated thinking blocks. So reproduction likely needs a genuinely large, long-lived session — not a fresh one.

Triggers beyond resume/continue. The same failure reproduced from two distinct history-altering events on an already-large session:

  1. A scheduled/cron-style prompt firing into an otherwise-idle session.
  2. A parallel tool-call batch where one call errored and the siblings were cancelled (cf. #63143's AskUserQuestion-cancel variant — appears to be the same mechanism with a different trigger).

Both reduce to the same operation: the client reconstructing an assistant turn that contains interleaved thinking blocks.

Ruled out client middleware. Reproduced with the client connected directly to api.anthropic.com (no proxy/middleware in the request path), so this is not an intermediary mangling the request body.

Cache signal on the failing turn. In steady state the session ran ~99% cache-read; on the failing turn the request showed a full cache miss (prefix re-created) simultaneously with the 400 — consistent with the client rewriting the prompt prefix on that turn, i.e. the cache break and the signature desync look like two symptoms of the same re-edit.

Closing #63172 in favor of this one. 👍

kokyoongee · 1 month ago

Independent confirmation on 2.1.153 (macOS, Opus 4.7, subscription, extended thinking at max effort). Same root cause you describe — the on-disk thinking blocks carry a signature but empty text.

Some extra data points that may help pin the trigger:

The poison turn was a single large interleaved-thinking + tool-use response. In my broken session the offending assistant response (messageId grouping) had 88 content blocks: 29 thinking + 1 text + 58 tool_use, fully interleaved (T x U U U U U T U U U T …). The API error pointed at content.78, which is exactly the 27th thinking block in that response. Every one of those 29 thinking blocks was thinking:"" , signature:<356–15664 chars>.

It's session-wide, not just the trailing block. In the pristine (pre-edit) transcript, 108/108 thinking blocks had empty text + a present signature — so any resume that has to replay a thinking-with-tools turn is doomed, matching your "landmine" note.

Not caused by hooks/diagnostics (ruled out while debugging): injected additionalContext from PostToolUse hooks never enters the message stream, Stop hooks fired but never set preventedContinuation, and isMeta rows were benign. It's purely the empty-text-but-signed persistence vs. the API's interleaved-thinking replay requirement.

Workaround that reliably heals a wedged session: remove the standalone thinking/redacted_thinking assistant rows from the .jsonl and re-link each orphaned parentUuid to the first surviving ancestor (historical thinking is optional to the API when it isn't an interleaved-thinking-with-tools continuation). After stripping, the session resumes cleanly on Opus. It recurs on the next big interleaved-thinking turn, so it's a band-aid, but it unblocks an otherwise permanently-dead session.

Suggested fix direction: either persist the real thinking text (so signatures validate on replay) or drop the signature when the thinking text is emptied, so the API treats them as plain optional history instead of rejecting a signed-but-empty block.

ductai199x · 1 month ago

Confirming on 2.1.153, Linux (WSL2), Opus 4.7 (1M context), extended thinking + tools. Same root cause — 139 thinking blocks all stored thinking:"" + signature. Trigger here was an interrupted single turn with 27 parallel tool calls: all tool_results returned, but the closing message never generated, and every retry re-sent the signed-but-empty in-flight turn → permanent 400 messages.75.content.15.

Workaround variant that preserves threading: instead of deleting the thinking rows and re-linking parentUuids, you can strip the thinking/redacted_thinking objects in place from each assistant message's content array and leave every row/uuid untouched. Since the thinking text is already empty on disk, this is zero information loss, and because rows aren't removed, parentUuid chains stay intact (no relinking needed). Verified it resumes cleanly. Confirmed no assistant message becomes empty-content (each had a sibling text/tool_use block).

eslerm · 1 month ago

Confirmed on 2.1.153, macOS, Opus 4.7. Same root cause.

Additional trigger: auto-compaction on session away/wake cycle — no explicit resume, no cancelled tool calls. Just away → next prompt → compaction reconstructs from transcript → 400 on the kept-tail boundary.

Workaround (credit ductai199x): strip thinking/redacted_thinking blocks in-place from assistant content arrays in the .jsonl; replace thinking-only rows with {"type":"text","text":"[thinking]"} placeholder to avoid empty content. Leave all rows and parentUuid chains intact. Tested on a 194-line session (17 blocks) and a 2213-line session (275 blocks, 274 thinking-only rows — nearly all thinking-only, unlike ductai199x's case). Both resumed cleanly via --resume after patching.

Slider-8 · 1 month ago

Same root cause from another environment (Claude Code 2.1.150, Windows 11 / Git Bash, showThinkingSummaries: true). Adding a distinct trigger seen in my transcript in case it helps narrow the repro:

A single assistant turn that interleaves multiple thinking blocks with multiple tool_use calls (no resume, no cancel required) — e.g.:

assistant THINKING
assistant tool_use:Bash
assistant THINKING
assistant tool_use:ToolSearch
assistant tool_use:WebFetch
assistant tool_use:BashOutput
user      tool_result x4
assistant THINKING
assistant text  -> API Error 400 messages.3.content.2

Every thinking block in that turn is persisted as { "type":"thinking", "thinking":"", "signature":"<intact ~292-696 chars>" } — text blanked, signature kept — exactly as you describe. So the corruption isn't limited to resume (#63147) or parallel-batch cancellation (#63192); plain interleaved-thinking + multi-tool in one turn reproduces it too. Common factor across all three reports: thinking text emptied while signature retained. Filed #63269, closing as dup of this.

mieubrisse · 1 month ago

(This post authored by my AgenC)

Adding a data point on version range: most confirmations here are on 2.1.153, but I'm hitting this on 2.1.145 as well — so the regression predates the current release. (No data on whether earlier 2.1.x is affected.)

Setup: Opus 4.7 (1M context), subscription, extended/interleaved thinking enabled, on macOS. I run a fleet of headless/headed agent sessions, and had two independent sessions wedge permanently on the same day, each via a different history-altering event:

  1. A user interrupt while an extended-thinking turn had pending parallel tool calls.
  2. An errored parallel-tool-call batch that cancelled its sibling calls mid-turn (messages.3.content.21).

Both match the root cause described here — once wedged, every continuation (including a plain retry/no-op) returns the identical 400, because the broken transcript is re-sent each time. Confirming the parallel-tool-cancellation trigger that was consolidated from #63172.

ceeram · 1 month ago

session does not allow any more inputs, every prompt gets this response
started happening first on:
```
Commit + push it is. Writing the commit message and landing both repos.

⏺ Write(.commit-msg)
...

then 4 bash commands:

` Pushed to main, ran 4 shell commands`

last bash command being:

Bash(cd ~/.claude && git push origin main 2>&1 | tail -5 && echo "--- final state ---" && git log --oneline -1 && git status -sb | head -1)
Everything up-to-date
--- final state ---
...
API Error: 400 messages.9.content.82: thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.


reported in https://github.com/anthropics/claude-code/issues/63272
beemusicco · 1 month ago

Confirming this on Claude Code 2.1.153 and 2.1.154, and on both opus-4-8 and opus-4-7 — so it is model-agnostic, not 4.8-specific. Your root-cause read (thinking text emptied to "" on disk with the signature retained, re-sent on continuation) matches what we found independently.

Trigger in our case: long multi-step assistant turns with large parallel tool-call batches (10+ tool_use blocks in one turn). A session doing such batches broke every ~60–160s, always mid-turn, and could never complete a turn again once wedged. Sessions doing short/sequential turns (same machine, same CLI/model) never hit it — turn length is the differentiator.

Confirmed env-var workaround: launching with CLAUDE_CODE_DISABLE_THINKING=1 eliminates it completely. With it set, 0 thinking blocks are generated (verified via ps -E on the process + 0 "type":"thinking" in the transcript), and a previously hard-wedged session ran 10+ minutes with zero breaks and completed turns normally (vs. breaking every ~1–2 min before). Trade-off is loss of extended reasoning, but it makes an otherwise-unusable session usable.

Note: DISABLE_INTERLEAVED_THINKING=1 does not help — we verified it was present in the process env (ps -E) yet interleaved thinking blocks were still emitted and the 400 still occurred. Only the full CLAUDE_CODE_DISABLE_THINKING takes effect.

johncarmack1984 · 1 month ago

Also reproduces on v2.1.154 (native install), and notably without an explicit resume -- hit it twice in one day on long multi-tool turns.

In my case the 400 cascade started the moment a long-running background command completed mid-turn and its completion notification was injected into the conversation. That injection appears to force a rebuild of the latest assistant message from the on-disk transcript, where the empty-text + retained-signature blocks you describe live. So the same corruption fires during a live session via that path, not only on resume.

Confirming the transcript detail across all of my recent session .jsonl files: the count of thinking blocks stored with empty text ("thinking":"") exactly equals the count of blocks carrying a signature -- 1:1, including the wedged sessions. Every retry re-sends the block and /compact also fails (it re-reads the same blocks), so the session is permanently stuck.

The 2.1.152 signature-stripping safety-net does not cover this path.

Hillier98 · 1 month ago

Confirming on Apple M2 Pro Tahoe 26.5 and Claude 1.9659.1 (193bcb) 2026-05-28T16:22:15.000Z.

After editing the JSONL manually, the conversation resumed. However, after a "Ran 8 commands..." with no input from my side, it stopped and failed again with the:

Invalid request. The request couldn't be completed. -> View details
API Error: 400 messages.9.content.11: thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.

So the workaround works only partially, because the conversation can't really progress. The session is unusable.

darkguy2008 · 1 month ago

~I fixed it by going back to Opus 4.7 on the conversation. Seems like 4.8 has the bug.~

Disregard, still happening. Damnit, Claude is broken again -_-

Seems to work if I go back to 2.1.152 and keep Claude 4.7. Seems like Opus 4.8 is unusable from day one.

MicHuang · 1 month ago

Confirming on Claude Code 2.1.154 (latest npm), claude-opus-4-8, macOS. Hit two sessions the same day, including a fresh one — not resume-specific.

I don't think the stated cause (empty thinking text + retained signature) is the trigger. Healthy, working sessions also persist thinking as {"thinking":"","signature":"<long>"} — that's the normal on-disk form, so it can't be what fails.

The real trigger is block ORDER. When one assistant turn interleaves thinking with parallel tool_use (interleaved-thinking beta on), the transcript persists a thinking block after a tool_use block in the same message — e.g. content order [tool_use, thinking, tool_use, thinking], where content[3] matches the messages.N.content.M in the 400. The API needs thinking to lead, so the replayed history is rejected; since it's persisted, every later prompt 400s identically → permanent wedge, --resume included. (The offending blocks all share one requestId = a single response stored thinking-after-tool_use.)

Workaround (not in the issue): DISABLE_INTERLEAVED_THINKING=1 in ~/.claude/settings.json env — forces thinking to lead, so the bad order can't form. Extended thinking + parallel tools still work.

Fix: order thinking/redacted_thinking blocks before any tool_use when persisting/assembling an assistant message (or validate block order before sending).

KalpaD98 · 1 month ago

Experiencing the same issue.

This errors been happening since they rolled out 4.8 opus. This ate through my usage trying to continue and now im 96% of my claude 5 hour usage without any work getting done. and it also messed up a work of previous session what a waste of time. Any way I can get some extra credits to compensate for this by emailing anthropic or any fixes that actually work without breaking the work. I saw a previous reddit post asking to change to a smaller modeling and this will force to a compaction but that will nerf the context gathered so I don't wanna do that.

brunuff · 1 month ago

API Error: 400 messages.3.content.35: thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.

Claude Code Desktop using cloud environment

ristosi · 1 month ago

Getting the same error constantly on Claude Code with desktop app version: Claude 1.9659.1 (193bcb) 2026-05-28T16:22:15.000Z

jonathan-chamberlin · 1 month ago

I've been running into the same issue.

joelodom · 1 month ago

Same problem. If I decline the use of tools in favor of other approaches it hoses my context.

stevew3000 · 1 month ago

Same 400 on 2.1.153 — reproduces on brand-new, minimal sessions (not just long ones)

Confirming this on Windows via the Claude desktop app. Unlike most reports here that describe long, context-heavy sessions, I'm hitting it on fresh sessions with almost no work done — bricked twice within ~30 minutes, each time after only a few messages.

This started immediately after the desktop app updated today; it has never happened to me before.

Error: API Error: 400 messages.13.content.11: thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.

Claude Code is essentially useless right now.

Environment:

  • Platform: Windows
  • Launched via: Claude desktop app (not the standalone CLI)
  • Claude Desktop version: 2.1.123
  • Model in use when it broke: Opus 4.8, Opus 4.7

Repro:

  1. Start a brand-new session.
  2. Send a couple of prompts that trigger thinking + tool use.
  3. By the 2nd–3rd turn every request fails with the 400 above. Retrying does nothing,

restarting the app does nothing — the session is permanently bricked.

Frequency / severity: Twice in ~30 minutes on separate new sessions. Effectively
unusable for coding until worked around.

Workaround that fully stops it for me: Disabling extended thinking via settings.json
(MAX_THINKING_TOKENS: 0 + CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: 1) and using Sonnet 4.6 model.

Consistent with the empty-thinking-text-but-retained-signature root cause described in this issue — with no thinking blocks generated, the 400 never appears.

Note: this reproduces in the desktop app, where there's no /feedback command or visible version/About screen to attach the usual IDs from.

tom-murray-dev · 1 month ago

Triggers Identified

I've stopped using both auto-mode and /goal, and Claude seems to be functioning. Using one (or both) of them appears to make this a lot worse.

This is also with the DISABLE_INTERLEAVED_THINKING=1 fix

SynVisions · 1 month ago

This is failing for me with essentially the same error without even using continue / resume.

shawnpetros · 1 month ago

<!-- internal -->
Adding two findings from a separate investigation (filed as #63394, which I'm closing as a duplicate of this) that I don't think are captured here yet.

1. Not version-locked. This reproduces across 2.1.153 (this issue, and #63143), and is confirmed still present on 2.1.156. It is not a single-version regression — the empty-text-with-signature persistence is the standing behavior; what varies is whether a given session hits a reconstruction path that re-sends a poisoned block.

2. Why some sessions survive the 400 and others wedge permanently — it's in-memory vs. disk. This issue describes the permanent state, but the same error is often transient and self-recovers, which is worth capturing because it points straight at the mechanism:

  • A continuously-live process still holds the original assistant message objects in memory with full thinking text. Any turn — including a normal user message — rebuilds the request from that in-memory copy, the signature validates, and the request succeeds. The 400 may flicker but recovers.
  • A disk reload--resume, a scheduled wakeup, a backgrounded tool/subagent completing, or a remote/bridge re-drive — replaces that in-memory copy with the stripped on-disk version (thinking:"" + signature). The only un-stripped copy is now gone. From this point the session is permanently poisoned, and no later turn can recover it, including a fresh human message, because there is nothing valid left to reconstruct from.

That last point explains a confusing symptom: people report that sending a new message to a wedged session doesn't help. It can't — by the time the message arrives, the disk reload has already discarded the good copy. It also means transcript surgery (truncating to a record with no trailing thinking block and resuming) only buys a single turn, since --resume is itself a disk reload.

Triggers observed, beyond the AskUserQuestion-cancel (#63143) and run_in_background (#63393) paths already noted: remote control / the cloud bridge (each remote-driven turn reconstructs from disk), and ScheduleWakeup / scheduled wakeups firing while a turn's latest assistant message contains a thinking block. The common factor across all of them is a history reconstruction from the stripped-on-disk transcript with no live in-memory copy to fall back on.

yurukusa · 1 month ago

Your root-cause analysis (transcript persists thinking text as "" while retaining the original signature → API rejects on resume) is the central pin for this cluster. I tracked the same 400 across 15+ open issues filed in the last 36 hours (this issue plus #63143, #63192, #63335, and 11 duplicate-flagged reports), and they sort cleanly into four sub-patterns with different triggers but the same wedged-session shape:

  • 13A — Resume serialization corruption (this issue) — your evidence (zero-length thinking + non-zero signature in the broken transcript) is the canonical signal.
  • 13B — Cancel-during-AskUserQuestion poisoning (#63143) — Escape on AskUserQuestion during extended thinking, compounded by concurrent AskUserQuestion prompts overwriting each other on screen.
  • 13C — Cancel-during-parallel-tool-batch corruption (#63192) — one tool errors, the rest auto-cancel, the in-flight assistant message's thinking blocks corrupt.
  • 13D — Intermittent signed-thinking-block replay (#63335 plus ~10 duplicate-flagged) — fires without 13A/B/C trigger, reproduces across v2.1.143–v2.1.154.

I wrote up the full sub-pattern map, identification path, recovery-per-sub-pattern, and the four advisory hooks I have in design for cc-safe-setup (a SessionStart resume-warning, a PreToolUse AskUserQuestion warning, a PreToolUse parallel-batch warning, and a PostToolUse transcript-state detector that fires before the next resume would hit the 400) here:

Extended-Thinking Session Wedging — A 36-Hour Surge with 4 Sub-Patterns and Operator-Side Recovery Paths

The recovery surface is structurally narrower than most earlier clusters in the cc-safe-setup catalog — the serialization that produces 13A happens inside the transcript writer, and the cancel-to-corruption transitions for 13B and 13C happen inside the streaming-response handler. No hook surface sees those moments directly. The hooks I'm planning are advisory-only — they warn before the operator does the action most likely to trigger the failure, rather than blocking the failure itself. Until the transcript serializer is fixed upstream (which is the real fix for 13A), the operator-side ceiling is "don't resume sessions that used extended thinking" and "save state to disk frequently."

I'll update the gist with shipped-hook references as the four advisory hooks ship into cc-safe-setup over the coming days, and the cluster tracker entry (Cluster 13) will go live there at that point as well.

beemusicco · 1 month ago

Follow-up on the "irrecoverable / only /exit or /clear" framing for 13A: in our setup the wedged session was recoverable without losing context, by editing the transcript instead of trying to reconstruct the lost thinking text.

Key observation: the API only rejects thinking blocks that are present but altered in the latest assistant message. It does not require thinking blocks to be present on historical assistant turns. So rather than reconstruct the emptied thinking text (impossible — it's gone from disk), just remove the thinking/redacted_thinking blocks entirely:

  1. Back up ~/.claude/projects/<slug>/<id>.jsonl.
  2. Strip every thinking/redacted_thinking block from assistant messages (they're empty-text on disk, so no information is actually lost).
  3. Truncate the trailing wedged turn + the repeated 400 error entries.
  4. Re-balance so every tool_use still has a matching tool_result (else resume 400s on the orphaned tool_use_id).
  5. claude --resume <id> — full history intact minus the one broken turn.

This un-wedges and preserves the work. Caveat: it's a temporary un-wedge — with extended thinking still on, the next long multi-step turn re-wedges identically. Pair it with prevention (CLAUDE_CODE_DISABLE_THINKING=1, from my earlier comment) for durability, or automate the repair (we run it from a watchdog that triggers on the wedge signature).

On detection: the per-block thinking:length==0, signature:length>0 test matches every thinking block on disk (that's the normal persisted format), so it can't by itself tell a wedged session from a healthy one. The unambiguous "wedged now" signal is the latest assistant entry being the 400 ... cannot be modified error text itself — that distinguishes an actually-stuck session from the benign empty-text+signature blocks present throughout every transcript.

LMS927369 · 1 month ago

Reproduced on Claude Code 2.1.154 / macOS (one patch newer than the 2.1.153 in the report) — confirming this is still live, not fixed. Same root cause you identified: latest assistant message was a standalone thinking block persisted as thinking:"" + signature (720-char sig), and all 12 thinking blocks in the transcript are stored empty-text-with-signature.

New amplification vector not covered above: /loop (and by extension headless/autonomous resume) turns this from a one-time resume failure into an unrecoverable infinite loop.

In my case the session was running under /loop. Sequence from the transcript:

  • tool_usetool_result → a standalone thinking block became the latest assistant message
  • next /loop-queued continuation replayed it → 400 messages.5.content.10: 'thinking' or 'redacted_thinking' blocks in the latest assistant message cannot be modified
  • /loop auto-fired again → identical 400 → repeat

Result: 4 byte-identical 400s over 21 minutes (05:22:12Z → 05:43:31Z), fully unattended, each burning a model request. An in-session restart did not help (consistent with the poison living in the persisted transcript). Under /loop/cron/headless there's no human to break the cycle, so it silently consumes quota until noticed.

Endorsing suggested fixes #2/#3 (drop unpreserved prior-turn thinking blocks on rebuild, or defensively strip empty-text+signature blocks before sending). Additional guard specific to this vector: /loop should abort on N consecutive identical API errors rather than re-firing into the same poisoned state.

cnighswonger · 1 month ago

Pulled the relevant logic out of the 2.1.148 binary to pin down two things that have stayed ambiguous in this thread — which thinking env vars actually take effect, and why some events permanently wedge a session while others don't.

Env vars that actually stop thinking (so the empty-signed blocks never get generated in the first place):

  • CLAUDE_CODE_DISABLE_THINKING=1yes. Thinking is gated on it directly (enabled ≈ type!=="disabled" && !DISABLE_THINKING).
  • MAX_THINKING_TOKENS=0yes. The enable check is parseInt(MAX_THINKING_TOKENS,10) > 0, so 0 disables.
  • DISABLE_INTERLEAVED_THINKING=1no. It only drops the interleaved-thinking beta from the request; thinking blocks still generate (matches what @beemusicco found empirically).
  • CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1no. Only affects adaptive effort on 4.6-class models.

So the only env levers that prevent the wedge also turn off reasoning entirely.

Why some events wedge and others don't — this matches @shawnpetros's in-memory-vs-disk read exactly. The failure is on the paths that reconstruct message history from the on-disk transcript (the stripped thinking:"" + signature copy): --resume, remote-control / cloud-bridge re-drive, scheduled wakeups, and backgrounded subagent completion. A normal in-session turn — including a recurring in-session timer/keepalive — stays on the in-memory copy (which still holds the real thinking text), so it doesn't wedge. Net: a continuously-live session is safe; the trigger is specifically a reload-from-disk, not session age or activity.

(Source: symbols/logic from the 2.1.148 linux-x64 build.)

cnighswonger · 1 month ago

We shipped v3.8.0 of our proxy today as a response to this, with the caveat up front that the actual mitigation is opt-in and off by default — it's a request-body change and we haven't yet proven it prevents a real wedge, so we're not turning it on for anyone automatically. Sharing in case it's useful to test or to crib from.

Two parts:

  • An early-warning (on by default, read-only) that watches a session's context size + thinking-block count and warns before it reaches the danger zone, so you can retire a long session deliberately instead of having it die mid-turn.
  • An opt-in request-path step (CACHE_FIX_THINKING_SANITIZE=on) that removes the omitted thinking blocks (thinking:"" + intact signature) before the request is forwarded. The API rejects thinking that's present-but-altered in the latest message, but removing it is accepted and historical turns don't require it — so we drop it from prior turns and from the latest completed turn. The one case we deliberately leave alone is when the latest turn ended mid-tool-use (a tool_use answered by a following tool_result of the same id): the API needs that turn's thinking intact and the text is already gone, so there's no safe proxy fix there.

Status is honest — it operates correctly in our own sessions, but we have not yet caught it preventing a wedge in the wild. That's exactly why the mutator is opt-in. If you try it, I'd genuinely like to hear whether it holds.

Release/repo: https://github.com/cnighswonger/claude-code-cache-fix/releases/tag/v3.8.0npm install -g claude-code-cache-fix (the early-warning is on by default and read-only; the CACHE_FIX_THINKING_SANITIZE=on mutator stays off unless you opt in).

(Env levers, unchanged from my earlier note: CLAUDE_CODE_DISABLE_THINKING=1 / MAX_THINKING_TOKENS=0 stop the wedge but disable reasoning entirely; DISABLE_INTERLEAVED_THINKING=1 does not stop it.)

Hillier98 · 1 month ago

Hi, I'm running Claude from the MacBook app, which is proposing to update from Claude 1.9659.1 (193bcb) to v1.9659.2. Does anyone know if that's an update that will solve this issue? I wouldn't want to update with the risk of introducing other bugs otherwise.

andrewallison2012 · 1 month ago

Confirming on Linux, Opus 4.8, v2.1.156

Same thinking/redacted_thinking 400 error, with a few details that may help triage:

Environment
| Field | Value |
|-------|-------|
| OS | Linux (Fedora) |
| Claude Code | 2.1.156 |
| Model | Opus 4.8 |
| Auth | Max subscription, extended thinking enabled |

What happens

  • Brand new sessions crash with: API Error: 400 messages.N.content.M: thinking or redacted_thinking blocks in the latest assistant message cannot be modified.
  • This occurs in live, active sessions, not only on resume/continue. Sometimes within the first ~10 minutes, sometimes deep into a session.
  • Once it triggers, the in-place retry keeps returning the same 400.

Notes that may be useful

  • Not macOS-specific. Reproduces on Linux, so the platform:macos label is misleading.
  • Still happening on 2.1.156, which is newer than the version in the linked reports (2.1.153).
  • Lines up with the Opus 4.8 rollout. Started for me in the last few days.
  • One partial recovery path: /rewind then Restore conversation (not "Restore code") got me past the corrupted turn without losing files.

Filed via in-app /bug. Feedback ID: 9673b7d8-2deb-454f-b3c3-f38f3ddb7f52

LiSong0214 · 1 month ago

Hit the exact same issue on macOS desktop Claude Code (Opus 4.7/4.8 with Medium effort, long conversation accumulating tool calls). After ~30 turns the session became permanently 400-rejected on every subsequent message.
Confirmed root cause matches the report: inspecting the session JSONL showed 313/322 thinking blocks had thinking: "" but a retained signature field.
Worked around it by stripping all thinking and redacted_thinking blocks from prior assistant messages in the JSONL (and dropping ~319 lines whose only content was a thinking block). Session resumed cleanly afterwards.
Would be great to see either of the suggested fixes (persist signed thinking text in full, or drop thinking blocks during request build when text is empty) land soon — this currently makes long extended-thinking sessions a one-way trip to a broken state.

cnighswonger · 1 month ago

This matches what we've been seeing exactly — the empty-text-but-retained-signature shape (your 313/322) is the fingerprint, and the manual strip you did (drop prior-turn thinking/redacted_thinking, remove lines that were thinking-only) is precisely the fix you're asking for.

We actually shipped that as an opt-in request-path step in v3.8.0 of our proxy earlier today (CACHE_FIX_THINKING_SANITIZE=on) — it does the empty-text drop automatically on every request so you don't have to keep editing the JSONL by hand. One scope caveat that's worth knowing from your "~30 turns" case: it drops from all prior turns and the latest completed turn, but it deliberately leaves the latest turn alone when it ended mid-tool-use (a tool_use awaiting its tool_result), since the API needs that turn's signed thinking intact and the text is already gone. So it covers the replay/resume re-wedge well, but a brand-new session that wedges on a pending-tool turn is the case it can't reach.

Agreed the real fix is upstream — persist the signed thinking text, or drop-on-empty during request build so it never desyncs. Until then this at least keeps long extended-thinking sessions from being a one-way trip.

yurukusa · 1 month ago

Following up on my earlier comment (the 13A–13D sub-pattern split). Three significant updates have landed in the thread in the ~17h since, worth synthesizing for anyone still wedged today:

1. Env-var prevention — corrected matrix (credit @cnighswonger's reverse-engineering)

A lot of the workarounds floating around try DISABLE_INTERLEAVED_THINKING=1. Per the 2.1.148 binary disassembly that does not stop the empty-signed blocks from being generated — it only drops the interleaved-thinking beta header from the request. The two env vars that actually gate generation:

| Var | Stops generation of signed-empty blocks? |
|---|---|
| CLAUDE_CODE_DISABLE_THINKING=1 | Yes — gated directly (type !== "disabled" && !DISABLE_THINKING) |
| MAX_THINKING_TOKENS=0 | Yes — enable check is parseInt(MAX_THINKING_TOKENS, 10) > 0 |
| DISABLE_INTERLEAVED_THINKING=1 | No — drops beta header only, blocks still generate |

If you're hitting this repeatedly and don't strictly need thinking for the in-flight task, the first two are the cleanest in-session prevention.

2. New amplification vector: /loop and headless autonomous resume (credit @LMS927369)

For anyone running Claude Code under /loop (or any scheduled/autonomous re-entry path), this matters: a one-time 400 becomes an unrecoverable infinite loop. The sequence: tool_use → tool_result → standalone thinking block becomes latest assistant message → next /loop continuation replays it → 400 → loop retries → 400 → … Self-healing paths that exist for interactive use (operator hits /clear or strips JSONL) don't fire under autonomous resume. If you're running long-horizon agents, gate the loop on a guard that detects the 400 fingerprint (messages.N.content.M: \thinking\ ... cannot be modified) and bails the loop instead of replaying the wedged turn.

3. Recovery: in-place JSONL strip preserves context (credit @beemusicco, @ductai199x, @eslerm, @LiSong0214)

Multiple independent confirmations that the wedged session is recoverable without context loss by editing the transcript instead of /clear-ing. The key insight: the API only validates thinking blocks in the latest assistant message; it does not require thinking on historical assistant turns. So removing rather than reconstructing is safe:

  1. Back up ~/.claude/projects/<slug>/<id>.jsonl.
  2. Strip every thinking / redacted_thinking block from assistant content arrays in-place. Don't delete rows — leave every uuid / parentUuid chain intact. For rows whose only content was a thinking block, replace with {"type":"text","text":"[thinking]"} placeholder so content arrays aren't empty.
  3. Resume via --resume.

@LiSong0214's case (313/322 thinking blocks empty + retained signatures, recovered cleanly) and @eslerm's (275 thinking-only rows out of 2213) are the larger-scale confirmations. Note @Hillier98's partial-recovery report — the strip fixes the immediate wedge but doesn't prevent the next one, so you still need (1) in place to stop re-poisoning.

4. Monitoring + opt-in auto-fix (credit @cnighswonger's proxy v3.8.0)

Two parts, both useful even if you don't adopt the proxy long-term:

  • Early-warning (on by default, read-only): watches context size + thinking-block count, warns before the danger zone. Lets you retire a long session deliberately instead of having it die mid-turn.
  • Opt-in CACHE_FIX_THINKING_SANITIZE=on: strips the omitted blocks (thinking: "" + intact signature) from request bodies before forwarding. Same logical operation as the manual JSONL strip, but applied per-request so you don't have to keep editing files. Caveat per @cnighswonger: it deliberately leaves the latest turn alone if it ended mid-tool to avoid corrupting an in-flight repair.

Pulling these together — what to do today depending on your situation:

| Situation | First action |
|---|---|
| Session currently wedged, want to keep context | Stop the client, JSONL strip (item 3), resume |
| Session currently wedged, willing to lose context | /clear — the original recovery path |
| Long sessions in active use, want to prevent recurrence | CLAUDE_CODE_DISABLE_THINKING=1 or MAX_THINKING_TOKENS=0 (item 1) |
| Running /loop or headless agents | Add a guard that bails the loop on the 400 fingerprint (item 2), do not auto-retry |
| Long-term fleet operator | Proxy v3.8.0 with monitor on, CACHE_FIX_THINKING_SANITIZE opt-in after you've validated on a non-critical session (item 4) |

On version-pinning as a workaround: @shawnpetros's data point that this reproduces on 2.1.156 and @mieubrisse's that 2.1.145 already had it confirm this isn't a single-version regression — pinning helps for some sub-patterns but isn't a guaranteed escape hatch, because the empty-text-with-signature persistence is the standing on-disk behavior across versions. The variation between versions is which reconstruction paths get exercised, not whether the data is poisoned to begin with.

Hope this saves people some time triaging.

yurukusa · 1 month ago

Follow-up to my earlier comment 12h ago (13A–13D sub-patterns + the v3.8.0 / env-var / manual-strip synthesis). Three new sub-patterns have emerged in the intervening reports that don't fit cleanly under any of 13A–13D and that I think are worth pulling out so they don't get lost in the duplicate-flood (≈30 new "thinking blocks cannot be modified" issues filed since yesterday, ≈180 since 2026-05-01).

Three new sub-patterns to add to the 13A–13D split

13E — Dynamic tool loading (ToolSearch) re-modifies signed thinking blocks every turn

#63792 (Opus 4.8 + ToolSearch) reports a strip-and-retry 400 storm on every turn, not a session-wedge. When the dynamic tool catalog (ToolSearch) loads a new tool mid-conversation, the prior assistant message's system is recomputed to reflect the new tools list, and the change to the tools surface invalidates the signature of every prior signed thinking block (because the signature is computed over the inputs that were present at thinking time, including the tools surface). Each turn → 400 → harness strips and retries → succeeds → next turn → 400 again.

This is distinct from 13A–D because:

  • It is not session-wedging (the strip-and-retry path works, so each turn eventually completes).
  • The trigger is tools-list mutation between turns, not transcript-store-empty-text.
  • The cost is latency + token waste, not unrecoverable poison.

The mitigation here is orthogonal to 13A–D: it has to come from the harness deciding to either (a) pin the tools surface for the duration of a conversation that has produced signed thinking, or (b) strip-on-write instead of strip-on-retry. cnighswonger's proxy v3.8.0 strip-on-write mode would handle this case as a side-effect, but the proxy doesn't see ToolSearch decisions, so the harness still has to do something.

13F — v2.1.154 context-ops (compact / clear / model-switch) emit an invalid message shape

#63396 is the clearest report: after any of /compact, /clear, or a model switch on 2.1.154, the CLI rebuilds the messages array and places the system role at messages[0] instead of as a top-level system field. Same request also carries modified signed thinking blocks (the empty-text+signature variant). The API now rejects both — the system-role-at-messages[0] error is new, and it independently 400s before the thinking-block check even runs.

This matters because:

  • It is 2.1.154-specific (2.1.153 does not emit system-at-messages[0]).
  • It explains why a non-trivial number of comments here describe sessions wedging without an explicit resume or interruption: a casual /compact (often automatic post-context-pressure) puts the session into the same trap.
  • It overlaps with — but is not the same as — the cluster-16 candidate system role at messages[0] regression I tracked separately on cc-safe-setup; the 2.1.154 context-ops path is what unifies the two threads.

13G — Opus 4.8 is structurally not wire-compatible with prior-turn Opus 4.7 thinking blocks

Reports filed in the last 12–18h consistently show that mid-conversation model switches Opus 4.7 → Opus 4.8 produce a hard 400 that no strip-on-retry recovers: #63607, #63606, #63612, #63412. The error text now reads thinking blocks must remain as original response — distinct phrasing from the 13A cannot be modified text, which suggests a different validation path on the server side. The fingerprint is that the offending block was emitted by 4.7 with a signature computed against the 4.7 reasoning state, and 4.8's resume path apparently produces a stricter validation that rejects it even when the text is preserved verbatim.

darkguy2008 upthread documented the same — going back to Opus 4.7 and 2.1.152 is the only fully restoring move; downgrading just the model is not enough on 2.1.154 because 13F kicks in.

This is the sub-pattern I'd flag to anyone hitting this on Opus 4.8 for the first time today: the model switch itself is the trigger, not extended thinking per se. Disabling thinking on 4.8 is a half-fix; the only known full fix is staying on 4.7 + 2.1.152.

What's not a new sub-pattern (worth eliminating)

For the duplicate-floor: I've checked the 30 new filings against 13A–G and all of them collapse into one of these seven. The most common is 13A (transcript-stored empty-signed blocks → resume → wedge), followed by 13C (parallel-tool-call interrupt → wedge), then 13G (4.8 model switch). 13E and 13F are individually low-volume but worth pulling out because they have different mitigations than 13A–D.

Practical implication for anyone wedged right now

Three escalating workarounds, in order of cost:

  1. cnighswonger's strip-on-write proxy (v3.8.0, opt-in) handles 13A, 13B, 13C, 13E, and partially 13G. Not 13F.
  2. beemusicco's manual transcript strip (drop prior-turn thinking / redacted_thinking, remove thinking-only lines) handles 13A, 13B, 13C, 13D. Not 13E (the next turn re-poisons), not 13F (rebuild path still emits system-at-messages[0]), not 13G (signature itself is incompatible).
  3. Stay on 2.1.152 + Opus 4.7 + DISABLE_INTERLEAVED_THINKING=1 handles all seven. This is the only known full fix today.

The Anthropic-side fixes that would unblock the cluster are, in rough priority order:

  • Stop persisting signed empty-text thinking blocks (fixes 13A directly, 13B–D structurally).
  • Strip prior-turn signed thinking on the rebuild path before /compact, /clear, model-switch, and tools-list mutation (fixes 13E + 13F).
  • Make Opus 4.8 backward-validate 4.7-signed thinking, or stop attempting to validate mid-conversation model-swap blocks at all (fixes 13G).

Logging the seven sub-patterns publicly so triagers and the duplicate-detector bot have a stable taxonomy to work against. I'm tracking the open issue list against this split in my pain-research notes and will keep this comment updated as the picture changes.

cnighswonger · 1 month ago

Appreciate the consolidated taxonomy — having 13A–G as a stable reference makes it a lot easier to know which mitigation to point someone at, and the dup-detection bot a fighting chance.

Two pieces of first-party context to add, both small refinements:

On 13A — a clean death-spiral timeline from a real session. A ~382K-token / 7-week Opus-4.7-[1m] session of ours (b16c607d, retired 2026-05-28) tolerated isolated wedges for weeks — 561, 4592, 2602, 3758 successful assistant turns after a 400 — and then on 2026-05-28 12:04 UTC tipped into the textbook 13A death-spiral: 25 wedges in 2.5 hours, with each /exit + --resume buying a non-deterministic 1–48 turns before re-wedging on the next interleaved-thinking-with-tools turn. Resume isn't binary safe/unsafe — it's shape-dependent on the latest assistant turn the reload lands on. Useful if anyone wants a concrete "what does the tipping point look like" reference; happy to share the marker log.

On 13E — small correction to the v3.8.0 coverage claim. Thanks for the credit, but to keep the matrix accurate: v3.8.0 strips only thinking:"" blocks (the empty-text-with-signature shape). On the ToolSearch-invalidation case the prior-turn thinking is non-empty — it has the real reasoning text plus a now-invalid signature — so our filter leaves it alone by design. We'd only catch a 13E request that had also been disk-reloaded into the omitted shape. So 13E is more accurately "not covered by v3.8.0 today" rather than "covered as a side-effect." Pinning the tools surface across signed-thinking turns at the harness layer is the right place; a strip-on-write that removed prior-turn thinking with mismatched-signature (not empty-text) could cover it from the proxy side, but that's a v3.9 conversation.

Everything else in the matrix matches our read — 13F (system-role-at-messages[0]) is genuinely out of scope for our extension (it never touches the system field or messages[0] shape), and 13G is upstream-only. The "stay on 2.1.152 + Opus 4.7" full-fix recommendation is consistent with everything we've seen on our side.

All-The-New · 1 month ago

Adding a Stop-hook trigger path that I don't think is captured in the existing repros above. Same root cause (JSONL persists thinking: "" with signature retained), different surface: a custom hooks.Stop entry that emits {decision: "block", reason: "..."}.

The chain:

  1. Custom Stop hook returns {decision: "block"} based on content of the just-generated assistant message.
  2. Claude Code continues the same turn — re-submits the message array (including the existing assistant message + its thinking blocks) to the API.
  3. The re-submitted array is reconstructed from the on-disk JSONL transcript, which by then has {thinking: "", signature: <orig>} for the affected block.
  4. API rejects with 400 messages.X.content.Y: 'thinking' or 'redacted_thinking' blocks in the latest assistant message cannot be modified.

Reliable repro shape:

  • Extended thinking enabled (Opus, default thinking mode).
  • A hooks.Stop entry that returns {decision: "block"} conditionally on the content of the last assistant message. In our case: a guard that enforces UI-style option-asking — fires when the orchestrator's message presents an option list with decision-framing words but no AskUserQuestion tool call.
  • A prompt that produces output matching the guard's fire conditions. For us: a prompt containing the literal framing "Decide whether to (a)... or (b)..." and a sensitive-path scope that nudges the assistant to use extended thinking + parallel tool calls.
  • Error reliably surfaces on the first turn that fires the guard.

Note for the team: An earlier comment in this thread ruled out hooks ("Stop hooks fired but never set preventedContinuation"). That observation is correct for informational Stop hooks, but decision: block Stop hooks DO trigger a continuation API call, which exposes the same JSONL serialization path that --resume/auto-compact hit. Might be worth adding to the canonical repro list.

Workaround we shipped: disabled the Stop hook entry in our global settings; the script itself stays in place for re-enablement once a fix lands. Posting this for posterity / future debuggers hitting the same trigger.

yurukusa · 1 month ago

@All-The-New — thanks for naming this surface explicitly. You're right that the earlier "Stop hooks fired but never set preventedContinuation" observation was about informational Stop hooks, and that decision: "block" Stop hooks reach a different code path that re-enters the API call.

This is a fifth sub-pattern of Cluster 13 that I didn't have. Adding it to the framework as:

13E — Stop-hook-block continuation corruption. Custom hooks.Stop with {decision: "block"} → Claude Code continues the same turn → re-submits the message array reconstructed from the on-disk transcript → the on-disk thinking blocks (thinking: "" + retained signature) get replayed → 400.

Three things that make 13E worth pinning separately from 13A/B/C/D:

  1. The trigger surface is operator-authored, not Anthropic-authored. 13A/B/C/D all surface through stock Claude Code flows (resume, AskUserQuestion cancel, parallel-batch cancel, intermittent). 13E surfaces because the operator wrote a decision: "block" hook — meaning operators who write more elaborate guard hooks are more likely to wedge their sessions, not less. The polarity is unusual.
  1. The repro is more deterministic than 13D. Your "reliably surfaces on the first turn that fires the guard" beats 13D's intermittency. If an operator wants to reproduce the cluster cleanly to test a transcript repair tool (miteshashar/claude-code-thinking-blocks-fix being the current canonical), 13E is now the easiest path.
  1. Your workaround (disable the Stop hook entry, keep the script) is the right operator-side response but it carries a real cost — the hook existed because it was preventing a different class of mistake (UI-style option-asking without AskUserQuestion). Disabling it leaves that gap open. The two failure modes (the one the guard catches, and the one the guard triggers) are now in tension until the upstream serializer is fixed.

I'll fold 13E into the cluster framing in my next docs commit on yurukusa/cc-safe-setup and into the field guide Gist (currently 4 sub-patterns; updating to 5). The hook surface for 13E is PreToolUse on Stop-hook registration — an advisory that warns when a user installs a decision: "block" Stop hook while extended thinking is enabled, naming this sub-pattern. Targeting that for the same June 2026 ship batch as the 13B advisory.

Posting the trigger separately because, as you say, an operator hitting this without the framing will spend significant time chasing a phantom: their hook fires, the API rejects, and the natural debug path is the hook itself rather than the underlying serialization. Your "for posterity / future debuggers" framing is exactly right.

cnighswonger · 1 month ago

Thanks for documenting this — and the decision: "block" vs informational Stop-hook distinction is a clean correction; the earlier "hooks ruled out" framing was missing the same-turn continuation path.

The chain you describe (Stop-hook block → continuation API call → rebuild from on-disk JSONL → empty-text-with-signature blocks → 400) is mechanically identical to the resume/compact paths — same root cause, fresh trigger surface. yurukusa has folded it in as the new 13E in the cluster framework.

Concretely: our thinking-block-sanitize extension in proxy v3.8.0 (CACHE_FIX_THINKING_SANITIZE=on) should cover this — the continuation rebuild produces exactly the omitted-text-with-signature shape the extension strips on the way out. If you'd be willing to re-enable your Stop-hook entry with the flag on and let us know whether it stays clean, that would be useful to us in a way I want to flag honestly: we shipped this opt-in two days ago and have not yet caught it preventing a wedge in the wild (which is exactly why it's opt-in). Your repro is the most clean-room one I've seen on this thread — you'd be the first real-world data point either way. If success, we mark efficacy validated; if not, you've given us a coverage gap we need to find.

Either way, the documentation of block-decision-as-continuation-trigger is a contribution that stands on its own — thank you for posting.

kennethkhoocy · 1 month ago

Still reproduces on Claude Code 2.1.157 (Windows 11, claude-opus-4-8[1m], effort max) — the release right after 2.1.156's "Fixed an issue when using Opus 4.8 where thinking blocks were modified" entry, so that fix doesn't close this path. Extends the "still present on 2.1.156" note above by one release. On-disk fingerprint is exactly as the issue describes: every interleaved thinking block is persisted as its own assistant-message line with thinking: "" + a retained signature — here 421 of them across ~1,575 assistant messages in a ~13 MB transcript.

Three points that cover a recovery path several people here tried and found only partially works:

**1. The request that 400s is a short reconstruction, not the live turn.** First wedge was messages.19.content.12; after a /rewind + retry it moved to messages.3.content.19. An index that low on a 1,500+-message transcript means the failing request is a short, front-loaded assembly — auto-compaction (or a subagent) replaying a poisoned interior thinking block from history. Whichever signed-but-empty block lands as the "latest assistant message" in that short request is the one that fails, and it changes between attempts. (Consistent with the reload-from-disk trigger analysis above.)

2. /rewind does not recover it — and structurally can't. The session .jsonl is an append-only event log linked by parentUuid; /rewind creates a new branch leaf but does not prune the poisoned ancestor lines (the file actually grew ~12.5 MB → ~13.0 MB across one rewind + retry). The next compaction re-walks a branch whose ancestors still hold the empty-text-with-signature blocks, so it re-wedges. This is also why the single-block manual edit reported earlier resumes once and then dies again: fixing only the block named in the error leaves the other 400+ in place for the next reconstruction to hit.

3. A complete recovery that held. Strip all signed thinking blocks, not just the one in the error. Because each block is its own assistant-message line, that means deleting those lines and relinking each orphaned parentUuid to its first surviving ancestor (blanking content in place would leave empty-content messages). After removing all 421 lines and relinking 419 pointers — verified 0 residual signed thinking blocks, 0 parentUuid references to deleted nodes, valid JSON throughout — --resume loaded cleanly and the 400 was gone. Full strip+relink script available if it'd help.

(Correcting an earlier version of this comment: I'd written that DISABLE_INTERLEAVED_THINKING avoids the trigger — that is wrong, and the 2.1.148 binary analysis above already explains why: it only drops the interleaved beta while thinking still generates and persists as thinking:"" + signature. The only env levers that actually prevent the wedge, CLAUDE_CODE_DISABLE_THINKING=1 / MAX_THINKING_TOKENS=0, disable reasoning entirely. The request-path sanitizer approach above looks like the only "keep reasoning + avoid wedge" option short of an upstream fix.)

Hillier98 · 1 month ago

@kennethkhoocy Thanks for your summary. I tried several things in this thread but the bug is still there for me. I'm using the Claude app on a MacBook if that helps with context. Could you please share your scripts for a "complete recovery"? Maybe I haven't disabled all the right options, not sure (I wouldn't want to disable thinking, though).

cicibre · 1 month ago

Confirming this on Claude Code 2.1.153 — we hit this bug 4 times in a single day (2026-05-29) across 4 distinct Opus extended-thinking sessions doing productive multi-step work (long reasoning + tool-use chains). Exact error string matches verbatim:

messages.<N>.content.<M>: "thinking" or "redacted_thinking" blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.

Mix of triggers: at least 3 auto-compaction (mid-reflection / mid-tool-chain), and 1 during a /hygiene distillation run as context approached the threshold. As described in this issue: every wedged session was unrecoverable in-session — only /clear (lose live state, on-disk artifacts safe) or double-Esc rewind worked.

Interim mitigation we deployed today (2026-05-30), in case it helps others reading: a PreCompact hook with matcher: "auto" returning {"decision":"block","reason":"..."}. The block message points operators to /clear or rewind and links back here. Manual /compact is left permitted (operator agency on the explicit case). It contains the wedges but obviously doesn't fix root cause — would be glad to retire the hook the moment an upstream fix lands.

Happy to share transcript excerpts (we exported each session before clearing) if it would help triage. Thanks for the detailed root-cause writeup in this issue — it was decisive in our diagnosis (the cleared-text-with-retained-signature mechanism explained exactly what we were seeing).

dliedke · 1 month ago

@claude, this is sev1, it is not possible to use Claude Code anymore. Temporary switching to Codex now.

cnighswonger · 1 month ago

@cicibre Useful data, thank you — and the PreCompact hook with matcher:"auto" + decision:"block" is a clean operator-side intervention I hadn't seen in the thread yet. It's complementary to the request-path approaches in a real way: yours prevents the wedge by blocking the trigger, ours strips the request body if the trigger fires anyway, and they compose without overlap.

Your 2026-05-29 frequency (4 wedges in one day, mixed mid-reflection auto-compact + /hygiene distillation) lines up with what we've seen — the auto-compaction surface is genuinely high-volume on long extended-thinking sessions, and a matcher:"auto" block targets it precisely without taking away operator-initiated /compact (which our binary trace confirms is the right scope: only the auto path rebuilds from the on-disk transcript in a way that re-exposes the empty-text-with-signature shape; manual /compact is in-memory summarize-and-replace).

For anyone reading and triaging which mitigation to apply: the picture is becoming three-layer.

  1. Prevent the trigger (operator side, this thread): cicibre's PreCompact hook is the cleanest example. Pair with SessionStart/advisory hooks per yurukusa's framework for --resume and the other reload-paths.
  2. Strip the request body if the trigger fires (proxy side): our v3.8.0 thinking-block-sanitize (opt-in) drops the omitted prior-turn thinking before the request leaves the machine. Composes cleanly with (1) — if the hook catches it, ours never runs; if a path slips past the hook, ours backstops.
  3. Repair the transcript after the fact (recovery side): kennethkhoocy's full strip+relink, miteshashar's tool, beemusicco's manual approach.

On the transcript excerpts — yes, that would be useful. The data point I'd most like to see (if it's in your captures) is the content-block index of the failing entry at the moment of the 400 — high vs low. kennethkhoocy's messages.19.content.12 → messages.3.content.19 after rewind suggests the failing request is often a short, front-loaded reconstruction (auto-compact assembling a small summary from history), not a replay of the full live state. If your 4 sessions show a similar pattern, that's strong corroboration of the auto-compaction-as-amplifier read.

cicibre · 1 month ago

@cnighswonger Thanks — the binary trace on auto vs manual /compact is meaningful for us; it independently validated our matcher:"auto" scope decision (I'd reasoned to it from the docs but didn't have the trace), so I'm grateful you wrote that out. The three-layer model (prevent the trigger / strip the request / repair the transcript) is a clean way to organise the mitigation stack — our hook (layer 1) composes with your proxy-side sanitizer (layer 2) without overlap, as you noted.

Data point you asked for — index patterns from 3 wedged sessions. Pulled from the actual API error envelopes (isApiErrorMessage: true lines) in our local Claude Code session JSONLs. The indices are not obviously front-loaded in our captures:

| Session | Failing messages.N.content.M (unique) | JSONL length |
|---|---|---|
| Session A (afternoon, mid-/hygiene) | (67,5), (69,8), (71,13) | 5,356 entries |
| Session B (morning, mid-reflection) | (39,7), (39,13), (41,7) | 3,039 entries |
| Session C (morning, mid-reflection) | (103,10) | 1,823 entries |

Two things worth flagging from the pattern:

  1. The failing msg_N indices are moderate-to-high (39, 67, 103) — not low. So these don't visually match kennethkhoocy's small front-loaded reconstruction (messages.3.content.19). They look more like substantial replays. Caveat: we only captured the post-compaction request shape; we don't have a clean before/after comparison the way kennethkhoocy did, so we can't say definitively that no reconstruction happened — only that the failing requests at error-time were substantial.
  1. **Multiple msg_N values per session, in decreasing order** (A: 71 → 69 → 67; B: 41 → 39). Reads like successive partial rewinds — the operator tried rewinding past the corruption, each rewind reduced the message-count, but the corruption was still inside the now-front-of-history, so the next request failed at a slightly lower index. They eventually /cleared rather than rewind further. This corroborates the "unrecoverable in-session by partial rewind" pattern when the corrupted block sits deep in history rather than near the tail.

So our data points more toward "substantial-replay wedges with corruption deep enough that partial rewind can't escape it" than toward short front-loaded reconstructions — though that's not necessarily inconsistent with the auto-compaction-as-amplifier read (could be that auto-compact reduces by a moderate factor and the corruption simply lives inside the reduced history).

Happy to share a specific transcript if useful for closer analysis (the 3,039-entry session — Session B — is the cleanest contiguous wedge + retry-rewind sequence we have). What's the right channel — email, or DM via GitHub?

cnighswonger · 1 month ago

@cicibre — thanks, that's the most useful in-the-wild dataset we've gotten on this. The decreasing-msg_N sequence (71→69→67, 41→39) is especially valuable: it suggests the invalid state survived the rewind window, or was being reintroduced by the same rebuild path, so successive retries surfaced failures at lower indices until a full reset / /clear appeared to be the escape hatch in those sessions.

Nuance on the front-loaded framing — you're right to push back. What your data clearly shows is that the surfaced failure is not confined to short front-loaded reconstructions: it can appear at moderate/high indices (39/67/103) in the post-compaction payload. kennethkhoocy's messages.3.content.19 was a vivid example but probably not the general shape. The auto-compact-as-amplifier read still seems compatible with this — anything that rebuilds history through a path that preserves signature-but-drops-text can introduce or surface the wedge — but the failure pattern is more general than "front-loaded short rebuild." I'll add a nuance comment to my earlier mechanism post so the thread tracks your data.

Yes, please — the Session B JSONL you described (3,039 entries, wedge + retry-rewind sequence) would be very useful. Email is best: dev@vsits.co. If you can include context on which claude version was in use and what mitigation (or absence) was in place at the time, that's ideal.

— AI Team Lead

menicPL · 1 month ago

Confirming on Claude Code 2.1.153, Linux (Fedora 44), Opus 4.7 Max subscription.

Hit this bug twice on 2026-05-29 in a single long extended-thinking session (multi-step implementation work with tool chains). Exact error, session became unrecoverable. /rewind (double-Esc) was the only escape — partial rewinds first, then had to go back far enough to get past the corrupted block.

Chain worth documenting — after the thinking-block wedge was resolved via /rewind, the session accumulated more context and I attempted /compact. /compact triggered the same 400 immediately. So the wedge can re-surface through the compaction path even after a successful rewind if the on-disk transcript still carries the empty-text/retained-signature blocks. The session ended up requiring /clear after two failed compactions.

Additional observation: on Max plan, the model switch to Opus 1M (for wider context headroom) came with an unexpected prompt about enabling usage credits — the two failure modes (thinking-block wedge + compaction + context-limit pricing gate) all hit within the same working session, which made it impossible to maintain continuity on a productive long-running task.

The PreCompact hook with matcher: "auto" + decision: "block" mentioned by @cicibre is what I'm currently running as a workaround. Confirms it prevents the auto-compact trigger; manual /compact stays permitted. Would be glad to retire it when upstream fix lands.

cicibre · 1 month ago

@menicPL Thanks for the cross-platform confirmation (Linux + your repro chain), and especially for the manual-/compact-post-wedge observation — that's a useful refinement our matcher:"auto" scope didn't account for.

Our hook leaves manual /compact permitted on the assumption that an operator typing it has intentional context — which holds for clean sessions, but your chain shows it explicitly fails when a session has already wedged-and-rewound: the bad thinking blocks persist in the on-disk transcript, and /compact rebuilds from there. So the right discipline for anyone in the same boat is: post-wedge → /clear, not /compact. The hook can't catch that from matcher:"auto" alone.

Glad it's holding for the auto path on your end. Same here — would love to retire it when upstream lands.

josiahbryan · 1 month ago

Same bug multiple times a day, v2.1.154 on Mac (Tahoe 25.4.1) - only started happening with Opus 4.8.

Gents I couldn't quite follow - how is /clear a good workaround? That loses all the context...? Forgive my naive question.

The only way I've been able to "workaround" is to rewind the conversation to last user turn, instruct agent to write a handoff doc, then resume the work in a new session using that handoff doc to "Seed" the work.

yurukusa · 1 month ago

@josiahbryan — Good question, and you're right that /clear loses the context. Here's the staged escape ladder this thread has converged on, ordered from least to most destructive:

  1. /rewind (double-Esc) — your current escape. This is the right first move because it preserves the most context: you're going back to a non-wedged state inside the same session and continuing from there.
  2. PreCompact hook with matcher:"auto" + decision:"block"@cicibre documented this earlier in the thread. The wedge surfaces specifically when auto-compaction reconstructs the transcript with empty thinking blocks plus surviving signatures. Blocking auto-compact while leaving manual /compact permitted gives you both safety and the ability to compact when you genuinely need to. This is the most-leverage operator-side intervention I've seen in the thread.
  3. Manual /compact after the wedge has already surfaced@menicPL's 2026-05-29 observation suggests manual /compact can re-trigger the same wedge if the auto-rebuild path has already corrupted the in-memory transcript. So if you've already hit the wedge, manual /compact is not a recovery, it's a re-trigger. Use /rewind first.
  4. /clear — context loss, exactly your concern. Reserve for when /rewind can't reach a clean state (which is the failure case @cicibre and @cnighswonger traced via the decreasing msg_N sequence 71→69→67, 41→39 — the invalid state survived the rewind window or was being reintroduced by the same rebuild path).

On Opus 4.8 specifically: multiple confirmations across the last 48h on v2.1.154 + Opus 4.8 in this thread. The PreCompact hook should still apply; the failure surface hasn't changed, only the trigger frequency. If you're seeing this multiple times a day, the auto-compact trigger is firing on shorter sessions than before — the hook closes off that path entirely.
If the wedge keeps recurring even with the PreCompact hook installed, you're likely seeing a different rebuild path (not just auto-compact). @cnighswonger's decreasing-msg_N trace points at the rewind reconstruction itself as one such path, which is harder to block from the operator side without an upstream fix to the empty-thinking-block reconstruction logic.

yurukusa · 1 month ago

@jdrolls — your root-cause analysis is exact, and the on-disk evidence (thinking-len = 0, signature-len retained) is the cleanest articulation of why this wedge is unrecoverable. Adding two cross-cluster signals to what is now ~150 aggregate reactions across the cluster 13 eruption.
What started as a single resume-after-extended-thinking failure has emerged as a cluster with at least 12 independent filings in a 72-hour window from 2026-05-28:

  • Lead (this issue): #63147 (40 reactions)
  • Bridge issues that connect to cluster 20 (parallel batch cancellation, articulated 2026-05-31): #63192 (14 reactions), #63199 (15 reactions), #63881 (11 reactions), #63576 (10 reactions)
  • Duplicate-or-related with same 400 signature: #63072, #63078, #63121, #63231, #63239, #63335, #63337, #63341, #63346, #63463

Aggregate reactions across the cluster sit at ~150 as of 2026-05-31 morning JST. This easily clears the 15-reaction promotion threshold for tracked-cluster status, and the eruption pace (12+ filings in 72 hours) suggests the underlying corruption is firing across a wider operator population than the lead-issue reactions alone suggest.
Your reproduction (resume an extended-thinking session) is the cleanest path to the on-disk corruption. There is a second trigger that produces the same on-disk shape ({"type":"thinking","thinking":"","signature":"<retained>"}) without requiring resume — and it is the bridge that explains some of the same-day cluster 13 filings:
Cluster 20 (parallel tool batch cancellation cascade, articulated 2026-05-31 from #64059 / #64052 / #64047) is one such trigger. When the assistant turn emits thinking blocks alongside a parallel batch of tool calls, and any single tool call in the batch fails non-fatally (exit 144 from pkill with nothing to kill, expected 404 from curl, invalid git revision exit 128), the sibling cancellation cascade interrupts the thinking-block lifecycle at the same point where session-resume drops the thinking text. Same corruption signature, no resume required.
The full bridge transcript is documented by @kiki830621 in #63192: a safari-browser Bash tool error cascades the batch → corrupted thinking blocks persist to disk → next turn hits this exact 400, permanently wedged. I posted a detailed cross-cluster reply there (link) with the structural mapping.
Implications:

  • Operators who never resume sessions can still hit this wedge if they use extended thinking + parallel tool calls
  • The fix-paths you proposed are necessary for both triggers — fixing resume alone leaves the cluster 20 composition wedged
  • The 12+ filings in 72 hours likely span both triggers, not just resume

Your three suggested fixes are exactly right and need to land server-side. Until then, four operator-side moves reduce wedge frequency:

  1. /clear is the immediate recovery from an already-wedged session, accepting context loss. The .jsonl edit path is technically possible (re-shape the corrupted block before resume) but error-prone; the conservative path is to discard and restart.
  2. Pre-flight session backup before high-risk turns — keep a snapshot of the active .jsonl before any turn that combines extended thinking + tool calls. Trivial bash: cp ~/.claude/projects/<slug>/<id>.jsonl /tmp/cc-backup-$(date +%s).jsonl. Lets you recover to the pre-corruption state if the wedge fires.
  3. Cap parallel batch size at N=3-5 via CLAUDE.md — reduces cluster 20 cascade frequency, which reduces this wedge's frequency for the cascade-triggered cases. See cluster 20 mitigations in #64047 and the field guide Gist.
  4. Avoid git / curl / pkill in parallel batches when extended thinking is enabled — the three most-cited cluster 20 cascade triggers. Even one mistake there can wedge the session for the rest of the day.

In cc-safe-setup (MIT, ~1,500 unique clones / 14 days), two cluster-20 hooks shipped 2026-05-31 reduce cascade frequency (and therefore wedge frequency for the cascade-triggered cases):

  • parallel-cascade-detector.sh (PR #501) — PostToolUse, surfaces cascade volume signal after it has happened.
  • parallel-batch-size-limiter.sh (PR #503) — PreToolUse, surfaces batch-size signal before any failure can cascade.

No hook prevents the cluster 13 corruption itself — that is the upstream-side fix you proposed. The hooks only reduce the cascade-triggered share of cluster 13 firings.
Your option (a) — "persist the full signed thinking text so the signed block round-trips intact" — fixes both triggers cleanly: resume preserves the round-trip, and cascade-induced corruption also no longer drops the text.
Your option (b) — "drop thinking blocks entirely from reconstructed prior turns" — has the same effect for resume but does not directly address the cascade-induced corruption path (that corruption happens to the current turn's thinking blocks, not a prior turn's).
Your option (c) — "defensive guard: strip empty-text-with-signature thinking blocks before sending" — is the right runtime fallback regardless of which fix lands. It would unwedge already-corrupted sessions on the very next turn, even before option (a) ships.
If I were prioritizing for fastest broad relief, I would push for (c) first (unwedges already-broken sessions immediately, regardless of how they got broken) and (a) second (prevents new wedges).

Filing this report when you did — with the on-disk evidence — gave the cluster the structural anchor it needed. The 40 reactions confirm a large affected population, and the composition with cluster 20 articulated 3 days later doubles the relevance. Your three proposed fix-paths are the right upstream surface; the operator-side mitigations buy time but don't replace the fix.

ialloul · 1 month ago

Independent data point: same 400 reproduces without resume, in headless --print runs (and a version bracket: fails on 2.1.141, clean on 2.1.158)

We hit the identical error class while running Claude Code as a non-interactive backend (the Paperclip claude_local adapter spawns the CLI per task):

400 messages.N.content.M: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.

Our setup differs from this thread's reproductions in a way that may help isolate the cause — there is no --resume/--continue and no transcript-reconstruction involved:

  • Invocation is single-shot headless: claude --print - --output-format stream-json --verbose --max-turns 1000 … reading the prompt from stdin. No --resume, no --continue, no session fork. The first transcript line is a normal user turn, not last-prompt/leafUuid.
  • The 400 therefore occurs within a single multi-turn run, as the CLI accumulates assistant turns (thinking + tool_use) and replays history across tool round-trips — not on a later resume.
  • Crucially, our on-disk transcript stores thinking blocks _cleanly_ — non-empty thinking text and signature present:

``
$ jq -rc 'select(.type=="assistant")|.message.content[]?|select(.type=="thinking")|[(.thinking|length),(.signature|length)]|@tsv' run.jsonl
812 988
...
``
So the empty-text-but-signed-on-disk mechanism described in the OP is not what we observe. In our case the corruption is introduced at request-assembly time, intra-run, not when rebuilding from a poisoned transcript. Same symptom, different trigger path → suggests the underlying request-assembly normalization of the latest assistant turn's thinking block is the common root, independent of resume.

Version bracket (may help bisection):

  • Affected: 2.1.141 — 8 occurrences clustered in a ~49-minute window (11:21–12:10 UTC, 2026-05-31) across 5 concurrent headless agents using extended thinking + tools.
  • Apparent fix: 2.1.158zero recurrences after upgrading the shared binary; same workload, same extended-thinking-on config.

This thread reports 2.1.153 still failing, and #63322 brackets a regression in the 2.1.147–2.1.150 range; our clean result on 2.1.158 suggests a fix (or mitigation) landed in 2.1.154–2.1.158. Sharing in case the intra-run / no-resume reproduction and the 2.1.158 result are useful for confirming the bisect.

cnighswonger · 1 month ago

@ialloul — your version bracket holds up under binary diff. There is a real detector-side mitigation here, narrower than "2.1.158 closes the cluster."

Ran cc-watch (strings + grep on the not-stripped Linux binaries) across 2.1.153 / 2.1.157 / 2.1.158 to find what actually changed. The auto-strip-and-retry codepath has existed since at least 2.1.153 — tengu_thinking_signature_strip_retry telemetry, retry:thinking-signature-strip return code, and the strip-retry function were all present. What was missing in 2.1.153 was the detector that fires it.

By 2.1.157 the detector had been widened (unchanged in 2.1.158):

// v2.1.153 detector
$.includes("thinking block") && ($.includes("cannot be modified") || $.includes("invalid signature"))

// v2.1.157+ detector (unchanged in 2.1.158)
($.includes("thinking block") || $.includes("`thinking`") || $.includes("redacted_thinking"))
  && ($.includes("cannot be modified") || $.includes("invalid signature"))

The Anthropic API error string contains backticks around ` thinking and the literal redacted_thinking` — neither matched the 2.1.153 substring check, so this detector would not have fired on that message even though the codepath existed. 2.1.157 widened the detector to match those substrings; 2.1.158 carries the same logic. This narrows the bracket to after 2.1.153 and no later than 2.1.157.

What this appears to fix: a single intra-turn 400 (your single-shot --print workload) now auto-recovers by stripping signed blocks and retrying. Matches your zero-recurrence result.

What this does NOT address (from what the diff shows):

  1. This diff does not show a preflight or resume-path scrub for persisted thinking:""+signature content, so I would not assume --resume recovery from already-corrupted transcripts.
  2. This also does not prove the producer-side bug is gone; the change I found is detector-side mitigation, and I did not verify here whether the empty-signed shape is still being created/persisted.
  3. I would not generalize this to @yurukusa's cluster-20 cancellation case; it has a different symptom, and I did not inspect that path here.

So: concrete mitigation for the single-turn intra-run case, with the producer-side and resume-path questions still open. Worth knowing the bracket landed before 2.1.157 (refining your "2.1.154-158" call) — and worth still treating already-wedged-on-disk sessions as unrecoverable through the wedged version.

— AI Team Lead

DanielJoyce · 1 month ago

Anthropic really really needs to add a lot more tests around this stuff. Just bug after bug in their caching, tool calling, etc. And with Claude adding tests is basically free.

If they are unwilling or incapable of improving the stability of Claude Code they need to open source it so we can at least add the tests and checks ourselves.

yurukusa · 1 month ago

@DanielJoyce — The frustration lands; this thread alone has surfaced more failure modes than would survive a serious property-test suite. Worth naming concretely what those tests would look like, because the cluster is now articulated tightly enough that the test shape falls out of it — and "here is the exact invariant the missing tests would assert" is a more actionable framing than "they should add more tests."

Per the byte-identity framing in @m13v's reply on #63335 (2026-05-31) — the server signs each thinking block over its emission-time canonical byte sequence, not over the structural content the harness sees. Every Cluster 13 sub-pattern (13A resume serialization, 13B AskUserQuestion cancel, 13C parallel-batch cancel, 13D intermittent SDK-loop, 13H non-interactive Agent SDK / CI) is a different code path forcing parse → struct → re-serialize on the latest assistant turn. The unifying test invariant is:

∀ thinking_block tb received from API:
  ∀ transcript-reconstruction path p (resume, cancel,
                                       parallel-unwind, sdk-request-loop, …):
    bytes(p(tb)) == bytes(tb), keyed by tb.id

A small property-test suite at each code path that asserts this invariant would catch the entire cluster at the layer where each sub-pattern's bytes get drifted, not at the API rejection layer many seconds and one user-visible 400 later. Concretely, the three test surfaces:

  1. Transcript writer + reader (catches 13A): for every assistant turn written to ~/.claude/projects/<slug>/<sid>.jsonl, read back and re-serialize the latest assistant turn; assert byte-identical thinking blocks per id. The empty-text-with-signature persistence shape (@shawnpetros's analysis above) becomes a property violation the test sees immediately, not a 400 the user sees 30 minutes later.
  2. Streaming-response cancel paths (catches 13B, 13C): for each interactive cancel path (AskUserQuestion reject, parallel-batch cascade cancel, single-tool cancel), assert that the in-flight assistant message's thinking blocks survive the cancel byte-for-byte. The cancel target is the tool_use block(s); the adjacent thinking blocks should be opaque pass-through, never re-serialized as a side effect.
  3. SDK request-loop (catches 13D, 13H): the @anthropic-ai/claude-agent-sdk package's typed-model round-trip needs a per-request invariant assertion that every thinking block sent on request N equals (bytes) what was received on response N-1, keyed by id. The 13H non-interactive Agent SDK / CI surface (@tlmader's confirmation) is the cleanest forcing function — deterministic, no interactive operations, any byte drift surfaces immediately.

The fix shape m13v articulated — stash raw block bytes by id, splice back unmodified at request-emission time, never rebuild from parsed fields — also makes the tests trivial to write, because each path becomes a pass-through pipeline rather than a parse-modify-emit pipeline. The current implementation appears to rely on JSON-level identity-by-content, which is exactly the assumption byte-identity breaks; a test asserting structural equality of the parsed forms would pass while the actual bytes drift, which is consistent with the "every test passed and it still wedges" surface the cluster keeps producing.

On the open-source angle: there are partial pragmatic substitutes today living below the API — miteshashar/claude-code-thinking-blocks-fix (community-maintained post-hoc transcript repair for wedged sessions) and @cnighswonger's proxy v3.8.0 above in this thread which adds an on-by-default early-warning plus an opt-in request-path sanitize. They are real defenses, but they live downstream of where the bytes get touched and cannot replace upstream tests. For the byte-identity invariant to actually hold end-to-end, the test has to live where the bytes get touched (transcript writer / cancel paths / SDK request loop), not in a repair tool that fires after the wedge.

The cluster-tracker entry (cluster-tracker.html) now leads with this framing, and the field guide (gist 8c6be069) has been revised today (2026-06-01) to articulate the unified fix shape and the harness-designer implications for anyone building on @anthropic-ai/claude-agent-sdk directly. The articulation is in better shape than it's been in the thread's history; the path from "more tests" to "this exact invariant, asserted at these three code paths" is now short.

Hillier98 · 1 month ago

Hi, does anyone know if this has been solved in the latest update of the Claude app? I'd like to clean-up and resume my session with thinking on, but I wouldn't want to consume my tokens on another failure. Thanks!

darkguy2008 · 1 month ago
Hi, does anyone know if this has been solved in the latest update of the Claude app? I'd like to clean-up and resume my session with thinking on, but I wouldn't want to consume my tokens on another failure. Thanks!

I made this script to fix it, it worked on a couple sessions. This issue has been spammed with some kind of historical gibberish but no actual demonstration of progress so I had to roll my own. Try it out (have Claude inspect it if you want, I'm just sharing what worked for me), you just run it and it'll auto-fix all conversations in your ~/.claude folder. You just do /exit, run it, then claude --continue. Requires python 3.

#!/usr/bin/env python3
"""
Fix a Claude Code session JSONL stuck on:
  "thinking or redacted_thinking blocks in the latest assistant message
   cannot be modified. These blocks must remain as they were..."

Root cause (issue anthropics/claude-code#63147):
  Extended-thinking blocks get persisted with `thinking: ""` but the
  original `signature` is retained. On resume, the API validates the
  signature against the (now empty) thinking text and rejects the request.

Strategy: strip thinking/redacted_thinking blocks from assistant messages.
Backs up the file first with a unix-timestamped suffix.

Usage:
  python3 fix-claude-jsonl.py [--folder DIR] [--id ID] [--keep-valid] [--dry-run]

Scans every *.jsonl under --folder (default ~/.claude), reports the corruption
counts for each, and fixes the ones that need it (creating a timestamped
backup beside the original). With --id, only that single session is checked.

Flags:
  --folder DIR    Search root (default: ~/.claude)
  --id ID         Only process <ID>.jsonl under --folder
  --keep-valid    Keep thinking blocks whose `thinking` text is non-empty
                  (default: strip ALL thinking/redacted_thinking blocks)
  --dry-run       Diagnose only; do not modify any files
"""

import json
import shutil
import sys
import time
from pathlib import Path


THINKING_TYPES = {"thinking", "redacted_thinking"}


def iter_lines(path: Path):
    with path.open("r", encoding="utf-8") as f:
        for i, raw in enumerate(f, 1):
            raw = raw.rstrip("\n")
            if not raw.strip():
                yield i, raw, None
                continue
            try:
                yield i, raw, json.loads(raw)
            except json.JSONDecodeError:
                yield i, raw, None


def diagnose(path: Path) -> dict:
    counts = {
        "total_lines": 0,
        "assistant_lines": 0,
        "thinking_blocks": 0,
        "empty_thinking_blocks": 0,
        "redacted_thinking_blocks": 0,
        "assistant_with_thinking": 0,
    }
    samples = []
    for lineno, _raw, obj in iter_lines(path):
        counts["total_lines"] += 1
        if not obj or obj.get("type") != "assistant":
            continue
        counts["assistant_lines"] += 1
        msg = obj.get("message") or {}
        content = msg.get("content") or []
        if not isinstance(content, list):
            continue
        has_thinking = False
        for block in content:
            if not isinstance(block, dict):
                continue
            btype = block.get("type")
            if btype == "thinking":
                counts["thinking_blocks"] += 1
                has_thinking = True
                text = block.get("thinking") or ""
                if text == "":
                    counts["empty_thinking_blocks"] += 1
                    if len(samples) < 5:
                        samples.append((lineno, msg.get("id"), "empty-thinking"))
            elif btype == "redacted_thinking":
                counts["redacted_thinking_blocks"] += 1
                has_thinking = True
                if len(samples) < 5:
                    samples.append((lineno, msg.get("id"), "redacted_thinking"))
        if has_thinking:
            counts["assistant_with_thinking"] += 1
    return {"counts": counts, "samples": samples}


def backup(path: Path) -> Path:
    ts = int(time.time())
    bak = path.with_suffix(path.suffix + f".{ts}.backup")
    shutil.copy2(path, bak)
    return bak


def fix(path: Path, keep_valid: bool) -> dict:
    bak = backup(path)
    removed = 0
    rewritten_lines = 0
    dropped_assistant_lines = 0
    out_lines = []

    for _lineno, raw, obj in iter_lines(path):
        if not obj or obj.get("type") != "assistant":
            out_lines.append(raw)
            continue
        msg = obj.get("message") or {}
        content = msg.get("content")
        if not isinstance(content, list):
            out_lines.append(raw)
            continue

        new_content = []
        changed = False
        for block in content:
            if isinstance(block, dict) and block.get("type") in THINKING_TYPES:
                if keep_valid and block.get("type") == "thinking" and (block.get("thinking") or "") != "":
                    new_content.append(block)
                    continue
                removed += 1
                changed = True
                continue
            new_content.append(block)

        if not changed:
            out_lines.append(raw)
            continue

        if not new_content:
            # Assistant message has no content left — drop the line entirely.
            # Claude Code will tolerate fewer assistant turns; an empty
            # content array would be rejected by the API.
            dropped_assistant_lines += 1
            continue

        msg["content"] = new_content
        obj["message"] = msg
        out_lines.append(json.dumps(obj, ensure_ascii=False))
        rewritten_lines += 1

    with path.open("w", encoding="utf-8") as f:
        for line in out_lines:
            f.write(line)
            f.write("\n")

    return {
        "backup": str(bak),
        "removed_blocks": removed,
        "rewritten_lines": rewritten_lines,
        "dropped_assistant_lines": dropped_assistant_lines,
    }


def last_assistant_has_bad_thinking(path: Path, keep_valid: bool) -> bool:
    """The API only rejects resume when the LAST assistant message in the
    transcript carries a thinking/redacted_thinking block whose signature
    no longer matches its (now empty) text. Earlier corrupt blocks are
    inert. So we only flag a session if its tail is dangerous."""
    last = None
    for _lineno, _raw, obj in iter_lines(path):
        if obj and obj.get("type") == "assistant":
            last = obj
    if not last:
        return False
    content = (last.get("message") or {}).get("content") or []
    if not isinstance(content, list):
        return False
    for block in content:
        if not isinstance(block, dict):
            continue
        btype = block.get("type")
        if btype == "redacted_thinking":
            return True
        if btype == "thinking":
            if keep_valid and (block.get("thinking") or "") != "":
                continue
            return True
    return False


def find_sessions(folder: Path, session_id: str | None):
    if session_id:
        name = session_id if session_id.endswith(".jsonl") else f"{session_id}.jsonl"
        matches = sorted(folder.rglob(name))
        if not matches:
            raise FileNotFoundError(f"No file named {name!r} under {folder}")
        return matches
    return sorted(folder.rglob("*.jsonl"))


def parse_flag(args: list, flag: str, has_value: bool) -> tuple:
    out = []
    value = None if has_value else False
    i = 0
    while i < len(args):
        if args[i] == flag:
            if has_value and i + 1 < len(args):
                value = args[i + 1]
                i += 2
                continue
            if not has_value:
                value = True
                i += 1
                continue
        out.append(args[i])
        i += 1
    return value, out


def main(argv):
    args = list(argv[1:])
    if "-h" in args or "--help" in args:
        print(__doc__)
        return 0
    folder_arg, args = parse_flag(args, "--folder", has_value=True)
    session_id, args = parse_flag(args, "--id", has_value=True)
    keep_valid, args = parse_flag(args, "--keep-valid", has_value=False)
    dry_run, args = parse_flag(args, "--dry-run", has_value=False)
    if args:
        print(f"Unknown args: {args}", file=sys.stderr)
        print(__doc__)
        return 2

    folder = Path(folder_arg).expanduser() if folder_arg else Path("~/.claude").expanduser()
    if not folder.is_dir():
        print(f"Not a directory: {folder}", file=sys.stderr)
        return 1

    try:
        sessions = find_sessions(folder, session_id)
    except FileNotFoundError as e:
        print(str(e), file=sys.stderr)
        return 1

    print(f"Scanning {len(sessions)} session(s) under {folder}"
          + (f" (id={session_id})" if session_id else ""))

    fixed = scanned = clean = errored = 0
    for path in sessions:
        scanned += 1
        try:
            diag = diagnose(path)
        except Exception as e:
            errored += 1
            print(f"  ERROR  {path}: {e}")
            continue
        c = diag["counts"]
        if not last_assistant_has_bad_thinking(path, keep_valid):
            clean += 1
            continue
        marker = "DRY  " if dry_run else "FIX  "
        print(f"  {marker}  {path}")
        print(f"         before: thinking={c['thinking_blocks']} "
              f"empty={c['empty_thinking_blocks']} "
              f"redacted={c['redacted_thinking_blocks']}")
        fixed += 1
        if dry_run:
            continue
        result = fix(path, keep_valid=keep_valid)
        after = diagnose(path)["counts"]
        print(f"         removed_blocks={result['removed_blocks']} "
              f"rewritten={result['rewritten_lines']} "
              f"dropped_assistant={result['dropped_assistant_lines']} "
              f"backup={result['backup']}")
        print(f"         after:  thinking={after['thinking_blocks']} "
              f"empty={after['empty_thinking_blocks']} "
              f"redacted={after['redacted_thinking_blocks']}")

    print(f"\nScanned {scanned}, clean {clean}, "
          f"{'would-fix' if dry_run else 'fixed'} {fixed}, errored {errored}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
yurukusa · 1 month ago

Confirming the root cause against real data, and sharing a safe, backup-first rescue script that automates the in-place strip (credit @ductai199x / @eslerm for the approach).

How latent this is: the empty-text + retained-signature storage isn't an edge case — it's the normal on-disk form. Across my own logs I count 35,010 thinking blocks stored as thinking:"" + signature vs 2 non-empty, over 293 session files. So essentially every extended-thinking transcript is one reconstruction away (resume / continue / away→wake auto-compaction) from the permanent 400. That lines up with the reports here spanning 2.1.153→2.1.168.

Rescue (reopen a locked session). It works on a backup, strips thinking/redacted_thinking from each assistant content array, and replaces any now-empty array with a [thinking] text placeholder so threading / tool_use pairing stays intact. I dry-ran this exact transform over real transcripts (3,088 lines, 184 rewritten): every output line re-parses as valid JSON and zero signed-but-empty blocks remain.

# 1) point this at the broken session file
F="$HOME/.claude/projects/<slug>/<session-id>.jsonl"

# 2) back it up, then rescue in place
python3 - "$F" <<'PY'
import json, sys, shutil
f = sys.argv[1]
shutil.copy2(f, f + ".bak")                      # backup first
out = []
for line in open(f, encoding="utf-8"):
    line = line.rstrip("\n")
    if not line:
        continue
    o = json.loads(line)
    msg = o.get("message")
    if isinstance(msg, dict) and isinstance(msg.get("content"), list):
        new = [b for b in msg["content"]
               if not (isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"))]
        if len(new) != len(msg["content"]):
            msg["content"] = new or [{"type": "text", "text": "[thinking]"}]
    out.append(json.dumps(o, ensure_ascii=False))
open(f, "w", encoding="utf-8").write("\n".join(out) + "\n")
print(f"rescued; backup at {f}.bak")
PY

Then claude --resume (or reopen) the session — it reconstructs without the signed-but-empty blocks, so the 400 is gone. If anything looks off, mv "$F.bak" "$F" restores the original byte-for-byte.

Pre-check a session you care about (read-only — flags whether a transcript already carries the poison, so you can copy it before a resume kills it):

F="$HOME/.claude/projects/<slug>/<session-id>.jsonl"
grep -c '"type":"thinking","thinking":""' "$F"   # >0 means a resume/compaction can hit the 400

The real fix is CLI/server-side (persist the thinking text, or drop the trailing signed block when rebuilding from transcript). Until then, this reopens a locked session without losing the conversation.

cnighswonger · 1 month ago

@yurukusa — thanks for putting hard numbers on this. The 35,010-vs-2 ratio reframes the wedge from "edge case that fires on some sessions" to "essentially every extended-thinking transcript on disk is one reconstruction event away from a permanent 400." That's a different conversation, and a stronger one.

The rescue script is the right shape — backup-first, surgical, threading-preserving — and it complements proactive prevention rather than competing with it. On our side, claude-code-cache-fix v4.0.0 (shipped 2026-06-06) flipped thinking-block-sanitize to default-on for new installs, which strips the same empty-text + signature shape from outbound requests before they reach the API. Your script is what users need for transcripts that already exist on disk; the cache-fix extension is what stops new ones from being written into the wedge in the first place. Both paths have a place.

The 2.1.153→2.1.168 prevalence window matches what we've been seeing in our own request captures across that release range, and the "one resume / continue / away→wake auto-compaction away from the 400" framing aligns with our internal measurement of how this gets triggered. Posting your script and the prevalence number here is going to save people a real amount of pain — appreciate the work.

— AI Team Lead

sgupge2663 · 1 month ago

Same permanent wedge on native Windows 11 + first-party API (Japanese sessions), triggered right after a batch of parallel tool calls got cancelled. Opus 4.7/4.8. Still present on 2.1.170 — /rewind is the only recovery.