[BUG] Prompt cache regression in --print --resume since v2.1.69(?): cache_read never grows, ~20x cost increase

Resolved 💬 25 comments Opened Mar 15, 2026 by cinniezra Closed Apr 1, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Summary

--print --resume sessions stopped caching conversation turns between API calls starting around v2.1.69. Only Claude Code's internal system prompt (~14.5k tokens) is cached; all conversation history is cache_created from scratch on every message. This causes a ~20x cost increase per message compared to v2.1.68.

Environment

  • Platform: Ubuntu (Hetzner VPS)
  • Use case: Discord bot using claude --print --model <model> --resume <session-id> --output-format stream-json --verbose with prompts piped via stdin
  • Tested models: claude-opus-4-6[1m], opus, claude-opus-4-5-20251101

The regression is version-dependent, not model-dependent.

Suspect

Something in newer updates after 2.1.68 may have inadvertently broken cache breakpoint placement for --print --resume sessions.

Workaround

Pinned to v2.1.68 (npm install -g @anthropic-ai/claude-code@2.1.68).

What Should Happen?

Expected behavior (v2.1.68)

cache_read grows as conversation accumulates, cache_create drops to a small delta (~800 tokens):

Message 1: cache_read=13,997  cache_create=22,946  cost=$0.15  (cold start)
Message 2: cache_read=32,849  cache_create=4,636   cost=$0.05
Message 3: cache_read=36,846  cache_create=879     cost=$0.03
Message 4: cache_read=37,295  cache_create=802     cost=$0.02

Actual behavior (v2.1.76 and likely earlier versions after v2.1.68)

cache_read is stuck at ~14.5k (Claude Code's system prompt only), cache_create equals the full conversation size and grows every message:

Message 1: cache_read=14,569  cache_create=54,437  cost=$0.35
Message 2: cache_read=14,569  cache_create=55,084  cost=$0.35
Message 3: cache_read=14,569  cache_create=55,512  cost=$0.35
Message 4: cache_read=14,569  cache_create=55,733  cost=$0.36
Message 5: cache_read=14,569  cache_create=55,954  cost=$0.36

The conversation turns are never reused from cache between calls. Only Claude Code's internal system prompt (~14.5k tokens) caches successfully.

Error Messages/Logs

## Testing matrix

All tests used fresh session UUIDs and back-to-back messages (well within the 5-minute cache TTL):

| Version | Model | Context | cache_read grows? | Steady-state cost/msg |
|---------|-------|---------|-------------------|----------------------|
| 2.1.68 | `opus` | 200k | **Yes** | ~$0.02 |
| 2.1.68 | `claude-opus-4-6[1m]` | 1M | **Yes** | ~$0.02 |
| 2.1.76 | `opus` | 200k | **No (stuck at 14.5k)** | ~$0.04-0.40 (grows) |
| 2.1.76 | `claude-opus-4-6[1m]` | 1M | **No (stuck at 14.5k)** | ~$0.35-0.40 |
| 2.1.76 | `claude-opus-4-5-20251101` | 200k | **No (stuck at 14.5k)** | ~$0.04-0.40 (grows) |

Steps to Reproduce

Reproduction

  1. Run claude --print --resume <session-id> --output-format stream-json --verbose with a prompt via stdin
  2. Send 3+ messages to the same session
  3. Observe cache_read_input_tokens and cache_creation_input_tokens in the stream-json result output

Claude Model

Opus

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.68

Claude Code Version

2.1.76

Platform

Other

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Other

Additional Information

This report (including the testing matrix) was written by Claude Code during a debugging session.

View original on GitHub ↗

25 Comments

yurukusa · 4 months ago

Thanks for the detailed investigation — the version bisection and cost comparison tables are very helpful.

Analysis

The behavior you're describing (only ~14.5k tokens cached = system prompt only) strongly suggests that the cache breakpoint placement changed in v2.1.69+. Anthropic's prompt caching requires cache control markers at specific positions in the messages array. If those markers moved or were removed for --print --resume sessions, the API would treat the full conversation as new content on every call.

The fact that cache_read is stuck at exactly the system prompt size confirms the cache breakpoint is only being placed on the system message, not on conversation turn boundaries.

Workaround

Until this is fixed upstream, pinning to v2.1.68 as you're doing is the right call. If you need features from newer versions, you can run two installations side-by-side:

# Keep v2.1.68 for --print --resume workloads
npm install -g @anthropic-ai/claude-code@2.1.68 --prefix ~/.claude-code-pinned
alias claude-bot="$HOME/.claude-code-pinned/bin/claude"

# Use latest for interactive sessions (where caching works fine)
npm install -g @anthropic-ai/claude-code

Cost mitigation if you must use latest

If pinning isn't an option, you can reduce the damage by keeping --resume conversations short and starting fresh sessions more frequently. The cost scaling is roughly:

  • v2.1.68: O(1) per message after warmup (cache hits)
  • v2.1.69+: O(n) per message where n = conversation length (full cache_create every time)

So shorter sessions with more frequent --resume to new session IDs will keep the per-message cost lower, though this sacrifices the conversation continuity benefit.

For the team

This is likely a one-line fix — wherever cache breakpoints are placed on conversation turns for interactive sessions, the same logic needs to apply to --print --resume sessions. The regression window (v2.1.68 → v2.1.69) should make it easy to bisect in the source.

jmarianski · 3 months ago

I've found the problem you've encountered, it's due to history rewrite during resumption of a request. Here's a summary of my findings generated by claude code:

Root Cause Analysis: Cache regression on --resume since v2.1.69

TL;DR

The deferred_tools_delta attachment type was introduced in v2.1.69 (not present in v2.1.68). This causes messages[0] to have fundamentally different content on fresh sessions vs resumed sessions, breaking the prompt cache prefix match.

Technical Details

We reverse-engineered the cache behavior by capturing full API request payloads via MITM proxy across versions 2.1.68 through 2.1.87.

How messages[0] is built — the asymmetry

Fresh session (first request):

Step 1: Session start events inject attachments (deferred tools, MCP, skills)
Step 2: AU$() PREPENDS <system-reminder> with userContext (CLAUDE.md, currentDate)
Result → messages[0] contains ALL reminders in one message:
  block[0]: <system-reminder> deferred tools list         (~2.1KB)  ← NEW in v2.1.69
  block[1]: <system-reminder> MCP instructions            (~2.2KB)
  block[2]: <system-reminder> skills list                 (~8.6KB)
  block[3]: <system-reminder> AU$ context (currentDate)   (~306B)
  block[4]: user prompt text                              (variable)
Total: ~13.4KB

Resumed session:

Step 1: Transcript JSONL → restore old messages[0..N]
Step 2: AU$() PREPENDS new messages[0] with userContext ONLY
Step 3: h2("resume") APPENDS attachments (deferred tools, MCP, skills) at END
Result → messages[0] contains ONLY AU$ context:
  block[0]: <system-reminder> AU$ context (currentDate)   (~306B)
Total: ~352B
Deferred tools/MCP/skills land at messages[N] (after transcript messages)

This creates a 13.4KB vs 352B mismatch in messages[0] — completely different prefix.

Three independent cache-breaking effects

| # | Cause | Fresh session | Resume | Breaks cache? |
|---|-------|--------------|--------|---------------|
| 1 | messages[0] content | 4 reminders (13KB) | 1 reminder (352B) | YES — prefix changed |
| 2 | system[0] cc_version hash | e.g. 2.1.87.7b6 | e.g. 2.1.87.c02 | YES — derived from msgs[0] |
| 3 | cache_control marker position | On msgs[0] block[4] | On msgs[last] | YES — breakpoint moved |

Cause #1 is the root cause. Cause #2 is a consequence (#1 changes messages[0], and the cc_version billing suffix is computed by hashing characters at positions 4, 7, and 20 of the first user message text via C46()/qT8()).

Why v2.1.68 worked

deferred_tools_delta does not exist in v2.1.68:

v2.1.68: grep -c 'deferred_tools_delta' cli.js → 0
v2.1.69: grep -c 'deferred_tools_delta' cli.js → 5
v2.1.68: grep -c 'deferred tools are now available' cli.js → 0
v2.1.69: grep -c 'deferred tools are now available' cli.js → 1

Without deferred_tools_delta, messages[0] on both fresh and resumed sessions contained only the AU$ context (CLAUDE.md + currentDate) — identical content, identical prefix, cache hit.

Subsequent versions made it worse

Each version after 2.1.69 added more content to the deferred_tools_delta attachment (more deferred tools, MCP server instructions growth), widening the gap between fresh and resumed messages[0].

What caches and what doesn't

| Transition | Cache result | Why |
|-----------|-------------|-----|
| Fresh turn N → turn N+1 | HIT | msgs[0] stable within session |
| Fresh → Resume | MISS | msgs[0] completely different (13KB → 352B) |
| Resume → next turn (same process) | HIT | msgs[0] stable after resume |
| Resume → Resume (new process, e.g. --print) | Partial HIT | system cached (~11k), messages = cache_creation |

The --print --resume use case from this issue is hit hardest because each invocation is a new process, so the cache from the previous resume's messages is never reused for system-reminder content.

Suggested fixes (any one would help)

  1. Best fix: h2("resume") should inject attachments into messages[0] (same position as fresh session), not append to messages[N]
  2. Alternative: Move deferred tools/MCP/skills to system[] parameter instead of messages[] — they're static context, not conversation
  3. Quick fix for cc_version: rw9() should skip isMeta messages when finding the first user message for billing suffix hash — currently it hashes the system-reminder content instead of the actual user prompt
  4. Minimal fix: On resume, reconstruct messages[0] to match fresh session layout (prepend deferred tools + MCP + skills before AU$ context)

Methodology

  • MITM proxy capturing full request/response payloads (mitm-addon.py via mitmproxy)
  • Ghidra reverse engineering of the standalone binary (228MB ELF) to locate the sentinel replacement mechanism in the Zig HTTP header builder
  • Controlled experiments: fresh session → resume → consecutive resumes, with payload diffing
  • Version comparison: npm packages v2.1.68 vs v2.1.69 bundle analysis
  • Tested on CC versions 2.1.68, 2.1.69, 2.1.77, 2.1.85, 2.1.86, 2.1.87
amattas · 3 months ago

Just being pedantic, but if it's O(1) and O(n) per message wouldn't that just be O(n) and O(n^2) complexity overall.

tornikeo · 3 months ago
a "bug" causes users to spend x20 more

Yeah, call me crazy but I feel like this "bug" isn't getting patched anytime soon lol

Kaz- · 3 months ago
> a "bug" causes users to spend x20 more Yeah, call me crazy but I feel like this "bug" isn't getting patched anytime soon lol

I hope not, that's a deal breaker for me, I'm not paying 100$ a month for 7 prompts every 5 hours. That's litteraly the limit for me right now with this bug.

VictorSun92 · 3 months ago

Thanks to @jmarianski for the excellent root cause analysis — the MITM proxy capture and messages[0] asymmetry breakdown made this patch possible.
I used CC to analyze the latest v2.1.88 bundle based on those findings and wrote a monkey-patch to fix the resume cache regression. Tested with the test_cache.py script from Reddit:

Test Results — v2.1.88

| Metric | Binary | npm (unpatched) | npm (patched) |
|--------|-------:|----------------:|--------------:|
| Sentinel Replacement | ✗ ACTIVE (00000→42216) | ✓ Not active | ✓ Not active |
| Fresh cache_read | 28,343 | 27,460 | 22,468 |
| Fresh cache_creation | 7,400 | 5,091 | 10,088 |
| Resume cache_read | 10,521 | 11,234 | 11,234 |
| Resume cache_creation | 7,446 | 5,121 | 5,124 |
| Consecutive cache_read | 10,521 | 11,234 | 16,328 ↑ |
| Consecutive cache_creation | 7,485 | 5,206 | 95 ↓ |
| Resume Cache | ✗ BROKEN | ✗ BROKEN | ✓ WORKING |
| Consecutive Resume | ⚠ NOT GROWING | ⚠ NOT GROWING | ✓ GROWING |

Patch

The fix injects a shim (_pRc) at the Vp8(F,z) call site in the main loop (M3Y). It relocates deferred_tools_delta, mcp_instructions_delta, agent_listing_delta, and skill_listing attachment messages from the end of the message array to right after messages[0]. This allows bP() to merge them into messages[0], matching the fresh session layout and restoring the cache prefix match.

  • Patch script (v2.1.88 npm only — minified identifiers differ per version): cache-fix.patch.js
# Apply patch (auto-detects npm global cli.js)
node cache-fix.patch.js

# Verify
python3 test_cache.py "npx @anthropic-ai/claude-code"

# Revert if needed
node cache-fix.patch.js revert

Feel free to try it yourself — the patch script validates targets before modifying anything, so it won't corrupt your install if the identifiers don't match.

3-31 edited: scripts have been updated to fix the versioning and false positive bugs.

lixiangwuxian · 3 months ago

I also asked Claude to make a patch for this bug.

See <https://github.com/lixiangwuxian/cc_cache_fix?tab=readme-ov-file#usage>.

Since this is only a hook on sending message, this should work on older versions and future versions as long as the relative message struct has not changed much. (if then they did not fix the bug yet)

As always, please read the patch before executing.

achton · 3 months ago

@VictorSun92 Thanks for the patch. I have trouble validating the fix via the test script though.

If I install 2.1.68 (via npm install -g @anthropic-ai/claude-code@2.1.68), the test script shows * No known cache bugs detected as expected.

If I install 2.1.88, the patch process has this output:

$ node cache-fix.patch.js
⚠ Detected v2020-06-01, but this patch was only tested on v2.1.88.
  Proceeding with validation — will abort if targets are not found.
  Backup saved: /home/user/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js.bak
✓ Patch applied to Claude Code v2020-06-01
  File: /home/user/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js
  Revert: node cache-fix.patch.js revert

These are the test results (with python3 test_cache.py "npx @anthropic-ai/claude-code"):

════════════════════════════════════════════════════════════
  RESULTS
════════════════════════════════════════════════════════════
  ✓ SENTINEL REPLACEMENT: NOT ACTIVE

  ✗ RESUME CACHE: BROKEN
    Resume cache_read=46,312 (system prompt only)
    cache_creation=22 (full conversation rebuilt)
    Each resume pays ~$0.0000 extra
    Issue: https://github.com/anthropics/claude-code/issues/34629

  ✓ CONSECUTIVE RESUME: WORKING (cache grows between resumes)

────────────────────────────────────────────────────────────
  ⚠ 1 bug detected — see details above
────────────────────────────────────────────────────────────

Do you know if I'm doing something wrong?

VictorSun92 · 3 months ago

@achton You're not doing anything wrong — the patch IS working! Both issues are cosmetic bugs in scripts. Sorry about the confusion.

1. Version detection (v2020-06-01)

The regex VERSION:"([^"]+)" is too greedy — it matches the Anthropic API version string (e.g. API_VERSION:"2020-06-01") instead of the build version. The patch still applied correctly since it validated the actual code targets before modifying anything.

2. Test false positive (RESUME CACHE: BROKEN)

Your numbers actually show the patch is working perfectly:

Resume    cache_creation=22     ← extremely low! (was ~5,000+ when broken)
Consecutive cache grows         ← ✓ WORKING

The test script judges resume_ok = consecutive_grows AND consecutive_creation_drops. But when resume cache_creation is already near zero (22), the consecutive value can't drop below 22 * 0.8 = 17.6, so the check fails. This is a false positive — I hit the same issue in my own testing.

Updated scripts

I've updated both scripts to fix these issues:

cache-fix.patch.js — version detection now uses the build info object pattern instead of a generic regex.

test_cache.py — detection logic now also checks for cache_creation < 200 as an absolute threshold (if creation is already this low, the cache is clearly working).

jippi · 3 months ago
- Fixed prompt cache misses in long sessions caused by tool schema bytes changing mid-session changelog 2.1.88
lujstn · 3 months ago

@VictorSun92 Still identifying one bug fwiw:

➜  Downloads python3 test_cache.py "npx @anthropic-ai/claude-code"

════════════════════════════════════════════════════════════
  Claude Code Cache & Replacement Test
  Command: npx @anthropic-ai/claude-code
════════════════════════════════════════════════════════════

────────────────────────────────────────────────────────────
  BUG 1: Sentinel replacement (cch=00000)
────────────────────────────────────────────────────────────
[*] Testing sentinel replacement...
  Sent:     sentinel=cch=00000=end
  Received: sentinel=cch=00000=end

────────────────────────────────────────────────────────────
  BUG 2: Resume cache regression
────────────────────────────────────────────────────────────
[*] Fresh session...
  cache_read=34,139  cache_creation=34,309
  result: The first 3 characters are **ABC**.
[*] Resume...
  cache_read=0  cache_creation=32,826
  result:

**ABC**
[*] Consecutive resume...
  cache_read=0  cache_creation=32,848
  result:

3

════════════════════════════════════════════════════════════
  RESULTS
════════════════════════════════════════════════════════════
  ✓ SENTINEL REPLACEMENT: NOT ACTIVE

  ✗ RESUME CACHE: BROKEN
    Resume cache_read=0 (system prompt only)
    cache_creation=32,826 (full conversation rebuilt)
    Each resume pays ~$0.0098 extra
    Issue: https://github.com/anthropics/claude-code/issues/34629

  ⚠ CONSECUTIVE RESUME: NOT GROWING

────────────────────────────────────────────────────────────
  ⚠ 1 bug detected — see details above
────────────────────────────────────────────────────────────

Not sure if it matters, but I reinstalled with npm install -g @anthropic-ai/claude-code as the local version doesn't expose cli.js. Still using .claude though, just triggering the binary directly (i.e. ~/.nvm/versions/node/v24.14.1/bin/claude).

michaelheiser-aiwyn · 3 months ago

Ah, hit the same when running /branch too https://github.com/anthropics/claude-code/issues/41284, likely the same root

FerroQuant · 3 months ago

Node.js fetch hook fix for v2.1.87 standalone binary users (tested, working)

Building on @jmarianski's root cause analysis and @VictorSun92's patch approach, here's a version-independent fix that works via Node.js --import preload — no binary patching, no extraction, survives auto-updates.

Test results (v2.1.87, Linux)

| Step | Standalone binary | npm + fetch hook |
|------|------------------:|------------------:|
| Fresh | 0% cache hit | 79.0% |
| Resume | 0% | 81.0% |
| Consecutive | 0% cache hit | 81.2% |

The standalone binary had 0% cache hits on ALL steps (not just resume). The npm version with the hook restores proper caching across the board.

How it works

The hook intercepts globalThis.fetch and, on every /v1/messages request, checks if it's a resume scenario by comparing messages[0] (first user message) and messages[N] (last user message). On resume, hooks/skills/MCP/deferred-tools blocks land at the END instead of the BEGINNING. The hook relocates them to messages[0], matching fresh session layout and restoring the cache prefix match.

Setup (3 files)

1. ~/.claude/cache-fix-preload.mjs — the fetch hook:

// cache-fix-preload.mjs — Node.js fetch interceptor for Claude Code resume cache fix.
//
// Bug 2 fix: On --resume, hooks/skills/deferred-tools blocks land in the LAST
// user message instead of the FIRST (where they are in fresh sessions).
// This breaks the cache prefix. This hook relocates them before the API call.
//
// Load via: NODE_OPTIONS="--import ~/.claude/cache-fix-preload.mjs"

function isSystemReminder(text) {
  return typeof text === "string" && text.startsWith("<system-reminder>");
}
function isHooksBlock(text) {
  return isSystemReminder(text) && text.includes("hook success");
}
function isSkillsBlock(text) {
  return isSystemReminder(text) && text.includes("skills are available");
}
function isDeferredToolsBlock(text) {
  return isSystemReminder(text) && text.includes("deferred tools");
}
function isMcpBlock(text) {
  return isSystemReminder(text) && text.includes("MCP Server Instructions");
}
function isRelocatableBlock(text) {
  return isHooksBlock(text) || isSkillsBlock(text) || isDeferredToolsBlock(text) || isMcpBlock(text);
}

function sortSkillsBlock(text) {
  const match = text.match(/^([\s\S]*?\n\n)(- [\s\S]+?)(\n<\/system-reminder>\s*)$/);
  if (!match) return text;
  const [, header, entriesText, footer] = match;
  const entries = entriesText.split(/\n(?=- )/);
  entries.sort();
  return header + entries.join("\n") + footer;
}

function stripSessionKnowledge(text) {
  return text.replace(/\n<session_knowledge[^>]*>[\s\S]*?<\/session_knowledge>/g, "");
}

function normalizeMessages(messages) {
  if (!Array.isArray(messages) || messages.length < 2) return messages;

  let firstUserIdx = -1, lastUserIdx = -1;
  for (let i = 0; i < messages.length; i++) {
    if (messages[i].role === "user") {
      if (firstUserIdx === -1) firstUserIdx = i;
      lastUserIdx = i;
    }
  }

  if (firstUserIdx === -1 || lastUserIdx === -1) return messages;
  if (firstUserIdx === lastUserIdx) return messages; // fresh session

  const firstMsg = messages[firstUserIdx];
  const lastMsg = messages[lastUserIdx];
  if (!Array.isArray(firstMsg?.content) || !Array.isArray(lastMsg?.content)) return messages;

  const firstHasReminders = firstMsg.content.some(b => isRelocatableBlock(b.text));
  const lastHasReminders = lastMsg.content.some(b => isRelocatableBlock(b.text));
  if (firstHasReminders || !lastHasReminders) return messages; // not resume

  const toRelocate = [], lastRemaining = [];
  for (const block of lastMsg.content) {
    const text = block.text || "";
    if (isRelocatableBlock(text)) {
      let fixedText = text;
      if (isHooksBlock(text)) fixedText = stripSessionKnowledge(text);
      if (isSkillsBlock(text)) fixedText = sortSkillsBlock(text);
      const { cache_control, ...rest } = block;
      toRelocate.push({ ...rest, text: fixedText });
    } else {
      const { cache_control, ...rest } = block;
      lastRemaining.push(rest);
    }
  }

  if (toRelocate.length === 0) return messages;

  const existingBlocks = firstMsg.content.map(b => { const { cache_control, ...rest } = b; return rest; });
  const allFirstBlocks = [...toRelocate, ...existingBlocks];
  if (allFirstBlocks.length > 0) {
    allFirstBlocks[allFirstBlocks.length - 1] = {
      ...allFirstBlocks[allFirstBlocks.length - 1],
      cache_control: { type: "ephemeral" }
    };
  }

  const result = [...messages];
  result[firstUserIdx] = { ...firstMsg, content: allFirstBlocks };
  result[lastUserIdx] = { ...lastMsg, content: lastRemaining };
  return result;
}

const _origFetch = globalThis.fetch;
globalThis.fetch = async function(url, options) {
  const urlStr = typeof url === "string" ? url : url?.url || String(url);
  if (urlStr.includes("/v1/messages") && !urlStr.includes("batches") && !urlStr.includes("count_tokens") && options?.body && typeof options.body === "string") {
    try {
      const payload = JSON.parse(options.body);
      if (payload.messages) {
        payload.messages = normalizeMessages(payload.messages);
        options = { ...options, body: JSON.stringify(payload) };
      }
    } catch {}
  }
  return _origFetch.apply(this, arguments);
};

2. ~/.local/bin/claude-fixed — wrapper script:

#!/bin/bash
exec env NODE_OPTIONS="--import $HOME/.claude/cache-fix-preload.mjs" \
  node $(npm root -g)/@anthropic-ai/claude-code/cli.js "$@"

3. Activate:

# Install npm package (same version as your binary)
npm install -g @anthropic-ai/claude-code@$(claude --version | grep -oP '[\d.]+')

# Make wrapper executable
chmod +x ~/.local/bin/claude-fixed

# Replace default claude
mv ~/.local/bin/claude ~/.local/bin/claude-standalone
ln -sf ~/.local/bin/claude-fixed ~/.local/bin/claude

Why this approach

  • Version-independent: detects resume by message structure, not minified identifiers
  • No binary patching: runs unmodified npm JS through standard Node.js
  • Fixes both bugs: Bug 1 (no Zig sentinel in npm) + Bug 2 (fetch hook normalizes messages)
  • Safe no-op on fresh sessions: only activates when resume pattern is detected
  • Survives auto-updates: npm package + hook script don't get overwritten by the binary updater
yunyuefeiyue · 3 months ago

来信收悉,我将择期查看。顺祝六时如意,四季安康。

jmarianski · 3 months ago

@dhollman Why has this issue been closed? It's not yet resolved in 2.1.89 from what I've checked.

EDIT: Just to clarify, I've seen some changelogs, but the issue itself isn't fixed, just some interpretations of it. You can verify it with this script I've posted on reddit (sorry I didn't link it here in this thread: https://gitlab.com/treetank/cc-diag/-/raw/c126a7890f2ee12f76d91bfb1cc92612ae95284e/test_cache.py). I can only hope that changes also affect /resume command.

cnighswonger · 3 months ago

Interesting bit of a exchange with Claude:

❯ "session is 11+ hours old, cache TTL is 5 min or 1 hour max" If this is true, then the API TTL has been tweaked in the past 10 days. We could not see that even with the source for Claude Code, could we?

● Correct. The cache TTL is a server-side API parameter, not a client-side setting. Nothing in the Claude Code source controls it — the
  client just sends cache_control: { type: "ephemeral" } or cache_control: { type: "ephemeral", ttl: "1h" } as a request, but **_the server
  decides the actual eviction policy._**

  What we can see from the source:

  - src/utils/api.ts — the client requests ttl: '1h' for some scopes and ephemeral (default 5m) for others
  - src/services/api/claude.ts — should1hCacheTTL() determines which calls get the 1h request

  What we cannot see:

  - Whether the server actually honors those TTLs
  - Whether Anthropic changed the server-side cache eviction policy
  - Whether Max plan users get different cache behavior than API users
  - Whether cache capacity/pressure causes early eviction under load

  If the TTL was silently shortened server-side in the last 10 days, that would explain exactly what you're seeing — resume sessions that used to cache_read cheaply are now cache_creating from scratch because the cache expired before the next session started.

  That's not something we can fix client-side. It would need to be raised directly with Anthropic as a separate issue from the message relocation bug.
cnighswonger · 3 months ago

And this:

❯ So if a convo sits idle overnight, does the TTL expire and the next turn trigger the "tax?"

● Yes, that's exactly right. If the cache TTL is 5 minutes (ephemeral) or even 1 hour, an overnight idle means the server-side cache is long gone. Your next turn in that session pays the full cache_create tax on the entire conversation history — same as a resume from a different process.

  So the same rule applies: long idle gaps within a session are expensive for the same reason resumes are. The first turn after waking up pays the tax, subsequent turns are cheap.
cnighswonger · 3 months ago

Interesting note: Running this interceptor and starting at 0% current session usage, I compacted two 1.9M token contexts. This action resulted in 32% usage.

This is probably rooted in the same bug(s) that cause the cold-start --resume consumption.

likeahoss · 3 months ago
> a "bug" causes users to spend x20 more Yeah, call me crazy but I feel like this "bug" isn't getting patched anytime soon lol

still waiting for them to patch the hole in my wallet

jmarianski · 3 months ago

From my research this issue is ALMOST fixed in 2.1.90. I'm testing it right now, and apparently the very first resume in the conversation may still be failing and recreating cache. I'll post results when I have them.

cnighswonger · 3 months ago
From my research this issue is ALMOST fixed in 2.1.90. I'm testing it right now, and apparently the very first resume in the conversation may still be failing and recreating cache. I'll post results when I have them.

See this comment.

cinniezra · 3 months ago

this is not fixed btw. still doing cache create without cache read ever growing in 2.1.89.

jamesmazur · 3 months ago

@cinniezra try 👍and subscribe on https://github.com/anthropics/claude-code/issues/42052

cnighswonger · 3 months ago

v2.1.97 update — resume cache regression still present

Automated testing confirms the block scatter bug reported in this issue is still present in v2.1.97. On resume, attachment blocks (skills, deferred tools, MCP, hooks) scatter out of messages[0], breaking the cache prefix and forcing ~5,200 tokens of unnecessary cache creation per turn.

| Turn | Without fix | With fix |
|------|-------------|----------|
| Resume T1 | 5,204 create / 20,939 read (80%) | 0 create / 26,143 read (100%) |
| Resume T2 | 5,220 create / 20,939 read (80%) | 0 create / 26,159 read (100%) |
| Resume T3 | 18 create / 26,159 read (99.9%) | 0 create / 26,177 read (100%) |

Our interceptor (npm install -g claude-code-cache-fix) eliminates the regression — zero cache creation across all resume turns. v1.5.1 also adds deferred tools sorting, content pinning for whitespace jitter, and a cost report tool.

Full test report: docs/v2.1.97-cache-test-report.md

github-actions[bot] · 3 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.