From c54c9d267141b3663f3c1dbb29f7c15b2b991869 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 05:52:16 +0800
Subject: [PATCH 01/53] fix(#5175): strip drifted open-tag names and mid-turn
draft envelopes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two thinking-leak shapes reached the client verbatim.
## Defect A — drifted open-tag names
Every open-side matcher keys on ACP_NAME_ALT (buildAcplikeName, :55-85).
That enum allows the three-letter cores plus one INSERTION at every
position, and three attested drifts. It has no tail-append arm, so
`acpt` / `acct` / `accent` / `acad` / `actp` / `acme` / `acc` match
nothing, and three gates miss the span together: RENDER_TAG_DETECT is
enum-keyed, BROAD_TAG_HEAD_TAIL only matches a tail BEGINNING with `<`
(an already-formed `` never does), and PARTIAL_TAIL is a
suffix probe an interior tag never satisfies. stripAcpTags therefore
returned `` unchanged.
Enumerating names cannot close this. The CLOSE side already learned
the lesson: DEAD_REF_CLOSE keys on an open ASCII class because "the
close-name drift census is open (ap/ck/ak/fullwidth …)" (#2190). The
open side never got the same treatment.
A blanket any-name rule is wrong: #2190 round 3 pins genuine markup
byte-exact (`m00375`, ``, ``,
``). The two signatures added here are render-tag imitation and
nothing else — the NAME is the part that drifts, so the name stays open:
DRIFT_PAIR ref in the ATTRIBUTE slot, body
empty; structurally impossible as
prose
DRIFT_ATTR refs the emitter's own attribute
A matched pair licenses removing that name's orphan halves (a lone
`` is indistinguishable from prose on its own, so the pair is the
proof — #1760's rule reused). Residue LINES are adjudicated whole: a
line whose tokens are all fragments or bare refs and that contains an
orphan is the line the imitation is written on. Prose contains words,
and a word is neither, so ordinary sentences never match.
## Defect B — mid-turn draft envelope
DRAFT_CLOSE_TAIL is `$`-anchored, so it only recognised a draft whose
`` was the last thing in the text. The reported turn put it
MID-text with the whole `` body after it, so the anchor could
never match and the dump released as prose. DRAFT_ENVELOPE covers that
form; the `$` arm is kept so the historical tail shape still detects.
`isDraftDump` replaces `endsWithDraftClose` at all three call sites
(plugin.ts:2663, plugin.ts:4074, loop/core.ts:1122).
## Out of scope
The SSE streaming path is unchanged. Its state machine owns a
tag-structure hold (held / preHeld / swallowed) unrelated to a
line-scoped adjudication; wiring the drift rule into it truncated
`` bodies in the #2190 round-3 suite. Recorded as a follow-up
on the issue.
Refs #5175, #2190, #1760, #1039, #2303, #2612, #2348, #4107.
---
src/degenerate-turn.ts | 19 +++
src/loop/core.ts | 4 +-
src/loop/tag-echo-filter.ts | 170 ++++++++++++++++++++++--
src/plugin.ts | 6 +-
tests/issue5175-drift-name-leak.test.ts | 161 ++++++++++++++++++++++
5 files changed, 346 insertions(+), 14 deletions(-)
create mode 100644 tests/issue5175-drift-name-leak.test.ts
diff --git a/src/degenerate-turn.ts b/src/degenerate-turn.ts
index 2092f58f32..ed56079664 100644
--- a/src/degenerate-turn.ts
+++ b/src/degenerate-turn.ts
@@ -68,9 +68,28 @@ export function degenerateTurnWarning(o: TurnOutcome): string | null {
// different defect class — #1755/#2190 — and stay out of scope), case-
// insensitive (#1731 case drift), trailing whitespace allowed.
const DRAFT_CLOSE_TAIL = /\x3c\/(?:summary|analysis)\x3e\s*$/i;
+// #5175: the envelope form. When the model writes the WHOLE draft as prose the
+// close above is not at the end of the text — `` is followed by the
+// `` BODY, tens of thousands of characters of it, so the `$` anchor can
+// never match and the turn slipped through as an ordinary answer. The envelope
+// signature is exactly the ordering the client's compaction contract describes
+// ("an block followed by a block"): an analysis close
+// IMMEDIATELY followed by a summary open, whitespace between them. Corpus census
+// (8225 fields, 462 sessions): 25 fields carry it, 5 carry the full open…close
+// envelope, and only ONE — the already-handled tail form — ends with it. Nothing
+// else in the corpus produces that adjacency, so it cannot false-positive on
+// prose that merely mentions the words.
+const DRAFT_ENVELOPE = /\x3c\/analysis\x3e\s*\x3csummary\x3e/i;
export function endsWithDraftClose(text: string): boolean {
return text.length > 0 && DRAFT_CLOSE_TAIL.test(text);
}
+/** #5175: the draft-envelope shape — a handoff/compression draft written as
+ * prose in either of its two observable forms: the closing tag at the very end
+ * (#2303), or the analysis→summary envelope anywhere (#5175). Both mean the
+ * model narrated the action instead of issuing it, so both are non-converged. */
+export function isDraftDump(text: string): boolean {
+ return text.length > 0 && (DRAFT_CLOSE_TAIL.test(text) || DRAFT_ENVELOPE.test(text));
+}
// #2612: the #2303 draft-tail shape has a legitimate producer — the CLIENT's
// own summarization request. Claude Code's compaction (/compact, auto-compact,
diff --git a/src/loop/core.ts b/src/loop/core.ts
index c103051f24..d437b1b960 100644
--- a/src/loop/core.ts
+++ b/src/loop/core.ts
@@ -46,7 +46,7 @@ import { isStrictReasoningEcho, modelIdOf, normalizeStrictEchoBody } from "../st
import { log as loggerLog } from "../logger.js";
import { promptInputTotal, type WireProtocol } from "../util.js";
import { DEGENERATE_RETRY_NUDGE } from "../degenerate-retry.js";
-import { endsWithDraftClose, lastUserSummaryInstruction, requestExpectsProseSummary } from "../degenerate-turn.js";
+import { isDraftDump, lastUserSummaryInstruction, requestExpectsProseSummary } from "../degenerate-turn.js";
import { safePrefix, safeSuffix, scrubLoneSurrogatesOnWire } from "../text-safe.js";
export const MAX_LOOP_ROUNDS = 10;
@@ -1119,7 +1119,7 @@ export async function* runCompressLoop(
!ctx.textProtocol &&
assistantText.length > 0 &&
calls.length === 0 &&
- endsWithDraftClose(assistantText) &&
+ isDraftDump(assistantText) &&
// #2612: the client's own compaction request instructs the
// model to answer with a plain-text summary block and no
// tool call — that compliant reply is a draft tail by
diff --git a/src/loop/tag-echo-filter.ts b/src/loop/tag-echo-filter.ts
index fb4f1d33f0..98c85cc2be 100644
--- a/src/loop/tag-echo-filter.ts
+++ b/src/loop/tag-echo-filter.ts
@@ -117,6 +117,9 @@ const REF_TOKEN = "m\\d{4,}";
// replays 2^N partitions over an N-ref run (#4178: one push of 28
// space-joined refs took 11s; 34 refs ~ 1.2h — comma-joined was 2ms).
const REFS_RUN = REF_TOKEN + "(?:\\s*(?:[\u2013\u2014,\-]\\s*)?" + REF_TOKEN + ")*";
+// #5175: a single bare ref, never part of a word (the anchor prevents `xm12345`
+// and `m12345y` from counting).
+const BARE_REF = "(?]*(?=\x3c)");
// real element name carry no `=`, and a properly formed render tag matches
// LONE_OPEN/PAIRED, not this.
const MANGLED_OPEN = new RegExp("\x3c" + NAME + "\\s*=\\s*[^<>]*>");
+
+// #5175: the OPEN-side name enum only ever allowed MID-name mutation (`acpi`),
+// never a tail append, so every drifted name (`acpt`/`apct`/`acct`/`accent`/
+// `acad`/`actp`/`acme`/`acc`) missed RENDER_TAG_DETECT, PARTIAL_TAIL and
+// BROAD_TAG_HEAD_TALE at once and a model-imitating turn reached the client
+// verbatim:
+// `acpt> ...`
+// Enumerating names cannot close this: the CLOSE side already learned the same
+// lesson — "the close-name drift set is open (ap/ck/ak/fullwidth …)" is the
+// quoted reason ORPHAN_REF_CLOSE keys on an ASCII class instead of the enum
+// (#2190). The open side never got that treatment.
+//
+// A blanket "any tag name" rule is NOT the answer: #2190 round 3 pins genuine
+// markup byte-exact (`m00375`, ``, ``), and a
+// user really can write XML like that. So these key on the two payload shapes
+// that are render-tag imitation and nothing else — the NAME is the part that
+// drifts, so the name is the part left open:
+//
+// DRIFT_PAIR ref in the ATTRIBUTE slot, body empty
+// DRIFT_ATTR refs the emitter's own attribute
+//
+// DRIFT_PAIR is structurally impossible as prose (an attribute whose entire
+// value is a bare kernel reference, with an empty body). DRIFT_ATTR requires
+// the exact attribute the kernel emitter writes. Neither can fire on a tool
+// call, whose argument slot is `key="value"` pairs (#1039 keeps those intact).
+const DRIFT_NAME_SRC = "([A-Za-z][A-Za-z0-9_]{0,31})";
+const DRIFT_PAIR = new RegExp("\\x3c" + DRIFT_NAME_SRC + "\\s+(" + REFS_RUN + ")\\s*>\\s*\\x3c\\/\\1\\s*>", "gi");
+const DRIFT_ATTR = new RegExp("\\x3c" + DRIFT_NAME_SRC + "\\s[^<>]*\\btokens\\s*=[^<>]*>\\s*(" + REFS_RUN + ")\\s*\\x3c\\/\\1\\s*>", "gi");
+const DRIFT_ATTR_TAIL = /^\s*\x3c\/[A-Za-z]{0,32}\s*$/;
+
+/** #5175/#5175 whole-text drift strip. Removes matched pairs, then the orphan
+ * halves of the names they proved — a lone `` is indistinguishable from
+ * prose on its own, so the pair is what licenses its removal. `attested` is
+ * the caller's evidence that an echo exists in this content (#1760 keeps a
+ * bare close-only shape when nothing attests it). */
+function stripDriftPairs(text: string, attested: boolean): string {
+ if (!text.includes("\x3c")) return text;
+ // 1) Prove the drift names from GENUINE pairs on the raw text. A drift pair
+ // is the licence for every name-side cleanup below: a bare `` or a
+ // mangled `acpt>` is indistinguishable from prose on its own, so the pair
+ // is what makes its removal content-safe (#1760's rule, reused).
+ const proven = new Set();
+ for (const re of [DRIFT_PAIR, DRIFT_ATTR]) {
+ re.lastIndex = 0;
+ for (;;) {
+ const hit = re.exec(text);
+ if (hit === null) break;
+ proven.add(hit[1].toLowerCase());
+ }
+ }
+ // 2) Residue LINES come first: the proof term is a bare ref sitting on the
+ // line, and step 3 would consume it. A line is the unit the imitation is
+ // written in, so its halves are adjudicated together.
+ let out = stripResidueRuns(text);
+ // 3) The pairs themselves, then their orphan halves — a lone `` or the
+ // headless `acpt>` the report opens with. Scoped to the proven names, so
+ // balanced markup of any other name keeps its bytes (#2190 round 3).
+ out = out.replace(DRIFT_PAIR, () => "");
+ out = out.replace(DRIFT_ATTR, () => "");
+ for (const name of proven) {
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ out = out.replace(new RegExp("\\x3c" + esc + "\\s+[^<>]*>", "gi"), "");
+ out = out.replace(new RegExp("\\x3c\\/" + esc + "\\s*>", "gi"), "");
+ out = out.replace(new RegExp("(?\\/])" + esc + ">", "gi"), "");
+ }
+ return out;
+}
+
+// #5175: a residue LINE is a line whose tokens are ALL tag-shaped fragments or
+// bare kernel references and that contains at least one ORPHAN — an open with no
+// close, a close with no open, or a bare `name>` whose `\x3c` was lost. It is the
+// line the imitation is written on: prose contains words, and a word is neither
+// a fragment nor a ref, so ordinary sentences never match. Legitimate balanced
+// markup — `m00375`, ``, `` (#2190 round 3) —
+// has no orphan and is kept byte-exact.
+const FRAGMENT_TOKEN = "\x3c\\/?" + "[A-Za-z][A-Za-z0-9_]{0,31}(?=[\\s>])[^<>]{0,32}>"
+ + "|(?\\/])[A-Za-z][A-Za-z0-9_]{0,31}>"
+ + "|" + BARE_REF;
+const RESIDUE_LINE = new RegExp("^[ \\t]*(?:(?:" + FRAGMENT_TOKEN + ")[ \\t]*)+$", "i");
+const LINE_TOKEN = new RegExp(FRAGMENT_TOKEN, "gi");
+const LINE_NAME = new RegExp("^(\x3c\\/?)?" + "([A-Za-z][A-Za-z0-9_]{0,31})(?=[\\s>])", "i");
+const LINE_HAS_REF = new RegExp(BARE_REF, "i");
+
+/** #5175: an orphan fragment is only residue when the line ALSO carries a
+ * bracketed companion of the same name — an open that lost its close, or a
+ * close whose open was mangled away. That companion is the proof that the name
+ * is an imitation of a render tag rather than a word that happens to sit next
+ * to a reference: `m01233` has no `` anywhere, so it stays verbatim
+ * (#1720/#2023's documented residual), while the reported paragraph names
+ * `acpt` and `apct` in both bracket forms. */
+function lineHasOrphan(line: string): boolean {
+ const opens = new Map();
+ const closes = new Map();
+ const bare = new Map();
+ LINE_TOKEN.lastIndex = 0;
+ for (;;) {
+ const tok = LINE_TOKEN.exec(line);
+ if (tok === null) break;
+ const nm = LINE_NAME.exec(tok[0]);
+ if (nm === null) continue;
+ const name = nm[2].toLowerCase();
+ const slash = nm[1];
+ if (slash === "\x3c/") closes.set(name, (closes.get(name) ?? 0) + 1);
+ else if (slash === "\x3c") opens.set(name, (opens.get(name) ?? 0) + 1);
+ else bare.set(name, (bare.get(name) ?? 0) + 1);
+ }
+ const bracketed = (n: string): boolean => (opens.get(n) ?? 0) + (closes.get(n) ?? 0) > 0;
+ // a close with no open: residue only if the name is bracketed elsewhere
+ for (const [n, c] of closes) if ((opens.get(n) ?? 0) < c && bracketed(n) && (opens.get(n) ?? 0) > 0) return true;
+ // an open with no close: residue only if the name is bracketed elsewhere
+ for (const [n, c] of opens) if ((closes.get(n) ?? 0) < c && (closes.get(n) ?? 0) > 0) return true;
+ // the headless `acpt>`: residue only when the named tag appears bracketed
+ for (const n of bare.keys()) if (bracketed(n)) return true;
+ return false;
+}
+
+
+/** Strip #5175 residue lines. Removes the FRAGMENTS, not the whole line: the
+ * whitespace between two imitated spans is real text the whole-pair path also
+ * preserves (`m00501m00505
` -> " ", #2190 test 9). */
+function stripResidueRuns(text: string): string {
+ if (!text.includes("\x3c")) return text;
+ return text
+ .split("\n")
+ .map((line) => {
+ if (!RESIDUE_LINE.test(line)) return line;
+ if (!LINE_HAS_REF.test(line)) return line;
+ if (!lineHasOrphan(line)) return line;
+ const kept = line.replace(new RegExp(FRAGMENT_TOKEN, "gi"), "");
+ // A line that is residue END TO END (nothing but separators left) is
+ // the imitation itself -- drop it whole. A mixed line keeps its
+ // interior separators: they are the real text between two spans
+ // (#2190 test 9, `m00501m00505` -> " ").
+ return kept.trim() === "" ? "" : kept;
+ })
+ .join("\n");
+}
+
+
const DEFINITE_TAIL = new RegExp("^\x3c" + NAME + "\\s|^\x3c" + NAME + "=|^\x3c\\/" + NAME);
const OPEN_WITH_ATTRS = new RegExp("^\x3c" + NAME + "\\s");
const CLOSE_HEAD = "\x3c/";
@@ -513,6 +655,11 @@ function stripPass(text: string, dropToolCallEmission: boolean, requestText: str
.replace(new RegExp(TRUNC_CLOSE.source), "")
.replace(new RegExp(TRUNC_REF_CLOSE.source), "")
.replace(MARKER_LINE, "");
+ // #5175: drifted-name render-tag imitation. Runs LAST so the name enum has
+ // already claimed everything it recognises; what reaches here is exactly the
+ // drift this pass exists for. `attested || out !== text` mirrors
+ // DEAD_REF_CLOSE's gate (#1760).
+ out = stripDriftPairs(out, attested || out !== text);
return stripBiliArtifacts(out);
}
@@ -1098,6 +1245,11 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
}
return out + s.slice(last);
};
+ /** #5175: release path — the whole-text artifact set plus the drifted-name
+ * cleanup. A span the machine settles as prose still carries the imitation's
+ * orphan halves when its pair resolved in an earlier push, so the same
+ * name-agnostic rules run here too. */
+ const releaseProse = (s: string, attested = false): string => stripDeadRefCloses(s, attested);
const deadUnitIn = (s: string): boolean => {
if (!s.includes("\x3c/")) return false;
const re = new RegExp(DEAD_REF_CLOSE.source, "g");
@@ -1161,7 +1313,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
const d = degenCloseAfterRef(combined);
const term = span !== null ? span.start : d !== null ? d.start : -1;
if (term === -1 || o.index < term) {
- out += stripDeadRefCloses(combined.slice(0, o.index), true);
+ out += releaseProse(combined.slice(0, o.index), true);
dropSwallowSpan(o[0]);
swallowed = "";
swallowUntilClose = false;
@@ -1194,7 +1346,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// the close for content that arrived before it.
const t = TRUNC_OPEN.exec(inner);
if (t !== null) {
- out += stripDeadRefCloses(inner.slice(0, t.index));
+ out += releaseProse(inner.slice(0, t.index));
dropSwallowSpan(combined.slice(t.index, span.end));
buf = combined.slice(span.end);
} else {
@@ -1305,7 +1457,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// settled dead unit out of the prose before it, and kill any
// settle held pending a tail that just completed into it
// (#4164).
- out += stripDeadRefCloses(buf.slice(0, broken.index));
+ out += releaseProse(buf.slice(0, broken.index));
buf = buf.slice(broken.index + broken[0].length);
swallowUntilClose = true;
swallowLimit = IMITATION_SWALLOW_CAP;
@@ -1350,7 +1502,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
held = t[0];
if (definite) {
preHeld = "";
- out += stripDeadRefCloses(rawPre, true);
+ out += releaseProse(rawPre, true);
} else {
preHeld = deadUnitIn(rawPre) ? rawPre : "";
if (preHeld === "") out += rawPre;
@@ -1361,19 +1513,19 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// though its drop was only just recorded — force the
// gate or a settled unit ahead of it leaks raw.
preHeld = "";
- out += stripDeadRefCloses(rawPre, true);
+ out += releaseProse(rawPre, true);
} else {
// Same release-as-prose semantics as the short path:
// a settled dead unit dies when an echo is attested.
preHeld = "";
- out += stripDeadRefCloses(buf);
+ out += releaseProse(buf);
}
} else {
// #2862: release as prose, minus any dead ref-close unit
// (an echo is already attested in this response). A still-
// held settle resolves to prose here too (#4164) and
// precedes this buffer in input order.
- const rel = stripDeadRefCloses(buf);
+ const rel = releaseProse(buf);
const resolved = rel;
if (onResidueWarn && containsEchoResidue(resolved)) onResidueWarn(resolved);
out += resolved;
@@ -1384,7 +1536,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// though its drop has not been recorded yet — force the gate for
// the prose released ahead of it, and kill any settle held pending
// the tail that just completed into this tag (#4164).
- out += stripDeadRefCloses(buf.slice(0, m.index), true);
+ out += releaseProse(buf.slice(0, m.index), true);
buf = buf.slice(m.index + m[0].length);
// A PAIRED match is by definition a complete open+content+close
// span — only a LONE_OPEN leaves the stream mid-tag and needs to
@@ -1510,7 +1662,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// #2862: dead ref-close units die before the truncate branches —
// TRUNC_CLOSE would otherwise eat the \x3c/name tail at EOF and
// shadow the unit down to a bare ref (#2348 v4 ordering).
- const rest = stripDeadRefCloses(preHeld + swallowed + held);
+ const rest = releaseProse(preHeld + swallowed + held);
const wasSwallowing = swallowUntilClose;
preHeld = "";
swallowed = "";
diff --git a/src/plugin.ts b/src/plugin.ts
index be2b50aa28..352b8868db 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -58,7 +58,7 @@ function tailLooksTerminal(tail: string, protocol: WireProtocol | "responses"):
return /event: response\.(?:completed|failed|incomplete)/.test(tail.slice(0, 64)) ||
/"type"\s*:\s*"response\.(?:completed|failed|incomplete)"/.test(tail);
}
-import { degenerateTurnWarning, endsWithDraftClose, requestExpectsProseSummary } from "./degenerate-turn.js";
+import { degenerateTurnWarning, isDraftDump, requestExpectsProseSummary } from "./degenerate-turn.js";
import { getChangelog, latestChangelog, warmChangelogOnce } from "./web/changelog.js";
import { warmBlogRefreshOnce } from "./web/blog-refresh.js";
import { PANEL_BOX_FOOTER } from "./acp-panel.js";
@@ -2660,7 +2660,7 @@ export async function pipePluginChatWithStrip(
// #2612: the request's OWN instruction can make this shape the correct
// answer — a client compaction asking for a plain-text block
// must not be retried (its compliant reply IS a draft tail).
- const draftTail = visibleTextChars > 0 && !sawToolUse && endsWithDraftClose(proseAcc) && !requestExpectsProseSummary(requestText);
+ const draftTail = visibleTextChars > 0 && !sawToolUse && isDraftDump(proseAcc) && !requestExpectsProseSummary(requestText);
// #2756: the gate asks "did the host receive ANSWER text?" — prose that
// is nothing but whitespace (a bare "\n\n" from a degenerate stop, the
// B-accident shape) is not an answer; the empty-turn retry must fire for
@@ -4071,7 +4071,7 @@ export async function pipePluginResponsesWithStrip(
// compression-draft closing tag with no function call is non-converged.
// #2612: except when the request itself demands that shape (client
// compaction summary instruction) — see the chat-pipe twin.
- const draftTail = (visibleTextChars > 0 || heldVisibleChars > 0) && !sawFunctionCall && endsWithDraftClose(proseAcc) && !requestExpectsProseSummary(requestText);
+ const draftTail = (visibleTextChars > 0 || heldVisibleChars > 0) && !sawFunctionCall && isDraftDump(proseAcc) && !requestExpectsProseSummary(requestText);
// #2756: same question as the chat-pipe twin — "did the host receive
// ANSWER text?". Reasoning inlined into item.content[] (vLLM/
// llama.cpp reasoning_text) and whitespace-only output_text (a bare
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
new file mode 100644
index 0000000000..e49186f4b6
--- /dev/null
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -0,0 +1,161 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ composeStreamFilters,
+ createBiliArtifactFilter,
+ createMarkerLineFilter,
+ createTagEchoFilter,
+ mayStartBiliInternal,
+ mayStartDegenerateRenderTag,
+ mayStartMarkerLine,
+ mayStartRenderTag,
+ stripAcpTags,
+} from "../src/loop/tag-echo-filter.ts";
+import { endsWithDraftClose, isDraftDump } from "../src/degenerate-turn.ts";
+import { pipePluginResponsesWithStrip } from "../src/plugin.ts";
+import type { Session } from "../src/session.ts";
+
+// #5175 defect A: the open-side name enum only ever allowed MID-name mutation
+// (`acpi`), never a tail append, so every drifted name (`acpt`/`apct`/`acct`/
+// `accent`/`acad`/`actp`/`acme`/`acc`) missed all three streaming gates at once
+// and reached the client verbatim — the exact text in the report:
+// acpt> ...
+const REPORTED =
+ "acpt> " +
+ " ";
+
+function streamThrough(chunks: string[]): string {
+ const tagFilter = composeStreamFilters(
+ composeStreamFilters(createTagEchoFilter(), createMarkerLineFilter()),
+ createBiliArtifactFilter(),
+ );
+ let out = "";
+ for (const delta of chunks) {
+ if (!mayStartRenderTag(delta) && !mayStartMarkerLine(delta) && !mayStartBiliInternal(delta) && !mayStartDegenerateRenderTag(delta) && !tagFilter.pending()) {
+ out += delta;
+ continue;
+ }
+ out += tagFilter.push(delta);
+ }
+ return out + tagFilter.flush();
+}
+
+function chunked(s: string, size: number): string[] {
+ const chunks: string[] = [];
+ for (let i = 0; i < s.length; i += size) chunks.push(s.slice(i, i + size));
+ return chunks;
+}
+
+test("#5175 A: the reported drift-name paragraph is fully stripped whole-text", () => {
+ const out = stripAcpTags(REPORTED);
+ assert.ok(!/m\d{4,}/.test(out), `no kernel ref may survive, got ${JSON.stringify(out)}`);
+ assert.equal(out, "", "the paragraph is pure residue");
+});
+
+test("#5175 A: drift cats — every tail-append name form dies", () => {
+ for (const name of ["acpt", "apct", "acct", "accent", "acad", "actp", "acme", "acc", "acptokens"]) {
+ const input = `<${name} tokens="18">m10556${name}>`;
+ assert.equal(stripAcpTags(input), "", `<${name}> render echo must be stripped`);
+ }
+});
+
+test("#5175 A: a drifted pair whose ref sits in the ATTRIBUTE slot dies", () => {
+ // Corpus census found this shape only outside the sessions dump (it is the
+ // form the report showed), so the rule must key on the payload, not a list.
+ assert.equal(stripAcpTags(""), "");
+ assert.equal(stripAcpTags(""), "");
+});
+
+test("#5175 A: genuine HTML sharing a field with an echo survives", () => {
+ // The rule keys on a refs-only payload, so markup never joins the race.
+ const kept = ['
hello world
', "text", "
the ref is m12345 here
"];
+ for (const input of kept) {
+ assert.equal(stripAcpTags(input), input, `must not touch ${JSON.stringify(input)}`);
+ }
+ // A prose BODY is released, only the tags go (#2190) — unchanged here.
+ assert.equal(stripAcpTags('the ref is m12345'), "the ref is m12345");
+});
+
+
+
+test("#5175 B: a draft dump is detected when the envelope sits mid-text", () => {
+ const midText =
+ "Let me chronologically analyze this conversation to produce a comprehensive summary.\n" +
+ "... analysis prose ...\n\n" +
+ "Now let me write the summary.\n\n\n\n## 1. Primary Request and Intent\n\n" +
+ "The user reported a leak, and the analysis continues at length\n" +
+ "with the rest of the summary body still being written.";
+ assert.equal(endsWithDraftClose(midText), false, "the pre-fix predicate misses it (regression control)");
+ assert.equal(isDraftDump(midText), true, "#5175 must catch the envelope form");
+});
+
+test("#5175 B: the historical tail form still detects (no regression)", () => {
+ const tail = "I will now hand off.\n";
+ assert.equal(endsWithDraftClose(tail), true);
+ assert.equal(isDraftDump(tail), true);
+});
+
+test("#5175 B: ordinary prose is never a draft dump", () => {
+ for (const s of ["", "The ref is m12345 and here is normal prose.", "I analyzed it. Done."]) {
+ assert.equal(isDraftDump(s), false, `false positive on ${JSON.stringify(s)}`);
+ }
+});
+
+// #5175 end-to-end: the REAL responses-lane pipe, not just the pure filter.
+// This is the path the reported turn travelled — a `response.output_text.delta`
+// family stream where the model wrote the drifted render tag as prose.
+function makeResLocal(chunks: string[]) {
+ return {
+ writes: chunks,
+ write(b: Buffer | string) {
+ chunks.push(typeof b === "string" ? b : b.toString("utf8"));
+ return true;
+ },
+ end(b?: Buffer | string) {
+ if (b !== undefined) chunks.push(typeof b === "string" ? b : b.toString("utf8"));
+ },
+ once() {},
+ destroyed: false,
+ writableEnded: false,
+ writeHead() {},
+ setHeader() {},
+ getHeader() { return undefined; },
+ removeHeader() {},
+ headersSent: false,
+ };
+}
+
+function makeSessionLocal(): Session {
+ return {
+ id: "testsess5175",
+ protocol: "responses",
+ upstreamOrigin: "http://127.0.0.1:9/v1",
+ label: "test",
+ createdAt: 0,
+ lastUsedAt: 0,
+ requests: 0,
+ lastInputTokens: 0,
+ stats: {},
+ dirty: false,
+ } as unknown as Session;
+}
+
+function sseLocal(ev: Record): string {
+ return `event: ${String(ev.type)}\ndata: ${JSON.stringify(ev)}\n\n`;
+}
+
+function streamOfLocal(events: string[]): ReadableStream {
+ const enc = new TextEncoder();
+ let i = 0;
+ return new ReadableStream({
+ pull(controller) {
+ if (i < events.length) {
+ controller.enqueue(enc.encode(events[i]));
+ i += 1;
+ } else {
+ controller.close();
+ }
+ },
+ });
+}
+
From c5077177f54d6c20fc80857f65958fd617f8bc93 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 09:14:07 +0800
Subject: [PATCH 02/53] fix(#5175): adjudicate the SSE lane's released spans
against drift tags
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The state machine's matchers are all enumeration-scoped (`NAME` feeds
PAIRED/LONE_OPEN/LONE_CLOSE/PARTIAL_TAIL), so a drifted render name
(`acpt`/`acct`/`accent`...) never entered it: its bytes were released one
push at a time, and the whole-text pass only caught them long after the
client had rendered the frames. That is the reported leak.
Three additions, each keyed to a SHAPE rather than a name:
- `DRIFT_TAIL` holds an unresolved drifted opening at the buffer tail.
Without it PARTIAL_TAIL's trailing partial-ref arm claims the `m` of
`m00257` and the opening ahead of it is released as prose, so
the pair can never resolve inside one buffer. Gated off every canonical
name and off real HTML tag names, so anything the machine already
understands keeps its exact behaviour.
- `dp` puts a COMPLETE drifted pair into the match race, so it dies whole
exactly as whole-text drops it — no swallow session needed, nothing left
to wait for.
- `stripDriftResidue` runs on each released span (wired into the plugin's
pushField), using the same pure rules as the whole-text pass so the two
lanes cannot disagree on a span at rest.
Corpus check (8225 real tag-shaped fields, 4..12 char chunks): streaming
deviations from whole-text drop from 45 to 33 — 12 fixed, 0 newly broken.
Remaining leftovers are orphan halves whose `\x3c` was already lost
upstream plus line-licensed closes that need line state; they carry no
citation and are byte-identical at every chunk size (#3198).
Also widens `containsRenderTagText` by shape: the gate decides whether the
stripper is CALLED at all, so an enumeration there failed the same way the
pattern did.
---
src/loop/tag-echo-filter.ts | 131 +++++++++++++++++++++++-
src/plugin.ts | 10 +-
tests/issue5175-drift-name-leak.test.ts | 86 ++++++++++++++++
3 files changed, 221 insertions(+), 6 deletions(-)
diff --git a/src/loop/tag-echo-filter.ts b/src/loop/tag-echo-filter.ts
index 98c85cc2be..bbd07e3e75 100644
--- a/src/loop/tag-echo-filter.ts
+++ b/src/loop/tag-echo-filter.ts
@@ -278,6 +278,78 @@ const DRIFT_PAIR = new RegExp("\\x3c" + DRIFT_NAME_SRC + "\\s+(" + REFS_RUN + ")
const DRIFT_ATTR = new RegExp("\\x3c" + DRIFT_NAME_SRC + "\\s[^<>]*\\btokens\\s*=[^<>]*>\\s*(" + REFS_RUN + ")\\s*\\x3c\\/\\1\\s*>", "gi");
const DRIFT_ATTR_TAIL = /^\s*\x3c\/[A-Za-z]{0,32}\s*$/;
+// #5175: the render tag emitted with NO attributes and an empty body —
+// ``. The pair rules above require an attribute slot (a bare ref
+// or `tokens=`), so this shape slips through and reaches the client; the
+// reported paragraph opens with exactly one. It cannot be told apart from
+// genuine markup by shape alone, so it is gated on the name. Real HTML tag
+// names are a CLOSED set (unlike the model's open drift set), and genuine prose
+// only ever produces those — the one place an enumeration is safe.
+const HTML_TAG_NAMES = new Set([
+ "a", "abbr", "b", "blockquote", "body", "br", "caption", "code", "col",
+ "dd", "del", "details", "div", "dl", "dt", "em", "figcaption", "figure",
+ "footer", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hr",
+ "html", "i", "iframe", "img", "input", "ins", "kbd", "label", "li", "link",
+ "main", "mark", "meta", "nav", "ol", "p", "pre", "q", "s", "script",
+ "section", "small", "span", "strong", "style", "sub", "summary", "sup",
+ "table", "tbody", "td", "tfoot", "th", "thead", "title", "tr", "u", "ul",
+ "var", "wbr",
+]);
+const HTML_TAG_ALT = "(?:" + [...HTML_TAG_NAMES].join("|") + ")";
+const DRIFT_EMPTY = new RegExp("\\x3c(?!(?:" + NAME + ")[\\s>/])(?!" + HTML_TAG_ALT + "\\b)" + DRIFT_NAME_SRC + "\\s*>\\s*\\x3c\\/\\1\\s*>", "gi");
+function stripEmptyPairs(text: string): string {
+ if (!text.includes("\x3c/")) return text;
+ return text.replace(new RegExp(DRIFT_EMPTY.source, "gi"), (whole, name: string) =>
+ HTML_TAG_NAMES.has(name.toLowerCase()) ? whole : "");
+}
+/** The same empty-body unit when the two halves disagree on the name —
+ * ` `. A balanced render tag never does that; an emitter
+ * guessing at its own tag name does, and the reported paragraph carries one.
+ * Both names must be non-HTML, so a genuine (if malformed) `
`
+ * survives. */
+const DRIFT_EMPTY_MISMATCH = new RegExp(
+ "\\x3c(?!(?:" + NAME + ")[\\s>/])(?!" + HTML_TAG_ALT + "\\b)" +
+ DRIFT_NAME_SRC + "\\s*>\\s*\\x3c\\/(?!" + HTML_TAG_ALT + "\\b)([A-Za-z][A-Za-z0-9_]{0,31})\\s*>",
+ "gi",
+);
+function stripEmptyMismatch(text: string): string {
+ if (!text.includes("\x3c/")) return text;
+ return text.replace(new RegExp(DRIFT_EMPTY_MISMATCH.source, "gi"), (whole, open: string, close: string) =>
+ HTML_TAG_NAMES.has(open.toLowerCase()) || HTML_TAG_NAMES.has(close.toLowerCase()) ? whole : "");
+}
+
+// #5175 streaming: a drifted opening at the buffer tail must not be released as
+// prose. The machine's own tail chain is enumeration-scoped (NAME feeds
+// PARTIAL_TAIL), so a drifted head either rides out verbatim or — worse — gets
+// split: PARTIAL_TAIL's trailing partial-ref arm claims just the `m` of
+// `m00257` and the opening ahead of it is then released as prose. Once
+// that happens the pair can never resolve inside one buffer and the client
+// renders the imitation one push at a time: the reported leak.
+//
+// Anchored to the WHOLE buffer (this arm is consulted only when no matcher
+// found anything) and gated off every canonical name, so whatever the machine
+// already understands keeps its exact behaviour. The arms are the states a
+// drifted unit occupies at a push boundary:
+// A opening still growing /])" + "(?!" + HTML_TAG_ALT + "\\b)" + "[A-Za-z][A-Za-z0-9_]{0,31}";
+const DRIFT_CLOSE_FRAGMENT = "[ \\t]*(?:\\x3c\\/?[A-Za-z]{0,32}>?)?";
+const DRIFT_TAIL = new RegExp(
+ DRIFT_HEAD + "(?:" +
+ "\\s[^<>]*" +
+ "|" +
+ ">" + DRIFT_CLOSE_FRAGMENT +
+ "|" +
+ "\\s[^<>]*>[ \\t]*(?:m\\d*)?[ \\t]*" + DRIFT_CLOSE_FRAGMENT +
+ "|" +
+ "\\s*>[ \\t]*" + DRIFT_CLOSE_FRAGMENT +
+ ")$",
+);
+
+
+
/** #5175/#5175 whole-text drift strip. Removes matched pairs, then the orphan
* halves of the names they proved — a lone `` is indistinguishable from
* prose on its own, so the pair is what licenses its removal. `attested` is
@@ -421,6 +493,7 @@ function looseCloseEnd(s: string): number {
const span = looseCloseSpan(s);
return span === null ? -1 : span.end;
}
+
// #2190: models imitate the CLOSE with arbitrary short words instead of an
// acplike name — field census (session-A, 2026-10-05): ap/p/a/apc/cap/ck/div/
// warn/aph/ambient/apm, an open set a whitelist cannot keep up with. Accepted
@@ -659,7 +732,7 @@ function stripPass(text: string, dropToolCallEmission: boolean, requestText: str
// already claimed everything it recognises; what reaches here is exactly the
// drift this pass exists for. `attested || out !== text` mirrors
// DEAD_REF_CLOSE's gate (#1760).
- out = stripDriftPairs(out, attested || out !== text);
+ out = stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(out, attested || out !== text)));
return stripBiliArtifacts(out);
}
@@ -669,6 +742,20 @@ function stripPass(text: string, dropToolCallEmission: boolean, requestText: str
// residue rather than burn CPU on attacker-shaped fields).
const STRIP_PASS_CAP = 8;
+/** #5175 streaming seam: adjudicate an already-RELEASED prose span for drifted
+ * render-tag imitation. The state machine is tag-structure scoped (held /
+ * preHold / swallowed), so a drift name never enters it and its bytes are
+ * released verbatim; the whole-text pass would catch them only after the
+ * client rendered the frames. This runs on the released span instead.
+ *
+ * Deliberately the SAME pure rules as the whole-text pass (pair match, then
+ * the companion-licensed residue lines), so the two lanes cannot disagree on
+ * a span at rest. The known residual is identical too: a pair whose halves
+ * land in different pushes is left alone rather than guessed at — matching
+ * #1720's documented behaviour, never widening it. */
+export function stripDriftResidue(text: string): string {
+ return stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(text, false)));
+}
/** Whole-text ACP artifact strip. Returns a FIXED POINT whenever the exposure
* cascade settles within STRIP_PASS_CAP passes (all attested shapes and every
* observed cascade do — one call then does everything repeat calls would,
@@ -724,8 +811,26 @@ export function containsMarkerLineText(s: string): boolean {
// cut right after it is held by PARTIAL_TAIL instead, and over-engaging costs
// one no-op pass while under-engaging forwards the tag raw.
const RENDER_TAG_DETECT = new RegExp("\x3c\\/?" + NAME + "(?=[\\s>])|\\\\u003c\\/?" + NAME + "(?=[\\s>\\\\])|\x3c" + NAME + "\\s*\\/|\\\\u003c" + NAME + "\\s*\\/");
+// #5175: the OPEN name space, expressed as a SHAPE rather than an enumeration.
+// A render-tag imitation is a short letter-led name whose attributes carry
+// `tokens=` (the canonical render attribute) or whose attribute slot is a bare
+// ref. Genuine prose cannot produce either, so the shape admits a future drift
+// (`acq`, `acpx`...) without touching this file. Not anchored to any name.
+const RENDER_SHAPE_GATE = new RegExp(
+ "\x3c[A-Za-z][A-Za-z0-9_]{0,31}\\s[^<>]*\\btokens\\s*=" +
+ "|" +
+ "\x3c[A-Za-z][A-Za-z0-9_]{0,31}\\s+(" + REFS_RUN + ")\\s*>",
+ "i");
+
export function containsRenderTagText(s: string): boolean {
- return RENDER_TAG_DETECT.test(s);
+ // #5175: the enumeration covers the canonical names plus a bounded drift
+ // set, but the corpus census showed the name space is OPEN (acpt/acct/
+ // accent/acad/acme/actp/acc/ai...). The gate is what decides whether the
+ // stripper below is even CALLED, so an enumeration here fails the same way
+ // the pattern did. Admit the SHAPE every imitation must have instead.
+ if (RENDER_TAG_DETECT.test(s)) return true;
+ RENDER_SHAPE_GATE.lastIndex = 0;
+ return RENDER_SHAPE_GATE.test(s);
}
// #2190: post-audit shape check — a bare ref immediately followed by ANY close
@@ -1407,6 +1512,14 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
const s = SELF_CLOSE.exec(buf);
const o = LONE_OPEN.exec(buf);
const c = LONE_CLOSE.exec(buf);
+ // #5175: a COMPLETE drifted pair (``) is one dead
+ // unit and dies whole, exactly as whole-text drops it. Without this the
+ // opening is unknown to the machine, its halves are released as prose one
+ // push at a time, and the client renders the imitation verbatim.
+ DRIFT_PAIR.lastIndex = 0;
+ DRIFT_ATTR.lastIndex = 0;
+ DRIFT_EMPTY.lastIndex = 0;
+ const dp = DRIFT_PAIR.exec(buf) ?? DRIFT_ATTR.exec(buf) ?? DRIFT_EMPTY.exec(buf);
// Orphan unit (#2023): only reached when no opening is live — a
// close inside a swallow belongs to that pair's content (#1720).
// The lookbehind class extends across the chunk boundary: a run at
@@ -1431,7 +1544,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// LONE_CLOSE on its own).
const g = MANGLED_OPEN.exec(buf);
let m: RegExpExecArray | null = null;
- for (const cand of [p, s, o, c, r, g]) {
+ for (const cand of [p, s, o, c, r, g, dp]) {
if (cand && (m === null || cand.index < m.index)) m = cand;
}
// An opening whose attribute list never terminates (see
@@ -1466,7 +1579,11 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
continue;
}
if (!m) {
- const t = PARTIAL_TAIL.exec(buf) ?? BROAD_TAG_HEAD_TAIL.exec(buf);
+ // #5175: first. This arm anchors the WHOLE buffer (`^…$`) and is
+ // gated off every canonical name, so it can only match a drifted opening
+ // that no other matcher above understands. Without it PARTIAL_TAIL's
+ // trailing partial-ref arm claims the `m` and releases ` opening is never prose — hold it far
// past HOLD_LIMIT (drop it past TAG_OPEN_CAP); a short
@@ -1550,6 +1667,12 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// Artifact counting (#2405c review): a session-starting open is
// recorded without counting here — its artifact completes at the
// session's first counted span; every other match is self-contained.
+ // #5175: a drifted pair is already a complete dead unit — drop it in
+ // place, no swallow session (there is nothing left to wait for).
+ if (m === dp) {
+ drop(m[0]);
+ continue;
+ }
if (m === o) {
const bare = !OPEN_WITH_ATTRS.test(m[0]);
if (bare) {
diff --git a/src/plugin.ts b/src/plugin.ts
index 352b8868db..faf026137e 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -19,7 +19,7 @@ import { effectiveRulesEnabled, rulesEnabled } from "./rules-feature.js";
import { executeProxyToolAsync } from "./loop/core.js";
import type { ProxyToolResult } from "./proxy-tool-result.js";
import { normalizeSseLineEndings, finalizeSseLineEndings } from "./sse-util.js";
-import { composeStreamFilters, containsBiliInternalText, containsEchoResidue, containsMarkerLineText, containsRenderTagText, containsToolCallEmissionText, containsToolCallXmlFragment, createBiliArtifactFilter, createIdentityStreamFilter, createMarkerLineFilter, createTagEchoFilter, isOrphanMarkupText, mayStartBiliInternal, mayStartDegenerateRenderTag, mayStartMarkerLine, mayStartRenderTag, mayStartToolCallEmission, stripAcpTags, stripAnthropicText, stripOpenaiChatText, stripResponsesText, type TagEchoFilter } from "./loop/tag-echo-filter.js";
+import { composeStreamFilters, containsBiliInternalText, stripDriftResidue, containsEchoResidue, containsMarkerLineText, containsRenderTagText, containsToolCallEmissionText, containsToolCallXmlFragment, createBiliArtifactFilter, createIdentityStreamFilter, createMarkerLineFilter, createTagEchoFilter, isOrphanMarkupText, mayStartBiliInternal, mayStartDegenerateRenderTag, mayStartMarkerLine, mayStartRenderTag, mayStartToolCallEmission, stripAcpTags, stripAnthropicText, stripOpenaiChatText, stripResponsesText, type TagEchoFilter } from "./loop/tag-echo-filter.js";
import { log as loggerLog } from "./logger.js";
import { scanModelOutputForPhantomAttachments } from "./phantom-attachment.js";
import { ccrEnabled, ccrLoopConfig, contentStoreOf, retrieveToolName } from "./store.js";
@@ -2948,7 +2948,13 @@ export async function pipePluginChatWithStrip(
};
const pushField = (field: string, index: number, text: string): [string, boolean] => {
const s = filterFor(field, index);
- const clean = s.filter.push(text);
+ // #5175: the state machine is tag-STRUCTURE scoped, so a drifted name
+ // (`acpt`) never enters it and its text is released verbatim. The
+ // whole-text pass catches it after the fact, but the client has already
+ // rendered the frames by then — so adjudicate the released span here
+ // too. Pure and idempotent; a pair split across two pushes degrades to
+ // the historical residual exactly like #1720's.
+ const clean = stripDriftResidue(s.filter.push(text));
if (clean.length > 0) proseAcc += clean;
return [clean, clean !== text];
};
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index e49186f4b6..58e89ceb46 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -159,3 +159,89 @@ function streamOfLocal(events: string[]): ReadableStream {
});
}
+
+// #5175 defect A, streaming lane: the state machine's matchers are all
+// enumeration-scoped (NAME feeds PAIRED/LONE_OPEN/LONE_CLOSE/PARTIAL_TAIL), so
+// a drifted name never entered it and its bytes were released one push at a
+// time. The whole-text pass only sees the finished text, long after the client
+// rendered the frames. These pin the two lanes to the SAME verdict at rest:
+// whatever chunking the SSE wire picks, the client must receive what
+// stripAcpTags would have produced. (#3198 chunk-split determinism.)
+function streamChunked(text: string, size: number): string {
+ const f = createTagEchoFilter();
+ let out = "";
+ for (let i = 0; i < text.length; i += size) out += f.push(text.slice(i, i + size));
+ return out + f.flush();
+}
+
+const PARITY_SHAPES: Array<[string, string]> = [
+ ["reported paragraph", REPORTED],
+ ["attribute-slot pair", ""],
+ ["two pairs, text between", " between "],
+ ["emitter attribute pair", 'm00257'],
+ ["empty-body pair", ""],
+ ["genuine html", '
hello world
'],
+ ["genuine html with ref", "m00375"],
+ ["documented residual", "m01233"],
+];
+
+// Two shapes cannot be fixed from the streaming lane and are pinned here as a
+// KNOWN RESIDUAL rather than left as a silently-accepted difference:
+//
+// 1. the reported paragraph OPENS with `acpt>` — an orphan half whose `\x3c`
+// was already lost upstream (the GUI rendered the bare tag as HTML). It
+// cannot start a tag, so no matcher can hold it and those five bytes are
+// on the wire before the pair that would license their removal arrives.
+// This is the same class as #1720's documented `m01233` leftover.
+// 2. the orphan closes that trail it are line-adjudicated whole-text (one
+// half licenses stripping its siblings on the same line). The streaming
+// lane has no line state — the state machine's cross-push state is tag
+// STRUCTURE (held/preHeld/swallowed) — and adding it collides with the
+// #2190 balanced-markup contract.
+//
+// Everything the lane CAN decide is asserted exactly, in the test below.
+// The only streaming leftover this lane cannot remove is ORPHAN drift tags
+// carrying no ref content: halves whose `\x3c` was lost upstream (`acpt>`, which
+// the GUI rendered as bare HTML) and the closing halves that need line state to
+// license their removal. They are 100% whitespace-and-fragments, they carry no
+// citation, and — crucially — they are byte-identical at every chunk size, so
+// chunk-split determinism (#3198) still holds. The assertion below therefore
+// pins the INVARIANT rather than a literal: no ref may leak, and removing
+// drift-shaped fragments must reproduce the whole-text verdict exactly.
+const DRIFT_FRAGMENT =
+ /<\/?[A-Za-z][A-Za-z0-9_]{0,31}[^<>]{0,32}>|(?\/])[A-Za-z][A-Za-z0-9_]{0,31}>/g;
+function refCount(s: string): number {
+ return (s.match(/m\d{4,}/g) ?? []).length;
+}
+
+
+
+
+// Shapes that MUST come out byte-identical: genuine markup and prose are never
+// touched by either lane, so exactness is the right bar there.
+const MUST_PRESERVE = new Set([
+ "genuine html",
+ "genuine html with ref",
+ "documented residual",
+]);
+
+for (const [name, text] of PARITY_SHAPES) {
+ test(`#5175 streaming parity A: ${name} survives every chunking`, () => {
+ const truth = stripAcpTags(text);
+ const seen: string[] = [];
+ for (const size of [1, 2, 3, 5, 7, 11, 13, 64, text.length]) {
+ const got = streamChunked(text, size);
+ seen.push(got);
+ if (MUST_PRESERVE.has(name)) {
+ assert.equal(got, truth, `chunk size ${size}: ${JSON.stringify(got)} !== ${JSON.stringify(truth)}`);
+ continue;
+ }
+ // Echo shapes: no citation may leak, and removing the drift-shaped
+ // leftovers must reproduce the whole-text verdict exactly.
+ assert.equal(refCount(got), refCount(truth), `chunk size ${size}: ref count ${refCount(got)} !== ${refCount(truth)} (${JSON.stringify(got)})`);
+ assert.equal(got.replace(new RegExp(DRIFT_FRAGMENT.source, "g"), "").trim(), truth.trim(), `chunk size ${size}: ${JSON.stringify(got)} !== ${JSON.stringify(truth)}`);
+ }
+ // #3198: the verdict must not depend on where the wire cut.
+ assert.equal(new Set(seen).size, 1, `chunking changed the output: ${JSON.stringify([...new Set(seen)])}`);
+ });
+}
From 4225a1e68fc2fd6094d5d706b4b4e660a1592efe Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 10:21:52 +0800
Subject: [PATCH 03/53] fix(#5175): never read the kernel's own protocol tags
as drift
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shape-keyed drift rules exist because the model's open-name set is
unbounded — but `acp_compress` / `acp_status` / `acp_search` / `acp_decompress`
are the exception: the kernel publishes them as a CLOSED set. Two of them
(`acp_status` is the live one) are read-only triggers the non-streaming
Responses loop executes via `extractResponsesTextTriggers`, and
`` is precisely the "unknown open name, empty body"
shape `stripEmptyPairs` drops. The trigger was therefore eaten before the
loop could run it, regressing #1439 (`fix-1439-responses-json-triggers`
went 4/4 -> 3/4).
Fix: a second closed-set exclusion beside the HTML one, applied to every
drift rule (head, empty-pair, mismatch-pair) and to both per-match Set
tests. Enumerating a CLOSED set is legitimate; enumerating the model's
drift names is not, which is why the two exclusions do not contradict.
Red->green verified: with the exclusion disabled the new test fails
(`#5175 A: the kernel's OWN protocol tags are never read as drift`),
with it enabled it passes, whole-text and at chunk size 1 and 5.
Regression surface: 10 suites, 188 pass / 0 fail (was 204 across 9 before
this file grew; the delta is the new test). `tsc --noEmit` clean, build +
both dist gates OK.
`issue4323-drift-escalation` remains red, but it is red on pristine
`upstream/master` with the byte-identical signature
("alarm also names the single-writer repeating-rewrite producer") —
inherited, not introduced here.
---
src/loop/tag-echo-filter.ts | 17 ++++++++++++++---
tests/issue5175-drift-name-leak.test.ts | 24 ++++++++++++++++++++++++
2 files changed, 38 insertions(+), 3 deletions(-)
diff --git a/src/loop/tag-echo-filter.ts b/src/loop/tag-echo-filter.ts
index bbd07e3e75..d8aefa43a4 100644
--- a/src/loop/tag-echo-filter.ts
+++ b/src/loop/tag-echo-filter.ts
@@ -296,11 +296,22 @@ const HTML_TAG_NAMES = new Set([
"var", "wbr",
]);
const HTML_TAG_ALT = "(?:" + [...HTML_TAG_NAMES].join("|") + ")";
+// #5175: the kernel's OWN protocol tags are canonical too, and they are a closed
+// set published by `acp-kernel` (`ACP_TEXT_OPEN` etc.). They must never be read
+// as drifted render names: `` is a READ-ONLY trigger
+// the non-streaming Responses path executes (`extractResponsesTextTriggers`),
+// and stripping it here ate the trigger before the loop could run it
+// (tests/fix-1439-responses-json-triggers.test.ts).
+const PROTOCOL_TAG_NAMES = new Set([
+ "acp_compress", "acp_status", "acp_search", "acp_decompress",
+]);
+const CANONICAL_TAG_ALT =
+ "(?:" + [...HTML_TAG_NAMES, ...PROTOCOL_TAG_NAMES].join("|") + ")";
const DRIFT_EMPTY = new RegExp("\\x3c(?!(?:" + NAME + ")[\\s>/])(?!" + HTML_TAG_ALT + "\\b)" + DRIFT_NAME_SRC + "\\s*>\\s*\\x3c\\/\\1\\s*>", "gi");
function stripEmptyPairs(text: string): string {
if (!text.includes("\x3c/")) return text;
return text.replace(new RegExp(DRIFT_EMPTY.source, "gi"), (whole, name: string) =>
- HTML_TAG_NAMES.has(name.toLowerCase()) ? whole : "");
+ (HTML_TAG_NAMES.has(name.toLowerCase()) || PROTOCOL_TAG_NAMES.has(name.toLowerCase())) ? whole : "");
}
/** The same empty-body unit when the two halves disagree on the name —
* ` `. A balanced render tag never does that; an emitter
@@ -315,7 +326,7 @@ const DRIFT_EMPTY_MISMATCH = new RegExp(
function stripEmptyMismatch(text: string): string {
if (!text.includes("\x3c/")) return text;
return text.replace(new RegExp(DRIFT_EMPTY_MISMATCH.source, "gi"), (whole, open: string, close: string) =>
- HTML_TAG_NAMES.has(open.toLowerCase()) || HTML_TAG_NAMES.has(close.toLowerCase()) ? whole : "");
+ (HTML_TAG_NAMES.has(open.toLowerCase()) || PROTOCOL_TAG_NAMES.has(open.toLowerCase())) || (HTML_TAG_NAMES.has(close.toLowerCase()) || PROTOCOL_TAG_NAMES.has(close.toLowerCase())) ? whole : "");
}
// #5175 streaming: a drifted opening at the buffer tail must not be released as
@@ -334,7 +345,7 @@ function stripEmptyMismatch(text: string): string {
// B opening closed, no space , , m00257, …>m00257
-const DRIFT_HEAD = "\\x3c(?!(?:" + NAME + ")[\\s>/])" + "(?!" + HTML_TAG_ALT + "\\b)" + "[A-Za-z][A-Za-z0-9_]{0,31}";
+const DRIFT_HEAD = "\\x3c(?!(?:" + NAME + ")[\\s>/])" + "(?!" + CANONICAL_TAG_ALT + "\\b)" + "[A-Za-z][A-Za-z0-9_]{0,31}";
const DRIFT_CLOSE_FRAGMENT = "[ \\t]*(?:\\x3c\\/?[A-Za-z]{0,32}>?)?";
const DRIFT_TAIL = new RegExp(
DRIFT_HEAD + "(?:" +
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index 58e89ceb46..5c73b15929 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -78,6 +78,30 @@ test("#5175 A: genuine HTML sharing a field with an echo survives", () => {
+test("#5175 A: the kernel's OWN protocol tags are never read as drift", () => {
+ // #1439 regression: `` is a READ-ONLY trigger that
+ // the non-streaming Responses loop executes. It is also an "unknown" open
+ // name with an empty body, i.e. exactly the shape `stripEmptyPairs` drops —
+ // so a shape-only rule ate the trigger before the loop could run it. The
+ // kernel publishes these four names in a CLOSED set, so enumerating them is
+ // legitimate (unlike the model's open drift set) and they must survive.
+ const protocol = [
+ "m00001-m00005",
+ "",
+ "quantum flux",
+ "b3",
+ ];
+ for (const tag of protocol) {
+ assert.equal(stripAcpTags(tag), tag, `kernel protocol tag must survive: ${tag}`);
+ }
+ // …and in a real sentence, with prose around it.
+ const sentence = "status please ";
+ assert.equal(stripAcpTags(sentence), sentence);
+ // The streaming lane must not eat it either.
+ assert.equal(streamChunked(sentence, 1), sentence);
+ assert.equal(streamChunked(sentence, 5), sentence);
+});
+
test("#5175 B: a draft dump is detected when the envelope sits mid-text", () => {
const midText =
"Let me chronologically analyze this conversation to produce a comprehensive summary.\n" +
From fff60bc8715be0c2ddb132b7b7ef29b24fd01463 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 10:52:10 +0800
Subject: [PATCH 04/53] fix(#5175): keep the signed-thinking channel out of the
drift adjudication
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`filterFor` routes `thinking` / `reasoning_content` / `reasoning` to an
IDENTITY filter: any rewrite desyncs a signature and bricks replay (#1960,
#2229 unified invariant — "the thinking channel is NEVER rewritten, only the
visible text channel is"). The drift adjudication added in this PR runs on
the RELEASED span, which applied to every field, so it reintroduced exactly
that rewrite through the back door: `plugin-passthrough-tag-strip-chat`
went 20/20 -> 16/20, with all four failures on reasoning bytes that are
supposed to ride verbatim.
Fix: an `isVerbatimField` predicate beside `filterFor`, so the identity-filter
decision and the "never post-process" decision live in one place and cannot
drift apart. The visible text channel keeps the adjudication.
Red->green verified both ways: removing only the guard restores the four
failures; with it the suite is 20/20.
Full dependent surface (every test importing tag-echo-filter.ts or
degenerate-plugin.ts — 27 files) now green: 0 red suites. This is what I
should have run the first time instead of the 9 I guessed were "related";
`fix-1439` and this one both live outside that guess.
---
src/plugin.ts | 15 ++++++++++++++-
tests/issue5175-drift-name-leak.test.ts | 19 +++++++++++++++++++
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/plugin.ts b/src/plugin.ts
index faf026137e..1d77203db7 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -2526,6 +2526,12 @@ export async function pipePluginChatWithStrip(
index: number;
}
const streams = new Map();
+ // #1960 / #2229 / #5175: fields whose bytes must ride BYTE-FOR-BYTE. Kept
+ // beside `filterFor` so the identity-filter decision and this one cannot
+ // drift apart — a field routed to the identity filter must never be
+ // post-processed either.
+ const isVerbatimField = (field: string): boolean =>
+ field === "thinking" || (protocol === "openai" && (field === "reasoning_content" || field === "reasoning"));
const filterFor = (field: string, index: number) => {
const key = `${field}:${index}`;
let s = streams.get(key);
@@ -2954,7 +2960,14 @@ export async function pipePluginChatWithStrip(
// rendered the frames by then — so adjudicate the released span here
// too. Pure and idempotent; a pair split across two pushes degrades to
// the historical residual exactly like #1720's.
- const clean = stripDriftResidue(s.filter.push(text));
+ //
+ // The signed-thinking channel is excluded: `filterFor` hands it an
+ // IDENTITY filter because any rewrite desyncs its signature and bricks
+ // replay (#1960 / #2229 unified invariant — "the thinking channel is
+ // NEVER rewritten, only the visible text channel is"). Adjudicating here
+ // would reintroduce exactly that rewrite through the back door.
+ const released = s.filter.push(text);
+ const clean = isVerbatimField(field) ? released : stripDriftResidue(released);
if (clean.length > 0) proseAcc += clean;
return [clean, clean !== text];
};
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index 5c73b15929..ba5915689e 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -78,6 +78,25 @@ test("#5175 A: genuine HTML sharing a field with an echo survives", () => {
+test("#5175 A: the signed-thinking channel is never rewritten by the drift rule", () => {
+ // #1960 / #2229 unified invariant: `thinking` / `reasoning_content` /
+ // `reasoning` ride BYTE-FOR-BYTE — any rewrite desyncs a signature and
+ // bricks replay, so `filterFor` hands them an identity filter. The drift
+ // adjudication runs on the RELEASED span, so it must be skipped for exactly
+ // those fields or the invariant is reintroduced through the back door.
+ //
+ // Pinned at the predicate level here because the pipe harness lives in
+ // `plugin-passthrough-tag-strip-chat.test.ts` (4 tests there cover it
+ // end-to-end; this one documents WHY the split exists).
+ const echo = 'm10556';
+ // Visible text channel: adjudicated.
+ assert.equal(stripAcpTags(echo), "", "visible text channel is filtered");
+ // The verbatim channel's rule is identity, so an echo riding there is
+ // expected to survive — that is the invariant, not a leak.
+ const identity = (x: string): string => x;
+ assert.equal(identity(echo), echo);
+});
+
test("#5175 A: the kernel's OWN protocol tags are never read as drift", () => {
// #1439 regression: `` is a READ-ONLY trigger that
// the non-streaming Responses loop executes. It is also an "unknown" open
From ba23a2994604c991dba5f3dc637c8338a5818561 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 11:15:48 +0800
Subject: [PATCH 05/53] =?UTF-8?q?test(#5176):=20pin=20the=20#2303=20retry'?=
=?UTF-8?q?s=20measured=20limit=20=E2=80=94=20streamed=20draft=20bytes=20a?=
=?UTF-8?q?re=20not=20retracted?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The scope note in this PR claimed the retry *cannot* retract what already
left the wire. That was reasoning; this pins it as a characterization test so
the next person changing the draft-tail path sees the actual behaviour:
calls === 1 -> the draft IS detected, the detector works
client text CONTAINS the draft -> measured: those bytes cannot be un-sent
client text CONTAINS the retry -> the retry merely appends after it
Mechanism: pipePluginChatWithStrip calls appendReply per delta, while
retryEmptyTurn only does streams.clear(); reader.cancel(); buf = "". This is
why 'just strip the draft out of the stream' is not a one-line follow-up and
the real fix has to HOLD the draft span before releasing it (#5176).
Verified red on pristine upstream/master (isDraftDump does not exist yet) and
green here: issue5175 suite 18/18.
---
tests/issue5175-drift-name-leak.test.ts | 60 +++++++++++++++++++++++++
1 file changed, 60 insertions(+)
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index ba5915689e..ca90d68b27 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -288,3 +288,63 @@ for (const [name, text] of PARITY_SHAPES) {
assert.equal(new Set(seen).size, 1, `chunking changed the output: ${JSON.stringify([...new Set(seen)])}`);
});
}
+
+// ---------- #5176 follow-up: the retry CANNOT retract what already left ----------
+
+// #4764 and this case are the same residual: the #2303 retry fires at stream
+// END, but every delta was already appended to the client's reply as it was
+// parsed (`appendReply` at each chunk). `retryEmptyTurn` only does
+// `streams.clear(); reader.cancel(); buf = ""` — it abandons the old attempt,
+// it cannot un-send bytes. So a draft dump that IS detected still lands in the
+// transcript, and the retry merely appends a second (correct) answer after it.
+//
+// This is pinned as a CHARACTERIZATION test, not an aspiration: it documents
+// why "just strip the draft from the stream" is not a one-line fix, and why
+// the real fix has to HOLD the draft span before releasing it (#5176).
+test("#5176 the #2303 retry documents its own limit: already-streamed draft bytes are not retracted", async () => {
+ const { pipePluginChatWithStrip } = await import("../src/plugin.ts");
+ const enc = new TextEncoder();
+ const chunk = (delta: Record): string =>
+ `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`;
+ const stop = `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`;
+ const DONE = "data: [DONE]\n\n";
+ const of = (ev: string[]): ReadableStream => {
+ let i = 0;
+ return new ReadableStream({
+ pull(c) {
+ if (i < ev.length) c.enqueue(enc.encode(ev[i++]));
+ else c.close();
+ },
+ });
+ };
+ const writes: string[] = [];
+ const res = {
+ write(b: Buffer | string) {
+ writes.push(typeof b === "string" ? b : b.toString("utf8"));
+ return true;
+ },
+ end(b?: Buffer | string) {
+ if (b !== undefined) writes.push(typeof b === "string" ? b : b.toString("utf8"));
+ },
+ once() {},
+ destroyed: false,
+ writableEnded: false,
+ } as unknown as import("node:http").ServerResponse;
+ const session = { id: "s", protocol: "openai", upstreamOrigin: "http://127.0.0.1:9/v1", label: "t", createdAt: 0, lastUsedAt: 0, requests: 0, lastInputTokens: 0, stats: {}, dirty: false } as never;
+
+ const DRAFT = "\nThe user asked for X.\n\n\n1. Primary Request: X.\n";
+ const firstTurn = [chunk({ role: "assistant" }), chunk({ content: DRAFT }), stop, DONE];
+ let calls = 0;
+ const refetch = (): Promise> => {
+ calls += 1;
+ return Promise.resolve(of([chunk({ role: "assistant" }), chunk({ content: "recovered action" }), stop, DONE]));
+ };
+
+ await pipePluginChatWithStrip(of(firstTurn), res, "openai", session, undefined, refetch);
+
+ const text = writes.join("");
+ assert.equal(calls, 1, "the draft IS detected and retried — the detector works");
+ // The measured limit: the draft text is still in what the client received.
+ assert.ok(text.includes("Primary Request: X"), "the already-streamed draft CANNOT be retracted (measured, not assumed)");
+ assert.ok(text.includes("recovered action"), "the retry appends its answer after it");
+});
From 7bee49ad5122e1ab56df799d2eec5d87246ae38d Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 11:20:36 +0800
Subject: [PATCH 06/53] chore(#5175): drop a dead drift-tail constant left from
an abandoned arm
---
src/loop/tag-echo-filter.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/loop/tag-echo-filter.ts b/src/loop/tag-echo-filter.ts
index d8aefa43a4..5c432936f1 100644
--- a/src/loop/tag-echo-filter.ts
+++ b/src/loop/tag-echo-filter.ts
@@ -276,7 +276,6 @@ const MANGLED_OPEN = new RegExp("\x3c" + NAME + "\\s*=\\s*[^<>]*>");
const DRIFT_NAME_SRC = "([A-Za-z][A-Za-z0-9_]{0,31})";
const DRIFT_PAIR = new RegExp("\\x3c" + DRIFT_NAME_SRC + "\\s+(" + REFS_RUN + ")\\s*>\\s*\\x3c\\/\\1\\s*>", "gi");
const DRIFT_ATTR = new RegExp("\\x3c" + DRIFT_NAME_SRC + "\\s[^<>]*\\btokens\\s*=[^<>]*>\\s*(" + REFS_RUN + ")\\s*\\x3c\\/\\1\\s*>", "gi");
-const DRIFT_ATTR_TAIL = /^\s*\x3c\/[A-Za-z]{0,32}\s*$/;
// #5175: the render tag emitted with NO attributes and an empty body —
// ``. The pair rules above require an attribute slot (a bare ref
@@ -360,7 +359,6 @@ const DRIFT_TAIL = new RegExp(
);
-
/** #5175/#5175 whole-text drift strip. Removes matched pairs, then the orphan
* halves of the names they proved — a lone `` is indistinguishable from
* prose on its own, so the pair is what licenses its removal. `attested` is
From 2ed07a476f6647068923287ce4060f270fee0b97 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 11:58:59 +0800
Subject: [PATCH 07/53] fix(#5175): route complete drifted units past the
streaming fast path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The reported `acpt>` paragraph reached the client WITHOUT A SINGLE FILTER CALL.
`pushClientNative`'s fast path releases a chunk verbatim when every
"mayStart…" probe is false, and `mayStartDegenerateRenderTag` only tested for
an ambiguous head at the chunk's TAIL (`BROAD_TAG_HEAD_TAIL` = \x3c…$). The
paragraph ends with a CLOSED ``, so the probe never matched, the bytes
rode the fast path, and everything added earlier in this PR — the shape gate,
the drift pairs, the residue rules — was never consulted.
Measured on the user's own reported text through the real plugin lane:
before: "acpt> " (verbatim)
after : "" (single chunk)
The probe now also accepts a COMPLETE drifted unit anywhere in the chunk, by
delegating to `containsRenderingTagText` — the same shape test
stream-openai/stream-google/stream already gate on. That removes a sixth,
stricter notion of "looks like a render tag" from the streaming lane instead of
adding one.
Scope, stated plainly: this closes the single-chunk case (the shape the report
shows, and the shape the corpus produces — one emitted paragraph per event).
Chunk-split fan-out still leaves the orphan halves (`acpt>`, whose \x3c was lost
upstream before bili ever saw it) on the wire; that needs line-level hold in
the state machine and collides with #2190's balanced-markup contract. Pinned as
a KNOWN RESIDUAL in the suite rather than papered over.
Verified: 11 affected suites 0 fails; issue5175 suite 18/18; tsc clean; build
OK; dist-import-annotations OK; dist-external OK.
---
src/loop/tag-echo-filter.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/loop/tag-echo-filter.ts b/src/loop/tag-echo-filter.ts
index 5c432936f1..bbbd90785a 100644
--- a/src/loop/tag-echo-filter.ts
+++ b/src/loop/tag-echo-filter.ts
@@ -876,7 +876,17 @@ export function mayStartRenderTag(s: string): boolean {
// prose, not residue.
const BROAD_TAG_HEAD_TAIL = new RegExp("\x3c\\/?[A-Za-z0-9]{0,16}$");
export function mayStartDegenerateRenderTag(s: string): boolean {
- return BROAD_TAG_HEAD_TAIL.test(s);
+ if (BROAD_TAG_HEAD_TAIL.test(s)) return true;
+ // #5175: a COMPLETE drifted unit anywhere in the chunk, not only a head at
+ // its tail. The head-only probe was the entire gap behind the report: the
+ // paragraph ends with a CLOSED ``, so BROAD_TAG_HEAD_TAIL never
+ // matched, the fast path released the bytes verbatim, and the stripper was
+ // never consulted — the imitation reached the client without a single
+ // filter call. `containsRenderTagText` is the same shape test the
+ // non-streaming lanes gate on (stream-openai/stream-google/stream), so this
+ // makes the streaming lane agree with them instead of inventing a sixth
+ // notion of "looks like a render tag".
+ return containsRenderTagText(s);
}
// #361: tool-call XML template fragments a model may echo from the context
From fc402db4b966a830b1c4adf9d93d3441553dd299 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 12:56:12 +0800
Subject: [PATCH 08/53] test(#5175): drive the REAL pipeline, because a
hand-rolled copy hid the bug
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The suite had 18 passing tests and the user still saw the paragraph verbatim.
Every one of those tests exercised `createTagEchoFilter()` directly, so they
all proved the FILTER was right while the bytes never reached it: the SSE fast
path releases a delta untouched when all four `mayStart…` probes are false, and
`mayStartDegenerateRenderTag` only looked for an ambiguous head at the chunk's
TAIL (`\x3c…?$`). The reported paragraph ends with a CLOSED ``, so the
probe missed and the filter was never called at all.
My first version of this test re-implemented that four-probe condition by hand.
It passed against the fix while the real pipeline still leaked — a copy of the
production predicate is exactly the thing that drifts away from production.
Replaced it with `driveChat()`, which pushes real `chat.completion.chunk`
frames through `pipePluginChatWithStrip` and reads back only the assistant text
the client received.
Red baseline (gate reverted to the tail-only probe, everything else unchanged):
error: a kernel ref reached the client:
"acpt> ..."
3 !== 0
Green after the fix, same input: 0 refs, no ``, whose `\x3c` was lost upstream before bili ever saw it) are the
documented KNOWN RESIDUAL for this PR, so demanding an empty string would fail
for a reason this change explicitly does not fix. What is asserted is that no
kernel citation and no render-tag open reaches the client.
Suite: 19 pass / 0 fail. All 11 suites that import the filter: 0 fails.
---
tests/issue5175-drift-name-leak.test.ts | 55 +++++++++++++++++++++++--
1 file changed, 52 insertions(+), 3 deletions(-)
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index ca90d68b27..5763476106 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -10,9 +10,10 @@ import {
mayStartMarkerLine,
mayStartRenderTag,
stripAcpTags,
+ stripDriftResidue,
} from "../src/loop/tag-echo-filter.ts";
import { endsWithDraftClose, isDraftDump } from "../src/degenerate-turn.ts";
-import { pipePluginResponsesWithStrip } from "../src/plugin.ts";
+import { pipePluginChatWithStrip, pipePluginResponsesWithStrip } from "../src/plugin.ts";
import type { Session } from "../src/session.ts";
// #5175 defect A: the open-side name enum only ever allowed MID-name mutation
@@ -35,9 +36,12 @@ function streamThrough(chunks: string[]): string {
out += delta;
continue;
}
- out += tagFilter.push(delta);
+ // #5175: mirror `pushField` exactly — the filter releases, then the
+ // release-point adjudication runs `stripDriftResidue`. Testing only the
+ // filter hides the gate bug that let the bytes skip both.
+ out += stripDriftResidue(tagFilter.push(delta));
}
- return out + tagFilter.flush();
+ return out + stripDriftResidue(tagFilter.flush());
}
function chunked(s: string, size: number): string[] {
@@ -46,6 +50,51 @@ function chunked(s: string, size: number): string[] {
return chunks;
}
+// #5175 defect A, the actual production gap: EVERY probe above is a test of the
+// filter, and the filter was never reached. The streaming fast path releases a
+// delta verbatim when all four "mayStart…" probes are false, and
+// `mayStartDegenerateRenderingTag` only looked for an ambiguous head at the
+// chunk's TAIL (`\x3c…$`). The reported paragraph ends with a CLOSED ``,
+// so that probe missed, the bytes rode the fast path, and everything else in
+// this file was dead code on the wire. This test drives the REAL fast-path
+// decision, so it fails if the gate regresses even while the filter is perfect.
+// Drive the REAL openai plugin pipeline and return only the assistant text the
+// client received, so the assertion is on what the user actually sees.
+async function driveChat(chunks: string[]): Promise {
+ const writes: string[] = [];
+ const res = makeResLocal(writes);
+ const chat = (delta: Record): string =>
+ `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`;
+ const stop = `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`;
+ const events = [chat({ role: "assistant" }), ...chunks.map((c) => chat({ content: c })), stop, "data: [DONE]\n\n"];
+ await pipePluginChatWithStrip(streamOfLocal(events), res as unknown as import("node:http").ServerResponse, "openai", makeSessionLocal(), undefined, undefined);
+ return [...writes.join("").matchAll(/"content":"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string).join("");
+}
+
+// #5175 defect A, the ACTUAL production gap — and the reason every other test
+// in this file could pass while the user still saw the paragraph verbatim:
+//
+// The SSE fast path (`processOpenai`/`processResponses`) forwards a delta
+// untouched when all four "mayStart…" probes are false. `mayStartDegenerate…`
+// only looked for an ambiguous head at the chunk's TAIL (`\x3c…$`). The
+// reported paragraph ends with a CLOSED ``, so that probe missed, the
+// bytes rode the fast path, and the filter — plus the release-point
+// adjudication behind it — was never called at all.
+//
+// So this test drives the REAL plugin pipeline end to end, not a hand-rolled
+// copy of the condition. A hand-rolled copy can drift from production (this one
+// did, and hid the bug); `pipePluginChatWithStrip` cannot.
+test("#5175 A: the streaming fast path must not release a drifted unit verbatim", async () => {
+ assert.equal(mayStartDegenerateRenderTag(REPORTED), true, "a complete drifted unit anywhere in the delta must engage the state machine, not only a head at its tail");
+ const got = await driveChat([REPORTED]);
+ // The bar is "no kernel citation reaches the client". The orphan halves
+ // (`acpt>`, whose `\x3c` was lost upstream before bili saw it) survive as
+ // the pinned KNOWN RESIDUAL below — asserting `got === ""` here would fail
+ // for a reason this PR explicitly does not fix.
+ assert.equal((got.match(/m\d{4,}/g) ?? []).length, 0, `a kernel ref reached the client: ${JSON.stringify(got)}`);
+ assert.ok(!got.includes(" {
const out = stripAcpTags(REPORTED);
assert.ok(!/m\d{4,}/.test(out), `no kernel ref may survive, got ${JSON.stringify(out)}`);
From 91ef05818faca96bd0de724dcc20a9bc6022ab97 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 14:31:55 +0800
Subject: [PATCH 09/53] fix(storage): name the sidecar's db path with native
separators
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`UnifiedShadow` built the path as `${dir}/index.db`. On Windows the warning
therefore read `D:\a\bctx\sessions/index.db` — a mixed-separator path an
operator cannot paste back into a shell, and one that `path.win32.join`
never matches, which is why `issue5103` was red on every Windows shard.
`path` is already imported at :30 and the file is opened through the same
join, so POSIX behavior is byte-identical.
Verified on pristine fc5d6893 first (issue5103 5 fail there), then 5 pass
with this patch; the mysql-backend arm at :354 already takes an error only
and keeps its own wording.
---
src/storage/shadow.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/storage/shadow.ts b/src/storage/shadow.ts
index b9dd9f19dc..4656314ced 100644
--- a/src/storage/shadow.ts
+++ b/src/storage/shadow.ts
@@ -300,12 +300,12 @@ export class UnifiedShadow {
: "; delete the file to reset the index";
loggerLog(
"warn",
- `[storage] unified sidecar failed to open ${dir}/index.db (${errStr}) — staying legacy-only${tail}`,
+ `[storage] unified sidecar failed to open ${path.join(dir, "index.db")} (${errStr}) — staying legacy-only${tail}`,
);
recordSidecarOpenFailure(errStr);
return null;
}
- const shadow = new UnifiedShadow(mode, store, legacyLoad ?? (() => null), undefined, `${dir}/index.db`);
+ const shadow = new UnifiedShadow(mode, store, legacyLoad ?? (() => null), undefined, path.join(dir, "index.db"));
recordSidecarInstance(shadow);
const engineTag = ` [engine: ${store.engine}/${store.engineSource}]`;
const walTag = ` [wal-cap: ${walLimit}]`;
From cadbf7709d2e9a5e2e4b67ae6652719a59ece7a5 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 14:05:55 +0800
Subject: [PATCH 10/53] docs(#4788): seed sessions.hardMaxBytes so the config
guards stop failing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#4788 D2 added `sessions.hardMaxBytes` to FileConfig and to knobs.ts
(`BILI_SESSION_RESIDENT_HARD_MAX_BYTES`), but never added the seed entry
`website/config-reference/process.yaml` requires. Four guards go red on
master for it:
docs-sync CONFIGURATION.md/.zh-CN drift (2 lines)
docs-coverage BILI_SESSION_RESIDENT_HARD_MAX_BYTES
config-surface-guard leaf field undocumented
lifecycle-ledger-guard BINAME read from src but not in LIFECYCLE.md
Reproduced on pristine upstream/master (fc5d6893) before touching anything,
so this is inherited, not this branch's.
Completes the omission rather than silencing it: the seed entry carries the
full desc_en/desc_desc text from the FileConfig doc comment (what the cap
bounds, why it differs from the pool soft cap, the 60 s warn throttle, and
`0` disabling it), and LIFECYCLE.md §2.4 registers the env var beside the
soft cap it defaults to.
All four guards green locally, and a second `gen-config-docs generate`
produces no diff — which is exactly what the CI step asserts.
---
CONFIGURATION.md | 4 ++--
CONFIGURATION.zh-CN.md | 2 ++
LIFECYCLE.md | 1 +
website/config-reference/process.yaml | 7 +++++++
4 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 6aefadce0b..ba65816abb 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -203,7 +203,7 @@ This index is generated from `website/config-reference/*.yaml` — edit the seed
| `sessions.warmMax` | number | 0 | BILI_SESSION_WARM_MAX | Bounded startup (#2803/#3050): max most-recently-saved sessions hydrated at boot before listening; older state reloads on demand. Default 0 (no eager warm set — first touch pays one on-demand load; set 8 to restore the eager behavior). Capped by sessions.max. |
| `sessions.warmMaxBytes` | number | 67108864 | BILI_SESSION_WARM_MAX_BYTES | Decoded-byte budget for the boot warm set (#2803). Counts decoded size, not compressed on-disk bytes, so zstd archives cannot sneak past the RAM bound. A single file over the budget is never warmed. |
| `sessions.residentMaxBytes` | number | 536870912 | BILI_SESSION_RESIDENT_MAX_BYTES | Soft target for estimated resident session bytes (#2803). Active/in-flight state stays pinned above it; when over target, idle state discharges back to disk as requests complete. |
-| `sessions.hardMaxBytes` | number | (same as `sessions.residentMaxBytes`) | BILI_SESSION_RESIDENT_HARD_MAX_BYTES | Per-SESSION hard cap on estimated resident bytes (#4788, decision #4792 D1-A). Unlike the pool soft cap — which can only evict OTHER cold sessions and never touches a sole-active session — this bounds ONE session: over the cap at a request-end boundary, its droppable fields (folded-block originals, metadata ledger, the raw tail beyond the bounded view) are forced back to disk even for a pool-exempt session, and the next turn transparently re-reads what it needs via narrow disk reads. Safe drops run at every request boundary as long as the last save has provably landed; the cap adds the per-session call-out (warn, throttled to 60 s). `0` disables the cap check. |
+| `sessions.hardMaxBytes` | number | (same as sessions.residentMaxBytes) | BILI_SESSION_RESIDENT_HARD_MAX_BYTES | Per-SESSION hard cap on estimated resident bytes (#4788, decision #4792 D1-A). Unlike the pool soft cap — which can only evict OTHER cold sessions and never touches a sole-active session — this bounds ONE session: over the cap at a request-end boundary, its droppable fields (folded-block originals, metadata ledger, the raw tail beyond the bounded view) are forced back to disk even for a pool-exempt session, and the next turn transparently re-reads what it needs via narrow disk reads. Safe drops run at every request boundary as long as the last save has provably landed; the cap adds the per-session call-out (warn, throttled to 60 s). `0` disables the cap check. |
| `sessions.gc.enabled` | boolean | false | BILI_SESSION_GC | Opt-in garbage collection of stale persisted sessions (user data is never pruned silently without this flag). |
| `sessions.gc.maxAgeDays` | number | 7 | BILI_SESSION_GC_MAX_AGE_DAYS | GC age threshold in days. |
| `sessions.gc.maxTokens` | number | 1000000 | BILI_SESSION_GC_MAX_TOKENS | GC token-size threshold per session record. |
@@ -1885,8 +1885,8 @@ File keys resolve only when the matching env var is unset. Defaults in parenthes
| `BILI_SESSION_GC_INTERVAL_MS` | `sessions.gc.intervalMs` | 3600000 |
| `BILI_SESSION_GC_MAX_AGE_DAYS` | `sessions.gc.maxAgeDays` | 7 |
| `BILI_SESSION_GC_MAX_TOKENS` | `sessions.gc.maxTokens` | 1000000 |
+| `BILI_SESSION_RESIDENT_HARD_MAX_BYTES` | `sessions.hardMaxBytes` | (same as sessions.residentMaxBytes) |
| `BILI_SESSION_RESIDENT_MAX_BYTES` | `sessions.residentMaxBytes` | 536870912 |
-| `BILI_SESSION_RESIDENT_HARD_MAX_BYTES` | `sessions.hardMaxBytes` | (same as `BILI_SESSION_RESIDENT_MAX_BYTES`) |
| `BILI_SESSION_WARM_MAX` | `sessions.warmMax` | 0 |
| `BILI_SESSION_WARM_MAX_BYTES` | `sessions.warmMaxBytes` | 67108864 |
| `BILI_SQLITE_AUTO_DOWNLOAD` | `storage.sqliteAutoDownload` | true |
diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md
index ac054a66e2..a7cf108bea 100644
--- a/CONFIGURATION.zh-CN.md
+++ b/CONFIGURATION.zh-CN.md
@@ -203,6 +203,7 @@
| `sessions.warmMax` | number | 0 | BILI_SESSION_WARM_MAX | 有界启动(#2803/#3050):启动监听前预热加载的最近保存会话数上限,更早的状态按需重新加载。默认 0(不预热——首次访问按需加载一次;设 8 恢复预热行为)。受 sessions.max 封顶。 |
| `sessions.warmMaxBytes` | number | 67108864 | BILI_SESSION_WARM_MAX_BYTES | 启动预热集的解码字节预算(#2803)。按解码后大小计而非磁盘压缩字节,zstd 归档无法绕过内存上限;单文件超预算则永不预热。 |
| `sessions.residentMaxBytes` | number | 536870912 | BILI_SESSION_RESIDENT_MAX_BYTES | 驻留会话估算字节的软目标(#2803)。活跃/在途状态始终固定保留;超过目标时,空闲状态随请求完成回写磁盘释放。 |
+| `sessions.hardMaxBytes` | number | (same as sessions.residentMaxBytes) | BILI_SESSION_RESIDENT_HARD_MAX_BYTES | 单个会话驻留估算字节的硬上限(#4788,决定 #4792 D1-A)。与只能驱逐其它冷会话、永不触碰唯一活跃会话的池软上限不同,这里约束的是单个会话:在请求结束边界超过上限时,即使该会话免于池驱逐,也会把可丢弃字段(折叠块原始内容、元数据账本、超出有界视图的原始尾巴)强制回写磁盘,下一轮再通过窄磁盘读取透明恢复。只要上次保存在证明已落盘,安全丢弃会在每个请求边界执行;超限会额外给出每会话提示(警告,60 秒节流)。设为 `0` 则关闭该检查。 |
| `sessions.gc.enabled` | boolean | false | BILI_SESSION_GC | 过期持久化会话的可选垃圾回收(未开启时用户数据绝不被悄悄清理)。 |
| `sessions.gc.maxAgeDays` | number | 7 | BILI_SESSION_GC_MAX_AGE_DAYS | GC 年龄阈值(天)。 |
| `sessions.gc.maxTokens` | number | 1000000 | BILI_SESSION_GC_MAX_TOKENS | GC 单会话记录的 token 大小阈值。 |
@@ -1867,6 +1868,7 @@ ACP 原生 agent(当前为 `pi` 扩展)会在每个进程内向代理上报
| `BILI_SESSION_GC_INTERVAL_MS` | `sessions.gc.intervalMs` | 3600000 |
| `BILI_SESSION_GC_MAX_AGE_DAYS` | `sessions.gc.maxAgeDays` | 7 |
| `BILI_SESSION_GC_MAX_TOKENS` | `sessions.gc.maxTokens` | 1000000 |
+| `BILI_SESSION_RESIDENT_HARD_MAX_BYTES` | `sessions.hardMaxBytes` | (same as sessions.residentMaxBytes) |
| `BILI_SESSION_RESIDENT_MAX_BYTES` | `sessions.residentMaxBytes` | 536870912 |
| `BILI_SESSION_WARM_MAX` | `sessions.warmMax` | 0 |
| `BILI_SESSION_WARM_MAX_BYTES` | `sessions.warmMaxBytes` | 67108864 |
diff --git a/LIFECYCLE.md b/LIFECYCLE.md
index 9564227eb1..dd504c589d 100644
--- a/LIFECYCLE.md
+++ b/LIFECYCLE.md
@@ -121,6 +121,7 @@
| `BILI_SESSION_GC` | 会话 GC 总开关(`=1/true/on` 启用,默认 off;启用后下面三个参数生效) |
| `BILI_SESSION_GC_INTERVAL_MS` / `BILI_SESSION_GC_MAX_AGE_DAYS` / `BILI_SESSION_GC_MAX_TOKENS` | 会话 GC 参数(默认 1h / 7d / 1M tokens) |
| `BILI_SESSION_RESIDENT_MAX_BYTES` | sessions.residentMaxBytes,512 MiB 软上限(#2803) |
+| `BILI_SESSION_RESIDENT_HARD_MAX_BYTES` | sessions.hardMaxBytes,单单会话驻留硬上限(#4788 D1-A);默认同 `resident` 软上限,`0` 关闭 |
| `BILI_SESSION_WARM_MAX_BYTES` | warm 解码字节上限,64 MiB(#2803) |
| `BILI_PERSIST` | `=0` 关闭持久化(ephemeral/test) |
| `BILI_PERSIST_DEBOUNCE_MS` | persist.debounceMs,500 |
diff --git a/website/config-reference/process.yaml b/website/config-reference/process.yaml
index 1d4aaebc3b..bee93d6139 100644
--- a/website/config-reference/process.yaml
+++ b/website/config-reference/process.yaml
@@ -367,6 +367,13 @@ entries:
group: process
desc_en: "Soft target for estimated resident session bytes (#2803). Active/in-flight state stays pinned above it; when over target, idle state discharges back to disk as requests complete."
desc_zh: "驻留会话估算字节的软目标(#2803)。活跃/在途状态始终固定保留;超过目标时,空闲状态随请求完成回写磁盘释放。"
+ - key: sessions.hardMaxBytes
+ type: number
+ default: "(same as sessions.residentMaxBytes)"
+ env: BILI_SESSION_RESIDENT_HARD_MAX_BYTES
+ group: process
+ desc_en: "Per-SESSION hard cap on estimated resident bytes (#4788, decision #4792 D1-A). Unlike the pool soft cap — which can only evict OTHER cold sessions and never touches a sole-active session — this bounds ONE session: over the cap at a request-end boundary, its droppable fields (folded-block originals, metadata ledger, the raw tail beyond the bounded view) are forced back to disk even for a pool-exempt session, and the next turn transparently re-reads what it needs via narrow disk reads. Safe drops run at every request boundary as long as the last save has provably landed; the cap adds the per-session call-out (warn, throttled to 60 s). `0` disables the cap check."
+ desc_zh: "单个会话驻留估算字节的硬上限(#4788,决定 #4792 D1-A)。与只能驱逐其它冷会话、永不触碰唯一活跃会话的池软上限不同,这里约束的是单个会话:在请求结束边界超过上限时,即使该会话免于池驱逐,也会把可丢弃字段(折叠块原始内容、元数据账本、超出有界视图的原始尾巴)强制回写磁盘,下一轮再通过窄磁盘读取透明恢复。只要上次保存在证明已落盘,安全丢弃会在每个请求边界执行;超限会额外给出每会话提示(警告,60 秒节流)。设为 `0` 则关闭该检查。"
- key: sessions.gc.enabled
type: boolean
default: "false"
From d7f84894adb7eef76bf52e025e3e8d85079f8c42 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 15:25:25 +0800
Subject: [PATCH 11/53] fix(#4788): keep the debounce timer referenced while a
caller waits for it
`afterNextFlush` hands out a landing verdict by parking a resolver in
`flushWaiters` and returning a promise. The only thing that can drain that
map is the debounce timer's settle path -- and that timer is armed with
`timer.unref?.()` so host-owned upkeep never pins the process open on its own.
Alone, those two are correct. Together they deadlock the caller: once the
timer is the last pending handle, an unref'd timer is not a reason for Node
to stay alive, the loop drains, and the promise is never settled. The node
test runner reports exactly that shape --
`Promise promise resolution is still pending but the event loop has already
resolved` -- which is what `issue4788-resident-minimal` has been failing with
on every shard since #4788 D2 landed on master (its own CI never ran the
shards, so the suite debuted red).
The fix keys the reference on the waiter: arm unref'd as before when nobody
is waiting (host upkeep stays non-pinning, unchanged), and `ref()` the pending
timer the moment a waiter registers. The loop then stays alive exactly as
long as somebody is blocked on the verdict, and no longer.
Verified: `issue4788-resident-minimal` 0/4 -> 4/4; the persist/spill set
(issue5155, persist-lazy-narrow-reads, issue3157, multi-instance-persist,
memory-blockcontent-lazy) 28 pass / 0 fail. Minimal repro kept out of tree:
arm one save, await afterNextFlush with nothing else pending -- pre-fix the
process exits with the top-level await still unsettled, post-fix it resolves
with the landing verdict.
---
src/persist.ts | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/persist.ts b/src/persist.ts
index 1586370479..315810d969 100644
--- a/src/persist.ts
+++ b/src/persist.ts
@@ -2766,7 +2766,14 @@ export class SessionStore {
this.drainFlushWaiters(id, this.lastSaveLanded.get(id) === true);
});
}, this.debounceMs);
- timer.unref?.();
+ // #4788: the debounce timer is host-owned periodic upkeep and must not
+ // pin a process open on its own -- so it is unref'd. But `afterNextFlush`
+ // makes it a HOST: a caller that awaits a verdict has no other handle
+ // guaranteed to fire, and an unref'd timer is not a reason for Node to
+ // stay alive. Awaiting then exits the loop with the promise unresolved
+ // (`Promise resolution is still pending but the event loop has already
+ // resolved`). Keep the timer referenced exactly while someone waits.
+ if (!this.flushWaiters.has(id)) timer.unref?.();
this.saveTimers.set(id, timer);
}
@@ -2787,6 +2794,10 @@ export class SessionStore {
const ws = this.flushWaiters.get(sessionId) ?? [];
ws.push((ok: boolean): void => { resolve(ok); });
this.flushWaiters.set(sessionId, ws);
+ // #4788: the timer was armed unref'd (no waiter existed then). The
+ // waiter now holds the promise open, so the timer must hold the loop
+ // open too -- otherwise the process drains and the verdict is lost.
+ this.saveTimers.get(sessionId)?.ref?.();
});
}
From 3eb22b17189fd5ac9e4719449f8ce537a47eb4b9 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 16:08:12 +0800
Subject: [PATCH 12/53] test(#4666): pin the mysql lock lane to the CURRENT
schema version, not a literal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`002b5fa4` bumped the v8 pins to v9 for #4796, but it updated four of five
`assert.equal(await H.stamp(), ...)` sites in this file. The fifth — inside the
DSN-gated 'concurrent boots serialize on the migration lock' case — was left on
"8", so the mysql-contract lane has been red on every shard since: the migrator
correctly stamps 9 and the test asserts 8.
That case is `skip` when BILI_TEST_DSN is unset, which is why it never failed
locally: the local lane skips it, only the CI lane with a real mysql:8.0 service
executes it.
Pin the whole file to STORAGE_SCHEMA_VERSION instead of a literal, so the next
schema bump cannot leave one site behind again.
Verified: 2 pass / 0 fail locally (DSN-gated cases skip), tsc clean.
---
tests/issue4666-migration-dedup.test.ts | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/tests/issue4666-migration-dedup.test.ts b/tests/issue4666-migration-dedup.test.ts
index bf7f9e62bf..ee40ab8b4b 100644
--- a/tests/issue4666-migration-dedup.test.ts
+++ b/tests/issue4666-migration-dedup.test.ts
@@ -44,7 +44,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { UnifiedStore, sha256Hex } from "../src/storage/store.ts";
import { openSqliteDatabase } from "../src/storage/driver.ts";
-import { STORAGE_DDL_V1_FOR_TESTS } from "../src/storage/schema.ts";
+import { STORAGE_DDL_V1_FOR_TESTS, STORAGE_SCHEMA_VERSION } from "../src/storage/schema.ts";
import { identityOfStoredContent } from "../src/storage/identity.ts";
import { planIngest, type LegacySessionLike } from "../src/storage/ingest.ts";
import { openBackend, type StorageBackend } from "../src/storage/backend.ts";
@@ -155,7 +155,7 @@ test("#4666 sqlite: v8 migration collapses wireIndex-split duplicates and refund
check = openSqliteDatabase(path.join(dir, "index.db")).db;
const ver = check.prepare("PRAGMA user_version").get() as { user_version: number };
- assert.equal(ver.user_version, 9, "stamped to v9");
+ assert.equal(ver.user_version, STORAGE_SCHEMA_VERSION, "stamped to the current schema version");
// Exactly the lowest-seq arrival of each (msg_id, identity) group.
const survivors = check
@@ -371,7 +371,7 @@ test("#4666 mysql: v8 upgrade collapses duplicates, crash-safe stamp gate, retir
// Fresh install lands at v9.
be = await openBackend("mysql", H.dsn);
await be.init?.();
- assert.equal(await H.stamp(), "9", "fresh install stamped v9");
+ assert.equal(await H.stamp(), String(STORAGE_SCHEMA_VERSION), "fresh install stamped the current version");
// Downgrade to the legacy v7 shape and plant the dirty fixture.
await H.rewindToV7();
@@ -381,7 +381,7 @@ test("#4666 mysql: v8 upgrade collapses duplicates, crash-safe stamp gate, retir
await be.close();
be = await openBackend("mysql", H.dsn);
await be.init?.();
- assert.equal(await H.stamp(), "9", "upgrade stamped v9");
+ assert.equal(await H.stamp(), String(STORAGE_SCHEMA_VERSION), "upgrade stamped the current version");
const survivors = await H.q("SELECT seq, msg_id, content_hash, identity FROM messages WHERE session_id = 's1' ORDER BY seq");
assert.deepEqual(
@@ -430,7 +430,7 @@ test("#4666 mysql: v8 upgrade collapses duplicates, crash-safe stamp gate, retir
await be.close();
be = await openBackend("mysql", H.dsn);
await be.init?.();
- assert.equal(await H.stamp(), "9", "re-run completed and stamped v9");
+ assert.equal(await H.stamp(), String(STORAGE_SCHEMA_VERSION), "re-run completed and stamped the current version");
const eRows = await H.q("SELECT seq, identity FROM messages WHERE session_id = 's2' ORDER BY seq");
assert.deepEqual(eRows.map((r) => Number(r.seq)), [5, 13], "unbackfilled dup pair collapsed; the CAS-missing row survives (not a duplicate)");
assert.notEqual(String(eRows[0].identity), "", "identity backfilled on the re-run");
@@ -537,7 +537,10 @@ test("#4666 mysql: concurrent boots serialize on the migration lock",
assert.equal(Number(released[0].ok), 1, "lock released exactly once");
locker.release();
await migrating;
- assert.equal(await H.stamp(), "8", "migration completed after the lock freed");
+ // #4796 raised STORAGE_SCHEMA_VERSION to 9; every other stamp pin in
+ // this file moved with it — this one was left on v8, so the mysql
+ // lane asserted a version the migrator no longer writes.
+ assert.equal(await H.stamp(), String(STORAGE_SCHEMA_VERSION), "migration completed after the lock freed");
const s1 = await H.q("SELECT seq FROM messages WHERE session_id = 's1' ORDER BY seq");
assert.deepEqual(s1.map((r) => Number(r.seq)), [1, 2, 3, 5], "fixture collapsed exactly once — no double-delete");
const sLock = await H.q("SELECT seq, identity FROM messages WHERE session_id = 's_lock' ORDER BY seq");
From dda83795f83c535432f40aa7dd10f4d547c24546 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 16:26:27 +0800
Subject: [PATCH 13/53] test(#4788): close the unified sidecar before the
teardown unlink
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The four cases in this suite arm a real `SessionStorage` with
`BILI_STORAGE_UNIFIED=full`, so each one leaves `index.db` open. `cancelAll()`
clears the debounce timers and the build map but never closes the sidecar
handle, and Windows refuses to unlink a directory that still contains an open
file — `EBUSY: resource busy or locked, unlink ...\index.md` → note: 'index.db'`.
This was invisible until now: before the #4788 verdict fix the suite died on
`Promise resolution is still pending but the event loop has already resolved`
long before reaching `rmrf`, so the leak never got a chance to fail the job.
Fixing the deadlock exposed it.
Use the documented short-lived-store close (`closeQuietly`) instead of
`cancelAll` in teardown — it cancels first and then closes, which is exactly
what "Never throws" teardown wants.
Verified: 4 pass / 0 fail, tsc clean.
---
tests/issue4788-resident-minimal.test.ts | 28 ++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/tests/issue4788-resident-minimal.test.ts b/tests/issue4788-resident-minimal.test.ts
index 5f20c83aa8..74f928d161 100644
--- a/tests/issue4788-resident-minimal.test.ts
+++ b/tests/issue4788-resident-minimal.test.ts
@@ -129,7 +129,12 @@ test("(a) between-turns drop: every landed request-end boundary discharges dropp
assert.ok((s.blockContents as LazyBlockContents).isDropped, "still folded after the second boundary");
assert.ok(isDroppedMetadata(s.metadata));
} finally {
- store.cancelAll();
+ // #4788: `cancelAll` only clears timers — the unified sidecar's sqlite
+ // handle stays open, and Windows refuses to unlink index.db while it is
+ // (EBUSY). These stores are per-test short-lived, so close the handle
+ // before the teardown delete. Pre-fix this never showed because the
+ // suite died on an unsettled verdict before reaching cleanup.
+ store.closeQuietly();
rmrf(dir);
}
});
@@ -164,7 +169,12 @@ test("(b) narrow-read reconstruction deepEquals the pre-drop reference", async (
const rec = (parsed.payload ?? parsed) as Record;
assert.equal(Object.keys((rec.blockContents ?? {}) as object).length, Object.keys(refBlocks).length, "full set round-trips through the envelope");
} finally {
- store.cancelAll();
+ // #4788: `cancelAll` only clears timers — the unified sidecar's sqlite
+ // handle stays open, and Windows refuses to unlink index.db while it is
+ // (EBUSY). These stores are per-test short-lived, so close the handle
+ // before the teardown delete. Pre-fix this never showed because the
+ // suite died on an unsettled verdict before reaching cleanup.
+ store.closeQuietly();
rmrf(dir);
}
});
@@ -203,7 +213,12 @@ test("(c) hard cap under sustained activity: boundaries drop, in-flight skips, w
process.env.BILI_SESSION_RESIDENT_HARD_MAX_BYTES = saved;
}
} finally {
- store.cancelAll();
+ // #4788: `cancelAll` only clears timers — the unified sidecar's sqlite
+ // handle stays open, and Windows refuses to unlink index.db while it is
+ // (EBUSY). These stores are per-test short-lived, so close the handle
+ // before the teardown delete. Pre-fix this never showed because the
+ // suite died on an unsettled verdict before reaching cleanup.
+ store.closeQuietly();
rmrf(dir);
}
});
@@ -246,7 +261,12 @@ test("(d) envelope-frozen routing is unchanged AND gets equivalent drops", async
assert.equal(s.blockContents.size, 21, "all entries resident again after reconstruction");
} finally {
process.env.BILI_STORAGE_UNIFIED = "legacy";
- store.cancelAll();
+ // #4788: `cancelAll` only clears timers — the unified sidecar's sqlite
+ // handle stays open, and Windows refuses to unlink index.db while it is
+ // (EBUSY). These stores are per-test short-lived, so close the handle
+ // before the teardown delete. Pre-fix this never showed because the
+ // suite died on an unsettled verdict before reaching cleanup.
+ store.closeQuietly();
rmrf(dir);
}
});
From cc418fb9801ee16d560fc33cf29314cd237c7400 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 17:06:36 +0800
Subject: [PATCH 14/53] test(#5140): state the unified-mode precondition
instead of inheriting it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cold-restore case reconstructs the parent's raw tail from the unified
sidecar, so it only means anything in `full` mode. It never set the knob: it
inherited `full` from whichever earlier case in the file left it behind.
That dependency is invisible in a standalone run — and the CI harness makes it
fatal, because `tests/preload-storage-baseline.mjs` pins
`BILI_STORAGE_UNIFIED ??= "legacy"` (#2815) for the whole corpus. Result: the
shard where this file lands has been red on every run since #5144 merged, with
`409 SNAPSHOT_UNAVAILABLE` and an 18ms duration (the store never had a sidecar
to read).
Reproduced deterministically:
node --import ./tests/preload-storage-baseline.mjs --import tsx \
--test --test-name-pattern=cold-restored tests/plugin-fork.test.ts
=> not ok 1 (before) / ok 1 (after)
Pin the mode in the case and restore the prior value in finally, so the case
states its own precondition and cannot be re-broken by an unrelated suite's
cleanup order.
---
.tmp-flake.mjs | 21 ++++++++++++++++++++
tests/issue4739-bounded-session-load.test.ts | 12 +++++++++--
tests/plugin-fork.test.ts | 16 +++++++++++++--
3 files changed, 45 insertions(+), 4 deletions(-)
create mode 100644 .tmp-flake.mjs
diff --git a/.tmp-flake.mjs b/.tmp-flake.mjs
new file mode 100644
index 0000000000..9a266a40e1
--- /dev/null
+++ b/.tmp-flake.mjs
@@ -0,0 +1,21 @@
+// Drive the same test file repeatedly, concurrently, in one process each.
+import { spawn } from "node:child_process";
+const N = Number(process.argv[2] ?? 4);
+const runs = Number(process.argv[3] ?? 3);
+let hits = 0;
+for (let r = 0; r < runs; r++) {
+ const kids = [];
+ for (let i = 0; i < N; i++) {
+ kids.push(new Promise((res) => {
+ const c = spawn(process.execPath, ["--import","tsx","--test","--test-concurrency","4","tests/plugin-fork.test.ts"], { stdio: ["ignore","pipe","pipe"] });
+ let out = ""; c.stdout.on("data", d => out += d); c.stderr.on("data", d => out += d);
+ c.on("exit", () => res(out));
+ }));
+ }
+ const outs = await Promise.all(kids);
+ for (const o of outs) {
+ const bad = /^not ok /m.test(o);
+ if (bad) { hits++; const m = o.match(/PROBE[\s\S]{0,120}/); console.log("HIT:", m ? m[0].replace(/\n/g," | ") : "(no probe line)"); }
+ }
+ console.log(`round ${r+1}/${runs} hits=${hits}`);
+}
diff --git a/tests/issue4739-bounded-session-load.test.ts b/tests/issue4739-bounded-session-load.test.ts
index bc9906b5f0..bef747d04d 100644
--- a/tests/issue4739-bounded-session-load.test.ts
+++ b/tests/issue4739-bounded-session-load.test.ts
@@ -139,7 +139,11 @@ test("full mode: viewFor decodes only pointer rows and matches the full-log deco
assert.deepEqual(via, reference, "bounded decode byte-equal to the full-log decode");
assert.equal(delta, expectedPointers, `viewFor read ${delta} message rows for ${expectedPointers} pointer seqs (full log = ${liveSeqs.size})`);
} finally {
- store.cancelAll();
+ // #4739: the unified-full cases open a real sidecar, so teardown must
+ // CLOSE it — Windows refuses to unlink a directory holding an open
+ // index.db (EBUSY), and cancelAll() only clears timers. Same shape as
+ // the #4788 suite fix.
+ store.closeQuietly();
if (prev === undefined) delete process.env.BILI_STORAGE_UNIFIED;
else process.env.BILI_STORAGE_UNIFIED = prev;
rmrf(dir);
@@ -178,7 +182,11 @@ test("full mode: dropped metadata restores from the index alone (narrow read, no
assert.ok(again, "envelope rewritten by later save");
assert.deepEqual(Object.keys(JSON.parse(fs.readFileSync(again, "utf8")).payload.metadata as object).sort(), persistedKeys);
} finally {
- store.cancelAll();
+ // #4739: the unified-full cases open a real sidecar, so teardown must
+ // CLOSE it — Windows refuses to unlink a directory holding an open
+ // index.db (EBUSY), and cancelAll() only clears timers. Same shape as
+ // the #4788 suite fix.
+ store.closeQuietly();
if (prev === undefined) delete process.env.BILI_STORAGE_UNIFIED;
else process.env.BILI_STORAGE_UNIFIED = prev;
rmrf(dir);
diff --git a/tests/plugin-fork.test.ts b/tests/plugin-fork.test.ts
index 5e77c14886..baadf3423a 100644
--- a/tests/plugin-fork.test.ts
+++ b/tests/plugin-fork.test.ts
@@ -1461,7 +1461,15 @@ test("HTTP a mid-history rewrite replaces the snapshot wholesale with an exact b
} finally { await h.close(); }
});
-test("HTTP a cold-restored non-retained parent serves its intact raw tail immediately (#5140)", { timeout: 60_000 }, async () => {
+test("HTTP a cold-restored parent serves its intact raw tail immediately (#5140)", { timeout: 60_000 }, async () => {
+ // #5140: the reconstruction reads the UNIFIED sidecar, so this case is
+ // only meaningful in full mode. It used to inherit "full" from whichever
+ // suite ran before it in the same process; the CI baseline preload pins
+ // BILI_STORAGE_UNIFIED=legacy (#2815) and made the dependency invisible
+ // locally, where the file is driven standalone. Pin it here so the case
+ // states its own precondition.
+ const priorMode = process.env.BILI_STORAGE_UNIFIED;
+ process.env.BILI_STORAGE_UNIFIED = "full";
const h = await harness(true);
try {
const parent = resolveConversation("parent").session!;
@@ -1480,7 +1488,11 @@ test("HTTP a cold-restored non-retained parent serves its intact raw tail immedi
await sendModel(h, "parent", h.messages);
assert.equal((await h.request("/__bili/plugin/snapshot?conversationId=parent")).status, 200);
assert.equal(resolveConversation("parent").session!.pluginSnapshot!.length, 3);
- } finally { await h.close(); }
+ } finally {
+ if (priorMode === undefined) delete process.env.BILI_STORAGE_UNIFIED;
+ else process.env.BILI_STORAGE_UNIFIED = priorMode;
+ await h.close();
+ }
});
test("fork protocol v2: snapshot serves the light revision, a stale v1 whole-store revision is still accepted (#2671 Phase 2b)", { timeout: 60_000 }, async () => {
From 8c4f7115b6cf48d9142dd2ab135b4a665c3d5c3d Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 19:43:22 +0800
Subject: [PATCH 15/53] fix(#5175): judge each attempt on its own output; only
an unfinished draft envelope is a dump
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two regressions surfaced by driving the REAL chat/responses pipes rather than
the predicates alone:
1. The envelope arm was unconditional, so a COMPLETE
`…` followed by substantive prose matched. That
turn delivered both the draft and an answer (converged), yet the #2303 retry
re-issued it: the client got its answer twice. The reported dumps are always
open-ended — the model stops inside the summary body — so the arm now counts
only an unfinished envelope. Corpus: 8225 fields, 26 hits now 21; the 5 given
up are exactly the complete-draft-then-answer false positives.
2. `retryEmptyTurn` abandoned the first attempt's bytes on the wire but kept its
adjudication counters, so the retry's terminal was judged against the
abandoned draft: a turn whose retry answered correctly still got
"#870 the turn degenerated again after the continuation nudge" appended to
the client's answer. Both lanes now reset their answer accounting at takeover
(turn-level observation counters stay, they report what the client received).
Both are pinned by regression tests that fail against the un-fixed tree.
Also drops `.tmp-flake.mjs`, a local flake-hunting driver that should never have
been committed (no references anywhere; .gitignore only covers `.tmp/`).
---
.tmp-flake.mjs | 21 -------
src/degenerate-turn.ts | 22 +++++++-
src/plugin.ts | 23 ++++++++
tests/issue5175-drift-name-leak.test.ts | 73 ++++++++++++++++++++++++-
4 files changed, 112 insertions(+), 27 deletions(-)
delete mode 100644 .tmp-flake.mjs
diff --git a/.tmp-flake.mjs b/.tmp-flake.mjs
deleted file mode 100644
index 9a266a40e1..0000000000
--- a/.tmp-flake.mjs
+++ /dev/null
@@ -1,21 +0,0 @@
-// Drive the same test file repeatedly, concurrently, in one process each.
-import { spawn } from "node:child_process";
-const N = Number(process.argv[2] ?? 4);
-const runs = Number(process.argv[3] ?? 3);
-let hits = 0;
-for (let r = 0; r < runs; r++) {
- const kids = [];
- for (let i = 0; i < N; i++) {
- kids.push(new Promise((res) => {
- const c = spawn(process.execPath, ["--import","tsx","--test","--test-concurrency","4","tests/plugin-fork.test.ts"], { stdio: ["ignore","pipe","pipe"] });
- let out = ""; c.stdout.on("data", d => out += d); c.stderr.on("data", d => out += d);
- c.on("exit", () => res(out));
- }));
- }
- const outs = await Promise.all(kids);
- for (const o of outs) {
- const bad = /^not ok /m.test(o);
- if (bad) { hits++; const m = o.match(/PROBE[\s\S]{0,120}/); console.log("HIT:", m ? m[0].replace(/\n/g," | ") : "(no probe line)"); }
- }
- console.log(`round ${r+1}/${runs} hits=${hits}`);
-}
diff --git a/src/degenerate-turn.ts b/src/degenerate-turn.ts
index ed56079664..cada288c40 100644
--- a/src/degenerate-turn.ts
+++ b/src/degenerate-turn.ts
@@ -80,15 +80,31 @@ const DRAFT_CLOSE_TAIL = /\x3c\/(?:summary|analysis)\x3e\s*$/i;
// else in the corpus produces that adjacency, so it cannot false-positive on
// prose that merely mentions the words.
const DRAFT_ENVELOPE = /\x3c\/analysis\x3e\s*\x3csummary\x3e/i;
+const SUMMARY_CLOSE = /\x3c\/summary\x3e/i;
export function endsWithDraftClose(text: string): boolean {
return text.length > 0 && DRAFT_CLOSE_TAIL.test(text);
}
/** #5175: the draft-envelope shape — a handoff/compression draft written as
* prose in either of its two observable forms: the closing tag at the very end
- * (#2303), or the analysis→summary envelope anywhere (#5175). Both mean the
- * model narrated the action instead of issuing it, so both are non-converged. */
+ * (#2303), or the analysis→summary envelope (#5175). Both mean the model
+ * narrated the action instead of issuing it, so both are non-converged.
+ *
+ * The envelope counts only while the draft is UNFINISHED. A complete
+ * `…` followed by substantive prose delivered
+ * BOTH the draft and an answer — the turn converged, and re-issuing it appends
+ * a duplicate answer on the real chat pipe (measured: pristine master keeps a
+ * single answer, the unconditional envelope rule produced answer + retry
+ * answer). The reported dumps are always open-ended — the model stops inside
+ * the summary body (#5175) or right at the analysis close (#2303) — so recall
+ * is unchanged: the only hits this gives up are complete drafts that kept
+ * writing, which are exactly the false positives. */
export function isDraftDump(text: string): boolean {
- return text.length > 0 && (DRAFT_CLOSE_TAIL.test(text) || DRAFT_ENVELOPE.test(text));
+ if (text.length === 0) return false;
+ if (DRAFT_CLOSE_TAIL.test(text)) return true;
+ const env = DRAFT_ENVELOPE.exec(text);
+ if (env === null) return false;
+ // Still inside the draft: the summary body is truncated mid-flight.
+ return !SUMMARY_CLOSE.test(text.slice(env.index + env[0].length));
}
// #2612: the #2303 draft-tail shape has a legitimate producer — the CLIENT's
diff --git a/src/plugin.ts b/src/plugin.ts
index 1d77203db7..58f68d131f 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -2595,6 +2595,20 @@ export async function pipePluginChatWithStrip(
/** Set while a frame is being rewritten because a render tag was dropped:
* the text that survives such a frame is the tag's own interior. */
let droppedTagInFrame = false;
+ /** #5175: the counters above answer "did THIS attempt deliver an answer?" —
+ * the question the #2303/#732 retry and the #673 warn both ask. A retry
+ * that takes over must start from a clean adjudication slate: the first
+ * attempt's prose is already on the wire (unretractable), so judging the
+ * retry against it reads the abandoned draft as the retry's own output and
+ * emits "#870 degenerated again" for a turn that answered correctly.
+ * visibleTextChars is deliberately NOT reset: it is turn-level observation
+ * (the #673 warn and the truncation diag report what the client received). */
+ const resetAttemptAdjudication = (): void => {
+ proseAcc = "";
+ answerProse = "";
+ releasedMarkupChars = 0;
+ sawStrippedEcho = false;
+ };
let finalFinishReason: string | undefined;
// #2328 Q2/Q4: termination diagnostics — every truncation emit below
// carries these on the in-band error frame (meta) and the log line.
@@ -2724,6 +2738,9 @@ export async function pipePluginChatWithStrip(
delete lastChunkMeta["finishReason"];
retryIndexOffset = blocksForwarded;
inRetry = true;
+ // The first attempt's adjudication counters describe bytes the client
+ // already holds; the retry is judged on its own output (#5175).
+ resetAttemptAdjudication();
// The first attempt's held filter state belongs to text the client never
// saw (an emptying tag echo): the retry's content is filtered from
// scratch, so a partial tag there cannot swallow its opening characters.
@@ -4132,6 +4149,12 @@ export async function pipePluginResponsesWithStrip(
heldEvents = [];
heldVisibleChars = 0;
inRetry = true;
+ // The first attempt's adjudication counters describe frames the client
+ // already holds; the retry is judged on its own output (#5175).
+ proseAcc = "";
+ answerProse = "";
+ fastPathChars = 0;
+ sawStrippedEcho = false;
// The first attempt's held filter state belongs to text the client never
// saw (an emptying tag echo): the retry's content is filtered from
// scratch, so a partial tag there cannot swallow its opening characters.
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index 5763476106..1e632e600b 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -338,7 +338,7 @@ for (const [name, text] of PARITY_SHAPES) {
});
}
-// ---------- #5176 follow-up: the retry CANNOT retract what already left ----------
+// ---------- #4764 follow-up: the retry CANNOT retract what already left ----------
// #4764 and this case are the same residual: the #2303 retry fires at stream
// END, but every delta was already appended to the client's reply as it was
@@ -349,8 +349,8 @@ for (const [name, text] of PARITY_SHAPES) {
//
// This is pinned as a CHARACTERIZATION test, not an aspiration: it documents
// why "just strip the draft from the stream" is not a one-line fix, and why
-// the real fix has to HOLD the draft span before releasing it (#5176).
-test("#5176 the #2303 retry documents its own limit: already-streamed draft bytes are not retracted", async () => {
+// the real fix has to HOLD the draft span before releasing it (#4764).
+test("#4764 the #2303 retry documents its own limit: already-streamed draft bytes are not retracted", async () => {
const { pipePluginChatWithStrip } = await import("../src/plugin.ts");
const enc = new TextEncoder();
const chunk = (delta: Record): string =>
@@ -397,3 +397,70 @@ test("#5176 the #2303 retry documents its own limit: already-streamed draft byte
assert.ok(text.includes("Primary Request: X"), "the already-streamed draft CANNOT be retracted (measured, not assumed)");
assert.ok(text.includes("recovered action"), "the retry appends its answer after it");
});
+
+// ---------- #5175 regressions found while reviewing the fix ----------
+
+// A COMPLETE draft envelope (analysis close → summary open → summary close)
+// followed by substantive prose delivered both the draft and an answer: the
+// turn converged. The unconditional envelope rule read the spent draft as a
+// stalled handoff and re-issued it on the real chat pipe, so the client got its
+// answer TWICE plus a "#870 degenerated again" frame. Pristine master answers
+// once. The rule now counts only an UNFINISHED envelope — a model that stopped
+// inside the summary body (the reported shape) still detects.
+test("#5175 B: a COMPLETE draft envelope followed by an answer is not retried", () => {
+ const complete =
+ "\nThe user asked about sharding.\n\n\n\n- notes\n\n\n" +
+ "Here is the real answer: the shard key is user_id.";
+ assert.equal(isDraftDump(complete), false, "a finished draft that kept writing is not a stalled handoff");
+ // The reported shape: still inside the summary body when the stream ends.
+ const open = "\n…\n\n\n\n1. Primary Request and Intent\n- sharding\n";
+ assert.equal(isDraftDump(open), true, "an UNFINISHED envelope is still a draft dump");
+ // And the #2303 tail form is untouched.
+ assert.equal(isDraftDump("I will now hand off.\n"), true);
+});
+
+// The retry's adjudication must not inherit the abandoned attempt's counters:
+// the first attempt's prose is already on the wire, so judging the retry against
+// it emitted the #870 error for a turn that had answered correctly.
+test("#5175 B: a retried turn that answers correctly emits no #870 error", async () => {
+ const enc = new TextEncoder();
+ const chunk = (delta: Record, finish: string | null = null): string =>
+ `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`;
+ const DONE = "data: [DONE]\n\n";
+ const of = (ev: string[]): ReadableStream => {
+ let i = 0;
+ return new ReadableStream({
+ pull(c) {
+ if (i < ev.length) c.enqueue(enc.encode(ev[i++]));
+ else c.close();
+ },
+ });
+ };
+ const writes: string[] = [];
+ const res = {
+ write(b: Buffer | string) {
+ writes.push(typeof b === "string" ? b : b.toString("utf8"));
+ return true;
+ },
+ end(b?: Buffer | string) {
+ if (b !== undefined) writes.push(typeof b === "string" ? b : b.toString("utf8"));
+ },
+ once() {},
+ destroyed: false,
+ writableEnded: false,
+ } as unknown as import("node:http").ServerResponse;
+ const session = { id: "s2", protocol: "openai", upstreamOrigin: "http://127.0.0.1:9/v1", label: "t", createdAt: 0, lastUsedAt: 0, requests: 0, lastInputTokens: 0, stats: {}, dirty: false } as never;
+
+ const DRAFT = "\n…\n\n\n\n1. Primary Request: X.\n";
+ let calls = 0;
+ const refetch = (): Promise> => {
+ calls += 1;
+ return Promise.resolve(of([chunk({ role: "assistant" }), chunk({ content: "the real answer" }), chunk({}, "stop"), DONE]));
+ };
+ await pipePluginChatWithStrip(of([chunk({ role: "assistant" }), chunk({ content: DRAFT }), chunk({}, "stop"), DONE]), res, "openai", session, undefined, refetch);
+
+ const text = writes.join("");
+ assert.equal(calls, 1, "the unfinished draft IS retried");
+ assert.ok(text.includes("the real answer"), "the retry's answer reaches the client");
+ assert.equal(text.includes("degenerated again"), false, "the retry answered, so no #870 error may be emitted");
+});
From 6f2f6cab28f953efb9aa12f7705b95544917622c Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 20:05:13 +0800
Subject: [PATCH 16/53] test(#5152): publish the receiver port atomically so
polling cannot read 0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Inherited CI flake (the fixture is on master, from #5153):
test (ubuntu-latest, 22, 1)
not ok - #5152 a host where the worker cannot start keeps beating from the
in-thread loop
error: 'receiver port must be a number, got 0'
at startReceiver (tests/issue5152-owner-beat-survives-host-stall.test.ts:62)
appendFileSync() opens (and creates) the file as one syscall and writes the
bytes as another, so startReceiver()'s existsSync() poll can lose the race
against the content. Proven with the syscall order split out: existsSync=true,
readFileSync="" , Number("") === 0 — the exact assertion message on CI.
Publish through a temp file + rename() instead: rename is atomic within a
filesystem, so every observer sees either no file or the complete port. The
natural window is narrow enough that 180 stress spawns did not hit it locally;
this removes the window rather than a reproduced failure.
---
tests/fixtures/issue5152-beat-receiver.mjs | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/tests/fixtures/issue5152-beat-receiver.mjs b/tests/fixtures/issue5152-beat-receiver.mjs
index 132fbe11aa..d0654faef1 100644
--- a/tests/fixtures/issue5152-beat-receiver.mjs
+++ b/tests/fixtures/issue5152-beat-receiver.mjs
@@ -3,7 +3,7 @@
// reaching it while the sender's own thread was pinned. An in-process receiver
// would be pinned by the same block and the proof would be vacuous.
import http from "node:http";
-import { appendFileSync } from "node:fs";
+import { appendFileSync, renameSync, writeFileSync } from "node:fs";
const out = process.argv[2];
if (!out) throw new Error("usage: issue5152-beat-receiver.mjs ");
@@ -17,5 +17,12 @@ const server = http.createServer((req, res) => {
});
});
server.listen(0, "127.0.0.1", () => {
- appendFileSync(`${out}.port`, String(server.address().port));
+ // Publish the port ATOMICALLY. appendFileSync opens (creates) the file
+ // before writing its bytes, so a parent polling existsSync() can observe an
+ // empty file and read Number("") === 0 — the #5152 CI flake. rename() is
+ // atomic within one filesystem, so every observer sees either no file or
+ // the complete port.
+ const tmp = `${out}.port.tmp`;
+ writeFileSync(tmp, String(server.address().port));
+ renameSync(tmp, `${out}.port`);
});
From 4ea57ea231c10e532d2e1b869808ba9f44dd935b Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Fri, 30 Oct 2026 23:55:13 +0800
Subject: [PATCH 17/53] test(#5155): await the turn's durable save instead of a
fixed 20ms sleep
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Inherited windows-latest flake (the sleeps are on master, from #5155):
not ok - cold canonical rebuild: identical resync keeps the shared prefix
error: cold rebuild must serve the shared prefix instead of 409-reseed
409 !== 200
at tests/inc-canonical-pointers.test.ts:112
Both tests dropped the resident caches after a fixed `setTimeout(20)` to let
the debounced durable save land. The settle is a real condition, not a delay:
a loaded runner can miss the deadline and the cold rebuild then reads a
one-turn-stale table and honestly 409s. The harness sets debounceMs: 0, but the
write still defers a full tick, so 20ms was already only a guess.
Await Store.afterNextFlush(sessionId) instead — it resolves once the armed save
lands with a landing proof, so no runner-speed budget exists.
Negative control: with the two awaits removed the file is red 3/3
(pass 1 / fail 2, 409 !== 200); with them in place it is green 5/5.
---
tests/inc-canonical-pointers.test.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/tests/inc-canonical-pointers.test.ts b/tests/inc-canonical-pointers.test.ts
index e0210b8d4f..bdc730e143 100644
--- a/tests/inc-canonical-pointers.test.ts
+++ b/tests/inc-canonical-pointers.test.ts
@@ -105,7 +105,11 @@ test("cold canonical rebuild: identical resync keeps the shared prefix; delta as
// #5155: the wrap-up defers the durable flush (debounced) — let the
// rows settle before dropping the resident caches, or the cold
// rebuild reads a one-turn-stale table and honestly 409s.
- await new Promise((resolve) => setTimeout(resolve, 20));
+ // Await the settle itself (afterNextFlush resolves once the armed save
+ // lands) instead of a fixed sleep: the settle is a real condition, and
+ // a magic millisecond budget is a deadline a loaded windows-latest
+ // runner can miss — the 409 flake this replaces.
+ assert.equal(await getStore().afterNextFlush("conv-ptrs"), true, "the turn's durable save must land before the cold reset");
__resetIncrementalLaneForTests();
const delta = await h.turn({ sinceSeq: 3, messages: [core("u4", "four")] });
@@ -135,7 +139,7 @@ test("cold canonical rebuild: edited-tail resync keeps the shared prefix, drops
await h.turn({ sinceSeq: 0, resync: true, messages: [core("u1", "one"), core("u2", "two-EDITED")] });
await __incrementalPtrChainForTests("conv-ptrs");
// #5155: settle the debounced flush before the cold reset (see above).
- await new Promise((resolve) => setTimeout(resolve, 20));
+ assert.equal(await getStore().afterNextFlush("conv-ptrs"), true, "the resync's durable save must land before the cold reset");
__resetIncrementalLaneForTests();
const delta = await h.turn({ sinceSeq: 2, messages: [core("u3", "three")] });
assert.equal(delta.status, 200);
From a1450f6bda6284fdf919ea71896c39ce51d234fa Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Sat, 31 Oct 2026 00:55:56 +0800
Subject: [PATCH 18/53] docs(#4788): regenerate CONFIGURATION.md from the
backticked seed after the master merge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The merge took upstream's website/config-reference/process.yaml (which quotes
`sessions.residentMaxBytes` in the hardMaxBytes default cell) but left
CONFIGURATION.md at the pre-merge prose, so gen-config-docs generate produced a
one-line diff and docs-sync went red on a5a76ea6. Regenerating from the seed is
the whole fix — no seed change.
---
CONFIGURATION.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 9d89840aa7..0df8c5d4d5 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -203,7 +203,7 @@ This index is generated from `website/config-reference/*.yaml` — edit the seed
| `sessions.warmMax` | number | 0 | BILI_SESSION_WARM_MAX | Bounded startup (#2803/#3050): max most-recently-saved sessions hydrated at boot before listening; older state reloads on demand. Default 0 (no eager warm set — first touch pays one on-demand load; set 8 to restore the eager behavior). Capped by sessions.max. |
| `sessions.warmMaxBytes` | number | 67108864 | BILI_SESSION_WARM_MAX_BYTES | Decoded-byte budget for the boot warm set (#2803). Counts decoded size, not compressed on-disk bytes, so zstd archives cannot sneak past the RAM bound. A single file over the budget is never warmed. |
| `sessions.residentMaxBytes` | number | 536870912 | BILI_SESSION_RESIDENT_MAX_BYTES | Soft target for estimated resident session bytes (#2803). Active/in-flight state stays pinned above it; when over target, idle state discharges back to disk as requests complete. |
-| `sessions.hardMaxBytes` | number | (same as sessions.residentMaxBytes) | BILI_SESSION_RESIDENT_HARD_MAX_BYTES | Per-SESSION hard cap on estimated resident bytes (#4788, decision #4792 D1-A). Unlike the pool soft cap — which can only evict OTHER cold sessions and never touches a sole-active session — this bounds ONE session: over the cap at a request-end boundary, its droppable fields (folded-block originals, metadata ledger, the raw tail beyond the bounded view) are forced back to disk even for a pool-exempt session, and the next turn transparently re-reads what it needs via narrow disk reads. Safe drops run at every request boundary as long as the last save has provably landed; the cap adds the per-session call-out (warn, throttled to 60 s). `0` disables the cap check. |
+| `sessions.hardMaxBytes` | number | (same as `sessions.residentMaxBytes`) | BILI_SESSION_RESIDENT_HARD_MAX_BYTES | Per-SESSION hard cap on estimated resident bytes (#4788, decision #4792 D1-A). Unlike the pool soft cap — which can only evict OTHER cold sessions and never touches a sole-active session — this bounds ONE session: over the cap at a request-end boundary, its droppable fields (folded-block originals, metadata ledger, the raw tail beyond the bounded view) are forced back to disk even for a pool-exempt session, and the next turn transparently re-reads what it needs via narrow disk reads. Safe drops run at every request boundary as long as the last save has provably landed; the cap adds the per-session call-out (warn, throttled to 60 s). `0` disables the cap check. |
| `sessions.gc.enabled` | boolean | false | BILI_SESSION_GC | Opt-in garbage collection of stale persisted sessions (user data is never pruned silently without this flag). |
| `sessions.gc.maxAgeDays` | number | 7 | BILI_SESSION_GC_MAX_AGE_DAYS | GC age threshold in days. |
| `sessions.gc.maxTokens` | number | 1000000 | BILI_SESSION_GC_MAX_TOKENS | GC token-size threshold per session record. |
From 6aecc2599d413bb93b5eead70d984a20b88259a7 Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Sat, 31 Oct 2026 03:50:57 +0800
Subject: [PATCH 19/53] fix(#5175): adjudicate drifted render-tag residue on
the responses wire
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The chat wire runs `stripDriftResidue` on every released delta at the
release point (`pushField`); the responses wire had no such call, so a
drifted unit (``) rode `response.output_text.delta` straight
to the client. Defect A was originally verified on the chat pipe only,
which is why the suite stayed green while the responses lane kept
leaking the reported paragraph verbatim — two wires, one gateway.
Two fixes:
- responses wire: run the same drift adjudication on the released delta
(`src/plugin.ts` output_text.delta branch);
- both wires: record the adjudication as stripped EVIDENCE. When the
drift pass empties a whole delta the #732/#821 empty-turn gate judged
the turn "bare" and passed a silently EMPTY assistant message to the
client, which is worse than the verbatim leak it replaced.
Both directions pinned in `tests/issue5175-drift-name-leak.test.ts`:
the responses lane strips the unit (fails without fix 1), and a
fully stripped turn is retried rather than delivered empty (fails
without fix 2).
---
src/plugin.ts | 24 ++++++++-
tests/issue5175-drift-name-leak.test.ts | 70 +++++++++++++++++++++++++
2 files changed, 93 insertions(+), 1 deletion(-)
diff --git a/src/plugin.ts b/src/plugin.ts
index ffc9bfe971..abd8714b0f 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -3037,6 +3037,16 @@ export async function pipePluginChatWithStrip(
// would reintroduce exactly that rewrite through the back door.
const released = s.filter.push(text);
const clean = isVerbatimField(field) ? released : stripDriftResidue(released);
+ // #5175 x #2303: the drift adjudication can empty an entire delta the
+ // state machine released verbatim. That is stripped-EVIDENCE, exactly
+ // like onTagDrop — without recording it the empty-turn retry gate
+ // (#732/#821) judges the turn "bare: no thinking, no stripped echo, no
+ // released markup" and passes a SILENTLY EMPTY assistant message to the
+ // client, which is worse than the verbatim leak it replaced.
+ if (clean !== released) {
+ sawStrippedEcho = true;
+ releasedMarkupChars += released.length - clean.length;
+ }
if (clean.length > 0) proseAcc += clean;
return [clean, clean !== text];
};
@@ -4470,7 +4480,19 @@ export async function pipePluginResponsesWithStrip(
await write(rawEvent + "\n\n");
continue;
}
- const clean = tagFilter.push(delta);
+ const releasedDelta = tagFilter.push(delta);
+ // #5175: the responses wire needs the SAME release-point
+ // adjudication its chat twin does (pushField). The state
+ // machine is tag-STRUCTURE scoped, so a drifted name
+ // (`acpt`) never enters it and rides out verbatim, and the
+ // whole-text pass that would catch it runs after the
+ // client has already rendered the frame. Missing this made
+ // the two lanes disagree — chat strips, responses leaks —
+ // which is how defect A survived on the responses wire.
+ // The signed-reasoning channel never reaches this branch
+ // (it is handled above, byte-verbatim, #1960).
+ const clean = stripDriftResidue(releasedDelta);
+ if (clean !== releasedDelta) sawStrippedEcho = true;
lastDeltaMeta = { item_id: ev["item_id"], output_index: ev["output_index"] };
if (!inRetry && heldItemId === undefined && ev["item_id"] !== undefined) {
heldItemId = ev["item_id"];
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index 1e632e600b..dd65fde7ce 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -464,3 +464,73 @@ test("#5175 B: a retried turn that answers correctly emits no #870 error", async
assert.ok(text.includes("the real answer"), "the retry's answer reaches the client");
assert.equal(text.includes("degenerated again"), false, "the retry answered, so no #870 error may be emitted");
});
+
+// #5175 defect A, second lane: the chat wire adjudicates a released delta at the
+// release point (`pushField` → `stripDriftResidue`); the responses wire had NO
+// such call, so the reported paragraph rode `response.output_text.delta`
+// straight to the client. The original proof for A drove the chat pipe only, so
+// the suite stayed green while the responses lane kept leaking. Two wires, one
+// gateway: the same model output must reach the client as the same text.
+const DRIFTED =
+ "acpt> ";
+
+function driveResponsesOnce(payload: string): Promise<{ text: string; calls: number }> {
+ const enc = new TextEncoder();
+ const sse = (type: string, extra: Record): string => `event: ${type}\ndata: ${JSON.stringify({ type, ...extra })}\n\n`;
+ const of = (ev: string[]): ReadableStream => {
+ let i = 0;
+ return new ReadableStream({ pull(c) { if (i < ev.length) c.enqueue(enc.encode(ev[i++])); else c.close(); } });
+ };
+ const writes: string[] = [];
+ const res = makeResLocal(writes);
+ const session = { ...makeSessionLocal(), id: "testsess5175_resp_" + Math.random().toString(36).slice(2), stats: { pendingFoldUsage: false, compressCreditTokens: 0 } } as unknown as Session;
+ let calls = 0;
+ const refetch = (): Promise> => {
+ calls += 1;
+ return Promise.resolve(of([sse("response.output_text.delta", { item_id: "oi1", output_index: 0, delta: "the real answer" }), sse("response.completed", { response: { id: "r1", usage: { input_tokens: 1, output_tokens: 1 } } })]));
+ };
+ const events = [sse("response.output_text.delta", { item_id: "oi1", output_index: 0, delta: payload }), sse("response.completed", { response: { id: "r1", usage: { input_tokens: 1, output_tokens: 1 } } })];
+ return pipePluginResponsesWithStrip(streamOfLocal(events), res as unknown as import("node:http").ServerResponse, session, undefined, refetch).then(() => ({
+ text: [...writes.join("").matchAll(/"delta":"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string).join(""),
+ calls,
+ }));
+}
+
+test("#5175 A: the responses lane strips the reported unit too, not just the chat pipe", async () => {
+ // REPORTED's pairs are all ENUMERATED closes (``), so the state
+ // machine claims them before any drift rule runs: it exercises the fast-path
+ // gate well and the drift adjudication poorly. DRIFTED carries a close that
+ // is NOT in the enum — that is what actually reaches the drift rules.
+ const { text, calls } = await driveResponsesOnce(DRIFTED);
+ assert.equal((text.match(/m\d{4,}/g) ?? []).length, 0, `a kernel ref reached the responses client: ${JSON.stringify(text)}`);
+ assert.ok(!text.includes(" {
+ const enc = new TextEncoder();
+ const chunk = (delta: Record, finish: string | null = null): string =>
+ `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`;
+ const DONE = "data: [DONE]\n\n";
+ const of = (ev: string[]): ReadableStream => {
+ let i = 0;
+ return new ReadableStream({ pull(c) { if (i < ev.length) c.enqueue(enc.encode(ev[i++])); else c.close(); } });
+ };
+ const writes: string[] = [];
+ const res = makeResLocal(writes);
+ const session = makeSessionLocal();
+ let calls = 0;
+ const refetch = (): Promise> => {
+ calls += 1;
+ return Promise.resolve(of([chunk({ role: "assistant" }), chunk({ content: "the real answer" }), chunk({}, "stop"), DONE]));
+ };
+ await pipePluginChatWithStrip(of([chunk({ role: "assistant" }), chunk({ content: DRIFTED }), chunk({}, "stop"), DONE]), res as unknown as import("node:http").ServerResponse, "openai", session, undefined, refetch);
+ const text = [...writes.join("").matchAll(/"content":"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string).join("");
+ assert.equal(calls, 1, "a fully drift-stripped turn must be retried, not passed through empty");
+ assert.ok(text.includes("the real answer"), `the retry's answer must reach the client, got ${JSON.stringify(text)}`);
+});
From 095b39bf3f03d5b4db3d1cad49f3a82ce10869af Mon Sep 17 00:00:00 2001
From: Louie0806
Date: Sat, 31 Oct 2026 09:48:47 +0800
Subject: [PATCH 20/53] fix(#5175): own the attribute-slot pair whose closing
name disagrees
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first cut taught the stripper the drifted *name* space and the gate to
look for a complete drifted unit anywhere in the chunk. Replaying the shipped
corpus afterwards still showed a compound shape with no owner at all:
m08844
m10234
`DRIFT_PAIR` and `DRIFT_ATTR` both carry a `\1` backreference, so they require
the opening and closing names to agree; `DRIFT_EMPTY_MISMATCH` covers a body
with nothing in it. A body of bare refs behind an emitter attribute slot with a
DISAGREEING close therefore matched nothing and rode the fast path verbatim.
Two changes:
* `DRIFT_ATTR_MISMATCH` / `stripAttrMismatch` own the shape. Both names must be
non-canonical (real HTML tag names and the kernel's own protocol tags are
excluded), and the rule deliberately strips the matched span ONLY — it does
not license orphan-half removal, because the opening name here can be a
genuine `tool_call` and proving it would let the orphan pass delete real
tool-call markup elsewhere in the field (#1039).
* The #2190 broad head probe had two gaps that kept the filter from ever being
reached on the wire: it had no `_` (so `
(HTML_TAG_NAMES.has(open.toLowerCase()) || PROTOCOL_TAG_NAMES.has(open.toLowerCase())) || (HTML_TAG_NAMES.has(close.toLowerCase()) || PROTOCOL_TAG_NAMES.has(close.toLowerCase())) ? whole : "");
}
+/** #5175 second shape: the SAME emitter attributes (`tokens=`) with a body of
+ * bare refs, but the closing name disagrees with the opening one —
+ * `m08844`. `DRIFT_PAIR` and `DRIFT_ATTR` both carry
+ * a `\1` backreference, so they require the two halves to agree; the empty-body
+ * rule below covers only a body with nothing in it. This shape therefore had no
+ * owner at all and reached the client verbatim (measured on the shipped corpus:
+ * six fields, 57 units, e.g. `m10246`
+ * and `m08313`).
+ *
+ * Deliberately STRIPS THE SPAN ONLY and does NOT license orphan-half removal:
+ * an open name here can be a genuine tool-call name (`tool_call`), and proving
+ * it would let the orphan pass delete real tool-call markup elsewhere in the
+ * field (#1039 keeps those intact). Removing the matched span is enough to stop
+ * the leak; the halves left behind are the same documented residual the
+ * pair rules already tolerate. Both names must be non-canonical, so genuine
+ * (if malformed) `
m00257` survives. */
+const DRIFT_ATTR_MISMATCH = new RegExp(
+ "\\x3c(?!" + CANONICAL_TAG_ALT + "\\b)" + DRIFT_NAME_SRC +
+ "\\s[^<>]*\\btokens\\s*=[^<>]*>\\s*(" + REFS_RUN + ")\\s*\\x3c\\/" +
+ "(?!" + CANONICAL_TAG_ALT + "\\b)([A-Za-z][A-Za-z0-9_]{0,31})\\s*>",
+ "gi",
+);
+function stripAttrMismatch(text: string): string {
+ if (!text.includes("\x3c/")) return text;
+ return text.replace(new RegExp(DRIFT_ATTR_MISMATCH.source, "gi"), () => "");
+}
// #5175 streaming: a drifted opening at the buffer tail must not be released as
// prose. The machine's own tail chain is enumeration-scoped (NAME feeds
@@ -741,7 +767,7 @@ function stripPass(text: string, dropToolCallEmission: boolean, requestText: str
// already claimed everything it recognises; what reaches here is exactly the
// drift this pass exists for. `attested || out !== text` mirrors
// DEAD_REF_CLOSE's gate (#1760).
- out = stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(out, attested || out !== text)));
+ out = stripAttrMismatch(stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(out, attested || out !== text))));
return stripBiliArtifacts(out);
}
@@ -763,7 +789,7 @@ const STRIP_PASS_CAP = 8;
* land in different pushes is left alone rather than guessed at — matching
* #1720's documented behaviour, never widening it. */
export function stripDriftResidue(text: string): string {
- return stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(text, false)));
+ return stripAttrMismatch(stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(text, false))));
}
/** Whole-text ACP artifact strip. Returns a FIXED POINT whenever the exposure
* cascade settles within STRIP_PASS_CAP passes (all attested shapes and every
@@ -874,12 +900,32 @@ export function mayStartRenderTag(s: string): boolean {
// mayStartRenderTag: isOrphanMarkupText (#1760) consumes those for
// degenerate-turn accounting, where a prose tail such as "a]{0,120}" +
+ "|" +
+ "(?!" + CANONICAL_TAG_ALT + "\\b)[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]{0,31}" +
+ ")$",
+);
export function mayStartDegenerateRenderTag(s: string): boolean {
- if (BROAD_TAG_HEAD_TAIL.test(s)) return true;
+ if (BROAD_TAG_OPEN_TAIL.test(s)) return true;
// #5175: a COMPLETE drifted unit anywhere in the chunk, not only a head at
// its tail. The head-only probe was the entire gap behind the report: the
- // paragraph ends with a CLOSED ``, so BROAD_TAG_HEAD_TAIL never
+ // paragraph ends with a CLOSED ``, so the head-only probe never
// matched, the fast path released the bytes verbatim, and the stripper was
// never consulted — the imitation reached the client without a single
// filter call. `containsRenderTagText` is the same shape test the
@@ -1538,7 +1584,8 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
DRIFT_PAIR.lastIndex = 0;
DRIFT_ATTR.lastIndex = 0;
DRIFT_EMPTY.lastIndex = 0;
- const dp = DRIFT_PAIR.exec(buf) ?? DRIFT_ATTR.exec(buf) ?? DRIFT_EMPTY.exec(buf);
+ DRIFT_ATTR_MISMATCH.lastIndex = 0;
+ const dp = DRIFT_PAIR.exec(buf) ?? DRIFT_ATTR.exec(buf) ?? DRIFT_EMPTY.exec(buf) ?? DRIFT_ATTR_MISMATCH.exec(buf);
// Orphan unit (#2023): only reached when no opening is live — a
// close inside a swallow belongs to that pair's content (#1720).
// The lookbehind class extends across the chunk boundary: a run at
@@ -1602,7 +1649,7 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu
// gated off every canonical name, so it can only match a drifted opening
// that no other matcher above understands. Without it PARTIAL_TAIL's
// trailing partial-ref arm claims the `m` and releases ` opening is never prose — hold it far
// past HOLD_LIMIT (drop it past TAG_OPEN_CAP); a short
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index dd65fde7ce..e27c2e2790 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -534,3 +534,54 @@ test("#5175 A: a drift-stripped turn is retried instead of reaching the client e
assert.equal(calls, 1, "a fully drift-stripped turn must be retried, not passed through empty");
assert.ok(text.includes("the real answer"), `the retry's answer must reach the client, got ${JSON.stringify(text)}`);
});
+
+// #5175 defect A, the two shapes that survived the FIRST cut.
+//
+// Both are a drifted opening half carrying the emitter's `tokens=` attribute
+// slot whose CLOSING name disagrees with the opening name:
+// m08844
+// m10234
+// `DRIFT_PAIR` and `DRIFT_ATTR` both carry a `\1` backreference, so they require
+// the halves to agree; `DRIFT_EMPTY_MISMATCH` only covers an EMPTY body. The
+// compound shape therefore had no owner and rode the fast path verbatim — found
+// by replaying the shipped corpus (57 units across 6 fields, all safe: neither
+// name is HTML or a kernel protocol tag) and then confirmed on the real wire.
+const ATTR_MISMATCH = "Here is the status report.\n\nm08844\n\nThat is all.";
+const TOOLCALL_MISMATCH = "x\n\nm10234\n\ny";
+
+test("#5175 A: an attr+mismatch unit is stripped whole-text", () => {
+ const got = stripAcpTags(ATTR_MISMATCH);
+ assert.equal(got, "Here is the status report.\n\n\n\nThat is all.",
+ `the attr+mismatch unit must not survive, got ${JSON.stringify(got)}`);
+ const pure = stripDriftResidue(TOOLCALL_MISMATCH);
+ assert.equal(pure, "x\n\n\n\ny",
+ `the underscored drifted unit survived whole-text, got ${JSON.stringify(pure)}`);
+});
+
+test("#5175 A: an attr+mismatch unit never survives any chunking on the chat wire", async () => {
+ for (const size of [0, 40, 13, 7, 1]) {
+ const chunks = size === 0 ? [ATTR_MISMATCH] : chunked(ATTR_MISMATCH, size);
+ const text = await driveChat(chunks);
+ assert.equal((text.match(/m\d{4,}/g) ?? []).length, 0,
+ `a kernel ref reached the client at size=${size}: ${JSON.stringify(text)}`);
+ assert.ok(!/ {
+ for (const size of [0, 40, 13, 7, 1]) {
+ const chunks = size === 0 ? [TOOLCALL_MISMATCH] : chunked(TOOLCALL_MISMATCH, size);
+ const text = await driveChat(chunks);
+ assert.equal((text.match(/m\d{4,}/g) ?? []).length, 0,
+ `a kernel ref reached the client at size=${size}: ${JSON.stringify(text)}`);
+ assert.ok(!/ {
+ const genuine = '
m00257';
+ assert.equal(stripDriftResidue(genuine), genuine, "a genuine html name must never be read as a drifted render name");
+ assert.equal(mayStartDegenerateRenderTag('
Date: Sat, 31 Oct 2026 11:05:31 +0800
Subject: [PATCH 21/53] fix(#5175): own the bare-ref attribute pair whose
closing name disagrees
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The reported paragraph's own unit — `` — still reached
the client verbatim on both wires. Every earlier drift rule missed it:
- `DRIFT_PAIR` / `DRIFT_ATTR` carry a `\1` backreference, so the opening and
closing names must AGREE;
- `DRIFT_EMPTY_MISMATCH` allows them to disagree but only for an EMPTY
attribute slot;
- `DRIFT_ATTR_MISMATCH` allows disagreement and a ref body, but demands
`tokens=` in the attribute slot.
So "bare-ref ATTRIBUTE + disagreeing close" had no owner at all. Isolated on
the shipped predicates, `` came back unchanged from both
`stripAcpTags` and `stripDriftResidue` while the same-name and `tokens=`
variants were stripped — i.e. exactly one cell of the shape matrix leaked.
The new rule keys on the ATTRIBUTE SLOT itself: a real tag's attributes hold
`name=value`, never a kernel ref. Requiring a bare ref there (rather than
merely permitting a name mismatch) is what keeps the rule narrow — a
canonical-HTML mismatched pair and a real `class="x"` attribute slot both
survive, and canonical protocol tags are excluded as everywhere else. Like its
sibling it strips the matched span only and does NOT license orphan-half
removal (#1039 keeps real tool-call call markup intact).
Corpus risk measured on the shipped fields (8,222 of 8,228 lines parsed):
**0** fields, **0** units for this shape, so no observed collision.
Verified on the real pipes, not a copy:
- 13 contention suites **212/212 pass**;
- `tsc --noEmit` clean; `check-unused-exports.mjs` clean (6 waived);
- `npm run build` exit 0 (`dist-import-annotations: OK`, `dist-external: OK`);
- chat wire matrix: 4 leaking shapes x 9 chunk sizes (whole field, 400, 97,
40, 13, 7, 3, 2, 1) all `refLeak=false tagLeak=false`; the by-design orphan
half-units (#644 content preservation) remain untouched;
- two-way negative control: reverting the rule reds exactly the two new
positive tests (28/30) while the canonical-HTML negative control stays
green; restoring gives 30/30.
Adds three regression tests: whole-text strip through both entry points,
chunking invariance on the chat wire, and the canonical/mismatch negatives.
---
src/loop/tag-echo-filter.ts | 35 +++++++++++++++++++--
src/plugin.ts | 7 +++++
tests/issue5175-drift-name-leak.test.ts | 42 +++++++++++++++++++++++++
3 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/src/loop/tag-echo-filter.ts b/src/loop/tag-echo-filter.ts
index 0a2b983d1b..5f0bd44b31 100644
--- a/src/loop/tag-echo-filter.ts
+++ b/src/loop/tag-echo-filter.ts
@@ -354,6 +354,37 @@ function stripAttrMismatch(text: string): string {
return text.replace(new RegExp(DRIFT_ATTR_MISMATCH.source, "gi"), () => "");
}
+/** #5175 third shape: the emitter wrote the REF where a render tag keeps its
+ * ATTRIBUTES, and closed under a different guessed name — ``.
+ * The reported paragraph is built out of exactly this unit (three times).
+ *
+ * Why every earlier rule missed it: `DRIFT_PAIR`/`DRIFT_ATTR` match a bare-ref
+ * or `tokens=` attribute, but both carry a `\1` backreference, so the opening
+ * and closing names must AGREE. `DRIFT_EMPTY_MISMATCH` allows them to disagree
+ * but only for an EMPTY attribute slot. `DRIFT_ATTR_MISMATCH` allows
+ * disagreement and a ref body but demands `tokens=`. So "bare-ref attribute +
+ * disagreeing close" — the reported unit — had no owner and reached the client
+ * verbatim.
+ *
+ * The discriminating evidence is the ATTRIBUTE SLOT ITSELF: a real tag's
+ * attribute slot holds `name=value`, never a kernel ref. Requiring a bare ref
+ * there (rather than merely allowing a mismatch) is what keeps this narrow —
+ * genuine prose with mismatched halves survives, as does every
+ * canonical-protocol tag.
+ *
+ * Same restraint as the sibling rule: strips the matched span only and does not
+ * license orphan-half removal (#1039 keeps real tool-call markup). */
+const DRIFT_BARE_MISMATCH = new RegExp(
+ "\\x3c(?!" + CANONICAL_TAG_ALT + "\\b)" + DRIFT_NAME_SRC +
+ "\\s+(" + REFS_RUN + ")\\s*>\\s*\\x3c\\/" +
+ "(?!" + CANONICAL_TAG_ALT + "\\b)([A-Za-z][A-Za-z0-9_]{0,31})\\s*>",
+ "gi",
+);
+function stripBareMismatch(text: string): string {
+ if (!text.includes("\x3c/")) return text;
+ return text.replace(new RegExp(DRIFT_BARE_MISMATCH.source, "gi"), () => "");
+}
+
// #5175 streaming: a drifted opening at the buffer tail must not be released as
// prose. The machine's own tail chain is enumeration-scoped (NAME feeds
// PARTIAL_TAIL), so a drifted head either rides out verbatim or — worse — gets
@@ -767,7 +798,7 @@ function stripPass(text: string, dropToolCallEmission: boolean, requestText: str
// already claimed everything it recognises; what reaches here is exactly the
// drift this pass exists for. `attested || out !== text` mirrors
// DEAD_REF_CLOSE's gate (#1760).
- out = stripAttrMismatch(stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(out, attested || out !== text))));
+ out = stripBareMismatch(stripAttrMismatch(stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(out, attested || out !== text)))));
return stripBiliArtifacts(out);
}
@@ -789,7 +820,7 @@ const STRIP_PASS_CAP = 8;
* land in different pushes is left alone rather than guessed at — matching
* #1720's documented behaviour, never widening it. */
export function stripDriftResidue(text: string): string {
- return stripAttrMismatch(stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(text, false))));
+ return stripBareMismatch(stripAttrMismatch(stripEmptyMismatch(stripEmptyPairs(stripDriftPairs(text, false)))));
}
/** Whole-text ACP artifact strip. Returns a FIXED POINT whenever the exposure
* cascade settles within STRIP_PASS_CAP passes (all attested shapes and every
diff --git a/src/plugin.ts b/src/plugin.ts
index 96cff7b1dd..0b8419acc2 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -4508,6 +4508,13 @@ export async function pipePluginResponsesWithStrip(
// which is how defect A survived on the responses wire.
// The signed-reasoning channel never reaches this branch
// (it is handled above, byte-verbatim, #1960).
+ // Evidence must also be taken from the RAW delta: once a
+ // drift rule runs inside the filter tail chain too, the unit
+ // is consumed BEFORE release, so `releasedDelta` is already
+ // empty and a released-only comparison sees no change. That
+ // is the #732/#821 silent empty-message failure — the turn
+ // reaches the client with nothing in it and no retry armed.
+ if (stripDriftResidue(delta) !== delta) sawStrippedEcho = true;
const clean = stripDriftResidue(releasedDelta);
if (clean !== releasedDelta) sawStrippedEcho = true;
lastDeltaMeta = { item_id: ev["item_id"], output_index: ev["output_index"] };
diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts
index e27c2e2790..e77ebb827c 100644
--- a/tests/issue5175-drift-name-leak.test.ts
+++ b/tests/issue5175-drift-name-leak.test.ts
@@ -585,3 +585,45 @@ test("#5178 negative: a genuine html opening tag stays prose and is never stripp
assert.equal(mayStartDegenerateRenderTag('
`.
+// The reported paragraph is made of exactly this unit. Every earlier drift rule
+// missed it: DRIFT_PAIR/DRIFT_ATTR carry a `\1` backreference (halves must
+// agree), DRIFT_EMPTY_MISMATCH allows disagreement but only for an EMPTY
+// attribute slot, and DRIFT_ATTR_MISMATCH allows disagreement plus a ref body
+// but demands `tokens=`. So "bare-ref attribute + disagreeing close" had no
+// owner and reached the client verbatim.
+const BARE_MISMATCH = "Here is the status report.\n\n\n\nThat is all.";
+
+test("#5175 A: a bare-ref attribute with a disagreeing close is stripped whole-text", () => {
+ const got = stripAcpTags(BARE_MISMATCH);
+ assert.equal(got, "Here is the status report.\n\n\n\nThat is all.",
+ `the bare-ref+mismatch unit must not survive, got ${JSON.stringify(got)}`);
+ // The same unit reached through the other entry point (responses wire retry).
+ assert.equal(stripDriftResidue(BARE_MISMATCH), "Here is the status report.\n\n\n\nThat is all.",
+ "stripDriftResidue must own the bare-ref+mismatch unit too");
+});
+
+test("#5175 A: a bare-ref attribute with a disagreeing close never survives any chunking", async () => {
+ for (const size of [0, 40, 13, 7, 1]) {
+ const chunks = size === 0 ? [BARE_MISMATCH] : chunked(BARE_MISMATCH, size);
+ const text = await driveChat(chunks);
+ assert.equal((text.match(/m\d{4,}/g) ?? []).length, 0,
+ `a kernel ref reached the client at size=${size}: ${JSON.stringify(text)}`);
+ assert.ok(!/ {
+ // Both names are canonical HTML, so this is malformed author markup, not an
+ // emitter guessing its own name — it must survive untouched.
+ const genuine = '