Prompt cache never hits across chained -p --resume calls, even at minimum config

Status Open
Reported on v2.1.79
Maintainer reply None cached
Activity 5 comments · opened Sep 4, 2026

Description

On chained -p --resume calls, prompt caching never accumulates. The static system-prompt/tool-definition prefix caches correctly (cache_read_input_tokens is nonzero and stable), but the per-turn conversation content never gets promoted into reusable cache after being written once — every subsequent call pays the same small fresh-write cost for new turn content instead of cache_read climbing to absorb what was created in the previous call.

To be precise about what's expected vs. not: it's normal for cache_creation_input_tokens to grow a little each call, since each --resume adds a genuinely new turn that didn't exist before. What's not expected is that cache_read_input_tokens stays completely flat forever instead of increasing turn-over-turn — the content written in call N should be readable from cache in call N+1, and it never is.

Reproduction

sid=""
for word in alpha beta gamma; do
  if [ -z "$sid" ]; then
    out=$(claude -p "Say the word $word" --model sonnet --effort high --output-format json 2>&1)
    sid=$(printf '%s' "$out" | grep -oE '"session_id":"[^"]+"' | head -1 | cut -d'"' -f4)
  else
    out=$(claude -p "Say the word $word" --resume "$sid" --model sonnet --effort high --output-format json 2>&1)
  fi
  printf '%s' "$out" | grep -oE '"(cache_creation_input_tokens|cache_read_input_tokens)":[0-9]+'
  echo "---"
done

Expected

cache_read_input_tokens climbs call-over-call as prior turns' content becomes reusable cache. cache_creation_input_tokens stays small and roughly constant (just the size of each new turn), not compounding.

Actual

cache_read_input_tokens never increases past its initial value, no matter how many resumed calls happen. cache_creation_input_tokens grows by a small but nonzero amount every call — consistent with each turn's own content, but never subsequently readable from cache on the next call.

Isolation work done

Bisected across 9 plugins, 91 skills, 48 remote connectors, 5 local MCP servers, 2 personal UserPromptSubmit hooks, and nested-session env vars (desktop-app child session vs. standalone terminal) — the flat-cache_read signature is identical at every configuration, including bare-minimum (no plugins/skills/MCP/connectors). Only the baseline token count scales with how much is loaded; the qualitative non-accumulation never changes. Also confirmed in a fully standalone Terminal.app session outside any parent Claude Code process, ruling out nested-session artifacts.

Environment

  • claude --version: 2.1.79 (Claude Code)
  • OS: macOS (Darwin)
  • Invocation: claude -p ... --resume <session_id>, one process per call, in a loop

View original on GitHub ↗

3 Comments

thebeals · 10 days ago

Update: confirmed this reproduces identically in a completely standalone Terminal.app session, fully outside any parent Claude Code/desktop process (no CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, etc. in the environment). Same signature: cache_read_input_tokens stuck flat across all 4 chained calls, cache_creation_input_tokens growing by a small but consistent amount every call instead of dropping to near-zero after the first.

This rules out nested-session/desktop-app artifacts as the cause — it's present in the plainest possible invocation.

Also ran an exhaustive one-at-a-time bisection (documented above) across 9 plugins, 91 skills, 48 remote connectors, 5 local MCP servers, 2 personal UserPromptSubmit hooks, and nested-session env vars — every single toggle produces the identical flat-cache_read signature. Only the baseline token count scales with how much is loaded; the qualitative behavior never changes, including at bare-minimum config.

Given a colleague on what's described as the same account setup reportedly doesn't see this, it may be account/session-specific on the server side rather than something reproducible from client-side config alone. Happy to provide full debug logs if useful for triage.

thebeals · 10 days ago

Correction to the issue description above: tightened the framing. The bug isn't that the whole prompt rebuilds every call — the static system/tool prefix caches fine. The actual defect is narrower: per-turn conversation content never gets promoted into reusable cache after being written once, so cache_read_input_tokens never climbs turn-over-turn the way it should. Small but real, since it means no session ever benefits from compounding cache savings over a long conversation.

Munsik-Park · 9 days ago

Same shape reproduced on a different entry point: waking a named subagent with SendMessage (Claude Code 2.1.260, macOS, 1-hour cache TTL, Haiku 4.5 subagent). Every wake reads the same ~22K static prefix from cache and re-writes everything after it, on every wake — not only the first one.

| call | trigger | cache_read | cache_creation |
|---|---|---|---|
| 1 | Agent(name: "probe-wake", …) spawn | 0 | 47,840 |
| 2 | same turn, after two tool results | 47,840 | 3,548 |
| 3 | SendMessage wake, +10 s after idle | 22,312 | 29,308 |
| 4 | SendMessage wake, +50 s after call 3 | 22,312 | 29,463 |

Within a turn the prefix is reused (call 2). On each wake the cache hit stops at exactly the same offset and the remainder — the per-session injected context (CLAUDE.md, skill/agent listings) plus the whole conversation — is written again. The same session's main loop, at ~310K context, read a median 100% from cache across 38 user-turn requests (300–1,200 tokens written per turn), so this is specific to the resume/rebuild path.

Repro (no hooks returning additionalContext, no MCP needed):

  1. Agent(subagent_type: "general-purpose", model: "haiku", name: "probe", prompt: "<run two cats of local files, end with a nonce line, call no tool afterwards>")
  2. wait for the idle notification, then SendMessage(to: "probe", message: "reply with nonce X") — twice, seconds apart
  3. in ~/.claude/projects/<project>/<session>/subagents/agent-*probe*.jsonl, compare message.usage.cache_read_input_tokens / cache_creation_input_tokens of the wake requests against the pre-wake request.

Each wake also records deferred_tools_delta and skill_listing attachment entries (~10 KB) immediately before the request, which matches the mechanism #44045 captured for SDK resume (a skill_listing block in messages[0] that differs between a fresh and a resumed session). #44724 reported the SendMessage path in April with a different root cause (system identity string); both were closed as stale. This measurement suggests the partial-miss-on-every-resume variant is still present on the subagent wake path as of 2.1.260.

Showing cached comments. Read the full discussion on GitHub ↗