Skip to content

Commit 7c1ebb6

Browse files
feat(insertion-normalization): suppress the migrated duplicate — complete the pin (anthropics/claude-code#76606)
When CC migrates a hook reminder out of its tool_result into a standalone system message mid-history, the pin restores the first-seen inline form — but the migrated copy still forwarded, splicing the same content in twice (measured live: ~61 kB splice, 124k tokens re-billed on one turn). Now a standalone message whose wrapper-normalized bytes equal a live pinned block is suppressed: never forwarded, never given a canonical identity. Genuine changes (normalized bytes differ) still forward and reset per the existing rule; assistant-role messages are excluded on principle. Suppression is re-detected each request from the pin set — no new state file. One event line per suppression rides the insertion event log. The real-pair red-green check in the new test file needs the replay tooling and capture; in this slice it skips, and runs where the tools land (cnighswonger#276). 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 ebfc3ea commit 7c1ebb6

2 files changed

Lines changed: 561 additions & 11 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
}

0 commit comments

Comments
 (0)