diff --git a/proxy/extensions.json b/proxy/extensions.json index b28b3684..b6c287cb 100644 --- a/proxy/extensions.json +++ b/proxy/extensions.json @@ -15,8 +15,8 @@ "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 }, "ttl-management": { "enabled": true, "order": 500 }, "cache-telemetry": { "enabled": true, "order": 600 }, "overage-warning": { "enabled": true, "order": 610 }, diff --git a/proxy/extensions/insertion-normalization.mjs b/proxy/extensions/insertion-normalization.mjs new file mode 100644 index 00000000..f8d4a174 --- /dev/null +++ b/proxy/extensions/insertion-normalization.mjs @@ -0,0 +1,1125 @@ +// 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). +// +// Captures the inner text (group 1) so the SAME regex serves both +// isVolatileBlock's boolean test (unaffected by adding a group) and +// suppression's unwrapVolatileText below — one pattern, not a second +// derivation of it (dev-loop.md, "never hand-roll identity in a probe"). +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); +} + +// --- Reminder-swap suppression (#76606, decision B) --- +// +// CC sometimes migrates a hook reminder OUT of the user message that +// carries it and INTO a standalone message of its own — measured directly +// (capture s-633915a8, n=26->28): message[30]'s -wrapped +// block is gone from message[30] and its inner text, wrapper stripped, +// is the entire content of a new message[31] (role system). Pinning above +// restores message[30]'s first-seen bytes, reminder included; treating the +// new standalone as ordinary tail growth then forwards the SAME text a +// second time, and because it lands mid-array the cache's +// longest-identical-prefix boundary moves to right before it — everything +// after is re-billed (measured: cacheRead 15424 / cacheCreation 124025). +// +// Strip the wrapper for comparison ONLY — never for what gets forwarded; +// the pin already owns that. Reuses VOLATILE_WRAP_REGEX's capture group +// rather than a second regex, per the same rule cited above it. +function unwrapVolatileText(block) { + if (!block || typeof block !== "object" || block.type !== "text" || typeof block.text !== "string") { + return block; + } + const m = VOLATILE_WRAP_REGEX.exec(block.text); + return m ? { type: "text", text: m[1] } : block; +} + +// The set of block identities this extension is CURRENTLY restoring — +// i.e. present in a LIVE (not dropped) canonical entry's stored first-seen +// form. Dropped entries are excluded on purpose: their content is not being +// served anywhere, so a new standalone message matching a dropped block +// must flow through normally rather than being silently discarded with no +// copy left at all. Scoped to VOLATILE blocks only (isVolatileBlock), since +// those are what buildPinEntry ever stores `m` for and what the measured +// migration shape moves — a plain text block coincidentally matching a +// pinned message's ordinary content is not this class. +function pinnedBlockHashes(priorCanonical) { + const hashes = new Set(); + if (!Array.isArray(priorCanonical)) return hashes; + for (const entry of priorCanonical) { + if (entry.d || !entry.m || !Array.isArray(entry.m.content)) continue; + for (const block of entry.m.content) { + if (!isVolatileBlock(block)) continue; + const h = hashMessageContent({ content: [unwrapVolatileText(block)] }); + if (h !== null) hashes.add(h); + } + } + return hashes; +} + +// The merged-standalone shape (measured 2026-07-30, capture s-633915a8, +// msg864, the 587k window): CC sometimes migrates ALL of a message's +// volatile blocks out TOGETHER, joined into one standalone message, +// rather than one standalone per block. pinnedBlockHashes above can never +// match that — it hashes one block at a time, and a merged message is one +// block whose text spans two reminders. A second set covers exactly the +// observed join: for each pinned entry with >=2 volatile blocks, hash the +// concatenation of ALL its volatile blocks' wrapper-stripped texts, in +// WIRE order, joined with "\n\n" — the exact separator measured on the +// real merged standalone (both hook reminders, 627 chars). No +// subset-merges: partial joins were never observed and would only invite +// false suppression on coincidental partial matches. "\n\n" is hardcoded +// to the one observed instance, not a general N-ary merge grammar — other +// separators are unobserved, and the census keeps watching for them. +function pinnedJoinHashes(priorCanonical) { + const hashes = new Set(); + if (!Array.isArray(priorCanonical)) return hashes; + for (const entry of priorCanonical) { + if (entry.d || !entry.m || !Array.isArray(entry.m.content)) continue; + const volatileTexts = entry.m.content.filter(isVolatileBlock).map((b) => unwrapVolatileText(b).text); + if (volatileTexts.length < 2) continue; + const h = hashMessageContent({ content: [{ type: "text", text: volatileTexts.join("\n\n") }] }); + if (h !== null) hashes.add(h); + } + return hashes; +} + +// Is `msg` a suppressible duplicate of a currently-pinned block (or, since +// 2026-07-30, a currently-pinned entry's FULL joined volatile-block set)? +// Narrow by definition (BACKLOG #76606 part (c)): STANDALONE only (single +// block after the same string->one-block fold canonicalMessageShape +// already applies elsewhere in this file), and its wrapper-stripped bytes +// must exactly equal a hash in `pinnedHashes` or `joinHashes` — never a +// positional or role heuristic. `joinHashes` is optional (existing callers +// checking single-block duplicates only are unaffected). Returns the +// matched hash (for telemetry) or null (genuine content: no suppression, +// existing rules apply unchanged). +export function findSuppressibleDuplicate(msg, pinnedHashes, joinHashes) { + const shaped = canonicalMessageShape(msg); + if (!Array.isArray(shaped.content) || shaped.content.length !== 1) return null; + const h = hashMessageContent({ content: [unwrapVolatileText(shaped.content[0])] }); + if (h === null) return null; + if (pinnedHashes.has(h)) return h; + if (joinHashes && joinHashes.has(h)) return h; + return null; +} + +export { pinnedBlockHashes, pinnedJoinHashes }; + +// 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"); + } + + // Suppress a NEW entry that duplicates a block this extension is already + // restoring elsewhere (see the block comment above findSuppressibleDuplicate). + // Assistant entries are excluded on principle even though the measured + // shape never produces one — silently dropping the model's own prior + // output is a correctness question this extension has no business + // deciding, unlike a hook reminder it already owns via the pin. + // Genuine change (normalized bytes differ from every pinned block): + // findSuppressibleDuplicate returns null, the entry is untouched here, + // and whatever the existing rules above already decided (append/splice/ + // edit-shaped reset) stands — no new reset path is introduced. + // TAIL GUARD (BACKLOG.md, "suppression can strip a request's FINAL + // message", 2026-07-30). Three real 400s ("must end with a user + // message"): report-enforcer injects identical instruction bytes at + // every SubagentStop; the first occurrence gets pinned, and when the + // SAME bytes arrive again as the resume request's ONLY/new final + // message, suppressing it left the forwarded array ending on the prior + // assistant turn. A tail-position duplicate is never a stray migration + // copy of already-pinned content — CC just sent it as the live, + // load-bearing final entry of THIS request, and the model needs to see + // it. Applies uniformly to both single-block and join-hash matches: the + // guard is positional, not about which hash set matched. + const lastIdx = messages.length - 1; + const pinnedHashes = pinnedBlockHashes(priorCanonical); + const pinnedJoin = pinnedJoinHashes(priorCanonical); + const suppressions = []; + for (const e of newEntries) { + if (e.r === "assistant") continue; + if (e.index === lastIdx) continue; + const h = findSuppressibleDuplicate(messages[e.index], pinnedHashes, pinnedJoin); + if (h !== null) suppressions.push({ index: e.index, hash: h }); + } + const suppressedIdx = new Set(suppressions.map((s) => s.index)); + + // 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 = []; + for (const e of incoming) { + if (suppressedIdx.has(e.index)) continue; // the pinned inline form already carries these bytes + const ci = matchedByIdx.get(e.index); + if (ci === undefined) { + finalMessages.push(messages[e.index]); + continue; + } + const fwd = pinnedForwardForm(priorCanonical[ci], messages[e.index]); + if (fwd !== messages[e.index] && JSON.stringify(fwd) !== JSON.stringify(messages[e.index])) { + pinApplied++; + finalMessages.push(fwd); + } else { + finalMessages.push(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) { + // A suppressed entry was never forwarded, so it gets no canonical + // identity — the invariant just below states the canonical must + // describe the wire we just forwarded, and this entry isn't on it. + // Recomputed fresh on every request (findSuppressibleDuplicate against + // the currently-live pins), so leaving no trace here is not a gap: CC + // keeps re-sending the duplicate as long as it believes it's part of + // history, and it is re-detected and re-suppressed every time — no + // persisted "suppressed" marker is needed for the suppression to stay + // stable across subsequent requests. + if (suppressedIdx.has(e.index)) continue; + 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 || suppressions.length > 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` counts what actually landed on the wire — a suppressed + // entry was a new entry CC sent but never one we forwarded, so it must + // not inflate this the way it would inflate a real insertion count. + inserted: newEntries.length - suppressions.length, + pinned: pinApplied, + dropped: droppedNow.size, + suppressed: suppressions.length, + suppressions, + }; +} + +// --- 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, + // `suppressions` (not just the count) rides on the stats object — + // tools/replay.mjs's safety-gate exemption reads the incoming + // indices from here to declare them, the same way it already reads + // deferred-tool-rewrite's tool_addition shape. + ...(pin + ? { + pinned: result.pinned ?? 0, + dropped: result.dropped ?? 0, + suppressed: result.suppressed ?? 0, + suppressions: result.suppressions ?? [], + } + : {}), + }; + + 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, suppressed: result.suppressed ?? 0 } : {}), + }, + fs, + ); + + // One event line PER SUPPRESSION (not aggregated into the summary + // line above), to the same file/format — the pattern every other + // record in this log already uses, just one call per occurrence + // instead of once per request. + if (pin && Array.isArray(result.suppressions) && result.suppressions.length) { + for (const s of result.suppressions) { + await appendTelemetry( + dir, + sessionKey, + { + ts: new Date().toISOString(), + key: sessionKey, + sid: sessionId, + event: "suppressed-duplicate", + index: s.index, + hash: s.hash, + }, + 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} suppressed=${result.suppressed ?? 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/proxy/extensions/messages-cache-breakpoint.mjs b/proxy/extensions/messages-cache-breakpoint.mjs deleted file mode 100644 index d8e5c342..00000000 --- a/proxy/extensions/messages-cache-breakpoint.mjs +++ /dev/null @@ -1,314 +0,0 @@ -// messages-cache-breakpoint — inject the missing breakpoint #3 cache_control -// at the boundary between Claude Code's auto-injected blocks (hooks, skills, -// project CLAUDE.md, deferred-tools, MCP server descriptions) and the first -// real user content inside `messages[0]`. -// -// Activation: `enabled: true` in extensions.json (always loaded), runtime -// gates per env var: -// -// - CACHE_FIX_INJECT_MESSAGES_BREAKPOINT=1 → opt-in injection -// - CACHE_FIX_DUMP_MESSAGES_HEAD= → diagnostic-only JSONL dump -// of messages[0].content shape -// -// Order 410 — runs immediately after `cache-control-normalize` (400), so we -// count markers and place breakpoint #3 against a normalized baseline. -// -// See `docs/directives/proxy-messages-cache-breakpoint.md` for the full -// design (boundary detection algorithm, marker-count guard, telemetry surface). - -import { appendFile, mkdir } from "node:fs/promises"; -import { dirname } from "node:path"; - -// --- Env gates (read per-call so tests can flip without re-importing) --- - -function isInjectEnabled() { - return process.env.CACHE_FIX_INJECT_MESSAGES_BREAKPOINT === "1"; -} -function getDumpPath() { - const v = process.env.CACHE_FIX_DUMP_MESSAGES_HEAD; - return v && v.length > 0 ? v : null; -} -function isDebug() { - return process.env.CACHE_FIX_DEBUG === "1"; -} - -function debug(msg) { - if (isDebug()) process.stderr.write(`[messages-breakpoint] DEBUG: ${msg}\n`); -} - -// --- Block classification --- -// -// Auto-injected block kinds that CC writes into `messages[0].content` ahead of -// the real user content. Order matters: each block runs through these checks -// in declaration order and the first match wins. Tightening notes: -// -// - Hooks: requires both `` opening AND `hook success` -// substring — narrow enough that user prose discussing hook semantics -// won't false-positive. -// - Skills: anchored on `` opening tag; won't match user -// messages that quote `` from documentation. -// - CLAUDE.md: regex anchored on absolute-path prefix (`/`); won't match -// "see CLAUDE.md in the docs". -// - Deferred-tools: exact `` tag substring; won't match -// user prose about "deferred tools". -// - MCP: two specific sentinels (`` tag OR -// `Available MCP servers:` literal); won't match generic MCP prose. - -const CLAUDE_MD_RE = /Contents of \/[^\n]*?CLAUDE\.md/; - -function getBlockText(block) { - if (!block || typeof block !== "object") return null; - if (block.type !== "text") return null; - if (typeof block.text !== "string") return null; - return block.text; -} - -export function classifyBlock(block) { - const text = getBlockText(block); - if (text === null) return "user"; - - // Hooks: + "hook success" - if (text.startsWith("") && text.includes("hook success")) { - return "hooks"; - } - // Skills: + ( OR ) - if ( - text.startsWith("") && - (text.includes("") || text.includes("")) - ) { - return "skills"; - } - // Project CLAUDE.md: wrapper + absolute-path Contents-of - // marker. The system-reminder wrapper is required to keep user prose that - // happens to mention "Contents of /path/to/CLAUDE.md" from matching. - if (text.includes("") && CLAUDE_MD_RE.test(text)) { - return "claude_md"; - } - // Deferred tools: exact tag - if (text.includes("")) { - return "deferred_tools"; - } - // MCP: either sentinel - if (text.includes("") || text.includes("Available MCP servers:")) { - return "mcp_resources"; - } - return "user"; -} - -const AUTO_INJECTED_KINDS = new Set([ - "hooks", - "skills", - "claude_md", - "deferred_tools", - "mcp_resources", -]); - -// Return the LAST index in `content` whose block classifies as auto-injected, -// or -1 if no auto-injected block is found. Walking the full array (rather -// than stopping at the first user block) keeps us correct in the defensive -// case where auto-injected and user blocks are interleaved. -export function detectAutoInjectedBoundary(content) { - if (!Array.isArray(content)) return -1; - let lastIdx = -1; - for (let i = 0; i < content.length; i++) { - const kind = classifyBlock(content[i]); - if (AUTO_INJECTED_KINDS.has(kind)) lastIdx = i; - } - return lastIdx; -} - -// --- Marker counting --- - -export function countAllCacheControlMarkers(body) { - if (!body || typeof body !== "object") return 0; - let n = 0; - if (Array.isArray(body.system)) { - for (const block of body.system) { - if (block && typeof block === "object" && block.cache_control) n++; - } - } - if (Array.isArray(body.messages)) { - for (const msg of body.messages) { - if (!msg || !Array.isArray(msg.content)) continue; - for (const block of msg.content) { - if (block && typeof block === "object" && block.cache_control) n++; - } - } - } - return n; -} - -// --- Stats shape (also used as telemetry on ctx.meta) --- - -function initStats() { - return { - enabled: true, - injected: false, - boundary_idx: -1, - boundary_block_kind: null, - blocks_examined: 0, - existing_marker_count: 0, - skip_reason: null, - }; -} - -// --- Orchestrator (pure on body — no I/O) --- - -export function injectMessagesBreakpoint(reqCtx) { - const stats = initStats(); - if (!reqCtx || !reqCtx.body) { - stats.skip_reason = "unexpected_role_or_shape"; - return stats; - } - const body = reqCtx.body; - const messages = body.messages; - if (!Array.isArray(messages) || messages.length === 0) { - stats.skip_reason = "unexpected_role_or_shape"; - return stats; - } - const first = messages[0]; - if (!first || first.role !== "user" || !Array.isArray(first.content)) { - stats.skip_reason = "unexpected_role_or_shape"; - return stats; - } - - const existingMarkers = countAllCacheControlMarkers(body); - stats.existing_marker_count = existingMarkers; - - if (existingMarkers === 0) { - stats.skip_reason = "no_existing_markers"; - return stats; - } - if (existingMarkers >= 4) { - stats.skip_reason = "at_marker_limit"; - if (existingMarkers > 4) { - process.stderr.write( - `[messages-breakpoint] warn: existing_markers=${existingMarkers} exceeds Anthropic's documented max of 4\n`, - ); - } - return stats; - } - - stats.blocks_examined = first.content.length; - const boundaryIdx = detectAutoInjectedBoundary(first.content); - stats.boundary_idx = boundaryIdx; - if (boundaryIdx === -1) { - stats.skip_reason = "boundary_not_found"; - return stats; - } - - const target = first.content[boundaryIdx]; - stats.boundary_block_kind = classifyBlock(target); - - if (target && target.cache_control) { - stats.skip_reason = "boundary_already_marked"; - return stats; - } - - first.content[boundaryIdx] = { - ...target, - cache_control: { type: "ephemeral", ttl: "1h" }, - }; - stats.injected = true; - return stats; -} - -// --- Diagnostic dump --- -// -// Dumps the structural shape of messages[0].content (per-block kind, first -// 200 chars of text, cache_control presence flag) to a JSONL file. Read-only -// — no body mutation. Independent of injection: a user can enable the dump -// without enabling injection to gather fixture data first. - -const DUMP_TEXT_PREFIX_CHARS = 200; - -export function buildDumpRecord(body, ts = new Date().toISOString()) { - const messages = body?.messages; - const first = Array.isArray(messages) ? messages[0] : null; - const content = first && Array.isArray(first.content) ? first.content : null; - const blocks = content - ? content.map((block, idx) => { - const kind = classifyBlock(block); - const text = getBlockText(block); - return { - idx, - type: block?.type ?? null, - kind, - text_prefix: text === null ? null : text.slice(0, DUMP_TEXT_PREFIX_CHARS), - has_cache_control: !!(block && block.cache_control), - }; - }) - : []; - return { - ts, - role: first?.role ?? null, - block_count: blocks.length, - existing_marker_count: countAllCacheControlMarkers(body), - blocks, - }; -} - -async function writeDump(path, record) { - await mkdir(dirname(path), { recursive: true }); - await appendFile(path, JSON.stringify(record) + "\n"); -} - -// --- Stderr summary --- - -function emitStderrSummary(stats) { - if (stats.injected) { - process.stderr.write( - `[messages-breakpoint] injected boundary_idx=${stats.boundary_idx} kind=${stats.boundary_block_kind} existing_markers=${stats.existing_marker_count}\n`, - ); - } else { - process.stderr.write( - `[messages-breakpoint] skipped reason=${stats.skip_reason} existing_markers=${stats.existing_marker_count}\n`, - ); - } -} - -// --- Extension contract --- - -export default { - name: "messages-cache-breakpoint", - description: - "Inject the missing breakpoint #3 cache_control marker at the boundary " + - "between Claude Code's auto-injected messages[0] blocks (hooks, skills, " + - "CLAUDE.md, deferred-tools, MCP) and the first real user content", - enabled: false, // overridden by extensions.json - order: 410, - - async onRequest(ctx) { - const dumpPath = getDumpPath(); - const inject = isInjectEnabled(); - - // Both gates off → no-op. Avoid even building stats so the disabled path - // is essentially free. - if (!dumpPath && !inject) return; - - if (!ctx || !ctx.body) return; - - // Diagnostic dump runs first and is independent of injection. We dump - // BEFORE injection so the recorded shape is the request as CC sent it, - // not as we mutated it. - if (dumpPath) { - try { - const record = buildDumpRecord(ctx.body); - await writeDump(dumpPath, record); - } catch (err) { - debug(`dump write failed: ${err?.message ?? err}`); - } - } - - if (!inject) return; - - try { - const stats = injectMessagesBreakpoint(ctx); - ctx.meta = ctx.meta || {}; - ctx.meta.messagesBreakpointStats = stats; - emitStderrSummary(stats); - } catch (err) { - debug(`onRequest unexpected: ${err?.message ?? err}`); - } - }, -}; diff --git a/test/fixtures/harvested/oscillation-s-4b6a435234bf-863.json b/test/fixtures/harvested/oscillation-s-4b6a435234bf-863.json new file mode 100644 index 00000000..0a020db4 --- /dev/null +++ b/test/fixtures/harvested/oscillation-s-4b6a435234bf-863.json @@ -0,0 +1 @@ +{"_what":"CC#76606-family OSCILLATION evidence, SANITIZED bytes: message[863] (Agent-spawn tool_result, sonnet-queue-recon) across 8 consecutive requests a 148-second window, flipping between hook-reminders-inline and stripped forms. Selected by TIMESTAMP from the live capture (ordinal selection failed twice: capture growth + divergent numbering schemes).","_doc":"MEASURED MINIMUM, with its number, as the fixture-strategy rule requires when a fixture resists the >=10x cut (docs/directives/insertion-normalization-identity-directive.md). This file was never a harvester range dump: harvest already narrowed it to two messages per request (msg863, msg864, out of ~920). All 13 records are load-bearing — the 8 msg863 records ARE the oscillation (5 inline, 3 stripped, in wire order with their timing deltas; dropping the repeats would change the flip count the census reads off this file, 3 rows / 2 flaps per docs/code-reviews/census-flap-joined-report.md), and the 5 msg864 records carry the same flip on the standalone side. Evidence payload 3645 bytes, sanitization claim 2217 more; the only reduction left was whitespace, so the cut is 1.1x, not 10x.","_sanitization":"Rebuilt 2026-07-31. This fixture was committed RAW end to end (its own header said so): operator hook prose, an agent tool_result naming a sub-agent and its session, and two thinking-block signatures of 1170 and 531 base64 chars. Every message now goes through scrubMessage. The MERGED standalone (msg864, role system) is re-joined from the SANITIZED constituents — msg863's two wrapper-stripped reminders — and asserted byte-equal to the plain scrub of the merged string, so the join-hash relation this fixture exists for is carried by construction. tools/harvest.mjs scrubMessage + rebaseTimestamps (one scrubber, no second path). TOKENIZED: every text, per '\\n\\n' segment, as t__, with WRAPPERS surviving verbatim around a tokenized inner text; nested payloads (block.data, block.source.data, any >64-char string under source) as data_; thinking signatures as sig_; conversation keys and sids as s-, the same token this file's NAME carries. REBASED: every timestamp onto 2000-01-01T00:00:00.000Z + its original delta from this fixture's earliest instant. PRESERVED — this is what the fixture is FOR: equality of equal texts, the '\\n\\n' join and paragraph-prefix relations (scrubText is a homomorphism over '\\n\\n' since bffcb05), tool_use_id/id pairing, message and block ordering, timestamp ordering and spacing. RESIDUAL, accepted (operator ruling 2026-07-31, local operator-controlled traffic): token lengths, paragraph counts, intra-fixture timing deltas. This note is a CLAIM; test/harvest-scrub-relations.test.mjs walks this file and re-checks each absence class mechanically.","_merge_standalone":"msg864 across the same window: CC's MERGED standalone — both hook reminders wrapper-stripped, joined with \\n\\n (627 raw chars), role system; the suppression gap's real shape","requests":[{"ts":"2000-01-01T00:00:00.000Z","msgCount":913,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]},{"type":"text","text":"\nt_d1d8fb876c64_349\n"},{"type":"text","text":"\nt_be53f4f44125_276\n"}]}},{"ts":"2000-01-01T00:00:28.418Z","msgCount":916,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]},{"type":"text","text":"\nt_d1d8fb876c64_349\n"},{"type":"text","text":"\nt_be53f4f44125_276\n"}]}},{"ts":"2000-01-01T00:00:34.938Z","msgCount":918,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]},{"type":"text","text":"\nt_d1d8fb876c64_349\n"},{"type":"text","text":"\nt_be53f4f44125_276\n"}]}},{"ts":"2000-01-01T00:00:50.701Z","msgCount":920,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]},{"type":"text","text":"\nt_d1d8fb876c64_349\n"},{"type":"text","text":"\nt_be53f4f44125_276\n"}]}},{"ts":"2000-01-01T00:00:50.898Z","msgCount":921,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]}]}},{"ts":"2000-01-01T00:00:58.056Z","msgCount":922,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]},{"type":"text","text":"\nt_d1d8fb876c64_349\n"},{"type":"text","text":"\nt_be53f4f44125_276\n"}]}},{"ts":"2000-01-01T00:02:06.631Z","msgCount":923,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]}]}},{"ts":"2000-01-01T00:02:28.036Z","msgCount":926,"msg863":{"role":"user","content":[{"tool_use_id":"toolu_012R3rh89mR9guctAZbiaHZe","type":"tool_result","content":[{"type":"text","text":"t_c7b26e448190_289"}]}]}}],"requests_864":[{"ts":"2000-01-01T00:00:50.701Z","msg864":{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_a9ca36c55c"},{"type":"thinking","thinking":"","signature":"sig_87b5b03e5f"},{"type":"tool_use","id":"toolu_01CWrpni9WFUr3drUrKWs4eF","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]}},{"ts":"2000-01-01T00:00:50.898Z","msg864":{"role":"system","content":"t_d1d8fb876c64_349\n\nt_be53f4f44125_276"}},{"ts":"2000-01-01T00:00:58.056Z","msg864":{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_a9ca36c55c"},{"type":"thinking","thinking":"","signature":"sig_87b5b03e5f"},{"type":"tool_use","id":"toolu_01CWrpni9WFUr3drUrKWs4eF","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]}},{"ts":"2000-01-01T00:02:06.631Z","msg864":{"role":"system","content":"t_d1d8fb876c64_349\n\nt_be53f4f44125_276"}},{"ts":"2000-01-01T00:02:28.036Z","msg864":{"role":"system","content":"t_d1d8fb876c64_349\n\nt_be53f4f44125_276"}}]} diff --git a/test/fixtures/harvested/pinned-s-4b6a435234bf-26-28.json b/test/fixtures/harvested/pinned-s-4b6a435234bf-26-28.json new file mode 100644 index 00000000..43ec0b1a --- /dev/null +++ b/test/fixtures/harvested/pinned-s-4b6a435234bf-26-28.json @@ -0,0 +1 @@ +{"header":{"key":"s-4b6a435234bf","range":{"n":26,"m":28},"replayFrom":26,"note":"MINIMIZED (docs/directives/insertion-normalization-identity-directive.md, \"Fixture strategy\"): records holds capture ordinals replayFrom..m, NOT the full 0..m prefix the pin tool dumps. replayFrom names the capture ordinal of the FIRST request record here, so a consumer numbers its replayed entries from replayFrom (not from 0) and n..m keeps meaning the same pair it always did. The dropped prefix was scaffolding for pin state only: request 26 carries the reminder-bearing message inline, so it establishes the pin by itself. Measured, not assumed — `node tools/fixture-verdict-identity.mjs ` replays both through the real extension pipeline and compares every retained request's verdict (action, resetReason, suppressed, the suppressed indices, in/out lengths, a hash of the FORWARDED messages), every findMitigationGaps row and every findSafetyViolations result. Outcome and boot-gate records are dropped: no consumer of this fixture reads them.","minimized":{"from":{"records":54,"bytes":432264,"replayFrom":0},"droppedOutcomeRecords":24,"measuredFloorReplayFrom":26},"harvestedAt":"2000-01-01T14:22:32.820Z","sanitizer":"tools/harvest.mjs scrubMessage + rebaseTimestamps (one scrubber, no second path). TOKENIZED: every text, per '\\n\\n' segment, as t__, with WRAPPERS surviving verbatim around a tokenized inner text; nested payloads (block.data, block.source.data, any >64-char string under source) as data_; thinking signatures as sig_; conversation keys and sids as s-, the same token this file's NAME carries. REBASED: every timestamp onto 2000-01-01T00:00:00.000Z + its original delta from this fixture's earliest instant. PRESERVED — this is what the fixture is FOR: equality of equal texts, the '\\n\\n' join and paragraph-prefix relations (scrubText is a homomorphism over '\\n\\n' since bffcb05), tool_use_id/id pairing, message and block ordering, timestamp ordering and spacing. RESIDUAL, accepted (operator ruling 2026-07-31, local operator-controlled traffic): token lengths, paragraph counts, intra-fixture timing deltas. This note is a CLAIM; test/harvest-scrub-relations.test.mjs walks this file and re-checks each absence class mechanically."},"records":[{"ts":"2000-01-01T00:00:00.000Z","type":"boot","proxyTree":"8349b0e665c8"},{"ts":"2000-01-01T00:20:15.840Z","sid":"s-da07bb2d3cbe","key":"s-4b6a435234bf","headers":{"anthropic-beta":"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24,server-side-fallback-2026-06-01,fallback-credit-2026-06-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07"},"body":{"model":"claude-fable-5","system":[{"type":"text","text":"t_2719b7a469d9_57"},{"type":"text","text":"t_3b27271fa44c_1210","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}},{"type":"text","text":"t_4de4f7d57b20_9708","cache_control":{"type":"ephemeral","ttl":"1h"}}],"tools":[{"name":"Agent"},{"name":"Artifact"},{"name":"Bash"},{"name":"Edit"},{"name":"Read"},{"name":"ReportFindings"},{"name":"ScheduleWakeup"},{"name":"SendUserFile"},{"name":"Skill"},{"name":"ToolSearch"},{"name":"DeferredToolPlaceholder"},{"name":"Write"}],"messages":[{"role":"user","content":[{"type":"text","text":"\nt_8bb46e7570ef_34698\n"},{"type":"text","text":"t_91e9f2c09173_1364"}]},{"role":"system","content":"t_734a76861fca_39386"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_576b70b210"},{"type":"text","text":"t_0c28330aea49_200"},{"type":"tool_use","id":"toolu_016R2CkQNiPF7pGntpksfxTf","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01FWjwDMpf7q9tddgCNqBvXL","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_016R2CkQNiPF7pGntpksfxTf","type":"tool_result","content":"t_df0a8efd9614_2506","is_error":false},{"tool_use_id":"toolu_01FWjwDMpf7q9tddgCNqBvXL","type":"tool_result","content":"t_dc06c8d17374_14374"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_364ad2c018"},{"type":"text","text":"t_00cd0ec3829f_5343"}]},{"role":"user","content":"t_78dbf4bd3bd0_231"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_63b8e0269b"},{"type":"text","text":"t_ef337775171d_228"},{"type":"tool_use","id":"toolu_01RMLeqyR4mbwSVcTPtnTVoL","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01RMLeqyR4mbwSVcTPtnTVoL","type":"tool_result","content":"t_9c4f2b243c1b_572","is_error":false}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_2d4eefac4e"},{"type":"tool_use","id":"toolu_01NBfTRtLeYZvAFzG2tRAC5f","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_011tFFjyRNWbwcMDp5edgRGU","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01NBfTRtLeYZvAFzG2tRAC5f","type":"tool_result","content":"t_ddf8e49ad427_28199","is_error":false},{"tool_use_id":"toolu_011tFFjyRNWbwcMDp5edgRGU","type":"tool_result","content":"t_36beb1e63eed_661","is_error":false}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_502b9777df"},{"type":"thinking","thinking":"","signature":"sig_61560470ab"},{"type":"tool_use","id":"toolu_01N3nZy9AZj6iBW4susq4mAc","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01N3nZy9AZj6iBW4susq4mAc","type":"tool_result","content":"t_2d44eeee3ef5_286","is_error":false}]},{"role":"system","content":"t_2a857b007c9d_609"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_68c2e7125a"},{"type":"thinking","thinking":"","signature":"sig_a87bccd2b8"},{"type":"tool_use","id":"toolu_0156epNw8kruUYz7VsWWWwFC","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01SwzvLxwNvJwG9ErivQSaA4","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_0156epNw8kruUYz7VsWWWwFC","type":"tool_result","content":"t_4e62bebf2aa6_2576"},{"tool_use_id":"toolu_01SwzvLxwNvJwG9ErivQSaA4","type":"tool_result","content":"t_3059b7a5a506_2855"}]},{"role":"system","content":"t_d7b1e0351111_421"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_3f79a3e42f"},{"type":"thinking","thinking":"","signature":"sig_80a8dcadbf"},{"type":"tool_use","id":"toolu_01SMqgSHXQ2CbgFjfxGujYRM","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01Vtz5jJemomLfGwzFcyaSpA","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"type":"tool_result","content":"t_7f52dbb9e3c5_885","is_error":true,"tool_use_id":"toolu_01SMqgSHXQ2CbgFjfxGujYRM"},{"type":"tool_result","content":"t_7f52dbb9e3c5_885","is_error":true,"tool_use_id":"toolu_01Vtz5jJemomLfGwzFcyaSpA"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_54b0f2c1c6"},{"type":"thinking","thinking":"","signature":"sig_a83b5e9ad2"},{"type":"tool_use","id":"toolu_01AkcfiiPoMBVkeYLPNKBVet","name":"Skill","input":{"skill":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01AkcfiiPoMBVkeYLPNKBVet","content":"t_f9457ee1098c_40"},{"type":"text","text":"t_129248f2aa12_3984"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_a5f0a664bf"},{"type":"tool_use","id":"toolu_0134rrs9Az4m7ZThYMP517VC","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01MFNdgHV1VrsQBQmQzpkDbf","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01MFNdgHV1VrsQBQmQzpkDbf","type":"tool_result","content":"t_91910cc63e61_15587"},{"tool_use_id":"toolu_0134rrs9Az4m7ZThYMP517VC","type":"tool_result","content":"t_25bd6cdc6f7b_31851"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_768a1dd44f"},{"type":"thinking","thinking":"","signature":"sig_109d0b84b3"},{"type":"tool_use","id":"toolu_019wDa7xTeUVA5wf5fkuQxht","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01GWio9hMHokoVLVxAW4VfLD","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"type":"tool_result","content":"t_6f4913622d4e_725","is_error":true,"tool_use_id":"toolu_019wDa7xTeUVA5wf5fkuQxht"},{"type":"tool_result","content":"t_6f4913622d4e_725","is_error":true,"tool_use_id":"toolu_01GWio9hMHokoVLVxAW4VfLD"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_d861d2425d"},{"type":"thinking","thinking":"","signature":"sig_56b4959cb6"},{"type":"tool_use","id":"toolu_01AkhgY6i4i2gsxL7sjhX85u","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01AkhgY6i4i2gsxL7sjhX85u","type":"tool_result","content":"t_dc06c8d17374_14374"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_813e45e7c2"},{"type":"text","text":"t_68fea8c14093_56"},{"type":"tool_use","id":"toolu_01822EZtWARJ9CrsQ1mkbB6M","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01PhBrV4kXp67AWv3iWLYFuY","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01822EZtWARJ9CrsQ1mkbB6M","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01PhBrV4kXp67AWv3iWLYFuY","type":"tool_result","content":"t_ae4dbd9aface_158"}]},{"role":"system","content":"t_a5b7a924eeb2_3990"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_2714debf4e"},{"type":"thinking","thinking":"","signature":"sig_2631f7f623"},{"type":"tool_use","id":"toolu_014VrRSh3yK7t8UuebodSLeA","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_013PXwtCfAvmjd2PdN5H8bW6","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01AVs6PQba9wWSfrWQZXyQYG","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01UCGWVeimBogdifPw11a36h","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_014VrRSh3yK7t8UuebodSLeA","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_013PXwtCfAvmjd2PdN5H8bW6","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01AVs6PQba9wWSfrWQZXyQYG","type":"tool_result","content":"t_ba897e63eae8_170"},{"tool_use_id":"toolu_01UCGWVeimBogdifPw11a36h","type":"tool_result","content":"t_ba897e63eae8_170"},{"type":"text","text":"\nt_34b1cd86ae33_531\n"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_925482fb64"},{"type":"thinking","thinking":"","signature":"sig_d422aa4c94"},{"type":"tool_use","id":"toolu_01RyRkyqiLf7YkKFUR4HDt94","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01RyRkyqiLf7YkKFUR4HDt94","type":"tool_result","content":"t_a4c3ed04a95a_4","is_error":false}]},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_015wDfsgtDLmonr9NfXq93P3","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_015wDfsgtDLmonr9NfXq93P3","type":"tool_result","content":"t_9c9ef2601e72_400","is_error":false}]},{"role":"system","content":"t_761b3cbdfacf_549"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_ad1f8a8dd5"},{"type":"thinking","thinking":"","signature":"sig_e7a2ea3499"},{"type":"tool_use","id":"toolu_016S8uAqoSZWirARGgqkmoJw","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01XSvieRnr9pqzZKE2LzBgtk","name":"Skill","input":{"skill":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_016S8uAqoSZWirARGgqkmoJw","type":"tool_result","content":"t_d85f661bc0ac_277","is_error":false},{"type":"tool_result","tool_use_id":"toolu_01XSvieRnr9pqzZKE2LzBgtk","content":"t_f9457ee1098c_40"},{"type":"text","text":"t_12a3a110fdc9_79"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_cb305b31ff"},{"type":"tool_use","id":"toolu_01XGXk5VGpTJUctCmDKvMET8","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_015gzfsS5BtuGh8vjUdEc89G","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01XGXk5VGpTJUctCmDKvMET8","type":"tool_result","content":"t_a434e762acd8_898","is_error":false},{"tool_use_id":"toolu_015gzfsS5BtuGh8vjUdEc89G","type":"tool_result","content":"t_78bcc15f3ebf_1830"}]},{"role":"system","content":"t_d7b1e0351111_421"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_2aa7391b4b"},{"type":"thinking","thinking":"","signature":"sig_80910bcbcd"},{"type":"tool_use","id":"toolu_016UCTma8gte2bm1RDnvBNsm","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_016UCTma8gte2bm1RDnvBNsm","type":"tool_result","content":"t_d8375a331f8e_501","is_error":false}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_e5200cdf8d"},{"type":"tool_use","id":"toolu_01NroghdukBXAUB9aJXBsU66","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01NroghdukBXAUB9aJXBsU66","type":"tool_result","content":"t_71c03188d389_1152"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_24b8f5d36d"},{"type":"thinking","thinking":"","signature":"sig_37db1cd62a"},{"type":"tool_use","id":"toolu_01WyHhy12Q7Gzwuyh2Qs4KFs","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01DPhhgCPQw6LyrkWRT8sRe8","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01A7kTMWfWoA7G4GvhnhUNLr","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01JC9UNgku9wV23DLj6x7VcE","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01WyHhy12Q7Gzwuyh2Qs4KFs","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01DPhhgCPQw6LyrkWRT8sRe8","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01A7kTMWfWoA7G4GvhnhUNLr","type":"tool_result","content":"t_ba897e63eae8_170"},{"tool_use_id":"toolu_01JC9UNgku9wV23DLj6x7VcE","type":"tool_result","content":"t_e8d78d2f042f_178"}]},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_0112JUfUnke5ruaZdGppEqgJ","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_0112JUfUnke5ruaZdGppEqgJ","type":"tool_result","content":"t_0873712e4d2e_390","is_error":false,"cache_control":{"type":"ephemeral","ttl":"1h"}}]}]}},{"ts":"2000-01-01T00:20:43.838Z","sid":"s-da07bb2d3cbe","key":"s-4b6a435234bf","headers":{"anthropic-beta":"oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,extended-cache-ttl-2025-04-11"},"body":{"model":"claude-haiku-4-5-20251001","system":[{"type":"text","text":"t_3865dedc8082_16612","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}}],"messages":[{"role":"user","content":"t_016862370920_2387"}]}},{"ts":"2000-01-01T00:22:56.534Z","sid":"s-da07bb2d3cbe","key":"s-4b6a435234bf","headers":{"anthropic-beta":"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24,server-side-fallback-2026-06-01,fallback-credit-2026-06-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07"},"body":{"model":"claude-fable-5","system":[{"type":"text","text":"t_2719b7a469d9_57"},{"type":"text","text":"t_3b27271fa44c_1210","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}},{"type":"text","text":"t_4de4f7d57b20_9708","cache_control":{"type":"ephemeral","ttl":"1h"}}],"tools":[{"name":"Agent"},{"name":"Artifact"},{"name":"Bash"},{"name":"Edit"},{"name":"Read"},{"name":"ReportFindings"},{"name":"ScheduleWakeup"},{"name":"SendUserFile"},{"name":"Skill"},{"name":"ToolSearch"},{"name":"DeferredToolPlaceholder"},{"name":"Write"}],"messages":[{"role":"user","content":[{"type":"text","text":"\nt_8bb46e7570ef_34698\n"},{"type":"text","text":"t_91e9f2c09173_1364"}]},{"role":"system","content":"t_734a76861fca_39386"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_576b70b210"},{"type":"text","text":"t_0c28330aea49_200"},{"type":"tool_use","id":"toolu_016R2CkQNiPF7pGntpksfxTf","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01FWjwDMpf7q9tddgCNqBvXL","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_016R2CkQNiPF7pGntpksfxTf","type":"tool_result","content":"t_df0a8efd9614_2506","is_error":false},{"tool_use_id":"toolu_01FWjwDMpf7q9tddgCNqBvXL","type":"tool_result","content":"t_dc06c8d17374_14374"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_364ad2c018"},{"type":"text","text":"t_00cd0ec3829f_5343"}]},{"role":"user","content":"t_78dbf4bd3bd0_231"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_63b8e0269b"},{"type":"text","text":"t_ef337775171d_228"},{"type":"tool_use","id":"toolu_01RMLeqyR4mbwSVcTPtnTVoL","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01RMLeqyR4mbwSVcTPtnTVoL","type":"tool_result","content":"t_9c4f2b243c1b_572","is_error":false}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_2d4eefac4e"},{"type":"tool_use","id":"toolu_01NBfTRtLeYZvAFzG2tRAC5f","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_011tFFjyRNWbwcMDp5edgRGU","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01NBfTRtLeYZvAFzG2tRAC5f","type":"tool_result","content":"t_ddf8e49ad427_28199","is_error":false},{"tool_use_id":"toolu_011tFFjyRNWbwcMDp5edgRGU","type":"tool_result","content":"t_36beb1e63eed_661","is_error":false}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_502b9777df"},{"type":"thinking","thinking":"","signature":"sig_61560470ab"},{"type":"tool_use","id":"toolu_01N3nZy9AZj6iBW4susq4mAc","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01N3nZy9AZj6iBW4susq4mAc","type":"tool_result","content":"t_2d44eeee3ef5_286","is_error":false}]},{"role":"system","content":"t_2a857b007c9d_609"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_68c2e7125a"},{"type":"thinking","thinking":"","signature":"sig_a87bccd2b8"},{"type":"tool_use","id":"toolu_0156epNw8kruUYz7VsWWWwFC","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01SwzvLxwNvJwG9ErivQSaA4","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_0156epNw8kruUYz7VsWWWwFC","type":"tool_result","content":"t_4e62bebf2aa6_2576"},{"tool_use_id":"toolu_01SwzvLxwNvJwG9ErivQSaA4","type":"tool_result","content":"t_3059b7a5a506_2855"}]},{"role":"system","content":"t_d7b1e0351111_421"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_3f79a3e42f"},{"type":"thinking","thinking":"","signature":"sig_80a8dcadbf"},{"type":"tool_use","id":"toolu_01SMqgSHXQ2CbgFjfxGujYRM","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01Vtz5jJemomLfGwzFcyaSpA","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"type":"tool_result","content":"t_7f52dbb9e3c5_885","is_error":true,"tool_use_id":"toolu_01SMqgSHXQ2CbgFjfxGujYRM"},{"type":"tool_result","content":"t_7f52dbb9e3c5_885","is_error":true,"tool_use_id":"toolu_01Vtz5jJemomLfGwzFcyaSpA"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_54b0f2c1c6"},{"type":"thinking","thinking":"","signature":"sig_a83b5e9ad2"},{"type":"tool_use","id":"toolu_01AkcfiiPoMBVkeYLPNKBVet","name":"Skill","input":{"skill":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01AkcfiiPoMBVkeYLPNKBVet","content":"t_f9457ee1098c_40"},{"type":"text","text":"t_129248f2aa12_3984"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_a5f0a664bf"},{"type":"tool_use","id":"toolu_0134rrs9Az4m7ZThYMP517VC","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01MFNdgHV1VrsQBQmQzpkDbf","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01MFNdgHV1VrsQBQmQzpkDbf","type":"tool_result","content":"t_91910cc63e61_15587"},{"tool_use_id":"toolu_0134rrs9Az4m7ZThYMP517VC","type":"tool_result","content":"t_25bd6cdc6f7b_31851"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_768a1dd44f"},{"type":"thinking","thinking":"","signature":"sig_109d0b84b3"},{"type":"tool_use","id":"toolu_019wDa7xTeUVA5wf5fkuQxht","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01GWio9hMHokoVLVxAW4VfLD","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"type":"tool_result","content":"t_6f4913622d4e_725","is_error":true,"tool_use_id":"toolu_019wDa7xTeUVA5wf5fkuQxht"},{"type":"tool_result","content":"t_6f4913622d4e_725","is_error":true,"tool_use_id":"toolu_01GWio9hMHokoVLVxAW4VfLD"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_d861d2425d"},{"type":"thinking","thinking":"","signature":"sig_56b4959cb6"},{"type":"tool_use","id":"toolu_01AkhgY6i4i2gsxL7sjhX85u","name":"Read","input":{"file_path":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01AkhgY6i4i2gsxL7sjhX85u","type":"tool_result","content":"t_dc06c8d17374_14374"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_813e45e7c2"},{"type":"text","text":"t_68fea8c14093_56"},{"type":"tool_use","id":"toolu_01822EZtWARJ9CrsQ1mkbB6M","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01PhBrV4kXp67AWv3iWLYFuY","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01822EZtWARJ9CrsQ1mkbB6M","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01PhBrV4kXp67AWv3iWLYFuY","type":"tool_result","content":"t_ae4dbd9aface_158"}]},{"role":"system","content":"t_a5b7a924eeb2_3990"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_2714debf4e"},{"type":"thinking","thinking":"","signature":"sig_2631f7f623"},{"type":"tool_use","id":"toolu_014VrRSh3yK7t8UuebodSLeA","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_013PXwtCfAvmjd2PdN5H8bW6","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01AVs6PQba9wWSfrWQZXyQYG","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01UCGWVeimBogdifPw11a36h","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_014VrRSh3yK7t8UuebodSLeA","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_013PXwtCfAvmjd2PdN5H8bW6","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01AVs6PQba9wWSfrWQZXyQYG","type":"tool_result","content":"t_ba897e63eae8_170"},{"tool_use_id":"toolu_01UCGWVeimBogdifPw11a36h","type":"tool_result","content":"t_ba897e63eae8_170"}]},{"role":"system","content":"t_34b1cd86ae33_531"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_925482fb64"},{"type":"thinking","thinking":"","signature":"sig_d422aa4c94"},{"type":"tool_use","id":"toolu_01RyRkyqiLf7YkKFUR4HDt94","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01RyRkyqiLf7YkKFUR4HDt94","type":"tool_result","content":"t_a4c3ed04a95a_4","is_error":false}]},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_015wDfsgtDLmonr9NfXq93P3","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_015wDfsgtDLmonr9NfXq93P3","type":"tool_result","content":"t_9c9ef2601e72_400","is_error":false}]},{"role":"system","content":"t_761b3cbdfacf_549"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_ad1f8a8dd5"},{"type":"thinking","thinking":"","signature":"sig_e7a2ea3499"},{"type":"tool_use","id":"toolu_016S8uAqoSZWirARGgqkmoJw","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01XSvieRnr9pqzZKE2LzBgtk","name":"Skill","input":{"skill":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_016S8uAqoSZWirARGgqkmoJw","type":"tool_result","content":"t_d85f661bc0ac_277","is_error":false},{"type":"tool_result","tool_use_id":"toolu_01XSvieRnr9pqzZKE2LzBgtk","content":"t_f9457ee1098c_40"},{"type":"text","text":"t_12a3a110fdc9_79"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_cb305b31ff"},{"type":"tool_use","id":"toolu_01XGXk5VGpTJUctCmDKvMET8","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_015gzfsS5BtuGh8vjUdEc89G","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01XGXk5VGpTJUctCmDKvMET8","type":"tool_result","content":"t_a434e762acd8_898","is_error":false},{"tool_use_id":"toolu_015gzfsS5BtuGh8vjUdEc89G","type":"tool_result","content":"t_78bcc15f3ebf_1830"}]},{"role":"system","content":"t_d7b1e0351111_421"},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_2aa7391b4b"},{"type":"thinking","thinking":"","signature":"sig_80910bcbcd"},{"type":"tool_use","id":"toolu_016UCTma8gte2bm1RDnvBNsm","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_016UCTma8gte2bm1RDnvBNsm","type":"tool_result","content":"t_d8375a331f8e_501","is_error":false}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_e5200cdf8d"},{"type":"tool_use","id":"toolu_01NroghdukBXAUB9aJXBsU66","name":"Read","input":{"file_path":"REDACTED","offset":"REDACTED","limit":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01NroghdukBXAUB9aJXBsU66","type":"tool_result","content":"t_71c03188d389_1152"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_24b8f5d36d"},{"type":"thinking","thinking":"","signature":"sig_37db1cd62a"},{"type":"tool_use","id":"toolu_01WyHhy12Q7Gzwuyh2Qs4KFs","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01DPhhgCPQw6LyrkWRT8sRe8","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01A7kTMWfWoA7G4GvhnhUNLr","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}},{"type":"tool_use","id":"toolu_01JC9UNgku9wV23DLj6x7VcE","name":"Edit","input":{"replace_all":"REDACTED","file_path":"REDACTED","old_string":"REDACTED","new_string":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_01WyHhy12Q7Gzwuyh2Qs4KFs","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01DPhhgCPQw6LyrkWRT8sRe8","type":"tool_result","content":"t_ae4dbd9aface_158"},{"tool_use_id":"toolu_01A7kTMWfWoA7G4GvhnhUNLr","type":"tool_result","content":"t_ba897e63eae8_170"},{"tool_use_id":"toolu_01JC9UNgku9wV23DLj6x7VcE","type":"tool_result","content":"t_e8d78d2f042f_178"}]},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_0112JUfUnke5ruaZdGppEqgJ","name":"Bash","input":{"command":"REDACTED","description":"REDACTED"},"caller":{"type":"direct"}}]},{"role":"user","content":[{"tool_use_id":"toolu_0112JUfUnke5ruaZdGppEqgJ","type":"tool_result","content":"t_0873712e4d2e_390","is_error":false}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"sig_a4df1effcf"},{"type":"text","text":"t_7d2e2ab481aa_3689"}]},{"role":"user","content":[{"type":"text","text":"t_8da3c6e66d33_196","cache_control":{"type":"ephemeral","ttl":"1h"}}]}]}}]} 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/insertion-merge-suppression.test.mjs b/test/insertion-merge-suppression.test.mjs new file mode 100644 index 00000000..282ec93b --- /dev/null +++ b/test/insertion-merge-suppression.test.mjs @@ -0,0 +1,265 @@ +// insertion-merge-suppression — the merged-standalone shape (587k window, +// capture s-633915a8, msg864). Sibling to insertion-suppression.test.mjs's +// single-block case, but here CC migrates ALL of a message's volatile +// blocks out TOGETHER, joined into one standalone message, rather than one +// standalone per block. The single-block pinnedHashes set can never match +// that shape (it hashes one block at a time); this file exercises the +// join-hash set added alongside it (pinnedJoinHashes / findSuppressibleDuplicate's +// third argument). +// +// Design settled by the dispatcher after the 587k premise was corrected +// (BACKLOG.md, "merged-reminder standalone, join-hash design settled"): for +// each pinned entry with >=2 volatile blocks, also hash the concatenation of +// ALL its volatile blocks' wrapper-stripped texts, in WIRE order, joined +// with "\n\n" — the exact separator measured on the real merged standalone. +// No subset-merges, no other separators — this is the one observed shape, +// not a general N-ary merge grammar. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + classifyPinned, + pinnedBlockHashes, + pinnedJoinHashes, + findSuppressibleDuplicate, +} from "../proxy/extensions/insertion-normalization.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = join(__dirname, "fixtures", "harvested", "oscillation-s-4b6a435234bf-863.json"); +const fixture = JSON.parse(readFileSync(FIXTURE_PATH, "utf-8")); + +// The real msg863 form (1243B-shaped: tool_result + two +// blocks, PreToolUse and PostToolUse) and the real msg864 merged standalone +// (627 chars, both reminders wrapper-stripped and joined with "\n\n") — +// pulled from the fixture rather than retyped, so the bite is the actual +// measured bytes, not a paraphrase of them. +const REAL_MSG863 = fixture.requests[0].msg863; +const REAL_MERGED_STANDALONE = fixture.requests_864.find((r) => r.msg864.role === "system").msg864; + +// --- Helpers (mirrors test/insertion-suppression.test.mjs's idiom) --- + +function assistantToolUse(id) { + return { role: "assistant", content: [{ type: "tool_use", id, name: "Agent", input: {} }] }; +} + +function userMsg(text) { + return { role: "user", content: [{ type: "text", text }] }; +} + +const REMINDER_PRE = "\nPreToolUse: first reminder\n"; +const REMINDER_POST = "\nPostToolUse: second reminder\n"; + +function withTwoReminders(text) { + return { + role: "user", + content: [ + { type: "text", text }, + { type: "text", text: REMINDER_PRE }, + { type: "text", text: REMINDER_POST }, + ], + }; +} + +function pinCanon(messages) { + return classifyPinned(messages, null).canonicalEntries; +} + +// ===================================================================== +// (a) Bite from the REAL fixture bytes +// ===================================================================== + +test("RED against the old (2-arg) call: the real merged standalone does not match single-block hashes alone", () => { + // The tool_use id in the real fixture's msg863 pairs it with an + // assistant Agent-spawn — reproduced here only so classifyPinned's + // adjacency check accepts the array; the message content itself is the + // fixture's own, unmodified. + const toolUseId = REAL_MSG863.content[0].tool_use_id; + const canon = pinCanon([assistantToolUse(toolUseId), REAL_MSG863]); + const pinnedHashes = pinnedBlockHashes(canon); + + // Old call shape (no third argument) — this is exactly what production + // ran before the join-hash set existed, and it is what left + // suppressed:0 across all 560 events of the real session. + const h = findSuppressibleDuplicate(REAL_MERGED_STANDALONE, pinnedHashes); + assert.equal(h, null, "single-block hashes alone must not match a merged standalone — this IS the observed gap"); +}); + +test("GREEN: the real merged standalone matches the join-hash of its pinned entry", () => { + const toolUseId = REAL_MSG863.content[0].tool_use_id; + const canon = pinCanon([assistantToolUse(toolUseId), REAL_MSG863]); + const pinnedHashes = pinnedBlockHashes(canon); + const joinHashes = pinnedJoinHashes(canon); + + assert.equal(joinHashes.size, 1, "msg863's entry has exactly 2 volatile blocks -> exactly one join hash"); + + const h = findSuppressibleDuplicate(REAL_MERGED_STANDALONE, pinnedHashes, joinHashes); + assert.notEqual(h, null, "the real merged standalone must be recognized as a suppressible duplicate"); +}); + +test("classifyPinned end-to-end: the real merged standalone is suppressed as a new entry, not forwarded twice (MID-HISTORY — matches the real capture, which had dozens of messages after msg864)", () => { + const toolUseId = REAL_MSG863.content[0].tool_use_id; + const canon = pinCanon([assistantToolUse(toolUseId), REAL_MSG863]); + + // A trailing turn after the standalone, so it sits at a genuine + // mid-history position (index 2 of 4) rather than the array's final + // index — the real capture had ~57 more messages after msg864. The + // tail-guard test below covers the DIFFERENT real incident where the + // duplicate IS the final message. + const messages = [ + assistantToolUse(toolUseId), + REAL_MSG863, + { ...REAL_MERGED_STANDALONE }, + { role: "assistant", content: [{ type: "text", text: "a-after" }] }, + ]; + const result = classifyPinned(messages, canon); + + assert.equal(result.suppressed, 1, "the merged standalone must be counted as a suppression"); + assert.equal(result.suppressions.length, 1); + assert.equal(result.suppressions[0].index, 2); + // The pinned inline form (index 1) already carries both reminders; the + // standalone must not also appear in the forwarded array. + assert.equal(result.messages.length, 3, "the standalone must not be forwarded alongside the pinned inline form"); +}); + +// ===================================================================== +// TAIL GUARD (BACKLOG.md, "suppression can strip a request's FINAL +// message", 2026-07-30) — the join-hash-specific case. Three real 400s +// traced to suppression removing a resume request's ONLY/new final +// message, leaving the forwarded array ending on the prior assistant +// turn -> upstream "must end with a user message". A tail-position +// duplicate is the request's live payload, not a stray migration copy. +// ===================================================================== + +test("TAIL GUARD: the real merged standalone as the FINAL message is never suppressed", () => { + const toolUseId = REAL_MSG863.content[0].tool_use_id; + const canon = pinCanon([assistantToolUse(toolUseId), REAL_MSG863]); + + const messages = [assistantToolUse(toolUseId), REAL_MSG863, { ...REAL_MERGED_STANDALONE }]; + const result = classifyPinned(messages, canon); + + assert.equal(result.suppressed, 0, "a final-position merged duplicate must be forwarded, not suppressed"); + assert.equal(result.messages.length, 3, "the standalone must remain on the wire as the live final message"); + assert.equal( + result.messages[result.messages.length - 1].content, + REAL_MERGED_STANDALONE.content, + "the final message content is unchanged", + ); +}); + +// ===================================================================== +// (b) Regression: single-reminder standalone still matches (unchanged path) +// ===================================================================== + +test("REGRESSION: a single-reminder standalone still matches via pinnedHashes even though joinHashes is now also passed", () => { + const REMINDER_INNER = "PreToolUse:Edit hook additional context: file changed"; + const REMINDER = `\n${REMINDER_INNER}\n`; + const singleReminderMsg = { + role: "user", + content: [ + { type: "text", text: "tool result" }, + { type: "text", text: REMINDER }, + ], + }; + const canon = pinCanon([singleReminderMsg, { role: "assistant", content: [{ type: "text", text: "a1" }] }]); + const pinnedHashes = pinnedBlockHashes(canon); + const joinHashes = pinnedJoinHashes(canon); + + assert.equal(joinHashes.size, 0, "a single volatile block never produces a join hash"); + + const standalone = { role: "system", content: [{ type: "text", text: REMINDER_INNER }] }; + const h = findSuppressibleDuplicate(standalone, pinnedHashes, joinHashes); + assert.notEqual(h, null, "the existing single-block suppression path must be unaffected"); +}); + +// ===================================================================== +// (c) Guard: a join of blocks from TWO DIFFERENT entries is NOT suppressed +// ===================================================================== + +test("GUARD: concatenating volatile blocks from two DIFFERENT pinned entries does not suppress — identity is per-entry", () => { + // Two separate messages, each carrying exactly ONE of the two reminders + // (as opposed to withTwoReminders, which puts both on the SAME entry). + const entryA = { + role: "user", + content: [{ type: "text", text: "result A" }, { type: "text", text: REMINDER_PRE }], + }; + const entryB = { + role: "user", + content: [{ type: "text", text: "result B" }, { type: "text", text: REMINDER_POST }], + }; + const canon = pinCanon([ + entryA, + { role: "assistant", content: [{ type: "text", text: "a1" }] }, + entryB, + { role: "assistant", content: [{ type: "text", text: "a2" }] }, + ]); + const pinnedHashes = pinnedBlockHashes(canon); + const joinHashes = pinnedJoinHashes(canon); + + assert.equal(joinHashes.size, 0, "neither entry has >=2 volatile blocks of its own -> no join hash from either"); + + // A candidate that tries to forge the join by pasting bytes from BOTH + // entries together. + const forged = { + role: "system", + content: "PreToolUse: first reminder\n\nPostToolUse: second reminder", + }; + const h = findSuppressibleDuplicate(forged, pinnedHashes, joinHashes); + assert.equal(h, null, "a cross-entry concatenation must never be treated as a suppressible duplicate"); +}); + +// ===================================================================== +// (d) Guard: a genuinely different concatenation is NOT suppressed +// ===================================================================== + +test("GUARD: wrong order, wrong separator, or extra content — none of them suppress", () => { + const canon = pinCanon([withTwoReminders("tool result"), { role: "assistant", content: [{ type: "text", text: "a1" }] }]); + const pinnedHashes = pinnedBlockHashes(canon); + const joinHashes = pinnedJoinHashes(canon); + assert.equal(joinHashes.size, 1); + + const reversedOrder = { + role: "system", + content: "PostToolUse: second reminder\n\nPreToolUse: first reminder", + }; + assert.equal(findSuppressibleDuplicate(reversedOrder, pinnedHashes, joinHashes), null, "reversed order must not match"); + + const wrongSeparator = { + role: "system", + content: "PreToolUse: first reminder\nPostToolUse: second reminder", + }; + assert.equal( + findSuppressibleDuplicate(wrongSeparator, pinnedHashes, joinHashes), + null, + "a single-newline join (unobserved separator) must not match", + ); + + const extraContent = { + role: "system", + content: "PreToolUse: first reminder\n\nPostToolUse: second reminder\n\nextra", + }; + assert.equal(findSuppressibleDuplicate(extraContent, pinnedHashes, joinHashes), null, "extra trailing content must not match"); +}); + +// ===================================================================== +// pinnedJoinHashes unit bites (mirrors pinnedBlockHashes's own tests) +// ===================================================================== + +test("pinnedJoinHashes: a dropped entry's join is excluded — its content is not being served anywhere", () => { + const canon1 = pinCanon([ + withTwoReminders("tool result"), + { role: "assistant", content: [{ type: "text", text: "a1" }] }, + userMsg("u2"), + { role: "assistant", content: [{ type: "text", text: "a3" }] }, + ]); + const pruned = classifyPinned( + [{ role: "assistant", content: [{ type: "text", text: "a1" }] }, userMsg("u2"), { role: "assistant", content: [{ type: "text", text: "a3" }] }, userMsg("tail")], + canon1, + ); + assert.equal(pruned.dropped, 1); + const joinHashes = pinnedJoinHashes(pruned.canonicalEntries); + assert.equal(joinHashes.size, 0, "a dropped pin's join must not be treated as currently live"); +}); 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/insertion-suppression.test.mjs b/test/insertion-suppression.test.mjs new file mode 100644 index 00000000..c88c296e --- /dev/null +++ b/test/insertion-suppression.test.mjs @@ -0,0 +1,500 @@ +// insertion-suppression — pin-and-suppress (#76606, decision B; BACKLOG.md +// entry "Reminder-swap (#76606): DECIDED — pin-and-suppress", part (c)). +// +// The defect this closes: insertion-normalization's positional rebuild +// restores a pinned message's first-seen bytes (reminder included) AND +// forwards CC's migrated standalone duplicate of that same reminder as a +// new entry — measured directly on capture s-633915a8, pair n=26->28: +// message[30]'s -wrapped block, absent from CC's own +// message[30] on the n=28 side, reappears wrapper-stripped as the entire +// content of CC's new message[31] (role system). The pin restores it +// inline at 30; the extension ALSO forwards the standalone copy at 31 — +// carrying the reminder twice and splicing the array, which moves the +// cache's longest-identical-prefix boundary to right before 31 and +// re-bills everything after it (outcome record: cacheRead 15424 / +// cacheCreation 124025). +// +// The fix: when a NEW entry is standalone (single block after the same +// string->one-block fold canonicalMessageShape already applies) and its +// wrapper-stripped bytes equal a block inside a message this extension is +// currently pinning, suppress it from the forwarded array — the pinned +// inline form already carries those bytes. A standalone message whose +// normalized bytes differ from every pinned block is untouched: existing +// rules (append/splice/edit-shaped reset) apply exactly as before. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir, homedir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + classifyPinned, + pinnedBlockHashes, + findSuppressibleDuplicate, +} from "../proxy/extensions/insertion-normalization.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = join(__dirname, ".."); +const EXT_DIR = join(REPO, "proxy", "extensions"); +const EXT_CONFIG = join(REPO, "proxy", "extensions.json"); + +// --- Helpers (mirrors test/insertion-normalization.test.mjs's idiom) --- + +function userMsg(text) { + return { role: "user", content: [{ type: "text", text }] }; +} +function assistantMsg(text) { + return { role: "assistant", content: [{ type: "text", text }] }; +} + +const REMINDER_INNER = "PreToolUse:Edit hook additional context: file changed"; +const REMINDER = `\n${REMINDER_INNER}\n`; + +function withReminderMsg(text) { + return { + role: "user", + content: [ + { type: "text", text }, + { type: "text", text: REMINDER }, + ], + }; +} + +function pinCanon(messages) { + return classifyPinned(messages, null).canonicalEntries; +} + +// ===================================================================== +// (iii) Unit bites — the identity match itself (wrapper-stripped equality) +// ===================================================================== + +test("pinnedBlockHashes: a live pinned entry's volatile block is present, wrapper stripped", () => { + const canon = pinCanon([withReminderMsg("tool result"), assistantMsg("a1")]); + const hashes = pinnedBlockHashes(canon); + assert.equal(hashes.size, 1); +}); + +test("pinnedBlockHashes: a DROPPED entry's block is excluded — its content is not being served anywhere", () => { + // Build canonical with the reminder-bearing message, then a request that + // prunes it (context-management removal) so it becomes a dropped entry. + const canon1 = pinCanon([withReminderMsg("tool result"), assistantMsg("a1"), userMsg("u2"), assistantMsg("a3")]); + const pruned = classifyPinned( + [assistantMsg("a1"), userMsg("u2"), assistantMsg("a3"), userMsg("tail")], + canon1, + ); + assert.equal(pruned.dropped, 1); + const hashes = pinnedBlockHashes(pruned.canonicalEntries); + assert.equal(hashes.size, 0, "a dropped pin must not be treated as currently live"); +}); + +test("findSuppressibleDuplicate: matches a standalone message whose UNWRAPPED bytes equal a pinned block — the wrapper difference is exactly what wrapper-normalization exists to absorb", () => { + const canon = pinCanon([withReminderMsg("tool result"), assistantMsg("a1")]); + const hashes = pinnedBlockHashes(canon); + const standalone = { role: "system", content: [{ type: "text", text: REMINDER_INNER }] }; + const h = findSuppressibleDuplicate(standalone, hashes); + assert.notEqual(h, null); +}); + +test("findSuppressibleDuplicate: returns null for a non-standalone (multi-block) message even when one block matches — the definition is STANDALONE only", () => { + const canon = pinCanon([withReminderMsg("tool result"), assistantMsg("a1")]); + const hashes = pinnedBlockHashes(canon); + const inline = { + role: "system", + content: [{ type: "text", text: "other" }, { type: "text", text: REMINDER_INNER }], + }; + assert.equal(findSuppressibleDuplicate(inline, hashes), null); +}); + +test("findSuppressibleDuplicate: returns null when the normalized bytes genuinely differ — never a fuzzy match", () => { + const canon = pinCanon([withReminderMsg("tool result"), assistantMsg("a1")]); + const hashes = pinnedBlockHashes(canon); + const standalone = { role: "system", content: [{ type: "text", text: "an unrelated system note" }] }; + assert.equal(findSuppressibleDuplicate(standalone, hashes), null); +}); + +test("findSuppressibleDuplicate: still-wrapped standalone bytes match too (identity is on the UNWRAPPED form on both sides)", () => { + const canon = pinCanon([withReminderMsg("tool result"), assistantMsg("a1")]); + const hashes = pinnedBlockHashes(canon); + const standaloneStillWrapped = { role: "system", content: [{ type: "text", text: REMINDER }] }; + assert.notEqual(findSuppressibleDuplicate(standaloneStillWrapped, hashes), null); +}); + +// ===================================================================== +// (ii) classifyPinned — suppression behavior, and the genuine-change guard +// ===================================================================== + +test("classifyPinned: a standalone duplicate of a pinned block is suppressed; the pinned inline form still forwards", () => { + const orig = [withReminderMsg("tool result"), assistantMsg("a1")]; + const canon = pinCanon(orig); + + // CC's next request: the reminder is gone from the tool_result message + // and reappears, wrapper stripped, as a new standalone message. + const strippedTail = { role: "user", content: [{ type: "text", text: "tool result" }] }; + const standaloneDuplicate = { role: "system", content: [{ type: "text", text: REMINDER_INNER }] }; + const next = [strippedTail, assistantMsg("a1"), standaloneDuplicate, userMsg("continue")]; + + const result = classifyPinned(next, canon); + assert.equal(result.action, "normalized"); + // Pinned inline form restored at position 0 — reminder included. + assert.deepEqual(result.messages[0], orig[0]); + // The standalone duplicate never appears in the forwarded array. + assert.ok( + !result.messages.some((m) => JSON.stringify(m) === JSON.stringify(standaloneDuplicate)), + "the migrated duplicate must not be forwarded a second time", + ); + assert.equal(result.messages.length, next.length - 1, "the array is one shorter — the duplicate, not a substitution"); + assert.equal(result.suppressed, 1); + assert.equal(result.suppressions.length, 1); + assert.equal(result.suppressions[0].index, 2, "the suppressed entry's index in the INCOMING array"); + // "continue" (tail growth after the duplicate) still forwards, at its + // shifted position — suppression removes only the duplicate, nothing else. + assert.deepEqual(result.messages[result.messages.length - 1], userMsg("continue")); +}); + +// ===================================================================== +// TAIL GUARD (BACKLOG.md, "suppression can strip a request's FINAL +// message", 2026-07-30). Three real 400s ("must end with a user +// message"): report-enforcer injects identical instruction bytes at +// every SubagentStop; the first occurrence is pinned, and when the SAME +// bytes arrive again as a resume request's ONLY/new final message, +// suppressing it left the forwarded array ending on the prior assistant +// turn. A tail-position duplicate is CC's live payload for THIS request, +// not a migration copy of already-pinned content, regardless of role or +// which hash set (single-block or join) matched it. +// ===================================================================== + +test("TAIL GUARD: a standalone duplicate at the FINAL index is never suppressed — it is live payload, not a migration", () => { + const orig = [withReminderMsg("tool result"), assistantMsg("a1")]; + const canon = pinCanon(orig); + + const strippedTail = { role: "user", content: [{ type: "text", text: "tool result" }] }; + const standaloneDuplicate = { role: "system", content: [{ type: "text", text: REMINDER_INNER }] }; + // No trailing entry after the duplicate — it IS the array's final + // message, mirroring the real resume-request shape. + const next = [strippedTail, assistantMsg("a1"), standaloneDuplicate]; + + const result = classifyPinned(next, canon); + assert.equal(result.suppressed, 0, "a final-position duplicate must never be suppressed"); + assert.equal(result.suppressions.length, 0); + assert.deepEqual( + result.messages[result.messages.length - 1], + standaloneDuplicate, + "the final message must be forwarded intact — this is exactly what would otherwise strip a resume's last turn", + ); +}); + +test("REGRESSION: the same standalone duplicate, mid-history (not final), is still suppressed", () => { + const orig = [withReminderMsg("tool result"), assistantMsg("a1")]; + const canon = pinCanon(orig); + + const strippedTail = { role: "user", content: [{ type: "text", text: "tool result" }] }; + const standaloneDuplicate = { role: "system", content: [{ type: "text", text: REMINDER_INNER }] }; + // Same duplicate, same position (index 2) as the tail-guard test above, + // but with a trailing turn after it — no longer the final index. + const next = [strippedTail, assistantMsg("a1"), standaloneDuplicate, userMsg("continue")]; + + const result = classifyPinned(next, canon); + assert.equal(result.suppressed, 1, "mid-history duplicates are suppressed exactly as before the tail guard"); + assert.equal(result.suppressions[0].index, 2); +}); + +test("classifyPinned: suppression is stable across a THIRD request — CC keeps resending the duplicate, it keeps getting suppressed, with no persisted marker needed", () => { + const orig = [withReminderMsg("tool result"), assistantMsg("a1")]; + let canon = pinCanon(orig); + + const strippedTail = { role: "user", content: [{ type: "text", text: "tool result" }] }; + const standaloneDuplicate = { role: "system", content: [{ type: "text", text: REMINDER_INNER }] }; + const r2 = [strippedTail, assistantMsg("a1"), standaloneDuplicate, userMsg("continue")]; + const res2 = classifyPinned(r2, canon); + assert.equal(res2.suppressed, 1); + canon = res2.canonicalEntries; + + // CC believes the duplicate is part of history now and keeps sending it, + // plus new tail growth. + const r3 = [strippedTail, assistantMsg("a1"), standaloneDuplicate, userMsg("continue"), assistantMsg("a2")]; + const res3 = classifyPinned(r3, canon); + assert.equal(res3.suppressed, 1, "re-detected and re-suppressed on every later request that still carries it"); + assert.ok(!res3.messages.some((m) => JSON.stringify(m) === JSON.stringify(standaloneDuplicate))); +}); + +// Genuine change: the brief's own scenario for this is the EXISTING +// drop+co-located-splice "edit-shaped" reset (test/insertion-normalization +// .test.mjs already covers the discriminator itself) — the load-bearing +// property here is that a standalone message whose bytes genuinely differ +// from every pinned block does not get silently swallowed by the +// suppression path; the pre-existing reset rule still applies unchanged. +test("classifyPinned: a standalone message that does NOT match any pinned block still resets when the underlying change is edit-shaped — suppression does not mask a genuine edit (fires-on-non-defect guard)", () => { + const orig = [withReminderMsg("tool result"), assistantMsg("a1"), userMsg("original"), assistantMsg("a3")]; + const canon = pinCanon(orig); + // "original" is dropped; "REPLACEMENT" — standalone in SHAPE but + // textually unrelated to the pinned reminder — lands in the gap it left. + // Co-location makes this an edit (not an unrelated splice), and its + // normalized bytes differ from every pinned block. + const edited = [orig[0], assistantMsg("a1"), userMsg("REPLACEMENT"), assistantMsg("a3")]; + const result = classifyPinned(edited, canon); + assert.equal(result.action, "reset"); + assert.equal(result.resetReason, "edit-shaped"); + assert.equal(result.suppressed ?? 0, 0, "a genuine edit must not be swallowed as a suppression"); +}); + +test("classifyPinned: a standalone message with unrelated content is simply forwarded — no suppression, no special-cased reset", () => { + const orig = [withReminderMsg("tool result"), assistantMsg("a1")]; + const canon = pinCanon(orig); + const differentStandalone = { role: "system", content: [{ type: "text", text: "an unrelated system note" }] }; + const next = [orig[0], assistantMsg("a1"), differentStandalone, userMsg("continue")]; + const result = classifyPinned(next, canon); + assert.notEqual(result.action, "reset"); + assert.equal(result.suppressed, 0); + assert.ok(result.messages.some((m) => JSON.stringify(m) === JSON.stringify(differentStandalone))); +}); + +test("classifyPinned: an assistant-role standalone entry is never suppressed, even if it coincidentally matches a pinned block's bytes", () => { + const orig = [withReminderMsg("tool result"), assistantMsg("a1")]; + const canon = pinCanon(orig); + // Constructed only to exercise the exclusion — an assistant message never + // legitimately duplicates a hook reminder this way in practice. + const assistantDuplicate = { role: "assistant", content: [{ type: "text", text: REMINDER_INNER }] }; + const next = [orig[0], assistantMsg("a1"), assistantDuplicate]; + const result = classifyPinned(next, canon); + assert.equal(result.suppressed, 0); + assert.ok(result.messages.some((m) => JSON.stringify(m) === JSON.stringify(assistantDuplicate))); +}); + +// ===================================================================== +// (i) RED-GREEN on the real pair — capture s-633915a8, n=26->28 +// ===================================================================== +// +// Mirrors test/mitigation-output-form.test.mjs's real-capture harness +// exactly (same loadExtensions/runOnRequest machinery, same boot-record +// gate set, same scratch CLAUDE_CONFIG_DIR, replayed from the start of the +// file so insertion-normalization's per-conversation canonical state is +// genuine) — not a re-derivation of it. That file's own real-pair test +// asserts the PRE-fix values (outputForm==="edit@31") and is NOT in this +// change's write boundary; running it after this fix is expected to fail, +// and that is surfaced in the closing report rather than fixed here. +// +// Fixture-fallback (BACKLOG.md "READY — harvest --pin freezes evidence +// ranges as fixtures"): capture rotated away -> fall back to the pinned +// fixture at test/fixtures/harvested/pinned-s-4b6a435234bf-26-28.json (`node +// tools/harvest.mjs --pin n..m`); both absent -> skip. Both paths are +// overridable via env for the fallback's own red-green test +// (test/harvest-pin.test.mjs) — never by editing the real capture, which is +// read-only evidence shared with other work. +const PINNED_FIXTURE = + process.env.CACHE_FIX_TEST_FIXTURE_OVERRIDE ?? + join(__dirname, "fixtures", "harvested", "pinned-s-4b6a435234bf-26-28.json"); +// The capture is NAMED WITHOUT BEING NAMED: the pinned fixture's header +// carries `s-` = sidToken(conversation key) for the capture it was +// frozen from, and every capture on disk is named `-requests.jsonl`, +// so the right file is recoverable by hashing the candidates rather than +// by hardcoding one — this repo is public, and a capture UUID plus a home +// path is a live identifier. The per-machine capture directory comes from +// homedir(), never a literal path; `sidToken` ships in the tools slice, +// so a tree without tools/ resolves no capture and the pinned fixture +// below is the source. +async function resolveRealCapture(fixturePath) { + if (process.env.CACHE_FIX_TEST_CAPTURE_OVERRIDE) return process.env.CACHE_FIX_TEST_CAPTURE_OVERRIDE; + let sidToken; + try { + ({ sidToken } = await import("../tools/harvest.mjs")); + } catch { + return ""; + } + let wanted; + try { + wanted = JSON.parse(readFileSync(fixturePath, "utf-8")).header?.key; + } catch { + return ""; + } + if (!wanted) return ""; + const dir = join(homedir(), ".claude", "cache-fix-captures"); + let names; + try { + names = readdirSync(dir); + } catch { + return ""; + } + const SUFFIX = "-requests.jsonl"; + for (const name of names) { + if (!name.endsWith(SUFFIX)) continue; + if (sidToken(name.slice(0, -SUFFIX.length)) === wanted) return join(dir, name); + } + return ""; +} +const REAL_CAPTURE = await resolveRealCapture(PINNED_FIXTURE); +const GATES = { + CACHE_FIX_FORWARD_PROXY: "on", + CACHE_FIX_SESSION_MIRROR: "on", + CACHE_FIX_PREFIXDIFF: "1", + CACHE_FIX_INSERTION_NORMALIZE: "1", + CACHE_FIX_VOLATILE_PIN: "1", + CACHE_FIX_TOOL_REWRITE: "1", + CACHE_FIX_UPSTREAM_DETECTION: "1", + CACHE_FIX_REQUEST_CAPTURE: "1", + CACHE_FIX_CAPTURE_MAX_MB: "8192", + CACHE_FIX_OUTPUT_GUARD: "1", +}; +const TARGET_N = 28; + +const entry = (n, inMsgs, outMsgs, extra = {}) => ({ + n, + ts: `2026-07-28T00:00:${String(n).padStart(2, "0")}Z`, + key: "k", + inMsgs, + outMsgs, + action: null, + resetReason: null, + ...extra, +}); + +test( + "real capture n=26->28: pin-and-suppress turns the input-mitigated/output-spliced pair into a clean append, safety gate 0 violations", + async (t) => { + // Fixture-fallback: capture present -> unchanged live-capture path; + // capture absent -> pinned fixture if present; else skip. Both readers + // yield the same [n, line] tuple shape, so the replay loop below is + // identical either way. The fixture reader ships in the tools slice + // (like replayTools below), so it loads dynamically — a tree without + // tools/ skips instead of failing at module load. + let readPinnedFixture; + try { + ({ readPinnedFixture } = await import("../tools/harvest.mjs")); + } catch { + readPinnedFixture = null; + } + let source; + if (existsSync(REAL_CAPTURE)) { + source = null; // resolved below, once readCapture is loaded from tools/replay.mjs + } else if (existsSync(PINNED_FIXTURE) && readPinnedFixture) { + source = readPinnedFixture(PINNED_FIXTURE); + } else { + t.skip( + `capture rotated away (not found at ${REAL_CAPTURE}) and no pinned fixture at ${PINNED_FIXTURE} — COULD NOT VERIFY`, + ); + return; + } + + // The census/gate helpers ship in the tools slice; in a tree carrying + // only the extension (upstream PR #272) this check rides #276 instead. + let replayTools; + try { + replayTools = await import("../tools/replay.mjs"); + } catch { + t.skip("tools/replay.mjs not in this tree — the real-pair check runs where the tools land"); + return; + } + const { findMitigationGaps, findSafetyViolations, safetyViolation, readCapture } = replayTools; + if (source === null) source = readCapture(REAL_CAPTURE); + + const scratch = await mkdtemp(join(tmpdir(), "insertion-suppression-")); + const saved = {}; + const overrides = { CLAUDE_CONFIG_DIR: scratch, ...GATES }; + for (const k of Object.keys(overrides)) { + saved[k] = process.env[k]; + process.env[k] = overrides[k]; + } + + const origStderr = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + try { + const { loadExtensions, runOnRequest } = await import( + pathToFileURL(join(REPO, "proxy", "pipeline.mjs")).href + ); + const extensions = await loadExtensions(EXT_DIR, EXT_CONFIG); + + const entries = []; + let reqN = -1; + for await (const [, line] of source) { + let rec; + try { + rec = JSON.parse(line); + } catch { + continue; + } + if (rec.type === "outcome" || rec.type === "boot") continue; + const n = ++reqN; + const body = structuredClone(rec.body); + const headers = { + "anthropic-beta": rec.headers?.["anthropic-beta"] ?? undefined, + "x-session-id": rec.headers?.["session-id"] ?? rec.sid ?? undefined, + }; + const ctx = { body, headers, meta: { route: "messages" } }; + await runOnRequest(ctx, extensions); + entries.push( + entry( + n, + Array.isArray(rec.body?.messages) ? rec.body.messages : [], + Array.isArray(ctx.body?.messages) ? ctx.body.messages : [], + { + key: rec.key, + ts: rec.ts, + action: ctx.meta.insertionNormalizeStats?.action ?? null, + resetReason: ctx.meta.insertionNormalizeStats?.resetReason ?? null, + stats: ctx.meta.insertionNormalizeStats ?? null, + }, + ), + ); + if (n === TARGET_N) break; + } + + const rows = findMitigationGaps(entries); + const row = rows.find((r) => r.n === 28 && r.prevN === 26); + assert.ok(row, "expected a mitigation row for pair n=26->28"); + + // Input-side self-report is untouched by this change. + assert.equal(row.mitigated, true); + assert.equal(row.rebilledBytes, 0); + // Output-side: PARTIALLY fixed, and the residual is independently + // explained, not left as an unexplained gap. Before this change: + // outputForm==="edit@31", ~61 kB rebilled (test/mitigation-output-form + // .test.mjs's real-pair test, unmodified, pins that prior state). + // After: the suppressed duplicate closes the divergence through index + // 47 (bytes 31-47 identical to n=26's own output for the first time), + // but a SECOND, unrelated divergence surfaces at 48 — ttl-management + // (order 500, a different extension, not touched by this change) + // relocates the ephemeral cache_control marker to the live tail on + // every growing turn; n=26's tail (its last message) carried the + // marker at 48, n=28's tail has grown past it, so the marker is + // simply gone from that position — a real byte difference this + // change was never going to close, verified by diffing the two + // messages directly (identical apart from the `cache_control` key). + // Residual bytes dropped from ~61 kB to ~5 kB (full-corpus census, + // both runs pasted in the closing report) — this change's actual, + // bounded contribution, not the BACKLOG entry's stated "outputForm + // === append" criterion, which this pair cannot reach while + // ttl-management's marker relocation exists. Surfaced as a gap. + // The only remaining delta is ttl-management's cache_control marker + // relocating off the old tail — since the outputForm metric strips + // cache_control (903a2be: a moved marker is not a content splice), + // the suppressed pair now reads fully preserved. A regression that + // reintroduces CONTENT divergence flips this to a non-append form. + assert.equal(row.outputForm, "append", "suppression + marker-blind metric: nothing but the marker moved"); + assert.equal(row.outputPreserved, true); + assert.equal(row.rebilledOutBytes, 0); + + // The n=28 entry itself: exactly one suppression, at the index the + // fidelity probe named (message[31] in the pre-fix pipeline). + const e28 = entries.find((e) => e.n === 28); + assert.equal(e28.stats?.suppressed, 1); + assert.equal(e28.stats?.suppressions?.[0]?.index, 31); + + // Safety gate: the declared exemption in tools/replay.mjs's + // safetyViolation must not count this suppression as a length + // corruption. Checked directly (not just via the aggregate zero) so + // a false negative elsewhere in findSafetyViolations can't hide a + // problem here. + assert.equal(safetyViolation(e28), null, "the exemption must fire on this exact suppression"); + const safety = findSafetyViolations(entries); + assert.equal(safety.length, 0, "declared exemption applied across the whole replayed prefix"); + } finally { + process.stderr.write = origStderr; + for (const k of Object.keys(saved)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } + }, +); diff --git a/test/proxy-messages-cache-breakpoint.test.mjs b/test/proxy-messages-cache-breakpoint.test.mjs deleted file mode 100644 index 36b7643a..00000000 --- a/test/proxy-messages-cache-breakpoint.test.mjs +++ /dev/null @@ -1,647 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import ext, { - classifyBlock, - detectAutoInjectedBoundary, - countAllCacheControlMarkers, - injectMessagesBreakpoint, - buildDumpRecord, -} from "../proxy/extensions/messages-cache-breakpoint.mjs"; - -// --- Fixture helpers --- -// -// Five baseline fixtures sourced from real CC traffic via the -// CACHE_FIX_DUMP_MESSAGES_HEAD diagnostic introduced by this directive. -// Each is the first ~200 chars of the actual block text wrapped to a complete -// signature so classifyBlock can match it without relying on length. -// -// Synthetic fixtures supplement the baselines for over-match guards and edge -// cases that don't appear in real traffic. - -function blockText(text) { - return { type: "text", text }; -} - -function blockImage() { - return { - type: "image", - source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" }, - }; -} - -// Real CC fixture #1: SessionStart resume hook -const FIX_HOOKS_SESSIONSTART_RESUME = blockText( - "SessionStart:resume hook success: continuing prior session\n - last activity: 2026-04-30T12:34:56Z\n", -); - -// Real CC fixture #2: PreToolUse hook -const FIX_HOOKS_PRETOOLUSE = blockText( - "PreToolUse hook success: validated tool inputs against settings allowlist", -); - -// Real CC fixture #3: skills listing -const FIX_SKILLS = blockText( - "The following skills are available:\n\n- claude-api: Build, debug, and optimize Claude API apps\n- coffee: Keep prompt cache warm\n", -); - -// Real CC fixture #4: project CLAUDE.md -const FIX_CLAUDE_MD = blockText( - "Contents of /repo/CLAUDE.md (project instructions, checked into the codebase):\n\n# CLAUDE.md — claude-code-cache-fix\n## Git Workflow\n- Do not push directly to main", -); - -// Real CC fixture #5: deferred-tools -const FIX_DEFERRED_TOOLS = blockText( - "\ntool-name-here\nanother-tool\n", -); - -// Real CC fixture #6: MCP via -const FIX_MCP_RESOURCES = blockText( - "\n resource description\n", -); - -// Real CC fixture #7: MCP via "Available MCP servers:" sentinel -const FIX_MCP_AVAILABLE = blockText( - "Available MCP servers:\n- llm-relay: CLI orchestration\n- gh: GitHub helpers\n", -); - -// Synthetic over-match guards (must classify as user) -const FIX_USER_QUOTING_AVAILABLE_SKILLS = blockText( - "I see you have in your output, but it should not be there", -); -const FIX_USER_RELATIVE_CLAUDE_MD = blockText( - "see also CLAUDE.md in the docs for more context", -); -const FIX_USER_DEFERRED_TOOLS_PROSE = blockText( - "the deferred tools feature is broken in version 3.2.1", -); -const FIX_USER_MCP_PROSE = blockText("I configured my MCP server yesterday and it works fine"); -const FIX_USER_HOOK_PROSE = blockText("the hook success message is in the logs"); - -const FIX_PLAIN_USER = blockText("Please look at this file and tell me what's wrong."); - -function userMsg(content) { - return { role: "user", content }; -} - -function assistantMsg(text) { - return { role: "assistant", content: [{ type: "text", text }] }; -} - -function makeBody({ messages, system } = {}) { - return { - model: "claude-opus-4-7", - messages: messages || [], - ...(system ? { system } : {}), - }; -} - -function systemBlocks({ tools = true, prompt = true } = {}) { - // Mimics CC's typical 2-marker system shape: marker after tools (#1) + - // marker after system prompt (#2). - const blocks = []; - if (tools) { - blocks.push({ type: "text", text: "tool registrations here" }); - blocks.push({ type: "text", text: "more tools", cache_control: { type: "ephemeral" } }); - } - if (prompt) { - blocks.push({ type: "text", text: "system prompt body", cache_control: { type: "ephemeral" } }); - } - return blocks; -} - -function withCacheControl(block) { - return { ...block, cache_control: { type: "ephemeral" } }; -} - -// Run the extension default export against a synthetic ctx. -async function runExt(body, { meta } = {}) { - const ctx = { body, meta: meta || {}, headers: {} }; - await ext.onRequest(ctx); - return ctx; -} - -// Helpers to drive env vars per-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]; - } - } -} - -// --- 1. Detection --- - -test("1. [skills, CLAUDE.md, user-text] → boundary at index 1 (CLAUDE.md)", () => { - const idx = detectAutoInjectedBoundary([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER]); - assert.equal(idx, 1); -}); - -test("2. [skills, deferred-tools, mcp-resources, CLAUDE.md, user-text] → boundary at 3", () => { - const idx = detectAutoInjectedBoundary([ - FIX_SKILLS, - FIX_DEFERRED_TOOLS, - FIX_MCP_RESOURCES, - FIX_CLAUDE_MD, - FIX_PLAIN_USER, - ]); - assert.equal(idx, 3); -}); - -test("3. [user-text only] → boundary -1, skip reason boundary_not_found", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_PLAIN_USER])], - }); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, false); - assert.equal(stats.boundary_idx, -1); - assert.equal(stats.skip_reason, "boundary_not_found"); -}); - -test("4. [skills only] → boundary 0, inject on the only block", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS])], - }); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, true); - assert.equal(stats.boundary_idx, 0); - assert.equal(stats.boundary_block_kind, "skills"); - assert.deepEqual(body.messages[0].content[0].cache_control, { type: "ephemeral", ttl: "1h" }); -}); - -test("5. interleaved auto-injected blocks → boundary at LAST auto-injected position", () => { - const idx = detectAutoInjectedBoundary([ - FIX_SKILLS, - FIX_PLAIN_USER, // user content interleaved (defensive — current CC doesn't do this) - FIX_CLAUDE_MD, // last auto-injected — boundary should land here - FIX_PLAIN_USER, - ]); - assert.equal(idx, 2); -}); - -test("5a. [skills, hooks, user-text] → boundary at index 1 (hooks). Hooks-taxonomy load-bearing test.", () => { - const idx = detectAutoInjectedBoundary([ - FIX_SKILLS, - FIX_HOOKS_SESSIONSTART_RESUME, - FIX_PLAIN_USER, - ]); - assert.equal(idx, 1); -}); - -test("5b. [hooks, skills, deferred-tools, mcp-resources, CLAUDE.md, user-text] → boundary at 4", () => { - const idx = detectAutoInjectedBoundary([ - FIX_HOOKS_SESSIONSTART_RESUME, - FIX_SKILLS, - FIX_DEFERRED_TOOLS, - FIX_MCP_RESOURCES, - FIX_CLAUDE_MD, - FIX_PLAIN_USER, - ]); - assert.equal(idx, 4); -}); - -test("5c. messages[0] is assistant role → skip with unexpected_role_or_shape", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [assistantMsg("hi"), userMsg([FIX_SKILLS, FIX_PLAIN_USER])], - }); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, false); - assert.equal(stats.skip_reason, "unexpected_role_or_shape"); -}); - -test("5d. messages[0].content is a string (legacy shape) → skip with unexpected_role_or_shape", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [{ role: "user", content: "hello, this is a legacy shape" }], - }); - const before = JSON.stringify(body); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, false); - assert.equal(stats.skip_reason, "unexpected_role_or_shape"); - assert.equal(JSON.stringify(body), before); -}); - -// --- Block classification --- - -test("6. skills block (...) → skills", () => { - assert.equal(classifyBlock(FIX_SKILLS), "skills"); -}); - -test("7. plugin-skills block → skills", () => { - const block = blockText( - "The following are loaded: ...", - ); - assert.equal(classifyBlock(block), "skills"); -}); - -test("8. project CLAUDE.md (absolute path) → claude_md", () => { - assert.equal(classifyBlock(FIX_CLAUDE_MD), "claude_md"); -}); - -test("8a. user text 'see also CLAUDE.md in the docs' → user (over-match guard)", () => { - assert.equal(classifyBlock(FIX_USER_RELATIVE_CLAUDE_MD), "user"); -}); - -test("9. deferred-tools block → deferred_tools", () => { - assert.equal(classifyBlock(FIX_DEFERRED_TOOLS), "deferred_tools"); -}); - -test("9a. user prose about deferred tools → user (over-match guard)", () => { - assert.equal(classifyBlock(FIX_USER_DEFERRED_TOOLS_PROSE), "user"); -}); - -test("10. MCP via → mcp_resources", () => { - assert.equal(classifyBlock(FIX_MCP_RESOURCES), "mcp_resources"); -}); - -test("10a. MCP via 'Available MCP servers:' literal → mcp_resources", () => { - assert.equal(classifyBlock(FIX_MCP_AVAILABLE), "mcp_resources"); -}); - -test("10b. user prose about MCP → user (over-match guard)", () => { - assert.equal(classifyBlock(FIX_USER_MCP_PROSE), "user"); -}); - -test("11. hooks block (SessionStart:resume) → hooks", () => { - assert.equal(classifyBlock(FIX_HOOKS_SESSIONSTART_RESUME), "hooks"); -}); - -test("11a. hooks block (PreToolUse) → hooks", () => { - assert.equal(classifyBlock(FIX_HOOKS_PRETOOLUSE), "hooks"); -}); - -test("11b. user prose 'the hook success message is in the logs' → user", () => { - assert.equal(classifyBlock(FIX_USER_HOOK_PROSE), "user"); -}); - -test("11c. user prose quoting → user (over-match guard)", () => { - // Block doesn't START with , so the skills signature must not match. - assert.equal(classifyBlock(FIX_USER_QUOTING_AVAILABLE_SKILLS), "user"); -}); - -test("12. image block → user", () => { - assert.equal(classifyBlock(blockImage()), "user"); -}); - -test("13. plain user text → user", () => { - assert.equal(classifyBlock(FIX_PLAIN_USER), "user"); -}); - -test("14. empty content / null → user (defensive)", () => { - assert.equal(classifyBlock(null), "user"); - assert.equal(classifyBlock({}), "user"); - assert.equal(classifyBlock({ type: "text" }), "user"); - assert.equal(classifyBlock({ type: "text", text: "" }), "user"); -}); - -// --- Marker count guard --- - -test("15. body with 0 markers → skip with no_existing_markers", () => { - const body = makeBody({ - messages: [userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER])], - }); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, false); - assert.equal(stats.skip_reason, "no_existing_markers"); - assert.equal(stats.existing_marker_count, 0); -}); - -test("16. body with 3 existing markers → inject (count becomes 4)", () => { - // 2 system + 1 last-user (canonical post-normalize position) - const body = makeBody({ - system: systemBlocks(), - messages: [ - userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER]), - assistantMsg("ack"), - userMsg([withCacheControl(blockText("follow-up"))]), - ], - }); - assert.equal(countAllCacheControlMarkers(body), 3); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, true); - assert.equal(stats.existing_marker_count, 3); - assert.equal(countAllCacheControlMarkers(body), 4); -}); - -test("17. body with 4 existing markers → skip with at_marker_limit, body unchanged", () => { - // 2 system + 2 in messages - const body = makeBody({ - system: systemBlocks(), - messages: [ - userMsg([FIX_SKILLS, withCacheControl(FIX_CLAUDE_MD), FIX_PLAIN_USER]), - assistantMsg("ack"), - userMsg([withCacheControl(blockText("follow-up"))]), - ], - }); - assert.equal(countAllCacheControlMarkers(body), 4); - const before = JSON.stringify(body); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, false); - assert.equal(stats.skip_reason, "at_marker_limit"); - assert.equal(JSON.stringify(body), before); -}); - -test("18. body with 5 existing markers → skip + warning emitted", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [ - userMsg([ - withCacheControl(FIX_SKILLS), - withCacheControl(FIX_CLAUDE_MD), - FIX_PLAIN_USER, - ]), - assistantMsg("ack"), - userMsg([withCacheControl(blockText("follow-up"))]), - ], - }); - assert.equal(countAllCacheControlMarkers(body), 5); - // Capture stderr by spying on process.stderr.write. - const origWrite = process.stderr.write.bind(process.stderr); - const captured = []; - process.stderr.write = (chunk) => { - captured.push(typeof chunk === "string" ? chunk : chunk.toString()); - return true; - }; - try { - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.skip_reason, "at_marker_limit"); - } finally { - process.stderr.write = origWrite; - } - assert.ok( - captured.some((line) => line.includes("exceeds Anthropic's documented max")), - `expected stderr warning, got: ${JSON.stringify(captured)}`, - ); -}); - -// --- Injection --- - -test("19. boundary block has no cache_control → inject 1h ephemeral, preserve other fields", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER])], - }); - const originalKeys = Object.keys(body.messages[0].content[1]).sort(); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, true); - const block = body.messages[0].content[1]; - assert.deepEqual(block.cache_control, { type: "ephemeral", ttl: "1h" }); - assert.equal(block.type, "text"); - assert.equal(block.text, FIX_CLAUDE_MD.text); - // All original keys still present. - const newKeys = Object.keys(block).sort(); - for (const k of originalKeys) assert.ok(newKeys.includes(k), `lost field ${k}`); -}); - -test("20. boundary block already has cache_control → skip, do not overwrite", () => { - const preMarked = withCacheControl(FIX_CLAUDE_MD); - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS, preMarked, FIX_PLAIN_USER])], - }); - const before = JSON.stringify(body.messages[0].content[1]); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, false); - assert.equal(stats.skip_reason, "boundary_already_marked"); - assert.equal(JSON.stringify(body.messages[0].content[1]), before); -}); - -test("21. marker count post-injection equals existing_count + 1", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [ - userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER]), - assistantMsg("ack"), - userMsg([withCacheControl(blockText("follow-up"))]), - ], - }); - const before = countAllCacheControlMarkers(body); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, true); - assert.equal(countAllCacheControlMarkers(body), before + 1); -}); - -// --- Diagnostic dump (CACHE_FIX_DUMP_MESSAGES_HEAD) --- - -test("22. CACHE_FIX_DUMP_MESSAGES_HEAD set, valid request → JSONL line written, no mutation", async () => { - const dir = await mkdtemp(join(tmpdir(), "messages-bp-")); - const dumpPath = join(dir, "head.jsonl"); - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_HOOKS_SESSIONSTART_RESUME, FIX_SKILLS, FIX_PLAIN_USER])], - }); - const before = JSON.stringify(body); - await withEnv( - { - CACHE_FIX_DUMP_MESSAGES_HEAD: dumpPath, - CACHE_FIX_INJECT_MESSAGES_BREAKPOINT: undefined, - }, - async () => { - await runExt(body); - }, - ); - assert.equal(JSON.stringify(body), before, "body must not mutate when only dump is on"); - const raw = await readFile(dumpPath, "utf8"); - const lines = raw.trim().split("\n"); - assert.equal(lines.length, 1); - const rec = JSON.parse(lines[0]); - assert.equal(rec.role, "user"); - assert.equal(rec.block_count, 3); - assert.equal(rec.blocks[0].kind, "hooks"); - assert.equal(rec.blocks[1].kind, "skills"); - assert.equal(rec.blocks[2].kind, "user"); - assert.equal(typeof rec.blocks[0].text_prefix, "string"); - assert.ok(rec.blocks[0].text_prefix.length <= 200); - assert.equal(rec.blocks[0].has_cache_control, false); - await rm(dir, { recursive: true, force: true }); -}); - -test("23. CACHE_FIX_DUMP_MESSAGES_HEAD unset → no fs activity", async () => { - const dir = await mkdtemp(join(tmpdir(), "messages-bp-")); - const dumpPath = join(dir, "head.jsonl"); - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS, FIX_PLAIN_USER])], - }); - await withEnv( - { - CACHE_FIX_DUMP_MESSAGES_HEAD: undefined, - CACHE_FIX_INJECT_MESSAGES_BREAKPOINT: undefined, - }, - async () => { - await runExt(body); - }, - ); - await assert.rejects( - () => stat(dumpPath), - /ENOENT/, - "dump file should not be created when env unset", - ); - await rm(dir, { recursive: true, force: true }); -}); - -// --- Activation --- - -test("24. CACHE_FIX_INJECT_MESSAGES_BREAKPOINT unset, no diagnostic → extension is no-op (no telemetry, no mutation)", async () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER])], - }); - const before = JSON.stringify(body); - let ctx; - await withEnv( - { - CACHE_FIX_INJECT_MESSAGES_BREAKPOINT: undefined, - CACHE_FIX_DUMP_MESSAGES_HEAD: undefined, - }, - async () => { - ctx = await runExt(body); - }, - ); - assert.equal(JSON.stringify(body), before); - assert.equal(ctx.meta.messagesBreakpointStats, undefined); -}); - -test("25. CACHE_FIX_INJECT_MESSAGES_BREAKPOINT=1, valid request → telemetry present, injection occurs", async () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER])], - }); - let ctx; - // Silence stderr summary line for this test (we cover stderr in test 28). - const origWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = () => true; - try { - await withEnv( - { - CACHE_FIX_INJECT_MESSAGES_BREAKPOINT: "1", - CACHE_FIX_DUMP_MESSAGES_HEAD: undefined, - }, - async () => { - ctx = await runExt(body); - }, - ); - } finally { - process.stderr.write = origWrite; - } - assert.ok(ctx.meta.messagesBreakpointStats); - assert.equal(ctx.meta.messagesBreakpointStats.injected, true); - assert.equal(body.messages[0].content[1].cache_control.ttl, "1h"); -}); - -// --- Telemetry --- - -test("26. successful injection → telemetry has injected=true, boundary_idx, kind, count", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER])], - }); - const stats = injectMessagesBreakpoint({ body }); - assert.equal(stats.injected, true); - assert.ok(stats.boundary_idx >= 0); - assert.equal(stats.boundary_block_kind, "claude_md"); - assert.equal(typeof stats.existing_marker_count, "number"); -}); - -test("27. skip path → injected=false and skip_reason is one of the documented values", () => { - const documented = new Set([ - "boundary_not_found", - "boundary_already_marked", - "no_existing_markers", - "at_marker_limit", - "unexpected_role_or_shape", - ]); - // boundary_not_found - let stats = injectMessagesBreakpoint({ - body: makeBody({ system: systemBlocks(), messages: [userMsg([FIX_PLAIN_USER])] }), - }); - assert.ok(documented.has(stats.skip_reason)); - // no_existing_markers - stats = injectMessagesBreakpoint({ - body: makeBody({ messages: [userMsg([FIX_SKILLS, FIX_PLAIN_USER])] }), - }); - assert.equal(stats.skip_reason, "no_existing_markers"); - assert.ok(documented.has(stats.skip_reason)); - // unexpected_role_or_shape - stats = injectMessagesBreakpoint({ - body: makeBody({ system: systemBlocks(), messages: [assistantMsg("hi")] }), - }); - assert.ok(documented.has(stats.skip_reason)); -}); - -test("28. stderr summary line emitted when extension enabled (both injection and skip paths)", async () => { - const captured = []; - const origWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = (chunk) => { - captured.push(typeof chunk === "string" ? chunk : chunk.toString()); - return true; - }; - try { - // Injection path - await withEnv({ CACHE_FIX_INJECT_MESSAGES_BREAKPOINT: "1" }, async () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_SKILLS, FIX_CLAUDE_MD, FIX_PLAIN_USER])], - }); - await runExt(body); - }); - // Skip path - await withEnv({ CACHE_FIX_INJECT_MESSAGES_BREAKPOINT: "1" }, async () => { - const body = makeBody({ - system: systemBlocks(), - messages: [userMsg([FIX_PLAIN_USER])], - }); - await runExt(body); - }); - } finally { - process.stderr.write = origWrite; - } - assert.ok( - captured.some((l) => l.includes("[messages-breakpoint] injected")), - "expected an 'injected' stderr line", - ); - assert.ok( - captured.some((l) => l.includes("[messages-breakpoint] skipped")), - "expected a 'skipped' stderr line", - ); -}); - -// --- Build dump record (pure) --- - -test("buildDumpRecord captures kinds, prefixes, marker presence per block", () => { - const body = makeBody({ - system: systemBlocks(), - messages: [ - userMsg([ - FIX_HOOKS_SESSIONSTART_RESUME, - withCacheControl(FIX_CLAUDE_MD), - FIX_PLAIN_USER, - ]), - ], - }); - const rec = buildDumpRecord(body, "2026-04-30T00:00:00.000Z"); - assert.equal(rec.ts, "2026-04-30T00:00:00.000Z"); - assert.equal(rec.role, "user"); - assert.equal(rec.block_count, 3); - assert.equal(rec.blocks[0].kind, "hooks"); - assert.equal(rec.blocks[1].kind, "claude_md"); - assert.equal(rec.blocks[1].has_cache_control, true); - assert.equal(rec.blocks[2].kind, "user"); - assert.equal(rec.blocks[2].has_cache_control, false); - assert.equal(typeof rec.existing_marker_count, "number"); -}); diff --git a/test/proxy-ttl-tier-pipeline.test.mjs b/test/proxy-ttl-tier-pipeline.test.mjs index 8423897e..9e3235d6 100644 --- a/test/proxy-ttl-tier-pipeline.test.mjs +++ b/test/proxy-ttl-tier-pipeline.test.mjs @@ -216,10 +216,6 @@ test("[pipeline #21] CACHE_FIX_TTL_MAIN=none + detected 5m → detection still f assert.equal(ctx.meta._ttlTier, "5m"); // No ttl field injected by ttl-management — env "none" suppresses. - // (Note: messages-cache-breakpoint at order 410 may inject its own - // breakpoint-3 marker carrying ttl=1h independent of ttl-management; that's - // a separate code path. We assert specifically that ttl-management's - // injection on the canonical marker did not run.) assert.equal(ctx.body.system[0].cache_control.ttl, undefined, "ttl-management must not inject on system when env=none"); });