Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions kernel/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion kernel/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 5 additions & 1 deletion kernel/src/parse-compress-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions kernel/tests/parse-compress-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 21 additions & 1 deletion src/compress-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,25 @@ function decodeDoubleEscapedContent(input: unknown): Record<string, unknown> | 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
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions tests/issue6071-compress-reject-preview.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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]}`);
});
Loading