import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import {
COMPACTION_IMAGE_PLACEHOLDER,
COMPACTION_TOOL_RESULT_PLACEHOLDER,
prepareMessageGroupsForSummary,
prepareMessagesForSummary,
serializeConversation,
shouldIncludeThinkingInSummary,
} from "../src/core/compaction/utils.js";
type UselessToolResultMessage = ToolResultMessage & { useless?: boolean };
function assistantWithTools(ids: string[]): AssistantMessage {
return {
role: "assistant",
content: [
{ type: "thinking", thinking: "private chain", thinkingSignature: "opaque" },
{ type: "text", text: "running tools" },
...ids.map((id) => ({
type: "toolCall" as const,
id,
name: "ipython",
arguments: { id, nested: { preserved: true } },
thoughtSignature: "tool-signature",
})),
],
api: "anthropic",
provider: "anthropic",
model: "test",
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: 1,
};
}
function toolResult(id: string, text: string, isError = false): ToolResultMessage {
return {
role: "toolResult",
toolCallId: id,
toolName: "ipython",
content: [{ type: "text", text }],
isError,
timestamp: 1,
};
}
describe("summary message preparation", () => {
it("prepares a loss-bounded clone without changing the raw messages", () => {
const newestResult = toolResult("t4", "newest output", true);
newestResult.details = { source: "raw-session", nested: { preserved: true } };
const uselessResult = toolResult("useless", "no matches") as UselessToolResultMessage;
uselessResult.useless = true;
const messages: Message[] = [
{
role: "user",
content: [
{ type: "text", text: "inspect this" },
{ type: "image", data: "base64-secret", mimeType: "image/png" },
],
timestamp: 1,
},
assistantWithTools(["t1", "t2", "t3", "t4", "empty", "useless"]),
toolResult("t1", "oldest exact output"),
toolResult("t2", "second output"),
toolResult("t3", "third output"),
newestResult,
toolResult("empty", ""),
uselessResult,
toolResult("orphan", "must disappear"),
];
const original = JSON.stringify(messages);
const prepared = prepareMessagesForSummary(messages);
const serialized = serializeConversation(messages);
expect(JSON.stringify(messages)).toBe(original);
expect(prepared).not.toBe(messages);
const preparedAssistant = prepared.find((message): message is AssistantMessage => message.role === "assistant")!;
const preparedCall = preparedAssistant.content.find((block) => block.type === "toolCall" && block.id === "t4");
if (!preparedCall || preparedCall.type !== "toolCall") throw new Error("prepared tool call is missing");
expect(preparedCall).toMatchObject({
name: "ipython",
arguments: { id: "t4", nested: { preserved: true } },
thoughtSignature: "tool-signature",
});
const preparedNewestResult = prepared.find(
(message): message is ToolResultMessage => message.role === "toolResult" && message.toolCallId === "t4",
)!;
expect(preparedNewestResult.details).toEqual({ source: "raw-session", nested: { preserved: true } });
(preparedCall.arguments.nested as { preserved: boolean }).preserved = false;
(preparedNewestResult.details as { nested: { preserved: boolean } }).nested.preserved = false;
expect(JSON.stringify(messages)).toBe(original);
expect(serialized).toContain(COMPACTION_IMAGE_PLACEHOLDER);
expect(serialized).not.toContain("base64-secret");
expect(serialized).toContain("[Assistant reasoning]: private chain");
expect(serialized).not.toContain("must disappear");
expect(serialized).not.toContain("no matches");
expect(serialized).not.toContain("ipython#empty");
expect(serialized).not.toContain("ipython#useless");
expect(serialized).not.toContain("oldest exact output");
expect(serialized).toContain(COMPACTION_TOOL_RESULT_PLACEHOLDER);
expect(serialized).toContain("second output");
expect(serialized).toContain("third output");
expect(serialized).toContain("newest output");
expect(serialized).toContain("ipython#t4 error=true");
});
it("omits uncloneable tool metadata instead of failing compaction", () => {
const result = toolResult("t1", "usable output");
result.details = { callback: () => "not serializable" };
expect(() => serializeConversation([assistantWithTools(["t1"]), result])).not.toThrow();
const prepared = prepareMessagesForSummary([assistantWithTools(["t1"]), result]);
const preparedResult = prepared.find((message) => message.role === "toolResult");
expect(preparedResult).not.toHaveProperty("details");
});
it("excludes readable reasoning for Claude-family summary targets", () => {
const messages = [assistantWithTools([])];
const serialized = serializeConversation(messages, { includeThinking: false });
const thinkingOnly = { ...assistantWithTools([]), content: [{ type: "thinking" as const, thinking: "private" }] };
const uselessOnly = {
...assistantWithTools([]),
content: [{ type: "toolCall" as const, id: "useless", name: "ipython", arguments: {} }],
};
const useless = toolResult("useless", "no matches") as UselessToolResultMessage;
useless.useless = true;
const emptied = prepareMessagesForSummary([thinkingOnly, uselessOnly, useless], { includeThinking: false });
expect(serialized).not.toContain("private chain");
expect(emptied).toEqual([]);
expect(
shouldIncludeThinkingInSummary({
api: "anthropic-messages",
id: "claude-sonnet-4-5",
provider: "anthropic",
}),
).toBe(false);
expect(
shouldIncludeThinkingInSummary({
api: "openai-responses",
id: "gpt-5.6-sol",
provider: "openai",
}),
).toBe(true);
});
it("preserves split-turn groups while applying retention across their boundary", () => {
const groups: Message[][] = [
[assistantWithTools(["t1", "t2"]), toolResult("t1", "old result")],
[
toolResult("t2", "boundary result"),
{ role: "user", content: "next round", timestamp: 1 },
assistantWithTools(["t3", "t4"]),
toolResult("t3", "third result"),
toolResult("t4", "fourth result"),
],
];
const original = JSON.stringify(groups);
const prepared = prepareMessageGroupsForSummary(groups);
const resultText = prepared
.flat()
.filter((message): message is ToolResultMessage => message.role === "toolResult")
.map((message) => message.content.map((block) => (block.type === "text" ? block.text : "")).join(""));
expect(JSON.stringify(groups)).toBe(original);
expect(prepared).toHaveLength(2);
expect(resultText).toEqual([
COMPACTION_TOOL_RESULT_PLACEHOLDER,
"boundary result",
"third result",
"fourth result",
]);
});
it("drops empty successes and useless pairs while retaining an empty error", () => {
const useless = toolResult("useless", "no matches") as UselessToolResultMessage;
useless.useless = true;
const messages: Message[] = [
assistantWithTools(["empty", "useless", "empty-error"]),
toolResult("empty", ""),
useless,
toolResult("empty-error", "", true),
];
const serialized = serializeConversation(messages);
expect(serialized).not.toContain("ipython#empty(");
expect(serialized).not.toContain("ipython#useless");
expect(serialized).not.toContain("no matches");
expect(serialized).toContain("ipython#empty-error");
expect(serialized).toContain("error=true");
});
it("can mask every tool result for the final overflow attempt", () => {
const messages = [assistantWithTools(["t1"]), toolResult("t1", "huge output")];
const serialized = serializeConversation(messages, { keepRecentToolResults: 0 });
expect(serialized).toContain(COMPACTION_TOOL_RESULT_PLACEHOLDER);
expect(serialized).not.toContain("huge output");
});
it("replaces tool-result image bytes without losing result identity", () => {
const imageResult: Message = {
role: "toolResult",
toolCallId: "t1",
toolName: "ipython",
content: [{ type: "image", data: "tool-image-bytes", mimeType: "image/png" }],
isError: false,
timestamp: 1,
};
const serialized = serializeConversation([assistantWithTools(["t1"]), imageResult]);
expect(serialized).toContain(`ipython#t1 error=false]: ${COMPACTION_IMAGE_PLACEHOLDER}`);
expect(serialized).not.toContain("tool-image-bytes");
});
it("drops a tool result without an immediately preceding matching call", () => {
const messages: Message[] = [
assistantWithTools(["t1"]),
{ role: "user", content: "new turn", timestamp: 1 },
toolResult("t1", "stale result"),
];
expect(serializeConversation(messages)).not.toContain("stale result");
});
it("keeps long user and assistant text intact", () => {
const longText = "y".repeat(5000);
const messages: Message[] = [
{ role: "user", content: longText, timestamp: 1 },
{
...assistantWithTools([]),
content: [{ type: "text", text: longText }],
stopReason: "stop",
},
];
const serialized = serializeConversation(messages);
expect(serialized).toContain(longText);
expect(serialized).not.toContain("truncated");
});
});