diff --git a/kernel/package-lock.json b/kernel/package-lock.json index 367841bc5a..5b0bd84cc3 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.185", + "version": "0.0.186", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.185", + "version": "0.0.186", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/kernel/package.json b/kernel/package.json index ce8eecf8e6..2d3eb6ee97 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.185", + "version": "0.0.186", "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/hide-consumed.ts b/kernel/src/hide-consumed.ts index fd62ef7585..cd9e1c3706 100644 --- a/kernel/src/hide-consumed.ts +++ b/kernel/src/hide-consumed.ts @@ -1,6 +1,7 @@ import { clampPrefix } from "./truncate.js"; import { BLOCKED_REF } from "./refs.js"; import { COMPRESS_TOOL_NAME } from "./compress-tools.js"; +import { parseLineContent, type CompressLineContentEntry } from "./parse-compress-input.js"; import type { CompressionState, CoreMessage } from "./types.js"; // Orphaned compress calls (no matching block — failed attempts, or historical @@ -104,6 +105,9 @@ interface ParsedCallText { obj: Record; content: unknown[]; contentWasString: boolean; + /** Non-null iff `content` was a plain-text LINE-FORM string (#3975); the + * object pipeline (`content`) is empty then. */ + lineEntries: CompressLineContentEntry[] | null; } // Adapters (pi) persist the rendered ref tag in front of the tool-call text, @@ -122,6 +126,7 @@ function parseCallText(text: string | undefined): ParsedCallText | null { if (!parsed || typeof parsed !== "object") return null; const obj = parsed as Record; let content: unknown[] | null = null; + let lineEntries: CompressLineContentEntry[] | null = null; let contentWasString = false; if (Array.isArray(obj.content)) { content = obj.content; @@ -129,17 +134,26 @@ function parseCallText(text: string | undefined): ParsedCallText | null { // Non-strict-tool providers (qwen etc.) sometimes stringify the content // array inside the JSON args; the compress tool accepts it, so the // rewrite must too. Measured: ALL 52 calls in the billion-context-pi - // #336 storm session used this form (#230). + // #336 storm session used this form (#230). When the inner value is NOT a + // JSON array, the kernel's own parser falls through to the plain-text + // LINE FORM (first line "m00100–m00120 topic", remaining lines the + // summary) — the cleanup must resolve ranges from that shape too, or such + // calls mis-resolve as orphans and keep their full summary text forever + // (#3975). parseLineContent mirrors the tool's acceptance ladder exactly. contentWasString = true; try { const inner: unknown = JSON.parse(obj.content); - if (Array.isArray(inner)) content = inner; + if (Array.isArray(inner)) { + content = inner; + } else { + lineEntries = parseLineContent(obj.content); + } } catch { - content = null; + lineEntries = parseLineContent(obj.content); } } - if (!content || content.length === 0) return null; - return { prefix: raw.slice(0, start), obj, content, contentWasString }; + if ((!content || content.length === 0) && !lineEntries) return null; + return { prefix: raw.slice(0, start), obj, content: content ?? [], contentWasString, lineEntries }; } // Range keys a compress call's args quote, using the same startId/endId @@ -149,6 +163,12 @@ function quotedRangeKeys(text: string | undefined): Set { const parsed = parseCallText(text); if (!parsed) return new Set(); const out = new Set(); + if (parsed.lineEntries !== null) { + for (const entry of parsed.lineEntries) { + if (entry.startRef && entry.endRef) out.add(rangeKey(entry.startRef, entry.endRef)); + } + return out; + } for (const entry of parsed.content) { if (!entry || typeof entry !== "object") continue; const e = entry as Record; @@ -175,6 +195,20 @@ function rewriteCompressText( ): string | null { const parsed = parseCallText(text); if (!parsed) return null; + if (parsed.lineEntries !== null) { + const kept = parsed.lineEntries.filter( + (entry) => + entry.startRef !== undefined && + entry.endRef !== undefined && + liveKeys.has(rangeKey(entry.startRef, entry.endRef)), + ); + if (kept.length === 0) return null; + const { text: rebuilt } = serializeLineCompacted(kept); + // Re-emit the FULL args object with the line-form string in the content + // slot — the string shape is preserved (#230 convention for stringified + // content). + return parsed.prefix + JSON.stringify({ ...parsed.obj, content: rebuilt }); + } const { prefix, obj, content, contentWasString } = parsed; const kept = content.filter((entry): entry is Record => { @@ -206,6 +240,10 @@ function rewriteCompressText( // leading stub for recall; the block remains the durable record. const SUMMARY_STUB_CHARS = 200; +function stubSummary(summary: string): string { + return `${clampPrefix(summary, SUMMARY_STUB_CHARS - 1)}…`; +} + function compactEntry(entry: unknown): unknown { if (!entry || typeof entry !== "object") return entry; const e = entry as Record; @@ -213,10 +251,25 @@ function compactEntry(entry: unknown): unknown { return entry; return { ...e, - summary: `${clampPrefix(e.summary, SUMMARY_STUB_CHARS - 1)}…`, + summary: stubSummary(e.summary), }; } +// Line-form compaction (#3975): rebuild each kept entry as "
\n" — headers stay verbatim, short or unparsable entries byte-identical. +function serializeLineCompacted( + entries: CompressLineContentEntry[], +): { text: string; changed: boolean } { + let changed = false; + const out = entries.map((entry) => { + if (entry.startRef === undefined || entry.summary.length <= SUMMARY_STUB_CHARS) + return entry.source; + changed = true; + return `${entry.header}\n${stubSummary(entry.summary)}`; + }); + return { text: out.join("\n"), changed }; +} + function serializeCompacted( obj: Record, content: unknown[], @@ -237,6 +290,12 @@ function serializeCompacted( function compactCompressText(text: string | undefined): string | null { const parsed = parseCallText(text); if (!parsed) return null; + if (parsed.lineEntries !== null) { + const { text: rebuilt, changed } = serializeLineCompacted(parsed.lineEntries); + return changed + ? parsed.prefix + JSON.stringify({ ...parsed.obj, content: rebuilt }) + : null; + } const { prefix, obj, content, contentWasString } = parsed; const { text: out, changed } = serializeCompacted( obj, diff --git a/kernel/src/parse-compress-input.ts b/kernel/src/parse-compress-input.ts index 7292a44ff1..4c31e6aa9c 100644 --- a/kernel/src/parse-compress-input.ts +++ b/kernel/src/parse-compress-input.ts @@ -525,6 +525,52 @@ function parseLineEntry( return { range }; } +/** One entry of a plain-text (line-form) compress `content` string. Entries + * whose header carries no parsable refs keep only `source`/`header` so a + * consumer can re-emit them verbatim; they never claim a range. */ +export interface CompressLineContentEntry { + /** Normalized start ref (m-refs padded to 5 digits, lowercased). */ + startRef?: string; + endRef?: string; + topic?: string; + /** Summary body verbatim (empty when the entry is header-only or unparsable). */ + summary: string; + /** Original (trimmed, residue-stripped) entry text for faithful re-layout. */ + source: string; + /** Trimmed first line of `source` — the refs/topic header. */ + header: string; +} + +/** Parse a plain-text (line-form) compress `content` string into structured + * entries using the SAME ladder the compress tool accepts when `content` is + * not a JSON array (#3975): stripJsonWrapperResidue → splitLineEntries → + * per-entry parseLineEntry. hide-consumed must resolve ranges from exactly + * what the tool accepts — a second, hand-rolled reader would drift. The + * single-source input is already split at ref headers by splitLineEntries, + * so coalesceLineEntries (array-of-siblings pairing) is not needed here. */ +export function parseLineContent(content: string): CompressLineContentEntry[] { + const bare = stripJsonWrapperResidue(content); + return splitLineEntries(bare).map((part): CompressLineContentEntry => { + const s = typeof part === "string" ? part : ""; + const nl = s.indexOf("\n"); + const base: CompressLineContentEntry = { + header: (nl === -1 ? s : s.slice(0, nl)).trim(), + summary: "", + source: s, + }; + if (typeof part !== "string") return base; + const outcome = parseLineEntry(part, undefined); + if ("reason" in outcome) return base; + return { + ...base, + startRef: outcome.range.startRef, + endRef: outcome.range.endRef, + summary: outcome.range.summary, + topic: outcome.range.topic, + }; + }); +} + /** Display/retrieval label for line-form entries without an explicit topic: * the summary's first markdown heading, else its first non-empty line, * truncated. The topic is metadata (panels, T2 source headers, search) — diff --git a/kernel/tests/issue3975-hide-consumed-lineform.test.ts b/kernel/tests/issue3975-hide-consumed-lineform.test.ts new file mode 100644 index 0000000000..3e3bf7561a --- /dev/null +++ b/kernel/tests/issue3975-hide-consumed-lineform.test.ts @@ -0,0 +1,316 @@ +// #3975: the compress tool accepts a PLAIN-TEXT line-form `content` string +// (first line "m00100–m00120 topic", remaining lines the summary), but +// hide-consumed's parseCallText only understood object arrays and +// stringified JSON arrays. Plain-text calls therefore mis-resolved as +// orphans: on quiet turns they rode along with their FULL summary text +// (never stubbed), duplicating the acp_summary carrier, and on fold turns +// they were swept as failure residue although they ARE the folded block's +// summary carrier (#1567). The cleanup now reuses the kernel's own line-form +// parser (parseLineContent) so both shapes get identical treatment. These +// tests pin: stubbing of live plain-text carriers (MCP synthetic-id + +// bare-name id-matched lanes), range-based fate resolution incl. fold-sweep +// exemption (#3769), partial-range degradation, all-dead hiding, quiet-turn +// orphan stubbing, and the pre-existing shapes (object array, stringified +// array, unparseable garbage) staying byte-stable where they already were. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { hideConsumedCompressCalls } from "../src/hide-consumed.js"; +import { parseLineContent } from "../src/parse-compress-input.js"; +import { createInitialState } from "../src/state.js"; +import type { + CompressionBlock, + CoreMessage, +} from "../src/types.js"; + +// Host-injected predicate mirroring src/config.ts isMcpBiliCompress (#2970). +const isMcp = (name: string): boolean => + name === "compress" || name === "mcp__bili__compress"; + +function block(overrides: Partial): CompressionBlock { + return { + blockId: "b0", + runId: "r0", + tier: 1, + summary: "summary", + directMessageIds: [], + effectiveMessageIds: [], + directBlockIds: [], + createdAt: 1000, + survivedCount: 0, + generation: "young", + active: true, + ...overrides, + }; +} + +function pair( + toolName: string, + toolCallId: string, + args: Record, +): [CoreMessage, CoreMessage] { + const call: CoreMessage = { + id: `call-${toolCallId}`, + role: "assistant", + contentType: "tool-call", + toolName, + toolCallId, + text: JSON.stringify(args), + }; + const result: CoreMessage = { + id: `result-${toolCallId}`, + role: "tool", + contentType: "tool-result", + toolCallId, + text: "ok", + }; + return [call, result]; +} + +/** Extract the parsed args object from a (possibly rewritten) call text. */ +function argsOf(text: string | undefined): Record | null { + if (!text) return null; + const start = text.indexOf("{"); + if (start < 0) return null; + try { + const v: unknown = JSON.parse(text.slice(start)); + return v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : null; + } catch { + return null; + } +} + +test("parseLineContent: normalizes refs exactly like the tool's own parser", () => { + const entries = parseLineContent("m100–m120 task progress\nfull body"); + assert.equal(entries.length, 1); + assert.equal(entries[0]!.startRef, "m00100", "m-refs padded to 5 digits"); + assert.equal(entries[0]!.endRef, "m00120"); + assert.equal(entries[0]!.summary, "full body"); + assert.equal(entries[0]!.header, "m100–m120 task progress"); + + const multi = parseLineContent( + "b2–b5 first\nbody one\nm00200~m00300 second topic\n## Head\nbody two", + ); + assert.equal(multi.length, 2); + assert.deepEqual( + [multi[0]!.startRef, multi[0]!.endRef], + ["b2", "b5"], + "block refs stay lowercase, unpadded", + ); + assert.deepEqual( + [multi[1]!.startRef, multi[1]!.endRef], + ["m00200", "m00300"], + ); + assert.equal(multi[1]!.topic, "second topic"); +}); + +test("parseLineContent: refless garbage stays verbatim and claims no range", () => { + const entries = parseLineContent("hello world"); + assert.equal(entries.length, 1); + assert.equal(entries[0]!.startRef, undefined); + assert.equal(entries[0]!.source, "hello world"); +}); + +test("#3975 L1: plain-text active carrier (MCP synthetic-id) is stubbed like object arrays", () => { + const state = createInitialState(); + state.blocks.push( + block({ + blockId: "b-live", + compressCallId: "plugin_live", + active: true, + startRef: "m00001", + endRef: "m00050", + }), + ); + const longSummary = "x".repeat(500); + const [call] = pair( + "mcp__bili__compress", + "tool_a", + { content: `m00001–m00050 Task progress\n${longSummary}` }, + ); + + const r = hideConsumedCompressCalls(state, [call], isMcp); + const kept = r.messages.find((m) => m.toolCallId === "tool_a")!; + const args = argsOf(kept.text!)!; + assert.equal(typeof args.content, "string", "line-form shape preserved"); + const lines = (args.content as string).split("\n"); + assert.equal(lines[0], "m00001–m00050 Task progress", "header verbatim"); + assert.equal(lines[1]!.length, 200, "long summary stubbed"); + assert.ok(lines[1]!.endsWith("…")); +}); + +test("#3975 L2: plain-text call resolves by range — fold sweep spares it, sweeps true orphans (#3769)", () => { + const state = createInitialState(); + state.blocks.push( + block({ + blockId: "b-live", + compressCallId: "plugin_live", + active: true, + startRef: "m00001", + endRef: "m00050", + }), + ); + // refs: p1=100 c-tool_a=101 r-tool_a=102 p2=103 c-f1=104 r-f1=105 + const msgs: CoreMessage[] = [ + { id: "p1", role: "user", contentType: "text", text: "early" }, + ...pair("mcp__bili__compress", "tool_a", { content: "m00001–m00050 t\nsummary" }), + { id: "p2", role: "user", contentType: "text", text: "mid" }, + ...pair("mcp__bili__compress", "f1", { content: [{ startId: "mX1", endId: "mY1", summary: "s" }] }), + ]; + state.messageRefs.byRaw["p1"] = "m00100"; + state.messageRefs.byRaw["call-tool_a"] = "m00101"; + state.messageRefs.byRaw["result-tool_a"] = "m00102"; + state.messageRefs.byRaw["p2"] = "m00103"; + state.messageRefs.byRaw["call-f1"] = "m00104"; + state.messageRefs.byRaw["result-f1"] = "m00105"; + // A fold whose earliest start ref covers everything in this history. + state.pendingOrphanSweepMinRef = 100; + + const r = hideConsumedCompressCalls(state, msgs, isMcp); + const kept = new Set(r.messages.map((m) => m.toolCallId)); + assert.ok(kept.has("tool_a"), "plain-text carrier quoting an ACTIVE range is fate=active, not an orphan"); + assert.deepEqual(r.newlySweptCallIds, ["f1"], "the true orphan at/after the fold is swept"); + assert.equal(r.hidden, 2, "swept orphan pair removed (call + result)"); + assert.ok(!kept.has("f1")); +}); + +test("#3975: partial range invalidation — live entries kept+stubbed, dead entries dropped", () => { + const state = createInitialState(); + state.blocks.push( + block({ blockId: "b-live", compressCallId: "plugin_live", active: true, startRef: "m00001", endRef: "m00050" }), + block({ blockId: "b-dead", compressCallId: "plugin_dead", active: false, startRef: "m00100", endRef: "m00150" }), + ); + const [call] = pair("mcp__bili__compress", "tool_mix", { + content: `m00001–m00050 Live topic\n${"a".repeat(500)}\nm00100–m00150 Dead topic\n${"b".repeat(500)}`, + }); + + const r = hideConsumedCompressCalls(state, [call], isMcp); + const kept = r.messages.find((m) => m.toolCallId === "tool_mix")!; + const out = argsOf(kept.text!)!.content as string; + assert.ok(out.includes("m00001"), "live range retained"); + assert.ok(!out.includes("m00100"), "dead range dropped from rendered args"); + const lines = out.split("\n"); + assert.equal(lines.length, 2, "one entry: header + stub"); + assert.equal(lines[1]!.length, 200, "retained summary stubbed"); +}); + +test("#3975: all ranges dead -> inactive carrier hidden entirely", () => { + const state = createInitialState(); + state.blocks.push( + block({ blockId: "b-dead", compressCallId: "plugin_dead", active: false, startRef: "m00001", endRef: "m00050" }), + ); + const msgs = pair("mcp__bili__compress", "tool_dead", { content: "m00001–m00050 t\nsummary" }); + + const r = hideConsumedCompressCalls(state, msgs, isMcp); + assert.equal(r.hidden, 2, "call + result removed"); + assert.ok(!r.messages.some((m) => m.toolCallId === "tool_dead")); +}); + +test("#3975: failed-fold orphans (plain text) stay visible on a quiet turn but their summaries are stubbed", () => { + const state = createInitialState(); + const msgs: CoreMessage[] = []; + // Valid digit refs naming messages that exist nowhere — the calls are orphans + // by range, not by malformed headers. + const ranges: Record = { f1: "m90001–m90051", f2: "m90002–m90052", f3: "m90003–m90053" }; + for (const id of ["f1", "f2", "f3"]) { + msgs.push( + ...pair("mcp__bili__compress", id, { + content: `${ranges[id]} failed attempt\n${id.repeat(400)}`, + }), + ); + } + + const r = hideConsumedCompressCalls(state, msgs, isMcp); + assert.equal(r.hidden, 0, "quiet turn sweeps nothing (#3769)"); + for (const id of ["f1", "f2", "f3"]) { + const kept = r.messages.find((m) => m.toolCallId === id)!; + const out = argsOf(kept.text!)!.content as string; + const summaryLine = out.split("\n")[1]!; + assert.equal(summaryLine.length, 200, `${id}: orphan summary stubbed (pre-fix: full text)`); + assert.ok(summaryLine.endsWith("…")); + } +}); + +test("#3975: bare-name proxy lane — id-matched plain-text call is stubbed too", () => { + const state = createInitialState(); + state.blocks.push( + block({ blockId: "b-live", compressCallId: "call_1", active: true, startRef: "m00001", endRef: "m00050" }), + ); + const [call] = pair("compress", "call_1", { + content: `m00001–m00050 proxy lane\n${"p".repeat(500)}`, + }); + + const r = hideConsumedCompressCalls(state, [call]); + const kept = r.messages.find((m) => m.toolCallId === "call_1")!; + const out = argsOf(kept.text!)!.content as string; + assert.equal(out.split("\n")[1]!.length, 200, "id-matched lane stubs long line-form summaries"); +}); + +test("regression: stringified JSON-array content keeps its shape and stubbing (#230)", () => { + const state = createInitialState(); + state.blocks.push( + block({ blockId: "b-live", compressCallId: "plugin_live", active: true, startRef: "m00001", endRef: "m00050" }), + ); + const inner = JSON.stringify([ + { startId: "m00001", endId: "m00050", summary: "y".repeat(500) }, + ]); + const [call] = pair("mcp__bili__compress", "tool_s", { content: inner }); + + const r = hideConsumedCompressCalls(state, [call], isMcp); + const kept = r.messages.find((m) => m.toolCallId === "tool_s")!; + const args = argsOf(kept.text!)!; + assert.equal(typeof args.content, "string", "stays a string"); + const decoded: unknown = JSON.parse(args.content as string); + assert.ok(Array.isArray(decoded), "still decodes to an array"); + assert.equal((decoded as { summary: string }[])[0]!.summary.length, 200); +}); + +test("regression: refless plain-text garbage stays byte-identical (orphan, nothing to stub)", () => { + const state = createInitialState(); + const text = JSON.stringify({ content: "hello world" }); + const [call, result] = pair("mcp__bili__compress", "tool_g", { content: "hello world" }); + + const r = hideConsumedCompressCalls(state, [call, result], isMcp); + assert.equal(r.hidden, 0); + assert.equal(r.messages[0]!.text, text, "unparsable-for-ranges args untouched"); + assert.equal(r.messages[1]!.text, "ok"); +}); + +test("#3975 review: clamped block range — id-matched call falls back to full stubbing", () => { + // The kernel clamped the fold at an interior protected message (#3953): the + // stored range no longer equals what the call quoted. The live-range rewrite + // keeps nothing for this entry; the compaction fallback must still stub the + // long summary (object-array calls already degrade the same way). + const state = createInitialState(); + state.blocks.push( + block({ blockId: "b-live", compressCallId: "call_1", active: true, startRef: "m00001", endRef: "m00048" }), + ); + const [call] = pair("compress", "call_1", { + content: `m00001–m00050 quoted wider than folded\n${"c".repeat(500)}`, + }); + + const r = hideConsumedCompressCalls(state, [call]); + assert.equal(r.hidden, 0, "id-matched active carrier stays visible"); + const kept = r.messages.find((m) => m.toolCallId === "call_1")!; + const out = argsOf(kept.text!)!.content as string; + assert.ok(out.startsWith("m00001–m00050"), "header retained verbatim"); + assert.equal(out.split("\n")[1]!.length, 200, "rewrite kept nothing -> compact stubs the entry"); +}); + +test("#3975 review: second pass over already-stubbed text is byte-identical (cache-stable fixpoint)", () => { + const mkState = (): CompressionState => { + const s = createInitialState(); + s.blocks.push( + block({ blockId: "b-live", compressCallId: "plugin_live", active: true, startRef: "m00001", endRef: "m00050" }), + ); + return s; + }; + const original = `m00001–m00050 Task progress\n${"x".repeat(500)}`; + const [call] = pair("mcp__bili__compress", "tool_a", { content: original }); + + const once = hideConsumedCompressCalls(mkState(), [call], isMcp).messages[0]!.text!; + assert.notEqual(once, JSON.stringify({ content: original }), "first pass stubs"); + const again = hideConsumedCompressCalls(mkState(), [{ ...call, text: once }], isMcp).messages[0]!.text!; + assert.equal(again, once, "stubbed output is a fixpoint — rendered bytes stable across turns"); +}); diff --git a/package-lock.json b/package-lock.json index b86748e21c..837fbea7d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.185", + "version": "0.0.186", "dev": true, "license": "MIT", "devDependencies": { diff --git a/tests/issue3975-mcp-lineform-endstate.test.ts b/tests/issue3975-mcp-lineform-endstate.test.ts new file mode 100644 index 0000000000..42ed5861bd --- /dev/null +++ b/tests/issue3975-mcp-lineform-endstate.test.ts @@ -0,0 +1,90 @@ +// #3975 end state: after consumed-call cleanup + carrier-aware strip, a +// PLAIN-TEXT MCP compress call must end up exactly like its object-array twin +// — args stubbed to 200 chars while the kernel's acp_summary anchor stays as +// the SOLE complete carrier. Pins issue expectations 2/3 ("every active block +// keeps at least one full summary"; "a stub is not a complete carrier") and +// guards against the tempting wrong fix: widening buildCarrierIndex's tool-name +// match to mcp__bili__compress would strip this anchor and leave only the stub +// (full-text loss). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createInitialState, hideConsumedCompressCalls, type CompressionBlock, type CompressionState, type CoreMessage } from "acp-kernel"; +import type { BiliMessage } from "acp-kernel/wire"; +import { isMcpBiliCompress } from "../src/config.ts"; +import { stripKernelSummaries } from "../src/server.ts"; + +// Single-line on purpose: the line form splits on ref-header lines, so a +// multi-line summary would make per-line assertions ambiguous (the stub keeps +// internal newlines verbatim). +const FULL = "# Task progress " + "detail line that carries real content ".repeat(60); + +function makeState(): CompressionState { + const state = createInitialState(); + const block: CompressionBlock = { + blockId: "b0", + runId: "r0", + tier: 1, + topic: "Task progress", + summary: FULL, + directMessageIds: [], + effectiveMessageIds: [], + directBlockIds: [], + compressedTokens: 0, + createdAt: 1000, + survivedCount: 0, + generation: "young", + active: true, + compressCallId: "plugin_123", + startRef: "m00100", + endRef: "m00120", + }; + state.blocks.push(block); + return state; +} + +function history(contentValue: unknown): CoreMessage[] { + return [ + { id: "acp_summary_b0", role: "system", contentType: "text", text: FULL }, + { id: "tc_c1", role: "assistant", contentType: "tool-call", toolName: "mcp__bili__compress", toolCallId: "c1", text: JSON.stringify({ content: contentValue }) }, + { id: "tr_c1", role: "tool", contentType: "tool-result", toolCallId: "c1", text: "ok" }, + { id: "u1", role: "user", contentType: "text", text: "tail" }, + ]; +} + +function cleanedThenStripped(contentValue: unknown): CoreMessage[] { + const state = makeState(); + const cleaned = hideConsumedCompressCalls(state, history(contentValue), isMcpBiliCompress).messages; + return stripKernelSummaries(cleaned as BiliMessage[], state) as CoreMessage[]; +} + +test("#3975: plain-text MCP call ends stubbed, anchor kept as sole full carrier", () => { + const out = cleanedThenStripped(`m00100–m00120 Task progress\n${FULL}`); + + const call = out.find((m) => m.id === "tc_c1"); + assert.ok(call, "call pair survives (active range)"); + const args: { content: unknown } = JSON.parse(call!.text!.slice(call!.text!.indexOf("{"))); + assert.equal(typeof args.content, "string", "line-form string shape preserved"); + const lines = (args.content as string).split("\n"); + assert.equal(lines[0], "m00100–m00120 Task progress", "header verbatim"); + assert.ok(lines[1]!.length <= 200 && lines[1]!.endsWith("…"), "call-arg summary stubbed"); + assert.ok(!call!.text!.includes(FULL), "no full copy left in rendered args"); + + const anchor = out.find((m) => m.id === "acp_summary_b0"); + assert.ok(anchor, "anchor kept — the stub is NOT a complete carrier"); + assert.equal(anchor!.text, FULL, "sole remaining full summary is byte-complete"); +}); + +test("#3975: object-array MCP call keeps its pre-existing end state (parity pin)", () => { + const out = cleanedThenStripped([{ startId: "m00100", endId: "m00120", topic: "Task progress", summary: FULL }]); + + const call = out.find((m) => m.id === "tc_c1")!; + const args: { content: unknown } = JSON.parse(call.text!.slice(call.text!.indexOf("{"))); + assert.ok(Array.isArray(args.content), "object-array shape preserved"); + const entry = (args.content as { summary: string }[])[0]!; + assert.equal(entry.summary.length, 200, "stubbed exactly like the plain-text lane"); + assert.ok(!call.text!.includes(FULL)); + + const anchor = out.find((m) => m.id === "acp_summary_b0"); + assert.ok(anchor, "anchor kept for object-array calls too (unchanged behavior)"); + assert.equal(anchor!.text, FULL); +});