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
8 changes: 4 additions & 4 deletions 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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"devDependencies": {
"@types/node": "^22.0.0",
"@types/node-forge": "^1.3.14",
"acp-kernel": "0.0.32",
"acp-kernel": "file:../acp-kernel/acp-kernel-0.0.33.tgz",
"fzstd": "0.1.1",
"node-forge": "^1.4.0",
"tar": "^7.5.22",
Expand Down
37 changes: 34 additions & 3 deletions src/compress-tool.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defaultPrompts, type Prompts } from "acp-kernel";
import { defaultPrompts, salvageParseRanges, type Prompts } from "acp-kernel";
import { log as loggerLog } from "./logger.js";

export const COMPRESS_TOOL_NAME = "compress";
Expand Down Expand Up @@ -52,6 +52,12 @@ export type ParsedRange = {
};

export function parseCompressInput(input: unknown, callId?: string): ParsedRange[] {
if (typeof input === "string") {
// Raw arguments string (text-protocol triggers and stream tails hand
// us the unparsed arguments). Route through the kernel's lenient
// parser instead of strict JSON.parse — see omp#121.
return parseCompressInputString(input, callId);
}
if (!input || typeof input !== "object") {
loggerLog("warn", `[acp-compress-input] rejected: not object (${typeof input})`);
return [];
Expand All @@ -63,8 +69,9 @@ export function parseCompressInput(input: unknown, callId?: string): ParsedRange
try {
content = JSON.parse(content);
} catch {
loggerLog("warn", `[acp-compress-input] content is a string but not valid JSON; parsed 0 valid ranges`);
return [];
// Not valid JSON — try the kernel salvage ladder before giving up
// (truncation/repairs may still recover complete entries).
return parseCompressInputString(JSON.stringify(input), callId);
}
}
const single = toRange(obj);
Expand All @@ -82,6 +89,30 @@ export function parseCompressInput(input: unknown, callId?: string): ParsedRange
return ranges;
}

/** Lenient string path: kernel salvageParseRanges (5-layer ladder) with
* evidence logging — the old code discarded raw args on parse failure, which
* made the ~50% weak-model arg failure class undiagnosable (omp#121). */
export function parseCompressInputString(raw: string, callId?: string): ParsedRange[] {
const res = salvageParseRanges(raw);
if (res.layer !== "json") {
loggerLog(
"warn",
`[acp-compress-input] lenient parse layer=${res.layer}: ${res.note}. ranges=${res.ranges.length}` +
(res.ranges.length === 0
? ` raw[:800]=${raw.slice(0, 800).replace(/\n/g, "\\n")} (len=${raw.length})`
: ""),
);
}
const out: ParsedRange[] = res.ranges.map((r: { startRef: string; endRef: string; summary: string; topic?: string }) => ({
startRef: r.startRef,
endRef: r.endRef,
summary: r.summary,
...(r.topic ? { topic: r.topic } : {}),
}));
if (callId) for (const r of out) r.compressCallId = callId;
return out;
}

function toRange(r: Record<string, unknown>): ParsedRange | null {
const startRef = pick(r, "startId", "startRef");
const endRef = pick(r, "endId", "endRef");
Expand Down
40 changes: 40 additions & 0 deletions tests/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,46 @@ test("parseCompressInput returns empty for malformed input", () => {
assert.deepEqual(parseCompressInput({ content: [{ startId: "m1" }] }), []);
});

test("parseCompressInput salvages raw truncated JSON args string (omp#121)", () => {
// Weak/local model emitted a truncated content array — strict parse fails,
// array-prefix salvage must recover the 2 complete entries.
const raw =
'{"content":[{"startId":"m00010","endId":"m00020","summary":"first"},{"startId":"m00030","endId":"m00040","summary":"secon';
const parsed = parseCompressInput(raw, "call-1");
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.startRef, "m00010");
assert.equal(parsed[0]?.endRef, "m00020");
assert.equal(parsed[0]?.summary, "first");
assert.equal(parsed[0]?.compressCallId, "call-1");
});

test("parseCompressInput repairs trailing commas and raw newlines", () => {
const raw = '{\n "content": [\n {"startId":"m00001","endId":"m00002","summary":"line1\\nline2"},\n ],\n}';
const parsed = parseCompressInput(raw);
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.summary, "line1\nline2");
});

test("parseCompressInput extracts fields from prose-shaped args", () => {
// kernel gates field-regex summaries at >=50 chars (anti-garbage); use a realistic one
const long =
"Discussed the auth refactor: token refresh moved out of the request path into a background worker, plus rotation on privilege change.";
const raw = `compress from m00150 to m00220 with summary "${long}"`;
const parsed = parseCompressInput(raw);
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.startRef, "m00150");
assert.equal(parsed[0]?.endRef, "m00220");
});

test("parseCompressInputString salvages JSON-string content that is itself broken", () => {
// content was double-encoded then truncated mid-way
const input = { content: '[{"startId":"m00005","endId":"m00006","summary":"ok"},{"startId":"m0' };
const parsed = parseCompressInput(input);
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.startRef, "m00005");
assert.equal(parsed[0]?.summary, "ok");
});

test("parseCompressInput accepts JSON-string content (non-strict providers stringify arrays)", () => {
const parsed = parseCompressInput({
content: JSON.stringify([
Expand Down
Loading