diff --git a/proxy/extensions.json b/proxy/extensions.json index b28b3684..852b530e 100644 --- a/proxy/extensions.json +++ b/proxy/extensions.json @@ -15,8 +15,10 @@ "workflow-agent-id-synthesis": { "enabled": true, "order": 365 }, "image-retry-circuit-breaker": { "enabled": true, "order": 370 }, "read-dedupe": { "enabled": true, "order": 380 }, + "insertion-normalization": { "enabled": true, "order": 395 }, "cache-control-normalize": { "enabled": true, "order": 400 }, "messages-cache-breakpoint": { "enabled": true, "order": 410 }, + "deferred-tool-rewrite": { "enabled": true, "order": 425 }, "ttl-management": { "enabled": true, "order": 500 }, "cache-telemetry": { "enabled": true, "order": 600 }, "overage-warning": { "enabled": true, "order": 610 }, diff --git a/proxy/extensions/deferred-tool-rewrite.mjs b/proxy/extensions/deferred-tool-rewrite.mjs new file mode 100644 index 00000000..c431e511 --- /dev/null +++ b/proxy/extensions/deferred-tool-rewrite.mjs @@ -0,0 +1,675 @@ +// deferred-tool-rewrite — Phase B (robustness-threat-matrix class 6). +// +// Design: docs/directives/proxy-deferred-tool-rewrite.md, including the +// 2026-07-28 Phase B addendum (documented wire shapes + persistent +// re-injection). Spec contradiction on record: CC docs say deferred-tool +// loads append without disturbing cache; measured 2026-07-27 12:47:56 +// (175k, ledger row tools[SendMessage:added], toolsMatch:false) says +// otherwise on this surface. Until upstream fixes it, the proxy holds +// tools[] byte-stable across a pure tool addition and delivers the newly +// available schema per the DOCUMENTED mid-conversation-tool-changes +// contract (beta mid-conversation-tool-changes-2026-07-01): +// +// - the new tool goes into tools[] with defer_loading: true; +// - the announcement is a {"type": "tool_addition", "tool": +// {"type": "tool_reference", "name": ...}} content block on a +// {"role": "system"} message appended to messages[] — NOT a text +// block on top-level system (Phase A's placeholder; wrong shape AND +// wrong location — top-level system heads the cache prefix, so every +// injection there would bust the whole cache). +// +// STATELESSNESS: the API loads a deferred tool only when its +// tool_addition block is present in THAT request, and CC never echoes +// our injected message back. So both halves re-apply every request: +// tools[] stays held with added tools permanently defer_loading:true, +// and each injected system message is re-spliced byte-identically at a +// content-anchored position (identity hash of the message it was +// injected after). Anchor pruned by context management → re-anchor after +// the latest user message, telemetry `reanchored`, one honest partial +// re-cache. Pipeline order isolates this from insertion-normalization +// (395 < 425): the canonical never sees the injected message. +// +// Detect: compare incoming tools[] against the persisted known set (keyed +// by name). Three things can happen to a known name, and only one is an +// honest content change: +// - present, byte-unchanged (fingerprint match) → carried forward using +// the FROZEN persisted object (not the incoming one), so its wire +// bytes stay stable turn over turn even if the incoming array's key +// order or position drifted; +// - ABSENT from incoming (harness GC'd a loaded deferred tool, e.g. a +// skills/tool-list update) → HELD: re-inserted at its first-seen +// position using the frozen object, exactly as if it were still +// present. Inert once held; costs ~0 (threat-matrix row 13). This +// also fixes pure reorder diffs (e.g. DeferredToolPlaceholder moving +// relative to its neighbors with no add/remove) as a side effect, +// since output order is ALWAYS the first-seen order, never the +// incoming array's order; +// - present but fingerprint-changed → the one honest case: passthrough +// + full reset (the directive's "never paper over a real edit" — and +// specifically never serve a stale schema for a name that changed). +// A new name (not in the known set) is additively marked +// defer_loading:true and announced via one appended tool_addition system +// block, exactly as before. Any combination of held + new in the same +// request composes (both are additive from the wire's perspective; only +// a fingerprint change is destructive). +// +// Phase B stops at: documented shapes + persistent re-injection, +// validated by unit tests and replay A/B (directive addendum's gates 1-2). +// The final live acceptance probe (gate 3: one real request through the +// proxy at a session boundary, watch for 400 vs the model using the +// added tool) happens before the service-unit flag flips — the header +// plumbing this depended on was fixed in server.mjs 10d33e4. +// +// Activation: `enabled: true` in extensions.json (always loaded), runtime +// gate CACHE_FIX_TOOL_REWRITE=1, default OFF per directive ("Phase A (build +// now, env-gated CACHE_FIX_TOOL_REWRITE=1, default off)"). Order 425 — after +// sort-stabilization (200, so tools[] arrives name-sorted — comparisons and +// output order are keyed on name, not incoming array order); +// before ttl-management (500), consistent with the rest +// of the body-shaping extensions running ahead of the TTL pass. + +import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { claudeHome } from "../claude-home.mjs"; +import { resolveSessionId } from "./cache-telemetry.mjs"; +import { hashMessageContent, conversationSubKey } from "./message-hash.mjs"; +import { systemPromptSubKey } from "./insertion-normalization.mjs"; +import { createHash } from "node:crypto"; + +// Models known to ACCEPT the mid-conversation-tool-changes contract. +// +// Opt-IN, not opt-out, and that direction is the whole point. On 2026-07-28 +// a sonnet-5 subagent dispatch died with: +// +// API Error: 400 tool_addition/tool_removal is not supported on this model +// +// The tool_addition block is a documented beta, but support is per-MODEL and +// this extension applied it to whatever came through. A cache mitigation that +// can HARD-FAIL a request is strictly worse than no mitigation, so an unknown +// model gets no announcement: it degrades to forwarding the new tool +// normally (a tools[] change, i.e. the bust we would have prevented) instead +// of a 400 that loses the request outright. +// +// This file's own header prescribed exactly this check — "the final live +// acceptance probe (gate 3: one real request through the proxy at a session +// boundary, watch for 400 vs the model using the added tool) happens before +// the service-unit flag flips". The flag was flipped without running it. +// +// Evidence, not guesswork — tools/probe-tool-addition.mjs measures a model +// in one real request (same OAuth path as production, wire shapes imported +// from this file). Add a prefix here only with a real request behind it. +// +// claude-opus-5 ACCEPTED sessions 58c979ce and 538c0aef, injections on +// the wire, no 400. +// claude-sonnet-5 REJECTED the 2026-07-28 live 400 above. +// claude-haiku-4-5 REJECTED probe 2026-07-29: "tool_addition/tool_removal +// requires a model that supports mid-conversation +// system content; this model does not" — the +// probe surfaced the CAPABILITY the beta gates +// on, which the sonnet error never named. +// claude-fable-5 ACCEPTED live probe 2026-07-29, session c05a754c: a +// disposable `claude -p` run through a throwaway +// proxy (CACHE_FIX_TOOL_ADDITION_EXTRA) injected +// the announcement for a mid-run ToolSearch load; +// production's capture holds the block at +// messages[4], the forwarded body hash matches +// the recorded outSha byte-for-byte, and the +// outcome record shows the API streamed a 200. +// (Direct-API probes 429 on this subscription for +// ALL big models — hand-built OAuth requests are +// refused regardless of quota, so the through-CC +// path is the only working probe for them; +// haiku's direct probe worked because CC itself +// sends it free-form utility traffic.) +const TOOL_ADDITION_MODELS = ["claude-opus-5", "claude-fable-5"]; + +// CACHE_FIX_TOOL_ADDITION_EXTRA: comma-separated additional prefixes, +// read per call like every gate. It exists for ONE purpose — the directive's +// live acceptance probe: a throwaway proxy instance sets it so a disposable +// real session can carry the announcement to a candidate model without +// touching the production allowlist. It is never set in the service unit; +// an ACCEPTED result graduates to TOOL_ADDITION_MODELS with its evidence, +// the override does not substitute for the entry. +export function supportsToolAddition(model) { + if (typeof model !== "string") return false; + const extra = (process.env.CACHE_FIX_TOOL_ADDITION_EXTRA ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return TOOL_ADDITION_MODELS.concat(extra).some((prefix) => model.startsWith(prefix)); +} + +const BETA_TOKEN = "mid-conversation-tool-changes-2026-07-01"; +const BETA_HEADER_NAME = "anthropic-beta"; + +const DEFAULT_FS = { readFile, writeFile, rename, appendFile, mkdir }; + +// Models already warned about in this process — the suppressed-announcement +// warning fires once per model, not per request. Module state is acceptable +// here precisely because losing it (restart, reload) only repeats a warning. +const warnedSuppressedModels = new Set(); + +// --- Env gates (read per-call, mirrors the insertion-normalization idiom) --- + +function isEnabled(env = process.env) { + return env.CACHE_FIX_TOOL_REWRITE === "1"; +} + +function isDebug(env = process.env) { + return env.CACHE_FIX_DEBUG === "1"; +} + +function debug(msg) { + if (isDebug()) process.stderr.write(`[deferred-tool-rewrite] DEBUG: ${msg}\n`); +} + +// --- Storage (snapshots-dir idiom, mirrors insertion-normalization) --- + +function getSnapshotDir() { + return join(claudeHome(), "cache-fix-snapshots"); +} + +function statePath(dir, sessionKey) { + return join(dir, `${sessionKey}-deferred-tool-canon.json`); +} + +function eventsPath(dir, sessionKey) { + return join(dir, `${sessionKey}-deferred-tool-events.jsonl`); +} + +// State: { tools: [...], additions: [{ name, anchorHash, message }] }. +// `additions` (Phase B) carries each injected system message byte-frozen, +// plus the identity hash of the message it was anchored after. Old files +// without the field read as additions=[] — no migration, sessions started +// under Phase A simply have no pending injections. +async function loadState(dir, sessionKey, fs) { + try { + const txt = await fs.readFile(statePath(dir, sessionKey), "utf-8"); + const parsed = JSON.parse(txt); + if (!Array.isArray(parsed?.tools)) return null; + return { tools: parsed.tools, additions: Array.isArray(parsed.additions) ? parsed.additions : [] }; + } catch (err) { + if (err && err.code !== "ENOENT") debug(`state read failed: ${err?.message ?? err}`); + return null; + } +} + +async function saveState(dir, sessionKey, state, fs) { + await fs.mkdir(dir, { recursive: true }); + const finalPath = statePath(dir, sessionKey); + const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(state, null, 2)); + await fs.rename(tmpPath, finalPath); +} + +async function appendTelemetry(dir, sessionKey, record, fs) { + try { + await fs.mkdir(dir, { recursive: true }); + await fs.appendFile(eventsPath(dir, sessionKey), JSON.stringify(record) + "\n"); + } catch (err) { + debug(`telemetry append failed: ${err?.message ?? err}`); + } +} + +// --- Session key (same idiom as insertion-normalization) --- + +// Sub-keyed by system-prompt hash for the same reason insertion-normalization +// is (threat-matrix row 14): the session-id header is shared by the main +// thread, every subagent it dispatches, and CC's own sidecar calls +// (title-generation etc.) — but those carry DIFFERENT tools arrays. Keyed on +// the bare session id they all collide on one baseline, so each alternation +// reads as "a known tool's schema changed" and takes the honest-reset path, +// re-baselining against whichever tenant spoke last. +// +// Measured before this fix (2026-07-28, capture s-35d72503, 602 requests): +// SIX distinct (tools, system-prompt) combinations shared a single baseline, +// and enabling the rewrite RAISED main-conversation tools[] churn from 1 to 2 +// — the extension built to hold tools[] byte-stable was destabilising it. +// The directive never considered sidecars; only replay over real multi-tenant +// traffic surfaced it. +// The key carries a CONVERSATION sub-key as well as the system prompt. +// +// Without it (until 2026-07-28) every subagent of a session shared one tools +// baseline AND one set of persisted additions, because they all run the same +// agent system prompt. That is not merely noisy: the tool_addition +// announcement is anchored to a MESSAGE IDENTITY, so under a shared key the +// stored anchor belongs to a different conversation's history, fails to +// match, and injectAdditions falls back to "after the last user message" — a +// different index on every request. Measured on corpus s-0edbd11c: our output +// diverged at index 4 while CC's own history was byte-identical through index +// 23, twice, re-billing 19 messages that never changed. +// +// insertion-normalization hit the identical collision and was fixed hours +// earlier; this extension had the same key and did not get the fix. Hence +// conversationSubKey living in message-hash.mjs rather than in either +// extension — a second copy is a second truth, and the second consumer +// learning the lesson late is exactly what happened here. +export function resolveToolRewriteSessionKey(headers, body) { + const sid = headers ? resolveSessionId(headers) : null; + const conv = conversationSubKey(body?.messages); + if (sid) return `s-${sid.replace(/[^A-Za-z0-9_-]/g, "_")}-${systemPromptSubKey(body?.system)}-${conv}`; + const model = typeof body?.model === "string" ? body.model : "unknown"; + return `c-${model}-${conv}`; +} + +// --- Canonical tool comparison --- +// +// Only name/description/input_schema participate in the equality check — +// any OTHER field (e.g. a defer_loading marker WE ourselves might have +// added on a prior rewrite) is deliberately excluded, so re-classifying a +// tool object that happens to carry that marker can never misfire as "the +// existing tool's schema changed." +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key]); + return out; + } + return value; +} + +// VOLATILE SUBSTRINGS inside a tool DESCRIPTION (2026-07-28). CC embeds the +// per-session console URL in the Bash tool's description — it is the commit +// trailer the model is instructed to write — and it does not embed it +// consistently: measured over 640 live requests, 628 carried it and 12 did +// not, with two transitions. Each transition is a tools[] byte change, and +// tools[] renders BEFORE system and messages, so no cache_control breakpoint +// can survive it; one of them cost 705k creation tokens on this session. +// +// Nothing about what Bash DOES changes across that flip — the session URL is +// not part of the tool's contract. So it is excluded from the identity and +// the first-seen description is forwarded: exactly the treatment +// insertion-normalization already applies to blocks in +// messages, one region over. +// +// Deliberately NARROW: only the session-URL shape. Any other description +// difference is still a real edit and still resets, because serving a stale +// schema for a tool whose contract changed is the one failure this extension +// must never produce. +const VOLATILE_DESC_PATTERNS = [ + // https://claude.ai/code/session_ — appears bare and as a + // "Claude-Session:" trailer line; the whole line goes either way. + /^.*https:\/\/claude\.ai\/code\/session_[A-Za-z0-9]+.*$/gm, +]; + +export function stripVolatileDescription(desc) { + if (typeof desc !== "string") return desc; + let out = desc; + for (const re of VOLATILE_DESC_PATTERNS) out = out.replace(re, ""); + // Collapse the blank lines the removal leaves behind, so a description that + // differs ONLY by the volatile line canonicalizes identically either way. + return out.replace(/\n{2,}/g, "\n").trim(); +} + +export function toolFingerprint(tool) { + if (!tool || typeof tool !== "object" || typeof tool.name !== "string") return null; + return JSON.stringify( + canonicalize({ + name: tool.name, + description: stripVolatileDescription(tool.description ?? null), + input_schema: tool.input_schema ?? null, + }), + ); +} + +// --- Core classifier (pure) --- +// +// Returns: +// { action: "no-baseline", knownTools } — first-seen session; persist baseline, forward unchanged +// { action: "unchanged", knownTools } — incoming === known set, same order; forward unchanged +// { action: "reset", knownTools, reason } — a known tool's schema changed; forward unchanged, re-baseline +// { action: "rewrite", tools, newNames, heldNames, knownTools } — held removal and/or reorder and/or pure addition; onRequest forwards forwardedTools(knownTools, additions) and injects the addition message +export function classifyToolChange(incomingTools, priorKnownTools) { + if (!Array.isArray(priorKnownTools)) { + return { action: "no-baseline", knownTools: incomingTools }; + } + + const priorByName = new Map(priorKnownTools.map((t) => [t.name, t])); + const incomingByName = new Map(incomingTools.map((t) => [t.name, t])); + + // Schema-change scan runs over every name present in BOTH sets — absence + // is handled separately below (held, not reset) — so a removal elsewhere + // in the array never short-circuits this check. + for (const [name, priorTool] of priorByName) { + const incomingTool = incomingByName.get(name); + if (incomingTool && toolFingerprint(incomingTool) !== toolFingerprint(priorTool)) { + return { action: "reset", knownTools: incomingTools, reason: "tool-schema-changed" }; + } + } + + const priorOrderNames = [...priorByName.keys()]; + const heldNames = priorOrderNames.filter((name) => !incomingByName.has(name)); + const newNames = [...incomingByName.keys()].filter((name) => !priorByName.has(name)); + + const incomingOrderNames = incomingTools.map((t) => t.name); + const orderMatches = + heldNames.length === 0 && + newNames.length === 0 && + incomingOrderNames.length === priorOrderNames.length && + incomingOrderNames.every((name, i) => name === priorOrderNames[i]); + + if (orderMatches) { + return { action: "unchanged", knownTools: priorKnownTools }; + } + + const newTools = newNames.map((name) => incomingByName.get(name)); + const deferredNewTools = newTools.map((t) => ({ ...t, defer_loading: true })); + // First-seen order, for every name ever known — held (removed) names + // included, using their frozen object so wire bytes never drift for a + // tool whose content didn't actually change. + const heldOrPresentTools = priorOrderNames.map((name) => priorByName.get(name)); + + return { + action: "rewrite", + tools: heldOrPresentTools.concat(deferredNewTools), + newNames, + heldNames, + knownTools: priorOrderNames.concat(newNames).map((name) => priorByName.get(name) ?? incomingByName.get(name)), + }; +} + +// --- Wire shapes (documented mid-conversation-tool-changes contract) --- + +// The tool_addition announcement: one system-ROLE message in messages[] +// carrying a tool_addition block per newly-added tool. The tool must +// already be in tools[] with defer_loading: true; the block references it +// by name. See the directive addendum for the placement constraints this +// satisfies. +export function buildToolAdditionMessage(toolNames) { + return { + role: "system", + content: toolNames.map((name) => ({ + type: "tool_addition", + tool: { type: "tool_reference", name }, + })), + }; +} + +// Identity hash for an anchor message. hashMessageContent covers +// block-array content; string-content messages hash their raw string +// (same fallback family as insertion-normalization's content-derived +// identity — never positional). +export function anchorHash(msg) { + const h = hashMessageContent(msg); + if (h) return h; + const c = msg?.content; + if (typeof c === "string") return "s:" + createHash("sha256").update(c).digest("hex").slice(0, 16); + return null; +} + +// Splice persisted addition messages back into messages[] at their +// anchors. Pure: returns { messages, reanchored } without mutating input. +// Each addition lands immediately after the message whose identity hash +// matches its anchorHash; a vanished anchor (context-management prune) +// re-anchors after the LAST user message — the closest stable position +// that satisfies the "must follow a user message" placement constraint — +// and reports it so state can be updated and telemetry emitted. +// +// Resolution happens in a first pass against the ORIGINAL `messages` array +// (never mutated while resolving), so a SHARED anchor's landing position is +// computed once regardless of how many additions target it. This is what +// keeps the run FIFO — discovery order, oldest first — instead of the +// previous idx+1-per-addition splice, which re-found the same anchor fresh +// on every iteration (the search excludes role==="system", so +// already-injected additions were invisible to it) and always landed the +// newest addition closest to the anchor: a LIFO stack that reordered the +// already-forwarded prefix on every new addition (probe s-dc3f8071, +// n=372-397, 25 stability violations during an MCP discovery cascade). +export function injectAdditions(messages, additions) { + if (!Array.isArray(additions) || additions.length === 0) { + return { messages, reanchored: [] }; + } + + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") { + lastUserIdx = i; + break; + } + } + + const reanchored = []; + // Original-array index -> messages to inject right after it, in discovery + // (oldest-first) order — the run for a shared anchor. + const byAnchorIdx = new Map(); + + for (const add of additions) { + const idx = messages.findIndex((m) => m.role !== "system" && anchorHash(m) === add.anchorHash); + let landingIdx = idx; + if (idx < 0) { + if (lastUserIdx >= 0) { + landingIdx = lastUserIdx; + reanchored.push({ names: add.names, anchorHash: anchorHash(messages[lastUserIdx]) }); + } else { + // No user message at all — cannot satisfy the placement + // constraint; skip this injection (the tool stays deferred and + // unloaded this request; honest degradation, not a malformed + // request). + reanchored.push({ names: add.names, anchorHash: null }); + continue; + } + } + if (!byAnchorIdx.has(landingIdx)) byAnchorIdx.set(landingIdx, []); + byAnchorIdx.get(landingIdx).push(add.message); + } + + const out = []; + messages.forEach((m, i) => { + out.push(m); + const injected = byAnchorIdx.get(i); + if (injected) out.push(...injected); + }); + + return { messages: out, reanchored }; +} + +// The frozen tools[] to forward when additions exist: knownTools order, +// with every name covered by an addition marked defer_loading — the +// classifier's knownTools stores UNMARKED objects (fingerprints must +// match CC's raw array), so the marker is applied at forward time. +export function forwardedTools(knownTools, additions) { + const deferredNames = new Set(additions.flatMap((a) => a.names)); + return knownTools.map((t) => (deferredNames.has(t.name) ? { ...t, defer_loading: true } : t)); +} + +// (Phase A's placeholder — a pseudo-XML text block appended to top-level +// body.system — was removed in Phase B: wrong shape, and decisively wrong +// location, since top-level system heads the cache prefix and every +// injection there would have busted the whole cache.) + +// --- Beta header (additive token; reuses no state from auto-1m-guard, but +// mirrors its header-token parse/join idiom rather than reimplementing ad +// hoc string splitting) --- + +function findBetaHeader(headers) { + if (!headers) return null; + for (const k of Object.keys(headers)) { + if (k.toLowerCase() === BETA_HEADER_NAME) return { key: k, raw: headers[k] }; + } + return null; +} + +function parseBetaTokens(raw) { + if (!raw) return []; + if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean); + if (typeof raw === "string") return raw.split(",").map((s) => s.trim()).filter(Boolean); + return []; +} + +export function addBetaToken(headers) { + const found = findBetaHeader(headers); + const tokens = found ? parseBetaTokens(found.raw) : []; + if (tokens.includes(BETA_TOKEN)) return; // already present — idempotent + tokens.push(BETA_TOKEN); + const key = found ? found.key : BETA_HEADER_NAME; + headers[key] = tokens.join(", "); +} + +// --- Extension contract --- + +export default { + name: "deferred-tool-rewrite", + description: + "Phase A: hold tools[] byte-stable across a pure tool addition, announcing the new tool via an appended " + + "tool_addition system block instead; also holds a harness-GC'd tool in place and pins output order to " + + "first-seen (works around CC's mid-conversation deferred-tool-load and tool-GC cache busts)", + enabled: false, // overridden by extensions.json + order: 425, + + async onRequest(ctx) { + if (!isEnabled()) return; + if (!ctx || !ctx.body) return; + + const body = ctx.body; + if (!Array.isArray(body.tools) || body.tools.length === 0) return; + + const dir = getSnapshotDir(); + const fs = DEFAULT_FS; + const headers = ctx.headers || null; + const sessionId = headers ? resolveSessionId(headers) : null; + const sessionKey = resolveToolRewriteSessionKey(headers, body); + + try { + const prior = await loadState(dir, sessionKey, fs); + const result = classifyToolChange(body.tools, prior?.tools ?? null); + + // Carry prior additions forward except on reset (a schema change + // re-baselines everything — the harness's own tools[] becomes truth + // and pending injections are abandoned with it). + let additions = result.action === "reset" ? [] : (prior?.additions ?? []); + + // Model gate, applied at the single point everything downstream reads. + // Emptying `additions` here disables the announcement, the + // defer_loading markers forwardedTools() derives from it, AND the beta + // header — one place rather than three, and it also neutralises state + // persisted before this gate existed (a session that accumulated + // additions under the old build must not keep replaying them into a + // model that 400s on them). + const announceOk = supportsToolAddition(body?.model); + if (!announceOk) additions = []; + + // A suppressed announcement is a real cost and must not be silent: the + // session pays a full-prefix bust per tool load exactly as if this + // extension were absent. The documented availability rule is "Opus + // onward", so a NEW model family landing here is most likely + // support-capable and unprobed — the warning names the probe so the + // gap closes in minutes instead of surviving until someone reads + // telemetry. Once per model per process; the telemetry entry carries + // `suppressed` on every occurrence. + const suppressed = + !announceOk && result.action === "rewrite" && (result.newNames?.length ?? 0) > 0; + if (suppressed && !warnedSuppressedModels.has(body?.model)) { + warnedSuppressedModels.add(body?.model); + process.stderr.write( + `[deferred-tool-rewrite] model ${body?.model} is not allowlisted for tool_addition — ` + + `tools[] busts are being paid (${result.newNames.join(",")}). ` + + `Probe it: see tools/probe-tool-addition.mjs (big models need the through-proxy method).\n`, + ); + } + + // The announcement path is gated on model support; the HOLD and + // ORDER-PIN paths are not, because neither needs the beta contract — + // they only ever re-send tools the model already understands. + if ( + announceOk && + result.action === "rewrite" && + result.newNames.length > 0 && + Array.isArray(body.messages) && + body.messages.length > 0 + ) { + // New tool(s): ONE addition message covering them all, anchored + // after the current last message. Injected below with any prior + // additions, and persisted for re-injection on every subsequent + // request (the API is stateless — see file header). + const anchorMsg = body.messages[body.messages.length - 1]; + additions = additions.concat([ + { + names: result.newNames, + anchorHash: anchorHash(anchorMsg), + message: buildToolAdditionMessage(result.newNames), + }, + ]); + } + + // Forward the frozen array whenever we hold state: rewrite uses the + // classifier's held order; "unchanged" ALSO re-forwards it when + // additions exist, because CC's incoming array never carries our + // defer_loading markers — forwarding it raw would silently un-defer + // every added tool. no-baseline and reset pass through untouched. + if (result.action === "rewrite") { + body.tools = forwardedTools(result.knownTools, additions); + } else if (result.action === "unchanged") { + // ALWAYS re-forward the frozen array here, not only when additions + // exist. Two reasons, and the second was measured the hard way: + // - CC's incoming array never carries our defer_loading markers, so + // forwarding it raw would silently un-defer every added tool; + // - "unchanged" now means "identical after volatile stripping", + // which includes descriptions that differ ONLY by the per-session + // console URL. Forwarding CC's raw array in that case would put + // the flip straight back on the wire and invalidate tools[] — + // making the identity fix pointless. The frozen array is the + // first-seen form, so the wire stays byte-stable. + body.tools = forwardedTools(prior.tools, additions); + } + + let reanchored = []; + if (additions.length > 0 && Array.isArray(body.messages)) { + const injected = injectAdditions(body.messages, additions); + body.messages = injected.messages; + reanchored = injected.reanchored; + if (reanchored.length > 0) { + additions = additions.map((a) => { + const r = reanchored.find((x) => x.names.join() === a.names.join()); + return r && r.anchorHash ? { ...a, anchorHash: r.anchorHash } : a; + }); + } + // Beta token whenever a deferred tool / injected message is on + // the wire — every request after the first addition. + if (headers) addBetaToken(headers); + } + + await saveState(dir, sessionKey, { tools: result.knownTools, additions }, fs); + + ctx.meta = ctx.meta || {}; + ctx.meta.deferredToolRewriteStats = { + action: result.action, + newNames: result.newNames ?? [], + heldNames: result.heldNames ?? [], + reason: result.reason ?? null, + injected: additions.length, + reanchored: reanchored.filter((r) => r.anchorHash).length, + }; + + await appendTelemetry( + dir, + sessionKey, + { + ts: new Date().toISOString(), + key: sessionKey, + sid: sessionId, + action: result.action, + newNames: result.newNames ?? [], + heldNames: result.heldNames ?? [], + injected: additions.length, + ...(suppressed ? { suppressed: true, model: body?.model } : {}), + ...(reanchored.length > 0 ? { reanchored } : {}), + ...(result.reason ? { reason: result.reason } : {}), + }, + fs, + ); + + if (isDebug()) { + process.stderr.write( + `[deferred-tool-rewrite] action=${result.action}` + + (result.newNames ? ` new=${result.newNames.join(",")}` : "") + + (result.heldNames && result.heldNames.length ? ` held=${result.heldNames.join(",")}` : "") + + (result.reason ? ` reason=${result.reason}` : "") + + "\n", + ); + } + } catch (err) { + debug(`onRequest unexpected: ${err?.message ?? err}`); + } + }, +}; diff --git a/proxy/extensions/insertion-normalization.mjs b/proxy/extensions/insertion-normalization.mjs new file mode 100644 index 00000000..ee49e91e --- /dev/null +++ b/proxy/extensions/insertion-normalization.mjs @@ -0,0 +1,937 @@ +// insertion-normalization — re-serialize a mid-history splice back into +// arrival order so the prefix cache sees an append instead of a rewrite. +// +// Design: docs/directives/proxy-insertion-normalization.md (phase 2 of the +// the removed mid-history-breakpoint-ladder work). Implements the Design +// sketch rules 1-4 only; the "Alternative considered" (full marker +// ownership) section is explicitly NOT built here. +// +// Activation: `enabled: true` in extensions.json (always loaded), runtime +// gate CACHE_FIX_INSERTION_NORMALIZE=1 (opt-in, read per-call so tests can +// flip it without re-importing). CACHE_FIX_DEBUG honored for swallowed +// I/O errors, same idiom as prefix-diff. +// +// Order 395 — after read-dedupe (380), before cache-control-normalize +// (400), so the marker-placing pass sees the normalized order. (Two other +// marker placers once sat at 410 and 420; both were removed 2026-07-28 — +// see message-hash.mjs.) +// Verified-safe adjacent slot: read-dedupe only rewrites LATER duplicate +// occurrences of a Read tool_result (the first/keeper occurrence is never +// rewritten, and "keeper" is monotonic — a message that was already the +// earliest occurrence of its dedupe key stays the earliest occurrence as +// new messages arrive), so a message already recorded in canonical never +// has its content-hash change out from under it by running after +// read-dedupe. content-strip (330) and microcompact-stability (350) run +// even earlier, so their output is what canonical hashes see from the +// start — no special handling needed for those either. +// +// --- Canonical history model --- +// +// Per session (keyed off the session-id header, same derivation as +// prefix-diff post-fc432bf — SUB-KEYED additionally by a hash +// of the request's system prompt, see systemPromptSubKey/threat-matrix +// row 14: sidecar requests such as title-generation share the session-id +// header with the main thread but carry a different system prompt, so +// without the sub-key every sidecar turn thrashed the main thread's +// canonical back to reset), the proxy holds an append-only list of entry +// identity records: { h: contentHash, r: role, o: occurrence }. +// The hash is computed AFTER stripping cache_control (message-hash.mjs's +// hashMessageContent, imported rather than reimplemented) so a marker placed by a downstream extension never +// changes an entry's identity. `o` is a 0-based occurrence counter over +// (hash, role) pairs in array order, which disambiguates duplicate +// identical messages (directive's "Known risks to resolve"). +// +// --- Classification --- +// +// On each request, canonical entries are matched into the incoming +// messages array by identity. Two things can go wrong with that match, +// and either one sends the request to rule 3 (passthrough + reset): +// - some canonical entry has no matching identity in incoming (covers +// true edits, removals, assistant-content changes, and a shrunk +// history — a shorter incoming array can never contain every +// canonical entry); +// - the matched incoming indices are not strictly increasing (the +// canonical order isn't preserved as a subsequence). +// +// If the match holds, incoming entries not matched into canonical are +// "new". New entries positioned AFTER every matched canonical index are +// ordinary tail growth (the ordinary shape of a conversation advancing — +// including a new assistant turn, which is expected and never restricted). +// New entries positioned AT OR BEFORE the last matched canonical index are +// the actual splice: content Claude Code inserted earlier than where it +// arrived. Rule 2 (INSERTION-ONLY) applies only to those: +// - every such entry's role must not be "assistant" (the directive says +// "user-role or system-role — never assistant"; this transport's +// messages[] array only ever carries role "user" or "assistant" — the +// system prompt is a separate top-level field, never a messages[] +// entry — so this reduces to "must be role user". Surfaced as a GAP +// rather than silently assumed away: see the closing report.); +// - re-serializing (canonical order first, then ALL new entries — +// spliced and tail alike — appended in their incoming relative order) +// must not separate any tool_result-bearing user message from an +// immediately-preceding assistant message carrying the matching +// tool_use id(s). +// +// When both hold, the request is re-serialized and forwarded; canonical +// grows by appending the new entries' identities (in the same order they +// were appended to the message array). When either fails, or when the +// match itself failed, the request passes through UNCHANGED and canonical +// resets to a fresh identity list computed from incoming — one honest +// bust, per the directive's conservative bias. +// +// Note the re-serialization formula subsumes plain append: when every new +// entry is already tail growth, "canonical order + new entries appended +// in arrival order" reproduces incoming byte-for-byte. The two cases are +// told apart only for telemetry (action: "append-only" when nothing +// moved, "normalized" when a splice was detected and corrected). +// +// --- Phase 3: volatile-block pinning + removal tolerance (opt-in) --- +// +// Directive: docs/directives/proxy-volatile-block-pinning.md. Gated +// separately by CACHE_FIX_VOLATILE_PIN=1 so the phase-2 behavior above is +// byte-identical when the flag is off — the two modes even keep separate +// canonical identity math, and a canon file written under one mode is +// ignored by the other (one honest reset at the flag flip, never a +// mismatch). +// +// WHAT THE FLIP COSTS, measured when it was actually thrown (2026-07-28 +// 17:08, live): the reset is per-conversation and lands on the FIRST request +// after the flip, so a session already deep in context re-caches all of it — +// here cache_read 605,220 -> 15,132 with 678,522 creation tokens, the first +// post-flip request reporting `cause=messages@4(assistant)`. The canon ledger +// shows it plainly: `reset/no-prior-canonical` under a NEW canon key, +// `append-only` immediately after. That is the documented behaviour working, +// not a defect — but it is a real one-time bill, so throw this flag at a +// session boundary or on a young session, never mid-way through a long one. +// It cannot recur for a session once flipped. +// +// (A canon migration — read the phase-2 file, re-derive pin identities — would +// remove the cost. Deliberately NOT built: it is one-time per session, and a +// migration path is a second identity code path to keep correct forever.) +// +// Pin mode changes two things, both measured on live traffic 2026-07-28: +// +// 1. FLIP ABSORPTION. CC serializes hook-injected additionalContext +// blocks nondeterministically for deep-history +// messages — present in one request, absent from the next (two +// attributed whole-context busts: 135k + 182k, both named by the +// prevContent/nowContent capture). In pin mode a user message's +// identity hash EXCLUDES volatile blocks (a text block that is +// entirely a wrap, or empty text — the observed +// flip counterpart), so both serializations match the same canonical +// entry, and the proxy forwards the FIRST-SEEN bytes: byte-stable +// history, the flip never reaches the cache. Hard limits: user-role +// only; text blocks only (tool_results are never volatile); a message +// carrying a cache_control marker is never rewritten; a non-volatile +// difference changes the identity hash and takes the reset path. +// +// 2. REMOVAL TOLERANCE. Same-tenant message-count shrinks are routine +// (91 measured; context-management-2025-06-27 confirmed in the wire +// beta set), and each one killed the phase-2 subsequence match — +// reset-per-prune, degrading the extension to a no-op for the rest of +// the session. In pin mode a canonical entry missing from incoming is +// marked dropped (kept in the file, flagged, never forwarded) and the +// match continues past the gap; order violations among SURVIVORS +// remain a hard reset, and dropping more than half the live entries +// resets too (that is a compaction, not a prune). + +import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { claudeHome } from "../claude-home.mjs"; +import { resolveSessionId } from "./cache-telemetry.mjs"; +import { hashMessageContent, conversationSubKey } from "./message-hash.mjs"; + +const DEFAULT_FS = { readFile, writeFile, rename, appendFile, mkdir }; + +// --- Env gates (read per-call, mirrors the prefix-diff idiom) --- + +function isEnabled(env = process.env) { + return env.CACHE_FIX_INSERTION_NORMALIZE === "1"; +} + +// Phase-3 gate (volatile-block pinning + removal tolerance). Requires the +// phase-2 gate too: pin mode is a refinement of the canonical model, not +// an independent extension. +function isPinEnabled(env = process.env) { + return env.CACHE_FIX_VOLATILE_PIN === "1"; +} + +function isDebug(env = process.env) { + return env.CACHE_FIX_DEBUG === "1"; +} + +function debug(msg) { + if (isDebug()) process.stderr.write(`[insertion-normalize] DEBUG: ${msg}\n`); +} + +// --- Storage --- + +function getSnapshotDir() { + return join(claudeHome(), "cache-fix-snapshots"); +} + +// Sub-key on the system prompt (threat-matrix row 14): sidecar requests +// (title-generation etc.) share the session-id header with the main thread +// but carry a DIFFERENT system prompt. Keying persisted canonical state on +// the session-id header alone made every sidecar turn look like a splice +// against the main thread's canonical (or vice versa), thrashing it back +// to a reset — never corrupting, but degrading the extension to a no-op +// for that session. A short hash of system[0]'s text (mirrors prefix- +// diff's computeSessionKey idiom — same "cheap discriminator over the +// diffed content" shape, but used here as a SUB-key alongside the stable +// session-id, never as the sole key, so prefix-diff's own "self-defeating +// key" lesson doesn't apply: a change inside the hashed text can only ever +// route to a *different* bucket, never lose the lookup entirely) buckets +// the main thread and each distinct sidecar system prompt independently +// under the same session-id. +// +// Exported so sibling extensions that persist per-session state key it the +// same way — the collision is a property of the session-id header, not of +// this extension, so every consumer of that header needs the same sub-key. +export function systemPromptSubKey(system) { + let text; + if (typeof system === "string") { + text = system; + } else if (Array.isArray(system) && system.length > 0) { + const first = system[0]; + text = typeof first?.text === "string" ? first.text : JSON.stringify(first ?? null); + } else { + return "nosys"; + } + if (!text) return "nosys"; + return createHash("sha256").update(text).digest("hex").slice(0, 8); +} + +// Session-id header derivation, same idiom as prefix-diff +// (post-fc432bf: session-id header preferred, content-hash fallback for +// requests without it — direct API calls, tests). Sub-keyed on the system +// prompt (see systemPromptSubKey) so sidecar requests sharing the header +// bucket separately from the main thread. Old single-key state files +// (pre-sub-key) are simply abandoned under the new path — loadCanonical's +// existing ENOENT handling already treats an absent file as "no prior +// canonical" (ordinary session start), so no explicit migration is needed. +// CONVERSATION sub-key (2026-07-28) — row 14, one level deeper. The +// system-prompt hash separates a sidecar CLASS from the main thread, but not +// the individual conversations WITHIN a class: every subagent this session +// dispatches runs the same agent system prompt, so they all landed in one +// bucket and overwrote each other's canonical. Measured on real traffic +// (capture s-35d72503, 602 requests): one system-prompt bucket held 39 +// distinct conversations, another 12 — and the correlation with resets was +// total. +// +// conversation SWITCH within a bucket: 60 requests, 60 resets (100%) +// same conversation continuing : 538 requests, 4 resets (1%) +// +// 72 of 83 resets across both corpora were this artifact, not real history +// churn: each switch made the incoming history look like a wholesale rewrite +// of whatever tenant spoke last, which classifies as dropped-majority. The +// extension was spending almost all of its reset budget on a keying bug. +// +// msgs[0] identifies a conversation because it is the one entry nothing +// appends past. When compaction or context-management replaces it the key +// moves and the canonical is abandoned — one honest reset, exactly what the +// old key produced anyway on the same event, since a replaced msgs[0] fails +// the subsequence match regardless. +// Conversation identity from msgs[0]. hashMessageContent covers block-array +// content only (it strips cache_control per block) and returns null for +// STRING content — correct for its own callers, but as a bucket key that +// null collapsed every string-content conversation into one shared "empty" +// bucket: 56 of 602 requests in the measured capture, which is where the +// residual dropped-majority resets lived after the first sub-key attempt. +// Falling back to a hash of the raw content covers both shapes; a message +// carrying no content at all is the only remaining "empty". +// conversationSubKey now lives in message-hash.mjs — deferred-tool-rewrite +// needs the identical function, and a second copy is a second truth. + +export function resolveInsertionSessionKey(headers, messages, system) { + const sid = headers ? resolveSessionId(headers) : null; + const conv = conversationSubKey(messages); + if (sid) { + return `s-${sid.replace(/[^A-Za-z0-9_-]/g, "_")}-${systemPromptSubKey(system)}-${conv}`; + } + return `c-${conv}`; +} + +function canonPath(dir, sessionKey) { + return join(dir, `${sessionKey}-insertion-canon.json`); +} + +function eventsPath(dir, sessionKey) { + return join(dir, `${sessionKey}-insertion-events.jsonl`); +} + +// `mode` discriminates phase-2 ("plain") from phase-3 ("pin") canon +// files: their identity hashes are incompatible ("v:"-prefixed user +// hashes in pin mode), and without the marker a flag flip could +// PARTIALLY match the other mode's file (assistant hashes are shared), +// producing wrong dropped flags instead of the intended single honest +// reset. Old files without the field read as "plain". +async function loadCanonical(dir, sessionKey, fs, mode = "plain") { + try { + const txt = await fs.readFile(canonPath(dir, sessionKey), "utf-8"); + const parsed = JSON.parse(txt); + if (!Array.isArray(parsed?.entries)) return null; + if ((parsed.mode ?? "plain") !== mode) return null; + return parsed.entries; + } catch (err) { + if (err && err.code !== "ENOENT") debug(`canonical read failed: ${err?.message ?? err}`); + return null; + } +} + +async function saveCanonical(dir, sessionKey, entries, fs, mode = "plain") { + await fs.mkdir(dir, { recursive: true }); + const finalPath = canonPath(dir, sessionKey); + const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify({ mode, entries }, null, 2)); + await fs.rename(tmpPath, finalPath); +} + +async function appendTelemetry(dir, sessionKey, record, fs) { + try { + await fs.mkdir(dir, { recursive: true }); + await fs.appendFile(eventsPath(dir, sessionKey), JSON.stringify(record) + "\n"); + } catch (err) { + debug(`telemetry append failed: ${err?.message ?? err}`); + } +} + +// --- Identity --- + +// One identity record per message, in array order. `o` is the 0-based +// occurrence count of (hash, role) seen so far — disambiguates duplicate +// identical messages (directive's "Known risks to resolve at +// implementation time"). +export function computeIdentities(messages) { + const seen = new Map(); // "hash|role" -> next occurrence index + const out = []; + for (let i = 0; i < messages.length; i++) { + const msg = canonicalMessageShape(messages[i]); + const h = hashMessageContent(msg) ?? hashNonBlockContent(msg, i); + const r = msg?.role ?? "unknown"; + const key = `${h}|${r}`; + const o = seen.get(key) ?? 0; + seen.set(key, o + 1); + out.push({ index: i, h, r, o }); + } + return out; +} + +// Identity for a message `hashMessageContent` cannot hash — it returns null +// unless `content` is a block ARRAY, and CC sends plenty of messages whose +// content is a plain string (system notes, mid-conversation system blocks). +// +// This fallback used to be `noContent:${i}` — the array INDEX, which made a +// message's identity its position. That is self-defeating for an extension +// whose entire job is absorbing mid-history insertions: the first insertion +// ahead of such a message shifted its index, the canonical lookup missed, and +// classifyInsertion reset with "not-subsequence". Measured 2026-07-27 in one +// live session: 83 index-keyed entries in a single sub-key and 125 resets +// across 350 requests — roughly one request in three, i.e. the extension was +// rebuilding from scratch instead of normalizing. +// +// Hash the content instead, so identity travels with the message. Only a +// genuinely contentless message (null/undefined) still falls back to the +// index, where no better identity exists; `noContent:` is kept as that +// marker's prefix so old canonical files degrade to one reset rather than +// mismatching silently. +function hashNonBlockContent(msg, i) { + const c = msg?.content; + if (c === null || c === undefined) return `noContent:${i}`; + const text = typeof c === "string" ? c : JSON.stringify(c); + return "s:" + createHash("sha256").update(text).digest("hex").slice(0, 16); +} + +// SHAPE FLIP (measured 2026-07-28, census over 771 captured requests). CC +// re-serializes the SAME message between two equivalent shapes: +// +// [{ "type": "text", "text": "X" }] <-> "X" +// +// The model sees identical content either way, but the two shapes hash +// through different functions (hashMessageContent for block arrays, +// hashNonBlockContent for strings), so their identities could never match: +// the message read as "one entry dropped, a different one added" and took a +// reset. Applied here — at the one point every identity path passes through +// — rather than in the pin, because the flip is NOT user-role-specific: the +// census found it predominantly on SYSTEM messages (harness reminders), and +// a user-only fold left every one of those still resetting. +// +// Deliberately narrow: only the exact single-text-block <-> string pair, and +// only when the block carries nothing beyond type/text/cache_control. A +// multi-block array is a genuinely different message and keeps its own +// identity. +function canonicalMessageShape(msg) { + const c = msg?.content; + if (typeof c === "string") return { ...msg, content: [{ type: "text", text: c }] }; + if (!Array.isArray(c) || c.length !== 1) return msg; + const b = c[0]; + if (!b || typeof b !== "object" || b.type !== "text" || typeof b.text !== "string") return msg; + const extra = Object.keys(b).filter((k) => k !== "type" && k !== "text" && k !== "cache_control"); + if (extra.length) return msg; + return { ...msg, content: [{ type: "text", text: b.text }] }; +} + +function identityKey(entry) { + return `${entry.h}|${entry.r}|${entry.o}`; +} + +// --- Phase 3: volatile blocks and pin-mode identity --- + +// The wrap regex identity-normalization already uses — the harness marks +// its own injections with it. No allowlist of reminder texts: the flip +// evidence already covers four reminder kinds, and a pattern list would +// be the next mole (directive, part A). +const VOLATILE_WRAP_REGEX = /^\n[\s\S]*\n<\/system-reminder>\s*$/; + +// A text block is volatile iff it is entirely a system-reminder wrap OR +// empty — the observed flip alternates a reminder block with an +// empty-text block (capture 2026-07-27T22:13Z: prev = the reminder, +// now = ""), so both sides must classify volatile for the identities to +// meet. tool_result / tool_use / thinking blocks are NEVER volatile. +export function isVolatileBlock(block) { + if (!block || typeof block !== "object" || block.type !== "text") return false; + if (typeof block.text !== "string") return false; + if (block.text === "") return true; + return VOLATILE_WRAP_REGEX.test(block.text); +} + +// Identity hash for pin mode: user-role messages hash over their +// non-volatile blocks only (cache_control stripped, same as +// hashMessageContent). Assistant and string-content messages fall through to +// the phase-2 identity, which applies canonicalMessageShape itself — so the +// shape flip documented there is absorbed on every role, not just this path. +function hashPinnedIdentity(msg) { + if (!msg || msg.role !== "user" || !Array.isArray(msg.content)) return null; + const kept = []; + for (const block of msg.content) { + if (isVolatileBlock(block)) continue; + if (block && typeof block === "object") { + const { cache_control, ...rest } = block; + kept.push(rest); + } else { + kept.push(block); + } + } + return "v:" + createHash("sha256").update(JSON.stringify(kept)).digest("hex").slice(0, 16); +} + +// Pin-mode identities: same record shape as computeIdentities, with the +// volatile-excluded hash for user block-array messages. The "v:" prefix +// keeps pin-mode canon files disjoint from phase-2 ones — a canon written +// under the other mode fails the identity match wholesale and takes one +// honest reset, never a silent partial mismatch. +export function computePinnedIdentities(messages) { + const seen = new Map(); + const out = []; + for (let i = 0; i < messages.length; i++) { + const msg = canonicalMessageShape(messages[i]); + const h = + hashPinnedIdentity(msg) ?? hashMessageContent(msg) ?? hashNonBlockContent(msg, i); + const r = msg?.role ?? "unknown"; + const key = `${h}|${r}`; + const o = seen.get(key) ?? 0; + seen.set(key, o + 1); + out.push({ index: i, h, r, o }); + } + return out; +} + +// --- Tool_result / tool_use adjacency invariant --- +// +// For every user message carrying >=1 tool_result block, the immediately +// preceding message must be an assistant message whose tool_use blocks +// cover every tool_use_id referenced by this message's tool_result +// blocks. Violating this is a hard API-shape break, not just a cache +// concern — the directive requires falling back to rule 3 rather than +// producing an invalid re-serialization. +export function validateToolAdjacency(messages) { + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (!msg || msg.role !== "user" || !Array.isArray(msg.content)) continue; + const toolUseIds = msg.content + .filter((b) => b && b.type === "tool_result" && typeof b.tool_use_id === "string") + .map((b) => b.tool_use_id); + if (toolUseIds.length === 0) continue; + + const prev = messages[i - 1]; + if (!prev || prev.role !== "assistant" || !Array.isArray(prev.content)) return false; + const prevToolUseIds = new Set( + prev.content.filter((b) => b && b.type === "tool_use" && typeof b.id === "string").map((b) => b.id), + ); + for (const id of toolUseIds) { + if (!prevToolUseIds.has(id)) return false; + } + } + return true; +} + +// --- Core classifier (pure) --- +// +// Returns: +// { action: "reset", resetReason, canonicalEntries } — passthrough, canonical := fresh(incoming) +// { action: "append-only", messages, canonicalEntries, inserted } — passthrough (formula reproduces incoming) +// { action: "normalized", messages, canonicalEntries, inserted } — re-serialized, forward `messages` +export function classifyInsertion(messages, priorCanonical) { + const incomingIdentities = computeIdentities(messages); + + if (!Array.isArray(priorCanonical) || priorCanonical.length === 0) { + return { + action: "reset", + resetReason: "no-prior-canonical", + canonicalEntries: incomingIdentities.map((e) => ({ h: e.h, r: e.r, o: e.o })), + }; + } + + const incomingByKey = new Map(incomingIdentities.map((e) => [identityKey(e), e.index])); + + const matchedIndices = []; + for (const stored of priorCanonical) { + const idx = incomingByKey.get(identityKey(stored)); + if (idx === undefined) { + return { + action: "reset", + resetReason: "not-subsequence", + canonicalEntries: incomingIdentities.map((e) => ({ h: e.h, r: e.r, o: e.o })), + }; + } + matchedIndices.push(idx); + } + for (let i = 1; i < matchedIndices.length; i++) { + if (matchedIndices[i] <= matchedIndices[i - 1]) { + return { + action: "reset", + resetReason: "not-subsequence", + canonicalEntries: incomingIdentities.map((e) => ({ h: e.h, r: e.r, o: e.o })), + }; + } + } + + const matchedSet = new Set(matchedIndices); + const lastMatched = matchedIndices.length > 0 ? matchedIndices[matchedIndices.length - 1] : -1; + const newEntries = incomingIdentities.filter((e) => !matchedSet.has(e.index)); + const splicedEntries = newEntries.filter((e) => e.index <= lastMatched); + + if (splicedEntries.length === 0) { + // Pure tail growth — the re-serialization formula reproduces `messages` + // unchanged, so skip building it and just report append-only. + const canonicalEntries = priorCanonical.concat(newEntries.map((e) => ({ h: e.h, r: e.r, o: e.o }))); + return { action: "append-only", messages, canonicalEntries, inserted: newEntries.length }; + } + + if (splicedEntries.some((e) => e.r === "assistant")) { + return { + action: "reset", + resetReason: "assistant-interleaved", + canonicalEntries: incomingIdentities.map((e) => ({ h: e.h, r: e.r, o: e.o })), + }; + } + + // Re-serialize: canonical order first, then ALL new entries (spliced and + // tail alike) appended in their incoming relative order. + const finalMessages = matchedIndices.map((idx) => messages[idx]).concat(newEntries.map((e) => messages[e.index])); + + if (!validateToolAdjacency(finalMessages)) { + return { + action: "reset", + resetReason: "adjacency-violation", + canonicalEntries: incomingIdentities.map((e) => ({ h: e.h, r: e.r, o: e.o })), + }; + } + + const canonicalEntries = priorCanonical.concat(newEntries.map((e) => ({ h: e.h, r: e.r, o: e.o }))); + return { action: "normalized", messages: finalMessages, canonicalEntries, inserted: newEntries.length }; +} + +// --- Phase 3 classifier (pure) --- + +function hasCacheControl(msg) { + if (!msg || !Array.isArray(msg.content)) return false; + return msg.content.some((b) => b && typeof b === "object" && b.cache_control); +} + +function stripAllCacheControl(msg) { + if (!msg || !Array.isArray(msg.content)) return msg; + return { + ...msg, + content: msg.content.map((b) => { + if (b && typeof b === "object" && b.cache_control) { + const { cache_control, ...rest } = b; + return rest; + } + return b; + }), + }; +} + +// Remove volatile blocks from a user message. When a canonical entry has +// no stored first-seen form (`m`), this IS the first-seen form: `m` is +// stored precisely when first-seen contained a volatile block, so its +// absence means first-seen had none — and stripping the incoming +// message's later-gained volatile blocks reproduces those bytes. +export function stripVolatileBlocks(msg) { + if (!msg || msg.role !== "user" || !Array.isArray(msg.content)) return msg; + const kept = msg.content.filter((b) => !isVolatileBlock(b)); + if (kept.length === msg.content.length) return msg; + return { ...msg, content: kept }; +} + +function buildPinEntry(identity, msg) { + const entry = { h: identity.h, r: identity.r, o: identity.o }; + if ( + msg?.role === "user" && + Array.isArray(msg.content) && + msg.content.some(isVolatileBlock) + ) { + // First-seen form, cache_control stripped: a marker the tail rotation + // happened to leave on this message at creation must not be replayed + // forever from the pin. + entry.m = stripAllCacheControl(msg); + } + return entry; +} + +// The bytes forwarded for a matched canonical entry. The pin applies only +// from the second appearance on: a NEW message (not yet canonical) always +// forwards as-is, so a fresh hook reminder reaches the model at the live +// edge; by the time the pin kicks in, the model has already consumed it. +// A message currently carrying a cache_control marker is never rewritten +// — markers sit at the tail, flips live deep, and losing a marker would +// cost more than one flip absorbs. +function pinnedForwardForm(stored, incomingMsg) { + if (stored.r !== "user") return incomingMsg; + if (hasCacheControl(incomingMsg)) return incomingMsg; + return stored.m ?? stripVolatileBlocks(incomingMsg); +} + +// Pin-mode classification. Differences from classifyInsertion: +// - identities exclude volatile blocks (flip absorption); +// - canonical entries missing from incoming are marked dropped +// (`d: true`, kept in the file, skipped in later matches) instead of +// resetting — unless the dropped total passes half the canon, which +// reads as a compaction, not a prune; +// - matched user messages forward their first-seen form. +export function classifyPinned(messages, priorCanonical) { + const incoming = computePinnedIdentities(messages); + const freshEntries = () => incoming.map((e) => buildPinEntry(e, messages[e.index])); + + // A reset abandons the ORDER model. It must NOT abandon the PINS, and + // conflating the two cost real cache — threat-matrix row 22, measured + // 2026-07-28 on capture s-538c0aef: + // + // CC honestly replaced message 196, so reset(edit-shaped) was the right + // verdict and the cost belonged to 196+. But every reset returns without + // a `messages` field, so the caller forwards the incoming array raw — and + // that silently un-pinned message 177, whose first-seen + // this extension had been restoring. Our bytes changed at 177 while CC's + // were byte-identical there, so the bust began 19 messages early. + // + // Identity deliberately EXCLUDES volatile blocks, so an identity still + // present in priorCanonical names the same message and its stored + // first-seen bytes are still the right bytes to send. Pinning substitutes + // the CONTENT of a single user message and never adds, drops or reorders + // one, so applying it on a reset cannot affect count, roles or adjacency. + // + // Deliberately NOT used by the adjacency-violation reset: that path exists + // precisely because the pinned form broke tool adjacency, so it must send + // the raw array. + const priorByKey = new Map( + (Array.isArray(priorCanonical) ? priorCanonical : []).map((e) => [identityKey(e), e]), + ); + const resetKeepingPins = (resetReason) => { + const out = messages.slice(); + let applied = 0; + for (const e of incoming) { + const stored = priorByKey.get(identityKey(e)); + if (!stored) continue; + const fwd = pinnedForwardForm(stored, messages[e.index]); + if (fwd !== messages[e.index] && JSON.stringify(fwd) !== JSON.stringify(messages[e.index])) { + out[e.index] = fwd; + applied++; + } + } + // The canonical must describe the wire we JUST FORWARDED — the same + // invariant the success path states. Building it from `messages` while + // sending `out` makes the two disagree, and the next request then + // diverges against a baseline that was never on the wire. Measured: that + // mistake turned 0 violations into 3 on capture s-0edbd11c before the + // canonical was switched to the pinned array. + return { + action: "reset", + resetReason, + canonicalEntries: incoming.map((e) => buildPinEntry(e, out[e.index])), + ...(applied > 0 ? { messages: out } : {}), + pinned: applied, + }; + }; + + if (!Array.isArray(priorCanonical) || priorCanonical.length === 0) { + return { action: "reset", resetReason: "no-prior-canonical", canonicalEntries: freshEntries() }; + } + + const incomingByKey = new Map(incoming.map((e) => [identityKey(e), e.index])); + + const matched = []; // { ci: index into priorCanonical, idx: incoming index } + const droppedNow = new Set(); + let droppedBefore = 0; + for (let ci = 0; ci < priorCanonical.length; ci++) { + const stored = priorCanonical[ci]; + if (stored.d) { + droppedBefore++; + continue; + } + const idx = incomingByKey.get(identityKey(stored)); + if (idx === undefined) droppedNow.add(ci); + else matched.push({ ci, idx }); + } + for (let i = 1; i < matched.length; i++) { + if (matched[i].idx <= matched[i - 1].idx) { + return resetKeepingPins("not-subsequence"); + } + } + if (droppedBefore + droppedNow.size > priorCanonical.length / 2) { + return resetKeepingPins("dropped-majority"); + } + + const matchedIdxSet = new Set(matched.map((m) => m.idx)); + const lastMatched = matched.length > 0 ? matched[matched.length - 1].idx : -1; + const newEntries = incoming.filter((e) => !matchedIdxSet.has(e.index)); + const splicedEntries = newEntries.filter((e) => e.index <= lastMatched); + + // A true EDIT decomposes under drop-tolerance into drop + splice: the old + // content's identity disappears and a new one appears IN ITS PLACE. That + // must still reset — never paper over a real content change. + // + // But "a drop and a splice occurred in the same request" is too coarse a + // test for it, because the two can be unrelated: measured 2026-07-28 + // (capture s-35d72503, request 09:47:31) a tail message was pruned by an + // operator interrupt while a hook reminder migrated mid-history 24 indices + // away — one prune plus one insertion, neither an edit, reset anyway. That + // single false positive was the last real reset in the corpus. + // + // Co-location is the discriminator: a dropped canonical entry sits in a + // definite gap — between its nearest surviving predecessor and successor — + // and only a spliced entry landing INSIDE that gap is a plausible + // replacement for it. A splice elsewhere is an independent insertion. + const matchedCi = new Set(matched.map((m) => m.ci)); + const isEdit = (() => { + if (droppedNow.size === 0 || splicedEntries.length === 0) return false; + const splicedIdx = splicedEntries.map((e) => e.index); + for (const ci of droppedNow) { + // Nearest surviving neighbours of the dropped entry, in incoming space. + let lo = -1; + for (let j = ci - 1; j >= 0; j--) { + if (!matchedCi.has(j)) continue; + lo = matched.find((m) => m.ci === j).idx; + break; + } + let hi = Infinity; + for (let j = ci + 1; j < priorCanonical.length; j++) { + if (!matchedCi.has(j)) continue; + hi = matched.find((m) => m.ci === j).idx; + break; + } + if (splicedIdx.some((idx) => idx > lo && idx < hi)) return true; + } + return false; + })(); + if (isEdit) { + return resetKeepingPins("edit-shaped"); + } + + if (splicedEntries.some((e) => e.r === "assistant")) { + return resetKeepingPins("assistant-interleaved"); + } + + // Forwarded order is the INCOMING order, not "survivors then new". The two + // agree for a plain append; they diverge when CC splices an entry + // mid-history, and concatenating new entries at the end would then reorder + // real content — the very thing this extension exists to prevent. + let pinApplied = 0; + const matchedByIdx = new Map(matched.map(({ ci, idx }) => [idx, ci])); + const finalMessages = incoming.map((e) => { + const ci = matchedByIdx.get(e.index); + if (ci === undefined) return messages[e.index]; + const fwd = pinnedForwardForm(priorCanonical[ci], messages[e.index]); + if (fwd !== messages[e.index] && JSON.stringify(fwd) !== JSON.stringify(messages[e.index])) { + pinApplied++; + return fwd; + } + return messages[e.index]; + }); + + if (!validateToolAdjacency(finalMessages)) { + return { action: "reset", resetReason: "adjacency-violation", canonicalEntries: freshEntries() }; + } + + // POSITIONAL canonical rebuild (2026-07-28). Appending new entries to the + // tail records ARRIVAL order, not the order they occupy on the wire. When + // CC splits a message — hook reminders migrating out of a user message into + // their own system message is the measured case — the new entry is created + // mid-history but was filed at the end. Canonical order and wire order then + // disagreed permanently, and the next request touching that region failed + // the strictly-increasing check with `not-subsequence`. + // + // Measured before this fix (capture s-35d72503): an inversion at canonical + // position 81 for an entry that sits at wire index 79, and every remaining + // real reset in both corpora traced to exactly this. + // + // Rebuilding in incoming order fixes it. Dropped entries have no position + // in the new array, so they are re-inserted after the last surviving entry + // that preceded them — keeping them adjacent to their original neighbours + // so a later un-prune still matches in order. + const canonByIdx = new Map(); + for (const { ci, idx } of matched) canonByIdx.set(idx, priorCanonical[ci]); + const newByIdx = new Map(newEntries.map((e) => [e.index, buildPinEntry(e, messages[e.index])])); + const droppedAfter = new Map(); // incoming index -> canonical entries to trail it + { + let lastSeenIdx = -1; + for (let ci = 0; ci < priorCanonical.length; ci++) { + const entry = priorCanonical[ci]; + const hit = matched.find((m) => m.ci === ci); + if (hit) { + lastSeenIdx = hit.idx; + continue; + } + const marked = entry.d ? entry : { ...entry, d: true }; + if (!droppedAfter.has(lastSeenIdx)) droppedAfter.set(lastSeenIdx, []); + droppedAfter.get(lastSeenIdx).push(marked); + } + } + const canonicalEntries = []; + for (const trailing of droppedAfter.get(-1) ?? []) canonicalEntries.push(trailing); + for (const e of incoming) { + canonicalEntries.push(canonByIdx.get(e.index) ?? newByIdx.get(e.index)); + for (const trailing of droppedAfter.get(e.index) ?? []) canonicalEntries.push(trailing); + } + + const changed = splicedEntries.length > 0 || pinApplied > 0; + return { + action: changed ? "normalized" : "append-only", + messages: finalMessages, + canonicalEntries, + // ORDER INVARIANT (2026-07-28). The canonical we just wrote must describe + // the wire we just forwarded: reading live entries in canonical order, the + // wire index each occupies must be STRICTLY INCREASING. + // + // This is the mechanism behind every reset class this extension has had. + // The arrival-order defect violated exactly this — canonical position 81 + // holding an entry that sits at wire index 79 — and was visible only + // downstream, as a not-subsequence reset on a LATER request whose cause + // had to be traced backwards. A size statistic cannot see it: a split adds + // one canonical entry AND one wire message, so the counts stay equal while + // the order diverges. Bite-tested both ways — a size-drift signal flagged + // nothing; this check names the exact inversion. + // + // Reported, not asserted: a violation is a defect in OUR state model, so + // it belongs in front of a developer with a location rather than being + // swallowed by a silent reset. + canonOrderViolation: (() => { + const wireOf = new Map(incoming.map((e) => [identityKey(e), e.index])); + let prev = -1; + let seen = 0; + for (const entry of canonicalEntries) { + if (entry.d) continue; + const idx = wireOf.get(identityKey(entry)); + if (idx === undefined) continue; + seen++; + if (idx <= prev) return { at: seen - 1, wireIdx: idx, prevWireIdx: prev }; + prev = idx; + } + return null; + })(), + inserted: newEntries.length, + pinned: pinApplied, + dropped: droppedNow.size, + }; +} + +// --- Extension contract --- + +export default { + name: "insertion-normalization", + description: + "Re-serialize a mid-history splice (queued message / hook attachment / " + + "notification inserted earlier than its arrival) back into arrival " + + "order so the prefix cache sees an append instead of a rewrite", + enabled: false, // overridden by extensions.json + order: 395, + + async onRequest(ctx) { + if (!isEnabled()) return; + if (!ctx || !ctx.body) return; + + const body = ctx.body; + const messages = body.messages; + if (!Array.isArray(messages) || messages.length === 0) return; + + const dir = getSnapshotDir(); + const fs = DEFAULT_FS; + const headers = ctx.headers || null; + const sessionId = headers ? resolveSessionId(headers) : null; + const sessionKey = resolveInsertionSessionKey(headers, messages, body.system); + + try { + const pin = isPinEnabled(); + const mode = pin ? "pin" : "plain"; + const prior = await loadCanonical(dir, sessionKey, fs, mode); + const result = pin ? classifyPinned(messages, prior) : classifyInsertion(messages, prior); + + // Apply whatever the classifier produced, rather than keying on the + // action name. A reset now returns a pinned array too (row 22): the + // order model is abandoned, the pins are not. `append-only` returns the + // incoming array unchanged, so this stays a no-op there. + if (result.messages) { + body.messages = result.messages; + } + + await saveCanonical(dir, sessionKey, result.canonicalEntries, fs, mode); + + ctx.meta = ctx.meta || {}; + // canonSize / canonLive / msgs expose the STATE, not just the verdict. + // The append-vs-position defect (canonical entries filed in arrival + // order while sitting mid-history on the wire) was invisible in + // action + resetReason alone — it surfaced only as a canonical grown to + // 92 entries for an 84-message history. A state model drifting from the + // wire is the failure mode behind every reset class this extension has + // had, so the sizes belong in the telemetry tools/replay.mjs --trace + // reads. + ctx.meta.insertionNormalizeStats = { + action: result.action, + inserted: result.inserted ?? 0, + resetReason: result.resetReason, + canonSize: result.canonicalEntries?.length ?? 0, + canonLive: result.canonicalEntries?.filter((e) => !e.d).length ?? 0, + msgs: messages.length, + canonOrderViolation: result.canonOrderViolation ?? null, + ...(pin ? { pinned: result.pinned ?? 0, dropped: result.dropped ?? 0 } : {}), + }; + + await appendTelemetry( + dir, + sessionKey, + { + ts: new Date().toISOString(), + key: sessionKey, + sid: sessionId, + action: result.action, + inserted: result.inserted ?? 0, + ...(result.resetReason ? { resetReason: result.resetReason } : {}), + ...(pin ? { pinned: result.pinned ?? 0, dropped: result.dropped ?? 0 } : {}), + }, + fs, + ); + + if (isDebug()) { + process.stderr.write( + `[insertion-normalize] action=${result.action} inserted=${result.inserted ?? 0}` + + (result.resetReason ? ` reason=${result.resetReason}` : "") + + (pin ? ` pinned=${result.pinned ?? 0} dropped=${result.dropped ?? 0}` : "") + + "\n", + ); + } + } catch (err) { + debug(`onRequest unexpected: ${err?.message ?? err}`); + } + }, +}; diff --git a/proxy/extensions/message-hash.mjs b/proxy/extensions/message-hash.mjs new file mode 100644 index 00000000..db5fb65a --- /dev/null +++ b/proxy/extensions/message-hash.mjs @@ -0,0 +1,63 @@ +// message-hash — content identity for a message, shared by every extension +// that needs to recognise "the same message" across requests. +// +// Hash everything EXCEPT cache_control, because cache_control is what the +// proxy itself mutates: including it would make a message look changed the +// instant we mark it, which is the opposite of an identity. +// +// Not an extension — a primitive. It lived in mid-history-breakpoint-ladder +// until that extension was removed (it manufactured the mid-history +// divergences it was meant to bound); insertion-normalization and +// deferred-tool-rewrite both depend on this function and never depended on +// rung placement, so it moved here rather than dying with its old host. + +import { createHash } from "node:crypto"; + +export function hashMessageContent(msg) { + if (!msg || !Array.isArray(msg.content)) return null; + const stripped = msg.content.map((block) => { + if (!block || typeof block !== "object") return block; + const { cache_control, ...rest } = block; + return rest; + }); + return createHash("sha256").update(JSON.stringify(stripped)).digest("hex").slice(0, 16); +} + +// Conversation identity: the hash of msgs[0], the one entry nothing appends +// past. Lives here, not in one extension, because BOTH stateful extensions +// need it and the second one learning it late is what this file exists to +// prevent. +// +// History (2026-07-28, one day, twice). insertion-normalization keyed its +// canonical on (session-id, system-prompt) and thrashed: every subagent of a +// session runs the same agent prompt, so one bucket held 39 distinct +// conversations and 100% of conversation switches within a bucket reset +// (60/60) against 1% of same-conversation continuations. Adding this sub-key +// took it to 0 resets across 940 requests. +// +// deferred-tool-rewrite had the IDENTICAL key and did not get the fix, and it +// cost real cache: its tool_addition announcement is anchored to a message +// identity, so under a shared key the anchor belongs to somebody else's +// history, fails to match, and re-anchors to "after the last user message" — +// a different index every request. Measured: output diverging at index 4 +// while CC's own history was identical through index 23, twice in one corpus. +// +// The general rule this keeps re-teaching: an identity computed more cheaply +// than the thing it identifies will collide, and the collision presents as +// churn rather than as a bug. +export function conversationSubKey(messages) { + const first = Array.isArray(messages) ? messages[0] : null; + if (!first) return "empty"; + const h = hashMessageContent(first); + if (h) return h; + // hashMessageContent covers block-array content only and returns null for + // STRING content — correct for its own callers, but as a bucket key that + // null collapsed every string-content conversation into one shared "empty" + // bucket (56 of 602 requests in the measured capture). A message carrying + // no content at all is the only remaining "empty". + if (first.content === undefined || first.content === null) return "empty"; + return createHash("sha256") + .update(JSON.stringify({ role: first.role ?? null, content: first.content })) + .digest("hex") + .slice(0, 16); +} diff --git a/test/deferred-tool-rewrite.test.mjs b/test/deferred-tool-rewrite.test.mjs new file mode 100644 index 00000000..4a57f9c3 --- /dev/null +++ b/test/deferred-tool-rewrite.test.mjs @@ -0,0 +1,950 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import ext, { + resolveToolRewriteSessionKey, + supportsToolAddition, + toolFingerprint, + classifyToolChange, + buildToolAdditionMessage, + injectAdditions, + forwardedTools, + anchorHash, + addBetaToken, +} from "../proxy/extensions/deferred-tool-rewrite.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(__dirname, "fixtures", "toolload-1247.json"); +const GC_FIXTURE_PATH = join(__dirname, "fixtures", "toolgc-1536.json"); + +async function newTmp() { + return mkdtemp(join(tmpdir(), "deferred-tool-rewrite-test-")); +} + +function withEnv(overrides, fn) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return fn(); + } finally { + for (const k of Object.keys(saved)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +async function withEnvAsync(overrides, fn) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return await fn(); + } finally { + for (const k of Object.keys(saved)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +// The announcement path is gated on MODEL support (see supportsToolAddition): +// a body without a model is "unknown", which is deliberately OFF. Most tests +// here predate that gate and exercise the announcement, so they default to a +// supported model; the tests that care about the gate set `model` explicitly. +async function runExt(body, { headers, dir } = {}) { + const savedHome = process.env.CLAUDE_CONFIG_DIR; + if (dir) process.env.CLAUDE_CONFIG_DIR = dir; + try { + const withModel = body && body.model === undefined ? { ...body, model: "claude-opus-5" } : body; + const ctx = { body: withModel, meta: {}, headers: headers || {} }; + await ext.onRequest(ctx); + return ctx; + } finally { + if (dir) { + if (savedHome === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = savedHome; + } + } +} + +function tool(name, extra = {}) { + return { name, input_schema: { type: "object", properties: {} }, ...extra }; +} + +// ============================================================================= +// GATE OFF = INERT +// ============================================================================= + +test("gate off (CACHE_FIX_TOOL_REWRITE unset) — onRequest is a no-op", async () => { + const dir = await newTmp(); + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: undefined }, async () => { + const body = { tools: [tool("Read"), tool("Bash")], messages: [] }; + const ctx = await runExt(body, { dir }); + assert.equal(ctx.meta.deferredToolRewriteStats, undefined); + assert.deepEqual(ctx.body.tools, [tool("Read"), tool("Bash")]); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ============================================================================= +// PURE CLASSIFIER +// ============================================================================= + +test("classifyToolChange: no prior baseline → action no-baseline, knownTools = incoming", () => { + const incoming = [tool("Read"), tool("Bash")]; + const result = classifyToolChange(incoming, null); + assert.equal(result.action, "no-baseline"); + assert.deepEqual(result.knownTools, incoming); +}); + +test("classifyToolChange: identical tools[] → action unchanged", () => { + const prior = [tool("Read"), tool("Bash")]; + const incoming = [tool("Read"), tool("Bash")]; + const result = classifyToolChange(incoming, prior); + assert.equal(result.action, "unchanged"); +}); + +test("classifyToolChange: pure addition (SendMessage added) → action rewrite, new tool marked defer_loading:true", () => { + const prior = [tool("Read"), tool("Bash")]; + const incoming = [tool("Read"), tool("Bash"), tool("SendMessage")]; + const result = classifyToolChange(incoming, prior); + assert.equal(result.action, "rewrite"); + assert.deepEqual(result.newNames, ["SendMessage"]); + assert.equal(result.tools.length, 3); + assert.equal(result.tools[0].name, "Read"); + assert.equal(result.tools[0].defer_loading, undefined, "existing tools are not marked defer_loading"); + assert.equal(result.tools[2].name, "SendMessage"); + assert.equal(result.tools[2].defer_loading, true); +}); + +test("classifyToolChange: existing tool removed → action rewrite, held in place at its first-seen position, byte-identical", () => { + const prior = [tool("Read"), tool("Bash")]; + const incoming = [tool("Read")]; // Bash missing — harness GC'd it + const result = classifyToolChange(incoming, prior); + assert.equal(result.action, "rewrite"); + assert.deepEqual(result.heldNames, ["Bash"]); + assert.equal(result.newNames.length, 0); + assert.equal(result.tools.length, 2, "held tool is re-inserted"); + assert.deepEqual(result.tools[0], tool("Read")); + assert.deepEqual(result.tools[1], tool("Bash"), "held tool is byte-identical to its known form"); +}); + +test("classifyToolChange: pure reorder (no add/remove) → action rewrite, output pinned to first-seen order", () => { + const prior = [tool("Read"), tool("Bash"), tool("SendMessage")]; + const incoming = [tool("SendMessage"), tool("Read"), tool("Bash")]; // reordered, nothing added/removed + const result = classifyToolChange(incoming, prior); + assert.equal(result.action, "rewrite"); + assert.deepEqual(result.heldNames, []); + assert.deepEqual(result.newNames, []); + assert.deepEqual( + result.tools.map((t) => t.name), + ["Read", "Bash", "SendMessage"], + "output order is first-seen order, not the incoming array's order", + ); +}); + +test("classifyToolChange: existing tool's schema changed → action reset, reason tool-schema-changed", () => { + const prior = [tool("Read", { input_schema: { type: "object", properties: { file_path: { type: "string" } } } })]; + const incoming = [tool("Read", { input_schema: { type: "object", properties: { path: { type: "string" } } } })]; + const result = classifyToolChange(incoming, prior); + assert.equal(result.action, "reset"); + assert.equal(result.reason, "tool-schema-changed"); +}); + +test("classifyToolChange: addition AND removal in the same request → composes (held removal + additive new tool), still rewrite", () => { + const prior = [tool("Read"), tool("Bash")]; + const incoming = [tool("Read"), tool("SendMessage")]; // Bash removed, SendMessage added + const result = classifyToolChange(incoming, prior); + assert.equal(result.action, "rewrite"); + assert.deepEqual(result.heldNames, ["Bash"]); + assert.deepEqual(result.newNames, ["SendMessage"]); + assert.deepEqual( + result.tools.map((t) => t.name), + ["Read", "Bash", "SendMessage"], + "held tool re-inserted at its first-seen position, new tool appended", + ); + assert.equal(result.tools[2].defer_loading, true); +}); + +test("classifyToolChange: a tool carrying OUR OWN defer_loading marker from a prior rewrite is not misread as schema-changed", () => { + // Simulates: prior known set was captured AFTER a rewrite had already + // marked a tool defer_loading:true; toolFingerprint must ignore that + // marker so re-comparing it against itself is still "unchanged". + const priorWithMarker = [tool("Read"), { ...tool("SendMessage"), defer_loading: true }]; + const incoming = [tool("Read"), tool("SendMessage")]; // no marker this time — still the same tool + const result = classifyToolChange(incoming, priorWithMarker); + assert.equal(result.action, "unchanged"); +}); + +test("toolFingerprint: order-independent on schema property keys", () => { + const a = tool("Read", { input_schema: { type: "object", properties: { a: {}, b: {} } } }); + const b = tool("Read", { input_schema: { type: "object", properties: { b: {}, a: {} } } }); + assert.equal(toolFingerprint(a), toolFingerprint(b)); +}); + +test("toolFingerprint: missing tool or missing name → null", () => { + assert.equal(toolFingerprint(null), null); + assert.equal(toolFingerprint({}), null); +}); + +// ============================================================================= +// WIRE SHAPES +// ============================================================================= + +test("buildToolAdditionMessage: documented contract — system-role message with tool_addition/tool_reference blocks", () => { + const msg = buildToolAdditionMessage(["SendMessage", "TaskCreate"]); + assert.equal(msg.role, "system"); + assert.equal(msg.content.length, 2); + assert.deepEqual(msg.content[0], { + type: "tool_addition", + tool: { type: "tool_reference", name: "SendMessage" }, + }); + assert.deepEqual(msg.content[1], { + type: "tool_addition", + tool: { type: "tool_reference", name: "TaskCreate" }, + }); +}); + +test("injectAdditions: splices the persisted message after its anchor, byte-identical", () => { + const u0 = { role: "user", content: [{ type: "text", text: "u0" }] }; + const a1 = { role: "assistant", content: [{ type: "text", text: "a1" }] }; + const u2 = { role: "user", content: [{ type: "text", text: "u2" }] }; + const addMsg = buildToolAdditionMessage(["SendMessage"]); + const additions = [{ names: ["SendMessage"], anchorHash: anchorHash(u0), message: addMsg }]; + const { messages, reanchored } = injectAdditions([u0, a1, u2], additions); + assert.equal(reanchored.length, 0); + assert.equal(messages.length, 4); + assert.equal(messages[1], addMsg, "injected immediately after the anchor"); + assert.equal(messages[0], u0); + assert.equal(messages[2], a1); +}); + +test("injectAdditions: pruned anchor → re-anchor after last user message, reported", () => { + const uNew = { role: "user", content: [{ type: "text", text: "new turn" }] }; + const addMsg = buildToolAdditionMessage(["SendMessage"]); + const additions = [{ names: ["SendMessage"], anchorHash: "gone-hash", message: addMsg }]; + const { messages, reanchored } = injectAdditions([uNew], additions); + assert.equal(messages.length, 2); + assert.equal(messages[1], addMsg, "re-anchored after the last user message"); + assert.equal(reanchored.length, 1); + assert.equal(reanchored[0].anchorHash, anchorHash(uNew)); +}); + +test("injectAdditions: no user message at all → injection skipped, reported with null anchor", () => { + const a = { role: "assistant", content: [{ type: "text", text: "only assistant" }] }; + const addMsg = buildToolAdditionMessage(["SendMessage"]); + const additions = [{ names: ["SendMessage"], anchorHash: "gone", message: addMsg }]; + const { messages, reanchored } = injectAdditions([a], additions); + assert.equal(messages.length, 1, "nothing injected"); + assert.equal(reanchored[0].anchorHash, null); +}); + +// BITE — the LIFO bug (BACKLOG "READY — fix injectAdditions' LIFO stacking"). +// Real capture s-dc3f8071, n=372-397: an MCP-tool-discovery cascade produces +// one new `additions` entry per request, all anchored to the SAME message +// (the real conversation stays at 1 message the whole burst). The buggy +// implementation re-finds the anchor fresh on every iteration (the search +// excludes role==="system", so already-injected additions are invisible to +// it) and always splices at anchorIdx+1 — so the newest addition always +// lands closest to the anchor, pushing every earlier addition one slot +// further back: a LIFO stack that reorders the already-forwarded prefix on +// every new addition. Fix: a shared anchor's run stays in discovery order +// (FIFO) — a new addition appends AFTER the additions already injected +// there, so the forwarded prefix is a byte-stable prefix of every +// subsequent output and only the tail of the run grows. +test("injectAdditions: three additions sharing one anchor → output is discovery order (FIFO), not LIFO", () => { + const u0 = { role: "user", content: [{ type: "text", text: "u0" }] }; + const sharedAnchor = anchorHash(u0); + const addA = buildToolAdditionMessage(["ToolA"]); + const addB = buildToolAdditionMessage(["ToolB"]); + const addC = buildToolAdditionMessage(["ToolC"]); + + // additions array is in DISCOVERY order (oldest first), matching how + // onRequest concatenates them across successive requests. + const additions = [ + { names: ["ToolA"], anchorHash: sharedAnchor, message: addA }, + { names: ["ToolB"], anchorHash: sharedAnchor, message: addB }, + { names: ["ToolC"], anchorHash: sharedAnchor, message: addC }, + ]; + + const { messages } = injectAdditions([u0], additions); + assert.deepEqual( + messages.map((m) => m.content?.[0]?.tool?.name ?? "u0"), + ["u0", "ToolA", "ToolB", "ToolC"], + "run stays in discovery order — ToolA first (oldest), ToolC last (newest), never reordered", + ); +}); + +test("injectAdditions: shared-anchor prefix stability — output N is a byte-prefix of output N+1", () => { + const u0 = { role: "user", content: [{ type: "text", text: "u0" }] }; + const sharedAnchor = anchorHash(u0); + const addA = buildToolAdditionMessage(["ToolA"]); + const addB = buildToolAdditionMessage(["ToolB"]); + + // Simulates two successive requests: first only ToolA has been discovered, + // then ToolB arrives too (additions accumulate, oldest first — as onRequest + // does via `additions.concat([...])`). + const afterFirst = injectAdditions([u0], [{ names: ["ToolA"], anchorHash: sharedAnchor, message: addA }]); + const afterSecond = injectAdditions( + [u0], + [ + { names: ["ToolA"], anchorHash: sharedAnchor, message: addA }, + { names: ["ToolB"], anchorHash: sharedAnchor, message: addB }, + ], + ); + + const prefixBytes = JSON.stringify(afterFirst.messages); + const nextBytes = JSON.stringify(afterSecond.messages.slice(0, afterFirst.messages.length)); + assert.equal( + nextBytes, + prefixBytes, + "the already-forwarded prefix must be byte-identical once a new addition arrives — only the tail grows", + ); + assert.equal(afterSecond.messages.length, 3, "the new addition appends at the tail of the run"); +}); + +test("forwardedTools: names covered by additions get defer_loading, others stay untouched", () => { + const known = [tool("Read"), tool("SendMessage")]; + const additions = [{ names: ["SendMessage"], anchorHash: "h", message: {} }]; + const fwd = forwardedTools(known, additions); + assert.deepEqual(fwd[0], tool("Read")); + assert.equal(fwd[1].defer_loading, true); +}); + +test("addBetaToken: adds the token when header absent", () => { + const headers = {}; + addBetaToken(headers); + assert.equal(headers["anthropic-beta"], "mid-conversation-tool-changes-2026-07-01"); +}); + +test("addBetaToken: appends to an existing anthropic-beta header without duplicating", () => { + const headers = { "anthropic-beta": "other-beta-2026-01-01" }; + addBetaToken(headers); + assert.equal(headers["anthropic-beta"], "other-beta-2026-01-01, mid-conversation-tool-changes-2026-07-01"); + addBetaToken(headers); // idempotent + assert.equal(headers["anthropic-beta"], "other-beta-2026-01-01, mid-conversation-tool-changes-2026-07-01"); +}); + +test("addBetaToken: case-insensitive header key lookup (Anthropic-Beta)", () => { + const headers = { "Anthropic-Beta": "x" }; + addBetaToken(headers); + assert.equal(headers["Anthropic-Beta"], "x, mid-conversation-tool-changes-2026-07-01"); + assert.equal(headers["anthropic-beta"], undefined, "must mutate the existing key, not add a duplicate"); +}); + +// ============================================================================= +// EXTENSION CONTRACT — full onRequest round trip +// ============================================================================= + +test("onRequest: first request (no prior state) → tools forwarded unchanged, baseline persisted", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-first" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const body = { tools: [tool("Read"), tool("Bash")], system: [{ type: "text", text: "sys" }], messages: [] }; + const ctx = await runExt(body, { headers, dir }); + assert.equal(ctx.meta.deferredToolRewriteStats.action, "no-baseline"); + assert.deepEqual(ctx.body.tools, [tool("Read"), tool("Bash")]); + assert.equal(ctx.body.system.length, 1, "no tool_addition appended on the baseline-establishing request"); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("onRequest: second request adds SendMessage → tools[] byte-stable for known tools + defer_loading on the new one + tool_addition system block + beta header", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-add" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + // Same conversation across both requests: msgs[0] is what identifies + // one, so an empty first request would now be a DIFFERENT conversation + // (and no real first request is empty). + const u1 = { role: "user", content: [{ type: "text", text: "turn 1" }] }; + const body1 = { tools: [tool("Read"), tool("Bash")], system: [{ type: "text", text: "sys" }], messages: [u1] }; + await runExt(body1, { headers, dir }); + + const body2 = { + tools: [tool("Read"), tool("Bash"), tool("SendMessage")], + system: [{ type: "text", text: "sys" }], + messages: [u1], + }; + const ctx2 = await runExt(body2, { headers, dir }); + + assert.equal(ctx2.meta.deferredToolRewriteStats.action, "rewrite"); + assert.deepEqual(ctx2.meta.deferredToolRewriteStats.newNames, ["SendMessage"]); + + // Known tools byte-stable (no defer_loading marker added to them). + assert.deepEqual(ctx2.body.tools[0], tool("Read")); + assert.deepEqual(ctx2.body.tools[1], tool("Bash")); + // New tool additively marked. + assert.equal(ctx2.body.tools[2].name, "SendMessage"); + assert.equal(ctx2.body.tools[2].defer_loading, true); + + // Top-level system UNTOUCHED (Phase A appended here — wrong location). + assert.equal(ctx2.body.system.length, 1); + // The announcement is a system-ROLE message injected into messages[], + // after the anchor (the last message at addition time). + assert.equal(ctx2.body.messages.length, 2); + const injected = ctx2.body.messages[1]; + assert.equal(injected.role, "system"); + assert.deepEqual(injected.content[0], { + type: "tool_addition", + tool: { type: "tool_reference", name: "SendMessage" }, + }); + + // Beta header added. + assert.equal(headers["anthropic-beta"], "mid-conversation-tool-changes-2026-07-01"); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("onRequest: subsequent requests re-inject byte-identically at the same anchor (statelessness handled)", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-stable" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const u1 = { role: "user", content: [{ type: "text", text: "turn 1" }] }; + const body1 = { tools: [tool("Read"), tool("Bash")], system: [{ type: "text", text: "sys" }], messages: [u1] }; + await runExt(body1, { headers, dir }); + + const body2 = { + tools: [tool("Read"), tool("Bash"), tool("SendMessage")], + system: [{ type: "text", text: "sys" }], + messages: [u1], + }; + const ctx2 = await runExt(body2, { headers, dir }); + const injectedAt2 = JSON.stringify(ctx2.body.messages[1]); + + // Requests 3 and 4: CC sends its own view (no injected message, no + // defer_loading markers) with the conversation advancing. The proxy + // must re-inject at the SAME anchor, byte-identically, and re-apply + // the frozen tools[] with the marker — every request. + for (const extra of [ + [{ role: "assistant", content: [{ type: "text", text: "a2" }] }], + [ + { role: "assistant", content: [{ type: "text", text: "a2" }] }, + { role: "user", content: [{ type: "text", text: "turn 3" }] }, + ], + ]) { + const body = { + tools: [tool("Read"), tool("Bash"), tool("SendMessage")], + system: [{ type: "text", text: "sys" }], + messages: [u1, ...extra], + }; + const ctx = await runExt(body, { headers, dir }); + assert.equal(ctx.meta.deferredToolRewriteStats.action, "unchanged"); + assert.equal(ctx.meta.deferredToolRewriteStats.injected, 1); + // Injection sits right after the anchor (u1), byte-identical. + assert.equal(JSON.stringify(ctx.body.messages[1]), injectedAt2); + // Frozen tools[] with defer_loading re-applied. + assert.equal(ctx.body.tools[2].defer_loading, true); + // Top-level system never touched. + assert.equal(ctx.body.system.length, 1); + } + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("onRequest: pruned anchor → re-anchor once, stable thereafter", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-prune" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + // msgs[0] identifies the conversation, so it must SURVIVE the prune for + // this to exercise re-anchoring rather than a new conversation. The + // addition anchors to the LAST message, so anchor and msgs[0] are + // deliberately different messages here. + const u0 = { role: "user", content: [{ type: "text", text: "turn 0" }] }; + const u1 = { role: "user", content: [{ type: "text", text: "turn 1" }] }; + await runExt({ tools: [tool("Read")], system: [], messages: [u0] }, { headers, dir }); + await runExt( + { tools: [tool("Read"), tool("SendMessage")], system: [], messages: [u0, u1] }, + { headers, dir }, + ); + + // Context management pruned the ANCHOR message while msgs[0] survives — + // the same conversation, minus the turn the addition was anchored to. + // (Replacing msgs[0] instead would be a different conversation by + // design: the prefix died at index 0, so no cache survives it and a + // fresh state costs nothing. The re-anchor path is for this case.) + const uNew = { role: "user", content: [{ type: "text", text: "post-prune turn" }] }; + const ctx3 = await runExt( + { tools: [tool("Read"), tool("SendMessage")], system: [], messages: [u0, uNew] }, + { headers, dir }, + ); + assert.equal(ctx3.meta.deferredToolRewriteStats.reanchored, 1); + assert.equal(ctx3.body.messages[2].role, "system", "re-anchored after the last user message"); + + // Next request: the new anchor holds — no further re-anchor. + const ctx4 = await runExt( + { + tools: [tool("Read"), tool("SendMessage")], + system: [], + messages: [u0, uNew, { role: "assistant", content: [{ type: "text", text: "a" }] }], + }, + { headers, dir }, + ); + assert.equal(ctx4.meta.deferredToolRewriteStats.reanchored, 0); + // Anchored after uNew, which is now index 1 — so the injection is at 2. + assert.equal(ctx4.body.messages[2].role, "system"); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("onRequest BITE: MCP-discovery cascade — same 1-message conversation, tools[] grows 3x → additions stack in discovery order, prefix stable", async () => { + // Mirrors the real capture (s-dc3f8071, n=372-397): CC's own progressive + // MCP-tool-discovery cascade at session boot sends one new tool batch per + // request while the real conversation never grows past 1 message, so every + // addition shares the identical anchor (messages[0]). + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-cascade" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const u0 = { role: "user", content: [{ type: "text", text: "u0" }] }; + const base = { system: [], messages: [u0], model: "claude-opus-5" }; + + await runExt({ ...base, tools: [tool("Read"), tool("Bash")] }, { headers, dir }); // no-baseline + const ctx1 = await runExt( + { ...base, tools: [tool("Read"), tool("Bash"), tool("ToolA")] }, + { headers, dir }, + ); + const ctx2 = await runExt( + { ...base, tools: [tool("Read"), tool("Bash"), tool("ToolA"), tool("ToolB")] }, + { headers, dir }, + ); + const ctx3 = await runExt( + { ...base, tools: [tool("Read"), tool("Bash"), tool("ToolA"), tool("ToolB"), tool("ToolC")] }, + { headers, dir }, + ); + + const names = (ctx) => + ctx.body.messages + .filter((m) => m.role === "system" && Array.isArray(m.content) && m.content[0]?.type === "tool_addition") + .flatMap((m) => m.content.map((b) => b.tool.name)); + + assert.deepEqual(names(ctx1), ["ToolA"]); + assert.deepEqual(names(ctx2), ["ToolA", "ToolB"], "ToolA stays first — discovery order, not LIFO"); + assert.deepEqual(names(ctx3), ["ToolA", "ToolB", "ToolC"], "run grows only at the tail"); + + // The forwarded prefix already produced must be a byte-prefix of the + // next request's output — this is the "reorders the already-forwarded + // prefix" bust the probe measured. + const prefixOf = (ctx, n) => JSON.stringify(ctx.body.messages.slice(0, n)); + assert.equal(prefixOf(ctx2, ctx1.body.messages.length), JSON.stringify(ctx1.body.messages)); + assert.equal(prefixOf(ctx3, ctx2.body.messages.length), JSON.stringify(ctx2.body.messages)); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("onRequest: a tool removed after an addition → HELD (rewrite, passthrough of held tool), no beta header (nothing new to defer)", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-hold" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const body1 = { tools: [tool("Read"), tool("Bash")], system: [{ type: "text", text: "sys" }], messages: [] }; + await runExt(body1, { headers, dir }); + + const body2 = { tools: [tool("Read")], system: [{ type: "text", text: "sys" }], messages: [] }; + const ctx2 = await runExt(body2, { headers, dir }); + + assert.equal(ctx2.meta.deferredToolRewriteStats.action, "rewrite"); + assert.deepEqual(ctx2.meta.deferredToolRewriteStats.heldNames, ["Bash"]); + assert.deepEqual(ctx2.body.tools, [tool("Read"), tool("Bash")], "Bash held in place, byte-identical"); + assert.equal(ctx2.body.system.length, 1, "a hold announces nothing — no tool_addition block appended"); + assert.equal(headers["anthropic-beta"], undefined, "no defer_loading tool this turn -> no beta token needed"); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("onRequest: a known tool's SCHEMA changing (not removal) → still resets (honest content change, never served stale)", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-schema-reset" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const body1 = { tools: [tool("Read"), tool("Bash")], system: [{ type: "text", text: "sys" }], messages: [] }; + await runExt(body1, { headers, dir }); + + const body2 = { + tools: [tool("Read", { input_schema: { type: "object", properties: { path: { type: "string" } } } }), tool("Bash")], + system: [{ type: "text", text: "sys" }], + messages: [], + }; + const ctx2 = await runExt(body2, { headers, dir }); + + assert.equal(ctx2.meta.deferredToolRewriteStats.action, "reset"); + assert.equal(ctx2.meta.deferredToolRewriteStats.reason, "tool-schema-changed"); + assert.equal(headers["anthropic-beta"], undefined); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("onRequest: state persists across a simulated restart (fresh dynamic import) via disk", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-restart" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const body1 = { tools: [tool("Read"), tool("Bash")], system: [{ type: "text", text: "sys" }], messages: [] }; + await runExt(body1, { headers, dir }); + + // Simulate restart: fresh module import, empty in-memory state — the + // classifier must reload the persisted baseline from disk. + const { pathToFileURL } = await import("node:url"); + const modPath = join(__dirname, "..", "proxy", "extensions", "deferred-tool-rewrite.mjs"); + const reloaded = await import(pathToFileURL(modPath).href + "?restart-probe=" + Date.now()); + + const savedHome = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = dir; + try { + const body2 = { + tools: [tool("Read"), tool("Bash"), tool("SendMessage")], + system: [{ type: "text", text: "sys" }], + messages: [], + }; + const ctx2 = { body: body2, meta: {}, headers }; + await reloaded.default.onRequest(ctx2); + assert.equal(ctx2.meta.deferredToolRewriteStats.action, "rewrite", "post-restart module reloaded baseline from disk"); + assert.deepEqual(ctx2.meta.deferredToolRewriteStats.newNames, ["SendMessage"]); + } finally { + if (savedHome === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = savedHome; + } + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ============================================================================= +// SYNTHETIC FIXTURE (ledger SHAPE, 2026-07-27 12:47:56 — tools[SendMessage:added]) +// ============================================================================= + +test("fixture toolload-1247.json: prior → incoming reproduces the ledger's tools[SendMessage:added] shape as a rewrite", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-fixture" }; + try { + const raw = await readFile(FIXTURE_PATH, "utf-8"); + const fixture = JSON.parse(raw); + + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const ctx1 = await runExt(structuredClone(fixture.prior), { headers, dir }); + assert.equal(ctx1.meta.deferredToolRewriteStats.action, "no-baseline"); + + const ctx2 = await runExt(structuredClone(fixture.incoming), { headers, dir }); + assert.equal(ctx2.meta.deferredToolRewriteStats.action, "rewrite"); + assert.deepEqual(ctx2.meta.deferredToolRewriteStats.newNames, ["SendMessage"]); + + // Known tools (Read, Bash) byte-identical to the fixture's prior entries. + assert.deepEqual(ctx2.body.tools[0], fixture.prior.tools[0]); + assert.deepEqual(ctx2.body.tools[1], fixture.prior.tools[1]); + // New tool present — but NOT marked, and this is the uncomfortable part. + // + // This fixture is the real 12:47:56 event that motivated the whole + // extension (threat-matrix rows 6 and 13, the 175k and 766k busts), and + // its model is `claude-sonnet-4-6`. The mid-conversation-tool-changes + // contract is not supported there — a sonnet-5 request carrying it + // returned `400 tool_addition/tool_removal is not supported on this + // model` on 2026-07-28 — so the announcement path is gated off for this + // model family and the new tool is forwarded plainly. + // + // Which means the mitigation does NOT apply to the traffic it was + // designed for. Recorded in the matrix rather than papered over here: + // holding tools[] stable and pinning ORDER still work on every model + // (they need no beta), but ADDITIONS on sonnet remain an honest bust. + const sendMsgTool = ctx2.body.tools.find((t) => t.name === "SendMessage"); + assert.ok(sendMsgTool, "the new tool is still forwarded — degrade, never drop"); + assert.ok( + !("defer_loading" in sendMsgTool), + "defer_loading belongs to a contract this model rejects with a 400", + ); + // tools[] count did not shrink or reorder the known prefix. + assert.equal(ctx2.body.tools.length, 3); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ============================================================================= +// SYNTHETIC FIXTURE (ledger SHAPE, 2026-07-27 15:36 — tools:REMOVE + reorder, +// threat-matrix row 13) +// ============================================================================= + +test("fixture toolgc-1536.json: CronCreate removed + DeferredToolPlaceholder reordered -> held in place, first-seen order pinned", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-gc-fixture" }; + try { + const raw = await readFile(GC_FIXTURE_PATH, "utf-8"); + const fixture = JSON.parse(raw); + + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const ctx1 = await runExt(structuredClone(fixture.prior), { headers, dir }); + assert.equal(ctx1.meta.deferredToolRewriteStats.action, "no-baseline"); + + const ctx2 = await runExt(structuredClone(fixture.incoming), { headers, dir }); + assert.equal(ctx2.meta.deferredToolRewriteStats.action, "rewrite"); + assert.deepEqual(ctx2.meta.deferredToolRewriteStats.heldNames, ["CronCreate"]); + assert.deepEqual(ctx2.meta.deferredToolRewriteStats.newNames, []); + + // Output order is first-seen order from the baseline request, not the + // incoming (reordered, CronCreate-missing) array's order. + assert.deepEqual( + ctx2.body.tools.map((t) => t.name), + ["Read", "Bash", "CronCreate", "DeferredToolPlaceholder"], + ); + // Held tool is byte-identical to its baseline form. + assert.deepEqual( + ctx2.body.tools.find((t) => t.name === "CronCreate"), + fixture.prior.tools.find((t) => t.name === "CronCreate"), + ); + // No addition -> no tool_addition block, no beta header. + assert.equal(ctx2.body.system.length, 1); + assert.equal(headers["anthropic-beta"], undefined); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ============================================================================= +// SESSION KEY RESOLUTION +// ============================================================================= + +// Volatile session URL inside a tool DESCRIPTION. CC embeds the per-session +// console URL in Bash's description (it is the commit trailer the model is +// told to write) and does not embed it consistently: measured over 652 +// same-key request pairs, it flipped twice. tools[] renders BEFORE system and +// messages, so no breakpoint survives a tools[] byte change — one such flip +// cost 705k creation tokens. Nothing about what Bash DOES changes across it. +test("toolFingerprint: the per-session console URL does not count as a schema change", () => { + const withUrl = { + name: "Bash", + description: + "Run a command.\n\nCo-Authored-By: X\nClaude-Session: https://claude.ai/code/session_01ABC\n" + + "- End PR bodies with:\n\nhttps://claude.ai/code/session_01ABC", + input_schema: { type: "object" }, + }; + const without = { + name: "Bash", + description: "Run a command.\n\nCo-Authored-By: X\n- End PR bodies with:", + input_schema: { type: "object" }, + }; + assert.equal(toolFingerprint(withUrl), toolFingerprint(without)); +}); + +// The narrowness is the safety property: serving a stale schema for a tool +// whose contract actually changed is the one failure this extension must +// never produce. +test("toolFingerprint: a genuine description change IS still a schema change", () => { + const a = { name: "Bash", description: "Run a command.", input_schema: { type: "object" } }; + const b = { name: "Bash", description: "Run a DIFFERENT command.", input_schema: { type: "object" } }; + assert.notEqual(toolFingerprint(a), toolFingerprint(b)); + // input_schema changes too, obviously. + const c = { name: "Bash", description: "Run a command.", input_schema: { type: "object", required: ["x"] } }; + assert.notEqual(toolFingerprint(a), toolFingerprint(c)); +}); + +test("resolveToolRewriteSessionKey: prefers session-id header, falls back to model string", () => { + // Header path is sub-keyed by system-prompt hash (threat-matrix row 14) — + // "nosys" when the body carries no system prompt. + const withHeader = resolveToolRewriteSessionKey({ "x-claude-code-session-id": "abc-123" }, { model: "x" }); + assert.equal(withHeader, "s-abc-123-nosys-empty"); + const withoutHeader = resolveToolRewriteSessionKey(null, { model: "claude-sonnet-4-6" }); + assert.equal(withoutHeader, "c-claude-sonnet-4-6-empty"); +}); + +// Regression guard for the row-14 collision this extension shipped with: +// one session-id header, several tenants (main thread, subagents, CC's own +// sidecar calls), each with a DIFFERENT system prompt and a different tools +// array. Keyed on the bare session id they shared one baseline, so every +// alternation classified as "schema changed" and re-baselined — measured on +// real traffic as tools[] churn RISING when the extension was enabled. +test("resolveToolRewriteSessionKey: sidecars sharing a session-id get distinct keys", () => { + const headers = { "x-claude-code-session-id": "abc-123" }; + const main = resolveToolRewriteSessionKey(headers, { + system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI." }], + }); + const sidecar = resolveToolRewriteSessionKey(headers, { + system: [{ type: "text", text: "You are a Claude agent, built on Anthropic's API." }], + }); + assert.notEqual(main, sidecar); + // Same system prompt → same bucket, so the main thread stays on one baseline. + const mainAgain = resolveToolRewriteSessionKey(headers, { + system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI." }], + }); + assert.equal(main, mainAgain); +}); + +// --- Model gate (the 400 that killed a live dispatch) --- +// +// 2026-07-28: a sonnet-5 subagent dispatch died with +// `API Error: 400 tool_addition/tool_removal is not supported on this model`. +// The contract is a documented beta but support is per-MODEL, and this +// extension applied it to whatever came through. A cache mitigation that can +// HARD-FAIL a request is worse than no mitigation, so the gate is opt-IN: +// unknown models degrade to forwarding the new tool normally. + +test("supportsToolAddition: opt-IN, so an unknown model is OFF", () => { + assert.equal(supportsToolAddition("claude-opus-5"), true); + assert.equal(supportsToolAddition("claude-opus-5-20260101"), true, "date-suffixed ids must match by prefix"); + // Wire evidence 2026-07-29 (probe session c05a754c: block forwarded + // byte-identically, API streamed 200). + assert.equal(supportsToolAddition("claude-fable-5"), true); + // The measured failure. + assert.equal(supportsToolAddition("claude-sonnet-5"), false); + // Everything unknown is off — a new model must not be able to break a + // request just by existing. + assert.equal(supportsToolAddition("claude-haiku-4-5"), false); + assert.equal(supportsToolAddition("some-future-model"), false); + assert.equal(supportsToolAddition(undefined), false); + assert.equal(supportsToolAddition(null), false); +}); + +test("supportsToolAddition: EXTRA override admits a candidate for the live probe, per call", () => { + // The override serves the throwaway acceptance-probe proxy only (it is how + // fable-5 earned its baseline entry on 2026-07-29); it must be read per + // call (a long-lived process picks up the change without a module reload) + // and must not disturb the baseline list. + const prev = process.env.CACHE_FIX_TOOL_ADDITION_EXTRA; + try { + process.env.CACHE_FIX_TOOL_ADDITION_EXTRA = "claude-candidate-x, claude-candidate-y"; + assert.equal(supportsToolAddition("claude-candidate-x"), true); + assert.equal(supportsToolAddition("claude-candidate-y-20260101"), true); + assert.equal(supportsToolAddition("claude-sonnet-5"), false, "override must not widen beyond its prefixes"); + delete process.env.CACHE_FIX_TOOL_ADDITION_EXTRA; + assert.equal(supportsToolAddition("claude-candidate-x"), false, "cleared override must clear per call"); + } finally { + if (prev === undefined) delete process.env.CACHE_FIX_TOOL_ADDITION_EXTRA; + else process.env.CACHE_FIX_TOOL_ADDITION_EXTRA = prev; + } +}); + +test("BITE — an unsupported model gets NO tool_addition, no beta header, tools passed through", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-sonnet" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const u1 = { role: "user", content: [{ type: "text", text: "turn 1" }] }; + const base = { system: [], messages: [u1], model: "claude-sonnet-5" }; + await runExt({ ...base, tools: [tool("Read")] }, { headers, dir }); + const ctx = await runExt( + { ...base, tools: [tool("Read"), tool("SendMessage")] }, + { headers, dir }, + ); + // No injected system message anywhere in messages[]. + const injected = (ctx.body.messages || []).filter( + (m) => m.role === "system" && Array.isArray(m.content) && m.content.some((b) => b.type === "tool_addition"), + ); + assert.equal(injected.length, 0, "no tool_addition may reach a model that 400s on it"); + // No beta token. + const beta = Object.entries(ctx.headers || {}).find(([k]) => k.toLowerCase() === "anthropic-beta"); + assert.ok( + !beta || !String(beta[1]).includes("mid-conversation-tool-changes"), + "beta token must not be sent to an unsupported model", + ); + // And no defer_loading marker smuggled onto the new tool. + const sm = (ctx.body.tools || []).find((t) => t.name === "SendMessage"); + assert.ok(sm, "the new tool is still forwarded — degrade, do not drop"); + assert.ok(!("defer_loading" in sm), "defer_loading belongs to the contract the model rejects"); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a suppressed announcement is LOUD: stderr once per model, telemetry every time", async () => { + // The silent version of this path is the failure mode: a new model family + // (documented rule is "Opus onward", so it likely supports the beta) pays + // a full-prefix bust per tool load with nothing anywhere saying so, until + // someone probes it by accident. The warning names the probe; telemetry + // records every occurrence for counting. + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-new-family" }; + const warnings = []; + const origWrite = process.stderr.write; + process.stderr.write = (s, ...rest) => { + if (String(s).includes("not allowlisted for tool_addition")) { + warnings.push(String(s)); + return true; + } + return origWrite.call(process.stderr, s, ...rest); + }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const u1 = { role: "user", content: [{ type: "text", text: "turn 1" }] }; + const base = { system: [], messages: [u1], model: "claude-new-family-7" }; + await runExt({ ...base, tools: [tool("Read")] }, { headers, dir }); + await runExt({ ...base, tools: [tool("Read"), tool("SendMessage")] }, { headers, dir }); + assert.equal(warnings.length, 1, "the first suppression must warn"); + assert.match(warnings[0], /claude-new-family-7/); + assert.match(warnings[0], /probe/i, "the warning must name the way out"); + // A second suppressed load on the same model: telemetry yes, stderr no. + await runExt( + { ...base, tools: [tool("Read"), tool("SendMessage"), tool("Monitor")] }, + { headers, dir }, + ); + assert.equal(warnings.length, 1, "once per model per process"); + const { readdir: rd, readFile: rf } = await import("node:fs/promises"); + const snapDir = join(dir, "cache-fix-snapshots"); + const evFile = (await rd(snapDir)).find((f) => f.endsWith("-deferred-tool-events.jsonl")); + assert.ok(evFile, "telemetry file must exist"); + const events = (await rf(join(snapDir, evFile), "utf-8")).trim().split("\n").map(JSON.parse); + const sup = events.filter((e) => e.suppressed); + assert.equal(sup.length, 2, "every suppressed occurrence is recorded"); + assert.equal(sup[0].model, "claude-new-family-7"); + assert.equal(sup[0].injected, 0); + }); + } finally { + process.stderr.write = origWrite; + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a SUPPORTED model still gets the announcement (the gate is not a kill switch)", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-opus" }; + try { + await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => { + const u1 = { role: "user", content: [{ type: "text", text: "turn 1" }] }; + const base = { system: [], messages: [u1], model: "claude-opus-5" }; + await runExt({ ...base, tools: [tool("Read")] }, { headers, dir }); + const ctx = await runExt( + { ...base, tools: [tool("Read"), tool("SendMessage")] }, + { headers, dir }, + ); + const injected = (ctx.body.messages || []).filter( + (m) => m.role === "system" && Array.isArray(m.content) && m.content.some((b) => b.type === "tool_addition"), + ); + assert.equal(injected.length, 1, "opus must keep the mitigation"); + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/fixtures/insertion-1405.json b/test/fixtures/insertion-1405.json new file mode 100644 index 00000000..753dc300 --- /dev/null +++ b/test/fixtures/insertion-1405.json @@ -0,0 +1,33 @@ +{ + "_comment": "Synthetic minimal repro of the 2026-07-27 14:05 splice shape (docs/directives/proxy-insertion-normalization.md). NOT real session content — built by hand for insertion-normalization.test.mjs. `priorMessages` is the 12-entry canonical history from the prior request (arrival order). `incomingMessages` is the NEXT request's messages[] as Claude Code actually sends it: two new user-role entries (a queued operator message and a task-reminder-shaped system-reminder wrapper) spliced in BETWEEN prior canonical index 9 and 10, instead of appended after index 11 where they causally arrived. Expected: classifyInsertion(incomingMessages, canonicalFromPrior) -> action 'normalized', re-serialized to priorMessages (0..11) followed by the two new entries in their incoming relative order (queued-message, then task-reminder).", + "priorMessages": [ + { "role": "user", "content": [{ "type": "text", "text": "turn0-user: investigate the widget cache" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn1-assistant: looking into it" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn2-user: check the config file" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn3-assistant: config looks fine" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn4-user: run the test suite" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn5-assistant: tests pass" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn6-user: add a regression guard" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn7-assistant: guard added" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn8-user: commit the change" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn9-assistant: committed" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn10-user: what's next on the roadmap" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn11-assistant: next is the deploy step" }] } + ], + "incomingMessages": [ + { "role": "user", "content": [{ "type": "text", "text": "turn0-user: investigate the widget cache" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn1-assistant: looking into it" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn2-user: check the config file" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn3-assistant: config looks fine" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn4-user: run the test suite" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn5-assistant: tests pass" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn6-user: add a regression guard" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn7-assistant: guard added" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn8-user: commit the change" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn9-assistant: committed" }] }, + { "role": "user", "content": [{ "type": "text", "text": "queued-message: operator says pause before deploy" }] }, + { "role": "user", "content": [{ "type": "text", "text": "\nThe task tools haven't been used recently.\n" }] }, + { "role": "user", "content": [{ "type": "text", "text": "turn10-user: what's next on the roadmap" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "turn11-assistant: next is the deploy step" }] } + ] +} diff --git a/test/fixtures/toolgc-1536.json b/test/fixtures/toolgc-1536.json new file mode 100644 index 00000000..bfc24eb2 --- /dev/null +++ b/test/fixtures/toolgc-1536.json @@ -0,0 +1,30 @@ +{ + "_comment": "Synthetic fixture, minimal, built from the ledger SHAPE only (measured 2026-07-27 15:36, ledger row tools:REMOVE, CronCreate removed + DeferredToolPlaceholder reordered, no ToolSearch nearby; skills-update system events in-window) per docs/directives/robustness-threat-matrix.md row 13. Session mirrors were NOT read to build this — only the shape named in the matrix row (a known tool disappearing from tools[] plus an unrelated reorder, both mid-conversation, with no addition).", + "prior": { + "model": "claude-sonnet-4-6", + "system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }], + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "schedule a cron job" }] } + ], + "tools": [ + { "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } }, + { "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } }, + { "name": "CronCreate", "input_schema": { "type": "object", "properties": { "schedule": { "type": "string" } } } }, + { "name": "DeferredToolPlaceholder", "input_schema": { "type": "object", "properties": {} } } + ] + }, + "incoming": { + "model": "claude-sonnet-4-6", + "system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }], + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "schedule a cron job" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "Scheduled." }] }, + { "role": "user", "content": [{ "type": "text", "text": "thanks, what else can you do" }] } + ], + "tools": [ + { "name": "DeferredToolPlaceholder", "input_schema": { "type": "object", "properties": {} } }, + { "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } }, + { "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } } + ] + } +} diff --git a/test/fixtures/toolload-1247.json b/test/fixtures/toolload-1247.json new file mode 100644 index 00000000..fbdcf58a --- /dev/null +++ b/test/fixtures/toolload-1247.json @@ -0,0 +1,35 @@ +{ + "_comment": "Synthetic fixture, minimal, built from the ledger SHAPE only (measured 2026-07-27 12:47:56, ledger row tools[SendMessage:added], toolsMatch:false) per docs/directives/proxy-deferred-tool-rewrite.md Phase A. Session mirrors were NOT read to build this — only the shape named in the directive (a tools[] array gaining exactly one new entry, SendMessage, mid-conversation).", + "prior": { + "model": "claude-sonnet-4-6", + "system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }], + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "start a background agent" }] } + ], + "tools": [ + { "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } }, + { "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } } + ] + }, + "incoming": { + "model": "claude-sonnet-4-6", + "system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }], + "messages": [ + { "role": "user", "content": [{ "type": "text", "text": "start a background agent" }] }, + { "role": "assistant", "content": [{ "type": "text", "text": "Starting a background agent now." }] }, + { "role": "user", "content": [{ "type": "text", "text": "ok, keep going" }] } + ], + "tools": [ + { "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } }, + { "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } }, + { + "name": "SendMessage", + "description": "Send a message to a teammate agent.", + "input_schema": { + "type": "object", + "properties": { "to": { "type": "string" }, "message": { "type": "string" } } + } + } + ] + } +} diff --git a/test/insertion-normalization.test.mjs b/test/insertion-normalization.test.mjs new file mode 100644 index 00000000..65bdf259 --- /dev/null +++ b/test/insertion-normalization.test.mjs @@ -0,0 +1,995 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import ext, { + computeIdentities, + classifyInsertion, + classifyPinned, + isVolatileBlock, + validateToolAdjacency, + resolveInsertionSessionKey, +} from "../proxy/extensions/insertion-normalization.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --- Helpers --- + +function userMsg(text) { + return { role: "user", content: [{ type: "text", text }] }; +} +function assistantMsg(text) { + return { role: "assistant", content: [{ type: "text", text }] }; +} +function toolUseMsg(id, name = "Bash") { + return { role: "assistant", content: [{ type: "tool_use", id, name, input: {} }] }; +} +function toolResultMsg(toolUseId, text = "result") { + return { role: "user", content: [{ type: "tool_result", tool_use_id: toolUseId, content: text }] }; +} + +function conv(n, seed = "c") { + const out = []; + for (let i = 0; i < n; i++) { + out.push(i % 2 === 0 ? userMsg(`${seed}-u${i}`) : assistantMsg(`${seed}-a${i}`)); + } + return out; +} + +async function newTmp() { + return mkdtemp(join(tmpdir(), "insertion-norm-test-")); +} + +function withEnv(overrides, fn) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return fn(); + } finally { + for (const k of Object.keys(saved)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +async function withEnvAsync(overrides, fn) { + const saved = {}; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + if (overrides[k] === undefined) delete process.env[k]; + else process.env[k] = overrides[k]; + } + try { + return await fn(); + } finally { + for (const k of Object.keys(saved)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +async function silenced(fn) { + const orig = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + try { + return await fn(); + } finally { + process.stderr.write = orig; + } +} + +async function runExt(body, { headers, dir } = {}) { + const savedHome = process.env.CLAUDE_CONFIG_DIR; + if (dir) process.env.CLAUDE_CONFIG_DIR = dir; + try { + const ctx = { body, meta: {}, headers: headers || {} }; + await ext.onRequest(ctx); + return ctx; + } finally { + if (dir) { + if (savedHome === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = savedHome; + } + } +} + +// ===================================================================== +// Pure classifier tests +// ===================================================================== + +test("pure append: canonical is a strict prefix, no splice -> action append-only, messages unchanged", () => { + const prior = conv(10, "append"); + const priorCanon = computeIdentities(prior).map((e) => ({ h: e.h, r: e.r, o: e.o })); + const incoming = prior.concat(conv(2, "append-tail")); + + const result = classifyInsertion(incoming, priorCanon); + assert.equal(result.action, "append-only"); + assert.deepEqual(result.messages, incoming); + assert.equal(result.inserted, 2); +}); + +test("single user-role mid-insertion: normalized to tail, cache-relevant prefix byte-identical to canonical", () => { + const prior = conv(10, "mid"); + const priorCanon = computeIdentities(prior).map((e) => ({ h: e.h, r: e.r, o: e.o })); + + // Splice one new user message between prior[4] and prior[5]. + const incoming = prior.slice(0, 5).concat([userMsg("mid-inserted")], prior.slice(5)); + + const result = classifyInsertion(incoming, priorCanon); + assert.equal(result.action, "normalized"); + assert.equal(result.inserted, 1); + // Cache-relevant prefix: canonical order first, byte-identical to `prior`. + assert.deepEqual(result.messages.slice(0, prior.length), prior); + // New entry appended at the tail. + assert.deepEqual(result.messages[prior.length], userMsg("mid-inserted")); +}); + +test("multiple insertions keep relative order", () => { + const prior = conv(10, "multi"); + const priorCanon = computeIdentities(prior).map((e) => ({ h: e.h, r: e.r, o: e.o })); + + const insertA = userMsg("insert-A"); + const insertB = userMsg("insert-B"); + // Both spliced between prior[3] and prior[4], in order A then B. + const incoming = prior.slice(0, 4).concat([insertA, insertB], prior.slice(4)); + + const result = classifyInsertion(incoming, priorCanon); + assert.equal(result.action, "normalized"); + assert.equal(result.inserted, 2); + assert.deepEqual(result.messages.slice(0, prior.length), prior); + assert.deepEqual(result.messages[prior.length], insertA); + assert.deepEqual(result.messages[prior.length + 1], insertB); +}); + +test("assistant-role insertion -> reset (never reorders an assistant message)", () => { + const prior = conv(10, "asst"); + const priorCanon = computeIdentities(prior).map((e) => ({ h: e.h, r: e.r, o: e.o })); + + const incoming = prior.slice(0, 4).concat([assistantMsg("unexpected-assistant-insert")], prior.slice(4)); + + const result = classifyInsertion(incoming, priorCanon); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "assistant-interleaved"); +}); + +test("shrunk history (fewer messages than canonical) -> reset", () => { + const prior = conv(10, "shrink"); + const priorCanon = computeIdentities(prior).map((e) => ({ h: e.h, r: e.r, o: e.o })); + + const incoming = prior.slice(0, 6); // fewer than canonical's 10 entries + const result = classifyInsertion(incoming, priorCanon); + assert.equal(result.action, "reset"); + // A shorter array cannot contain every canonical identity, so it fails + // the subsequence match. + assert.equal(result.resetReason, "not-subsequence"); +}); + +test("tool_result adjacency violation -> reset even when insertion would otherwise qualify", () => { + // Canonical ends on a plain user turn (u3) that is NOT part of the + // tool_use/tool_result pair — this is what makes the inserted entries + // count as a genuine mid-canonical splice (index <= lastMatched) rather + // than ordinary tail growth, so the splice path (and its adjacency + // check) actually runs. + const tu = toolUseMsg("tu-1"); + const trOrig = toolResultMsg("tu-1", "orig-result"); + const u3 = userMsg("u3-final-canonical"); + const prior = [userMsg("p0"), tu, trOrig, u3]; + const priorCanon = computeIdentities(prior).map((e) => ({ h: e.h, r: e.r, o: e.o })); + + // Two new user-role entries spliced between trOrig and u3: an unrelated + // message, then a DIFFERENT tool_result for the same tu-1 id (content + // differs from trOrig, so it doesn't match that canonical identity and + // is treated as new). Re-serializing (canonical order + new entries + // appended) would place the unrelated message directly before this new + // tool_result, separating it from its tool_use — must reset instead. + const otherNew = userMsg("unrelated queued message"); + const trDiffering = toolResultMsg("tu-1", "different-late-result"); + const incoming = [userMsg("p0"), tu, trOrig, otherNew, trDiffering, u3]; + + const result = classifyInsertion(incoming, priorCanon); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "adjacency-violation"); +}); + +test("validateToolAdjacency: true for well-formed tool_use/tool_result pairing", () => { + const tu = toolUseMsg("tu-2"); + const tr = toolResultMsg("tu-2"); + assert.equal(validateToolAdjacency([userMsg("a"), tu, tr]), true); +}); + +test("validateToolAdjacency: false when tool_result's preceding message isn't the matching tool_use", () => { + const tu = toolUseMsg("tu-3"); + const tr = toolResultMsg("tu-3"); + assert.equal(validateToolAdjacency([userMsg("a"), tu, userMsg("intervening"), tr]), false); +}); + +test("duplicate identical user messages disambiguated by occurrence counter", () => { + const dup = userMsg("same text every time"); + const prior = [userMsg("p0"), dup, userMsg("p2"), dup]; + const identities = computeIdentities(prior); + // Both `dup` entries share the same hash+role but must get distinct + // occurrence indices (0 and 1). + const dupEntries = identities.filter((e) => e.h === identities[1].h && e.r === "user"); + assert.deepEqual( + dupEntries.map((e) => e.o).sort(), + [0, 1], + ); +}); + +test("no prior canonical -> reset with reason no-prior-canonical (first request in a session)", () => { + const incoming = conv(4, "first"); + const result = classifyInsertion(incoming, null); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "no-prior-canonical"); + assert.equal(result.canonicalEntries.length, 4); +}); + +// ===================================================================== +// Extension-level tests (env gate, persistence, telemetry) +// ===================================================================== + +test("gate off: CACHE_FIX_INSERTION_NORMALIZE unset -> passthrough byte-identical, no telemetry file written", async () => { + const dir = await newTmp(); + try { + const messages = conv(6, "gate-off"); + const body = { model: "claude-opus-4-7", messages }; + const before = JSON.stringify(body); + + let ctx; + await withEnvAsync({ CACHE_FIX_INSERTION_NORMALIZE: undefined }, async () => { + ctx = await runExt(body, { dir }); + }); + + assert.equal(JSON.stringify(body), before, "body must be untouched when gate is off"); + assert.equal(ctx.meta.insertionNormalizeStats, undefined, "no telemetry when gate is off"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("canonical persistence round-trip: write, reload, continue (append-only across two requests)", async () => { + const dir = await newTmp(); + try { + const headers = { "x-claude-code-session-id": "sess-roundtrip" }; + const messages1 = conv(6, "rt"); + const body1 = { model: "claude-opus-4-7", messages: messages1 }; + + let ctx1; + await silenced(() => + withEnvAsync({ CACHE_FIX_INSERTION_NORMALIZE: "1" }, async () => { + ctx1 = await runExt(body1, { headers, dir }); + }), + ); + assert.equal(ctx1.meta.insertionNormalizeStats.action, "reset"); + assert.equal(ctx1.meta.insertionNormalizeStats.resetReason, "no-prior-canonical"); + + // Second request: pure append of 2 more messages. Canonical was + // persisted by request 1 — this call must reload it from disk (fresh + // ctx, same extension module) rather than relying on in-memory state. + const messages2 = messages1.concat(conv(2, "rt-tail")); + const body2 = { model: "claude-opus-4-7", messages: messages2 }; + let ctx2; + await silenced(() => + withEnvAsync({ CACHE_FIX_INSERTION_NORMALIZE: "1" }, async () => { + ctx2 = await runExt(body2, { headers, dir }); + }), + ); + assert.equal(ctx2.meta.insertionNormalizeStats.action, "append-only"); + assert.equal(ctx2.meta.insertionNormalizeStats.inserted, 2); + assert.equal(JSON.stringify(body2.messages), JSON.stringify(messages2)); + + // Third request: a real mid-history splice — must reload request 2's + // persisted canonical (8 entries) and correctly detect the splice. + // Insert BEFORE the last two canonical entries (not at the tail) so + // this is a genuine splice, not ordinary append growth. + const spliced = messages2 + .slice(0, 6) + .concat([userMsg("late-splice")], messages2.slice(6)); + const body3 = { model: "claude-opus-4-7", messages: spliced }; + let ctx3; + await silenced(() => + withEnvAsync({ CACHE_FIX_INSERTION_NORMALIZE: "1" }, async () => { + ctx3 = await runExt(body3, { headers, dir }); + }), + ); + assert.equal(ctx3.meta.insertionNormalizeStats.action, "normalized"); + assert.equal(ctx3.meta.insertionNormalizeStats.inserted, 1); + assert.deepEqual(body3.messages.slice(0, messages2.length), messages2); + assert.deepEqual(body3.messages[messages2.length], userMsg("late-splice")); + + // Telemetry file exists with 3 lines, one per action. No system prompt + // was set on any of the three bodies and all three share one msgs[0], so + // all three land in the same bucket. The key is DERIVED rather than + // spelled out: it carries a conversation sub-key now, and hardcoding the + // format made this test fail on a keying change that broke nothing. + const key = resolveInsertionSessionKey(headers, body3.messages, body3.system); + const telemetryFile = join(dir, "cache-fix-snapshots", `${key}-insertion-events.jsonl`); + const lines = (await readFile(telemetryFile, "utf-8")).trim().split("\n"); + assert.equal(lines.length, 3); + const actions = lines.map((l) => JSON.parse(l).action); + assert.deepEqual(actions, ["reset", "append-only", "normalized"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("session key resolution: session-id header takes precedence over content-hash fallback", () => { + const messages = conv(4, "key"); + const withHeader = resolveInsertionSessionKey({ "x-claude-code-session-id": "abc123" }, messages); + const withoutHeader = resolveInsertionSessionKey({}, messages); + assert.notEqual(withHeader, withoutHeader); + assert.ok(withHeader.startsWith("s-")); + assert.ok(withoutHeader.startsWith("c-")); +}); + +// ===================================================================== +// Sidecar sub-keying (threat-matrix row 14) +// ===================================================================== + +test("session key resolution: same session-id, different system prompt -> different sub-key", () => { + const messages = conv(4, "sidecar-key"); + const headers = { "x-claude-code-session-id": "shared-sid" }; + const mainKey = resolveInsertionSessionKey(headers, messages, [{ type: "text", text: "You are Claude Code" }]); + const sidecarKey = resolveInsertionSessionKey(headers, messages, [{ type: "text", text: "Generate a short title" }]); + assert.notEqual(mainKey, sidecarKey); + assert.ok(mainKey.startsWith("s-shared-sid-")); + assert.ok(sidecarKey.startsWith("s-shared-sid-")); +}); + +test("session key resolution: same session-id + same system prompt -> same sub-key (stable across calls)", () => { + const messages = conv(4, "stable-key"); + const headers = { "x-claude-code-session-id": "shared-sid-2" }; + const system = [{ type: "text", text: "You are Claude Code" }]; + const k1 = resolveInsertionSessionKey(headers, messages, system); + const k2 = resolveInsertionSessionKey(headers, messages, system); + assert.equal(k1, k2); +}); + +test("session key resolution: absent system prompt -> stable bucket, distinct from a present one", () => { + const messages = conv(4, "nosys-key"); + const headers = { "x-claude-code-session-id": "shared-sid-3" }; + const withSystem = resolveInsertionSessionKey(headers, messages, [{ type: "text", text: "sys" }]); + const withoutSystem = resolveInsertionSessionKey(headers, messages, undefined); + assert.notEqual(withSystem, withoutSystem); + assert.ok(withoutSystem.includes("-nosys-")); + // Stable across calls — the absent-system bucket is a bucket, not a nonce. + assert.equal(withoutSystem, resolveInsertionSessionKey(headers, messages, undefined)); +}); + +// Regression guard: the system-prompt sub-key separates sidecar CLASSES, not +// the individual conversations within one class. Every subagent of a session +// runs the same agent system prompt, so keyed on (sid, system) alone they all +// shared one canonical and overwrote each other. Measured on real traffic +// before the conversation sub-key: one system-prompt bucket held 39 distinct +// conversations, and 100% of conversation switches within a bucket reset +// (60/60) versus 1% of same-conversation continuations. +test("session key resolution: same session-id AND same system prompt, different conversations -> different keys", () => { + const headers = { "x-claude-code-session-id": "shared-sid-4" }; + const system = [{ type: "text", text: "You are a Claude agent." }]; + const a = resolveInsertionSessionKey(headers, conv(4, "agent-one"), system); + const b = resolveInsertionSessionKey(headers, conv(4, "agent-two"), system); + assert.notEqual(a, b); + // Same conversation continuing (more messages appended) keeps its key — + // otherwise every turn would look like a new conversation. + const grown = resolveInsertionSessionKey(headers, conv(9, "agent-one"), system); + assert.equal(a, grown); +}); + +// msgs[0] with STRING content must still yield a conversation identity: +// hashMessageContent covers block arrays only and returns null for strings, +// which collapsed every string-content conversation into one shared bucket +// (56 of 602 requests in the measured capture). +test("session key resolution: string-content msgs[0] gets a real conversation key, not a shared 'empty' bucket", () => { + const headers = { "x-claude-code-session-id": "shared-sid-5" }; + const system = [{ type: "text", text: "sys" }]; + const strA = resolveInsertionSessionKey(headers, [{ role: "user", content: "alpha" }], system); + const strB = resolveInsertionSessionKey(headers, [{ role: "user", content: "beta" }], system); + assert.notEqual(strA, strB); + assert.ok(!strA.endsWith("-empty")); + // A genuinely contentless first message is the only "empty". + const none = resolveInsertionSessionKey(headers, [{ role: "user" }], system); + assert.ok(none.endsWith("-empty")); +}); + +// Compaction. Verified against real traffic 2026-07-28 (session 58c979ce): +// across the boundary the session-id and the system-prompt sub-key are +// unchanged while the conversation sub-key flips 0dc13516 -> 554180f8 — +// +// n=780 1548 msgs conv 0dc13516c44f88c7 (summarization call) +// n=786 4 msgs conv 554180f85a9a1528 (continuation) +// +// so a compacted thread is a NEW conversation to every stateful extension: +// fresh canonical, no reset, and `dropped-majority` is NEVER the compaction +// path. That is correct — compaction replaces messages[0], so the prefix +// changed at index 0 and no cached bytes survive by construction. +// +// Pinned here because the alternative was believed before it was checked, +// and because the property is load-bearing in the other direction too: if a +// future keying change made the continuation share the pre-compaction key, +// the 1548-message canonical would be applied to a 4-message history. +test("session key resolution: a compacted continuation is a NEW conversation, not a continuation", () => { + const headers = { "x-claude-code-session-id": "shared-sid-compact" }; + const system = [{ type: "text", text: "You are Claude Code" }]; + + const before = resolveInsertionSessionKey(headers, conv(40, "long-thread"), system); + // What CC actually sends after compacting: a fresh short history whose + // first message is the summary, NOT the original opening message. + const compacted = [ + { role: "user", content: [{ type: "text", text: "This session is being continued... Summary: ..." }] }, + { role: "assistant", content: [{ type: "text", text: "ok" }] }, + ]; + const after = resolveInsertionSessionKey(headers, compacted, system); + + assert.notEqual(before, after, "the compacted thread must not inherit the pre-compaction canonical"); + // Only the conversation sub-key moves: same session, same system prompt. + assert.ok(before.startsWith("s-shared-sid-compact-")); + assert.ok(after.startsWith("s-shared-sid-compact-")); + assert.equal( + before.split("-").slice(0, -1).join("-"), + after.split("-").slice(0, -1).join("-"), + "session-id and system-prompt sub-key are unchanged across a compaction", + ); + // And the post-compaction thread is itself stable as it grows. + assert.equal(after, resolveInsertionSessionKey(headers, [...compacted, { role: "user", content: "next" }], system)); +}); + +test("two interleaved streams under one session-id (main thread + sidecar) keep independent canonicals, neither thrashes the other", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-interleave" }; + const mainSystem = [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }]; + const sidecarSystem = [{ type: "text", text: "Generate a concise 5-word title for this conversation." }]; + try { + await silenced(() => + withEnvAsync({ CACHE_FIX_INSERTION_NORMALIZE: "1" }, async () => { + // Main thread request 1: establishes canonical. + const mainMessages1 = conv(6, "main"); + const mainBody1 = { model: "claude-opus-4-7", system: mainSystem, messages: mainMessages1 }; + const mainCtx1 = await runExt(mainBody1, { headers, dir }); + assert.equal(mainCtx1.meta.insertionNormalizeStats.action, "reset"); + assert.equal(mainCtx1.meta.insertionNormalizeStats.resetReason, "no-prior-canonical"); + + // Sidecar request (title-gen), same session-id header, different + // system prompt, entirely unrelated single-turn messages. Before + // the fix this would have been compared against the main thread's + // canonical and thrashed it to reset. + const sidecarMessages = [userMsg("please title this conversation")]; + const sidecarBody = { model: "claude-haiku-4-5", system: sidecarSystem, messages: sidecarMessages }; + const sidecarCtx = await runExt(sidecarBody, { headers, dir }); + assert.equal(sidecarCtx.meta.insertionNormalizeStats.action, "reset"); + assert.equal(sidecarCtx.meta.insertionNormalizeStats.resetReason, "no-prior-canonical", "sidecar's own first-seen, not a thrash of the main thread's canonical"); + + // Main thread request 2: pure append of 2 more messages. Must + // reload MAIN'S OWN canonical (6 entries) from request 1 — not + // reset, and not polluted by the sidecar call in between. + const mainMessages2 = mainMessages1.concat(conv(2, "main-tail")); + const mainBody2 = { model: "claude-opus-4-7", system: mainSystem, messages: mainMessages2 }; + const mainCtx2 = await runExt(mainBody2, { headers, dir }); + assert.equal(mainCtx2.meta.insertionNormalizeStats.action, "append-only", "main thread's canonical survived the interleaved sidecar call"); + assert.equal(mainCtx2.meta.insertionNormalizeStats.inserted, 2); + + // A second sidecar call (another title-gen turn, same system + // prompt) similarly should not disturb, and should build its OWN + // append-only history rather than resetting every time. + const sidecarMessages2 = sidecarMessages.concat([assistantMsg("Title: proxy fixes")]); + const sidecarBody2 = { model: "claude-haiku-4-5", system: sidecarSystem, messages: sidecarMessages2 }; + const sidecarCtx2 = await runExt(sidecarBody2, { headers, dir }); + assert.equal(sidecarCtx2.meta.insertionNormalizeStats.action, "append-only", "sidecar's own canonical persisted across its own turns"); + }), + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("old-format (pre-sub-key) state file is ignored gracefully -> treated as no-prior-canonical, not a crash", async () => { + const dir = await newTmp(); + const headers = { "x-claude-code-session-id": "sess-oldformat" }; + const system = [{ type: "text", text: "You are Claude Code" }]; + try { + // Simulate a leftover pre-sub-key state file at the OLD path (no + // system sub-key suffix) — the new code never reads this path, so it + // must be silently abandoned rather than erroring. + const { mkdir: mkdirP, writeFile: writeFileP } = await import("node:fs/promises"); + const snapshotDir = join(dir, "cache-fix-snapshots"); + await mkdirP(snapshotDir, { recursive: true }); + await writeFileP( + join(snapshotDir, "s-sess-oldformat-insertion-canon.json"), + JSON.stringify({ entries: [{ h: "stale-hash", r: "user", o: 0 }] }), + ); + + await silenced(() => + withEnvAsync({ CACHE_FIX_INSERTION_NORMALIZE: "1" }, async () => { + const messages = conv(4, "oldformat"); + const body = { model: "claude-opus-4-7", system, messages }; + const ctx = await runExt(body, { headers, dir }); + // New sub-keyed path has no file yet -> ordinary first-seen reset, + // not a crash and not accidentally matching the stale entries. + assert.equal(ctx.meta.insertionNormalizeStats.action, "reset"); + assert.equal(ctx.meta.insertionNormalizeStats.resetReason, "no-prior-canonical"); + }), + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ===================================================================== +// Fixture-driven repro: the 2026-07-27 14:05 shape +// ===================================================================== + +test("fixture insertion-1405: normalization yields the arrival-order serialization", async () => { + const fixturePath = join(__dirname, "fixtures", "insertion-1405.json"); + const raw = await readFile(fixturePath, "utf-8"); + const fixture = JSON.parse(raw); + + const priorCanon = computeIdentities(fixture.priorMessages).map((e) => ({ h: e.h, r: e.r, o: e.o })); + const result = classifyInsertion(fixture.incomingMessages, priorCanon); + + assert.equal(result.action, "normalized"); + assert.equal(result.inserted, 2); + // Arrival-order serialization: prior canonical order first... + assert.deepEqual(result.messages.slice(0, fixture.priorMessages.length), fixture.priorMessages); + // ...then the two new entries in their incoming relative order. + assert.deepEqual(result.messages[fixture.priorMessages.length].content[0].text, "queued-message: operator says pause before deploy"); + assert.deepEqual( + result.messages[fixture.priorMessages.length + 1].content[0].text, + "\nThe task tools haven't been used recently.\n", + ); +}); + +// ===================================================================== +// String-content identity (regression, 2026-07-27) +// ===================================================================== +// +// hashMessageContent returns null unless `content` is a block ARRAY, and CC +// sends many messages whose content is a plain string. The fallback identity +// used to be `noContent:${i}` — the array INDEX — so such a message's identity +// WAS its position. The first insertion ahead of one shifted it, the canonical +// lookup missed, and the classifier reset with "not-subsequence": the +// extension broke on exactly the event it exists to absorb. Live measurement +// that day: 83 index-keyed entries in one sub-key, 125 resets over 350 +// requests. + +test("string-content message keeps its identity when an insertion shifts its index", () => { + const sys = (t) => ({ role: "system", content: t }); // string, not blocks + const prior = [ + userMsg("q1"), + { role: "assistant", content: [{ type: "text", text: "a1" }] }, + sys("sys-note"), + userMsg("q2"), + ]; + const priorCanon = computeIdentities(prior).map((e) => ({ h: e.h, r: e.r, o: e.o })); + + // Insert a mid-conversation system message BEFORE the string-content one, + // shifting its index from 2 to 3. + const incoming = [ + prior[0], + sys("MID-TURN NOTE"), + prior[1], + prior[2], + prior[3], + { role: "assistant", content: [{ type: "text", text: "a2" }] }, + ]; + + const result = classifyInsertion(incoming, priorCanon); + assert.notEqual(result.action, "reset", `must not reset: ${result.resetReason ?? ""}`); + assert.equal(result.action, "normalized"); +}); + +test("string-content identity is content-derived, not positional", () => { + const sys = (t) => ({ role: "system", content: t }); + const atTwo = computeIdentities([userMsg("a"), userMsg("b"), sys("same text")]); + const atThree = computeIdentities([userMsg("a"), userMsg("b"), userMsg("c"), sys("same text")]); + assert.equal( + atTwo[2].h, + atThree[3].h, + "identical string content must hash identically regardless of position", + ); + // Different content must still differ. + const other = computeIdentities([sys("different text")]); + assert.notEqual(atTwo[2].h, other[0].h); +}); + +test("a genuinely contentless message still falls back to the index", () => { + const ids = computeIdentities([userMsg("a"), { role: "system" }]); + assert.match(ids[1].h, /^noContent:1$/); +}); + +// ===================================================================== +// Phase 3: volatile-block pinning + removal tolerance (classifyPinned) +// Directive: docs/directives/proxy-volatile-block-pinning.md +// ===================================================================== + +const REMINDER = + "\nPreToolUse:Agent hook additional context: Dispatch starting\n"; + +function userWithReminder(text, reminder = REMINDER) { + return { + role: "user", + content: [ + { type: "text", text }, + { type: "text", text: reminder }, + ], + }; +} + +function pinCanon(messages) { + return classifyPinned(messages, null).canonicalEntries; +} + +test("pin: the attributed flip — reminder vanishing deep in history is absorbed, first-seen bytes forwarded", () => { + // Request N: message 2 carries the hook reminder. Request N+1: same + // message WITHOUT it (the measured 135k/182k shape). + const withBlock = [userMsg("u0"), assistantMsg("a1"), userWithReminder("do it"), assistantMsg("a3")]; + const canon = pinCanon(withBlock); + const flipped = [ + userMsg("u0"), + assistantMsg("a1"), + { role: "user", content: [{ type: "text", text: "do it" }, { type: "text", text: "" }] }, + assistantMsg("a3"), + userMsg("next"), + ]; + const result = classifyPinned(flipped, canon); + assert.equal(result.action, "normalized", "flip absorbed, not reset"); + assert.equal(result.pinned, 1); + assert.deepEqual( + result.messages[2].content, + [{ type: "text", text: "do it" }, { type: "text", text: REMINDER }], + "first-seen bytes forwarded — byte-stable history", + ); +}); + +test("pin: flip back (reminder REAPPEARING) also forwards first-seen — both directions stable", () => { + const without = [userMsg("u0"), assistantMsg("a1"), + { role: "user", content: [{ type: "text", text: "do it" }] }, assistantMsg("a3")]; + const canon = pinCanon(without); + const reappeared = [userMsg("u0"), assistantMsg("a1"), userWithReminder("do it"), assistantMsg("a3")]; + const result = classifyPinned(reappeared, canon); + assert.equal(result.action, "normalized"); + assert.equal(result.pinned, 1); + assert.deepEqual( + result.messages[2].content, + [{ type: "text", text: "do it" }], + "no stored first-seen form (first-seen had no volatile block) -> volatile blocks stripped", + ); +}); + +test("pin: phase-2 baseline still resets on the same flip (the behavior being fixed)", () => { + const withBlock = [userMsg("u0"), assistantMsg("a1"), userWithReminder("do it"), assistantMsg("a3")]; + const canon = computeIdentities(withBlock).map((e) => ({ h: e.h, r: e.r, o: e.o })); + const flipped = [userMsg("u0"), assistantMsg("a1"), + { role: "user", content: [{ type: "text", text: "do it" }] }, assistantMsg("a3")]; + const result = classifyInsertion(flipped, canon); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "not-subsequence"); +}); + +test("pin: a NON-volatile content change is NOT absorbed — reset, correctness over savings", () => { + const orig = [userMsg("u0"), assistantMsg("a1"), userMsg("original"), assistantMsg("a3")]; + const canon = pinCanon(orig); + const edited = [userMsg("u0"), assistantMsg("a1"), userMsg("EDITED"), assistantMsg("a3")]; + const result = classifyPinned(edited, canon); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "edit-shaped"); +}); + +// The edit test above and this one are a pair: both requests contain one drop +// and one splice, and only CO-LOCATION tells them apart. "Any drop + any +// splice = edit" was the shipped rule and it misfired on real traffic — an +// operator interrupt pruned the tail while a hook reminder migrated 24 indices +// away, which is a prune plus an insertion, not an edit. That false positive +// was the last remaining real reset in the measured corpora. +test("pin: an UNRELATED drop and splice in one request is not an edit — no reset", () => { + const orig = [ + userMsg("u0"), + assistantMsg("a1"), + userMsg("u2"), + assistantMsg("a3"), + userMsg("u4"), + assistantMsg("a5"), + userMsg("tail-to-be-pruned"), + ]; + const canon = pinCanon(orig); + // Splice near the FRONT, prune at the TAIL — far apart, so neither can be a + // replacement for the other. + const next = [ + userMsg("u0"), + assistantMsg("a1"), + userMsg("SPLICED"), + userMsg("u2"), + assistantMsg("a3"), + userMsg("u4"), + assistantMsg("a5"), + ]; + const result = classifyPinned(next, canon); + assert.notEqual(result.action, "reset"); + assert.equal(result.dropped, 1); + assert.equal(result.inserted, 1); + // CC's order is preserved — the splice is not moved to the tail. + assert.deepEqual( + result.messages.map((m) => m.content[0].text), + ["u0", "a1", "SPLICED", "u2", "a3", "u4", "a5"], + ); +}); + +// The other side of the discriminator: a splice landing in the gap left by a +// dropped entry IS an edit and must still reset, even with drop-tolerance on. +test("pin: a splice inside the dropped entry's gap IS an edit — reset", () => { + const orig = [userMsg("u0"), assistantMsg("a1"), userMsg("original"), assistantMsg("a3")]; + const canon = pinCanon(orig); + const edited = [userMsg("u0"), assistantMsg("a1"), userMsg("REPLACEMENT"), assistantMsg("a3")]; + const result = classifyPinned(edited, canon); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "edit-shaped"); +}); + +test("pin: prune (context-management removal) — match survives, entries flagged dropped, no reset", () => { + const full = conv(10, "prune"); + const canon = pinCanon(full); + // Remove messages 2 and 3 (an old exchange), keep the rest, grow the tail. + const pruned = [...full.slice(0, 2), ...full.slice(4), userMsg("new tail")]; + const result = classifyPinned(pruned, canon); + assert.notEqual(result.action, "reset", "prune must not reset canonical"); + assert.equal(result.dropped, 2); + const droppedEntries = result.canonicalEntries.filter((e) => e.d); + assert.equal(droppedEntries.length, 2, "dropped entries kept in the file, flagged"); +}); + +test("pin: phase-2 baseline resets on the same prune (the behavior being fixed)", () => { + const full = conv(10, "prune2"); + const canon = computeIdentities(full).map((e) => ({ h: e.h, r: e.r, o: e.o })); + const pruned = [...full.slice(0, 2), ...full.slice(4)]; + const result = classifyInsertion(pruned, canon); + assert.equal(result.action, "reset"); +}); + +test("pin: dropping the majority resets — a compaction is not a prune", () => { + const full = conv(10, "compact"); + const canon = pinCanon(full); + const compacted = [full[0], userMsg("summary of the rest")]; + const result = classifyPinned(compacted, canon); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "dropped-majority"); +}); + +// THE positional-canonical regression guard. A mid-history splice must leave +// the canonical in WIRE order, so the next request is a plain append. Filing +// the new entry at the tail of the canonical instead (arrival order) made +// canonical and wire order disagree permanently: request 3 then failed the +// strictly-increasing check with not-subsequence. That was the mechanism +// behind every remaining real reset measured on live captures 2026-07-28. +test("pin: a mid-history splice stays in place — the NEXT request is append-only, not a reset", () => { + const r1 = [userMsg("u0"), assistantMsg("a1"), userMsg("u2"), assistantMsg("a3")]; + let canon = pinCanon(r1); + + // CC splices INJECTED between a1 and u2. + const r2 = [userMsg("u0"), assistantMsg("a1"), userMsg("INJECTED"), userMsg("u2"), assistantMsg("a3")]; + const res2 = classifyPinned(r2, canon); + assert.equal(res2.action, "normalized"); + assert.equal(res2.inserted, 1); + // Forwarded order is CC's order — the spliced entry is NOT moved to the tail. + assert.deepEqual( + res2.messages.map((m) => m.content[0].text), + ["u0", "a1", "INJECTED", "u2", "a3"], + ); + canon = res2.canonicalEntries; + + // CC keeps appending; the spliced entry stays where it was. + const r3 = [...r2, assistantMsg("a4"), userMsg("u5")]; + const res3 = classifyPinned(r3, canon); + assert.equal(res3.action, "append-only", "a settled splice must not re-classify"); + assert.deepEqual( + res3.messages.map((m) => m.content[0].text), + ["u0", "a1", "INJECTED", "u2", "a3", "a4", "u5"], + ); +}); + +test("pin: flip + prune combined in one request — both handled", () => { + const msgs = [userMsg("u0"), assistantMsg("a1"), userWithReminder("deep"), + assistantMsg("a3"), userMsg("u4"), assistantMsg("a5")]; + const canon = pinCanon(msgs); + // Prune u4/a5, flip the reminder off, grow tail. + const next = [userMsg("u0"), assistantMsg("a1"), + { role: "user", content: [{ type: "text", text: "deep" }] }, + assistantMsg("a3"), userMsg("tail")]; + const result = classifyPinned(next, canon); + assert.equal(result.action, "normalized"); + assert.equal(result.pinned, 1); + assert.equal(result.dropped, 2); + assert.deepEqual(result.messages[2].content[1], { type: "text", text: REMINDER }); +}); + +test("pin: a message carrying cache_control is never rewritten", () => { + const marked = { + role: "user", + content: [ + { type: "text", text: "tail msg", cache_control: { type: "ephemeral" } }, + { type: "text", text: REMINDER }, + ], + }; + const msgs = [userMsg("u0"), assistantMsg("a1"), marked]; + const canon = pinCanon(msgs); + const flipped = [userMsg("u0"), assistantMsg("a1"), + { role: "user", content: [{ type: "text", text: "tail msg", cache_control: { type: "ephemeral" } }] }, + userMsg("new")]; + const result = classifyPinned(flipped, canon); + assert.notEqual(result.action, "reset"); + assert.deepEqual( + result.messages[2].content, + [{ type: "text", text: "tail msg", cache_control: { type: "ephemeral" } }], + "marker-carrying message forwarded as-is", + ); +}); + +test("pin: assistant messages keep phase-2 identity — an assistant content change still resets", () => { + const msgs = [userMsg("u0"), assistantMsg("original"), userMsg("u2")]; + const canon = pinCanon(msgs); + const changed = [userMsg("u0"), assistantMsg("CHANGED"), userMsg("u2")]; + const result = classifyPinned(changed, canon); + assert.equal(result.action, "reset"); +}); + +test("pin: tool_result blocks are never volatile even when reminder-shaped", () => { + const tr = { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: REMINDER }], + }; + assert.equal(isVolatileBlock(tr.content[0]), false); +}); + +test("pin: adjacency invariant enforced across pinned forwarding", () => { + const msgs = [toolUseMsg("t1"), toolResultMsg("t1"), userMsg("u2")]; + const canon = pinCanon(msgs); + const next = [toolUseMsg("t1"), toolResultMsg("t1"), userMsg("u2"), toolUseMsg("t2"), toolResultMsg("t2")]; + const result = classifyPinned(next, canon); + assert.notEqual(result.action, "reset"); + assert.equal(validateToolAdjacency(result.messages), true); +}); + +test("pin: identical request is append-only with zero pins (idempotent)", () => { + const msgs = [userMsg("u0"), assistantMsg("a1"), userWithReminder("stable")]; + const canon = pinCanon(msgs); + const result = classifyPinned(msgs, canon); + assert.equal(result.action, "append-only"); + assert.equal(result.pinned, 0); + assert.equal(result.dropped, 0); +}); + +test("pin: mode marker isolates canon files — a plain-mode file is ignored under pin mode (one honest reset)", async () => { + const dir = await newTmp(); + try { + const body1 = { model: "m", system: [{ type: "text", text: "s" }], messages: conv(4, "mode") }; + // Write canon under phase-2. + await withEnvAsync( + { CACHE_FIX_INSERTION_NORMALIZE: "1", CACHE_FIX_VOLATILE_PIN: undefined }, + () => runExt(body1, { dir, headers: { "x-session-id": "mode-test" } }), + ); + // Same session under pin mode: prior canon must NOT half-match. + const body2 = { model: "m", system: [{ type: "text", text: "s" }], messages: conv(5, "mode") }; + const ctx = await withEnvAsync( + { CACHE_FIX_INSERTION_NORMALIZE: "1", CACHE_FIX_VOLATILE_PIN: "1" }, + () => runExt(body2, { dir, headers: { "x-session-id": "mode-test" } }), + ); + assert.equal(ctx.meta.insertionNormalizeStats.action, "reset"); + assert.equal(ctx.meta.insertionNormalizeStats.resetReason, "no-prior-canonical"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("pin: end-to-end onRequest — flip absorbed and body mutated under the flag", async () => { + const dir = await newTmp(); + const mk = (msgs) => ({ model: "m", system: [{ type: "text", text: "s" }], messages: msgs }); + try { + await withEnvAsync( + { CACHE_FIX_INSERTION_NORMALIZE: "1", CACHE_FIX_VOLATILE_PIN: "1" }, + () => runExt(mk([userMsg("u0"), assistantMsg("a1"), userWithReminder("deep"), assistantMsg("a3")]), + { dir, headers: { "x-session-id": "e2e-pin" } }), + ); + const flippedBody = mk([userMsg("u0"), assistantMsg("a1"), + { role: "user", content: [{ type: "text", text: "deep" }] }, + assistantMsg("a3"), userMsg("go on")]); + const ctx = await withEnvAsync( + { CACHE_FIX_INSERTION_NORMALIZE: "1", CACHE_FIX_VOLATILE_PIN: "1" }, + () => runExt(flippedBody, { dir, headers: { "x-session-id": "e2e-pin" } }), + ); + assert.equal(ctx.meta.insertionNormalizeStats.action, "normalized"); + assert.equal(ctx.meta.insertionNormalizeStats.pinned, 1); + assert.deepEqual(ctx.body.messages[2].content[1], { type: "text", text: REMINDER }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("pin: flag off -> classifyPinned never runs, phase-2 byte-identical behavior", async () => { + const dir = await newTmp(); + const mk = (msgs) => ({ model: "m", system: [{ type: "text", text: "s" }], messages: msgs }); + try { + await withEnvAsync( + { CACHE_FIX_INSERTION_NORMALIZE: "1", CACHE_FIX_VOLATILE_PIN: undefined }, + () => runExt(mk([userMsg("u0"), assistantMsg("a1"), userWithReminder("deep")]), + { dir, headers: { "x-session-id": "off-test" } }), + ); + const flippedBody = mk([userMsg("u0"), assistantMsg("a1"), + { role: "user", content: [{ type: "text", text: "deep" }] }]); + const before = JSON.stringify(flippedBody.messages); + const ctx = await withEnvAsync( + { CACHE_FIX_INSERTION_NORMALIZE: "1", CACHE_FIX_VOLATILE_PIN: undefined }, + () => runExt(flippedBody, { dir, headers: { "x-session-id": "off-test" } }), + ); + assert.equal(ctx.meta.insertionNormalizeStats.action, "reset"); + assert.equal(JSON.stringify(ctx.body.messages), before, "no mutation without the flag"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// --- Pins survive a reset (threat-matrix row 22) --- +// +// Measured 2026-07-28, capture s-538c0aef: CC honestly replaced message 196, +// so reset(edit-shaped) was right and the cost belonged to 196+. But every +// reset returned without a `messages` field, so the caller forwarded the raw +// array and message 177 lost the first-seen this extension +// had been restoring — our bytes changed at 177 where CC's were identical, +// starting the bust 19 messages early. +// +// The order model is what a reset abandons. The pins are not. +test("classifyPinned: a reset still forwards PINNED bytes for surviving identities", () => { + const volatileBlock = { type: "text", text: "\nhook note\n" }; + const u0 = { role: "user", content: [{ type: "text", text: "turn 0" }, volatileBlock] }; + const a0 = assistantMsg("reply 0"); + const u1 = userMsg("turn 1"); + const a1 = assistantMsg("reply 1"); + + // First request pins u0's first-seen form. + const first = classifyPinned([u0, a0, u1, a1], null); + assert.equal(first.resetReason, "no-prior-canonical"); + const canon = first.canonicalEntries; + + // CC drops the volatile block from u0 AND splices an assistant message + // mid-history — the latter forces a reset that has nothing to do with u0. + const u0NoBlock = userMsg("turn 0"); + const res = classifyPinned([u0NoBlock, a0, assistantMsg("SPLICED"), u1, a1], canon); + + assert.equal(res.action, "reset"); + assert.equal(res.resetReason, "assistant-interleaved"); + assert.ok(res.messages, "a reset must still carry the pinned array"); + assert.deepEqual( + res.messages[0], + u0, + "u0 keeps its first-seen form — the reset is about ORDER, not decoration", + ); + // Safety: substitution only — never a count, role or order change. + assert.equal(res.messages.length, 5); + assert.deepEqual( + res.messages.map((m) => m.role), + ["user", "assistant", "assistant", "user", "assistant"], + ); +}); + +test("BITE — without the carry-over the reset would un-pin the surviving message", () => { + // Same setup, but assert the property that actually costs cache: the bytes + // we forward for an UNCHANGED message must not move because some OTHER + // message was edited. + const volatileBlock = { type: "text", text: "\nnote\n" }; + const u0 = { role: "user", content: [{ type: "text", text: "u0" }, volatileBlock] }; + const a0 = { role: "assistant", content: [{ type: "text", text: "a0" }] }; + const canon = classifyPinned([u0, a0], null).canonicalEntries; + + const stripped = { role: "user", content: [{ type: "text", text: "u0" }] }; + const res = classifyPinned([stripped, { role: "assistant", content: [{ type: "text", text: "EDITED" }] }], canon); + assert.notDeepEqual( + res.messages[0], + stripped, + "forwarding CC's stripped form here is exactly the row-22 defect", + ); +}); diff --git a/test/session-key-invariants.test.mjs b/test/session-key-invariants.test.mjs new file mode 100644 index 00000000..edf26b6d --- /dev/null +++ b/test/session-key-invariants.test.mjs @@ -0,0 +1,127 @@ +// Cross-extension session-key invariants — the guard against "the lesson did +// not travel to the sibling". +// +// This exact failure happened twice in one day (2026-07-28), the second time +// costing real cache: +// +// insertion-normalization keyed persisted state on (session-id, +// system-prompt). Every subagent of a session runs the same agent prompt, +// so one bucket held 39 distinct conversations and 100% of conversation +// switches within a bucket reset (60/60). Fixed by adding a conversation +// sub-key: 0 resets across 940 requests. +// +// deferred-tool-rewrite had the IDENTICAL key and did not get the fix, +// because nothing connected the two. Its tool_addition announcement is +// anchored to a MESSAGE IDENTITY, so under a shared key the stored anchor +// belonged to another conversation's history, failed to match, and +// re-anchored to "after the last user message" — a different index every +// request. Measured: our output diverging at index 4 while CC's history was +// byte-identical through index 23, twice in one corpus. +// +// A fix applied to one consumer of a shared idea is not applied. So this file +// does not test a list someone maintains: it DISCOVERS every exported +// `*SessionKey` function under proxy/extensions/ and holds all of them to the +// same invariants. A new stateful extension is covered the moment it exports +// one, and an existing one cannot quietly regress. +// +// If a future extension legitimately needs a coarser key, this test failing is +// the conversation about it — which is the point. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const EXT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "proxy", "extensions"); + +// prefix-diff is exempt, and the exemption is checked rather than trusted. +// It keeps its FILE key at the session id deliberately (its design note 1: a +// path that moves with content misses its own baseline, so a bust never gets +// logged) and separates co-tenants INSIDE the file via tenantId. It also +// shapes no request — it is telemetry, so a coarse key costs attribution +// precision, not cache. The exemption is paired with an assertion that +// tenantId still exists, so if that design ever changes this guard notices +// instead of staying quietly satisfied. +const SEPARATES_INSIDE_THE_FILE = new Set(["prefix-diff.mjs"]); + +async function discoverKeyResolvers({ all = false } = {}) { + const found = []; + for (const f of (await readdir(EXT_DIR)).sort()) { + if (!f.endsWith(".mjs")) continue; + if (!all && SEPARATES_INSIDE_THE_FILE.has(f)) continue; + const mod = await import(pathToFileURL(join(EXT_DIR, f)).href); + for (const [name, fn] of Object.entries(mod)) { + if (typeof fn === "function" && /SessionKey$/.test(name)) { + found.push({ file: f, name, fn }); + } + } + } + return found; +} + +const HEADERS = { "x-claude-code-session-id": "shared-session" }; +const SYSTEM = [{ type: "text", text: "You are a Claude agent." }]; +const convA = [{ role: "user", content: [{ type: "text", text: "conversation A" }] }]; +const convB = [{ role: "user", content: [{ type: "text", text: "conversation B" }] }]; + +// The resolvers do not share a signature — insertion-normalization takes +// (headers, messages, system), deferred-tool-rewrite takes (headers, body). +// ARITY distinguishes them mechanically, so no name list is maintained here: +// a name list is the same hand-maintained roster this file exists to avoid. +function callResolver(fn, { messages, system }) { + return fn.length >= 3 + ? fn(HEADERS, messages, system) + : fn(HEADERS, { messages, system, model: "test-model" }); +} + +test("every extension exporting a *SessionKey is discovered", async () => { + const resolvers = await discoverKeyResolvers(); + assert.ok(resolvers.length >= 2, `expected at least the two stateful extensions, found ${resolvers.length}`); + const files = new Set(resolvers.map((r) => r.file)); + // These two are the reason the file exists; losing either from discovery + // would silently empty the guard. + assert.ok(files.has("insertion-normalization.mjs"), [...files].join(",")); + assert.ok(files.has("deferred-tool-rewrite.mjs"), [...files].join(",")); +}); + +test("BITE — a session key must separate CONVERSATIONS, not just system prompts", async () => { + for (const { file, name, fn } of await discoverKeyResolvers()) { + const a = callResolver(fn, { messages: convA, system: SYSTEM }); + const b = callResolver(fn, { messages: convB, system: SYSTEM }); + assert.notEqual( + a, + b, + `${file}:${name} gives one key to two conversations under the same session-id and system prompt — ` + + `the collision that cost cache in deferred-tool-rewrite. Add conversationSubKey from message-hash.mjs.`, + ); + } +}); + +test("a session key must separate SYSTEM PROMPTS (sidecar classes)", async () => { + for (const { file, name, fn } of await discoverKeyResolvers()) { + const main = callResolver(fn, { messages: convA, system: SYSTEM }); + const sidecar = callResolver(fn, { + messages: convA, + system: [{ type: "text", text: "Generate a concise 5-word title." }], + }); + assert.notEqual(main, sidecar, `${file}:${name} shares a key across system-prompt classes`); + } +}); + +test("a session key is STABLE for the same conversation as it grows", async () => { + // The other half: a key that changes every turn is not an identity either, + // and would abandon state on every request rather than colliding. + for (const { file, name, fn } of await discoverKeyResolvers()) { + const first = callResolver(fn, { messages: convA, system: SYSTEM }); + const grown = callResolver(fn, { + messages: [...convA, { role: "assistant", content: [{ type: "text", text: "reply" }] }], + system: SYSTEM, + }); + assert.equal(first, grown, `${file}:${name} changes key as the conversation grows — state cannot persist`); + } +}); + +// (A further case verifying prefix-diff's exemption from this invariant -- +// its own tenantId separation -- ships with the prefix-diff changes, which +// export the function it inspects.) diff --git a/tools/probe-tool-addition.mjs b/tools/probe-tool-addition.mjs new file mode 100644 index 00000000..fe6cd42b --- /dev/null +++ b/tools/probe-tool-addition.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +// probe-tool-addition — measure, per model, whether the API accepts the +// mid-conversation-tool-changes contract (tool_addition blocks). +// +// Exists because the allowlist in deferred-tool-rewrite.mjs is opt-in with +// evidence required, and the evidence was collected the expensive way once: +// the extension announced additions to every model, and on 2026-07-28 a +// sonnet-5 dispatch died with +// +// API Error: 400 tool_addition/tool_removal is not supported on this model +// +// after which TOOL_ADDITION_MODELS was cut to the one model with wire +// evidence. This script is the cheap way: one minimal real request per model, +// same auth path production uses (the CC OAuth credentials the proxy keeps +// fresh), wire shapes IMPORTED from the extension rather than re-typed here — +// a probe that hand-rolls the shape tests the probe author's memory, not the +// contract (the identity-key lesson, again). +// +// Direct to the API, not through the proxy — deliberately. The question is a +// property of the API endpoint per model, and the proxy would wrap the probe +// in session state, capture records and telemetry that all describe traffic +// no session sent. The directive's "one live request through the proxy" +// acceptance step remains what it is: end-to-end validation of the EXTENSION, +// done once per gate flip. This measures the MODEL support matrix. +// +// Three answers per model, never two: +// ACCEPTED — HTTP 200 with the addition block on the wire +// REJECTED — HTTP 400 naming tool_addition/tool_removal +// COULD NOT VERIFY — anything else (auth failure, rate limit, network, +// unrelated 400); reported verbatim, never classified, and the +// process exits non-zero so a broken probe cannot read as a +// clean sweep. +// +// KNOWN LIMIT (measured 2026-07-29): on a subscription OAuth token, this +// direct-API probe gets HTTP 429 for EVERY big model (opus, sonnet, fable) +// regardless of quota state — hand-built requests are refused for those +// models; only haiku answers, because CC itself sends it free-form utility +// traffic. For big models the working probe is a real session: start a +// throwaway proxy with CACHE_FIX_TOOL_ADDITION_EXTRA= on a spare +// port, run `claude --model -p` through it with a prompt that loads +// a tool via ToolSearch, then verify on production's capture that the +// injected block was forwarded byte-identically (replay the pipeline, +// compare against the outcome record's outSha) and that an outcome record +// exists (only written on a streamed 200). That is how fable-5 was measured. +// +// An ACCEPTED verdict is the evidence an allowlist entry cites (prefix + +// probe date); nothing is edited automatically. + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +import { + buildToolAdditionMessage, + injectAdditions, + forwardedTools, + anchorHash, + addBetaToken, +} from "../proxy/extensions/deferred-tool-rewrite.mjs"; + +const API = "https://api.anthropic.com/v1/messages"; + +// The current Claude lineup as CC sends it. Override: pass model ids as argv. +const DEFAULT_MODELS = [ + "claude-opus-5", + "claude-fable-5", + "claude-sonnet-5", + "claude-haiku-4-5-20251001", +]; + +async function accessToken() { + const raw = await readFile(join(homedir(), ".claude", ".credentials.json"), "utf-8"); + const c = JSON.parse(raw); + const o = c.claudeAiOauth ?? c; + if (!o.accessToken) throw new Error(".credentials.json carries no accessToken"); + if (o.expiresAt && o.expiresAt < Date.now()) { + throw new Error("access token expired — start a Claude Code session to refresh it"); + } + return o.accessToken; +} + +function probeBody(model) { + // Two tools: one present from the start, one "added mid-conversation" via + // the real builders. forwardedTools marks the added one defer_loading; the + // addition message is injected at its anchor exactly as onRequest does. + const toolA = { + name: "echo_base", + description: "Echo the input string.", + input_schema: { type: "object", properties: { s: { type: "string" } }, required: ["s"] }, + }; + const toolB = { + name: "echo_added", + description: "Echo the input string (added mid-conversation).", + input_schema: { type: "object", properties: { s: { type: "string" } }, required: ["s"] }, + }; + const user = { role: "user", content: "Reply with the single word ok. Do not use tools." }; + const additions = [ + { + names: [toolB.name], + anchorHash: anchorHash(user), + message: buildToolAdditionMessage([toolB.name]), + }, + ]; + const { messages } = injectAdditions([user], additions); + return { + model, + max_tokens: 16, + messages, + tools: forwardedTools([toolA, toolB], additions), + }; +} + +async function probe(model, token) { + const headers = { + "content-type": "application/json", + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + authorization: `Bearer ${token}`, + }; + addBetaToken(headers); // the same token the extension puts on real traffic + let res, text; + try { + res = await fetch(API, { method: "POST", headers, body: JSON.stringify(probeBody(model)) }); + text = await res.text(); + } catch (e) { + return { model, verdict: "COULD NOT VERIFY", detail: `network: ${e?.message ?? e}` }; + } + if (res.status === 200) return { model, verdict: "ACCEPTED", detail: "HTTP 200" }; + if (res.status === 400 && /tool_addition|tool_removal/.test(text)) { + return { model, verdict: "REJECTED", detail: text.slice(0, 200) }; + } + return { model, verdict: "COULD NOT VERIFY", detail: `HTTP ${res.status}: ${text.slice(0, 300)}` }; +} + +const models = process.argv.slice(2).length ? process.argv.slice(2) : DEFAULT_MODELS; +const token = await accessToken(); +let unverified = 0; +console.log(`probing ${models.length} model(s) against ${API}\n`); +for (const m of models) { + const r = await probe(m, token); + if (r.verdict === "COULD NOT VERIFY") unverified++; + console.log(`${r.verdict.padEnd(18)} ${m}`); + if (r.verdict !== "ACCEPTED") console.log(` ${r.detail}\n`); +} +if (unverified) { + console.error(`\n${unverified} model(s) COULD NOT be verified — that is not a verdict either way.`); + process.exit(1); +}