Skip to content
Merged
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 @@ -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"
Expand Down
67 changes: 60 additions & 7 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ export function coreOutToAgentMessages(
): AgentMessage[] {
const out: AgentMessage[] = [];
const emittedSplit = new Set<string>();
const kernelTextByCallId = new Map<string, string>();
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;
Expand All @@ -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<string>,
kernelTextByCallId: Map<string, string>,
): AgentMessage {
const base = original as AnyMessage;
const match = firstCore.text ? firstCore.text.match(REF_TAG) : null;
Expand All @@ -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;
}

Expand All @@ -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) {
Expand All @@ -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.
Expand Down
171 changes: 171 additions & 0 deletions tests/anchor-text-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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);
});
Loading