diff --git a/docs/configuration.md b/docs/configuration.md index 3e89d041a4..4f9e5b2c19 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -297,7 +297,7 @@ This index is generated from `website/config-reference/*.yaml` — edit the seed | `compress.maxSummaryLength` | number | kernel 20000 | — | Hard character cap on ONE generated summary; longer replies are rejected whole (no truncation, no retry). External summarizers are told this exact cap up front (#3635). 0 = unbounded. | | `compress.minSummaryLength` | number | kernel 50 | — | Character floor for generated summaries: a single range must meet it alone; a multi-range batch passes when its combined summaries reach it (#5894). 0 = disabled. External summarizers remain held to the per-summary floor (#3635). | | `compress.reconcile` | "off" \| "warn" \| "repair" | "repair" | BILI_FOLD_RECONCILE | Reconcile folded state when the client rewinds or rewrites history between turns. | -| `compress.promptPack` | string (builtin: "default", "lean") | builtin "default" | — | Compression prompt pack, resolved project pack → user pack → builtin; not gated by acknowledgePromptsRisk. | +| `compress.promptPack` | string (builtin: "default", "lean", "chat") | builtin "default" | — | Compression prompt pack, resolved project pack → user pack → shipped pack → builtin; not gated by acknowledgePromptsRisk. | | `compress.tiers` | boolean | true | — | Tiered T1→T3 distillation spreads folding cost across generations. | | `compress.protectedTools` | string[] | none | — | Hard exclusion across all history: results of listed tools never fold. Path patterns (skill/) select skills by name (#1947). | | `compress.protectedLatestTools` | string[] | none | — | Protects only the LATEST instance of cumulative-snapshot tools whose newest result supersedes older ones (e.g. todo lists). Path patterns keep the latest instance per skill (skill/*, #1947). | diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index c5f18d030e..90ef682c00 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -297,7 +297,7 @@ | `compress.maxSummaryLength` | number | kernel 20000 | — | 单条生成摘要的硬字符上限;超长的回复被整段拒绝(不截断、不重试)。外部摘要模型会被提前告知这个确切上限(#3635)。0 = 不限制。 | | `compress.minSummaryLength` | number | kernel 50 | — | 生成摘要的字符下限:单段压缩按自身判定;批量压缩按合计判定——一段偷懒不再拖垮整批(#5894)。0 = 禁用。外部摘要模型仍受逐段下限约束(#3635)。 | | `compress.reconcile` | "off" \| "warn" \| "repair" | "repair" | BILI_FOLD_RECONCILE | 客户端在轮次之间回退或改写历史时校准已折叠状态。 | -| `compress.promptPack` | string (builtin: "default", "lean") | builtin "default" | — | 压缩提示词包,按 项目包 → 用户包 → 内置 解析;不受 acknowledgePromptsRisk 门控。 | +| `compress.promptPack` | string (builtin: "default", "lean", "chat") | builtin "default" | — | 压缩提示词包,按 项目包 → 用户包 → 随包附带(chat)→ 内置 解析;不受 acknowledgePromptsRisk 门控。 | | `compress.tiers` | boolean | true | — | T1→T3 分级蒸馏把折叠成本摊到多代。 | | `compress.protectedTools` | string[] | none | — | 全历史硬排除:列出工具的结果永不折叠。路径模式(skill/)可按名指定 skill(#1947)。 | | `compress.protectedLatestTools` | string[] | none | — | 只保护累积快照类工具「最新一次」实例(新结果覆盖旧结果的工具,如 todo 列表)。路径模式按 skill 分组各保最新(skill/*,#1947)。 | diff --git a/src/degenerate-turn.ts b/src/degenerate-turn.ts index 2092f58f32..cada288c40 100644 --- a/src/degenerate-turn.ts +++ b/src/degenerate-turn.ts @@ -68,9 +68,44 @@ 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; +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 (#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 { + 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 // own summarization request. Claude Code's compaction (/compact, auto-compact, diff --git a/src/loop/core.ts b/src/loop/core.ts index 31b903b6f8..0fa2ad3a29 100644 --- a/src/loop/core.ts +++ b/src/loop/core.ts @@ -47,7 +47,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, scrubLoneSurrogateEscapesOnJson } from "../text-safe.js"; export const MAX_LOOP_ROUNDS = 10; @@ -1248,9 +1248,10 @@ export async function* runCompressLoop( } } - // #2303: a terminal turn that DID deliver visible prose but ends with a - // compression-draft closing tag (summary/analysis close forms) and no - // tool call — the model wrote a handoff/compression draft in prose + // #2303: a terminal turn that DID deliver visible prose but carries a + // compression-draft dump (tail close form, or the analysis-to-summary + // envelope mid-text, #5175) and no tool call — the model wrote a + // handoff/compression draft in prose // instead of issuing the action it described, most often after the // upstream cut the final tool call out of the step (149 silent stops / // 70 sessions, DSH native). Same non-convergence as the zero-visible @@ -1273,7 +1274,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 @@ -1283,7 +1284,7 @@ export async function* runCompressLoop( !signal?.aborted ) { degenerateRetried = true; - ctx.log(`[acp-loop] round ${round}: terminal turn ends with a compression-draft closing tag and no tool call; retrying once with continuation nudge (#2303)`); + ctx.log(`[acp-loop] round ${round}: terminal turn carries a compression-draft dump (tail or mid-text envelope) and no tool call; retrying once with continuation nudge (#2303)`); loggerLog("warn", `[acp-loop] draft-tail auto-retry round ${round} (session ${ctx.session.id})`); const nudge: CoreMessage = { id: `acp_draft_tail_retry_r${round}`, diff --git a/src/loop/tag-echo-filter.ts b/src/loop/tag-echo-filter.ts index fb4f1d33f0..1522f82534 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"); +// #5179 live report (v0.1.213, 2026-11-09): the emission degraded on the NAME +// instead of mutating it — the identifier replaced wholesale with an +// underscore-joined word of the emitter's own namespace (`acp_tokens`), body = +// the bare per-turn token count. No ref, no `tokens=` attribute, non-empty +// body: every payload shape above misses it, and the name enum cannot grow to +// meet an open drift set (#2190 round 2). The one provable shape: an +// `acp`-prefixed name (no English word starts with "acp") wrapping a +// digits-only body, closed by its OWN name (\1). Structurally impossible as +// prose; a tool call's argument slot is key="value" pairs, never this (#1039). +// Width is deliberate (#5179 review, 2026-11-09): ANY acp-prefixed name, not +// just underscore-joined ones. The prefix alone excludes English prose and +// real tool-call XML (independent A/B sweep: function_calls/invoke/parameter/ +// td/title byte-exact survivors); requiring an underscore would overfit the +// single attested form (acp_tokens) and leave siblings from the same emitter +// degradation uncovered. Pinned both directions in the #5179 suite. +const DRIFT_COUNT = new RegExp("\\x3c(acp[A-Za-z0-9_]{0,28})>\\s*(\\d{1,7})\\s*\\x3c\\/\\1>", "gi"); + +// #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("|") + ")"; +// #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("|") + ")"; +/** #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 former + * empty-body rule covered only a body with nothing in it (#5179 removed it — + * a same-name empty pair is valid XML and appears in real content). 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 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. The removed empty-body rule allowed 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 +// 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 /])" + "(?!" + 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 + "(?:" + + "\\s[^<>]*" + + "|" + + ">" + DRIFT_CLOSE_FRAGMENT + + "|" + + "\\s[^<>]*>[ \\t]*(?:m\\d*)?[ \\t]*" + DRIFT_CLOSE_FRAGMENT + + "|" + + "\\s*>[ \\t]*" + DRIFT_CLOSE_FRAGMENT + + "|" + + // #5179: the count unit's body — digits where another shape's ref or + // whitespace would sit. Without this arm the well-formed unknown open + // resolves to prose at the first body char and the digits leak. The close + // fragment here tolerates underscores/digits (DRIFT_HEAD's own name class) + // because the convicted close carries them. + ">[ \\t]*\\d{1,7}[ \\t]*(?:\\x3c\\/?[A-Za-z0-9_]{0,32}>?)?" + + ")$", +); + + +/** #5175 whole-text drift strip. Removes matched pairs, then the orphan halves + * of the names they proved — a lone close half is indistinguishable from prose + * on its own, so the pair found in the same text is what licenses its removal + * (#1760 keeps a bare close-only shape when nothing attests it). */ +function stripDriftPairs(text: string): 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, DRIFT_COUNT]) { + 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, () => ""); + out = out.replace(DRIFT_COUNT, () => ""); + 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 (`m00501 m00505

` -> " ", #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, `m00501 m00505

` -> " "). + 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/"; @@ -279,6 +563,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 @@ -513,6 +798,12 @@ 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. Unconditional — unlike DEAD_REF_CLOSE's + // attestation gate above, the drift pass proves its names from genuine + // pairs in the same text (#1760). + out = stripBareMismatch(stripAttrMismatch(stripDriftPairs(out))); return stripBiliArtifacts(out); } @@ -522,6 +813,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 stripBareMismatch(stripAttrMismatch(stripDriftPairs(text))); +} /** 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, @@ -577,8 +882,32 @@ 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; #5179 added a third arm — an `acp`-namespace name whose body is a bare +// token count (DRIFT_COUNT). Genuine prose cannot produce any of them, so the +// shapes admit a future drift (`acq`, `acpx`...) without touching this file. +// Not anchored to any name. The third arm is a superset on purpose: it +// tolerates open/close disagreement, which DRIFT_COUNT's \1 backreference +// then rejects in the strip itself. +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*>" + + "|" + + "\x3cacp[A-Za-z0-9_]{0,28}>\\s*\\d{1,7}\\s*\x3c\\/acp[A-Za-z0-9_]{0,28}\\s*\x3e", + "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 @@ -613,9 +942,39 @@ 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 { - return BROAD_TAG_HEAD_TAIL.test(s); + 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 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 + // 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 @@ -1098,6 +1457,14 @@ export function createTagEchoFilter(onDrop?: (snippet: string) => void, onResidu } return out + s.slice(last); }; + /** #5175: release path — orphan-close cleanup only. A span the machine + * settles as prose still carries the imitation's orphan halves when its + * pair resolved in an earlier push, so `stripDeadRefCloses` runs here too. + * This is NOT the whole-text artifact set: `stripAcpTags` and + * `stripDriftResidue` are separate entry points, and the drift pairs + * themselves are adjudicated earlier in `process` — by the time a span is + * released they are already gone. */ + 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 +1528,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 +1561,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 { @@ -1255,6 +1622,42 @@ 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. + // Three rules here, not the whole-text pass's four: DRIFT_BARE_MISMATCH + // is deliberately absent from this candidate list, and the asymmetry is + // measured rather than an oversight -- do not "complete the set". + // + // Adding it is worse, not better, and the reason is counter-intuitive. + // The fourth rule does delete the unit's span, so no kernel pointer + // survives either way. But the filter then releases the unit's REF-LESS + // HALVES as prose, and that residue counts as visible text -- so the + // degenerate/empty-turn retry no longer fires, and the client is handed + // the leftover markup instead of a regenerated answer. Measured on the + // reported shape, responses lane, 4-rule list vs this one: + // this list : calls=1 client="the real answer" + // 4 rules : calls=0 client="acpt> " + // Omitting the rule here is what keeps the turn visibly EMPTY, which is + // exactly the condition the retry exists for: the caller replaces that + // empty turn with a real one. The invariant that matters -- no kernel + // ref reaches the client -- holds on both paths. + // + // The fourth rule stays in the whole-text pass because there the field + // is at rest and no swallow can claim the shape first, so it is the only + // owner. Here a live unit already dies whole above, and a unit the + // swallow did not take is released with its ref already gone. Same + // invariant, different job: the seam is defined on the released SPAN, + // not on matching regex sets. + DRIFT_PAIR.lastIndex = 0; + DRIFT_ATTR.lastIndex = 0; + DRIFT_ATTR_MISMATCH.lastIndex = 0; + // #5179: DRIFT_COUNT rides the pair rules' path — the unit carries + // no ref, so dropping it whole releases no half-formed residue, the + // measured failure mode behind the excluded fourth rule above. + DRIFT_COUNT.lastIndex = 0; + const dp = DRIFT_PAIR.exec(buf) ?? DRIFT_ATTR.exec(buf) ?? DRIFT_ATTR_MISMATCH.exec(buf) ?? DRIFT_COUNT.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 @@ -1279,7 +1682,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 @@ -1305,7 +1708,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; @@ -1314,7 +1717,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 @@ -1350,7 +1757,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 +1768,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 +1791,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 @@ -1398,6 +1805,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) { @@ -1510,7 +1923,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 = ""; @@ -1670,12 +2083,16 @@ function stripIfString(v: unknown, drop: boolean, requestText?: string): unknown // Plugin-passthrough parity for the OpenAI chat-completions wire (issue #14: // pi + qwen echoed render tags through the verbatim plugin stream): strip the -// text fields a chat chunk / completion carries — -// `choices[].delta.{content,reasoning_content,reasoning}` on streams, -// `choices[].message.*` on non-streaming bodies. Tool-call arguments are -// deliberately left untouched (#1039): they carry user intent that hosts -// execute/persist, so a shape-based false positive would silently corrupt -// data — only model prose is stripped. Mutates in place, mirroring +// visible text a chat chunk / completion carries — +// `choices[].{delta,message}.content` on streams and non-streaming bodies +// alike. Two channels stay byte-for-byte on purpose: +// - tool-call arguments (#1039): they carry user intent that hosts +// execute/persist, so a shape-based false positive would silently +// corrupt data; +// - `reasoning_content` / `reasoning` (#2229): the thinking channel is the +// model's private channel, and replay or signature checks validate it, so +// rewriting it desyncs the session. +// Only model prose is stripped. Mutates in place, mirroring // stripResponsesText. export function stripOpenaiChatText(obj: T, drop: boolean = false, requestText?: string): T { if (!obj || typeof obj !== "object") return obj; diff --git a/src/plugin.ts b/src/plugin.ts index ab0b10c90e..5b5b4c7fab 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"; @@ -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"; @@ -2772,6 +2772,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); @@ -2835,6 +2841,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. @@ -2897,16 +2917,18 @@ export async function pipePluginChatWithStrip( * re-send on a degenerate completion — one re-issue per request, total. */ const retryEmptyTurn = async (reason: string | undefined): Promise => { if (refetch === undefined || truncationRetried) return false; - // #2303: a terminal turn whose visible prose ends with a compression-draft - // closing tag and no tool call is non-converged even though it has visible - // text — the model wrote a handoff/compression draft in prose instead of + // #2303: a terminal turn whose visible prose carries a + // compression-draft dump (tail close form, or the analysis-to-summary + // envelope mid-text, #5175) and no tool call is non-converged even + // though it has visible text — the model wrote a handoff/compression + // draft in prose instead of // issuing the action it described (149 silent stops / 70 sessions, DSH // native). Treat it like the empty-turn shape below so at worst the client // gets one extra continuation instead of losing the whole turn. // #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 @@ -2948,7 +2970,7 @@ export async function pipePluginChatWithStrip( } degenerateRetried = true; log?.(draftTail - ? "[plugin] terminal turn ends with a compression-draft closing tag and no tool call; retrying once with a continuation nudge (#2303)" + ? "[plugin] terminal turn carries a compression-draft dump (tail or mid-text envelope) and no tool call; retrying once with a continuation nudge (#2303)" : "[plugin] degenerate terminal turn (no usable output); retrying once with a continuation nudge (#732/#821)"); let next: ReadableStream | null = null; try { @@ -2969,6 +2991,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. @@ -3256,7 +3281,30 @@ 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. + // + // 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); + // #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]; }; @@ -4568,11 +4616,12 @@ export async function pipePluginResponsesWithStrip( const retryEmptyTurn = async (status: string | undefined): Promise => { // truncationRetried: one re-issue per request, total — see the chat-pipe twin. if (degenerateRetried || truncationRetried || refetch === undefined) return false; - // #2303: same shape as the chat-pipe twin — visible prose ending in a - // compression-draft closing tag with no function call is non-converged. + // #2303: same shape as the chat-pipe twin — visible prose carrying a + // compression-draft dump (tail or mid-text envelope, #5175) 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 @@ -4597,7 +4646,7 @@ export async function pipePluginResponsesWithStrip( } degenerateRetried = true; log?.(draftTail - ? "[plugin] terminal turn ends with a compression-draft closing tag and no function call; retrying once with a continuation nudge (#2303)" + ? "[plugin] terminal turn carries a compression-draft dump (tail or mid-text envelope) and no function call; retrying once with a continuation nudge (#2303)" : "[plugin] degenerate terminal turn (no visible output); retrying once with a continuation nudge (#732/#821)"); let next: ReadableStream | null = null; try { @@ -4614,6 +4663,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. @@ -4877,7 +4932,26 @@ 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). + // 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"] }; if (!inRetry && heldItemId === undefined && ev["item_id"] !== undefined) { heldItemId = ev["item_id"]; diff --git a/src/server/relay.ts b/src/server/relay.ts index 99c6cb5667..c3842ecd59 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -2480,7 +2480,13 @@ async function forwardCaptured( reply, ); } else { - await pipeThrough(toClient, res); + // #5175: the non-SSE twin of the strip pipe above. A native + // compaction that answers as plain JSON hands its whole text + // field back verbatim, and a compaction turn's context is by + // definition tag-laden — the model's echoed drift residue is + // the likeliest leak here. Same pipe as the JSON egress in the + // proxy branch below; no session, so usage accounting stays off. + await pipePluginJson(toClient, res, undefined, prepared.protocol, undefined, undefined, undefined, reply); } const terminal = await observed; if (terminal === "completed") { diff --git a/src/storage/mysql.ts b/src/storage/mysql.ts index b3005bb5f4..ba63d8a38a 100644 --- a/src/storage/mysql.ts +++ b/src/storage/mysql.ts @@ -74,7 +74,7 @@ export type Mysql2Module = typeof import("mysql2/promise"); * DDL block below exactly once on first open (information_schema probe + * ALTER ADD INDEX); fresh installs pick the keys up from the CREATE TABLE * DDL. */ -const MYSQL_SCHEMA_VERSION = 11; +export const MYSQL_SCHEMA_VERSION = 11; /** Parity constants with the sqlite v8 streaming backfill * (src/storage/schema.ts, #4677): same page size, same printable absent 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`); }); diff --git a/tests/issue4666-migration-dedup.test.ts b/tests/issue4666-migration-dedup.test.ts index 4d0f0e51aa..ff18f708db 100644 --- a/tests/issue4666-migration-dedup.test.ts +++ b/tests/issue4666-migration-dedup.test.ts @@ -44,10 +44,11 @@ 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"; +import { MYSQL_SCHEMA_VERSION } from "../src/storage/mysql.ts"; process.env.BILI_PERSIST_ZSTD = "0"; // Same hermeticity pin as the other storage suites: a developer machine with @@ -155,7 +156,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, 11, "stamped to v11 (v10 ts column + v11 FK child indexes, #5539)"); + assert.equal(ver.user_version, STORAGE_SCHEMA_VERSION, `stamped to the current schema version (v${STORAGE_SCHEMA_VERSION})`); // Exactly the lowest-seq arrival of each (msg_id, identity) group. const survivors = check @@ -371,7 +372,7 @@ test("#4666 mysql: v8 upgrade collapses duplicates, crash-safe stamp gate, retir // Fresh install lands at v11. be = await openBackend("mysql", H.dsn); await be.init?.(); - assert.equal(await H.stamp(), "11", "fresh install stamped v11"); + assert.equal(await H.stamp(), String(MYSQL_SCHEMA_VERSION), "fresh install stamped the current version"); // Downgrade to the legacy v7 shape and plant the dirty fixture. await H.rewindToV7(); @@ -381,7 +382,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(), "11", "upgrade stamped v11"); + assert.equal(await H.stamp(), String(MYSQL_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 +431,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(), "11", "re-run completed and stamped v11"); + assert.equal(await H.stamp(), String(MYSQL_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 +538,13 @@ 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(), "11", "migration completed after the lock freed"); + // MySQL carries its OWN schema version (MYSQL_SCHEMA_VERSION), which is + // independent of the sqlite unified store's STORAGE_SCHEMA_VERSION — two + // separate constants that can drift apart over time, so each assertion + // pins its own side instead of assuming they move together. Pin the + // mysql one here; the sqlite assertions above derive from + // STORAGE_SCHEMA_VERSION. + assert.equal(await H.stamp(), String(MYSQL_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"); diff --git a/tests/issue4739-bounded-session-load.test.ts b/tests/issue4739-bounded-session-load.test.ts index bf91303252..bef747d04d 100644 --- a/tests/issue4739-bounded-session-load.test.ts +++ b/tests/issue4739-bounded-session-load.test.ts @@ -139,6 +139,10 @@ 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 { + // #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; @@ -178,6 +182,10 @@ 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 { + // #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; diff --git a/tests/issue4788-resident-minimal.test.ts b/tests/issue4788-resident-minimal.test.ts index 5c21377d6a..6cbcb24058 100644 --- a/tests/issue4788-resident-minimal.test.ts +++ b/tests/issue4788-resident-minimal.test.ts @@ -133,11 +133,11 @@ 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 { - // #4739/#5140 family: the suite arms a real SessionStorage under - // BILI_STORAGE_UNIFIED=full, so the sidecar handle is still open here. - // cancelAll() clears timers and the build map but never closes the - // shadow; Windows then refuses to unlink the directory (EBUSY/EPERM). - // Close the short-lived store first: cancel, then close. + // #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); } @@ -173,6 +173,11 @@ 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 { + // #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); } @@ -212,6 +217,11 @@ test("(c) hard cap under sustained activity: boundaries drop, in-flight skips, w process.env.BILI_SESSION_RESIDENT_HARD_MAX_BYTES = saved; } } finally { + // #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); } @@ -255,6 +265,11 @@ 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"; + // #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); } diff --git a/tests/issue5175-drift-name-leak.test.ts b/tests/issue5175-drift-name-leak.test.ts new file mode 100644 index 0000000000..808faad8bf --- /dev/null +++ b/tests/issue5175-drift-name-leak.test.ts @@ -0,0 +1,1411 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + containsRenderTagText, + createIdentityStreamFilter, + createTagEchoFilter, + mayStartDegenerateRenderTag, + stripAcpTags, + stripDriftResidue, +} from "../src/loop/tag-echo-filter.ts"; +import { endsWithDraftClose, isDraftDump } from "../src/degenerate-turn.ts"; +import { createCore, defaultConfig, type Config, type CoreMessage } from "acp-kernel"; +import { createSession } from "../src/session.ts"; +import { rewriteJsonResponseAsync } from "../src/stream.ts"; +import { rewriteOpenaiJsonResponseAsync } from "../src/stream-openai.ts"; +import { rewriteResponsesJsonResponseAsync } from "../src/stream-responses.ts"; +import { rewriteGoogleJsonResponseAsync } from "../src/stream-google.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 +// (`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 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; +} + +// #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)}`); + 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`; + 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 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 rides the PRODUCTION identity filter — the same + // object `filterFor` hands the signed-thinking fields — so an echo there is + // expected to survive byte-for-byte: that is the invariant, not a leak. + // Asserting against `createIdentityStreamFilter()` keeps this a real + // tripwire: if a verbatim field is ever routed through a stripping filter, + // this fails instead of documenting a stub. + const identity = createIdentityStreamFilter(); + assert.equal(identity.push(echo), echo, "the verbatim channel is byte-for-byte identity"); + assert.equal(identity.flush(), "", "the identity filter buffers nothing"); + assert.equal(identity.pending(), false, "the identity filter never holds bytes back"); +}); + +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" + + "... 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(); + } + }, + }); +} + + +// #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'], + ["genuine html", '
hello world
'], + ["genuine html with ref", "m00375"], + ["documented residual", "m01233"], + // #5179: an empty SAME-name pair is valid XML and appears in real content + // (a driver-repository response body, a paper quoting ). + // It is not emitter evidence, so both lanes must leave it byte-identical. + ["genuine xml empty pair", "Repository header:\n\x3cfields\x3e\n\t\n\x3c/fields\x3e\n\x3cpage\x3e1\x3c/page\x3e"], + ["genuine xml answer pair", "Reasoning goes in \x3cthink\x3e\x3c/think\x3e and the answer in \x3canswer\x3e\x3c/answer\x3e tags."], + // #5179 live report: the count unit must die whole at every wire cut — a + // count-only turn that survived streaming would be visible text and would + // defeat the degenerate-turn retry that replaces it with a real answer. + ["live report count unit", "\x3cacp_tokens\x3e910\x3c/acp_tokens\x3e"], + // Sibling without underscore: the rule is deliberately acp-prefix-wide + // (see DRIFT_COUNT), so this must die whole at every wire cut too. + ["count unit, underscore-free sibling", "\x3cacp_tool\x3e5\x3c/acp_tool\x3e"], +]; + +// 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. +// +// There is no span-local shortcut for (2), and that was MEASURED, not assumed: +// the residual at every one of the 50 byte cuts is one of exactly three shapes — +// `acct> ` (34x), ` ` (8x), `` +// (8x). Deleting a lone close there does not balance the line, it UNBALANCES the +// one thing still balanced: the headless `acct>` open was already released as +// prose in an earlier push, so removing its close produces a dangling open — +// the very shape #2190 round 3 forbids — while removing both would require +// retracting bytes the client already has. A rule keyed on "two orphan closes of +// different drift names" matches none of the three shapes either. The +// whole-field pass is the only unit that can see both halves, which is why +// stripAcpTags cleans it and this seam cannot. +// +// 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", + "genuine xml empty pair", + "genuine xml answer pair", +]); + +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)) { + // Genuine content is not merely consistent across lanes: the + // SOURCE text must survive intact. Comparing got to truth alone + // is too weak — if both lanes over-strip the same way the test + // still passes (caught by the #5179 negative control). + assert.equal(got, text, `chunk size ${size}: genuine content was rewritten: ${JSON.stringify(got)} !== ${JSON.stringify(text)}`); + assert.equal(truth, text, `whole-text pass rewrote genuine content: ${JSON.stringify(truth)} !== ${JSON.stringify(text)}`); + 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)])}`); + }); +} + +// ---------- #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 +// 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 (#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 => + `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"); +}); + +// ---------- #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"); +}); + +// ---------- #5175 defect B: the retry limit is the same on every lane ---------- + +// The characterization above pins the retraction limit on the openai chat wire. +// Defect B is not an openai shape — the model writes the envelope as prose, so +// whichever fast path carries it sees the same bytes. This replays the identical +// turn through the other two chat lanes and through the responses wire, and +// asserts the SAME three facts each time: +// +// 1. the unfinished draft IS detected (one continuation is spent), +// 2. the draft body still reaches the client (bytes are unretractable), and +// 3. the retry's answer is appended after it. +// +// Measured on this branch: identical on all four wires. Measured on pristine +// master: the draft is forwarded with NO retry at all (calls=0), because the +// pre-fix predicate only matched a closing tag at the very END of the text. +const DRAFT_ENVELOPE = "\n…\n\n\n\n1. Primary Request: X.\n"; + +function driveChatLaneOnce(protocol: "openai" | "anthropic" | "google", pieces: string[]): Promise<{ text: string; calls: number }> { + const enc = new TextEncoder(); + 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 frames: string[] = []; + if (protocol === "anthropic") { + frames.push(sseLocal({ type: "message_start", message: { id: "m1", role: "assistant", content: [] } })); + frames.push(sseLocal({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })); + for (const p of pieces) frames.push(sseLocal({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: p } })); + frames.push(sseLocal({ type: "content_block_stop", index: 0 })); + frames.push(sseLocal({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } })); + frames.push(sseLocal({ type: "message_stop" })); + } else if (protocol === "google") { + // Production google frames carry the text WITHOUT a finishReason and a + // separate terminal frame carries it — folding both into one frame changes + // what the fast-path gate can see. + for (const p of pieces) frames.push(sseLocal({ candidates: [{ index: 0, content: { role: "model", parts: [{ text: p }] } }] })); + frames.push(sseLocal({ candidates: [{ index: 0, content: { role: "model", parts: [] }, finishReason: "STOP" }] })); + } + const session = { + ...makeSessionLocal(), + id: "testsess5175_b_" + protocol + "_" + Math.random().toString(36).slice(2), + protocol, + } as unknown as Session; + + const chat = (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 retryFrames = protocol === "anthropic" + ? [ + sseLocal({ type: "message_start", message: { id: "m2", role: "assistant", content: [] } }), + sseLocal({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), + sseLocal({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "the real answer" } }), + sseLocal({ type: "content_block_stop", index: 0 }), + sseLocal({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }), + sseLocal({ type: "message_stop" }), + ] + : protocol === "google" + ? [ + sseLocal({ candidates: [{ index: 0, content: { role: "model", parts: [{ text: "the real answer" }] } }] }), + sseLocal({ candidates: [{ index: 0, content: { role: "model", parts: [] }, finishReason: "STOP" }] }), + ] + : [chat({ role: "assistant" }), chat({ content: "the real answer" }), chat({}, "stop"), DONE]; + + let calls = 0; + const refetch = (): Promise> => { + calls += 1; + return Promise.resolve(of(retryFrames)); + }; + const events = protocol === "openai" ? [chat({ role: "assistant" }), ...pieces.map((p) => chat({ content: p })), chat({}, "stop"), DONE] : frames; + return pipePluginChatWithStrip(of(events), res as unknown as import("node:http").ServerResponse, protocol, session, undefined, refetch).then(() => { + let acc = ""; + for (const line of writes.join("").split("\n")) { + const t = line.trim(); + if (!t.startsWith("data:")) continue; + const raw = t.slice(5).trim(); + if (raw === "[DONE]") continue; + try { + const obj = JSON.parse(raw) as Record; + const choices = obj["choices"]; + if (Array.isArray(choices)) { + for (const ch of choices as Array>) { + const d = ch["delta"] as Record | undefined; + if (d && typeof d["content"] === "string") acc += d["content"]; + } + } + const delta = obj["delta"] as Record | undefined; + if (delta && typeof delta["text"] === "string") acc += delta["text"]; + const candidates = obj["candidates"]; + if (Array.isArray(candidates)) { + for (const cand of candidates as Array>) { + const c = cand["content"] as Record | undefined; + const parts = c?.["parts"]; + if (Array.isArray(parts)) { + for (const part of parts as Array>) { + if (typeof part["text"] === "string") acc += part["text"]; + } + } + } + } + } catch { + /* non-JSON keep-alive comment */ + } + } + return { text: acc, calls }; + }); +} + +for (const protocol of ["openai", "anthropic", "google"] as const) { + test(`#5175 B (${protocol}): the unfinished draft is retried, and its already-streamed body is not retracted`, async () => { + const whole = await driveChatLaneOnce(protocol, [DRAFT_ENVELOPE]); + assert.equal(whole.calls, 1, `the ${protocol} lane must detect the envelope and spend one continuation`); + assert.ok(whole.text.includes("Primary Request: X"), `the ${protocol} lane cannot retract bytes it already sent (measured, not assumed)`); + assert.ok(whole.text.includes("the real answer"), `the ${protocol} lane appends the retry's answer`); + // The cut at the envelope boundary is the shape production actually hits. + const at = DRAFT_ENVELOPE_CUT; + const split = await driveChatLaneOnce(protocol, [DRAFT_ENVELOPE.slice(0, at), DRAFT_ENVELOPE.slice(at)]); + assert.equal(split.calls, 1, `the ${protocol} lane detects the envelope even when it is split across deltas`); + assert.ok(split.text.includes("the real answer"), `the ${protocol} lane appends the retry's answer after a split`); + }); +} + +/** The envelope boundary — the split point production actually hits. */ +const DRAFT_ENVELOPE_CUT = DRAFT_ENVELOPE.indexOf("") + 4; + +test("#5175 B (responses): the unfinished draft is retried, and its already-streamed body is not retracted", async () => { + const enc = new TextEncoder(); + const sseEv = (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_b_resp_" + Math.random().toString(36).slice(2), + stats: { pendingFoldUsage: false, compressCreditTokens: 0 }, + } as unknown as Session; + const at = DRAFT_ENVELOPE_CUT; + const pieces = [DRAFT_ENVELOPE.slice(0, at), DRAFT_ENVELOPE.slice(at)]; + let calls = 0; + const refetch = (): Promise> => { + calls += 1; + return Promise.resolve(of([ + sseEv("response.output_text.delta", { item_id: "oi1", output_index: 0, delta: "the real answer" }), + sseEv("response.completed", { response: { id: "r1", usage: { input_tokens: 1, output_tokens: 1 } } }), + ])); + }; + const events = [ + ...pieces.map((p) => sseEv("response.output_text.delta", { item_id: "oi1", output_index: 0, delta: p })), + sseEv("response.completed", { response: { id: "r1", usage: { input_tokens: 1, output_tokens: 1 } } }), + ]; + await pipePluginResponsesWithStrip(of(events), res as unknown as import("node:http").ServerResponse, session, undefined, refetch); + const text = [...writes.join("").matchAll(/"delta":"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string).join(""); + assert.equal(calls, 1, "the responses lane must detect the envelope and spend one continuation"); + assert.ok(text.includes("Primary Request: X"), "the responses lane cannot retract bytes it already sent"); + assert.ok(text.includes("the real answer"), "the responses lane appends the retry's answer"); +}); + +// #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)}`); +}); + +// #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. Nothing owned a disagreeing close, so the compound shape +// rode the fast path verbatim — found by replaying the shipped corpus (21 units +// across 21 field paths in 10 sessions, all safe: every removed span carries a +// kernel ref, so it is emitter echo and never ordinary content) 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('
`. +// 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) 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 = '
m00257'; + assert.equal(stripDriftResidue(genuine), genuine, + "a canonical-html mismatched pair must never be read as a drifted render unit"); + // And a real attribute slot (name=value) is NOT a bare ref: same restraint. + const attrs = 'm00257'; + assert.equal(stripDriftResidue(attrs), attrs, + "a real name=value attribute slot must not license the bare-ref rule"); +}); + +// ---------- #5175 last mile: the invariant that survives the residual ---------- + +// Measured on the REAL plugin lane (`pipePluginChatWithBuf`), across 16 chunk +// sizes from 1 to 200 bytes: +// +// * no kernel ref and no drifted render-tag opener ever reaches the client — +// that was the reported harm, and it is gone at every size; +// * the delivered PROSE is identical at every size; +// * only which ref-LESS orphan fragments get removed varies with the cut. +// +// That last point is the honest residual: an orphan half whose `\x3c` was lost +// upstream cannot be adjudicated without line state, and line state collides with +// the #2190 balanced-markup contract (see the KNOWN RESIDUAL note above). What +// must never vary is the citation bar and the prose. Pinning THOSE turns this +// test into a tripwire for any future rule that starts eating content — such a +// rule would break the skeleton assertion while every per-shape test still +// passed. +const FRAGMENT = + /<\/?[A-Za-z][A-Za-z0-9_]{0,40}[^<>]{0,40}>|(?/])[A-Za-z][A-Za-z0-9_]{0,40}>/g; +function proseSkeleton(s: string): string { + return s.replace(new RegExp(FRAGMENT.source, "g"), "").replace(/\s+/g, " ").trim(); +} + +const LAST_MILE_CASES: Array<[string, string]> = [ + ["the reported paragraph", REPORTED], + [ + "an orphan half beside prose", + "Here is the report.\n\nacpt> \n\nThat is all.", + ], + [ + "a drifted unit between two prose blocks", + 'Start.\n\nm08844\n\nMiddle words here.\n\nm00257\n\nEnd.', + ], +]; + +const LAST_MILE_SIZES = [1, 2, 3, 5, 7, 11, 13, 17, 23, 31, 40, 50, 64, 90, 127, 200]; + +for (const [name, text] of LAST_MILE_CASES) { + test(`#5175 last mile: ${name} varies only in ref-less residue, never in prose`, async () => { + const skeletons = new Set(); + for (const size of LAST_MILE_SIZES) { + const got = await driveChat(chunked(text, size)); + assert.equal( + (got.match(/m\d{4,}/g) ?? []).length, + 0, + `a kernel ref reached the client at size=${size}: ${JSON.stringify(got)}`, + ); + assert.ok( + !/<(?:acpt|apct|acct|acad|acme|accent|actp)\b/i.test(got), + `a drifted render-tag opener reached the client at size=${size}: ${JSON.stringify(got)}`, + ); + skeletons.add(proseSkeleton(got)); + } + assert.equal( + skeletons.size, + 1, + `the delivered prose changed with the chunking: ${JSON.stringify([...skeletons])}`, + ); + }); +} + +// ---------- #5175 defect A on the ANTHROPIC wire ---------- + +// The two lanes above are `openai` and `responses`. `processAnthropic` is a +// SEPARATE fast path (its own four-probe gate at `src/plugin.ts`), so passing +// those two says nothing about this one — and production traffic is not +// openai-only. Measured on pristine master (`e209109bf`): every chunk +// size forwards the reported paragraph with `refs=3 openers=3`; on this branch +// the same drive yields `refs=0 openers=0` at every size. Without this test the +// anthropic gate has no end-to-end tripwire at all. +function driveAnthropicOnce(pieces: string[]): Promise { + const evs: string[] = []; + evs.push(sseLocal({ type: "message_start", message: { id: "msg1", role: "assistant", content: [] } })); + evs.push(sseLocal({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })); + for (const p of pieces) { + evs.push(sseLocal({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: p } })); + } + evs.push(sseLocal({ type: "content_block_stop", index: 0 })); + evs.push(sseLocal({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } })); + evs.push(sseLocal({ type: "message_stop" })); + const writes: string[] = []; + const res = makeResLocal(writes); + const session = { + ...makeSessionLocal(), + id: "testsess5175_anthro_" + Math.random().toString(36).slice(2), + protocol: "anthropic", + } as unknown as Session; + return pipePluginChatWithStrip( + streamOfLocal(evs), + res as unknown as import("node:http").ServerResponse, + "anthropic", + session, + undefined, + undefined, + ).then(() => { + let acc = ""; + for (const line of writes.join("").split("\n")) { + const t = line.trim(); + if (!t.startsWith("data:")) continue; + const body = t.slice(5).trim(); + if (body.length === 0 || body === "[DONE]") continue; + let ev: Record; + try { + ev = JSON.parse(body) as Record; + } catch { + continue; + } + const d = ev["delta"] as Record | undefined; + if (d && d["type"] === "text_delta" && typeof d["text"] === "string") acc += d["text"]; + } + return acc; + }); +} + +for (const [name, text] of [ + ["the reported paragraph", REPORTED], + ["a drifted unit with a non-enum close", DRIFTED], +] as Array<[string, string]>) { + test(`#5175 A: the anthropic wire never carries the drift residue (${name})`, async () => { + for (const size of [1000, 40, 7, 1]) { + const got = await driveAnthropicOnce(chunked(text, size)); + assert.equal( + (got.match(/m\d{4,}/g) ?? []).length, + 0, + `a kernel ref reached the anthropic client at size=${size}: ${JSON.stringify(got)}`, + ); + assert.ok( + !/<(?:acpt|apct|acct|acad|acme|accent|actp)\b/i.test(got), + `a drifted render-opener reached the anthropic client at size=${size}: ${JSON.stringify(got)}`, + ); + } + }); +} + +test("#5175 A: the anthropic wire leaves genuine markup and prose alone", async () => { + const html = 'The formula is x2 and z is the label.'; + for (const size of [1000, 7, 1]) { + const got = await driveAnthropicOnce(chunked(html, size)); + assert.equal( + got, + html, + `genuine markup must survive the anthropic lane verbatim at size=${size}`, + ); + } +}); + +// ---------- #5175 defect A on the GOOGLE (Gemini) wire ---------- + +// `processGoogle` is a THIRD independent fast path with its own gate, so passing +// openai-chat and responses says nothing about it either. Measured on pristine +// master (`e209109bf`): the reported paragraph forwards with refs=3 openers=3 at +// every chunk size, and DRIFTED forwards whole with refs=1 openers=1; on this +// branch both are zero at every size. Same tripwire role as the anthropic block +// above — without it the Gemini gate has no end-to-end coverage. +function googleFrame(parts: Array>, finishReason?: string): string { + const candidate: Record = { index: 0, content: { role: "model", parts } }; + if (finishReason) candidate["finishReason"] = finishReason; + return `data: ${JSON.stringify({ modelVersion: "gemini-test", candidates: [candidate] })}\n\n`; +} + +function driveGoogleOnce(pieces: string[]): Promise { + const evs = pieces.map((p, i) => + googleFrame([{ text: p }], i === pieces.length - 1 ? "STOP" : undefined), + ); + const writes: string[] = []; + const res = makeResLocal(writes); + const session = { + ...makeSessionLocal(), + id: "testsess5175_goog_" + Math.random().toString(36).slice(2), + protocol: "google", + } as unknown as Session; + return pipePluginChatWithStrip( + streamOfLocal(evs), + res as unknown as import("node:http").ServerResponse, + "google", + session, + undefined, + undefined, + ).then(() => { + let acc = ""; + for (const line of writes.join("").split("\n")) { + const t = line.trim(); + if (!t.startsWith("data:")) continue; + const body = t.slice(5).trim(); + if (body.length === 0 || body === "[DONE]") continue; + let ev: Record; + try { + ev = JSON.parse(body) as Record; + } catch { + continue; + } + const cands = ev["candidates"]; + if (!Array.isArray(cands)) continue; + for (const c of cands as Array>) { + const content = c["content"] as Record | undefined; + const parts = content?.["parts"]; + if (!Array.isArray(parts)) continue; + for (const p of parts as Array>) { + if (typeof p["text"] === "string") acc += p["text"]; + } + } + } + return acc; + }); +} + +for (const [name, text] of [ + ["the reported paragraph", REPORTED], + ["a drifted unit with a non-enum close", DRIFTED], +] as Array<[string, string]>) { + test(`#5175 A: the google wire never carries the drift residue (${name})`, async () => { + for (const size of [1000, 40, 7, 1]) { + const got = await driveGoogleOnce(chunked(text, size)); + assert.equal( + (got.match(/m\d{4,}/g) ?? []).length, + 0, + `a kernel ref reached the google client at size=${size}: ${JSON.stringify(got)}`, + ); + assert.ok( + !/<(?:acpt|apct|acct|acad|acme|accent|actp)\b/i.test(got), + `a drifted render-opener reached the google client at size=${size}: ${JSON.stringify(got)}`, + ); + } + }); +} + +test("#5175 A: the google wire leaves genuine markup and prose alone", async () => { + const html = 'The formula is x2 and z is the label.'; + for (const size of [1000, 7, 1]) { + const got = await driveGoogleOnce(chunked(html, size)); + assert.equal(got, html, `genuine markup must survive the google lane verbatim at size=${size}`); + } + for (const size of [1000, 7, 1]) { + const prose = "Here is the report. That is all."; + const got = await driveGoogleOnce(chunked(prose, size)); + assert.equal(got, prose, `prose must survive the google lane verbatim at size=${size}`); + } +}); + +// ---------- #5175 residual: the cross-cut invariant, stated per lane ---------- + +// Every sweep in this file so far drives a payload WHOLE or in fixed-size +// chunks. The production failure in #5175 is a payload arriving as two SSE +// deltas split at an ARBITRARY byte boundary, which is what makes the drift +// pair's two halves land in different `push` calls. This pins the invariant +// that actually matters for that split, on all three chat lanes at once: +// +// a cut may leave ref-less residue, but it may NEVER deliver a kernel ref +// or a drifted opener. +// +// Measured on pristine master (`e209109bf`): 287 of 287 cuts leak on every +// lane. On this branch: 0 refs and 0 openers on every lane, while the four +// structural shapes are clean end to end (237 of 237 cuts). +const CUT_PAYLOADS: Array<[string, string]> = [ + ["enum-close", "Here is the report.\n\n\n\nThat is all."], + ["drift-close", "Here is the report.\n\n\n\nThat is all."], + ["attr-mismatch", 'Here is the report.\n\nm08844\n\nThat is all.'], + ["bare-mismatch", "Here is the status report.\n\n\n\nThat is all."], + ["reported", "acct> "], +]; + +const KERNEL_REF_TEST = /m\d{5,}/; +const DRIFT_OPENER_TEST = /<(?:acpt|apct|acct|acad|acme|accent|actp)\b/i; + +for (const [laneName, drive] of [ + ["openai", driveChat], + ["anthropic", (chunks: string[]) => driveAnthropicOnce(chunks)], + ["google", (chunks: string[]) => driveGoogleOnce(chunks)], +] as Array<[string, (chunks: string[]) => Promise]>) { + test(`#5175 residual: a byte cut never hands the ${laneName} client a ref or a drifted opener`, async () => { + let cuts = 0; + const offenders: string[] = []; + for (const [name, text] of CUT_PAYLOADS) { + const bytes = Buffer.from(text, "utf8"); + for (let cut = 1; cut < bytes.length; cut += 1) { + const a = bytes.subarray(0, cut).toString("utf8"); + const b = bytes.subarray(cut).toString("utf8"); + const got = await drive([a, b]); + cuts += 1; + if (KERNEL_REF_TEST.test(got) || DRIFT_OPENER_TEST.test(got)) { + offenders.push(`${name}@${cut}: ${JSON.stringify(got.slice(0, 90))}`); + } + } + } + assert.ok(cuts > 200, `the sweep must cover every boundary, got ${cuts}`); + assert.equal( + offenders.length, + 0, + `${offenders.length}/${cuts} cuts leaked on the ${laneName} lane:\n${offenders.slice(0, 4).join("\n")}`, + ); + }); +} + +// ---------- #5175 residual on the FOURTH wire: responses ---------- + +// `pipePluginResponsesWithStrip` owns the last independent fast path. It takes a +// single payload rather than a chunk list, so the cut sweep needs its own +// two-delta driver — but the invariant is the same one the three chat lanes +// just proved, and leaving this wire out would repeat exactly the gap the +// anthropic and google blocks closed. +function driveResponsesCut(pieces: 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_respcut_" + 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 = [ + ...pieces.map((p) => sse("response.output_text.delta", { item_id: "oi1", output_index: 0, delta: p })), + 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 residual: a byte cut never hands the responses client a ref or a drifted opener", async () => { + let cuts = 0; + const offenders: string[] = []; + for (const [name, text] of CUT_PAYLOADS) { + const bytes = Buffer.from(text, "utf8"); + for (let cut = 1; cut < bytes.length; cut += 1) { + const a = bytes.subarray(0, cut).toString("utf8"); + const b = bytes.subarray(cut).toString("utf8"); + const got = (await driveResponsesCut([a, b])).text; + cuts += 1; + if (KERNEL_REF_TEST.test(got) || DRIFT_OPENER_TEST.test(got)) { + offenders.push(`${name}@${cut}: ${JSON.stringify(got.slice(0, 90))}`); + } + } + } + assert.ok(cuts > 200, `the sweep must cover every boundary, got ${cuts}`); + assert.equal( + offenders.length, + 0, + `${offenders.length}/${cuts} cuts leaked on the responses lane:\n${offenders.slice(0, 4).join("\n")}`, + ); +}); + +// #5175 fourth seam: the NON-STREAMING plugin JSON path (`pipePluginJson` → +// `pipePluginJson`). Every other lane in this file is SSE: the four +// fast-path gates live in the streaming processors, and the pure-rule tests +// exercise whole-text helpers directly. `pipePluginJson` parses the finished body +// and strips it in place — a separate entry with its own gate +// (`containsRenderTagText(text) || containsMarkerLineText …`). Nothing above +// proves it, so a future change could reopen the leak there alone. +function jsonBodyFor(protocol: string, text: string): Record { + if (protocol === "openai") { + return { + id: "c1", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 4 }, + }; + } + if (protocol === "anthropic") { + return { + id: "m1", + type: "message", + role: "assistant", + content: [{ type: "text", text }], + stop_reason: "end_turn", + usage: { input_tokens: 3, output_tokens: 4 }, + }; + } + if (protocol === "google") { + return { + candidates: [{ index: 0, content: { role: "model", parts: [{ text }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 4 }, + }; + } + return { + id: "r1", + object: "response", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text }] }], + usage: { input_tokens: 3, output_tokens: 4 }, + }; +} + +function jsonTextFrom(protocol: string, json: any): string { + if (protocol === "openai") return json?.choices?.[0]?.message?.content ?? ""; + if (protocol === "anthropic") return (json?.content ?? []).map((p: any) => p?.text ?? "").join(""); + if (protocol === "google") return (json?.candidates?.[0]?.content?.parts ?? []).map((p: any) => p?.text ?? "").join(""); + return (json?.output ?? []).map((o: any) => (o?.content ?? []).map((p: any) => p?.text ?? "").join("")).join(""); +} + +async function driveJsonOnce(protocol: "openai" | "anthropic" | "google" | "responses", text: string): Promise { + const { pipePluginJson } = await import("../src/plugin.ts"); + const writes: string[] = []; + const res = makeResLocal(writes); + const session = { + ...makeSessionLocal(), + id: "testsess5175_json_" + Math.random().toString(36).slice(2), + protocol, + } as unknown as Session; + const body = JSON.stringify(jsonBodyFor(protocol, text)); + await pipePluginJson( + streamOfLocal([body]), + res as unknown as import("node:http").ServerResponse, + session, + protocol, + undefined, + false, + "", + ); + const sent = writes.join(""); + let parsed: any; + try { + parsed = JSON.parse(sent); + } catch { + return sent; + } + return jsonTextFrom(protocol, parsed); +} + +for (const protocol of ["openai", "anthropic", "google", "responses"] as const) { + test(`#5175 A: the non-streaming ${protocol} JSON body never carries the drift residue`, async () => { + const got = await driveJsonOnce(protocol, REPORTED); + assert.equal((got.match(KERNEL_REF_TEST) ?? []).length, 0, `a kernel ref reached the ${protocol} JSON client: ${JSON.stringify(got.slice(0, 90))}`); + assert.equal((got.match(DRIFT_OPENER_TEST) ?? []).length, 0, `a drifted opener reached the ${protocol} JSON client: ${JSON.stringify(got.slice(0, 90))}`); + }); +} + +test("#5175 A control: the non-streaming JSON path leaves genuine markup and prose byte-exact", async () => { + const genuine = 'x2 and z — all plain prose here.'; + for (const protocol of ["openai", "anthropic", "google", "responses"] as const) { + const got = await driveJsonOnce(protocol, genuine); + assert.equal(got, genuine, `${protocol} JSON path must not rewrite genuine prose/markup`); + } +}); + +// #5175 fifth seam: the four NON-STREAMING JSON REWRITERS +// (`rewriteJsonResponseAsync` / `-Openai` / `-Responses` / `-Google`). These are +// what a real (non-plugin) client gets when `prepare()` injects the compress tool +// and the upstream answers with `stream:false` — the relay parses the finished +// body, hands it to the per-protocol rewriter, then `res.end(JSON.stringify)`. +// Every one of them gates on `containsRenderTagText(text)` and then calls +// `stripAcpTags`. On the pre-#5175 predicate that gate is false for a drifted +// name, so all four hand the model's echo back verbatim — measured on pristine +// `master`: refs=1 openers=1 for each of the four. After the shape rule: +// refs=0 openers=0. Nothing else in this file covers these entries. +function rewriteCtx(): never { + const core = createCore(); + const config = defaultConfig(400_000) as Config; + config.preserveRecentMessages = 0; + config.preserveRecentTokens = 0; + const session = createSession(`rw-${Math.random().toString(36).slice(2)}`); + const messages: CoreMessage[] = [ + { id: "history", role: "assistant", contentType: "text", text: "plain history" }, + { id: "now", role: "user", contentType: "text", text: "go on" }, + ]; + session.state = core.processTurn({ messages, state: session.state, config, tokenCount: 10_000, renderTags: "text-only" }).state; + return { core, config, session, messages, log: () => {} } as never; +} + +const REWRITE_CASES: Array<[string, () => Promise]> = [ + ["anthropic", () => rewriteJsonResponseAsync({ content: [{ type: "text", text: REPORTED }] }, rewriteCtx())], + [ + "openai", + () => + rewriteOpenaiJsonResponseAsync( + { choices: [{ message: { role: "assistant", content: REPORTED }, finish_reason: "stop" }] }, + rewriteCtx(), + ), + ], + [ + "responses", + () => + rewriteResponsesJsonResponseAsync( + { output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: REPORTED }] }] }, + rewriteCtx(), + ), + ], + ["google", () => rewriteGoogleJsonResponseAsync({ candidates: [{ content: { role: "model", parts: [{ text: REPORTED }] } }] }, rewriteCtx())], +]; + +for (const [name, run] of REWRITE_CASES) { + test(`#5175 A: the non-streaming ${name} rewriter never hands the drift residue back`, async () => { + const out = await run(); + const s = JSON.stringify(out) ?? ""; + assert.equal((s.match(KERNEL_REF_TEST) ?? []).length, 0, `a kernel ref survived the ${name} rewriter: ${JSON.stringify(s.slice(0, 120))}`); + assert.equal((s.match(DRIFT_OPENER_TEST) ?? []).length, 0, `a drifted opener survived the ${name} rewriter: ${JSON.stringify(s.slice(0, 120))}`); + }); +} + +test("#5175 A control: the four non-streaming rewriters leave genuine markup and prose byte-exact", async () => { + // Compare the REWRITTEN TEXT, not the serialized JSON: JSON.stringify escapes + // the `"` in class="a", so a substring test against the raw source would fail + // even when the rewriter is a pure identity. This caught a bug in my own test, + // not in the rewriter — the payload round-tripped unchanged. + const genuine = 'x2 and z — all plain prose here.'; + const extract = { + anthropic: (v: any) => (v?.content ?? []).map((p: any) => p?.text ?? "").join(""), + openai: (v: any) => v?.choices?.[0]?.message?.content ?? "", + responses: (v: any) => + (v?.output ?? []).map((o: any) => (o?.content ?? []).map((p: any) => p?.text ?? "").join("")).join(""), + google: (v: any) => (v?.candidates?.[0]?.content?.parts ?? []).map((p: any) => p?.text ?? "").join(""), + } as const; + const bodies: Array<[keyof typeof extract, Promise]> = [ + ["anthropic", rewriteJsonResponseAsync({ content: [{ type: "text", text: genuine }] }, rewriteCtx())], + ["openai", rewriteOpenaiJsonResponseAsync({ choices: [{ message: { role: "assistant", content: genuine }, finish_reason: "stop" }] }, rewriteCtx())], + ["responses", rewriteResponsesJsonResponseAsync({ output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: genuine }] }] }, rewriteCtx())], + ["google", rewriteGoogleJsonResponseAsync({ candidates: [{ content: { role: "model", parts: [{ text: genuine }] } }] }, rewriteCtx())], + ]; + for (const [name, pending] of bodies) { + const got = extract[name](await pending); + assert.equal(got, genuine, `${name} rewriter must not rewrite genuine prose/markup`); + } +}); + +// ---------- #5179 live report (v0.1.213, 2026-11-09) ---------- +// +// Two consecutive assistant turns opened with a first text line +// 910 / 502 +// (blank line, then the reply body). The emission degraded on the NAME instead +// of mutating it: not an acp misspelling (acpt/acct/accent) but a different +// underscore-joined word of the emitter's own namespace, body = the bare +// per-turn token count. No ref, no tokens= attribute, non-empty body — every +// payload-shape rule missed it AND the gate never called the stripper. The +// convicted shape is DRIFT_COUNT: an acp-prefixed name wrapping a digits-only +// body, closed by its own name. + +test("#5179 A: the reported acp_tokens count unit is stripped whole-text", () => { + assert.equal(stripAcpTags("\x3cacp_tokens\x3e910\x3c/acp_tokens\x3e"), ""); +}); + +test("#5179 A: the reported two-line turn drops both units, keeps the body byte-exact", () => { + const turn = + "\x3cacp_tokens\x3e910\x3c/acp_tokens\x3e\n" + + "\n" + + "\x3cacp_tokens\x3e502\x3c/acp_tokens\x3e\n" + + "\n" + + "Actual reply body."; + assert.equal(stripAcpTags(turn), "\n\n\n\nActual reply body."); +}); + +test("#5179 A negatives: non-acp names, non-digit bodies, mismatched closes survive", () => { + assert.equal(stripAcpTags("\x3cfoo\x3e5\x3c/foo\x3e"), "\x3cfoo\x3e5\x3c/foo\x3e"); + assert.equal(stripAcpTags("\x3cacp_tokens\x3esee below\x3c/acp_tokens\x3e"), "\x3cacp_tokens\x3esee below\x3c/acp_tokens\x3e"); + assert.equal(stripAcpTags("\x3cacp_tokens\x3e910\x3c/acp_tokenz\x3e"), "\x3cacp_tokens\x3e910\x3c/acp_tokenz\x3e"); + assert.equal(stripAcpTags("the acp process counts tokens"), "the acp process counts tokens"); +}); + +test("#5179 A: the gate engages on the count shape, stays off for prose pairs", () => { + assert.equal(containsRenderTagText("\x3cacp_tokens\x3e910\x3c/acp_tokens\x3e"), true); + assert.equal(containsRenderTagText("\x3cfoo\x3e5\x3c/foo\x3e"), false); + // Deliberate width (#5179 review): any acp-prefixed name engages the gate. + assert.equal(containsRenderTagText("\x3cacp_tool\x3e5\x3c/acp_tool\x3e"), true); +}); + +test("#5179 A: the released-span seam strips the count unit too", () => { + assert.equal(stripDriftResidue("\x3cacp_tokens\x3e910\x3c/acp_tokens\x3e"), ""); +}); + +// Width ruling (#5179 review, 2026-11-09): the count shape fires on ANY +// acp-prefixed name with a digits-only body - not just the attested +// acp_tokens. Kept deliberately wide: the acp prefix excludes English prose +// and real tool-call XML on its own, while an underscore requirement would +// overfit one instance and leave siblings (acp_count/acp_usage or a future +// underscore-less form) leaking. Both directions pinned here. +test("#5179 A width: underscore-free acp-prefixed count units are stripped too", () => { + assert.equal(stripAcpTags("\x3cacp_tool\x3e5\x3c/acp_tool\x3e"), ""); + assert.equal(stripAcpTags("\x3cacpx\x3e910\x3c/acpx\x3e"), ""); +}); diff --git a/tests/native-compact-terminal.test.ts b/tests/native-compact-terminal.test.ts index 9b8a7ca65c..466b4909d4 100644 --- a/tests/native-compact-terminal.test.ts +++ b/tests/native-compact-terminal.test.ts @@ -59,6 +59,18 @@ test("e2e #321 PR-C: rebase only on completed; trigger request forwarded clean", res.end(); return; } + if (mode === "drift" || mode === "markup") { + // #5175: a native compact turn answers as plain JSON. Its + // context is by definition tag-laden, so the echoed prose + // carries drift residue — that must not reach the client. + // `markup` is the control: genuine markup must survive. + const text = mode === "drift" + ? "acpt> done" + : "x2 and z done"; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ id: "resp_c", status: "completed", output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text }] }], usage: { input_tokens: 10, output_tokens: 1 } })); + return; + } res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ id: "resp_c", status: "completed", output: [{ type: "message", role: "user", content: "summary" }], usage: { input_tokens: 10, output_tokens: 1 } })); return; @@ -192,6 +204,23 @@ test("e2e #321 PR-C: rebase only on completed; trigger request forwarded clean", assert.equal(r6.status, 200); await r6.text(); assert.equal(pendingRebase("term-jsonok"), true, "JSON output response schedules the rebase"); + + // 7. #5175: the non-SSE native compact egress strips render-tag residue. + // A compaction turn's context is by definition tag-laden, so this is + // the likeliest echo leak of any Responses stream. Master forwarded + // it verbatim (2 refs + 2 openers reached the client). + const r7 = await postResponses("/responses/compact?mode=drift", { model: "gpt-resp", session_id: "term-drift" }); + assert.equal(r7.status, 200); + const driftText = await r7.text(); + assert.equal((driftText.match(/m\d{5,}/g) ?? []).length, 0, `no kernel ref may reach the client, got: ${driftText.slice(0, 200)}`); + assert.equal((driftText.match(/<(?:acpt|apct|acct|acad|acme|accent|actp)\b/gi) ?? []).length, 0, `no drifted opener may reach the client, got: ${driftText.slice(0, 200)}`); + assert.equal(pendingRebase("term-drift"), true, "the strip must not disturb the terminal-state observer"); + + // 8. #5175 control: genuine markup on the same egress survives verbatim. + const r8 = await postResponses("/responses/compact?mode=markup", { model: "gpt-resp", session_id: "term-markup" }); + assert.equal(r8.status, 200); + const markupText = await r8.text(); + assert.ok(markupText.includes("x2") && markupText.includes("z"), `genuine markup must survive, got: ${markupText.slice(0, 200)}`); } finally { proxy.close(); proxy.closeIdleConnections?.(); diff --git a/website/config-reference/compress.yaml b/website/config-reference/compress.yaml index 058f36d66b..6244130efc 100644 --- a/website/config-reference/compress.yaml +++ b/website/config-reference/compress.yaml @@ -206,15 +206,15 @@ entries: site_en: "\"off\" | \"warn\" | \"repair\" (default repair): reconcile folded state when the client rewinds or rewrites history between turns." site_zh: "\"off\" | \"warn\" | \"repair\"(默认 repair):客户端在轮次之间回退或改写历史时校准已折叠状态。" - key: compress.promptPack - type: "string (builtin: \"default\", \"lean\")" + type: "string (builtin: \"default\", \"lean\", \"chat\")" default: "builtin \"default\"" group: compress id: cw.ppk render: compress-row - desc_en: "Compression prompt pack, resolved project pack → user pack → builtin; not gated by acknowledgePromptsRisk." - desc_zh: "压缩提示词包,按 项目包 → 用户包 → 内置 解析;不受 acknowledgePromptsRisk 门控。" - site_en: "Compression prompt pack, resolved project pack → user pack → builtin. Builtins: \"default\" and \"lean\" (one-line tool descriptions). Not gated by acknowledgePromptsRisk." - site_zh: "压缩提示词包,按 项目包 → 用户包 → 内置 解析。内置:\"default\" 与 \"lean\"(单行工具描述)。不受 acknowledgePromptsRisk 门控。" + desc_en: "Compression prompt pack, resolved project pack → user pack → shipped pack → builtin; not gated by acknowledgePromptsRisk." + desc_zh: "压缩提示词包,按 项目包 → 用户包 → 随包附带(chat)→ 内置 解析;不受 acknowledgePromptsRisk 门控。" + site_en: "Compression prompt pack, resolved project pack → user pack → shipped pack → builtin. Builtins: \"default\" and \"lean\" (one-line tool descriptions); hosts may also ship a \"chat\" pack (#5319). Not gated by acknowledgePromptsRisk." + site_zh: "压缩提示词包,按 项目包 → 用户包 → 随包附带 → 内置 解析。内置:\"default\" 与 \"lean\"(单行工具描述);宿主可另附 \"chat\" 包(#5319)。不受 acknowledgePromptsRisk 门控。" - key: compress.tiers type: boolean default: "true" diff --git a/website/docs/index.html b/website/docs/index.html index 3e4bf64991..16984e9a73 100644 --- a/website/docs/index.html +++ b/website/docs/index.html @@ -201,7 +201,7 @@

compress — every knob

maxSummaryLengthnumberHard cap, in characters, on one generated summary (kernel default 20000): longer replies are rejected whole by the kernel check or the external-summary executor. External summarizers receive this exact cap in their instructions (#3635); 0 disables the cap. minSummaryLengthnumberFloor, in characters, for generated summaries (kernel default 50): a single range must meet it on its own; a multi-range batch passes when its COMBINED summaries reach it — one lazy paragraph no longer sinks the whole batch (#5894). 0 disables the floor. External summarizers remain held to the per-summary floor (#3635). reconcile"off" | "warn" | "repair""off" | "warn" | "repair" (default repair): reconcile folded state when the client rewinds or rewrites history between turns. - promptPackstring (builtin: "default", "lean")Compression prompt pack, resolved project pack → user pack → builtin. Builtins: "default" and "lean" (one-line tool descriptions). Not gated by acknowledgePromptsRisk. + promptPackstring (builtin: "default", "lean", "chat")Compression prompt pack, resolved project pack → user pack → shipped pack → builtin. Builtins: "default" and "lean" (one-line tool descriptions); hosts may also ship a "chat" pack (#5319). Not gated by acknowledgePromptsRisk. tiersbooleanOn by default: tiered T1→T3 distillation spreads folding cost across generations. protectedToolsstring[]Hard exclusion across all history: results of listed tools never fold (use with care). protectedLatestToolsstring[]Protects only the LATEST instance of cumulative-snapshot tools whose newest result supersedes older ones (e.g. todo lists). diff --git a/website/main.js b/website/main.js index 1c30e0b268..245b37158b 100644 --- a/website/main.js +++ b/website/main.js @@ -81,7 +81,7 @@ "cw.msl": "Hard cap, in characters, on one generated summary (kernel default 20000): longer replies are rejected whole by the kernel check or the external-summary executor. External summarizers receive this exact cap in their instructions (#3635); 0 disables the cap.", "cw.mnl": "Floor, in characters, for generated summaries (kernel default 50): a single range must meet it on its own; a multi-range batch passes when its COMBINED summaries reach it — one lazy paragraph no longer sinks the whole batch (#5894). 0 disables the floor. External summarizers remain held to the per-summary floor (#3635).", "cw.rec": "\"off\" | \"warn\" | \"repair\" (default repair): reconcile folded state when the client rewinds or rewrites history between turns.", - "cw.ppk": "Compression prompt pack, resolved project pack → user pack → builtin. Builtins: \"default\" and \"lean\" (one-line tool descriptions). Not gated by acknowledgePromptsRisk.", + "cw.ppk": "Compression prompt pack, resolved project pack → user pack → shipped pack → builtin. Builtins: \"default\" and \"lean\" (one-line tool descriptions); hosts may also ship a \"chat\" pack (#5319). Not gated by acknowledgePromptsRisk.", "cw.tiers": "On by default: tiered T1→T3 distillation spreads folding cost across generations.", "ct.pt": "Hard exclusion across all history: results of listed tools never fold (use with care).", "ct.plt": "Protects only the LATEST instance of cumulative-snapshot tools whose newest result supersedes older ones (e.g. todo lists).", @@ -338,7 +338,7 @@ "cw.msl": "单条生成摘要的硬上限,单位为字符(内核默认 20000):超长的回复由内核检查或外部摘要执行器整段拒绝。外部摘要模型的指令中会写明这个确切上限(#3635);0 禁用上限。", "cw.mnl": "生成摘要的下限,单位为字符(内核默认 50):单段压缩按自身判定;批量压缩按各段合计判定——一段偷懒不再拖垮整批(#5894)。0 禁用下限。外部摘要模型仍受逐段下限约束(#3635)。", "cw.rec": "\"off\" | \"warn\" | \"repair\"(默认 repair):客户端在轮次之间回退或改写历史时校准已折叠状态。", - "cw.ppk": "压缩提示词包,按 项目包 → 用户包 → 内置 解析。内置:\"default\" 与 \"lean\"(单行工具描述)。不受 acknowledgePromptsRisk 门控。", + "cw.ppk": "压缩提示词包,按 项目包 → 用户包 → 随包附带 → 内置 解析。内置:\"default\" 与 \"lean\"(单行工具描述);宿主可另附 \"chat\" 包(#5319)。不受 acknowledgePromptsRisk 门控。", "cw.tiers": "默认开:T1→T3 分级蒸馏,把折叠成本摊到多代。", "ct.pt": "全历史硬排除:列出工具的结果永不折叠(慎用)。", "ct.plt": "只保护累积快照类工具的「最新一次」实例(新结果覆盖旧结果的工具,如 todo 列表)。",