[BUG] Opening a session with a large slash command makes the second request discard the entire prompt cache

Status Open
Reported on v2.1.247
Maintainer reply None cached
Activity 1 comment · opened Aug 27, 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 a session opens with a large slash command, the second request of that session reads nothing
from cache and rewrites the entire prefix, after a tool_result of a few hundred characters.

A reproducer is below. It builds a throwaway project and needs python3 and nothing else: no MCP
server and no connector. Five runs, CLI 2.1.247, Opus:

| | req 1 cache_read | req 1 cache_creation | req 2 cache_read | req 2 cache_creation |
|---|---|---|---|---|
| run 1 | ~27,500 | 51,965 | 0 | 79,884 |
| run 2 | ~27,500 | 51,969 | 0 | 79,862 |
| run 3 | ~27,500 | 51,938 | 0 | 79,725 |
| run 4 | ~27,500 | 51,939 | 0 | 79,724 |
| run 5 | ~27,500 | 51,947 | 0 | 79,728 |

Request 1 caches about 79,470 tokens. Request 2 reuses none of it and rewrites about 79,730, so
roughly 250 tokens of new content cost a full rebuild of the prefix.

Request 1 splits its prefix: a warm cache_read of about 27,500 and a fresh cache_creation of
about 51,950. That read figure is identical across unrelated projects on this machine, so it looks
like the system prompt and tool definitions cached from earlier sessions. Request 2 reuses neither
block, when it should be able to read back both.

Across my local transcripts, cut by how the session opened and whether request 1 split, prefix at
least 30,000 tokens:

| opener | request 1 | sessions | rebuilt |
|---|---|---|---|
| slash command | split | 38 | 36 |
| slash command | one block | 12 | 2 |
| /clear | split | 16 | 0 |
| /clear | one block | 20 | 0 |
| typed | one block | 30 | 0 |

A split prefix on its own is harmless: /clear sessions split 16 times without a single rebuild.
The combination of a slash command opener and a split request 1 is what rebuilds.

What Should Happen?

Request 2 should read request 1's prefix and write only the new turn.

Error Messages/Logs

Nothing errors. The failure is silent. This is the checker's output from two runs, with request and
session identifiers redacted:

session <redacted>   CLI 2.1.247
  request 1   cache_read   ~27,500   cache_creation    51,965   req_<redacted>
  request 2   cache_read         0   cache_creation    79,884   req_<redacted>
  request 1 prefix shape: SPLIT (warm read + fresh write)
  >>> REBUILT. 79,493 token prefix discarded, request 2 rewrote 79,884 (+391).

session <redacted>   CLI 2.1.247
  request 1   cache_read   ~27,500   cache_creation    51,969   req_<redacted>
  request 2   cache_read         0   cache_creation    79,862   req_<redacted>
  request 1 prefix shape: SPLIT (warm read + fresh write)
  >>> REBUILT. 79,497 token prefix discarded, request 2 rewrote 79,862 (+365).

Steps to Reproduce

You need python3. Nothing else.

Save this as make-fixture.sh. It creates a throwaway project with a large CLAUDE.md and one slash
command whose first action is a short shell call, and refuses to write into a directory that already
has anything in it:

#!/bin/sh
# Build a throwaway project that reproduces the cache rebuild. No personal content, and no
# MCP server or connector required.
#
#   sh make-fixture.sh /tmp/cache-rebuild-demo
#
# Then run the two commands it prints. The SECOND run is the one that should rebuild:
# the first exists only to put the session's opening prefix in the cache.
set -e
DIR="${1:-/tmp/cache-rebuild-demo}"

# Refuse to touch anything that already exists. This writes CLAUDE.md and a slash command,
# and pointing it at a real project would silently destroy both.
for f in "$DIR/CLAUDE.md" "$DIR/.claude/commands/checklist.md"; do
    if [ -e "$f" ]; then
        echo "refusing to overwrite $f" >&2
        echo "point this at a new, empty directory." >&2
        exit 1
    fi
done
if [ -d "$DIR" ] && [ -n "$(ls -A "$DIR" 2>/dev/null)" ]; then
    echo "refusing to write into non-empty directory $DIR" >&2
    echo "point this at a new, empty directory." >&2
    exit 1
fi
mkdir -p "$DIR/.claude/commands"

python3 - "$DIR" <<'PY'
import sys, os
d = sys.argv[1]

# A large project instruction file. Generic filler, no instructions that do anything.
para = ("This project keeps release notes for an internal tool. Entries are appended in order "
        "and are never edited once written. Nothing in this file asks the assistant to take an "
        "action; it exists so the session has a sizeable prefix worth caching. ")
open(os.path.join(d, "CLAUDE.md"), "w").write("# Release notes\n\n" + para * 300)

# A large slash command whose FIRST action is one short shell call. The size matters:
# the real cases had command bodies around 60,000 characters.
step = ("Section %04d. Background material for the checklist below. It carries no instruction "
        "and is not to be summarised. It is here so the expanded command body is large, which "
        "is the shape the observed cases have. ")
open(os.path.join(d, ".claude", "commands", "checklist.md"), "w").write(
    "---\ndescription: release checklist\n---\n\n"
    + "".join(step % i for i in range(300))
    + "\n\n## Run the check\n\nRun exactly this with the Bash tool, then reply with the single "
      "word it prints and nothing else:\n\n    sleep 8 && echo ok\n")
print("CLAUDE.md   ", os.path.getsize(os.path.join(d, "CLAUDE.md")), "bytes")
print("checklist.md", os.path.getsize(os.path.join(d, ".claude", "commands", "checklist.md")), "bytes")
PY

cat <<EOF

Fixture written to $DIR

Run these two, in a terminal, one after the other. Both are interactive on purpose: headless
claude -p did not reproduce this.

    cd $DIR && claude "/checklist"     # 1st: warms the cache. Type /exit when it finishes.
    cd $DIR && claude "/checklist"     # 2nd: this is the one that should rebuild.

Then:  sh check-fixture.sh $DIR
EOF

Build it, then run two interactive sessions:

sh make-fixture.sh /tmp/cache-rebuild-demo
cd /tmp/cache-rebuild-demo && claude "/checklist"    # warms the prefix, then /exit
cd /tmp/cache-rebuild-demo && claude "/checklist"    # this run rebuilds

Save this as check-fixture.sh and run it. It pulls request 1 and request 2 out of the session
transcript and reports whether the prefix was discarded:

#!/bin/sh
# Read request 1 and request 2 out of the newest session for a directory.
#   sh check-fixture.sh /tmp/cache-rebuild-demo
exec python3 - "${1:-/tmp/cache-rebuild-demo}" <<'PY'
import glob, json, os, sys, datetime

d = os.path.realpath(sys.argv[1])
slug = d.replace("/", "-")
paths = sorted(glob.glob(os.path.expanduser(f"~/.claude/projects/{slug}/*.jsonl")),
               key=os.path.getmtime)
if not paths:
    sys.exit(f"no sessions recorded for {d}")

print("note: request and session identifiers below are yours. Redact them before posting.")

for p in paths[-2:]:
    recs = []
    for line in open(p, "rb"):
        try: recs.append(json.loads(line.decode("utf-8", "replace")))
        except Exception: pass
    seen, reqs, deltas = set(), [], []
    for i, r in enumerate(recs):
        if r.get("type") == "attachment" and r.get("attachment", {}).get("type") == "deferred_tools_delta":
            if r["attachment"].get("addedNames"):
                deltas.append((i, r["attachment"]["addedNames"]))
        if r.get("type") == "assistant":
            u = r.get("message", {}).get("usage") or {}
            if u and r.get("requestId") not in seen:
                seen.add(r.get("requestId"))
                reqs.append((i, r.get("requestId"), r.get("timestamp"),
                             u.get("cache_read_input_tokens") or 0,
                             u.get("cache_creation_input_tokens") or 0))
    print(f"\nsession {os.path.basename(p)[:8]}   CLI {next((r.get('version') for r in recs if r.get('version')), '?')}")
    if len(reqs) < 2:
        print("  fewer than two priced requests, nothing to compare"); continue
    (i1, id1, t1, rd1, wr1), (i2, id2, t2, rd2, wr2) = reqs[0], reqs[1]
    # One request can span several assistant records sharing a requestId. Scope the gap from
    # request 1's LAST record, or a delta still belonging to request 1 is read as being between.
    i1_last = max([i for i, r in enumerate(recs)
                   if r.get("type") == "assistant" and r.get("requestId") == id1] or [i1])
    gap = [n for i, n in deltas if i1_last < i < i2]
    print(f"  request 1   cache_read {rd1:>9,}   cache_creation {wr1:>9,}   {id1}")
    print(f"  request 2   cache_read {rd2:>9,}   cache_creation {wr2:>9,}   {id2}")
    print(f"  tools added between them: {gap if gap else 'none'}")
    split = rd1 > 0 and wr1 > 0
    print(f"  request 1 prefix shape: {'SPLIT (warm read + fresh write)' if split else 'ONE BLOCK'}")
    if rd2 == 0:
        print(f"  >>> REBUILT. {rd1+wr1:,} token prefix discarded, request 2 rewrote "
              f"{wr2:,} ({wr2-(rd1+wr1):+,}).")
        if gap:
            print("      A tool was also added between the requests, but that is not required:")
            print("      this reproduces with no tools change at all.")
    else:
        print(f"  --- reused. Request 2 read {rd2:,} and wrote only {wr2:,}.")
        if not split:
            print("      Request 1 wrote one undivided block, which is the case that survives.")
PY
sh check-fixture.sh /tmp/cache-rebuild-demo

Two things about the fixture. The session has to be interactive: headless claude -p did not
reproduce it for me. And the command's first action has to be a tool call lasting several seconds,
which the fixture does with sleep 8 && echo ok.

Additional Information

What I could not work out

The mechanism. I can see the usage counters but not the cache breakpoints, so I cannot tell why a
two block prefix is unusable on the next request when a single block is fine, or why a /clear
opener splits harmlessly and a slash command does not.

Runs that did not reproduce it

Some sessions in the same project do not rebuild, and I could not find the rule. Typing the command
rather than passing it as an argument helped in one project and not in another. Opening the terminal
pane wider helped when combined with sending a short first message, and did nothing on its own. I
tried each of those as an explanation and each one failed on the next test, so I am recording them as
observations rather than offering a workaround.

The reproducer above rebuilt on every one of five runs, so it is the reliable path in.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗