diff --git a/kernel/package-lock.json b/kernel/package-lock.json index da83f9717..42edfa9cb 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/kernel/package.json b/kernel/package.json index c130a693b..f03fc7ade 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "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 7ebc49c20..cf3212f27 100644 --- a/kernel/src/parse-compress-input.ts +++ b/kernel/src/parse-compress-input.ts @@ -496,8 +496,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 2e548cc83..53a846164 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"); +}); + // --------------------------------------------------------------------------- // stringified single entry (#5949 field) // --------------------------------------------------------------------------- diff --git a/package-lock.json b/package-lock.json index b333eb55f..e2ff6fb48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "dev": true, "license": "MIT", "devDependencies": { diff --git a/src/compress-tool.ts b/src/compress-tool.ts index ccfe2d0de..d662a09c4 100644 --- a/src/compress-tool.ts +++ b/src/compress-tool.ts @@ -599,6 +599,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 @@ -635,7 +654,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 000000000..2ad5d06a3 --- /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]}`); +});