Skip to content

Commit 16a3ca3

Browse files
refresh(tools): gate-warning + --gates-from-capture + marker-blind output metric; extensions synced
Three replay improvements from operating the gate: an unmissable stderr warning when a gated capture replays under default gates (the instrument error that booked a wrong verdict three times in one day — and whose first live fire caught the operator's own gateless replay); a --gates-from-capture flag applying the all-boot-records union so nobody hand-extracts gates; and outputForm now strips cache_control before comparing (a moved cache marker is not a content splice — five pairs totalling ~0.6 MB of phantom "re-billed splice" were CC's own benign marker relocation). Extensions synced to the cnighswonger#272/cnighswonger#273 tips so the slice's real-capture tests exercise the actual pipeline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZxGrF1LRBvmb7cFXmS2DH
1 parent 93203c9 commit 16a3ca3

7 files changed

Lines changed: 682 additions & 48 deletions

proxy/extensions/deferred-tool-rewrite.mjs

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -405,37 +405,62 @@ export function anchorHash(msg) {
405405
// re-anchors after the LAST user message — the closest stable position
406406
// that satisfies the "must follow a user message" placement constraint —
407407
// and reports it so state can be updated and telemetry emitted.
408+
//
409+
// Resolution happens in a first pass against the ORIGINAL `messages` array
410+
// (never mutated while resolving), so a SHARED anchor's landing position is
411+
// computed once regardless of how many additions target it. This is what
412+
// keeps the run FIFO — discovery order, oldest first — instead of the
413+
// previous idx+1-per-addition splice, which re-found the same anchor fresh
414+
// on every iteration (the search excludes role==="system", so
415+
// already-injected additions were invisible to it) and always landed the
416+
// newest addition closest to the anchor: a LIFO stack that reordered the
417+
// already-forwarded prefix on every new addition (probe s-dc3f8071,
418+
// n=372-397, 25 stability violations during an MCP discovery cascade).
408419
export function injectAdditions(messages, additions) {
409420
if (!Array.isArray(additions) || additions.length === 0) {
410421
return { messages, reanchored: [] };
411422
}
412-
const out = [...messages];
423+
424+
let lastUserIdx = -1;
425+
for (let i = messages.length - 1; i >= 0; i--) {
426+
if (messages[i].role === "user") {
427+
lastUserIdx = i;
428+
break;
429+
}
430+
}
431+
413432
const reanchored = [];
433+
// Original-array index -> messages to inject right after it, in discovery
434+
// (oldest-first) order — the run for a shared anchor.
435+
const byAnchorIdx = new Map();
436+
414437
for (const add of additions) {
415-
const idx = out.findIndex((m) => m.role !== "system" && anchorHash(m) === add.anchorHash);
416-
if (idx >= 0) {
417-
out.splice(idx + 1, 0, add.message);
418-
} else {
419-
let lastUser = -1;
420-
for (let i = out.length - 1; i >= 0; i--) {
421-
if (out[i].role === "user") {
422-
lastUser = i;
423-
break;
424-
}
425-
}
426-
if (lastUser >= 0) {
427-
out.splice(lastUser + 1, 0, add.message);
428-
const newAnchor = anchorHash(out[lastUser]);
429-
reanchored.push({ names: add.names, anchorHash: newAnchor });
438+
const idx = messages.findIndex((m) => m.role !== "system" && anchorHash(m) === add.anchorHash);
439+
let landingIdx = idx;
440+
if (idx < 0) {
441+
if (lastUserIdx >= 0) {
442+
landingIdx = lastUserIdx;
443+
reanchored.push({ names: add.names, anchorHash: anchorHash(messages[lastUserIdx]) });
430444
} else {
431445
// No user message at all — cannot satisfy the placement
432446
// constraint; skip this injection (the tool stays deferred and
433447
// unloaded this request; honest degradation, not a malformed
434448
// request).
435449
reanchored.push({ names: add.names, anchorHash: null });
450+
continue;
436451
}
437452
}
453+
if (!byAnchorIdx.has(landingIdx)) byAnchorIdx.set(landingIdx, []);
454+
byAnchorIdx.get(landingIdx).push(add.message);
438455
}
456+
457+
const out = [];
458+
messages.forEach((m, i) => {
459+
out.push(m);
460+
const injected = byAnchorIdx.get(i);
461+
if (injected) out.push(...injected);
462+
});
463+
439464
return { messages: out, reanchored };
440465
}
441466

test/census-output-hash.test.mjs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// census-output-hash — unit bites for the outHashSem strip added to
2+
// findMitigationGaps' outputForm/outputPreserved/rebilledOutBytes.
3+
// BACKLOG.md: "READY — census outputForm hashes must strip cache_control
4+
// (mirror the input side)."
5+
//
6+
// DEFINITION under test (stated before the assertions, per dev-loop.md's
7+
// "Adding a check" — a bite's expected value comes from the invariant's
8+
// DEFINITION, never from the implementation): cache_control designates a
9+
// cache breakpoint, it is not conversation content. A pair of forwarded
10+
// messages that differ ONLY in whether/where a cache_control block is
11+
// attached is not a content splice — the model-visible bytes are
12+
// identical, only the cache metadata moved. A pair that differs in actual
13+
// TEXT is a real edit regardless of any cache_control noise riding along,
14+
// and must still be caught (a checker that stops firing on the marker
15+
// case must not also stop firing on the real one — the "fires on a
16+
// non-defect" and "misses a real defect" failures are both broken the
17+
// same way, dev-loop.md).
18+
//
19+
// These are unit-level bites on findMitigationGaps directly (synthetic
20+
// entries), the small-corpus sibling of the real-capture assertions in
21+
// test/mitigation-output-form.test.mjs.
22+
23+
import { test } from "node:test";
24+
import assert from "node:assert/strict";
25+
26+
import { findMitigationGaps } from "../tools/replay.mjs";
27+
28+
const user = (t) => ({ role: "user", content: [{ type: "text", text: t }] });
29+
const asst = (t) => ({ role: "assistant", content: [{ type: "text", text: t }] });
30+
31+
// One capture entry in the pre-compactEntry shape findMitigationGaps'
32+
// caller (asCompact) accepts — same shape test/mitigation-output-form.test.mjs
33+
// uses.
34+
const entry = (n, inMsgs, outMsgs, extra = {}) => ({
35+
n,
36+
ts: `2026-07-30T00:00:${String(n).padStart(2, "0")}Z`,
37+
key: "k",
38+
inMsgs,
39+
outMsgs,
40+
action: null,
41+
resetReason: null,
42+
...extra,
43+
});
44+
45+
// --- red-first observation (recorded, not re-asserted): before the strip
46+
// existed, this exact scenario ran through unpatched findMitigationGaps
47+
// (outHash built from raw JSON.stringify(message), no cache_control strip)
48+
// and returned outputForm: "edit@1", outputPreserved: false,
49+
// rebilledOutBytes: 24 (the tail-only bytes) — the marker relocation read
50+
// as a content splice. Observed by running this file against the
51+
// pre-fix tree (git stash the outHashSem change, `node --test
52+
// test/census-output-hash.test.mjs`): AssertionError, actual "edit@1" !==
53+
// "append". That is the real defect this bite targets.
54+
55+
test("census output-hash: a cache_control-only relocation is not a splice (preserved)", () => {
56+
// message index 1 carries a cache_control breakpoint while it is the
57+
// tail in prevOut; curOut carries the SAME text at the same position
58+
// with no cache_control (the breakpoint moved off because the
59+
// conversation grew past it — the flap-probe's measured shape,
60+
// capture s-633915a8, n=678->681: identical 32,140-char text sent with
61+
// a cache_control block while tail, then as a bare string once it
62+
// wasn't) and one genuinely new message appended at the tail.
63+
const withMarker = {
64+
role: "user",
65+
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral", ttl: "1h" } }],
66+
};
67+
const withoutMarker = { role: "user", content: [{ type: "text", text: "u1" }] };
68+
69+
const prevIn = [user("u0"), asst("a0"), user("u1")];
70+
const curIn = [user("u0"), asst("a0"), user("SPLICED"), user("u1")];
71+
const prevOut = [user("u0"), asst("a0"), withMarker];
72+
const curOut = [user("u0"), asst("a0"), withoutMarker, user("u2-new")];
73+
74+
const rows = findMitigationGaps([
75+
entry(0, prevIn, prevOut, { action: "append-only" }),
76+
entry(1, curIn, curOut, { action: "normalized" }),
77+
]);
78+
79+
assert.equal(rows.length, 1);
80+
assert.equal(rows[0].kind, "splice/insert-mid", "input-side classification is unchanged");
81+
assert.equal(rows[0].outputForm, "append", "marker-only delta must not read as a splice/edit");
82+
assert.equal(rows[0].outputPreserved, true);
83+
assert.equal(rows[0].rebilledOutBytes, 0);
84+
});
85+
86+
test("census output-hash: a real text delta beside a cache_control change is still caught", () => {
87+
// Same shape as above, but message index 1's TEXT also changes, not
88+
// just its cache_control. The checker must still fire — stripping
89+
// cache_control must not also blind it to a genuine edit riding
90+
// alongside one.
91+
const withMarker = {
92+
role: "user",
93+
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral", ttl: "1h" } }],
94+
};
95+
const editedNoMarker = { role: "user", content: [{ type: "text", text: "u1-EDITED" }] };
96+
97+
const prevIn = [user("u0"), asst("a0"), user("u1")];
98+
const curIn = [user("u0"), asst("a0"), user("SPLICED"), user("u1")];
99+
const prevOut = [user("u0"), asst("a0"), withMarker];
100+
const curOut = [user("u0"), asst("a0"), editedNoMarker, user("u2-new")];
101+
102+
const rows = findMitigationGaps([
103+
entry(0, prevIn, prevOut, { action: "append-only" }),
104+
entry(1, curIn, curOut, { action: "normalized" }),
105+
]);
106+
107+
assert.equal(rows.length, 1);
108+
assert.notEqual(rows[0].outputForm, "append", "a genuine text edit must still be flagged");
109+
assert.equal(rows[0].outputPreserved, false);
110+
assert.ok(rows[0].rebilledOutBytes > 0);
111+
});

test/deferred-tool-rewrite.test.mjs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,70 @@ test("injectAdditions: no user message at all → injection skipped, reported wi
254254
assert.equal(reanchored[0].anchorHash, null);
255255
});
256256

257+
// BITE — the LIFO bug (BACKLOG "READY — fix injectAdditions' LIFO stacking").
258+
// Real capture s-dc3f8071, n=372-397: an MCP-tool-discovery cascade produces
259+
// one new `additions` entry per request, all anchored to the SAME message
260+
// (the real conversation stays at 1 message the whole burst). The buggy
261+
// implementation re-finds the anchor fresh on every iteration (the search
262+
// excludes role==="system", so already-injected additions are invisible to
263+
// it) and always splices at anchorIdx+1 — so the newest addition always
264+
// lands closest to the anchor, pushing every earlier addition one slot
265+
// further back: a LIFO stack that reorders the already-forwarded prefix on
266+
// every new addition. Fix: a shared anchor's run stays in discovery order
267+
// (FIFO) — a new addition appends AFTER the additions already injected
268+
// there, so the forwarded prefix is a byte-stable prefix of every
269+
// subsequent output and only the tail of the run grows.
270+
test("injectAdditions: three additions sharing one anchor → output is discovery order (FIFO), not LIFO", () => {
271+
const u0 = { role: "user", content: [{ type: "text", text: "u0" }] };
272+
const sharedAnchor = anchorHash(u0);
273+
const addA = buildToolAdditionMessage(["ToolA"]);
274+
const addB = buildToolAdditionMessage(["ToolB"]);
275+
const addC = buildToolAdditionMessage(["ToolC"]);
276+
277+
// additions array is in DISCOVERY order (oldest first), matching how
278+
// onRequest concatenates them across successive requests.
279+
const additions = [
280+
{ names: ["ToolA"], anchorHash: sharedAnchor, message: addA },
281+
{ names: ["ToolB"], anchorHash: sharedAnchor, message: addB },
282+
{ names: ["ToolC"], anchorHash: sharedAnchor, message: addC },
283+
];
284+
285+
const { messages } = injectAdditions([u0], additions);
286+
assert.deepEqual(
287+
messages.map((m) => m.content?.[0]?.tool?.name ?? "u0"),
288+
["u0", "ToolA", "ToolB", "ToolC"],
289+
"run stays in discovery order — ToolA first (oldest), ToolC last (newest), never reordered",
290+
);
291+
});
292+
293+
test("injectAdditions: shared-anchor prefix stability — output N is a byte-prefix of output N+1", () => {
294+
const u0 = { role: "user", content: [{ type: "text", text: "u0" }] };
295+
const sharedAnchor = anchorHash(u0);
296+
const addA = buildToolAdditionMessage(["ToolA"]);
297+
const addB = buildToolAdditionMessage(["ToolB"]);
298+
299+
// Simulates two successive requests: first only ToolA has been discovered,
300+
// then ToolB arrives too (additions accumulate, oldest first — as onRequest
301+
// does via `additions.concat([...])`).
302+
const afterFirst = injectAdditions([u0], [{ names: ["ToolA"], anchorHash: sharedAnchor, message: addA }]);
303+
const afterSecond = injectAdditions(
304+
[u0],
305+
[
306+
{ names: ["ToolA"], anchorHash: sharedAnchor, message: addA },
307+
{ names: ["ToolB"], anchorHash: sharedAnchor, message: addB },
308+
],
309+
);
310+
311+
const prefixBytes = JSON.stringify(afterFirst.messages);
312+
const nextBytes = JSON.stringify(afterSecond.messages.slice(0, afterFirst.messages.length));
313+
assert.equal(
314+
nextBytes,
315+
prefixBytes,
316+
"the already-forwarded prefix must be byte-identical once a new addition arrives — only the tail grows",
317+
);
318+
assert.equal(afterSecond.messages.length, 3, "the new addition appends at the tail of the run");
319+
});
320+
257321
test("forwardedTools: names covered by additions get defer_loading, others stay untouched", () => {
258322
const known = [tool("Read"), tool("SendMessage")];
259323
const additions = [{ names: ["SendMessage"], anchorHash: "h", message: {} }];
@@ -449,6 +513,53 @@ test("onRequest: pruned anchor → re-anchor once, stable thereafter", async ()
449513
}
450514
});
451515

516+
test("onRequest BITE: MCP-discovery cascade — same 1-message conversation, tools[] grows 3x → additions stack in discovery order, prefix stable", async () => {
517+
// Mirrors the real capture (s-dc3f8071, n=372-397): CC's own progressive
518+
// MCP-tool-discovery cascade at session boot sends one new tool batch per
519+
// request while the real conversation never grows past 1 message, so every
520+
// addition shares the identical anchor (messages[0]).
521+
const dir = await newTmp();
522+
const headers = { "x-claude-code-session-id": "sess-cascade" };
523+
try {
524+
await withEnvAsync({ CACHE_FIX_TOOL_REWRITE: "1" }, async () => {
525+
const u0 = { role: "user", content: [{ type: "text", text: "u0" }] };
526+
const base = { system: [], messages: [u0], model: "claude-opus-5" };
527+
528+
await runExt({ ...base, tools: [tool("Read"), tool("Bash")] }, { headers, dir }); // no-baseline
529+
const ctx1 = await runExt(
530+
{ ...base, tools: [tool("Read"), tool("Bash"), tool("ToolA")] },
531+
{ headers, dir },
532+
);
533+
const ctx2 = await runExt(
534+
{ ...base, tools: [tool("Read"), tool("Bash"), tool("ToolA"), tool("ToolB")] },
535+
{ headers, dir },
536+
);
537+
const ctx3 = await runExt(
538+
{ ...base, tools: [tool("Read"), tool("Bash"), tool("ToolA"), tool("ToolB"), tool("ToolC")] },
539+
{ headers, dir },
540+
);
541+
542+
const names = (ctx) =>
543+
ctx.body.messages
544+
.filter((m) => m.role === "system" && Array.isArray(m.content) && m.content[0]?.type === "tool_addition")
545+
.flatMap((m) => m.content.map((b) => b.tool.name));
546+
547+
assert.deepEqual(names(ctx1), ["ToolA"]);
548+
assert.deepEqual(names(ctx2), ["ToolA", "ToolB"], "ToolA stays first — discovery order, not LIFO");
549+
assert.deepEqual(names(ctx3), ["ToolA", "ToolB", "ToolC"], "run grows only at the tail");
550+
551+
// The forwarded prefix already produced must be a byte-prefix of the
552+
// next request's output — this is the "reorders the already-forwarded
553+
// prefix" bust the probe measured.
554+
const prefixOf = (ctx, n) => JSON.stringify(ctx.body.messages.slice(0, n));
555+
assert.equal(prefixOf(ctx2, ctx1.body.messages.length), JSON.stringify(ctx1.body.messages));
556+
assert.equal(prefixOf(ctx3, ctx2.body.messages.length), JSON.stringify(ctx2.body.messages));
557+
});
558+
} finally {
559+
await rm(dir, { recursive: true, force: true });
560+
}
561+
});
562+
452563
test("onRequest: a tool removed after an addition → HELD (rewrite, passthrough of held tool), no beta header (nothing new to defer)", async () => {
453564
const dir = await newTmp();
454565
const headers = { "x-claude-code-session-id": "sess-hold" };

test/insertion-suppression.test.mjs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,9 +351,14 @@ test(
351351
// bounded contribution, not the BACKLOG entry's stated "outputForm
352352
// === append" criterion, which this pair cannot reach while
353353
// ttl-management's marker relocation exists. Surfaced as a gap.
354-
assert.equal(row.outputForm, "edit@48", "residual divergence is the KNOWN, unrelated ttl-management marker relocation, not the reminder-swap");
355-
assert.equal(row.outputPreserved, false);
356-
assert.ok(row.rebilledOutBytes > 0 && row.rebilledOutBytes < 10000, "residual is the marker-sized tail, not the ~61kB pre-fix splice");
354+
// The only remaining delta is ttl-management's cache_control marker
355+
// relocating off the old tail — since the outputForm metric strips
356+
// cache_control (903a2be: a moved marker is not a content splice),
357+
// the suppressed pair now reads fully preserved. A regression that
358+
// reintroduces CONTENT divergence flips this to a non-append form.
359+
assert.equal(row.outputForm, "append", "suppression + marker-blind metric: nothing but the marker moved");
360+
assert.equal(row.outputPreserved, true);
361+
assert.equal(row.rebilledOutBytes, 0);
357362

358363
// The n=28 entry itself: exactly one suppression, at the index the
359364
// fidelity probe named (message[31] in the pre-fix pipeline).

0 commit comments

Comments
 (0)