diff --git a/proxy/extensions/prefix-diff.mjs b/proxy/extensions/prefix-diff.mjs index 9baf6525..aa7bbe95 100644 --- a/proxy/extensions/prefix-diff.mjs +++ b/proxy/extensions/prefix-diff.mjs @@ -1,42 +1,176 @@ // prefix-diff — diagnostic extension for hunting cache-bust sources. // -// On every request, snapshots a small projection of the prefix (system -// prompt + tools + first 5 messages) and writes it to -// `~/.claude/cache-fix-snapshots/-last.json`. If a prior snapshot -// exists and differs, also writes a `-diff.json` and emits a -// one-line stderr summary. +// On every request, snapshots a projection of everything the prompt cache +// keys on and diffs it against the previous request's snapshot. Writes +// three artifacts under `~/.claude/cache-fix-snapshots/`: // -// No request mutation. The diagnostic is fail-open: any I/O error is -// swallowed silently in production. Set CACHE_FIX_DEBUG=1 to also log -// swallowed errors so silent failures stay observable. +// -last.json latest snapshot (overwritten; the diff baseline) +// -diff.json latest diff, full detail (overwritten) +// -events.jsonl append-only ledger, one bounded record per diff // -// Adaptation from preload's `snapshotPrefix(payload)` (preload.mjs ~1656): -// preload fired the diff once per process restart. The proxy is long-lived -// and supports hot-reload, so we drop the "first call" gate and run the -// diff per call. Trade-off: more disk writes, but each is tiny and the -// diagnostic value is higher (drift visible across every turn, not just -// at startup). +// No request mutation. Fail-open: any I/O error is swallowed. Set +// CACHE_FIX_DEBUG=1 to also log swallowed errors. +// +// --------------------------------------------------------------------- +// Design notes — the four blind spots this version closes (2026-07-27) +// +// A resume-after-quit bust was traced to `system=DIFFER` and went no +// further: the tooling could say the system prompt changed but not which +// block, let alone which bytes. Investigating that turned up four ways +// the diagnostic could report nothing, or report something misleading, +// on exactly the events it exists to catch. +// +// 1. SELF-DEFEATING KEY. The session key was +// `sha256(JSON.stringify(system).slice(0, 2000))` — derived from one +// of the very things being diffed. A system-prompt change inside that +// 2000-char window changed the key, so the lookup missed the prior +// snapshot, so NO diff was written and NO line was logged. The most +// important bust class could vanish without a trace, and whether it +// did came down to whether the changed bytes happened to sit before +// or after char 2000. The key now comes from the request's session-id +// header (`resolveSessionId`) — an identifier that cannot drift with +// the content it indexes. The content hash remains only as a fallback +// for requests without the header. +// +// 2. SELF-ERASING EVIDENCE. `-diff.json` was a single fixed path +// rewritten on every diff, so a bust's detail survived only until the +// next one — minutes, in an active session. Post-mortems ran against +// a file that had already been overwritten. The full-detail file +// stays (it is the convenient "what just happened" view), but every +// diff now also appends a bounded record to `-events.jsonl`, +// which is never rewritten in place. +// +// 3. NAME-ONLY TOOL HASHING. `toolsHash` hashed `tools.map(t => t.name)`. +// Schema and description changes — MCP servers reconnecting with +// revised definitions, a tool gaining a parameter — were invisible, +// and `tools=match` was routinely reported for a tools array whose +// bytes had changed. Tools are now hashed over their full JSON, with +// a per-tool hash so a diff names the specific tool. +// +// 4. OPAQUE SYSTEM HASH. One hash over the whole system array answered +// "did it change" and nothing else. Snapshots now store each block's +// full text (capped), so a diff reports the changed block's index, +// label, first differing character offset, and a context window from +// both sides — byte-level evidence rather than a boolean. +// +// Two further gaps closed at the same time: +// - TOP-LEVEL PARAMS (model, temperature, max_tokens, thinking, …) +// were not snapshotted at all, yet a change to any of them changes +// the cache key. A model switch mid-session read as "0 differences". +// - MESSAGE-ARRAY MIDDLE. Head (5) + markers (8) + tail (3) windows +// leave a long session's middle unobserved. Every message is now +// hashed — cheap, ~40 bytes each — so the diff reports the exact +// first divergent index regardless of where it lands. The three +// windows remain, now as detail providers for the region the hash +// chain points at. +// +// The organising rule, learned the hard way: the snapshot must cover +// everything the cache keys on, and the key under which it is stored +// must not be one of those things. +// +// Closing the diagnostic's own blind spots (2026-07-27, same day): the +// tool that hunts cache busts had three of its own. `output_config` +// (effort — "each effort level has its own cache"), `speed` (fast mode +// — adds a request header that is part of the cache key) and `betas` +// were body params TRACKED_PARAMS never listed, so an effort or +// fast-mode change read as "0 differences" the same way an untracked +// model switch used to. They're plain entries in TRACKED_PARAMS now; +// diffParams already deep-compares by JSON.stringify, so an object +// param like `output_config` (effort lives inside it) diffs correctly +// with no extra code. The fourth blind spot lived outside `ctx.body` +// entirely: the anthropic-beta REQUEST HEADER (e.g. +// context-1m-2025-08-07 — see auto-1m-guard.mjs, which exists because +// of exactly this header) is part of the cache key but was never +// snapshotted at all, so a beta-header-only bust — an effort/speed +// change never touches it either — was unattributable. `ctx.headers` +// reaches onRequest hooks (see pipeline.mjs's runOnRequest and every +// extension that reads `ctx.headers`), so the header is now snapshotted +// via auto-1m-guard's own `findBetaHeader`/`parseBetaTokens` and diffed +// as a normalized, sorted token set — order doesn't change the cache +// key, only membership does. +// --------------------------------------------------------------------- import { mkdir as _mkdir, readFile as _readFile, writeFile as _writeFile, rename as _rename, + appendFile as _appendFile, + stat as _stat, } from "node:fs/promises"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { claudeHome } from "../claude-home.mjs"; +import { resolveSessionId } from "./cache-telemetry.mjs"; +import { findBetaHeader, parseBetaTokens } from "./auto-1m-guard.mjs"; const ENABLED = process.env.CACHE_FIX_PREFIXDIFF === "1"; const DEBUG = process.env.CACHE_FIX_DEBUG === "1"; +// This extension's pipeline order (dev-loop.md, "Tap points"). A bare index +// was equated across tap points during the 587k attribution — a raw-capture +// index (order 60) read as if it were this module's forwarded-body index +// (order 680), naming the wrong message. Every record this module emits now +// carries WHICH tap point its indices belong to, so a reader (or a future +// attribution) never has to guess or re-derive it. Single source: the +// default export's `order` field reads this same constant rather than +// duplicating the literal. +const TAP_ORDER = 680; +const TAP_VIEW = `forwarded@${TAP_ORDER}`; + const DEFAULT_FS = { mkdir: _mkdir, readFile: _readFile, writeFile: _writeFile, rename: _rename, + appendFile: _appendFile, + stat: _stat, }; +// Per-block system text is stored in full so diffs can locate exact bytes. +// Capped so a pathological block can't bloat the snapshot; the cap is far +// above any real Claude Code system block. +const SYSTEM_TEXT_CAP = 20000; +// Context window shown either side of a byte-level divergence. +const DIVERGENCE_WINDOW = 120; +const MARKER_CAP = 8; +const MARKER_PREVIEW_CHARS = 200; +// Per-message preview carried on every messageHashes entry. Deliberately +// shorter than the marker preview: this one is paid on EVERY message of +// every snapshot, so it trades detail for a bounded per-request cost. +// Enough to recognise which message an index refers to and see whether its +// opening bytes moved; not a substitute for the full body. +const MESSAGE_PREVIEW_CHARS = 120; +// Rotate the append-only ledger past this size so it cannot grow forever. +const EVENTS_MAX_BYTES = 5 * 1024 * 1024; + +// Top-level request fields that participate in the cache key. Anything +// here changing invalidates the prefix, so all of it must be snapshotted. +const TRACKED_PARAMS = [ + "model", + "max_tokens", + "temperature", + "top_p", + "top_k", + "stop_sequences", + "thinking", + "tool_choice", + "service_tier", + "anthropic_version", + // Added 2026-07-27 (blind-spot closure): each of these participates in + // the cache key exactly like the params above, per Claude Code's own + // caching docs — output_config (effort: "each effort level has its own + // cache"), speed (fast mode: "adds a request header that is part of + // the cache key"), betas (the anthropic-beta BODY param some call + // sites use instead of the header — see the header-based tracking + // below for the header form). diffParams already deep-compares via + // JSON.stringify, so output_config's nested effort field diffs + // correctly with no dedicated code. + "output_config", + "speed", + "betas", +]; + function getSnapshotDir() { return join(claudeHome(), "cache-fix-snapshots"); } @@ -45,102 +179,691 @@ function debug(msg) { if (DEBUG) process.stderr.write(`[prefix-diff] ${msg}\n`); } +function sha(value, len = 16) { + return createHash("sha256").update(value).digest("hex").slice(0, len); +} + +// Fallback key only. Prefer resolveSessionKey(), which reads the session-id +// header — a key derived from the diffed content is the blind spot that +// made busts disappear (see design note 1). function computeSessionKey(system) { - return createHash("sha256") - .update(JSON.stringify(system).slice(0, 2000)) - .digest("hex") - .slice(0, 12); + return sha(JSON.stringify(system).slice(0, 2000), 12); +} + +/** + * Storage key for a request. The session-id header is stable across the + * life of a conversation and independent of the payload, so a snapshot + * stays findable no matter what changed inside it. Content hash is the + * fallback when no header is present (direct API calls, tests). + * + * The FILE key stays the session id alone — deliberately. Co-tenant + * separation happens inside the file (see `tenantId`), never by moving the + * path: a path that moves with content is design note 1's blind spot, where + * a changed system prompt misses its baseline and the bust is never logged. + */ +function resolveSessionKey(headers, system) { + const sid = headers ? resolveSessionId(headers) : null; + if (sid) return `s-${sha(sid, 12)}`; + return computeSessionKey(system); +} + +/** + * Which conversation a request belongs to, WITHIN one session id. + * + * The session-id header is not unique per conversation: a main session, + * every subagent it dispatches, and Claude Code's own background calls + * (title generation and friends) all carry the same id. With a single + * baseline per session, the diff compared each request against whichever + * co-tenant happened to go last, so conversations advancing normally + * rendered as violent prefix churn: + * + * msgs: 80->82 (main advancing) + * msgs: 82->40 model: opus-5 -> sonnet-5 <- subagent's turn + * msgs: 40->43 (subagent advancing) + * msgs: 43->2 (a small background call) + * + * Nothing is wrong in that trace, but it reads as thrashing — and it was + * read that way on 2026-07-27, as the cause of a 93k bust it had nothing + * to do with (cache_read climbed straight through; nothing was evicted). + * A diagnostic that manufactures false causes is worse than none, so each + * tenant gets its own baseline — stored side by side under the SAME file + * key, so nothing has to move. Same remedy insertion-normalization applies + * to its persisted canonical (commit e0bb7f2). + * + * Identity comes from `x-claude-code-agent-id` when CC sets it — that is + * authoritative and immune to prompt edits. It is often absent + * (workflow-agent-id-synthesis.mjs exists precisely because CC omits it + * for Workflow legs, CC#66761), so the fallback is the leading system + * block, which identifies the agent ("You are an interactive agent…" vs a + * subagent's brief) and is stable for a conversation's life. + * + * The fallback cannot be made exact: `workflow-agent-derivation.mjs:40-55` + * establishes that CC keeps agentId/agentType/run-id in IN-PROCESS state, + * never on the wire, and the Messages API has no slots for them. So a new + * tenant and a mid-conversation system-prompt change are genuinely + * indistinguishable from here; the read path below labels that case rather + * than guessing, and keeps the evidence either way. + */ +function tenantId(headers, system) { + const agentId = headers?.["x-claude-code-agent-id"]; + if (agentId) return sha(String(agentId), 8); + let text = "none"; + if (typeof system === "string") text = system; + else if (Array.isArray(system)) { + const first = system[0]; + const t = typeof first === "string" ? first : first?.text; + if (typeof t === "string") text = t; + } + // FULL text, not a prefix. Truncating to 400 chars made two conversations + // with the same long preamble collide into one tenant, silently merging + // their baselines — and a merged baseline is exactly the "sidecar churn" + // artifact this function exists to remove. Found 2026-07-28 by an audit + // sweep; same class as the session-key collisions fixed in + // insertion-normalization and deferred-tool-rewrite the same day: an + // identity computed more cheaply than the thing it identifies will collide, + // and the collision presents as churn rather than as a bug. + // + // Hashing the whole block costs one pass over a string already in memory. + // Agent-id short-circuits above when present, so this path is for traffic + // that carries no agent header. + return sha(text, 8); +} + +// Full-JSON tool hashing (design note 3) plus per-tool hashes so a diff +// can name the tool that changed rather than only that something did. +function buildToolsSnapshot(tools) { + if (!Array.isArray(tools) || tools.length === 0) { + return { hash: "none", count: 0, tools: [] }; + } + const entries = tools.map((t, i) => ({ + index: i, + name: t?.name ?? ``, + hash: sha(JSON.stringify(t ?? null)), + bytes: JSON.stringify(t ?? null).length, + })); + return { + // Order-sensitive: reordering tools changes the request bytes, so it + // is a real bust cause and must not hash equal. + hash: sha(JSON.stringify(entries.map((e) => [e.name, e.hash]))), + count: entries.length, + tools: entries, + }; } +// Backwards-compatible scalar hash (kept for the snapshot's flat field and +// for callers/tests that only need "did tools change at all"). function computeToolsHash(tools) { - if (!Array.isArray(tools) || tools.length === 0) return "none"; - // Match preload behavior: hash unsorted tool names so order changes - // surface as hash mismatches (a real cache-bust signal). - return createHash("sha256") - .update(JSON.stringify(tools.map((t) => t?.name ?? ""))) - .digest("hex") - .slice(0, 16); + return buildToolsSnapshot(tools).hash; } function computeSystemHash(system) { if (!system) return "none"; - return createHash("sha256") - .update(JSON.stringify(system)) - .digest("hex") - .slice(0, 16); + return sha(JSON.stringify(system)); } -// Project the first 5 user/assistant messages: strip cache_control, -// truncate text >500 chars with `...[N chars]` marker. Pure: returns -// new objects, never mutates input. -function truncatePrefixMessages(messages) { - if (!Array.isArray(messages)) return []; - return messages.slice(0, 5).map((msg) => { - if (!msg || !Array.isArray(msg.content)) { - return { role: msg?.role, content: msg?.content }; +// A short human label for a system block, so a diff line reads +// "block 2 (env)" instead of "block 2". Derived from content because +// the API gives blocks no identity of their own. +function labelSystemBlock(text) { + if (typeof text !== "string" || text.length === 0) return "empty"; + const head = text.slice(0, 200).replace(/\s+/g, " ").trim(); + if (/^You are Claude Code/i.test(head)) return "cc-identity"; + if (/claudeMd|CLAUDE\.md/.test(head)) return "claude-md"; + if (/|Working directory|Platform:/i.test(head)) return "env"; + if (/billing|x-anthropic-billing/i.test(head)) return "billing"; + if (/system-reminder/i.test(head)) return "system-reminder"; + if (/available skills|Skill tool/i.test(head)) return "skills"; + if (/agent types|subagent_type/i.test(head)) return "agents"; + return head.slice(0, 40) || "text"; +} + +// Store each system block's full text (capped) rather than one opaque +// hash, so computeDiff can point at the exact bytes that moved. +function buildSystemSnapshot(system) { + if (typeof system === "string") { + return [ + { + index: 0, + type: "string", + label: labelSystemBlock(system), + chars: system.length, + hash: sha(system), + text: system.slice(0, SYSTEM_TEXT_CAP), + truncated: system.length > SYSTEM_TEXT_CAP, + }, + ]; + } + if (!Array.isArray(system)) return []; + return system.map((block, i) => { + const text = typeof block?.text === "string" ? block.text : ""; + // Hash the whole block, not just its text — cache_control placement + // and any other field are part of the request bytes. + const { cache_control, ...rest } = block ?? {}; + return { + index: i, + type: block?.type ?? "unknown", + label: labelSystemBlock(text), + chars: text.length, + hash: sha(JSON.stringify(rest)), + hasCacheControl: Boolean(cache_control), + text: text.slice(0, SYSTEM_TEXT_CAP), + truncated: text.length > SYSTEM_TEXT_CAP, + }; + }); +} + +function buildParamsSnapshot(payload) { + const params = {}; + for (const key of TRACKED_PARAMS) { + if (payload && payload[key] !== undefined) params[key] = payload[key]; + } + return params; +} + +// Snapshot of the anthropic-beta REQUEST HEADER (blind spot: this is a +// header, not a body param, so it lives outside `payload` entirely and +// TRACKED_PARAMS can never cover it). Reuses auto-1m-guard's own +// findBetaHeader/parseBetaTokens — the same idiom that extension uses to +// read this exact header — rather than re-deriving header-lookup logic. +// Tokens are stored SORTED: reordering the header (e.g. CC re-serializing +// the same beta set in a different order) does not change the cache key, +// only membership does, so a sorted set is the right comparison basis. +// `present: false` (no header on the request) is a real, distinct state +// from "header present but empty" — both are normalized to an empty +// tokens array, but callers reading `present` can still tell them apart +// if that ever matters. +function buildBetaHeaderSnapshot(headers) { + const found = findBetaHeader(headers || null); + if (!found) return { present: false, tokens: [] }; + const tokens = parseBetaTokens(found.raw).slice().sort(); + return { present: true, tokens }; +} + +// Diff two beta-header snapshots by set membership (order-insensitive by +// construction, since tokens are stored pre-sorted). Old-format snapshots +// (pre-2026-07-27) have no `betaHeader` field at all — the `?? {tokens: +// []}` default treats "field absent" identically to "header absent", +// which is the correct degrade: nothing to compare against means no +// spurious cause on the first request after upgrade (design note: version- +// boundary safety, same rule as systemBlockDiffs/toolDiffs/paramDiffs). +function diffBetaHeader(prevSnap, nowSnap) { + const prevTokens = new Set((prevSnap ?? { tokens: [] }).tokens ?? []); + const nowTokens = new Set((nowSnap ?? { tokens: [] }).tokens ?? []); + const added = [...nowTokens].filter((t) => !prevTokens.has(t)); + const removed = [...prevTokens].filter((t) => !nowTokens.has(t)); + return { added, removed }; +} + +// Project a single message: strip cache_control, truncate text >500 chars +// with `...[N chars]` marker. Pure: returns a new object, never mutates +// input. Shared by the head and tail windows so both apply identical +// truncation rules. +function truncateOneMessage(msg) { + if (!msg || !Array.isArray(msg.content)) { + return { role: msg?.role, content: msg?.content }; + } + const cleanedContent = msg.content.map((block) => { + if (!block || typeof block !== "object") return block; + const { cache_control, ...rest } = block; + if (typeof rest.text === "string" && rest.text.length > 500) { + return { + ...rest, + text: rest.text.slice(0, 500) + `...[${rest.text.length} chars]`, + }; } - const cleanedContent = msg.content.map((block) => { + return rest; + }); + return { role: msg.role, content: cleanedContent }; +} + +// Strip cache_control WITHOUT truncating. The hash chain must not be +// blind to a change past char 500 — truncation there would report +// "match" for genuinely different bytes. +function stripCacheControl(msg) { + if (!msg || !Array.isArray(msg.content)) { + return { role: msg?.role, content: msg?.content }; + } + return { + role: msg.role, + content: msg.content.map((block) => { if (!block || typeof block !== "object") return block; const { cache_control, ...rest } = block; - if (typeof rest.text === "string" && rest.text.length > 500) { - return { - ...rest, - text: rest.text.slice(0, 500) + `...[${rest.text.length} chars]`, - }; - } return rest; + }), + }; +} + +// Hash EVERY message (design note: message-array middle). ~40 bytes per +// entry, so even a 1000-turn session costs ~40KB — cheap enough to make +// the head/marker/tail windows detail providers rather than the only +// detection surface. +function buildMessageHashes(messages) { + if (!Array.isArray(messages)) return []; + return messages.map((msg, i) => ({ + i, + role: msg?.role ?? "unknown", + h: sha(JSON.stringify(stripCacheControl(msg)), 12), + // Bounded preview on EVERY index, not just the head/tail windows. + // Rationale: the hash tells you an index changed, never how. A + // mid-history bust (2026-07-27, `messages@83(user)`, ~93k) was + // unattributable afterwards because index 83 falls outside head-5 and + // tail-3, and nothing else stored its content. A preview costs + // MESSAGE_PREVIEW_CHARS per message and makes the ledger + // self-sufficient for exactly the class that previously needed the + // live request — which by then no longer exists. + p: messageTextPreview(msg, MESSAGE_PREVIEW_CHARS), + })); +} + +// Project the first 5 messages (the head window): strip cache_control, +// truncate text >500 chars with `...[N chars]` marker. +function truncatePrefixMessages(messages) { + if (!Array.isArray(messages)) return []; + return messages.slice(0, 5).map(truncateOneMessage); +} + +// Project the last 3 messages (the tail window), same truncation rules as +// the head window. Catches busts near the live edge of long sessions that +// the head window (fixed at the start of the array) can never see. +function truncateTailMessages(messages) { + if (!Array.isArray(messages)) return []; + return messages.slice(-3).map(truncateOneMessage); +} + +// True if `msg` carries at least one content block with a cache_control +// marker. These are exactly the boundaries the API caches against. +function hasCacheControl(msg) { + if (!msg || !Array.isArray(msg.content)) return false; + return msg.content.some((b) => b && typeof b === "object" && b.cache_control); +} + +// Bounded preview of a message's content, first N chars. Used for the +// marker window and the per-index preview (not the full truncated-content +// detail the head/tail windows carry). +// +// Covers every content shape, not just text blocks. The original +// text-blocks-only version returned null for string-content messages and +// ""/null for tool_use- or tool_result-only messages — which are exactly +// the shapes mid-history mutations kept landing on (2026-07-27: several +// `messages@N(system)`/`(user)` mutation events carried null previews on +// both sides, and a 37k bust on 2026-07-28 was unattributable because +// every divergent message was tool-shaped). A preview that can be empty +// only when the content is empty keeps the ledger self-sufficient. +function messageTextPreview(msg, maxChars = MARKER_PREVIEW_CHARS) { + if (!msg) return null; + if (typeof msg.content === "string") return msg.content.slice(0, maxChars); + if (!Array.isArray(msg.content)) return null; + const parts = []; + for (const b of msg.content) { + if (!b || typeof b !== "object") continue; + if (b.type === "text" && typeof b.text === "string") { + parts.push(b.text); + } else if (b.type === "tool_use") { + parts.push(`[tool_use:${b.name ?? "?"} ${JSON.stringify(b.input ?? null)}]`); + } else if (b.type === "tool_result") { + const inner = + typeof b.content === "string" ? b.content : JSON.stringify(b.content ?? null); + parts.push(`[tool_result${b.is_error ? ":error" : ""} ${inner}]`); + } else if (b.type === "thinking") { + // Redacted on purpose: thinking bytes are the longest and least + // diagnostic; the block's presence is what matters for a diff. + parts.push("[thinking]"); + } else { + parts.push(`[${b.type ?? "unknown"}]`); + } + } + return parts.join(" ").slice(0, maxChars); +} + +// Hash a message's cache_control-stripped content. Used for the marker +// window so stored entries stay small (hash + preview, not full bodies). +function hashMessageContent(msg) { + return sha(JSON.stringify(truncateOneMessage(msg))); +} + +// Build the marker window: one entry per message carrying a cache_control +// block, in array order, capped at MARKER_CAP entries. Each entry stores +// only a hash + short preview, not the full message body — long sessions +// can accumulate many turns, and this window must stay cheap regardless +// of session length. +function buildMarkerSnapshot(messages) { + if (!Array.isArray(messages)) return []; + const markers = []; + for (let i = 0; i < messages.length && markers.length < MARKER_CAP; i++) { + const msg = messages[i]; + if (!hasCacheControl(msg)) continue; + markers.push({ + index: i, + hash: hashMessageContent(msg), + textPreview: messageTextPreview(msg), }); - return { role: msg.role, content: cleanedContent }; - }); + } + return markers; } -function buildSnapshot(payload) { +function buildSnapshot(payload, headers) { if (!payload || !payload.system) return null; return { timestamp: new Date().toISOString(), messageCount: Array.isArray(payload.messages) ? payload.messages.length : 0, toolsHash: computeToolsHash(payload.tools), systemHash: computeSystemHash(payload.system), + // Coverage added 2026-07-27 — see design notes at the top. + params: buildParamsSnapshot(payload), + systemBlocks: buildSystemSnapshot(payload.system), + toolsDetail: buildToolsSnapshot(payload.tools), + messageHashes: buildMessageHashes(payload.messages), prefixMessages: truncatePrefixMessages(payload.messages), + tailMessages: truncateTailMessages(payload.messages), + markerMessages: buildMarkerSnapshot(payload.messages), + // Blind spot 5 (2026-07-27): the anthropic-beta header lives outside + // `payload`, so it's threaded in separately rather than read off the + // body like everything else above. `headers` is optional — direct + // callers (tests, the fallback content-hash path) that don't have a + // headers object get {present:false, tokens:[]}, the same shape an + // old-format snapshot degrades to on diff. + betaHeader: buildBetaHeaderSnapshot(headers), }; } -function computeDiff(prev, current) { - const diff = { - timestamp: current.timestamp, - prevTimestamp: prev.timestamp, - toolsMatch: prev.toolsHash === current.toolsHash, - systemMatch: prev.systemHash === current.systemHash, - messageCountPrev: prev.messageCount, - messageCountNow: current.messageCount, - prefixDiffs: [], - }; - const prevMsgs = Array.isArray(prev.prefixMessages) ? prev.prefixMessages : []; - const nowMsgs = Array.isArray(current.prefixMessages) ? current.prefixMessages : []; +// Positional diff over two arrays of truncated messages (used for both the +// head and tail windows — same shape, same comparison rule: serialize and +// compare by array position). +function diffMessageWindow(prevMsgs, nowMsgs) { + const diffs = []; const maxIdx = Math.max(prevMsgs.length, nowMsgs.length); for (let i = 0; i < maxIdx; i++) { const prevSer = JSON.stringify(prevMsgs[i] ?? null); const nowSer = JSON.stringify(nowMsgs[i] ?? null); if (prevSer !== nowSer) { - diff.prefixDiffs.push({ - index: i, - prev: prevMsgs[i] ?? null, - now: nowMsgs[i] ?? null, + diffs.push({ index: i, prev: prevMsgs[i] ?? null, now: nowMsgs[i] ?? null }); + } + } + return diffs; +} + +// Diff the marker window. Markers are keyed by their message index in the +// original array (not by position in the marker list) so a shift caused by +// markers entering/leaving the cap doesn't read as spurious churn — each +// entry reports the marker's message index directly, as the diff consumer +// needs to know which prefix boundary moved. +function diffMarkerWindow(prevMarkers, nowMarkers) { + const prevByIdx = new Map((prevMarkers ?? []).map((m) => [m.index, m])); + const nowByIdx = new Map((nowMarkers ?? []).map((m) => [m.index, m])); + const allIdx = new Set([...prevByIdx.keys(), ...nowByIdx.keys()]); + const diffs = []; + for (const idx of [...allIdx].sort((a, b) => a - b)) { + const prevM = prevByIdx.get(idx) ?? null; + const nowM = nowByIdx.get(idx) ?? null; + if ((prevM?.hash ?? null) !== (nowM?.hash ?? null)) { + diffs.push({ index: idx, prev: prevM, now: nowM }); + } + } + return diffs; +} + +// First index at which two strings differ, or -1 if neither diverges +// before the shorter one ends. +function firstDifferingChar(a = "", b = "") { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i; + return a.length === b.length ? -1 : n; +} + +// Byte-level system diff: for each changed block, report where it changed +// and show a window from both sides. This is the evidence that was missing +// when `system=DIFFER` was all the tooling could say. +function diffSystemBlocks(prevBlocks = [], nowBlocks = []) { + const prevByIdx = new Map(prevBlocks.map((b) => [b.index, b])); + const nowByIdx = new Map(nowBlocks.map((b) => [b.index, b])); + const allIdx = [...new Set([...prevByIdx.keys(), ...nowByIdx.keys()])].sort( + (a, b) => a - b, + ); + const diffs = []; + for (const idx of allIdx) { + const p = prevByIdx.get(idx) ?? null; + const n = nowByIdx.get(idx) ?? null; + if ((p?.hash ?? null) === (n?.hash ?? null)) continue; + const at = firstDifferingChar(p?.text ?? "", n?.text ?? ""); + const from = at < 0 ? 0 : Math.max(0, at - DIVERGENCE_WINDOW / 2); + diffs.push({ + index: idx, + label: n?.label ?? p?.label ?? null, + prevChars: p?.chars ?? null, + nowChars: n?.chars ?? null, + added: !p, + removed: !n, + charAt: at, + prevWindow: (p?.text ?? "").slice(from, from + DIVERGENCE_WINDOW), + nowWindow: (n?.text ?? "").slice(from, from + DIVERGENCE_WINDOW), + }); + } + return diffs; +} + +// Name the tools that changed. Keyed by name so a reordering reports as +// index movement on stable names rather than as N unrelated changes. +function diffTools(prevDetail, nowDetail) { + const prev = new Map((prevDetail?.tools ?? []).map((t) => [t.name, t])); + const now = new Map((nowDetail?.tools ?? []).map((t) => [t.name, t])); + const names = [...new Set([...prev.keys(), ...now.keys()])]; + const diffs = []; + for (const name of names) { + const p = prev.get(name) ?? null; + const n = now.get(name) ?? null; + if (!p) diffs.push({ name, change: "added", index: n.index }); + else if (!n) diffs.push({ name, change: "removed", index: p.index }); + else if (p.hash !== n.hash) + diffs.push({ + name, + change: "schema", + index: n.index, + prevBytes: p.bytes, + nowBytes: n.bytes, }); + else if (p.index !== n.index) + diffs.push({ name, change: "reordered", prevIndex: p.index, index: n.index }); + } + return diffs; +} + +function diffParams(prevParams = {}, nowParams = {}) { + const keys = [...new Set([...Object.keys(prevParams), ...Object.keys(nowParams)])]; + const diffs = []; + for (const k of keys) { + const p = JSON.stringify(prevParams[k] ?? null); + const n = JSON.stringify(nowParams[k] ?? null); + if (p !== n) diffs.push({ key: k, prev: prevParams[k] ?? null, now: nowParams[k] ?? null }); + } + return diffs; +} + +// The exact first message index whose content changed — the answer the +// three fixed windows could only approximate. Appends and truncations are +// reported separately from in-place mutation, because only the latter +// invalidates a prefix the API had already cached. +function diffMessageHashes(prevHashes = [], nowHashes = []) { + const n = Math.min(prevHashes.length, nowHashes.length); + let firstDivergent = -1; + for (let i = 0; i < n; i++) { + if (prevHashes[i]?.h !== nowHashes[i]?.h) { + firstDivergent = i; + break; } } - return diff; + return { + firstDivergentIndex: firstDivergent, + commonLength: n, + prevLength: prevHashes.length, + nowLength: nowHashes.length, + // True when the shared prefix is intact and the new array only grew — + // the normal, cache-friendly shape of a conversation advancing. + appendOnly: firstDivergent === -1 && nowHashes.length >= prevHashes.length, + divergentRole: firstDivergent >= 0 ? nowHashes[firstDivergent]?.role ?? null : null, + }; +} + +// Previews of the first divergent message, prev side and now side. Returns +// nulls when there is no divergence, when the index is out of range, or when +// either snapshot predates the `p` field (older files simply have no preview +// to give — degrade, never throw). +function divergentPreviews(prevHashes, nowHashes) { + const chain = diffMessageHashes(prevHashes, nowHashes); + const i = chain?.firstDivergentIndex ?? -1; + if (i < 0) return { divergentPrev: null, divergentNow: null }; + return { + divergentPrev: (Array.isArray(prevHashes) ? prevHashes[i]?.p : null) ?? null, + divergentNow: (Array.isArray(nowHashes) ? nowHashes[i]?.p : null) ?? null, + }; +} + +function computeDiff(prev, current) { + const prevMsgs = Array.isArray(prev.prefixMessages) ? prev.prefixMessages : []; + const nowMsgs = Array.isArray(current.prefixMessages) ? current.prefixMessages : []; + const prevTail = Array.isArray(prev.tailMessages) ? prev.tailMessages : []; + const nowTail = Array.isArray(current.tailMessages) ? current.tailMessages : []; + const prevMarkers = Array.isArray(prev.markerMessages) ? prev.markerMessages : []; + const nowMarkers = Array.isArray(current.markerMessages) ? current.markerMessages : []; + + return { + timestamp: current.timestamp, + prevTimestamp: prev.timestamp, + toolsMatch: prev.toolsHash === current.toolsHash, + systemMatch: prev.systemHash === current.systemHash, + messageCountPrev: prev.messageCount, + messageCountNow: current.messageCount, + prefixDiffs: diffMessageWindow(prevMsgs, nowMsgs), + tailDiffs: diffMessageWindow(prevTail, nowTail), + markerDiffs: diffMarkerWindow(prevMarkers, nowMarkers), + markerCount: nowMarkers.length, + // Coverage added 2026-07-27. Older snapshots lack these fields; the + // helpers default to empty so a diff across a version boundary + // degrades to the old behaviour instead of throwing. + systemBlockDiffs: diffSystemBlocks(prev.systemBlocks, current.systemBlocks), + toolDiffs: diffTools(prev.toolsDetail, current.toolsDetail), + paramDiffs: diffParams(prev.params, current.params), + messageChain: diffMessageHashes(prev.messageHashes, current.messageHashes), + // Previews of the first divergent message on both sides, so the ledger + // records HOW it changed, not only that it did. Snapshots predating the + // `p` field yield null here rather than throwing. + ...divergentPreviews(prev.messageHashes, current.messageHashes), + // prev.betaHeader is undefined on a pre-2026-07-27 snapshot; diffBetaHeader's + // `?? {tokens: []}` default degrades that to "no header" rather than throwing. + betaHeaderDiff: diffBetaHeader(prev.betaHeader, current.betaHeader), + }; } function diffHasChanges(diff) { return ( diff.prefixDiffs.length > 0 || + diff.tailDiffs.length > 0 || + diff.markerDiffs.length > 0 || !diff.toolsMatch || !diff.systemMatch || - diff.messageCountPrev !== diff.messageCountNow + diff.messageCountPrev !== diff.messageCountNow || + (diff.paramDiffs?.length ?? 0) > 0 || + (diff.messageChain && diff.messageChain.firstDivergentIndex >= 0) || + (diff.betaHeaderDiff && + (diff.betaHeaderDiff.added.length > 0 || diff.betaHeaderDiff.removed.length > 0)) ); } +// Rank the causes so the log line leads with the one that invalidates the +// most of the prefix. Order matters diagnostically: a param or system +// change re-writes everything after it, while a tail-only change is the +// normal cost of the conversation advancing. +function summariseCauses(diff) { + const causes = []; + if ((diff.paramDiffs?.length ?? 0) > 0) + causes.push(`params:${diff.paramDiffs.map((d) => d.key).join(",")}`); + if (!diff.systemMatch) { + const labels = (diff.systemBlockDiffs ?? []) + .map((d) => `${d.index}:${d.label ?? "?"}@${d.charAt}`) + .join(","); + causes.push(`system[${labels || "unlocated"}]`); + } + if (!diff.toolsMatch) { + const names = (diff.toolDiffs ?? []).map((d) => `${d.name}:${d.change}`).join(","); + causes.push(`tools[${names || "unlocated"}]`); + } + const chain = diff.messageChain; + if (chain && chain.firstDivergentIndex >= 0) + causes.push(`messages@${chain.firstDivergentIndex}(${chain.divergentRole ?? "?"})`); + else if (chain && !chain.appendOnly && chain.nowLength < chain.prevLength) + causes.push(`messages:truncated(${chain.prevLength}→${chain.nowLength})`); + const betaDiff = diff.betaHeaderDiff; + if (betaDiff && (betaDiff.added.length > 0 || betaDiff.removed.length > 0)) { + const parts = []; + if (betaDiff.added.length) parts.push(`+${betaDiff.added.join(",")}`); + if (betaDiff.removed.length) parts.push(`-${betaDiff.removed.join(",")}`); + causes.push(`header:anthropic-beta[${parts.join(" ")}]`); + } + return causes; +} + +// Bounded record for the append-only ledger: enough to identify and +// classify a bust months later, without the full message bodies that +// make the detail file large. +function buildEventRecord(diff, sessionKey, sessionId) { + return { + ts: diff.timestamp, + prevTs: diff.prevTimestamp, + key: sessionKey, + sid: sessionId ?? null, + // Tap point (dev-loop.md): every index in this record — chain.first + // above all — is a position in the FORWARDED body at this module's own + // pipeline order, never a raw-capture or other-extension index. Naming + // it here means a reader never has to know or re-derive the order to + // avoid the equating-across-tap-points mistake. + view: TAP_VIEW, + causes: summariseCauses(diff), + systemMatch: diff.systemMatch, + toolsMatch: diff.toolsMatch, + msgs: `${diff.messageCountPrev}->${diff.messageCountNow}`, + chain: diff.messageChain + ? { + first: diff.messageChain.firstDivergentIndex, + role: diff.messageChain.divergentRole, + appendOnly: diff.messageChain.appendOnly, + // The divergent message's own content, bounded. Without this the + // ledger says WHICH index changed but never HOW: on 2026-07-27 a + // ~93k bust reported `messages@83(user)` and the mutation could not + // be identified afterwards from any stored artifact — head-5/tail-3 + // are the only windows carrying content, and 83 is in neither. + // Investigating it required the live request, which no longer + // existed. Captured at diff time, when the index is already known. + prevContent: diff.divergentPrev ?? null, + nowContent: diff.divergentNow ?? null, + } + : null, + params: (diff.paramDiffs ?? []).map((d) => ({ + k: d.key, + prev: typeof d.prev === "object" ? "" : d.prev, + now: typeof d.now === "object" ? "" : d.now, + })), + system: (diff.systemBlockDiffs ?? []).map((d) => ({ + i: d.index, + label: d.label, + at: d.charAt, + prevChars: d.prevChars, + nowChars: d.nowChars, + prevWin: d.prevWindow, + nowWin: d.nowWindow, + })), + tools: (diff.toolDiffs ?? []).map((d) => ({ n: d.name, c: d.change })), + betaHeader: + diff.betaHeaderDiff && + (diff.betaHeaderDiff.added.length > 0 || diff.betaHeaderDiff.removed.length > 0) + ? { added: diff.betaHeaderDiff.added, removed: diff.betaHeaderDiff.removed } + : null, + windows: { + head: diff.prefixDiffs.length, + markers: diff.markerDiffs.length, + tail: diff.tailDiffs.length, + }, + }; +} + // Atomic write: stage to a unique-per-invocation .tmp, then rename to // final path. The unique suffix is essential under concurrency — two // parallel callers writing to the same finalPath would otherwise share @@ -159,6 +882,21 @@ async function atomicWriteJson(finalPath, obj, fs) { await fs.rename(tmpPath, finalPath); } +// Append one JSONL record, rotating past EVENTS_MAX_BYTES so the ledger +// is permanent but not unbounded. Rotation keeps exactly one generation: +// the point is surviving the next overwrite, not indefinite history. +async function appendEvent(eventsPath, record, fs) { + try { + const st = await fs.stat(eventsPath).catch(() => null); + if (st && st.size > EVENTS_MAX_BYTES) { + await fs.rename(eventsPath, `${eventsPath}.1`).catch(() => {}); + } + } catch { + // Rotation is best-effort; never block the append on it. + } + await fs.appendFile(eventsPath, JSON.stringify(record) + "\n"); +} + /** * Snapshot the prefix of `payload` and diff against the prior snapshot. * @@ -168,21 +906,64 @@ async function atomicWriteJson(finalPath, obj, fs) { * @param {object} payload The request body (system, tools, messages). * @param {object} options * @param {string} [options.dir] Snapshot directory. Defaults to ~/.claude/cache-fix-snapshots. + * @param {object} [options.headers] Request headers, used to derive a + * drift-proof storage key from the session id. * @param {object} [options.fs] fs/promises overrides for tests: - * { mkdir, readFile, writeFile, rename }. + * { mkdir, readFile, writeFile, rename, appendFile, stat }. * Any subset replaces the corresponding default. * @returns {Promise<{ key, wroteSnapshot, wroteDiff } | null>} Result for tests; null if no system. */ +// Serialize per-key so the tenants read-modify-write below cannot interleave. +// +// The read and the write are separated by awaits, so two concurrent requests +// on the SAME session key both read the old map and the second write clobbers +// the first — a classic lost update. Measured: 10 distinct tenants fired via +// Promise.all left 1 of 10 baselines on disk, which defeats per-tenant +// baselines exactly when co-tenants are genuinely concurrent (a main session +// with parallel subagents) rather than merely interleaved. +// +// One promise chain per key. The proxy is a single daemon process, so +// in-process serialization is sufficient; there is no second writer to this +// path. Entries are dropped when their chain drains, so the map cannot grow +// with session count. +const keyLocks = new Map(); + +function withKeyLock(key, fn) { + const prev = keyLocks.get(key) ?? Promise.resolve(); + // Never let a rejection poison the chain for later callers. + const run = prev.then(fn, fn); + keyLocks.set( + key, + run.then( + () => { + if (keyLocks.get(key) === run) keyLocks.delete(key); + }, + () => { + if (keyLocks.get(key) === run) keyLocks.delete(key); + }, + ), + ); + return run; +} + async function snapshotPrefix(payload, options = {}) { - const current = buildSnapshot(payload); + const headers = options.headers || null; + const current = buildSnapshot(payload, headers); if (!current) return null; + const lockKey = resolveSessionKey(headers, payload.system); + return withKeyLock(lockKey, () => snapshotPrefixLocked(payload, options, current, headers)); +} + +async function snapshotPrefixLocked(payload, options, current, headers) { const dir = options.dir || getSnapshotDir(); const fs = { ...DEFAULT_FS, ...(options.fs || {}) }; - const sessionKey = computeSessionKey(payload.system); + const sessionId = headers ? resolveSessionId(headers) : null; + const sessionKey = resolveSessionKey(headers, payload.system); const lastPath = join(dir, `${sessionKey}-last.json`); const diffPath = join(dir, `${sessionKey}-diff.json`); + const eventsPath = join(dir, `${sessionKey}-events.jsonl`); // Ensure directory exists. mkdir failure aborts — without dir, nothing // else can succeed. @@ -195,31 +976,83 @@ async function snapshotPrefix(payload, options = {}) { // Read prior snapshot if it exists. Missing file is normal; corrupt // file is treated as no prior (skip diff, proceed to overwrite). + // + // The file holds one baseline PER TENANT (see tenantId): co-tenants on a + // shared session id must not diff against each other. Legacy files hold a + // bare snapshot instead of a `tenants` map; those load as the baseline for + // whichever tenant reads first, so the upgrade costs no lost diff. + const tid = tenantId(headers, payload.system); let prev = null; + let stored = null; try { const txt = await fs.readFile(lastPath, "utf-8"); - prev = JSON.parse(txt); + stored = JSON.parse(txt); } catch (err) { if (err && err.code !== "ENOENT") { debug(`prior snapshot unreadable at ${lastPath}: ${err?.message ?? err}`); } } + // A request whose tenant we have never seen is ambiguous: either a + // co-tenant's first turn (diffing it against another conversation is + // exactly the false-cause bug) or the same conversation whose system + // prompt just changed (refusing to diff it would silently drop a real + // bust — design note 1's blind spot). Both readings stay available: + // diff against the last writer so nothing is lost, and mark the record + // `crossTenant` so a reader can tell a genuine cause from this artifact. + // Evidence is kept; only its interpretation is labelled. + let crossTenant = false; + if (stored && stored.tenants) { + prev = stored.tenants[tid] || null; + if (!prev && stored.lastTenant && stored.tenants[stored.lastTenant]) { + prev = stored.tenants[stored.lastTenant]; + crossTenant = true; + } + } else if (stored) { + prev = stored; + } // Compute and write diff if anything changed. let wroteDiff = false; if (prev) { const diff = computeDiff(prev, current); if (diffHasChanges(diff)) { + // Ledger first: the append is what must survive, and it must not be + // lost to a failure in the (overwritable) detail file. + try { + const record = buildEventRecord(diff, sessionKey, sessionId); + if (crossTenant) record.crossTenant = true; + await appendEvent(eventsPath, record, fs); + } catch (err) { + debug(`event append failed at ${eventsPath}: ${err?.message ?? err}`); + } try { await atomicWriteJson(diffPath, diff, fs); wroteDiff = true; // Always log the summary line when a diff fires (not just under - // CACHE_FIX_DEBUG) — this is the diagnostic's whole purpose. + // CACHE_FIX_DEBUG) — this is the diagnostic's whole purpose. The + // per-window counts (head/markers/tail) tell the reader which + // window caught the bust; `cause=` names the specific block, + // tool, param, or message index so the line alone is often the + // whole answer. + const totalDiffs = + diff.prefixDiffs.length + diff.markerDiffs.length + diff.tailDiffs.length; + const causes = summariseCauses(diff); process.stderr.write( - `[prefix-diff] ${sessionKey}: ${diff.prefixDiffs.length} differences, ` + + `[prefix-diff] ${sessionKey} (${TAP_VIEW}): ${totalDiffs} differences ` + + `(head=${diff.prefixDiffs.length}, markers=${diff.markerDiffs.length}, ` + + `tail=${diff.tailDiffs.length}), ` + `tools=${diff.toolsMatch ? "match" : "DIFFER"}, ` + `system=${diff.systemMatch ? "match" : "DIFFER"}, ` + - `messages=${diff.messageCountPrev}→${diff.messageCountNow}\n`, + `messages=${diff.messageCountPrev}→${diff.messageCountNow}, ` + + `marker_count=${diff.markerCount}` + + (causes.length ? `, cause=${causes.join(" | ")}` : "") + + // Loud, because the unlabelled version of this line is what + // caused a 93k bust to be misattributed on 2026-07-27. + (crossTenant + ? `, CROSS-TENANT=baseline belongs to another conversation on this ` + + `session id (subagent/background call) — NOT evidence of a bust` + : "") + + `\n`, ); } catch (err) { debug(`diff write failed at ${diffPath}: ${err?.message ?? err}`); @@ -229,9 +1062,37 @@ async function snapshotPrefix(payload, options = {}) { // Always write the new snapshot atomically so the next call has a // fresh baseline. On failure, prior snapshot is intact. + // + // Write back every tenant, replacing only this one: a co-tenant's + // baseline must survive our turn, or the next request from it diffs + // against nothing and the churn returns. `lastTenant` drives the + // fallback on the read side above. Cap the map so a long-lived session + // that spawns many subagents cannot grow the file without bound — + // evicting the oldest costs at most one stale baseline. let wroteSnapshot = false; + const tenants = { ...(stored?.tenants || {}), [tid]: current }; + const MAX_TENANTS = 16; + // Evict by the timestamp each baseline carries, NOT by key order. + // + // The previous version sliced `Object.keys()` as if it were insertion + // order. It is not: ECMA-262 enumerates integer-like keys FIRST, in + // ascending numeric order, regardless of when they were added. Tenant ids + // are 8-hex sha slices, so roughly one in 16 is all-digits — such a tenant + // sorts to the front and gets evicted ahead of genuinely older entries, + // however recently it was written. + // + // `timestamp` is set by buildSnapshot on every write, so oldest-first is + // well-defined and independent of key shape. The current tenant is never + // evicted: it was just written and is the one the next request needs. + const ids = Object.keys(tenants); + if (ids.length > MAX_TENANTS) { + const byAge = ids + .filter((k) => k !== tid) + .sort((a, b) => String(tenants[a]?.timestamp ?? "").localeCompare(String(tenants[b]?.timestamp ?? ""))); + for (const old of byAge.slice(0, ids.length - MAX_TENANTS)) delete tenants[old]; + } try { - await atomicWriteJson(lastPath, current, fs); + await atomicWriteJson(lastPath, { tenants, lastTenant: tid }, fs); wroteSnapshot = true; } catch (err) { debug(`snapshot write failed at ${lastPath}: ${err?.message ?? err}`); @@ -250,26 +1111,48 @@ export { buildSnapshot, computeDiff, computeSessionKey, + resolveSessionKey, + tenantId, truncatePrefixMessages, + truncateTailMessages, + buildMarkerSnapshot, + buildSystemSnapshot, + buildToolsSnapshot, + buildMessageHashes, + messageTextPreview, + diffSystemBlocks, + diffTools, + diffParams, + diffMessageHashes, + summariseCauses, + buildEventRecord, diffHasChanges, + buildBetaHeaderSnapshot, + diffBetaHeader, + TAP_ORDER, + TAP_VIEW, }; export default { name: "prefix-diff", description: - "Snapshot prefix (first 5 msgs + system + tools) and diff against previous run for cache-bust hunting", + "Snapshot everything the prompt cache keys on (system blocks, full tool " + + "schemas, top-level params including output_config/speed/betas, the " + + "anthropic-beta request header, per-message hash chain, head/marker/tail " + + "windows) and diff against the previous request, naming the exact block, " + + "tool, param, header, or message index that changed", // Always loaded; gated at runtime by CACHE_FIX_PREFIXDIFF=1 inside onRequest. // This matches the acceptance criteria (env var alone activates) — the // extension is cheap to load (one no-op check per request when disabled). enabled: true, - order: 680, + order: TAP_ORDER, async onRequest(ctx) { if (!ENABLED) return; if (!ctx || !ctx.body) return; // snapshotPrefix never throws; double-belt try/catch is defense in depth. try { - await snapshotPrefix(ctx.body); + await snapshotPrefix(ctx.body, { headers: ctx.headers }); } catch (err) { debug(`onRequest unexpected: ${err?.message ?? err}`); } diff --git a/test/proxy-prefix-diff.test.mjs b/test/proxy-prefix-diff.test.mjs index 33bc56c0..9d4651a3 100644 --- a/test/proxy-prefix-diff.test.mjs +++ b/test/proxy-prefix-diff.test.mjs @@ -10,8 +10,26 @@ import ext, { buildSnapshot, computeDiff, computeSessionKey, + resolveSessionKey, + tenantId, truncatePrefixMessages, + truncateTailMessages, + buildMarkerSnapshot, + buildSystemSnapshot, + buildToolsSnapshot, + buildMessageHashes, + messageTextPreview, + diffSystemBlocks, + diffTools, + diffParams, + diffMessageHashes, + summariseCauses, + buildEventRecord, diffHasChanges, + buildBetaHeaderSnapshot, + diffBetaHeader, + TAP_ORDER, + TAP_VIEW, } from "../proxy/extensions/prefix-diff.mjs"; // `import.meta.dirname` requires Node 20.11+; CI matrix includes Node 18, @@ -121,6 +139,117 @@ test("truncatePrefixMessages slices to first 5 messages", () => { assert.equal(out[4].content[0].text, "msg 4"); }); +// --- Tail window tests --- + +test("truncateTailMessages slices to last 3 messages", () => { + const messages = Array.from({ length: 10 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: `msg ${i}` }], + })); + const out = truncateTailMessages(messages); + assert.equal(out.length, 3); + assert.equal(out[0].content[0].text, "msg 7"); + assert.equal(out[2].content[0].text, "msg 9"); +}); + +test("truncateTailMessages applies the same truncation rules as the head window", () => { + const longText = "y".repeat(600); + const messages = [ + { role: "user", content: [{ type: "text", text: "short" }] }, + { + role: "assistant", + content: [ + { type: "text", text: longText, cache_control: { type: "ephemeral" } }, + ], + }, + ]; + const out = truncateTailMessages(messages); + assert.equal(out[1].content[0].cache_control, undefined); + assert.ok(out[1].content[0].text.endsWith("...[600 chars]")); +}); + +test("truncateTailMessages handles fewer than 3 messages", () => { + const messages = [{ role: "user", content: [{ type: "text", text: "only" }] }]; + const out = truncateTailMessages(messages); + assert.equal(out.length, 1); +}); + +// --- Marker window tests --- + +test("buildMarkerSnapshot returns empty array when no messages carry cache_control", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + ]; + assert.deepEqual(buildMarkerSnapshot(messages), []); +}); + +test("buildMarkerSnapshot picks up messages carrying a cache_control block, in order", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "no marker" }] }, + { + role: "user", + content: [ + { type: "text", text: "marked one", cache_control: { type: "ephemeral" } }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "no marker either" }] }, + { + role: "user", + content: [ + { type: "text", text: "marked two", cache_control: { type: "ephemeral" } }, + ], + }, + ]; + const markers = buildMarkerSnapshot(messages); + assert.equal(markers.length, 2); + assert.equal(markers[0].index, 1); + assert.equal(markers[1].index, 3); + assert.equal(typeof markers[0].hash, "string"); + assert.ok(markers[0].textPreview.includes("marked one")); +}); + +test("buildMarkerSnapshot caps at 8 stored markers even with more in the array", () => { + const messages = Array.from({ length: 12 }, (_, i) => ({ + role: "user", + content: [ + { type: "text", text: `m${i}`, cache_control: { type: "ephemeral" } }, + ], + })); + const markers = buildMarkerSnapshot(messages); + assert.equal(markers.length, 8); + assert.equal(markers[0].index, 0); + assert.equal(markers[7].index, 7); +}); + +test("buildMarkerSnapshot hash changes when marked message content changes", () => { + const markedMsg = (text) => ({ + role: "user", + content: [{ type: "text", text, cache_control: { type: "ephemeral" } }], + }); + const a = buildMarkerSnapshot([markedMsg("v1")]); + const b = buildMarkerSnapshot([markedMsg("v2")]); + assert.notEqual(a[0].hash, b[0].hash); +}); + +test("buildMarkerSnapshot hash strips cache_control before hashing (marker presence alone isn't a diff)", () => { + const a = buildMarkerSnapshot([ + { + role: "user", + content: [{ type: "text", text: "same", cache_control: { type: "ephemeral" } }], + }, + ]); + const b = buildMarkerSnapshot([ + { + role: "user", + content: [ + { type: "text", text: "same", cache_control: { type: "ephemeral", ttl: "1h" } }, + ], + }, + ]); + assert.equal(a[0].hash, b[0].hash); +}); + test("buildSnapshot returns null when payload has no system", () => { assert.equal(buildSnapshot({ messages: [] }), null); assert.equal(buildSnapshot({ system: null }), null); @@ -136,6 +265,19 @@ test("buildSnapshot includes systemHash, toolsHash, messageCount, prefixMessages assert.equal(snap.prefixMessages.length, 2); }); +test("buildSnapshot includes tailMessages and markerMessages windows", () => { + const messages = Array.from({ length: 10 }, (_, i) => ({ + role: "user", + content: [{ type: "text", text: `msg ${i}` }], + })); + messages[6].content[0].cache_control = { type: "ephemeral" }; + const snap = buildSnapshot(makePayload({ messages })); + assert.equal(snap.tailMessages.length, 3); + assert.equal(snap.tailMessages[2].content[0].text, "msg 9"); + assert.equal(snap.markerMessages.length, 1); + assert.equal(snap.markerMessages[0].index, 6); +}); + test("computeDiff: identical snapshots → no differences", () => { const snap = buildSnapshot(makePayload()); const diff = computeDiff(snap, snap); @@ -171,6 +313,63 @@ test("computeDiff: differing prefixMessages produce indexed diff entries", () => assert.equal(diff.prefixDiffs[0].index, 0); }); +test("computeDiff: tail window catches a change outside the head window's first 5 messages", () => { + // The blind-spot regression: a long session where the change lands past + // index 5 must NOT report 0 differences just because the head window + // (first 5 messages) is unaffected. + const longSession = (tailText) => + makePayload({ + messages: [ + ...Array.from({ length: 7 }, (_, i) => ({ + role: "user", + content: [{ type: "text", text: `head-stable ${i}` }], + })), + { role: "user", content: [{ type: "text", text: tailText }] }, + ], + }); + const a = buildSnapshot(longSession("original tail")); + const b = buildSnapshot(longSession("BUSTED tail")); + const diff = computeDiff(a, b); + assert.equal(diff.prefixDiffs.length, 0, "head window is unaffected by design"); + assert.ok(diff.tailDiffs.length > 0, "tail window must catch the change"); + assert.equal(diffHasChanges(diff), true); +}); + +test("computeDiff: marker window catches a change at a cache_control boundary outside head/tail", () => { + const messages = (markerText) => [ + ...Array.from({ length: 5 }, (_, i) => ({ + role: "user", + content: [{ type: "text", text: `head ${i}` }], + })), + { + role: "user", + content: [ + { type: "text", text: markerText, cache_control: { type: "ephemeral" } }, + ], + }, + ...Array.from({ length: 20 }, (_, i) => ({ + role: "user", + content: [{ type: "text", text: `filler ${i}` }], + })), + ]; + const a = buildSnapshot(makePayload({ messages: messages("marker v1") })); + const b = buildSnapshot(makePayload({ messages: messages("marker v2") })); + const diff = computeDiff(a, b); + assert.equal(diff.prefixDiffs.length, 0, "head window doesn't reach the marker"); + assert.equal(diff.tailDiffs.length, 0, "tail window doesn't reach the marker either"); + assert.equal(diff.markerDiffs.length, 1); + assert.equal(diff.markerDiffs[0].index, 5); + assert.equal(diffHasChanges(diff), true); +}); + +test("computeDiff: identical marker/tail windows produce no marker/tail diffs", () => { + const snap = buildSnapshot(makePayload()); + const diff = computeDiff(snap, snap); + assert.equal(diff.tailDiffs.length, 0); + assert.equal(diff.markerDiffs.length, 0); + assert.equal(diff.markerCount, 0); +}); + test("computeDiff: messageCount change is reflected", () => { const a = buildSnapshot(makePayload()); const b = buildSnapshot( @@ -207,8 +406,9 @@ test("snapshotPrefix: writes -last.json on first invocation", async () => { const expected = `${result.key}-last.json`; const files = await listFiles(dir); assert.deepEqual(files, [expected]); + // The file holds one baseline per tenant (co-tenants share a session id). const json = JSON.parse(await readFile(join(dir, expected), "utf-8")); - assert.equal(json.messageCount, 2); + assert.equal(json.tenants[json.lastTenant].messageCount, 2); } finally { await rm(dir, { recursive: true, force: true }); } @@ -249,7 +449,13 @@ test("snapshotPrefix: differing second call writes -diff.json with prefixDi assert.ok(r2.wroteSnapshot); assert.ok(r2.wroteDiff); const files = await listFiles(dir); - assert.deepEqual(files, [`${r1.key}-diff.json`, `${r1.key}-last.json`]); + // The events ledger is written alongside the detail file (see design + // note 2 — the detail file alone was self-erasing). + assert.deepEqual(files, [ + `${r1.key}-diff.json`, + `${r1.key}-events.jsonl`, + `${r1.key}-last.json`, + ]); const diff = JSON.parse(await readFile(join(dir, `${r1.key}-diff.json`), "utf-8")); assert.ok(diff.prefixDiffs.length > 0); assert.equal(diff.prefixDiffs[0].index, 0); @@ -313,7 +519,7 @@ test("snapshotPrefix: corrupt prior snapshot is treated as no-prior", async () = assert.equal(result.wroteDiff, false); // Last.json should now be valid JSON const json = JSON.parse(await readFile(lastPath, "utf-8")); - assert.equal(json.messageCount, 2); + assert.equal(json.tenants[json.lastTenant].messageCount, 2); } finally { await rm(dir, { recursive: true, force: true }); } @@ -354,7 +560,7 @@ test("snapshotPrefix: concurrent invocations both succeed; final snapshot matche toolsHash, systemHash, }); - const actual = JSON.stringify(deterministic(json)); + const actual = JSON.stringify(deterministic(json.tenants[json.lastTenant])); assert.ok( actual === JSON.stringify(deterministic(snapshotA)) || actual === JSON.stringify(deterministic(snapshotB)), @@ -471,3 +677,720 @@ test("default has correct extension contract metadata", () => { assert.equal(typeof ext.onRequest, "function"); assert.equal(typeof ext.description, "string"); }); + +// --- Blind-spot regressions (2026-07-27) --- +// +// Each test below pins one way the diagnostic used to report nothing, or +// something misleading, on a real cache bust. They are regressions in the +// strict sense: the old implementation fails every one of them. + +// Blind spot 1: the storage key was derived from `system`, so a change +// inside the first 2000 chars moved the key, missed the prior snapshot, +// and produced NO diff and NO log line at all. +test("resolveSessionKey: stays stable when the system prompt changes", () => { + const headers = { "x-claude-code-session-id": "abc-123" }; + const k1 = resolveSessionKey(headers, [{ type: "text", text: "system v1" }]); + const k2 = resolveSessionKey(headers, [{ type: "text", text: "system v2 TOTALLY DIFFERENT" }]); + assert.equal(k1, k2, "session-keyed storage must not move when content changes"); +}); + +test("resolveSessionKey: differs per session id", () => { + const sys = [{ type: "text", text: "same" }]; + const a = resolveSessionKey({ "x-claude-code-session-id": "aaa" }, sys); + const b = resolveSessionKey({ "x-claude-code-session-id": "bbb" }, sys); + assert.notEqual(a, b); +}); + +test("resolveSessionKey: falls back to the content hash without headers", () => { + const sys = [{ type: "text", text: "no headers here" }]; + assert.equal(resolveSessionKey(null, sys), computeSessionKey(sys)); +}); + +// Blind spot 6 (2026-07-27): co-tenants on ONE session id. A main session, +// its subagents, and Claude Code's background calls share the session-id +// header. With a single baseline per session they diffed against each +// other, so conversations advancing normally rendered as prefix churn — +// misread that day as the cause of a 93k bust it had no part in. +test("snapshotPrefix: co-tenants on one session id do not diff against each other", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "shared-session" }; + const main = (text) => + makePayload({ + system: [{ type: "text", text: "You are an interactive agent" }], + messages: [{ role: "user", content: [{ type: "text", text }] }], + }); + const sub = (text) => + makePayload({ + system: [{ type: "text", text: "You are a subagent with a task brief" }], + messages: [{ role: "user", content: [{ type: "text", text }] }], + }); + try { + await snapshotPrefix(main("turn 1"), { dir, headers }); + // The subagent's FIRST request has no baseline of its own. The diff + // against the main session's snapshot is kept (a system-prompt change + // in ONE conversation looks identical, and dropping it would hide a + // real bust) but must be MARKED, so it is never read as a cause. + let rSub, subErr; + subErr = await captureStderr(async () => { + rSub = await snapshotPrefix(sub("sub turn 1"), { dir, headers }); + }); + assert.ok(rSub.wroteDiff, "evidence is kept, not dropped"); + assert.match(String(subErr), /CROSS-TENANT/, "and it is labelled on stderr"); + const events = (await readFile(join(dir, `${rSub.key}-events.jsonl`), "utf-8")) + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + assert.equal(events.at(-1).crossTenant, true, "and in the ledger"); + + // Main advancing must still diff against MAIN's baseline, not the + // subagent's — and must still be reported. + let rMain; + await captureStderr(async () => { + rMain = await snapshotPrefix(main("turn 2"), { dir, headers }); + }); + assert.ok(rMain.wroteDiff, "the main session's own advance still diffs"); + + // One file per session id: separation is inside the file, so the + // path never moves (design note 1's blind spot stays closed). + assert.equal(rSub.key, rMain.key); + const json = JSON.parse(await readFile(join(dir, `${rMain.key}-last.json`), "utf-8")); + assert.equal(Object.keys(json.tenants).length, 2, "both tenants keep a baseline"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("tenantId: prefers the agent-id header over the system prompt", () => { + const sysA = [{ type: "text", text: "prompt A" }]; + const sysB = [{ type: "text", text: "prompt B TOTALLY DIFFERENT" }]; + const hdr = { "x-claude-code-agent-id": "agent-7" }; + // Authoritative when present: an edited system prompt must not split a + // conversation's baseline in two. + assert.equal(tenantId(hdr, sysA), tenantId(hdr, sysB)); + // Distinct agents stay distinct even on an identical prompt. + assert.notEqual( + tenantId({ "x-claude-code-agent-id": "agent-8" }, sysA), + tenantId(hdr, sysA), + ); + // Absent (the common case — CC omits it for Workflow legs): fall back + // to the system prompt. + assert.notEqual(tenantId({}, sysA), tenantId({}, sysB)); + assert.equal(tenantId({}, sysA), tenantId(null, sysA)); +}); + +test("snapshotPrefix: a system-prompt change still finds its prior snapshot (key stability)", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "resume-test" }; + try { + await snapshotPrefix(makePayload({ system: [{ type: "text", text: "v1 prompt" }] }), { + dir, + headers, + }); + let result; + await captureStderr(async () => { + result = await snapshotPrefix( + makePayload({ system: [{ type: "text", text: "v2 prompt" }] }), + { dir, headers }, + ); + }); + // The whole point: the diff MUST fire. Under the old key derivation + // this wrote a fresh baseline under a new name and reported nothing. + assert.ok(result.wroteDiff, "system change must still produce a diff"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// Blind spot 2: `-diff.json` was overwritten on every diff, so a +// bust's detail survived only until the next one. +test("snapshotPrefix: appends every diff to an events ledger that is never overwritten", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "ledger-test" }; + try { + await snapshotPrefix(makePayload(), { dir, headers }); + await captureStderr(async () => { + for (const text of ["change one", "change two", "change three"]) { + await snapshotPrefix( + makePayload({ messages: [{ role: "user", content: [{ type: "text", text }] }] }), + { dir, headers }, + ); + } + }); + const key = resolveSessionKey(headers, makePayload().system); + const lines = (await readFile(join(dir, `${key}-events.jsonl`), "utf-8")) + .trim() + .split("\n"); + assert.equal(lines.length, 3, "each diff appends one record"); + for (const line of lines) { + const rec = JSON.parse(line); + assert.ok(rec.ts && Array.isArray(rec.causes)); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// Blind spot 3: toolsHash covered only names, so schema/description +// changes reported `tools=match` for a genuinely different payload. +test("buildToolsSnapshot: hashes full schemas, not just names", () => { + const a = buildToolsSnapshot([{ name: "Read", input_schema: { a: 1 } }]); + const b = buildToolsSnapshot([{ name: "Read", input_schema: { a: 2 } }]); + assert.notEqual(a.hash, b.hash, "same name, different schema must not hash equal"); +}); + +test("diffTools: names the tool and classifies the change", () => { + const prev = buildToolsSnapshot([{ name: "Read", description: "old" }, { name: "Bash" }]); + const now = buildToolsSnapshot([{ name: "Read", description: "new" }, { name: "Edit" }]); + const diffs = diffTools(prev, now); + const byName = Object.fromEntries(diffs.map((d) => [d.name, d.change])); + assert.equal(byName.Read, "schema"); + assert.equal(byName.Bash, "removed"); + assert.equal(byName.Edit, "added"); +}); + +test("diffTools: pure reordering is reported as reordered, not as churn", () => { + const prev = buildToolsSnapshot([{ name: "A" }, { name: "B" }]); + const now = buildToolsSnapshot([{ name: "B" }, { name: "A" }]); + const diffs = diffTools(prev, now); + assert.equal(diffs.length, 2); + assert.ok(diffs.every((d) => d.change === "reordered")); +}); + +// Blind spot 4: one opaque systemHash answered "did it change" and +// nothing else — the exact wall a real investigation hit. +test("diffSystemBlocks: locates the changed block, its label, and the differing offset", () => { + const mk = (envText) => [ + { type: "text", text: "You are Claude Code, an agent." }, + { type: "text", text: envText }, + ]; + const prev = buildSystemSnapshot(mk("Working directory: /tmp\nToday: 2026-07-26")); + const now = buildSystemSnapshot(mk("Working directory: /tmp\nToday: 2026-07-27")); + const diffs = diffSystemBlocks(prev, now); + assert.equal(diffs.length, 1, "only the env block changed"); + assert.equal(diffs[0].index, 1); + assert.equal(diffs[0].label, "env"); + assert.ok(diffs[0].charAt > 0, "reports where the divergence starts"); + assert.ok( + diffs[0].prevWindow.includes("2026-07-26") && diffs[0].nowWindow.includes("2026-07-27"), + "shows the bytes from both sides", + ); +}); + +test("buildSystemSnapshot: labels known Claude Code block shapes", () => { + const blocks = buildSystemSnapshot([ + { type: "text", text: "You are Claude Code, an agent" }, + { type: "text", text: "Platform: linux" }, + ]); + assert.equal(blocks[0].label, "cc-identity"); + assert.equal(blocks[1].label, "env"); +}); + +test("buildSystemSnapshot: accepts a plain string system prompt", () => { + const blocks = buildSystemSnapshot("plain string prompt"); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].type, "string"); +}); + +// Coverage gap: top-level params were not snapshotted at all, so a model +// switch — a guaranteed cache-key change — read as "0 differences". +test("diffParams: a model switch is detected", () => { + const a = buildSnapshot({ ...makePayload(), model: "claude-opus-5" }); + const b = buildSnapshot({ ...makePayload(), model: "claude-sonnet-5" }); + const diff = computeDiff(a, b); + assert.equal(diff.paramDiffs.length, 1); + assert.equal(diff.paramDiffs[0].key, "model"); + assert.equal(diffHasChanges(diff), true, "param-only change must count as a change"); +}); + +test("diffParams: temperature and thinking config are tracked", () => { + const a = buildSnapshot({ ...makePayload(), temperature: 1, thinking: { type: "enabled" } }); + const b = buildSnapshot({ ...makePayload(), temperature: 0, thinking: { type: "disabled" } }); + const keys = computeDiff(a, b).paramDiffs.map((d) => d.key).sort(); + assert.deepEqual(keys, ["temperature", "thinking"]); +}); + +// Coverage gap: head(5) + markers(8) + tail(3) left a long session's +// middle unobserved — a mutation there reported zero differences. +test("messageChain: catches a mutation in the middle, outside every window", () => { + const build = (midText) => + makePayload({ + messages: Array.from({ length: 60 }, (_, i) => ({ + role: "user", + content: [{ type: "text", text: i === 30 ? midText : `msg ${i}` }], + })), + }); + const a = buildSnapshot(build("original middle")); + const b = buildSnapshot(build("MUTATED middle")); + const diff = computeDiff(a, b); + assert.equal(diff.prefixDiffs.length, 0, "head window cannot see index 30"); + assert.equal(diff.tailDiffs.length, 0, "tail window cannot see index 30"); + assert.equal(diff.markerDiffs.length, 0, "no cache_control markers present"); + assert.equal(diff.messageChain.firstDivergentIndex, 30); + assert.equal(diffHasChanges(diff), true); +}); + +test("messageChain: a normal append is flagged appendOnly with no divergence", () => { + const base = makePayload(); + const a = buildSnapshot(base); + const b = buildSnapshot( + makePayload({ + messages: [...base.messages, { role: "user", content: [{ type: "text", text: "next" }] }], + }), + ); + const chain = computeDiff(a, b).messageChain; + assert.equal(chain.firstDivergentIndex, -1); + assert.equal(chain.appendOnly, true); +}); + +// Every content shape a request can carry must yield a non-null preview — +// null previews are exactly what made the 2026-07-28 37k bust +// unattributable (all divergent messages were tool-shaped). +test("messageTextPreview: string content is previewed, not nulled", () => { + const p = messageTextPreview({ role: "user", content: "plain string body" }); + assert.equal(p, "plain string body"); +}); + +test("messageTextPreview: tool_use-only content names the tool and input", () => { + const p = messageTextPreview({ + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }], + }); + assert.match(p, /tool_use:Bash/); + assert.match(p, /"command":"ls"/); +}); + +test("messageTextPreview: tool_result-only content carries the result body", () => { + const p = messageTextPreview({ + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "exit 0" }], + }); + assert.match(p, /tool_result/); + assert.match(p, /exit 0/); +}); + +test("messageTextPreview: tool_result error flag and block-array content survive", () => { + const p = messageTextPreview({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + is_error: true, + content: [{ type: "text", text: "boom" }], + }, + ], + }); + assert.match(p, /tool_result:error/); + assert.match(p, /boom/); +}); + +test("messageTextPreview: thinking blocks are marked present but redacted", () => { + const p = messageTextPreview({ + role: "assistant", + content: [ + { type: "thinking", thinking: "secret reasoning bytes" }, + { type: "text", text: "visible" }, + ], + }); + assert.match(p, /\[thinking\]/); + assert.match(p, /visible/); + assert.doesNotMatch(p, /secret reasoning/); +}); + +test("messageTextPreview: unknown block types name their type", () => { + const p = messageTextPreview({ + role: "user", + content: [{ type: "image", source: { type: "base64", data: "AAAA" } }], + }); + assert.match(p, /\[image\]/); +}); + +test("messageTextPreview: still bounded and null only for shapeless input", () => { + const p = messageTextPreview( + { role: "user", content: "y".repeat(5000) }, + 120, + ); + assert.equal(p.length, 120); + assert.equal(messageTextPreview(null), null); + assert.equal(messageTextPreview({ role: "user" }), null); +}); + +test("buildMessageHashes: sees a change past 500 chars (no truncation blindness)", () => { + const mk = (tail) => [ + { role: "user", content: [{ type: "text", text: "x".repeat(600) + tail }] }, + ]; + const a = buildMessageHashes(mk("A")); + const b = buildMessageHashes(mk("B")); + assert.notEqual(a[0].h, b[0].h, "hash chain must not truncate before hashing"); +}); + +test("diffMessageHashes: truncation is distinguished from in-place mutation", () => { + const long = buildMessageHashes( + Array.from({ length: 5 }, (_, i) => ({ role: "user", content: [{ type: "text", text: `m${i}` }] })), + ); + const short = long.slice(0, 3); + const chain = diffMessageHashes(long, short); + assert.equal(chain.firstDivergentIndex, -1, "shared prefix is intact"); + assert.equal(chain.appendOnly, false, "shrinking is not an append"); + assert.equal(chain.prevLength, 5); + assert.equal(chain.nowLength, 3); +}); + +// The summary line is the artifact most reads stop at, so the cause must +// be legible there without opening any file. +test("summariseCauses: names the specific block, tool, param, and index", () => { + const a = buildSnapshot({ + ...makePayload({ system: [{ type: "text", text: "Today: A" }] }), + model: "claude-opus-5", + }); + const b = buildSnapshot({ + ...makePayload({ + system: [{ type: "text", text: "Today: B" }], + tools: [{ name: "Read", input_schema: { changed: true } }, { name: "Bash" }], + messages: [{ role: "user", content: [{ type: "text", text: "different" }] }], + }), + model: "claude-sonnet-5", + }); + const causes = summariseCauses(computeDiff(a, b)).join(" | "); + assert.ok(causes.includes("params:model"), `expected model in: ${causes}`); + assert.ok(causes.includes("system["), `expected system block in: ${causes}`); + assert.ok(causes.includes("tools["), `expected tools in: ${causes}`); + assert.ok(causes.includes("messages@0"), `expected message index in: ${causes}`); +}); + +// Tap points (dev-loop.md): a bare index was equated across tap points +// during the 587k attribution. Every record this module emits must name +// its own tap point, derived from the SAME order the extension itself +// registers under — never a second literal that could drift from it. +test("buildEventRecord: carries its tap-point view marker, single-sourced from the module's own order", () => { + assert.equal(ext.order, TAP_ORDER, "the default export's order must be the single source TAP_VIEW derives from"); + assert.equal(TAP_VIEW, `forwarded@${TAP_ORDER}`); + + const a = buildSnapshot(makePayload({ system: [{ type: "text", text: "A" }] })); + const b = buildSnapshot(makePayload({ system: [{ type: "text", text: "B" }] })); + const rec = buildEventRecord(computeDiff(a, b), "key123", "sid-1"); + assert.equal(rec.view, TAP_VIEW, `expected tap-point view marker on the record, got ${JSON.stringify(rec.view)}`); +}); + +test("buildEventRecord: bounded — carries evidence without full message bodies", () => { + const a = buildSnapshot(makePayload({ system: [{ type: "text", text: "A" }] })); + const b = buildSnapshot( + makePayload({ + system: [{ type: "text", text: "B" }], + messages: [{ role: "user", content: [{ type: "text", text: "y".repeat(5000) }] }], + }), + ); + const rec = buildEventRecord(computeDiff(a, b), "key123", "sid-1"); + const serialized = JSON.stringify(rec); + assert.ok(serialized.length < 8000, `record should stay small, got ${serialized.length}`); + assert.ok(!serialized.includes("y".repeat(200)), "must not embed full message bodies"); + assert.equal(rec.sid, "sid-1"); + assert.ok(rec.system.length > 0, "but must carry the system-block evidence"); +}); + +// Version-boundary safety: a snapshot written by the previous release has +// none of the new fields. Diffing across that boundary must degrade, not +// throw. +test("computeDiff: tolerates an old-format snapshot with no new fields", () => { + const old = { + timestamp: "2026-07-26T00:00:00.000Z", + messageCount: 2, + toolsHash: "abc", + systemHash: "def", + prefixMessages: [], + tailMessages: [], + markerMessages: [], + }; + const current = buildSnapshot(makePayload()); + const diff = computeDiff(old, current); + assert.ok(Array.isArray(diff.systemBlockDiffs)); + assert.ok(Array.isArray(diff.toolDiffs)); + assert.ok(Array.isArray(diff.paramDiffs)); + assert.equal(typeof diff.messageChain.firstDivergentIndex, "number"); +}); + +test("snapshotPrefix: ledger append failure does not prevent the diff file", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "append-fail" }; + try { + await snapshotPrefix(makePayload(), { dir, headers }); + const failingFs = { + appendFile: async () => { + throw new Error("simulated append failure"); + }, + }; + let result; + await captureStderr(async () => { + result = await snapshotPrefix( + makePayload({ messages: [{ role: "user", content: [{ type: "text", text: "X" }] }] }), + { dir, headers, fs: failingFs }, + ); + }); + assert.ok(result.wroteDiff, "detail file must still be written"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// --- Prefix-diff's own blind spots (2026-07-27): output_config/speed/betas, +// and the anthropic-beta HEADER, are part of the cache key but were +// untracked — a bust from an effort change, fast-mode toggle, or beta-header +// drift was previously unattributable (bare messages@N or empty causes). + +test("diffParams: an effort change inside output_config is detected", () => { + const a = buildSnapshot({ ...makePayload(), output_config: { effort: "low" } }); + const b = buildSnapshot({ ...makePayload(), output_config: { effort: "high" } }); + const diff = computeDiff(a, b); + assert.equal(diff.paramDiffs.length, 1); + assert.equal(diff.paramDiffs[0].key, "output_config"); + assert.equal(diffHasChanges(diff), true); + const causes = summariseCauses(diff).join(" | "); + assert.ok(causes.includes("params:output_config"), `expected output_config in: ${causes}`); +}); + +test("diffParams: a speed (fast mode) change is detected", () => { + const a = buildSnapshot({ ...makePayload(), speed: "standard" }); + const b = buildSnapshot({ ...makePayload(), speed: "fast" }); + const diff = computeDiff(a, b); + assert.equal(diff.paramDiffs.length, 1); + assert.equal(diff.paramDiffs[0].key, "speed"); + assert.equal(diffHasChanges(diff), true); +}); + +test("diffParams: a betas (body param) change is detected", () => { + const a = buildSnapshot({ ...makePayload(), betas: ["fast-mode-2026-02-01"] }); + const b = buildSnapshot({ ...makePayload(), betas: ["fast-mode-2026-02-01", "context-1m-2025-08-07"] }); + const diff = computeDiff(a, b); + assert.equal(diff.paramDiffs.length, 1); + assert.equal(diff.paramDiffs[0].key, "betas"); +}); + +test("buildBetaHeaderSnapshot: absent header normalizes to present:false, tokens:[]", () => { + assert.deepEqual(buildBetaHeaderSnapshot(null), { present: false, tokens: [] }); + assert.deepEqual(buildBetaHeaderSnapshot({}), { present: false, tokens: [] }); +}); + +test("buildBetaHeaderSnapshot: parses and sorts comma-separated tokens, case-insensitive header name", () => { + const snap = buildBetaHeaderSnapshot({ "Anthropic-Beta": "b-token, a-token" }); + assert.equal(snap.present, true); + assert.deepEqual(snap.tokens, ["a-token", "b-token"]); +}); + +test("diffBetaHeader: no change when the token set is identical (order-insensitive)", () => { + const a = buildBetaHeaderSnapshot({ "anthropic-beta": "x, y" }); + const b = buildBetaHeaderSnapshot({ "anthropic-beta": "y, x" }); + const diff = diffBetaHeader(a, b); + assert.deepEqual(diff, { added: [], removed: [] }); +}); + +test("diffBetaHeader: detects an added and a removed token in the same change", () => { + const a = buildBetaHeaderSnapshot({ "anthropic-beta": "context-1m-2025-08-07, fast-mode-2026-02-01" }); + const b = buildBetaHeaderSnapshot({ "anthropic-beta": "fast-mode-2026-02-01, task-budgets-2026-03-13" }); + const diff = diffBetaHeader(a, b); + assert.deepEqual(diff.added, ["task-budgets-2026-03-13"]); + assert.deepEqual(diff.removed, ["context-1m-2025-08-07"]); +}); + +test("computeDiff + summariseCauses: a beta-header-only change produces a header cause", () => { + const a = buildSnapshot(makePayload(), { "anthropic-beta": "fast-mode-2026-02-01" }); + const b = buildSnapshot(makePayload(), { "anthropic-beta": "context-1m-2025-08-07" }); + const diff = computeDiff(a, b); + assert.equal(diffHasChanges(diff), true, "a header-only change must count as a change"); + const causes = summariseCauses(diff).join(" | "); + assert.ok(causes.includes("header:anthropic-beta"), `expected header cause in: ${causes}`); + assert.ok(causes.includes("+context-1m-2025-08-07"), `expected added token in: ${causes}`); + assert.ok(causes.includes("-fast-mode-2026-02-01"), `expected removed token in: ${causes}`); +}); + +test("computeDiff: identical beta headers produce no header cause and no spurious change", () => { + const a = buildSnapshot(makePayload(), { "anthropic-beta": "context-1m-2025-08-07" }); + const b = buildSnapshot(makePayload(), { "anthropic-beta": "context-1m-2025-08-07" }); + const diff = computeDiff(a, b); + assert.equal(diffHasChanges(diff), false); + assert.deepEqual(diff.betaHeaderDiff, { added: [], removed: [] }); +}); + +// Version-boundary safety, mirroring the existing "tolerates an old-format +// snapshot" test above: a snapshot written before this change has no +// `betaHeader` field at all. Diffing across that boundary must not throw, +// and — critically — must not report a spurious header cause just because +// the old side has no data to compare against. +test("computeDiff: old-format snapshot (no betaHeader field) migrates silently when neither side actually carries a header", () => { + const old = { + timestamp: "2026-07-26T00:00:00.000Z", + messageCount: 2, + toolsHash: "abc", + systemHash: "def", + prefixMessages: [], + tailMessages: [], + markerMessages: [], + // no params, systemBlocks, toolsDetail, messageHashes, betaHeader — simulates + // a snapshot from before ANY of the coverage additions, old and new alike. + }; + // No anthropic-beta header on this request either — the pre-upgrade snapshot + // and the first post-upgrade request agree there is nothing to report. + const current = buildSnapshot(makePayload(), {}); + const diff = computeDiff(old, current); + assert.deepEqual(diff.betaHeaderDiff, { added: [], removed: [] }); + assert.ok(!summariseCauses(diff).some((c) => c.startsWith("header:"))); +}); + +// Distinguishes "no data on the old side" from "no header on either side": +// when the old snapshot predates beta-header tracking but the CURRENT +// request genuinely carries one, that's real new information the old +// snapshot has no way to rule out — it must surface, exactly as the +// pre-existing paramDiffs/toolDiffs/systemBlockDiffs helpers already do +// when their "old" side lacks the corresponding field (see the sibling +// "tolerates an old-format snapshot" test above, which asserts the arrays +// exist and doesn't claim they stay empty on a real underlying change). +test("computeDiff: old-format snapshot + a real header on the current request still surfaces as added", () => { + const old = { + timestamp: "2026-07-26T00:00:00.000Z", + messageCount: 2, + toolsHash: "abc", + systemHash: "def", + prefixMessages: [], + tailMessages: [], + markerMessages: [], + }; + const current = buildSnapshot(makePayload(), { "anthropic-beta": "context-1m-2025-08-07" }); + const diff = computeDiff(old, current); + assert.deepEqual(diff.betaHeaderDiff, { added: ["context-1m-2025-08-07"], removed: [] }); +}); + +test("snapshotPrefix: a beta-header-only change on real requests fires a diff with a header cause", async () => { + const dir = await newTmp(); + try { + await snapshotPrefix(makePayload(), { + dir, + headers: { "x-claude-code-session-id": "beta-test", "anthropic-beta": "fast-mode-2026-02-01" }, + }); + let result; + await captureStderr(async () => { + result = await snapshotPrefix(makePayload(), { + dir, + headers: { "x-claude-code-session-id": "beta-test", "anthropic-beta": "context-1m-2025-08-07" }, + }); + }); + assert.ok(result.wroteDiff, "beta-header-only change must produce a diff"); + const key = resolveSessionKey({ "x-claude-code-session-id": "beta-test" }, makePayload().system); + const diffJson = JSON.parse(await readFile(join(dir, `${key}-diff.json`), "utf-8")); + assert.deepEqual(diffJson.betaHeaderDiff.added, ["context-1m-2025-08-07"]); + assert.deepEqual(diffJson.betaHeaderDiff.removed, ["fast-mode-2026-02-01"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("default.onRequest: passes ctx.headers through to the key derivation", async () => { + // Contract check: onRequest must forward headers, else every request + // silently falls back to the content-derived key this work removed. + const src = await readFile( + join(__dirname, "..", "proxy", "extensions", "prefix-diff.mjs"), + "utf-8", + ); + assert.ok( + /snapshotPrefix\(ctx\.body,\s*\{\s*headers:\s*ctx\.headers\s*\}\)/.test(src), + "onRequest must pass ctx.headers to snapshotPrefix", + ); +}); + +// Blind spot 7 (2026-07-27): a mid-history mutation was reported by index and +// nothing more. `messages@83(user)` on a ~93k bust could not be attributed +// afterwards — index 83 falls outside the head-5 and tail-3 content windows, +// so no stored artifact held what changed, and the live request was long gone. +test("a mid-history mutation records HOW it changed, not just where", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "midhistory" }; + const mk = (midText) => { + const msgs = []; + for (let i = 0; i < 12; i++) { + msgs.push({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: i === 7 ? midText : `turn ${i}` }], + }); + } + return makePayload({ messages: msgs }); + }; + try { + await snapshotPrefix(mk("ORIGINAL mid-history content"), { dir, headers }); + let r; + await captureStderr(async () => { + r = await snapshotPrefix(mk("MUTATED mid-history content"), { dir, headers }); + }); + assert.ok(r.wroteDiff); + + const events = (await readFile(join(dir, `${r.key}-events.jsonl`), "utf-8")) + .trim().split("\n").map((l) => JSON.parse(l)); + const rec = events.at(-1); + assert.equal(rec.chain.first, 7, "index 7 diverged"); + // The point of the fix: both sides are recoverable from the ledger alone. + assert.match(rec.chain.prevContent, /ORIGINAL/); + assert.match(rec.chain.nowContent, /MUTATED/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// Defects found by an isolated verifier against 6aa85f8 (2026-07-27). Both are +// in the per-tenant baseline machinery introduced by that commit. + +test("concurrent co-tenants each keep a baseline (no lost update)", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "shared" }; + try { + // The exact shape the per-tenant work targets: one session id, many + // agents in flight at once. The read-modify-write of the tenants map is + // separated by awaits, so unserialized writers clobber each other — + // measured 1 of 10 surviving before the per-key lock. + const reqs = []; + for (let i = 0; i < 10; i++) { + reqs.push( + snapshotPrefix( + makePayload({ + system: [{ type: "text", text: `agent ${i} system prompt` }], + messages: [{ role: "user", content: [{ type: "text", text: `t${i}` }] }], + }), + { dir, headers }, + ), + ); + } + let results; + await captureStderr(async () => { + results = await Promise.all(reqs); + }); + const json = JSON.parse(await readFile(join(dir, `${results[0].key}-last.json`), "utf-8")); + assert.equal(Object.keys(json.tenants).length, 10, "every concurrent tenant keeps its baseline"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("tenant eviction drops the oldest by timestamp, not by key order", () => { + // Object.keys enumerates integer-like keys FIRST in ascending numeric order, + // regardless of insertion order — and an 8-hex tenant id is all-digits about + // one time in 16. Slicing Object.keys therefore evicted such a tenant ahead + // of genuinely older ones. This pins the ordering rule the fix relies on. + const obj = {}; + obj["deadbeef"] = 1; + obj["12345678"] = 2; + obj["cafe0000"] = 3; + assert.equal( + Object.keys(obj)[0], + "12345678", + "numeric-string keys sort to the front — key order is NOT insertion order", + ); +}); + +// BITE — the shape a short-prompt test cannot catch. Short prompts differ in +// their first bytes, so they separate under ANY prefix length; the collision +// needed a long SHARED preamble, which is what real agent system prompts look +// like. tenantId truncated to 400 chars until 2026-07-28 and merged these two +// into one tenant, silently combining their baselines — every co-tenant diff +// after that was attributed against the wrong baseline. +test("tenantId: two prompts sharing a long preamble must not collide — hash the FULL text", () => { + const preamble = "You are a Claude agent. ".repeat(40); // ~960 chars, shared + const longA = tenantId({}, [{ type: "text", text: preamble + "TASK A" }]); + const longB = tenantId({}, [{ type: "text", text: preamble + "TASK B" }]); + assert.notEqual(longA, longB, "a truncated hash merges tenants that differ only past the cut"); +});