[BUG] Prompt cache partial miss on every --resume turn — skill_listing block missing from messages[0]
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
- Use the SDK's
query()to start a fresh session with a simple prompt - Call
query()again withresume: sessionId - Capture both
/v1/messagesrequests via mitmproxy - Compare
messages[0].contentblock count and lengths between the two requests
Related Issues
- #34629 —
--resumeprompt cache regression (same class of bug, reported fordeferred_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)
19 Comments
Found 2 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
After more testing with production-like SDK options (
systemPrompt,settingSources), the problem extends beyondskill_listing. Other system-reminder blocks — deferred tools listing and companion intro — are also affected.Using mitmproxy to capture
/v1/messagesacross a 3-turn session with tool calls (5 total API calls), here's whatmessages[0]looks like:Without fix:
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()withsystemPromptandsettingSourcesoptions — a minimal test without these won't trigger it.This looks like the same bug class as the
deferred_tools_deltaregression (#34629) — blocks that should stay at a fixed position inmessages[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, butskill_listingwas 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\nat 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
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_createrepeating every turn is exactly what we see.Our interceptor's
normalizeResumeMessageshandles 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 intomessages[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:
session_knowledge— ephemeral content that differs between sessions. We strip it.\ndrift 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.@cnighswonger Thanks for sharing all these insights. I've been following your posts on other issues.
Does
claude-code-cache-fixfix1hvs5mcaching when using sdk ?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_tokensandephemeral_5m_input_tokensfrom the response usage object. It writes this to~/.claude/quota-status.jsonso 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 patchesglobalThis.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 sameNODE_OPTIONS="--import claude-code-cache-fix"approach applies — just set it in your environment before launching your SDK process.@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.
@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.
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.
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_tokensandephemeral_5m_input_tokensfrom the usage object. WithCACHE_FIX_DEBUG=1, you can see it immediately:You'll see lines like:
If you're getting
tier=5mwith zero1htokens, 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.@cnighswonger Testing now! Will let you know how it goes.
@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?
@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:
If you can capture it, enabling
CACHE_FIX_DEBUG=1in 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 thetoolsarray 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.
@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!
@bilby91 Great — looking forward to seeing it. You can send to dev@veritassuperaitsolutions.com and we'll dig into the diff.
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: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.
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:
readdirdoesn't guarantee order, so skills appear in different positions between calls despite being the same setv1.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 outputmessages[0](previously only triggered on scatter)@bilby91 — we've emailed you separately with details. Let us know how it goes.
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
smooshSystemReminderSiblingsfolding drift,SessionStart:resumevs:startupcontent mismatch, MCP reconnect race, bookkeeping system-reminder churn,cache_controlmarker position shuffle,--continuetrailer injection, andtool_use.inputnon-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.
Closing for now — inactive for too long. Please open a new issue if this is still relevant.