[BUG] Prompt cache partial miss on every --resume turn — skill_listing block missing from messages[0]

Resolved 💬 19 comments Opened Apr 6, 2026 by bilby91 Closed May 25, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

When using the SDK's query() with resume, every resume turn causes a partial prompt cache miss. The first user message sent to the Anthropic API (messages[0]) has different content blocks on a fresh session vs a resumed one.

We captured the raw /v1/messages request bodies with mitmproxy and compared them:

Fresh session — messages[0] has 5 content blocks:

[0] len=906   <system-reminder> deferred tools listing
[1] len=532   <system-reminder> companion intro
[2] len=3924  <system-reminder> skills listing
[3] len=9207  <system-reminder> user context (claudeMd, currentDate)
[4] len=32    user prompt (with cache_control)

Resume — messages[0] has 4 content blocks:

[0] len=906   <system-reminder> deferred tools listing
[1] len=533   <system-reminder> companion intro  (+1 byte, trailing \n)
[2] len=9207  <system-reminder> user context
[3] len=32    user prompt

The skills listing block (~3,924 chars) is absent from messages[0] on resume. It appears deeper in the conversation (messages[2]), at its historical position from the original transcript rather than at the head where it belongs.

This causes the API prompt cache prefix to differ on every resume turn — only the system prompt caches; the messages array is rebuilt.

Measured Impact

SDK query() with @anthropic-ai/claude-agent-sdk 0.2.92 (CLI 2.1.92):

Turn 1 (fresh):  cache_read=0      cache_create=12,528  (cold start, expected)
Turn 2 (resume): cache_read=8,760  cache_create=3,790   (partial miss)
Turn 3 (resume): cache_read=8,760  cache_create=3,810   (same partial miss)

The ~3,800 tokens of cache_create on resume closely matches the ~3,924 char skills listing block. For multi-turn agent sessions with frequent user interaction, this waste compounds on every turn.

What Should Happen?

messages[0] should have identical content blocks on both fresh and resume sessions so the prompt cache prefix is consistent across turns.

Secondary Issue

The companion intro block has a 1-byte serialization difference between fresh (532 bytes) and resume (533 bytes, extra trailing \n). This doesn't currently break caching but is a latent risk.

Reproduction

  1. Use the SDK's query() to start a fresh session with a simple prompt
  2. Call query() again with resume: sessionId
  3. Capture both /v1/messages requests via mitmproxy
  4. Compare messages[0].content block count and lengths between the two requests

Related Issues

  • #34629 — --resume prompt cache regression (same class of bug, reported for deferred_tools_delta)
  • #39732 — Prompt caching gaps in SDK query() sessions
  • #40524 — Sentinel replacement breaks cache

The fix for #34629 appears to have resolved the deferred_tools_delta case, but the same structural problem survives for the skills listing.

Environment

  • @anthropic-ai/claude-agent-sdk: 0.2.92
  • Claude Code CLI: 2.1.92 (bundled in SDK)
  • OS: macOS (Darwin 25.3.0, arm64)

View original on GitHub ↗

19 Comments

github-actions[bot] · 3 months ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/43657
  2. https://github.com/anthropics/claude-code/issues/34039

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

bilby91 · 3 months ago

After more testing with production-like SDK options (systemPrompt, settingSources), the problem extends beyond skill_listing. Other system-reminder blocks — deferred tools listing and companion intro — are also affected.

Using mitmproxy to capture /v1/messages across a 3-turn session with tool calls (5 total API calls), here's what messages[0] looks like:

Without fix:

API call 1 (turn 1):         msg[0] = 5 blocks  [deferred_tools, companion, skills, context, prompt]
API call 2 (turn 1):         msg[0] = 5 blocks  [deferred_tools, companion, skills, context, prompt]
API call 3 (turn 2, resume): msg[0] = 3 blocks  [skills, context, prompt]  ← deferred_tools + companion missing
API call 4 (turn 2):         msg[0] = 3 blocks  [skills, context, prompt]
API call 5 (turn 3, resume): msg[0] = 3 blocks  [skills, context, prompt]

The messages[0] prefix changes from 5 blocks to 3 blocks at the first resume, breaking the cache. The missing blocks appear deeper in the conversation history instead of at the head.

Note: this only reproduces when using SDK query() with systemPrompt and settingSources options — a minimal test without these won't trigger it.

ArkNill · 3 months ago

This looks like the same bug class as the deferred_tools_delta regression (#34629) — blocks that should stay at a fixed position in messages[0] end up at their historical transcript position on resume, breaking the cache prefix.

I ran into this on the CLI side and tracked it through a proxy (#42338). The v2.1.90 fix resolved deferred_tools_delta, but skill_listing was never persisted in session storage — so on resume it regenerates at a different position. That's exactly your 5→4 block change.

The 532→533 byte companion intro difference is probably joinTextAtSeam — the text-block merging function appends \n at boundaries during normalization, and on replay these accumulate. Small now, but it'll bite eventually.

@simpolism put together a patch that addresses both of these: https://gist.github.com/simpolism/302621e661f462f3e78684d96bf307ba — your mitmproxy numbers (8,760 read + 3,790 create repeating every resume) are consistent with the unpatched behavior. I tested the patch on v2.1.91 through a monitoring proxy and from the second resume onward it went to 99.7–99.9% cache hit.

More context on the resume cache issue and proxy data here if useful: 01_BUGS.md § Bug 2

cnighswonger · 3 months ago

This is the exact bug our cache-fix interceptor addresses. Your mitmproxy captures are textbook — the 5→3 block change on resume and the ~3,800 token cache_create repeating every turn is exactly what we see.

Our interceptor's normalizeResumeMessages handles this by scanning the entire message array backward on every API call, finding the latest version of each block type (skills, deferred tools, MCP, hooks), removing them from wherever they've drifted, and consolidating them into messages[0] in a deterministic order matching fresh session layout. This is the same class of fix as @simpolism's patch, applied at the fetch level rather than in CC source.

A few additional findings from our testing that extend your observations:

  • Tool ordering is also non-deterministic — 97.8% of API calls in our data arrive with tool definitions in a different order, busting cache independently of the block scatter. We sort them alphabetically.
  • Skills entries within the skills block are also non-deterministic — the listing order varies between calls. We sort those too.
  • Hooks blocks carry session_knowledge — ephemeral content that differs between sessions. We strip it.
  • The companion intro \n drift you flagged is real — we haven't seen it cause a cache miss yet, but it's on our radar.

Across 4,700+ intercepted API calls over 8 days, 52–73% needed block relocation (your bug), and 98% needed tool reordering. With the fix applied, our sessions sustain 98.3% cache hit rates on resumed sessions.

npm install -g claude-code-cache-fix — works as a Node.js preload module with the npm installation.

bilby91 · 3 months ago

@cnighswonger Thanks for sharing all these insights. I've been following your posts on other issues.

Does claude-code-cache-fix fix 1h vs 5m caching when using sdk ?

cnighswonger · 3 months ago

Good question. Two separate things:

1h vs 5m TTL — No, the interceptor can't change which TTL tier the server assigns. That's a server-side decision tied to your quota utilization (and possibly other account-level state we haven't fully identified — see the discussion on #42052). What the interceptor does as of v1.4.0 is detect which tier you're on by reading ephemeral_1h_input_tokens and ephemeral_5m_input_tokens from the response usage object. It writes this to ~/.claude/quota-status.json so you can monitor it.

Cache fixes with the SDK — Yes, the block relocation and tool ordering fixes work with @anthropic-ai/claude-agent-sdk. The interceptor patches globalThis.fetch, which the SDK uses under the hood. Your mitmproxy data showing the 5→3 block change on resume is exactly the pattern we fix. The same NODE_OPTIONS="--import claude-code-cache-fix" approach applies — just set it in your environment before launching your SDK process.

bilby91 · 3 months ago

@cnighswonger Thanks for the feedback.

I understand there is a server side component. Having said that, without making some patches client-side, I was not able to get 1hr caching. Could have been something else fooling.

I'm going to give it a try. What we currently have is not really maintainable and I like way more how your strategy works.

cnighswonger · 3 months ago
I'm going to give it a try. What we currently have is not really maintainable and I like way more how your strategy works.

@bilby91 I'd love to hear any feedback on our interceptor. If you encounter any issues, please feel free to ask here or open an issue on the repo. We'll be glad to help.

bilby91 · 3 months ago

For sure. I'll try to give it a shot later today. What we currently have is unmaintainble and we need a less intrusive solution like yours. The only reason I haven't tried it yet is that I have a gut feeling that caching ttl will break.

cnighswonger · 3 months ago

The interceptor shouldn't break your TTL — it stabilizes the request prefix to match fresh session layout, which is what the server expects. The risk is the opposite direction: without it, the scattered blocks produce a different prefix every turn, which prevents cache hits regardless of TTL tier.

On the TTL concern specifically: v1.4.0 added TTL tier detection. On every API call, the interceptor clones the response and reads ephemeral_1h_input_tokens and ephemeral_5m_input_tokens from the usage object. With CACHE_FIX_DEBUG=1, you can see it immediately:

tail -f ~/.claude/cache-fix-debug.log | grep "CACHE TTL"

You'll see lines like:

CACHE TTL: tier=1h create=1345 read=25013 hit=94.9% (1h=1345 5m=0)

If you're getting tier=5m with zero 1h tokens, the issue is server-side TTL assignment, not client-side caching. That's a different problem (see #42052 for the ongoing investigation). But at least you'll know which layer to debug.

bilby91 · 3 months ago

@cnighswonger Testing now! Will let you know how it goes.

bilby91 · 3 months ago

@cnighswonger I gave it a shot.

The 1h cache doesn't seem to be a problem. Having said that. On the second request, I see a 1 character difference in the tool block + the tool definitions are being sent in different order.

Have you seen this behaviour?

cnighswonger · 3 months ago

@bilby91 Good to hear the 1h TTL is holding — that's the key concern off the table.

The tool reorder is exactly what our interceptor's tool stabilization fix addresses (sorts tool definitions alphabetically by name on every call). If you're using the interceptor, that should already be handled. If you're seeing the reorder with the interceptor active, let us know — that would mean the SDK is reordering after the intercept.

The 1-character difference in the tool block is a new one for us — we haven't seen that in Claude Code. Could you share:

  1. What the 1-char diff actually is? (trailing newline, whitespace, punctuation change in a description?)
  2. Which request it appears on — first vs second call, or does it persist?

If you can capture it, enabling CACHE_FIX_DEBUG=1 in your environment will log the full request structure to ~/.claude/cache-fix-debug.log, which would show us exactly what changed. Alternatively, if you have your own request logging, a diff of the tools array between two consecutive calls would be enough.

Since you're on the Agent SDK rather than Claude Code, this could be an SDK-specific difference in how tool schemas are serialized. If so, your data would help us adapt the interceptor for SDK users.

bilby91 · 3 months ago

@cnighswonger I have used the debugging flags to capture the information. I have a few samples and the diff.

Is there a place I can share with you privately ?

Thanks!

cnighswonger · 3 months ago

@bilby91 Great — looking forward to seeing it. You can send to dev@veritassuperaitsolutions.com and we'll dig into the diff.

cnighswonger · 3 months ago

New failure mode: MCP tool registration timing jitter

@bilby91 provided a debug trace from his Agent SDK setup that reveals a cache bust pattern we hadn't seen in standard Claude Code:

When MCP tools register asynchronously, the skills and deferred tools blocks in messages[0] can change between consecutive API calls — not because blocks scattered, but because a new tool finished registering between call 1 and call 2. This causes:

  1. The deferred tools block grows by one entry (1-char diff in the delimiter)
  2. The skills block content changes as the new skill appears

Both break the cache prefix. Our interceptor's skill sorting and block relocation don't help here because it's a content change, not an ordering or placement issue.

This primarily affects users with MCP servers that have variable startup times. Standard Claude Code with bundled tools doesn't exhibit this because all tools are available before the first API call.

We're working on a content-pinning fix — snapshot the block content after stabilization, absorb one cache miss when a new tool registers, then lock to prevent repeated busts from registration jitter. Targeting a release tomorrow.

cnighswonger · 3 months ago

Correction and fix shipped (v1.5.1)

@bilby91 pointed out that our initial diagnosis was wrong on two counts — the MCP tools were already registered in both calls (not late-registering), and the skills load from the filesystem, not MCP. The actual root causes are:

  1. Non-deterministic filesystem enumerationreaddir doesn't guarantee order, so skills appear in different positions between calls despite being the same set
  2. Trailing whitespace jitter — the deferred tools block had a 1-character difference (754 → 755 chars) from whitespace normalization differences between serializations

v1.5.1 is now on npm with three fixes:

  • sortDeferredToolsBlock() — alphabetically sorts deferred tool names (matching the existing skills sort)
  • pinBlockContent() — normalizes trailing whitespace and pins block content by hash, so identical logical content produces byte-identical output
  • Non-scattered block processing — sorting and pinning now apply even when blocks stay in messages[0] (previously only triggered on scatter)
npm install -g claude-code-cache-fix@1.5.1

@bilby91 — we've emailed you separately with details. Let us know how it goes.

cnighswonger · 3 months ago

Quick update: claude-code-cache-fix v2.0.0 is stable with 15 cache-stability fixes — up from 8 in v1.x. The new fixes cover smooshSystemReminderSiblings folding drift, SessionStart:resume vs :startup content mismatch, MCP reconnect race, bookkeeping system-reminder churn, cache_control marker position shuffle, --continue trailer injection, and tool_use.input non-deterministic re-serialization.

Measured impact on affected sessions: 940K → 1.7K cache creation tokens on first post-resume request (99.8% reduction). 146 tests. Compatible with CC v2.1.112.

Credit to @deafsquad for 7 PRs providing the source-level function attribution and fixes that made this release possible.

github-actions[bot] · 1 month ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.