Skip to content

Commit c4569ec

Browse files
refresh(tools): replay reads the output side — block-migration census, output-form metric, suppression exemptions
The gate's mitigation metric was input-side only: it trusted the extension's self-report and never compared what was actually forwarded. Now each mitigation row carries outputForm (append / splice@N / edit@N), outputPreserved, and rebilledOutBytes — measured on the forwarded bytes — which is how a "mitigated" pair that still re-billed 124k was caught. The census classifies reminder block-migrations (inline <-> standalone) on splice and edit rows, and the safety and stability checks gain telemetry-sourced exemptions for the new suppression (a removed message has no shape to detect after the fact, so exemption keys off the extension's own suppression records). Extension synced to the cnighswonger#272 tip; real-pair red-green tests run in this slice, where tooling, extensions, and capture meet. 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 0633e23 commit c4569ec

5 files changed

Lines changed: 1108 additions & 17 deletions

File tree

proxy/extensions/insertion-normalization.mjs

Lines changed: 153 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,12 @@ function identityKey(entry) {
382382
// its own injections with it. No allowlist of reminder texts: the flip
383383
// evidence already covers four reminder kinds, and a pattern list would
384384
// be the next mole (directive, part A).
385-
const VOLATILE_WRAP_REGEX = /^<system-reminder>\n[\s\S]*\n<\/system-reminder>\s*$/;
385+
//
386+
// Captures the inner text (group 1) so the SAME regex serves both
387+
// isVolatileBlock's boolean test (unaffected by adding a group) and
388+
// suppression's unwrapVolatileText below — one pattern, not a second
389+
// derivation of it (dev-loop.md, "never hand-roll identity in a probe").
390+
const VOLATILE_WRAP_REGEX = /^<system-reminder>\n([\s\S]*)\n<\/system-reminder>\s*$/;
386391

387392
// A text block is volatile iff it is entirely a system-reminder wrap OR
388393
// empty — the observed flip alternates a reminder block with an
@@ -604,6 +609,70 @@ function pinnedForwardForm(stored, incomingMsg) {
604609
return stored.m ?? stripVolatileBlocks(incomingMsg);
605610
}
606611

612+
// --- Reminder-swap suppression (#76606, decision B) ---
613+
//
614+
// CC sometimes migrates a hook reminder OUT of the user message that
615+
// carries it and INTO a standalone message of its own — measured directly
616+
// (capture s-633915a8, n=26->28): message[30]'s <system-reminder>-wrapped
617+
// block is gone from message[30] and its inner text, wrapper stripped,
618+
// is the entire content of a new message[31] (role system). Pinning above
619+
// restores message[30]'s first-seen bytes, reminder included; treating the
620+
// new standalone as ordinary tail growth then forwards the SAME text a
621+
// second time, and because it lands mid-array the cache's
622+
// longest-identical-prefix boundary moves to right before it — everything
623+
// after is re-billed (measured: cacheRead 15424 / cacheCreation 124025).
624+
//
625+
// Strip the wrapper for comparison ONLY — never for what gets forwarded;
626+
// the pin already owns that. Reuses VOLATILE_WRAP_REGEX's capture group
627+
// rather than a second regex, per the same rule cited above it.
628+
function unwrapVolatileText(block) {
629+
if (!block || typeof block !== "object" || block.type !== "text" || typeof block.text !== "string") {
630+
return block;
631+
}
632+
const m = VOLATILE_WRAP_REGEX.exec(block.text);
633+
return m ? { type: "text", text: m[1] } : block;
634+
}
635+
636+
// The set of block identities this extension is CURRENTLY restoring —
637+
// i.e. present in a LIVE (not dropped) canonical entry's stored first-seen
638+
// form. Dropped entries are excluded on purpose: their content is not being
639+
// served anywhere, so a new standalone message matching a dropped block
640+
// must flow through normally rather than being silently discarded with no
641+
// copy left at all. Scoped to VOLATILE blocks only (isVolatileBlock), since
642+
// those are what buildPinEntry ever stores `m` for and what the measured
643+
// migration shape moves — a plain text block coincidentally matching a
644+
// pinned message's ordinary content is not this class.
645+
function pinnedBlockHashes(priorCanonical) {
646+
const hashes = new Set();
647+
if (!Array.isArray(priorCanonical)) return hashes;
648+
for (const entry of priorCanonical) {
649+
if (entry.d || !entry.m || !Array.isArray(entry.m.content)) continue;
650+
for (const block of entry.m.content) {
651+
if (!isVolatileBlock(block)) continue;
652+
const h = hashMessageContent({ content: [unwrapVolatileText(block)] });
653+
if (h !== null) hashes.add(h);
654+
}
655+
}
656+
return hashes;
657+
}
658+
659+
// Is `msg` a suppressible duplicate of a currently-pinned block? Narrow by
660+
// definition (BACKLOG #76606 part (c)): STANDALONE only (single block after
661+
// the same string->one-block fold canonicalMessageShape already applies
662+
// elsewhere in this file), and its wrapper-stripped bytes must exactly
663+
// equal a hash in `pinnedHashes` — never a positional or role heuristic.
664+
// Returns the matched hash (for telemetry) or null (genuine content: no
665+
// suppression, existing rules apply unchanged).
666+
export function findSuppressibleDuplicate(msg, pinnedHashes) {
667+
const shaped = canonicalMessageShape(msg);
668+
if (!Array.isArray(shaped.content) || shaped.content.length !== 1) return null;
669+
const h = hashMessageContent({ content: [unwrapVolatileText(shaped.content[0])] });
670+
if (h === null || !pinnedHashes.has(h)) return null;
671+
return h;
672+
}
673+
674+
export { pinnedBlockHashes };
675+
607676
// Pin-mode classification. Differences from classifyInsertion:
608677
// - identities exclude volatile blocks (flip absorption);
609678
// - canonical entries missing from incoming are marked dropped
@@ -743,22 +812,47 @@ export function classifyPinned(messages, priorCanonical) {
743812
return resetKeepingPins("assistant-interleaved");
744813
}
745814

815+
// Suppress a NEW entry that duplicates a block this extension is already
816+
// restoring elsewhere (see the block comment above findSuppressibleDuplicate).
817+
// Assistant entries are excluded on principle even though the measured
818+
// shape never produces one — silently dropping the model's own prior
819+
// output is a correctness question this extension has no business
820+
// deciding, unlike a hook reminder it already owns via the pin.
821+
// Genuine change (normalized bytes differ from every pinned block):
822+
// findSuppressibleDuplicate returns null, the entry is untouched here,
823+
// and whatever the existing rules above already decided (append/splice/
824+
// edit-shaped reset) stands — no new reset path is introduced.
825+
const pinnedHashes = pinnedBlockHashes(priorCanonical);
826+
const suppressions = [];
827+
for (const e of newEntries) {
828+
if (e.r === "assistant") continue;
829+
const h = findSuppressibleDuplicate(messages[e.index], pinnedHashes);
830+
if (h !== null) suppressions.push({ index: e.index, hash: h });
831+
}
832+
const suppressedIdx = new Set(suppressions.map((s) => s.index));
833+
746834
// Forwarded order is the INCOMING order, not "survivors then new". The two
747835
// agree for a plain append; they diverge when CC splices an entry
748836
// mid-history, and concatenating new entries at the end would then reorder
749837
// real content — the very thing this extension exists to prevent.
750838
let pinApplied = 0;
751839
const matchedByIdx = new Map(matched.map(({ ci, idx }) => [idx, ci]));
752-
const finalMessages = incoming.map((e) => {
840+
const finalMessages = [];
841+
for (const e of incoming) {
842+
if (suppressedIdx.has(e.index)) continue; // the pinned inline form already carries these bytes
753843
const ci = matchedByIdx.get(e.index);
754-
if (ci === undefined) return messages[e.index];
844+
if (ci === undefined) {
845+
finalMessages.push(messages[e.index]);
846+
continue;
847+
}
755848
const fwd = pinnedForwardForm(priorCanonical[ci], messages[e.index]);
756849
if (fwd !== messages[e.index] && JSON.stringify(fwd) !== JSON.stringify(messages[e.index])) {
757850
pinApplied++;
758-
return fwd;
851+
finalMessages.push(fwd);
852+
} else {
853+
finalMessages.push(messages[e.index]);
759854
}
760-
return messages[e.index];
761-
});
855+
}
762856

763857
if (!validateToolAdjacency(finalMessages)) {
764858
return { action: "reset", resetReason: "adjacency-violation", canonicalEntries: freshEntries() };
@@ -801,11 +895,21 @@ export function classifyPinned(messages, priorCanonical) {
801895
const canonicalEntries = [];
802896
for (const trailing of droppedAfter.get(-1) ?? []) canonicalEntries.push(trailing);
803897
for (const e of incoming) {
898+
// A suppressed entry was never forwarded, so it gets no canonical
899+
// identity — the invariant just below states the canonical must
900+
// describe the wire we just forwarded, and this entry isn't on it.
901+
// Recomputed fresh on every request (findSuppressibleDuplicate against
902+
// the currently-live pins), so leaving no trace here is not a gap: CC
903+
// keeps re-sending the duplicate as long as it believes it's part of
904+
// history, and it is re-detected and re-suppressed every time — no
905+
// persisted "suppressed" marker is needed for the suppression to stay
906+
// stable across subsequent requests.
907+
if (suppressedIdx.has(e.index)) continue;
804908
canonicalEntries.push(canonByIdx.get(e.index) ?? newByIdx.get(e.index));
805909
for (const trailing of droppedAfter.get(e.index) ?? []) canonicalEntries.push(trailing);
806910
}
807911

808-
const changed = splicedEntries.length > 0 || pinApplied > 0;
912+
const changed = splicedEntries.length > 0 || pinApplied > 0 || suppressions.length > 0;
809913
return {
810914
action: changed ? "normalized" : "append-only",
811915
messages: finalMessages,
@@ -840,9 +944,14 @@ export function classifyPinned(messages, priorCanonical) {
840944
}
841945
return null;
842946
})(),
843-
inserted: newEntries.length,
947+
// `inserted` counts what actually landed on the wire — a suppressed
948+
// entry was a new entry CC sent but never one we forwarded, so it must
949+
// not inflate this the way it would inflate a real insertion count.
950+
inserted: newEntries.length - suppressions.length,
844951
pinned: pinApplied,
845952
dropped: droppedNow.size,
953+
suppressed: suppressions.length,
954+
suppressions,
846955
};
847956
}
848957

@@ -904,7 +1013,18 @@ export default {
9041013
canonLive: result.canonicalEntries?.filter((e) => !e.d).length ?? 0,
9051014
msgs: messages.length,
9061015
canonOrderViolation: result.canonOrderViolation ?? null,
907-
...(pin ? { pinned: result.pinned ?? 0, dropped: result.dropped ?? 0 } : {}),
1016+
// `suppressions` (not just the count) rides on the stats object —
1017+
// tools/replay.mjs's safety-gate exemption reads the incoming
1018+
// indices from here to declare them, the same way it already reads
1019+
// deferred-tool-rewrite's tool_addition shape.
1020+
...(pin
1021+
? {
1022+
pinned: result.pinned ?? 0,
1023+
dropped: result.dropped ?? 0,
1024+
suppressed: result.suppressed ?? 0,
1025+
suppressions: result.suppressions ?? [],
1026+
}
1027+
: {}),
9081028
};
9091029

9101030
await appendTelemetry(
@@ -917,16 +1037,38 @@ export default {
9171037
action: result.action,
9181038
inserted: result.inserted ?? 0,
9191039
...(result.resetReason ? { resetReason: result.resetReason } : {}),
920-
...(pin ? { pinned: result.pinned ?? 0, dropped: result.dropped ?? 0 } : {}),
1040+
...(pin ? { pinned: result.pinned ?? 0, dropped: result.dropped ?? 0, suppressed: result.suppressed ?? 0 } : {}),
9211041
},
9221042
fs,
9231043
);
9241044

1045+
// One event line PER SUPPRESSION (not aggregated into the summary
1046+
// line above), to the same file/format — the pattern every other
1047+
// record in this log already uses, just one call per occurrence
1048+
// instead of once per request.
1049+
if (pin && Array.isArray(result.suppressions) && result.suppressions.length) {
1050+
for (const s of result.suppressions) {
1051+
await appendTelemetry(
1052+
dir,
1053+
sessionKey,
1054+
{
1055+
ts: new Date().toISOString(),
1056+
key: sessionKey,
1057+
sid: sessionId,
1058+
event: "suppressed-duplicate",
1059+
index: s.index,
1060+
hash: s.hash,
1061+
},
1062+
fs,
1063+
);
1064+
}
1065+
}
1066+
9251067
if (isDebug()) {
9261068
process.stderr.write(
9271069
`[insertion-normalize] action=${result.action} inserted=${result.inserted ?? 0}` +
9281070
(result.resetReason ? ` reason=${result.resetReason}` : "") +
929-
(pin ? ` pinned=${result.pinned ?? 0} dropped=${result.dropped ?? 0}` : "") +
1071+
(pin ? ` pinned=${result.pinned ?? 0} dropped=${result.dropped ?? 0} suppressed=${result.suppressed ?? 0}` : "") +
9301072
"\n",
9311073
);
9321074
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// blockMigration — the reminder-swap shape self-identifies.
2+
//
3+
// The census reduces messages to semantic hashes and, for a
4+
// system-reminder-wrapped text block, drops it outright as decoration
5+
// (semanticCore's isVolatileTextBlock) — correct when the reminder really is
6+
// noise, and exactly what makes the census blind to the case where the same
7+
// bytes are NOT noise: they leave one message's content array and reappear
8+
// as a message of their own (measured directly in capture
9+
// s-633915a8, n=26->28, message[30]'s 5th block
10+
// -> the new message[31]). blockMigration is the check for that shape; see
11+
// tools/replay.mjs for the DEFINITION comment above findBlockMigrations.
12+
13+
import { test } from "node:test";
14+
import assert from "node:assert/strict";
15+
16+
import { findBlockMigrations } from "../tools/replay.mjs";
17+
18+
const text = (t) => ({ type: "text", text: t });
19+
const human = (t) => ({ role: "user", content: [text(t)] });
20+
const asst = (t) => ({ role: "assistant", content: [text(t)] });
21+
22+
// One capture entry as the replay loop builds it — same shape the other
23+
// replay tests (replay-edit-anchor.test.mjs, replay-gate-selfcheck.test.mjs)
24+
// pass to the checker functions, so findBlockMigrations's own asCompact call
25+
// exercises the same compactEntry path production traffic goes through.
26+
const conv = (msgs, n) => ({ n, ts: `t${n}`, key: "k", inMsgs: msgs, outMsgs: msgs, inTools: [], outTools: [] });
27+
28+
const REMINDER = "<system-reminder>\nPreToolUse:Edit hook additional context: do the thing\n</system-reminder>";
29+
const INNER = "PreToolUse:Edit hook additional context: do the thing";
30+
31+
test("BITE — a hook reminder detaching from its host message into a standalone message is annotated inline->standalone", () => {
32+
// Real shape: a user message carries a tool-output block AND a
33+
// <system-reminder>-wrapped block; the next request drops the wrapped
34+
// block from that message and adds a new standalone system message
35+
// carrying the SAME bytes, wrapper stripped — exactly what
36+
// PreToolUse:Edit's hook context does on the wire.
37+
const prev = [human("q1"), asst("a1"), { role: "user", content: [text("tool output"), text(REMINDER)] }, asst("a2")];
38+
const cur = [
39+
human("q1"),
40+
asst("a1"),
41+
{ role: "user", content: [text("tool output")] },
42+
{ role: "system", content: INNER },
43+
asst("a2"),
44+
];
45+
const rows = findBlockMigrations([conv(prev, 0), conv(cur, 1)]);
46+
assert.equal(rows.length, 1);
47+
assert.equal(rows[0].direction, "inline->standalone");
48+
assert.equal(rows[0].sourceIdx, 2, "the block's index in the message array where it was embedded");
49+
assert.equal(rows[0].targetIdx, 3, "the index of the new standalone message carrying the same bytes");
50+
assert.equal(rows[0].n, 1);
51+
assert.equal(rows[0].prevN, 0);
52+
});
53+
54+
test("fires-on-non-defect guard: a genuinely NOVEL inserted message is not annotated", () => {
55+
// Same splice/insert-mid shape (a new message lands mid-history, later
56+
// messages shift by one) but the inserted content has no counterpart
57+
// anywhere in the predecessor — nothing migrated, something new arrived.
58+
// A detector that fires here would train its reader to ignore the class.
59+
const prev = [human("q1"), asst("a1"), { role: "user", content: [text("tool output")] }, asst("a2")];
60+
const cur = [
61+
human("q1"),
62+
asst("a1"),
63+
{ role: "user", content: [text("tool output")] },
64+
{ role: "system", content: "totally novel content with no counterpart in prev, never existed before" },
65+
asst("a2"),
66+
];
67+
const rows = findBlockMigrations([conv(prev, 0), conv(cur, 1)]);
68+
assert.equal(rows.length, 0);
69+
});
70+
71+
test("a block still present at the SAME position on the other side is not a migration", () => {
72+
// Sanity companion to the guard above: the reminder block is untouched,
73+
// sitting at the identical index on both sides — an unrelated insertion
74+
// elsewhere forces the pair to splice/insert-mid so the scan actually
75+
// runs; asserting 0 here would be trivial if the pair were "identical"
76+
// (skipped by the kind filter before the scan ever executes).
77+
const reminderMsg = { role: "user", content: [text("tool output"), text(REMINDER)] };
78+
const prev = [human("q1"), asst("a1"), reminderMsg, asst("a2")];
79+
const cur = [
80+
human("q1"),
81+
asst("a1"),
82+
reminderMsg,
83+
{ role: "system", content: "unrelated novel content, forces splice/insert-mid" },
84+
asst("a2"),
85+
];
86+
const rows = findBlockMigrations([conv(prev, 0), conv(cur, 1)]);
87+
assert.equal(rows.length, 0);
88+
});

0 commit comments

Comments
 (0)