claude-code-cache-fix 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/README.zh.md +167 -0
- package/package.json +1 -1
- package/preload.mjs +148 -1
- package/tools/cache-test.sh +249 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# claude-code-cache-fix
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [中文](./README.zh.md)
|
|
4
|
+
|
|
5
|
+
Fixes prompt cache regressions in [Claude Code](https://github.com/anthropics/claude-code) that cause **up to 20x cost increase** on resumed sessions, plus monitoring for silent context degradation. Confirmed through v2.1.97.
|
|
4
6
|
|
|
5
7
|
## The problem
|
|
6
8
|
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# claude-code-cache-fix
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 中文
|
|
4
|
+
|
|
5
|
+
修复 [Claude Code](https://github.com/anthropics/claude-code) 中导致恢复会话时**成本增加高达 20 倍**的提示缓存回归问题,同时监控静默上下文降级。已在 v2.1.92 至 v2.1.97 上验证。
|
|
6
|
+
|
|
7
|
+
## 问题描述
|
|
8
|
+
|
|
9
|
+
当你在 Claude Code 中使用 `--resume` 或 `/resume` 时,提示缓存会静默失效。API 不再读取已缓存的 token(廉价),而是每一轮都从头重建(昂贵)。原本每小时约 $0.50 的会话可能在无任何提示的情况下飙升至 $5-10/小时。
|
|
10
|
+
|
|
11
|
+
三个 bug 导致了这个问题:
|
|
12
|
+
|
|
13
|
+
1. **附件块散布** — 技能列表、MCP 服务器、延迟工具、钩子等附件块应当位于 `messages[0]` 中。恢复会话时,它们会漂移到后续消息中,改变缓存前缀。
|
|
14
|
+
|
|
15
|
+
2. **指纹不稳定** — `cc_version` 指纹(如 `2.1.92.a3f`)是根据 `messages[0]` 的内容计算的,包括元数据/附件块。当这些块发生偏移时,指纹改变,系统提示改变,缓存失效。
|
|
16
|
+
|
|
17
|
+
3. **工具定义排序不确定** — 工具定义在不同轮次间可能以不同顺序到达,改变请求字节并使缓存键失效。
|
|
18
|
+
|
|
19
|
+
此外,通过 Read 工具读取的图片会以 base64 形式持久化在对话历史中,在每次后续 API 调用时一并发送,悄然增加 token 成本。
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
需要 Node.js >= 18,且 Claude Code 通过 npm 安装(非独立二进制文件)。
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g claude-code-cache-fix
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 使用方法
|
|
30
|
+
|
|
31
|
+
本修复以 Node.js 预加载模块的形式工作,在 API 请求离开本机之前进行拦截。
|
|
32
|
+
|
|
33
|
+
### 方式 A:包装脚本(推荐)
|
|
34
|
+
|
|
35
|
+
创建包装脚本(如 `~/bin/claude-fixed`):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
#!/bin/bash
|
|
39
|
+
CLAUDE_NPM_CLI="$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
|
|
40
|
+
|
|
41
|
+
if [ ! -f "$CLAUDE_NPM_CLI" ]; then
|
|
42
|
+
echo "Error: Claude Code npm package not found at $CLAUDE_NPM_CLI" >&2
|
|
43
|
+
echo "Install with: npm install -g @anthropic-ai/claude-code" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
exec env NODE_OPTIONS="--import claude-code-cache-fix" node "$CLAUDE_NPM_CLI" "$@"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
chmod +x ~/bin/claude-fixed
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
如果你的 npm 全局前缀不同,请相应调整 `CLAUDE_NPM_CLI`。使用以下命令查找:
|
|
55
|
+
```bash
|
|
56
|
+
npm root -g
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 方式 B:Shell 别名
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
alias claude='NODE_OPTIONS="--import claude-code-cache-fix" node "$(npm root -g)/@anthropic-ai/claude-code/cli.js"'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 方式 C:直接调用
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
NODE_OPTIONS="--import claude-code-cache-fix" claude
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> **注意**:仅在 `claude` 指向 npm/Node 安装时有效。独立二进制文件使用不同的执行路径,会绕过 Node.js 预加载。
|
|
72
|
+
|
|
73
|
+
## 工作原理
|
|
74
|
+
|
|
75
|
+
模块在 Claude Code 向 `/v1/messages` 发起 API 调用前拦截 `globalThis.fetch`。每次调用时:
|
|
76
|
+
|
|
77
|
+
1. **扫描所有用户消息**中的附件块(技能、MCP、延迟工具、钩子),将每种类型的最新版本移回 `messages[0]`,匹配全新会话的布局
|
|
78
|
+
2. **按名称字母顺序排列工具定义**,确保确定性排序
|
|
79
|
+
3. **重新计算 cc_version 指纹**,基于真实用户消息文本而非元数据/附件内容
|
|
80
|
+
|
|
81
|
+
所有修复都是幂等的 — 如果无需修复,请求将原样传递。拦截器对你的对话是只读的;它只在请求到达 API 之前规范化请求结构。
|
|
82
|
+
|
|
83
|
+
## 图片剥离
|
|
84
|
+
|
|
85
|
+
通过 Read 工具读取的图片以 base64 编码存储在对话历史的 `tool_result` 块中。它们会**在每次后续 API 调用中**随行发送,直到压缩。单张 500KB 的图片每轮带来约 62,500 token 的额外开销。
|
|
86
|
+
|
|
87
|
+
启用图片剥离以移除旧的工具结果中的图片:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export CACHE_FIX_IMAGE_KEEP_LAST=3
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
这将保留最近 3 条用户消息中的图片,并将较早的替换为文本占位符。仅针对 `tool_result` 块(Read 工具输出)中的图片 — 用户粘贴的图片不受影响。文件仍保留在磁盘上,需要时可重新读取。
|
|
94
|
+
|
|
95
|
+
设为 `0`(默认)以禁用。
|
|
96
|
+
|
|
97
|
+
## 监控功能
|
|
98
|
+
|
|
99
|
+
拦截器包含社区发现的多项额外问题的监控:
|
|
100
|
+
|
|
101
|
+
### 微压缩 / 预算执行
|
|
102
|
+
|
|
103
|
+
Claude Code 通过服务器控制机制(GrowthBook 标志)静默替换旧的工具结果为 `[Old tool result content cleared]`。200,000 字符的聚合上限和每工具上限(Bash: 30K, Grep: 20K)会截断较早的结果且无通知。
|
|
104
|
+
|
|
105
|
+
拦截器检测已清除的工具结果并记录计数。当总工具结果字符数接近 200K 阈值时,会记录警告。
|
|
106
|
+
|
|
107
|
+
### 虚假速率限制器
|
|
108
|
+
|
|
109
|
+
客户端可以在不发起 API 调用的情况下生成合成的 "Rate limit reached" 错误,可通过 `"model": "<synthetic>"` 识别。拦截器会记录这些事件。
|
|
110
|
+
|
|
111
|
+
### 配额追踪
|
|
112
|
+
|
|
113
|
+
解析响应头中的 `anthropic-ratelimit-unified-5h-utilization` 和 `7d-utilization`,保存到 `~/.claude/quota-status.json`,供状态栏钩子或其他工具使用。
|
|
114
|
+
|
|
115
|
+
### 高峰时段检测
|
|
116
|
+
|
|
117
|
+
Anthropic 在工作日高峰时段(UTC 13:00-19:00,周一至周五)会提高配额消耗速率。拦截器检测高峰窗口并将 `peak_hour: true/false` 写入 `quota-status.json`。详见 `docs/peak-hours-reference.md`。
|
|
118
|
+
|
|
119
|
+
### 使用量遥测与成本报告
|
|
120
|
+
|
|
121
|
+
拦截器将每次调用的使用数据记录到 `~/.claude/usage.jsonl` — 每次 API 调用一行 JSON,包含模型、token 计数和缓存明细。使用内置的成本报告工具分析费用:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
node tools/cost-report.mjs # 从拦截器日志查看今日费用
|
|
125
|
+
node tools/cost-report.mjs --date 2026-04-08 # 指定日期
|
|
126
|
+
node tools/cost-report.mjs --since 2h # 最近 2 小时
|
|
127
|
+
node tools/cost-report.mjs --admin-key <key> # 与 Admin API 交叉验证
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
同样适用于任何包含 Anthropic 使用量字段的 JSONL(`--file`、stdin)— 适合 SDK 用户和代理设置。支持文本、JSON 和 Markdown 输出格式。详见 `docs/cost-report.md`。
|
|
131
|
+
|
|
132
|
+
## 调试模式
|
|
133
|
+
|
|
134
|
+
启用调试日志以验证修复是否生效:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
CACHE_FIX_DEBUG=1 claude-fixed
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
日志写入 `~/.claude/cache-fix-debug.log`。
|
|
141
|
+
|
|
142
|
+
## 环境变量
|
|
143
|
+
|
|
144
|
+
| 变量 | 默认值 | 说明 |
|
|
145
|
+
|------|--------|------|
|
|
146
|
+
| `CACHE_FIX_DEBUG` | `0` | 启用调试日志 |
|
|
147
|
+
| `CACHE_FIX_PREFIXDIFF` | `0` | 启用前缀快照差异对比 |
|
|
148
|
+
| `CACHE_FIX_IMAGE_KEEP_LAST` | `0` | 保留最近 N 条用户消息中的图片(0 = 禁用) |
|
|
149
|
+
| `CACHE_FIX_USAGE_LOG` | `~/.claude/usage.jsonl` | 每次调用使用量遥测日志路径 |
|
|
150
|
+
|
|
151
|
+
## 限制
|
|
152
|
+
|
|
153
|
+
- **仅支持 npm 安装** — 独立 Claude Code 二进制文件具有 Zig 级别的证明机制,会绕过 Node.js。本修复仅适用于 npm 包(`npm install -g @anthropic-ai/claude-code`)。
|
|
154
|
+
- **超额 TTL 降级** — 超过 5 小时配额的 100% 会触发服务器端 TTL 从 1h 降级至 5m。这是服务器端决策,无法在客户端修复。拦截器通过防止缓存不稳定来避免你首先进入超额状态。
|
|
155
|
+
- **微压缩不可阻止** — 监控功能可以检测上下文降级,但无法阻止。微压缩和预算执行机制是通过 GrowthBook 标志进行服务器控制的,没有客户端禁用选项。
|
|
156
|
+
|
|
157
|
+
## 相关问题
|
|
158
|
+
|
|
159
|
+
- [#34629](https://github.com/anthropics/claude-code/issues/34629) — 恢复缓存回归的原始报告
|
|
160
|
+
- [#40524](https://github.com/anthropics/claude-code/issues/40524) — 会话内指纹失效,图片持久化
|
|
161
|
+
- [#42052](https://github.com/anthropics/claude-code/issues/42052) — 社区拦截器开发,TTL 降级发现
|
|
162
|
+
- [#44045](https://github.com/anthropics/claude-code/issues/44045) — 恢复时提示缓存部分缺失
|
|
163
|
+
- [#41930](https://github.com/anthropics/claude-code/issues/41930) — 多种根因导致的异常用量消耗
|
|
164
|
+
|
|
165
|
+
## 许可证
|
|
166
|
+
|
|
167
|
+
MIT
|
package/package.json
CHANGED
package/preload.mjs
CHANGED
|
@@ -169,6 +169,75 @@ function sortSkillsBlock(text) {
|
|
|
169
169
|
return header + entries.join("\n") + footer;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Sort deferred tools listing for deterministic ordering. The block format is:
|
|
174
|
+
* <system-reminder>
|
|
175
|
+
* The following deferred tools are now available via ToolSearch:
|
|
176
|
+
* ToolName1
|
|
177
|
+
* ToolName2
|
|
178
|
+
* ...
|
|
179
|
+
* </system-reminder>
|
|
180
|
+
*
|
|
181
|
+
* When MCP tools register asynchronously, new tools can appear between API
|
|
182
|
+
* calls, changing the block content and busting cache. Sorting ensures that
|
|
183
|
+
* once a tool appears, its position is deterministic.
|
|
184
|
+
*/
|
|
185
|
+
function sortDeferredToolsBlock(text) {
|
|
186
|
+
const match = text.match(
|
|
187
|
+
/^(<system-reminder>\nThe following deferred tools are now available[^\n]*\n)([\s\S]+?)(\n<\/system-reminder>\s*)$/
|
|
188
|
+
);
|
|
189
|
+
if (!match) return text;
|
|
190
|
+
const [, header, toolsList, footer] = match;
|
|
191
|
+
const tools = toolsList.split("\n").map(t => t.trim()).filter(Boolean);
|
|
192
|
+
tools.sort();
|
|
193
|
+
return header + tools.join("\n") + footer;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --------------------------------------------------------------------------
|
|
197
|
+
// Content pinning for MCP registration jitter (Bug 4)
|
|
198
|
+
// --------------------------------------------------------------------------
|
|
199
|
+
//
|
|
200
|
+
// When MCP tools register asynchronously, the skills and deferred tools blocks
|
|
201
|
+
// can change between consecutive API calls as new tools finish registering.
|
|
202
|
+
// This causes repeated cache busts even though the final tool set is stable.
|
|
203
|
+
//
|
|
204
|
+
// Fix: track the content hash of each block type. When content changes, accept
|
|
205
|
+
// one cache miss (the new tool needs to be visible), then pin the new content.
|
|
206
|
+
// If the SAME content appears on consecutive calls, use the pinned version
|
|
207
|
+
// with normalized whitespace to prevent trivial diffs.
|
|
208
|
+
//
|
|
209
|
+
// Reported by @bilby91 on #44045 (Agent SDK with MCP tools).
|
|
210
|
+
// --------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
const _pinnedBlocks = new Map(); // blockType → { hash, text }
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Normalize a block's trailing whitespace and pin its content. Returns the
|
|
216
|
+
* normalized text. On first call for a block type, pins the content. On
|
|
217
|
+
* subsequent calls, if the content hash matches the pin, returns the pinned
|
|
218
|
+
* version (byte-identical). If content changed, updates the pin and returns
|
|
219
|
+
* the new content (accepts one cache bust).
|
|
220
|
+
*/
|
|
221
|
+
function pinBlockContent(blockType, text) {
|
|
222
|
+
// Normalize: trim trailing whitespace inside the </system-reminder> tag
|
|
223
|
+
const normalized = text.replace(/\s+(<\/system-reminder>)\s*$/, "\n$1");
|
|
224
|
+
|
|
225
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
226
|
+
const pinned = _pinnedBlocks.get(blockType);
|
|
227
|
+
|
|
228
|
+
if (pinned && pinned.hash === hash) {
|
|
229
|
+
// Content matches pin — return pinned version (byte-identical)
|
|
230
|
+
return pinned.text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Content changed or first call — update pin
|
|
234
|
+
if (pinned && pinned.hash !== hash) {
|
|
235
|
+
debugLog(`CONTENT PIN: ${blockType} changed (${pinned.hash} → ${hash}) — accepting one cache bust`);
|
|
236
|
+
}
|
|
237
|
+
_pinnedBlocks.set(blockType, { hash, text: normalized });
|
|
238
|
+
return normalized;
|
|
239
|
+
}
|
|
240
|
+
|
|
172
241
|
/**
|
|
173
242
|
* Strip session_knowledge from hooks blocks — ephemeral content that differs
|
|
174
243
|
* between sessions and would bust cache.
|
|
@@ -226,7 +295,46 @@ function normalizeResumeMessages(messages) {
|
|
|
226
295
|
}
|
|
227
296
|
}
|
|
228
297
|
}
|
|
229
|
-
|
|
298
|
+
|
|
299
|
+
// Even when blocks aren't scattered, apply sorting and content pinning to
|
|
300
|
+
// blocks in messages[0]. This handles MCP registration jitter where block
|
|
301
|
+
// CONTENT changes between calls (new tool registers) without scattering.
|
|
302
|
+
// (Reported by @bilby91 — Agent SDK with async MCP tools, #44045)
|
|
303
|
+
if (!hasScatteredBlocks) {
|
|
304
|
+
let contentModified = false;
|
|
305
|
+
const newContent = firstMsg.content.map((block) => {
|
|
306
|
+
const text = block.text || "";
|
|
307
|
+
if (!isRelocatableBlock(text)) return block;
|
|
308
|
+
|
|
309
|
+
let fixedText = text;
|
|
310
|
+
if (isSkillsBlock(text)) fixedText = sortSkillsBlock(text);
|
|
311
|
+
else if (isDeferredToolsBlock(text)) fixedText = sortDeferredToolsBlock(text);
|
|
312
|
+
else if (isHooksBlock(text)) fixedText = stripSessionKnowledge(text);
|
|
313
|
+
|
|
314
|
+
// Determine block type for pinning
|
|
315
|
+
let blockType;
|
|
316
|
+
if (isSkillsBlock(text)) blockType = "skills";
|
|
317
|
+
else if (isDeferredToolsBlock(text)) blockType = "deferred";
|
|
318
|
+
else if (isMcpBlock(text)) blockType = "mcp";
|
|
319
|
+
else if (isHooksBlock(text)) blockType = "hooks";
|
|
320
|
+
|
|
321
|
+
if (blockType) fixedText = pinBlockContent(blockType, fixedText);
|
|
322
|
+
|
|
323
|
+
if (fixedText !== text) {
|
|
324
|
+
contentModified = true;
|
|
325
|
+
const { cache_control, ...rest } = block;
|
|
326
|
+
return { ...rest, text: fixedText };
|
|
327
|
+
}
|
|
328
|
+
return block;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (contentModified) {
|
|
332
|
+
return messages.map((msg, idx) =>
|
|
333
|
+
idx === firstUserIdx ? { ...msg, content: newContent } : msg
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return messages;
|
|
337
|
+
}
|
|
230
338
|
|
|
231
339
|
// Scan ALL user messages (including first) in reverse to collect the LATEST
|
|
232
340
|
// version of each block type. This handles both full and partial scatter.
|
|
@@ -254,6 +362,10 @@ function normalizeResumeMessages(messages) {
|
|
|
254
362
|
let fixedText = text;
|
|
255
363
|
if (blockType === "hooks") fixedText = stripSessionKnowledge(text);
|
|
256
364
|
if (blockType === "skills") fixedText = sortSkillsBlock(text);
|
|
365
|
+
if (blockType === "deferred") fixedText = sortDeferredToolsBlock(text);
|
|
366
|
+
|
|
367
|
+
// Pin content to prevent jitter from late MCP tool registration
|
|
368
|
+
fixedText = pinBlockContent(blockType, fixedText);
|
|
257
369
|
|
|
258
370
|
const { cache_control, ...rest } = block;
|
|
259
371
|
found.set(blockType, { ...rest, text: fixedText });
|
|
@@ -722,6 +834,41 @@ globalThis.fetch = async function (url, options) {
|
|
|
722
834
|
}
|
|
723
835
|
}
|
|
724
836
|
|
|
837
|
+
// Bug 5: 1h TTL enforcement
|
|
838
|
+
// The client gates 1h cache TTL behind a GrowthBook allowlist that checks
|
|
839
|
+
// querySource against patterns like "repl_main_thread*", "sdk", "auto_mode".
|
|
840
|
+
// Interactive CLI sessions may not match any pattern, causing the client to
|
|
841
|
+
// send cache_control without ttl (defaulting to 5m server-side).
|
|
842
|
+
// The server honors whatever TTL the client requests — so we inject it.
|
|
843
|
+
// Discovered by @TigerKay1926 on #42052 using our GrowthBook flag dump.
|
|
844
|
+
if (payload.system) {
|
|
845
|
+
let ttlInjected = 0;
|
|
846
|
+
payload.system = payload.system.map((block) => {
|
|
847
|
+
if (block.cache_control?.type === "ephemeral" && !block.cache_control.ttl) {
|
|
848
|
+
ttlInjected++;
|
|
849
|
+
return { ...block, cache_control: { ...block.cache_control, ttl: "1h" } };
|
|
850
|
+
}
|
|
851
|
+
return block;
|
|
852
|
+
});
|
|
853
|
+
// Also check messages for cache_control blocks (conversation history breakpoints)
|
|
854
|
+
if (payload.messages) {
|
|
855
|
+
for (const msg of payload.messages) {
|
|
856
|
+
if (!Array.isArray(msg.content)) continue;
|
|
857
|
+
for (let i = 0; i < msg.content.length; i++) {
|
|
858
|
+
const b = msg.content[i];
|
|
859
|
+
if (b.cache_control?.type === "ephemeral" && !b.cache_control.ttl) {
|
|
860
|
+
msg.content[i] = { ...b, cache_control: { ...b.cache_control, ttl: "1h" } };
|
|
861
|
+
ttlInjected++;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (ttlInjected > 0) {
|
|
867
|
+
modified = true;
|
|
868
|
+
debugLog(`APPLIED: 1h TTL injected on ${ttlInjected} cache_control block(s)`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
725
872
|
if (modified) {
|
|
726
873
|
options = { ...options, body: JSON.stringify(payload) };
|
|
727
874
|
debugLog("Request body rewritten");
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# cache-test.sh — Test Claude Code cache behavior with and without interceptor.
|
|
3
|
+
#
|
|
4
|
+
# Runs four scenarios and captures cache stats for each:
|
|
5
|
+
# 1. One-shot WITHOUT interceptor (baseline)
|
|
6
|
+
# 2. One-shot WITH interceptor
|
|
7
|
+
# 3. Multi-turn WITHOUT interceptor (conversation + resume)
|
|
8
|
+
# 4. Multi-turn WITH interceptor (conversation + resume)
|
|
9
|
+
#
|
|
10
|
+
# Outputs a summary report comparing TTL tier, cache hit rates, and
|
|
11
|
+
# whether the interceptor's fixes fired.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# ./cache-test.sh [--skip-resume] # --skip-resume skips the resume tests
|
|
15
|
+
#
|
|
16
|
+
# Requires: Claude Code installed via npm, claude-code-cache-fix installed.
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
CLAUDE_CLI="$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
|
|
21
|
+
PRELOAD="$HOME/.claude/cache-fix-preload.mjs"
|
|
22
|
+
QUOTA_FILE="$HOME/.claude/quota-status.json"
|
|
23
|
+
USAGE_LOG="$HOME/.claude/usage.jsonl"
|
|
24
|
+
DEBUG_LOG="$HOME/.claude/cache-fix-debug.log"
|
|
25
|
+
REPORT_DIR="/tmp/cache-test-$(date +%Y%m%d_%H%M%S)"
|
|
26
|
+
SKIP_RESUME=false
|
|
27
|
+
|
|
28
|
+
for arg in "$@"; do
|
|
29
|
+
case "$arg" in
|
|
30
|
+
--skip-resume) SKIP_RESUME=true ;;
|
|
31
|
+
esac
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
# Verify prerequisites
|
|
35
|
+
if [ ! -f "$CLAUDE_CLI" ]; then
|
|
36
|
+
echo "ERROR: Claude Code not found at $CLAUDE_CLI" >&2
|
|
37
|
+
echo "Install with: npm install -g @anthropic-ai/claude-code" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [ ! -f "$PRELOAD" ]; then
|
|
42
|
+
echo "ERROR: cache-fix preload not found at $PRELOAD" >&2
|
|
43
|
+
echo "Install with: npm install -g claude-code-cache-fix" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
CC_VERSION=$(node "$CLAUDE_CLI" --version 2>/dev/null | head -1)
|
|
48
|
+
echo "=========================================="
|
|
49
|
+
echo " CACHE BEHAVIOR TEST"
|
|
50
|
+
echo " Claude Code: $CC_VERSION"
|
|
51
|
+
echo " Report dir: $REPORT_DIR"
|
|
52
|
+
echo "=========================================="
|
|
53
|
+
echo ""
|
|
54
|
+
|
|
55
|
+
mkdir -p "$REPORT_DIR"
|
|
56
|
+
|
|
57
|
+
# Helper: snapshot cache state from quota-status.json
|
|
58
|
+
snapshot_cache() {
|
|
59
|
+
local label="$1"
|
|
60
|
+
local outfile="$REPORT_DIR/${label}.json"
|
|
61
|
+
if [ -f "$QUOTA_FILE" ]; then
|
|
62
|
+
cp "$QUOTA_FILE" "$outfile"
|
|
63
|
+
local tier=$(python3 -c "import json; d=json.load(open('$QUOTA_FILE')); print(d.get('cache',{}).get('ttl_tier','?'))" 2>/dev/null || echo "?")
|
|
64
|
+
local create=$(python3 -c "import json; d=json.load(open('$QUOTA_FILE')); print(d.get('cache',{}).get('cache_creation',0))" 2>/dev/null || echo "?")
|
|
65
|
+
local read=$(python3 -c "import json; d=json.load(open('$QUOTA_FILE')); print(d.get('cache',{}).get('cache_read',0))" 2>/dev/null || echo "?")
|
|
66
|
+
local e1h=$(python3 -c "import json; d=json.load(open('$QUOTA_FILE')); print(d.get('cache',{}).get('ephemeral_1h',0))" 2>/dev/null || echo "?")
|
|
67
|
+
local e5m=$(python3 -c "import json; d=json.load(open('$QUOTA_FILE')); print(d.get('cache',{}).get('ephemeral_5m',0))" 2>/dev/null || echo "?")
|
|
68
|
+
local hit=$(python3 -c "import json; d=json.load(open('$QUOTA_FILE')); print(d.get('cache',{}).get('hit_rate','?'))" 2>/dev/null || echo "?")
|
|
69
|
+
echo " [$label] TTL=$tier create=$create read=$read 1h=$e1h 5m=$e5m hit=$hit%"
|
|
70
|
+
else
|
|
71
|
+
echo " [$label] No quota-status.json found"
|
|
72
|
+
fi
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Helper: count usage.jsonl entries
|
|
76
|
+
count_usage() {
|
|
77
|
+
if [ -f "$USAGE_LOG" ]; then
|
|
78
|
+
wc -l < "$USAGE_LOG" | tr -d ' '
|
|
79
|
+
else
|
|
80
|
+
echo "0"
|
|
81
|
+
fi
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Helper: capture debug log entries
|
|
85
|
+
snapshot_debug() {
|
|
86
|
+
local label="$1"
|
|
87
|
+
if [ -f "$DEBUG_LOG" ]; then
|
|
88
|
+
cp "$DEBUG_LOG" "$REPORT_DIR/${label}-debug.log"
|
|
89
|
+
fi
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# ─── Test 1: One-shot WITHOUT interceptor ────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
echo "--- Test 1: One-shot WITHOUT interceptor ---"
|
|
95
|
+
rm -f "$DEBUG_LOG"
|
|
96
|
+
usage_before=$(count_usage)
|
|
97
|
+
|
|
98
|
+
# Call 1: cold start
|
|
99
|
+
node "$CLAUDE_CLI" -p "respond with exactly: cache-test-1a" --dangerously-skip-permissions > "$REPORT_DIR/test1a-output.txt" 2>&1
|
|
100
|
+
snapshot_cache "test1a-no-interceptor"
|
|
101
|
+
|
|
102
|
+
# Wait 2 seconds for any async writes
|
|
103
|
+
sleep 2
|
|
104
|
+
|
|
105
|
+
# Call 2: should get cache hit
|
|
106
|
+
node "$CLAUDE_CLI" -p "respond with exactly: cache-test-1b" --dangerously-skip-permissions > "$REPORT_DIR/test1b-output.txt" 2>&1
|
|
107
|
+
snapshot_cache "test1b-no-interceptor"
|
|
108
|
+
|
|
109
|
+
usage_after=$(count_usage)
|
|
110
|
+
echo " Usage entries added: $((usage_after - usage_before))"
|
|
111
|
+
echo ""
|
|
112
|
+
|
|
113
|
+
# ─── Test 2: One-shot WITH interceptor ───────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
echo "--- Test 2: One-shot WITH interceptor ---"
|
|
116
|
+
rm -f "$DEBUG_LOG"
|
|
117
|
+
usage_before=$(count_usage)
|
|
118
|
+
|
|
119
|
+
# Call 1: cold start with interceptor
|
|
120
|
+
CACHE_FIX_DEBUG=1 NODE_OPTIONS="--import $PRELOAD" \
|
|
121
|
+
node "$CLAUDE_CLI" -p "respond with exactly: cache-test-2a" --dangerously-skip-permissions > "$REPORT_DIR/test2a-output.txt" 2>&1
|
|
122
|
+
snapshot_cache "test2a-with-interceptor"
|
|
123
|
+
snapshot_debug "test2a"
|
|
124
|
+
|
|
125
|
+
sleep 2
|
|
126
|
+
|
|
127
|
+
# Call 2: should get cache hit
|
|
128
|
+
CACHE_FIX_DEBUG=1 NODE_OPTIONS="--import $PRELOAD" \
|
|
129
|
+
node "$CLAUDE_CLI" -p "respond with exactly: cache-test-2b" --dangerously-skip-permissions > "$REPORT_DIR/test2b-output.txt" 2>&1
|
|
130
|
+
snapshot_cache "test2b-with-interceptor"
|
|
131
|
+
snapshot_debug "test2b"
|
|
132
|
+
|
|
133
|
+
usage_after=$(count_usage)
|
|
134
|
+
echo " Usage entries added: $((usage_after - usage_before))"
|
|
135
|
+
echo ""
|
|
136
|
+
|
|
137
|
+
# ─── Test 3 & 4: Multi-turn + Resume ────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
if [ "$SKIP_RESUME" = true ]; then
|
|
140
|
+
echo "--- Tests 3 & 4: SKIPPED (--skip-resume) ---"
|
|
141
|
+
echo ""
|
|
142
|
+
else
|
|
143
|
+
# Test 3: Multi-turn WITHOUT interceptor
|
|
144
|
+
echo "--- Test 3: Multi-turn + Resume WITHOUT interceptor ---"
|
|
145
|
+
rm -f "$DEBUG_LOG"
|
|
146
|
+
usage_before=$(count_usage)
|
|
147
|
+
|
|
148
|
+
# Start a session with a named session, do 2 turns, exit, then resume
|
|
149
|
+
SESSION_NAME="cache-test-no-fix-$$"
|
|
150
|
+
|
|
151
|
+
# Turn 1
|
|
152
|
+
node "$CLAUDE_CLI" -p "respond with exactly: turn1-done" \
|
|
153
|
+
--dangerously-skip-permissions -n "$SESSION_NAME" \
|
|
154
|
+
> "$REPORT_DIR/test3-turn1-output.txt" 2>&1
|
|
155
|
+
snapshot_cache "test3-turn1-no-interceptor"
|
|
156
|
+
|
|
157
|
+
sleep 2
|
|
158
|
+
|
|
159
|
+
# Turn 2 (resume)
|
|
160
|
+
node "$CLAUDE_CLI" -p "respond with exactly: turn2-done" \
|
|
161
|
+
--dangerously-skip-permissions -c \
|
|
162
|
+
> "$REPORT_DIR/test3-turn2-output.txt" 2>&1
|
|
163
|
+
snapshot_cache "test3-turn2-no-interceptor"
|
|
164
|
+
|
|
165
|
+
sleep 2
|
|
166
|
+
|
|
167
|
+
# Turn 3 (second resume — this is where scatter typically shows)
|
|
168
|
+
node "$CLAUDE_CLI" -p "respond with exactly: turn3-done" \
|
|
169
|
+
--dangerously-skip-permissions -c \
|
|
170
|
+
> "$REPORT_DIR/test3-turn3-output.txt" 2>&1
|
|
171
|
+
snapshot_cache "test3-turn3-no-interceptor"
|
|
172
|
+
|
|
173
|
+
usage_after=$(count_usage)
|
|
174
|
+
echo " Usage entries added: $((usage_after - usage_before))"
|
|
175
|
+
echo ""
|
|
176
|
+
|
|
177
|
+
# Test 4: Multi-turn WITH interceptor
|
|
178
|
+
echo "--- Test 4: Multi-turn + Resume WITH interceptor ---"
|
|
179
|
+
rm -f "$DEBUG_LOG"
|
|
180
|
+
usage_before=$(count_usage)
|
|
181
|
+
|
|
182
|
+
SESSION_NAME="cache-test-with-fix-$$"
|
|
183
|
+
|
|
184
|
+
# Turn 1
|
|
185
|
+
CACHE_FIX_DEBUG=1 CACHE_FIX_PREFIXDIFF=1 NODE_OPTIONS="--import $PRELOAD" \
|
|
186
|
+
node "$CLAUDE_CLI" -p "respond with exactly: turn1-done" \
|
|
187
|
+
--dangerously-skip-permissions -n "$SESSION_NAME" \
|
|
188
|
+
> "$REPORT_DIR/test4-turn1-output.txt" 2>&1
|
|
189
|
+
snapshot_cache "test4-turn1-with-interceptor"
|
|
190
|
+
snapshot_debug "test4-turn1"
|
|
191
|
+
|
|
192
|
+
sleep 2
|
|
193
|
+
|
|
194
|
+
# Turn 2 (resume)
|
|
195
|
+
CACHE_FIX_DEBUG=1 CACHE_FIX_PREFIXDIFF=1 NODE_OPTIONS="--import $PRELOAD" \
|
|
196
|
+
node "$CLAUDE_CLI" -p "respond with exactly: turn2-done" \
|
|
197
|
+
--dangerously-skip-permissions -c \
|
|
198
|
+
> "$REPORT_DIR/test4-turn2-output.txt" 2>&1
|
|
199
|
+
snapshot_cache "test4-turn2-with-interceptor"
|
|
200
|
+
snapshot_debug "test4-turn2"
|
|
201
|
+
|
|
202
|
+
sleep 2
|
|
203
|
+
|
|
204
|
+
# Turn 3 (second resume)
|
|
205
|
+
CACHE_FIX_DEBUG=1 CACHE_FIX_PREFIXDIFF=1 NODE_OPTIONS="--import $PRELOAD" \
|
|
206
|
+
node "$CLAUDE_CLI" -p "respond with exactly: turn3-done" \
|
|
207
|
+
--dangerously-skip-permissions -c \
|
|
208
|
+
> "$REPORT_DIR/test4-turn3-output.txt" 2>&1
|
|
209
|
+
snapshot_cache "test4-turn3-with-interceptor"
|
|
210
|
+
snapshot_debug "test4-turn3"
|
|
211
|
+
|
|
212
|
+
usage_after=$(count_usage)
|
|
213
|
+
echo " Usage entries added: $((usage_after - usage_before))"
|
|
214
|
+
echo ""
|
|
215
|
+
fi
|
|
216
|
+
|
|
217
|
+
# ─── Summary ────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
echo "=========================================="
|
|
220
|
+
echo " SUMMARY"
|
|
221
|
+
echo "=========================================="
|
|
222
|
+
echo ""
|
|
223
|
+
echo "All snapshots saved to: $REPORT_DIR"
|
|
224
|
+
echo ""
|
|
225
|
+
echo "Cache snapshots:"
|
|
226
|
+
for f in "$REPORT_DIR"/*.json; do
|
|
227
|
+
label=$(basename "$f" .json)
|
|
228
|
+
tier=$(python3 -c "import json; d=json.load(open('$f')); print(d.get('cache',{}).get('ttl_tier','?'))" 2>/dev/null || echo "?")
|
|
229
|
+
create=$(python3 -c "import json; d=json.load(open('$f')); print(d.get('cache',{}).get('cache_creation',0))" 2>/dev/null || echo "?")
|
|
230
|
+
read=$(python3 -c "import json; d=json.load(open('$f')); print(d.get('cache',{}).get('cache_read',0))" 2>/dev/null || echo "?")
|
|
231
|
+
e1h=$(python3 -c "import json; d=json.load(open('$f')); print(d.get('cache',{}).get('ephemeral_1h',0))" 2>/dev/null || echo "?")
|
|
232
|
+
e5m=$(python3 -c "import json; d=json.load(open('$f')); print(d.get('cache',{}).get('ephemeral_5m',0))" 2>/dev/null || echo "?")
|
|
233
|
+
printf " %-40s TTL=%-4s create=%-6s read=%-6s 1h=%-6s 5m=%-6s\n" "$label" "$tier" "$create" "$read" "$e1h" "$e5m"
|
|
234
|
+
done
|
|
235
|
+
|
|
236
|
+
# Check for interceptor actions in debug logs
|
|
237
|
+
echo ""
|
|
238
|
+
echo "Interceptor actions:"
|
|
239
|
+
for f in "$REPORT_DIR"/*-debug.log; do
|
|
240
|
+
[ -f "$f" ] || continue
|
|
241
|
+
label=$(basename "$f" -debug.log)
|
|
242
|
+
applied=$(grep -c "APPLIED:" "$f" 2>/dev/null || echo 0)
|
|
243
|
+
skipped=$(grep -c "SKIPPED:" "$f" 2>/dev/null || echo 0)
|
|
244
|
+
pins=$(grep -c "CONTENT PIN:" "$f" 2>/dev/null || echo 0)
|
|
245
|
+
echo " $label: $applied applied, $skipped skipped, $pins content pins"
|
|
246
|
+
done
|
|
247
|
+
|
|
248
|
+
echo ""
|
|
249
|
+
echo "Done. Review $REPORT_DIR for full details."
|