From bad6544d4038bffd5a4d7c30554eae79d4149ca2 Mon Sep 17 00:00:00 2001 From: ci-check Date: Tue, 10 Nov 2026 10:42:20 +0800 Subject: [PATCH 1/3] kernel: refuse to narrow ref pairs joined by Han-script words (#6071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field evidence (session-9dd7b2f0, v0.1.213): a CJK model joined two refs with the Han range word 到. The #4253 guard class [^\p{L}\p{N}\s] only matches punctuation runs, so "m07890到m07950 t" fell through as a SINGLE ref m07890 with topic "到m07950 t" and silently folded one message of a many-message range — exactly the outcome the guard's own comment says it exists to prevent. Extend UNRECOGNIZED_REF_SEP_RE so the unrecognized-separator run may mix punctuation with Han-script letters; such headers now reject loudly with the actionable reason instead of narrowing. No new accepted forms. Latin topics are untouched (the extension is \p{Script=Han}-only), pinned both directions: Han-word+ref rejects; Han topic without a following ref still parses as a single ref. --- kernel/package.json | 2 +- kernel/src/parse-compress-input.ts | 6 ++++- kernel/tests/parse-compress-input.test.ts | 33 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/kernel/package.json b/kernel/package.json index 160acc27c1..d5c9064802 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.191", + "version": "0.0.192", "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.", "license": "MIT", "author": "ranxianglei", diff --git a/kernel/src/parse-compress-input.ts b/kernel/src/parse-compress-input.ts index fa96b7a947..b00a12a2bb 100644 --- a/kernel/src/parse-compress-input.ts +++ b/kernel/src/parse-compress-input.ts @@ -515,8 +515,12 @@ const SINGLE_REF_IN_LINE = /^([mb]\d{1,7})\b/i; // An unrecognized divider between two refs (punctuation run + a ref right // after) — the pair regex missed a separator variant. Folding as a single // ref would silently compress ONE message of a many-message range. +// #6071: the run may mix punctuation with Han-script letters — CJK range +// words (到/至) are \p{L}, so the original class let "m07890到m07950" through +// as a single-ref header and narrowed a multi-message fold to one message, +// exactly what this guard exists to prevent. const UNRECOGNIZED_REF_SEP_RE = - /^\s*[^\p{L}\p{N}\s]{1,3}\s*[mb]\d{1,7}\b/iu; + /^\s*(?:[^\p{L}\p{N}\s]|\p{Script=Han}){1,3}\s*[mb]\d{1,7}\b/iu; function normalizeLineRef(raw: string): string { const lower = raw.toLowerCase(); diff --git a/kernel/tests/parse-compress-input.test.ts b/kernel/tests/parse-compress-input.test.ts index 1e53987642..94087450ed 100644 --- a/kernel/tests/parse-compress-input.test.ts +++ b/kernel/tests/parse-compress-input.test.ts @@ -1387,6 +1387,39 @@ test("parseCompressArgs refuses to narrow a ref pair with an unrecognized separa assert.equal(single.ranges[0]?.endRef, "m00150"); }); +// #6071 field (session-9dd7b2f0): CJK models join refs with Han range words +// like 到/至. Those are \p{L}, so the original guard class never matched them +// — "m07890到m07950 t" parsed as a SINGLE ref m07890 with topic "到m07950 t" +// and silently folded one message of a many-message range instead of +// rejecting loudly. Both directions pinned: Han-word + second ref rejects; +// a Han topic NOT followed by a ref still parses as a single ref. +test("parseCompressArgs refuses to narrow a ref pair joined by a Han-script word", () => { + const { ranges, diagnostics } = parseCompressArgs({ + content: "m07890到m07950 t\nsummary.", + }); + assert.equal(ranges.length, 0); + assert.equal(diagnostics.kind, "no-valid-ranges"); + assert.match(diagnostics.invalidReasons?.[0] ?? "", /separator not recognized/); + const zhi = parseCompressArgs({ content: "m00150至m00168 t\nsummary." }); + assert.equal(zhi.diagnostics.kind, "no-valid-ranges"); + // A mixed punct+Han run joins refs just as loudly. + const mixed = parseCompressArgs({ content: "m00150,到m00168 t\nsummary." }); + assert.equal(mixed.diagnostics.kind, "no-valid-ranges"); +}); + +test("single-ref headers with Han topics still parse when no second ref follows", () => { + // 到期 is an ordinary topic word — nothing ref-shaped behind it → single ref. + const due = parseCompressArgs({ content: "m00150 到期的任务\nsummary." }); + assert.equal(due.ranges[0]?.startRef, "m00150"); + assert.equal(due.ranges[0]?.endRef, "m00150"); + // The guard extension is Han-only: Latin-letter topics were always safe and stay safe. + // (Topic must not start with the literal "to" separator — "tom00220" would join + // into a pair via the pre-existing word form.) + const latin = parseCompressArgs({ content: "m00150 xyz00220 notes\nsummary." }); + assert.equal(latin.ranges[0]?.startRef, "m00150"); + assert.equal(latin.ranges[0]?.endRef, "m00150"); +}); + // --------------------------------------------------------------------------- // compressAtFoldPoint v2 pointer echo + stringified single entry (#5949 field) // --------------------------------------------------------------------------- From 3a9c2633f4c836e23014a9c3af31f3d52a47dc87 Mon Sep 17 00:00:00 2001 From: ci-check Date: Tue, 10 Nov 2026 10:42:28 +0800 Subject: [PATCH 2/3] fix: log bounded preview of rejected compress content (#6071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rejected warn carried only kind/keys/reasons, so a field occurrence of an unrecognized separator had to be guessed from the model's next retry (session-9dd7b2f0: the failing bytes were never in the log). The line now carries preview= with up to 300 chars of the offending content (bare string or the string entries of a content array). Server log only via the existing note() helper — quiet mode (#5161) still suppresses it, and it is never echoed into the model-facing receipt (#1454). Object-only rejections log no preview (nothing string-shaped to show). --- src/compress-tool.ts | 22 ++++++- .../issue6071-compress-reject-preview.test.ts | 64 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/issue6071-compress-reject-preview.test.ts diff --git a/src/compress-tool.ts b/src/compress-tool.ts index 6335334104..5e629ee25f 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -565,6 +565,25 @@ function decodeDoubleEscapedContent(input: unknown): Record | u return { ...input, content: decoded }; } +// #6071: rejected line-form entries were logged without their payload, so an +// unrecognized separator seen in the field had to be guessed from the model's +// next retry. A bounded preview of the offending content (a bare string or +// the string entries of a content array) pins the exact shape on first sight. +// Server log only — never echoed into the model-facing receipt (#1454). +function rejectedContentPreview(normalized: unknown): string | undefined { + const pieces: string[] = []; + if (typeof normalized === "string") pieces.push(normalized); + else if (isPlainObject(normalized)) { + const content = normalized["content"]; + if (typeof content === "string") pieces.push(content); + else if (Array.isArray(content)) { + for (const item of content) if (typeof item === "string") pieces.push(item); + } + } + if (pieces.length === 0) return undefined; + return JSON.stringify(pieces.map((p) => p.slice(0, 150)).join(" // ").slice(0, 300)); +} + // #5161: options.quiet suppresses every diagnostic log of this parse. The // internal #2749 scan (visibleSummaryStubs) re-parses ALL visible past compress // calls on every fold, and in external-summary mode a summary-less entry is @@ -601,7 +620,8 @@ export function parseCompressInput(input: unknown, callId?: string, options: { q note(`[acp-compress-input] flat-summary salvage: recovered ${parsed.ranges.length} range(s); summary text arrived in content alongside top-level refs`); } if (!parsed.diagnostics.ok && parsed.diagnostics.kind !== "ok") { - note(`[acp-compress-input] rejected: kind=${parsed.diagnostics.kind} invalidItems=${parsed.diagnostics.invalidItems}${parsed.diagnostics.keys ? ` keys=[${parsed.diagnostics.keys.join(",")}]` : ""}${parsed.diagnostics.length !== undefined ? ` len=${parsed.diagnostics.length}` : ""}${parsed.diagnostics.invalidReasons && parsed.diagnostics.invalidReasons.length > 0 ? ` reasons=[${parsed.diagnostics.invalidReasons.join(" | ")}]` : ""}`); + const preview = rejectedContentPreview(normalized); + note(`[acp-compress-input] rejected: kind=${parsed.diagnostics.kind} invalidItems=${parsed.diagnostics.invalidItems}${parsed.diagnostics.keys ? ` keys=[${parsed.diagnostics.keys.join(",")}]` : ""}${parsed.diagnostics.length !== undefined ? ` len=${parsed.diagnostics.length}` : ""}${preview !== undefined ? ` preview=${preview}` : ""}${parsed.diagnostics.invalidReasons && parsed.diagnostics.invalidReasons.length > 0 ? ` reasons=[${parsed.diagnostics.invalidReasons.join(" | ")}]` : ""}`); } else if (parsed.diagnostics.invalidItems > 0 || parsed.diagnostics.kind === "truncated") { // #1494: partial drop — at least one range survived, so the old // condition above never fired and the rejection was invisible on both diff --git a/tests/issue6071-compress-reject-preview.test.ts b/tests/issue6071-compress-reject-preview.test.ts new file mode 100644 index 0000000000..2ad5d06a3f --- /dev/null +++ b/tests/issue6071-compress-reject-preview.test.ts @@ -0,0 +1,64 @@ +// #6071 field (session-9dd7b2f0): a compress call rejected with +// kind=no-valid-ranges ("refs separator not recognized") was logged WITHOUT +// the offending payload — the server line carried only kind/keys/reasons, so +// the exact separator the model used had to be guessed from its next retry. +// Fix: the rejected warn carries a bounded preview of the offending content +// (a bare string or the string entries of a content array). Server log only; +// never echoed into the model-facing receipt (#1454), and quiet (#5161) still +// suppresses it via the shared note() helper. +// +// Gate both directions (#3617): a rejected LINE entry logs preview=; a +// rejected OBJECT entry (nothing string-shaped to preview) does not. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseCompressInput } from "../src/compress-tool.ts"; +import { setLogCapture } from "../src/logger.ts"; + +/** Run fn under the logger's programmatic capture seam (same pattern as + * tests/issue5161-compress-scan-log-noise.test.ts) and return its lines. */ +function withLogCapture(fn: () => T): { result: T; lines: string[] } { + const lines: string[] = []; + setLogCapture((_level, msg) => { lines.push(msg); }); + try { + return { result: fn(), lines }; + } finally { + setLogCapture(null); + } +} + +const ACP_INPUT_LINES = (lines: string[]): string[] => lines.filter((l) => l.includes("[acp-compress-input]")); + +test("#6071 rejected line entry logs a bounded preview of the offending content", () => { + // The pinned unrecognized-separator shape (=> must stay rejected, kernel + // guard test) — independent of which separators the kernel accepts. + const header = "m00150 => m00168 topic\nsummary."; + const { result, lines } = withLogCapture(() => + parseCompressInput({ content: [header] }), + ); + assert.equal(result.ranges.length, 0); + const rejected = ACP_INPUT_LINES(lines).filter((l) => l.includes("rejected:")); + assert.equal(rejected.length, 1); + assert.ok(rejected[0]!.includes("preview="), `missing preview in: ${rejected[0]}`); + // The exact offending bytes are on the line — no more guessing from retries. + assert.ok(rejected[0]!.includes("m00150 => m00168 topic")); +}); + +test("#6071 preview is bounded: a long offending entry is truncated, not dumped", () => { + const header = `m00150 => m00168 topic\n${"x".repeat(800)}`; + const { lines } = withLogCapture(() => parseCompressInput({ content: [header] })); + const rejected = ACP_INPUT_LINES(lines).filter((l) => l.includes("rejected:")); + assert.equal(rejected.length, 1); + assert.ok(rejected[0]!.includes("preview=")); + assert.ok(!rejected[0]!.includes("x".repeat(200)), "preview must not carry the full payload"); +}); + +test("#6071 rejected OBJECT entry logs no preview (nothing string-shaped to show)", () => { + // Summary-less object entry: rejected for missing summary, but there is no + // raw text whose shape matters — keys+reasons already name it. + const { lines } = withLogCapture(() => + parseCompressInput({ content: [{ startId: "m00001", endId: "m00003" }] }), + ); + const rejected = ACP_INPUT_LINES(lines).filter((l) => l.includes("rejected:")); + assert.equal(rejected.length, 1); + assert.ok(!rejected[0]!.includes("preview="), `unexpected preview: ${rejected[0]}`); +}); From 6d246c0db85e07ca5ec75065a97c7d4bcb1bd51a Mon Sep 17 00:00:00 2001 From: ci-check Date: Tue, 10 Nov 2026 10:59:50 +0800 Subject: [PATCH 3/3] chore: sync acp-kernel 0.0.192 into kernel and root lockfiles (#6071) Review fix for the red lock-sync lane on this PR head: the kernel version bump commit updated kernel/package.json alone, while the established bump pattern (e.g. 5ac2983c5c for 0.0.191) also moves the recorded version in kernel/package-lock.json (top-level + packages[""]) and the root package-lock.json packages["kernel"] entry. Regenerated with npm install --package-lock-only in both trees; diff is version lines only. --- kernel/package-lock.json | 4 ++-- package-lock.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kernel/package-lock.json b/kernel/package-lock.json index 5439a5aa59..faf2a36220 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.191", + "version": "0.0.192", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.191", + "version": "0.0.192", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/package-lock.json b/package-lock.json index 9a3d581f1c..206ead55ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.191", + "version": "0.0.192", "dev": true, "license": "MIT", "devDependencies": {