diff --git a/src/hide-consumed.ts b/src/hide-consumed.ts index 2456ddb..1d75626 100644 --- a/src/hide-consumed.ts +++ b/src/hide-consumed.ts @@ -19,28 +19,92 @@ function rangeKey(startRef: string, endRef: string): string { return `${startRef}::${endRef}`; } -function rewriteCompressText(text: string | undefined, liveKeys: Set): string | null { +// Adapters (pi) persist the rendered ref tag in front of the tool-call text, +// so the JSON args no longer start at index 0. Locate the first "{" instead of +// parsing the raw text — the prefix is preserved on output. +function parseCallText(text: string | undefined): { prefix: string; obj: Record; content: unknown[]; contentWasString: boolean } | null { + const raw = text ?? ""; + const start = raw.indexOf("{"); + if (start < 0) return null; let parsed: unknown; try { - parsed = JSON.parse(text ?? ""); + parsed = JSON.parse(raw.slice(start)); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; - const obj = parsed as { content?: unknown }; - const content = obj.content; - if (!Array.isArray(content) || content.length === 0) return null; + const obj = parsed as Record; + let content: unknown[] | null = null; + let contentWasString = false; + if (Array.isArray(obj.content)) { + content = obj.content; + } else if (typeof obj.content === "string") { + // 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). + contentWasString = true; + try { + const inner: unknown = JSON.parse(obj.content); + if (Array.isArray(inner)) content = inner; + } catch { + content = null; + } + } + if (!content || content.length === 0) return null; + return { prefix: raw.slice(0, start), obj, content, contentWasString }; +} + +function rewriteCompressText(text: string | undefined, liveKeys: Set): string | null { + const parsed = parseCallText(text); + if (!parsed) return null; + const { prefix, obj, content, contentWasString } = parsed; const kept = content.filter((entry): entry is Record => { if (!entry || typeof entry !== "object") return false; - const s = typeof entry.startId === "string" ? entry.startId : typeof entry.messageId === "string" ? entry.messageId : ""; - const e = typeof entry.endId === "string" ? entry.endId : typeof entry.messageId === "string" ? entry.messageId : ""; - return liveKeys.has(rangeKey(s, e)); + const e = entry as Record; + const s = typeof e.startId === "string" ? e.startId : typeof e.messageId === "string" ? e.messageId : ""; + const end = typeof e.endId === "string" ? e.endId : typeof e.messageId === "string" ? e.messageId : ""; + return liveKeys.has(rangeKey(s, end)); }); - if (kept.length === content.length || kept.length === 0) return null; + if (kept.length === 0) return null; - return JSON.stringify({ ...obj, content: kept }); + return prefix + serializeCompacted(obj, kept, contentWasString).text; +} + +// Live compress-call args duplicate every range's full summary text while the +// rendered acp_summary message already carries it — on long sessions the +// duplication alone measured ~22K tokens (billion-context-pi #336). Keep a +// leading stub for recall; the block remains the durable record. +const SUMMARY_STUB_CHARS = 200; + +function compactEntry(entry: unknown): unknown { + if (!entry || typeof entry !== "object") return entry; + const e = entry as Record; + if (typeof e.summary !== "string" || e.summary.length <= SUMMARY_STUB_CHARS) return entry; + return { ...e, summary: `${e.summary.slice(0, SUMMARY_STUB_CHARS - 1)}…` }; +} + +function serializeCompacted(obj: Record, content: unknown[], contentWasString: boolean): { text: string; changed: boolean } { + let changed = false; + const compacted = content.map((entry) => { + const out = compactEntry(entry); + if (out !== entry) changed = true; + return out; + }); + // Preserve the original shape: a stringified content array stays a string + // so downstream text comparisons and replays are unaffected. + const outContent = contentWasString ? JSON.stringify(compacted) : compacted; + return { text: JSON.stringify({ ...obj, content: outContent }), changed }; +} + +function compactCompressText(text: string | undefined): string | null { + const parsed = parseCallText(text); + if (!parsed) return null; + const { prefix, obj, content, contentWasString } = parsed; + const { text: out, changed } = serializeCompacted(obj, content, contentWasString); + return changed ? prefix + out : null; } export function hideConsumedCompressCalls( @@ -124,6 +188,11 @@ export function hideConsumedCompressCalls( continue; } } + const compacted = compactCompressText(message.text); + if (compacted !== null) { + result.push({ ...message, text: compacted }); + continue; + } } result.push(message); } diff --git a/tests/hide-consumed-stub.test.ts b/tests/hide-consumed-stub.test.ts new file mode 100644 index 0000000..2b233e2 --- /dev/null +++ b/tests/hide-consumed-stub.test.ts @@ -0,0 +1,167 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { hideConsumedCompressCalls } from "../src/hide-consumed.js"; +import { renderVisibleRefs } from "../src/render-refs.js"; +import { createInitialState } from "../src/state.js"; +import type { CompressionBlock, CompressionState, CoreMessage } from "../src/types.js"; + +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 callWith(text: string, toolCallId = "call1"): CoreMessage { + return { id: "mc1", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId, text }; +} + +function liveState(summary: string, startRef: string, endRef: string): CompressionState { + const state = createInitialState(); + state.blocks.push(block({ blockId: "b1", compressCallId: "call1", summary, startRef, endRef })); + return state; +} + +interface StubEntry { + startId?: string; + summary?: string; + topic?: string; +} + +function parsedContent(text: string | undefined): StubEntry[] { + return (JSON.parse(text ?? "") as { content: StubEntry[] }).content; +} + +test("fully-live compress call args get their long summaries stubbed", () => { + const longSummary = "x".repeat(5000); + const callText = JSON.stringify({ + content: [{ startId: "m00001", endId: "m00050", topic: "T", summary: longSummary }], + }); + const state = liveState(longSummary, "m00001", "m00050"); + + const { messages } = hideConsumedCompressCalls(state, [callWith(callText)]); + + assert.equal(messages.length, 1); + const content = parsedContent(messages[0]!.text); + assert.equal(content.length, 1); + assert.equal(content[0]!.summary!.length, 200); + assert.ok(content[0]!.summary!.endsWith("…")); + assert.ok(content[0]!.summary!.startsWith("xxxx")); + assert.equal(content[0]!.topic, "T"); + assert.equal(content[0]!.startId, "m00001"); +}); + +test("short summaries are left byte-identical; kept orphans with long summaries are stubbed", () => { + const shortText = JSON.stringify({ + content: [{ startId: "m00001", endId: "m00050", summary: "short" }], + }); + const state = liveState("short", "m00001", "m00050"); + + const { messages } = hideConsumedCompressCalls(state, [callWith(shortText)]); + assert.equal(messages[0]!.text, shortText); + + const orphanText = JSON.stringify({ + content: [{ startId: "m00001", endId: "m00050", summary: "y".repeat(4000) }], + }); + const { messages: orphanOut } = hideConsumedCompressCalls(createInitialState(), [callWith(orphanText, "orphan")]); + assert.equal(orphanOut.length, 1); + assert.equal(parsedContent(orphanOut[0]!.text)[0]!.summary!.length, 200); +}); + +test("tag-prefixed compress call text is still parsed and stubbed (ref tag precedes the JSON)", () => { + const longSummary = "z".repeat(3000); + const state = liveState(longSummary, "m00001", "m00050"); + state.messageRefs.byRaw["mc1"] = "m00010"; + state.messageRefs.byRef["m00010"] = "mc1"; + const callText = JSON.stringify({ + content: [{ startId: "m00001", endId: "m00050", summary: longSummary }], + }); + const tagged = renderVisibleRefs([callWith(callText)], state)[0]!; + assert.ok(tagged.text!.startsWith(" { + const state: CompressionState = { + ...createInitialState(), + blocks: [ + block({ blockId: "b5", compressCallId: "call-batch", active: true, startRef: "m5", endRef: "m6" }), + block({ blockId: "b8", compressCallId: "call-batch", active: false, startRef: "m8", endRef: "m9" }), + ], + }; + state.messageRefs.byRaw["mc"] = "m00010"; + state.messageRefs.byRef["m00010"] = "mc"; + const call: CoreMessage = { + id: "mc", + role: "assistant", + contentType: "tool-call", + toolName: "compress", + toolCallId: "call-batch", + text: JSON.stringify({ + content: [ + { startId: "m5", endId: "m6", summary: "live entry summary" }, + { startId: "m8", endId: "m9", summary: "c".repeat(300) }, + ], + }), + }; + const tagged = renderVisibleRefs([call], state)[0]!; + + const { messages } = hideConsumedCompressCalls(state, [tagged]); + const kept = messages.find((m) => m.toolCallId === "call-batch")!; + const text = kept.text!; + assert.ok(text.startsWith(" { + const state: CompressionState = { + ...createInitialState(), + blocks: [ + block({ blockId: "b1", compressCallId: "call-str", active: true, startRef: "m1", endRef: "m2" }), + block({ blockId: "b2", compressCallId: "call-str", active: false, startRef: "m3", endRef: "m4" }), + ], + }; + const inner = JSON.stringify([ + { startId: "m1", endId: "m2", summary: "x".repeat(300) }, + { startId: "m3", endId: "m4", summary: "y".repeat(300) }, + ]); + const messages: CoreMessage[] = [ + { + id: "m5", + role: "assistant", + contentType: "tool-call", + toolName: "compress", + toolCallId: "call-str", + text: JSON.stringify({ content: inner }), + }, + ]; + const result = hideConsumedCompressCalls(state, messages); + const kept = result.messages.find((m) => m.toolCallId === "call-str"); + assert.ok(kept, "live call kept"); + const parsed = JSON.parse(kept!.text!.replace(/^[^{]*/, "")) as { content: unknown }; + assert.equal(typeof parsed.content, "string", "string shape preserved"); + const entries = JSON.parse(parsed.content as string) as { startId: string; summary: string }[]; + assert.equal(entries.length, 1); + assert.equal(entries[0]!.startId, "m1"); + assert.ok(entries[0]!.summary.length <= 200, "summary stubbed"); + assert.ok(entries[0]!.summary.endsWith("…")); +});