Skip to content

Commit b6ccd64

Browse files
feat(thinking-sanitize): opt-in request-path mitigation for the thinking-desync wedge (#162)
New proxy/extensions/thinking-block-sanitize.mjs (order 550, opt-in via CACHE_FIX_THINKING_SANITIZE=on). Drops the omitted (thinking:"" + signature) extended-thinking blocks CC re-sends on history-replay paths, before the request is forwarded — heading off the permanent 400 ... thinking blocks cannot be modified wedge (anthropics/claude-code#63147). Turn-selection rule (empirically resolved, directive Behavior #3): drop omitted thinking from all prior assistant turns AND the latest, unless the latest is an active tool-continuation (last block tool_use + following tool_result) — that case is uncoverable by the proxy (API needs the signed thinking; we can't restore the emptied text) -> DISABLE_INTERLEAVED_THINKING=1. Never touches non-empty thinking; redacted_thinking out of scope for v1. Deterministic / cache-stable. Emits a thinking_blocks_dropped count merged into the per-session JSON by the existing cache-telemetry writer (order 550 < session-health 590, so #160 counts the forwarded body). Counts only — never content. Full suite 904 green. Docs + CHANGELOG updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3f42a53 commit b6ccd64

6 files changed

Lines changed: 337 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
**Token-gated warning.** `thinking_desync_risk` is computed from `context_tokens` against `CACHE_FIX_THINKING_RISK_HIGH_TOKENS` (default `340000`, just under the observed ~382K trip) and `CACHE_FIX_THINKING_RISK_WARN_TOKENS` (default `250000`). On first crossing into `high`, a one-time content-free stderr line is emitted. Block-count is recorded but does not yet gate the warning (calibrated fast-follow). `CACHE_FIX_THINKING_RISK=off` suppresses the warning signal (stderr line + risk field) while raw count telemetry keeps recording.
1212

13+
- **thinking-block-sanitize mitigation extension (#162), opt-in.** A new request-path extension (`proxy/extensions/thinking-block-sanitize.mjs`, order 550) that drops the *omitted* (`thinking:""` + signature) extended-thinking blocks CC re-sends on history-replay paths, before the request is forwarded — heading off the permanent `400 ... thinking blocks ... cannot be modified` wedge (upstream `anthropics/claude-code#63147`). This is the *mitigate* half (the *warn-before* half is session-health above). **Opt-in:** only runs when `CACHE_FIX_THINKING_SANITIZE=on` (default off) — it mutates request bodies and full live-coverage validation is pending.
14+
15+
**Turn-selection rule (empirically resolved).** Drops omitted thinking from all prior assistant turns **and** the latest assistant turn — *unless* the latest turn is an active tool-continuation (its last block is a `tool_use` with a following `tool_result`), where the API requires the signed thinking intact and the proxy must not strip it (that case is uncoverable here → user-side `DISABLE_INTERLEAVED_THINKING=1`). Never touches non-empty thinking; `redacted_thinking` is out of scope for v1 (a full scan of the worst-case wedged transcript found zero). Deterministic and cache-prefix-stable. Emits a per-request `thinking_blocks_dropped` count into the per-session JSON (counts only — never content), via the existing `cache-telemetry` writer.
16+
1317
### Fixed
1418

1519
- **`ttl-management`: never inject a TTL into `thinking` / `redacted_thinking` blocks (#157).** `injectTtl` iterated every block in the request; if a `cache_control: {type: "ephemeral"}` breakpoint landed on a thinking block (possible on Opus 4.7 interleaved-thinking turns), it rewrote the block to add `ttl`, which mutates a signed thinking block — the API rejects that with `400 ... thinking blocks ... cannot be modified`. The injector now skips `thinking`/`redacted_thinking` blocks entirely (the chokepoint covers both the system-block and message-block paths). Defensive hardening: this was not the cause of the 2026-05-28 interleaved-thinking incident (that was CC-side, `anthropics/claude-code#63172`), but it's a real latent mutation path with zero upside to keeping. Regression tests pin the skip and the still-inject-on-non-thinking happy path.

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ That's it. The proxy applies all 7 cache-fix extensions automatically. No wrappe
2929

3030
### What the proxy does
3131

32-
On every `/v1/messages` request, 8 extensions run in order:
32+
On every `/v1/messages` request, 9 extensions run in order (one opt-in):
3333

3434
| Extension | What it fixes |
3535
|-----------|--------------|
@@ -41,6 +41,7 @@ On every `/v1/messages` request, 8 extensions run in order:
4141
| `cache-control-normalize` | Normalizes cache_control markers across messages |
4242
| `cache-telemetry` | Extracts cache stats from response headers → `~/.claude/quota-status/{account.json,sessions/<id>.json}` |
4343
| `session-health` | Observes per-session thinking-desync risk (context size + thinking-block count) and warns before a session reaches the danger zone. Read-only |
44+
| `thinking-block-sanitize` | Drops omitted (empty-text) thinking blocks to head off the CC thinking-desync `400` (#63147). **Opt-in** (`CACHE_FIX_THINKING_SANITIZE=on`) |
4445

4546
Extensions are hot-reloadable — add, remove, or modify `.mjs` files in `proxy/extensions/` and changes apply to the next request without restarting. Configuration in `proxy/extensions.json`.
4647

@@ -746,6 +747,18 @@ Token thresholds are anchored to the observed ~382K-token trip with margin; the
746747
| `CACHE_FIX_THINKING_RISK_HIGH_TOKENS` | `340000` | Context-token level at which risk becomes `high` and the one-time stderr warn fires. |
747748
| `CACHE_FIX_THINKING_RISK` | unset (on) | Set to `off` to suppress the warning signal (stderr line + `thinking_desync_risk` field). Raw count telemetry keeps recording. |
748749

750+
## Thinking-block sanitize (proxy mode, opt-in, thinking-desync mitigation)
751+
752+
The *mitigate* half of the thinking-desync response (the *warn-before* half is session-health above). On history-replay paths (resume / `--continue` / auto-compaction / parallel-tool-cancel), Claude Code re-sends prior assistant turns' extended thinking in the **omitted** shape `{ "type":"thinking", "thinking":"", "signature":"<intact>" }`. The API rejects modified thinking in the **latest** assistant message with a permanent `400 … thinking … blocks cannot be modified`, which wedges the session on every subsequent turn (upstream root cause: [anthropics/claude-code#63147](https://github.com/anthropics/claude-code/issues/63147)).
753+
754+
The `thinking-block-sanitize` extension drops those omitted blocks — which the API treats as optional history — from the request before it is forwarded. Empirically-resolved turn-selection rule: drop omitted thinking from **all prior assistant turns and the latest assistant turn, unless the latest turn is an active tool-continuation** (its last block is a `tool_use` answered by a following `tool_result`). In that one case the API requires the signed thinking intact and the proxy cannot restore the emptied text, so it leaves the turn untouched — **the user-side answer for that case is `DISABLE_INTERLEAVED_THINKING=1`** in Claude Code's settings `env`. Non-empty thinking is never touched; `redacted_thinking` is out of scope for v1.
755+
756+
**Opt-in.** v1 ships behind `CACHE_FIX_THINKING_SANITIZE=on` (default off): it mutates request bodies and full live-coverage validation is pending. The transform is deterministic and cache-prefix-stable, and emits a per-request `thinking_blocks_dropped` count into the per-session JSON (counts only — never content) that complements the session-health signal.
757+
758+
| Env var | Default | Purpose |
759+
|---------|---------|---------|
760+
| `CACHE_FIX_THINKING_SANITIZE` | unset (off) | Set to `on` to enable the request-path drop of omitted thinking blocks. Off = no-op (no mutation, no telemetry). |
761+
749762
## System prompt rewrite (preload mode, optional)
750763

751764
The interceptor can rewrite Claude Code's `# Output efficiency` system-prompt section. Disabled by default. Enable with `CACHE_FIX_OUTPUT_EFFICIENCY_REPLACEMENT`. See [docs/output-efficiency-prompts.md](docs/output-efficiency-prompts.md) for the three known prompt variants and usage instructions.

proxy/extensions/cache-telemetry.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ export default {
233233
// 590, stashes these before this writer runs). Optional — absent if
234234
// that extension is disabled or produced nothing this request.
235235
...(ctx.meta._sessionHealth || {}),
236+
// Additive thinking-block-sanitize drop count (order 550, opt-in).
237+
// Optional — absent unless CACHE_FIX_THINKING_SANITIZE=on.
238+
...(ctx.meta._thinkingSanitize || {}),
236239
timestamp,
237240
session_id: rawSid,
238241
},
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// thinking-block-sanitize — request-path mitigation for the CC thinking-desync
2+
// wedge (anthropics/claude-code#63147). On replay paths (resume / --continue /
3+
// auto-compaction / parallel-tool-cancel), CC re-sends prior assistant turns'
4+
// thinking in the OMITTED shape `{ type:"thinking", thinking:"", signature }`.
5+
// The API rejects modified thinking in the *latest* assistant message with a
6+
// permanent 400, which wedges the session. This extension drops the omitted
7+
// thinking blocks the API treats as optional, before the request is forwarded.
8+
//
9+
// Resolved turn-selection rule (directive Open Question 1, empirical capture):
10+
// - drop omitted thinking from ALL prior assistant turns, AND
11+
// - from the LATEST assistant turn UNLESS it is an active tool-continuation
12+
// (last block is a tool_use with a following tool_result) — that case is
13+
// uncoverable by the proxy (the API needs the signed thinking for the
14+
// pending tool call; we can't restore the emptied text) → user-side
15+
// DISABLE_INTERLEAVED_THINKING=1.
16+
// Never touches non-empty thinking, and never touches redacted_thinking (v1).
17+
//
18+
// OPT-IN for v1: only runs when CACHE_FIX_THINKING_SANITIZE=on (default off) —
19+
// it mutates request bodies and its coverage is not yet live-validated.
20+
//
21+
// Order 550: after the request-body mutators (ttl-management 500) and before
22+
// session-health (590), so #160's thinking_block_count reflects the forwarded
23+
// body. The per-request drop count is exposed via ctx.meta._thinkingSanitize
24+
// for cache-telemetry (600) to merge into the per-session JSON.
25+
26+
export function isOmittedThinking(block) {
27+
return (
28+
!!block &&
29+
block.type === "thinking" &&
30+
typeof block.thinking === "string" &&
31+
block.thinking.trim() === ""
32+
);
33+
}
34+
35+
function hasToolResult(msg) {
36+
return (
37+
!!msg &&
38+
Array.isArray(msg.content) &&
39+
msg.content.some((b) => b && b.type === "tool_result")
40+
);
41+
}
42+
43+
// The latest assistant message is an active tool-continuation when its last
44+
// block is a tool_use that a later message answers with a tool_result. The API
45+
// requires that turn's thinking intact, so we must not strip it.
46+
export function isActiveToolContinuation(messages, idx) {
47+
const msg = messages[idx];
48+
if (!msg || !Array.isArray(msg.content) || msg.content.length === 0) return false;
49+
const last = msg.content[msg.content.length - 1];
50+
if (!last || last.type !== "tool_use") return false;
51+
for (let j = idx + 1; j < messages.length; j++) {
52+
if (hasToolResult(messages[j])) return true;
53+
}
54+
return false;
55+
}
56+
57+
function latestAssistantIndex(messages) {
58+
for (let i = messages.length - 1; i >= 0; i--) {
59+
if (messages[i] && messages[i].role === "assistant") return i;
60+
}
61+
return -1;
62+
}
63+
64+
// Pure planner: returns { messages, dropped }. Does not mutate the input.
65+
// `messages` is the new array (a message that loses all content is dropped).
66+
export function planSanitize(messages) {
67+
if (!Array.isArray(messages)) return { messages, dropped: 0 };
68+
const latestAsst = latestAssistantIndex(messages);
69+
const protectLatest = latestAsst >= 0 && isActiveToolContinuation(messages, latestAsst);
70+
71+
let dropped = 0;
72+
let changed = false;
73+
const out = [];
74+
for (let i = 0; i < messages.length; i++) {
75+
const msg = messages[i];
76+
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) {
77+
out.push(msg);
78+
continue;
79+
}
80+
if (i === latestAsst && protectLatest) {
81+
out.push(msg); // active continuation — leave its thinking intact
82+
continue;
83+
}
84+
const kept = msg.content.filter((b) => {
85+
if (isOmittedThinking(b)) {
86+
dropped++;
87+
return false;
88+
}
89+
return true;
90+
});
91+
if (kept.length === msg.content.length) {
92+
out.push(msg); // unchanged
93+
} else if (kept.length === 0) {
94+
changed = true; // message became empty → drop it entirely
95+
} else {
96+
out.push({ ...msg, content: kept });
97+
changed = true;
98+
}
99+
}
100+
return { messages: changed ? out : messages, dropped };
101+
}
102+
103+
export default {
104+
name: "thinking-block-sanitize",
105+
description:
106+
"Drop omitted (empty-text) thinking blocks from prior assistant turns and the latest non-continuation turn, to head off the CC thinking-desync 400 (#63147). Opt-in via CACHE_FIX_THINKING_SANITIZE=on.",
107+
order: 550,
108+
109+
async onRequest(ctx) {
110+
if (process.env.CACHE_FIX_THINKING_SANITIZE !== "on") return;
111+
const body = ctx.body;
112+
if (!body || !Array.isArray(body.messages)) return;
113+
114+
const { messages, dropped } = planSanitize(body.messages);
115+
if (dropped > 0) body.messages = messages;
116+
117+
// Counts only — never content. Exposed for cache-telemetry to persist and
118+
// for the #160 session-health signal.
119+
ctx.meta._thinkingSanitize = { thinking_blocks_dropped: dropped };
120+
},
121+
};

test/proxy-quota-status-pipeline.test.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,35 @@ test("[pipeline #160] degraded path: no quota headers → no per-session write,
179179
}
180180
});
181181

182+
test("[pipeline #162] thinking-block-sanitize drop count merges into the per-session JSON (opt-in)", async () => {
183+
const env = setupHome();
184+
const old = process.env.CACHE_FIX_THINKING_SANITIZE;
185+
process.env.CACHE_FIX_THINKING_SANITIZE = "on";
186+
try {
187+
const exts = await loadExtensions(EXT_DIR, EXT_CONFIG);
188+
const sid = "sess-sanitize-merge";
189+
const body = {
190+
system: [],
191+
messages: [
192+
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "S" }, { type: "text", text: "a1" }] },
193+
{ role: "user", content: [{ type: "text", text: "q" }] },
194+
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "S" }, { type: "text", text: "a2" }] },
195+
],
196+
};
197+
await driveFullResponse(exts, { ...QUOTA_HEADERS, "x-claude-code-session-id": sid }, { body });
198+
199+
const sessionPath = join(env.home, ".claude", "quota-status", "sessions", `${sid}.json`);
200+
const sess = JSON.parse(readFileSync(sessionPath, "utf8"));
201+
assert.equal(sess.thinking_blocks_dropped, 2, "drop count merged into per-session JSON");
202+
// session-health (590) runs after sanitize (550), so it counts the post-sanitize forwarded body
203+
assert.equal(sess.thinking_block_count, 0, "session-health counts the post-sanitize forwarded body");
204+
} finally {
205+
if (old === undefined) delete process.env.CACHE_FIX_THINKING_SANITIZE;
206+
else process.env.CACHE_FIX_THINKING_SANITIZE = old;
207+
env.cleanup();
208+
}
209+
});
210+
182211
test("[pipeline #11j] malformed session-id ends up in a hashed file, no path-traversal escape", async () => {
183212
const env = setupHome();
184213
try {

0 commit comments

Comments
 (0)