diff --git a/package-lock.json b/package-lock.json index af3363a..6bc3bd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@earendil-works/pi-coding-agent": "0.83.0", "@earendil-works/pi-tui": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.56", + "acp-kernel": "0.0.60", "tsup": "^8.5.1", "tsx": "^4.23.1", "typescript": "^7.0.2" @@ -3200,9 +3200,9 @@ } }, "node_modules/acp-kernel": { - "version": "0.0.56", - "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.56.tgz", - "integrity": "sha512-5bNbzKHmbojWAHxA3dOa7yL1WD+6DSPe+szTLCGYE4k3afUUIz/v6akrGGUfn8tzkckVBno9SfPlT6iVj14D6g==", + "version": "0.0.60", + "resolved": "https://registry.npmjs.org/acp-kernel/-/acp-kernel-0.0.60.tgz", + "integrity": "sha512-AdvQ5hVsupmspjFJrmtD3sWBShKmd2I/ZzglO+0Z2djJI5f1T/4Cp7WDhhl3E7qqft7rExBr4qTxWiOSCj1BFw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index b614e09..97d47a2 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@earendil-works/pi-coding-agent": "0.83.0", "@earendil-works/pi-tui": "0.83.0", "@types/node": "^26.1.2", - "acp-kernel": "0.0.56", + "acp-kernel": "0.0.60", "tsup": "^8.5.1", "tsx": "^4.23.1", "typescript": "^7.0.2" diff --git a/src/messages.ts b/src/messages.ts index 35e9d21..989425d 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -221,6 +221,10 @@ export function coreOutToAgentMessages( ): AgentMessage[] { const out: AgentMessage[] = []; const emittedSplit = new Set(); + const kernelTextByCallId = new Map(); + for (const core of coreOut) { + if (core.toolCallId && core.text) kernelTextByCallId.set(core.toolCallId, core.text); + } for (const core of coreOut) { if (core.id.startsWith("acp_summary_")) continue; @@ -246,16 +250,48 @@ export function coreOutToAgentMessages( .filter((id): id is string => !!id), ); - out.push(reconstructToolCallMessage(original, core, survivingCallIds)); + out.push(reconstructToolCallMessage(original, core, survivingCallIds, kernelTextByCallId)); } return out; } +function compactedArgsFrom(kernelText: string | undefined, originalArgs: unknown): unknown | null { + if (!kernelText) return null; + const start = kernelText.indexOf("{"); + if (start < 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(kernelText.slice(start)); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + if (safeStringify(parsed) === safeStringify(originalArgs)) return null; + return parsed; +} + +function syncToolCallArgs( + blocks: unknown[], + kernelTextFor: (callId: string) => string | undefined, +): unknown[] { + let changed = false; + const out = blocks.map((block) => { + const b = block as { type?: string; id?: string; arguments?: unknown }; + if (b.type !== "toolCall" || !b.id) return block; + const compacted = compactedArgsFrom(kernelTextFor(b.id), b.arguments); + if (compacted === null) return block; + changed = true; + return { ...b, arguments: compacted }; + }); + return changed ? out : blocks; +} + function reconstructToolCallMessage( original: AgentMessage, firstCore: CoreMessage, survivingCallIds: Set, + kernelTextByCallId: Map, ): AgentMessage { const base = original as AnyMessage; const match = firstCore.text ? firstCore.text.match(REF_TAG) : null; @@ -272,7 +308,10 @@ function reconstructToolCallMessage( if (b.type === "toolCall") return survivingCallIds.has(b.id ?? ""); return true; }); - const peeled2 = peelRefTagBlocks(filtered2); + const peeled2 = syncToolCallArgs( + peelRefTagBlocks(filtered2), + (callId) => kernelTextByCallId.get(callId), + ); return { ...(original as object), content: peeled2 } as AgentMessage; } @@ -288,7 +327,10 @@ function reconstructToolCallMessage( return true; }); - const peeled = peelRefTagBlocks(filtered); + const peeled = syncToolCallArgs( + peelRefTagBlocks(filtered), + (callId) => kernelTextByCallId.get(callId), + ); const stableTag = rewriteTagTokens(tag, coreBodyOf(firstCore.text ?? "", tag)); const lastTextIdx = [...peeled].reverse().findIndex((b) => (b as { type?: string }).type === "text"); if (lastTextIdx >= 0) { @@ -309,14 +351,25 @@ function coreBodyOf(coreText: string, tag: string): string { } function patchRefTag(original: AgentMessage, core: CoreMessage): AgentMessage { - const match = core.text ? core.text.match(REF_TAG) : null; - const tag = match ? match[0] : null; - if (!tag) return original; const base = original as AnyMessage; // Skip tag injection for assistant messages — the model sees tags on its own // previous responses and echoes them, causing visible tag fragments in the terminal. // The model can still reference assistant messages by inferring refs from context. - if (base.role === "assistant") return original; + if (base.role === "assistant") { + if (core.contentType === "tool-call" && core.toolCallId) { + const rawBlocks = Array.isArray(base.content) ? base.content : []; + const synced = syncToolCallArgs(rawBlocks, (callId) => + callId === core.toolCallId ? core.text : undefined, + ); + if (synced !== rawBlocks) { + return { ...(original as object), content: synced } as AgentMessage; + } + } + return original; + } + const match = core.text ? core.text.match(REF_TAG) : null; + const tag = match ? match[0] : null; + if (!tag) return original; // Honor kernel body mutations (emergency truncation of large tool-results, // future rewrites): if core.text's body differs from the original text, // rebuild from the kernel body — otherwise truncation never reaches the model. diff --git a/tests/anchor-text-sync.test.ts b/tests/anchor-text-sync.test.ts new file mode 100644 index 0000000..4266878 --- /dev/null +++ b/tests/anchor-text-sync.test.ts @@ -0,0 +1,171 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { coreOutToAgentMessages } from "../src/messages.js"; +import type { CoreMessage } from "acp-kernel"; +import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; + +const LT = "\x3c"; +const GT = "\x3e"; +function acpRef(ref: string, tokens = "2", type = "text"): string { + return LT + 'acp tokens="' + tokens + '" type="' + type + '"' + GT + ref + LT + "/acp" + GT; +} + +function msgEntry(id: string, message: object): SessionMessageEntry { + return { + type: "message", + id, + parentId: null, + timestamp: new Date().toISOString(), + message: message as SessionMessageEntry["message"], + }; +} + +function assistantMeta(): Record { + return { + api: "anthropic", + provider: "anthropic", + model: "m", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + +const LONG_SUMMARY = "S".repeat(600); +const STUB_SUMMARY = "S".repeat(200) + "\u2026"; + +function compressArgs(): { content: unknown[] } { + return { + content: [ + { startId: "m00001", endId: "m00009", summary: LONG_SUMMARY }, + { startId: "m00020", endId: "m00022", summary: "dead range, block distilled away" }, + ], + }; +} + +function kernelStubbedText(): string { + return JSON.stringify({ + content: [{ startId: "m00001", endId: "m00009", summary: STUB_SUMMARY }], + }); +} + +function callsOf(out: unknown): Array<{ type: string; id: string; name: string; arguments: unknown }> { + const m = out as { content: Array<{ type: string; id: string; name: string; arguments: unknown }> }; + return m.content.filter((b) => b.type === "toolCall"); +} + +test("single-call compress anchor: kernel-stubbed text syncs into outbound arguments", () => { + const original = msgEntry("a", { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "compress", arguments: compressArgs() }], + ...assistantMeta(), + }).message; + const originalById = new Map([["a", original]]); + const coreOut: CoreMessage[] = [ + { id: "a", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: kernelStubbedText() }, + ]; + + const out = coreOutToAgentMessages(coreOut, originalById); + const call = callsOf(out[0])[0]!; + const content = (call.arguments as { content: unknown[] }).content; + assert.equal(content.length, 1, "dead range entry dropped"); + assert.equal((content[0] as { summary: string }).summary, STUB_SUMMARY, "stubbed summary synced"); + assert.ok(!JSON.stringify(call.arguments).includes("dead range"), "no dead-range residue"); +}); + +test("string-form content (non-strict providers) is preserved and stubbed through sync", () => { + const stringArgs = { content: JSON.stringify(compressArgs().content) }; + const original = msgEntry("a", { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "compress", arguments: stringArgs }], + ...assistantMeta(), + }).message; + const originalById = new Map([["a", original]]); + const kernelText = JSON.stringify({ content: JSON.stringify([{ startId: "m00001", endId: "m00009", summary: STUB_SUMMARY }]) }); + const coreOut: CoreMessage[] = [ + { id: "a", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: kernelText }, + ]; + + const out = coreOutToAgentMessages(coreOut, originalById); + const args = callsOf(out[0])[0]!.arguments as { content: string }; + assert.equal(typeof args.content, "string", "string shape preserved"); + const inner = JSON.parse(args.content) as Array<{ summary: string }>; + assert.equal(inner.length, 1); + assert.equal(inner[0]!.summary, STUB_SUMMARY); +}); + +test("multi-call message: per-call sync via sub-id cores, untouched call keeps original args", () => { + const bashArgs = { command: "ls" }; + const original = msgEntry("a", { + role: "assistant", + content: [ + { type: "text", text: "Running tools" }, + { type: "toolCall", id: "tcC", name: "compress", arguments: compressArgs() }, + { type: "toolCall", id: "tcB", name: "bash", arguments: bashArgs }, + ], + ...assistantMeta(), + }).message; + const originalById = new Map([["a", original]]); + const coreOut: CoreMessage[] = [ + { id: "a#tcC", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tcC", text: kernelStubbedText() }, + { id: "a#tcB", role: "assistant", contentType: "tool-call", toolName: "bash", toolCallId: "tcB", text: JSON.stringify(bashArgs) }, + ]; + + const out = coreOutToAgentMessages(coreOut, originalById); + const calls = callsOf(out[0]); + assert.equal(calls.length, 2); + const compress = calls.find((c) => c.name === "compress")!; + const bash = calls.find((c) => c.name === "bash")!; + assert.equal((compress.arguments as { content: unknown[] }).content.length, 1, "compress stubbed"); + assert.deepEqual(bash.arguments, bashArgs, "bash untouched"); +}); + +test("no kernel diff: arguments object passes through unchanged (same reference)", () => { + const args = compressArgs(); + const original = msgEntry("a", { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "compress", arguments: args }], + ...assistantMeta(), + }).message; + const originalById = new Map([["a", original]]); + const coreOut: CoreMessage[] = [ + { id: "a", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: JSON.stringify(args) }, + ]; + + const out = coreOutToAgentMessages(coreOut, originalById); + assert.equal(callsOf(out[0])[0]!.arguments, args, "identity preserved when kernel did not rewrite"); +}); + +test("unparseable kernel text keeps original arguments", () => { + const args = compressArgs(); + const original = msgEntry("a", { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "compress", arguments: args }], + ...assistantMeta(), + }).message; + const originalById = new Map([["a", original]]); + const coreOut: CoreMessage[] = [ + { id: "a", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: "no json here" }, + ]; + + const out = coreOutToAgentMessages(coreOut, originalById); + assert.equal(callsOf(out[0])[0]!.arguments, args, "fail-safe: original args kept"); +}); + +test("tag-prefixed kernel text still syncs (first-{ scan, prefix dropped from arguments)", () => { + const original = msgEntry("a", { + role: "assistant", + content: [{ type: "toolCall", id: "tc1", name: "compress", arguments: compressArgs() }], + ...assistantMeta(), + }).message; + const originalById = new Map([["a", original]]); + const coreOut: CoreMessage[] = [ + { id: "a", role: "assistant", contentType: "tool-call", toolName: "compress", toolCallId: "tc1", text: acpRef("m00009") + "\n" + kernelStubbedText() }, + ]; + + const out = coreOutToAgentMessages(coreOut, originalById); + const call = callsOf(out[0])[0]!; + const content = (call.arguments as { content: unknown[] }).content; + assert.equal(content.length, 1, "JSON after tag prefix synced"); + assert.equal((content[0] as { summary: string }).summary, STUB_SUMMARY); +});