import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { getModel } from "@earendil-works/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
const completeSimple = vi.hoisted(() => vi.fn());
vi.mock("@earendil-works/pi-ai", async (importOriginal) => ({
...(await importOriginal()),
completeSimple,
}));
import { type CompactionPreparation, compact, generateSummary } from "../src/core/compaction/compaction.js";
const usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
function response(overrides: Partial<AssistantMessage>): AssistantMessage {
return {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage,
stopReason: "stop",
timestamp: 1,
...overrides,
};
}
function summaryMessages(): AgentMessage[] {
return [
{ role: "user", content: "oldest round", timestamp: 1 },
response({ content: [{ type: "text", text: "oldest reply" }] }),
{ role: "user", content: "newest round", timestamp: 2 },
response({ content: [{ type: "text", text: "newest reply" }] }),
];
}
describe("summary generation recovery", () => {
beforeEach(() => completeSimple.mockReset());
it("feeds readable reasoning to compatible summarizers but not Claude", async () => {
const messages: AgentMessage[] = [
{ role: "user", content: "debug the failure", timestamp: 1 },
response({
content: [
{ type: "thinking", thinking: "the cache key is stale" },
{ type: "text", text: "I found the cause" },
],
}),
];
completeSimple.mockResolvedValue(response({ content: [{ type: "text", text: "valid summary" }] }));
await generateSummary(messages, getModel("anthropic", "claude-sonnet-4-5")!, 16_384, "test-key");
const anthropicPrompt = completeSimple.mock.calls[0][1].messages[0].content[0].text as string;
expect(anthropicPrompt).not.toContain("the cache key is stale");
completeSimple.mockClear();
await generateSummary(messages, getModel("openai", "gpt-5.2")!, 16_384, "test-key");
const openaiPrompt = completeSimple.mock.calls[0][1].messages[0].content[0].text as string;
expect(openaiPrompt).toContain("[Assistant reasoning]: the cache key is stale");
});
it("bounds a single oversized reasoning round after overflow", async () => {
completeSimple
.mockResolvedValueOnce(response({ stopReason: "error", errorMessage: "context_length_exceeded" }))
.mockResolvedValueOnce(response({ content: [{ type: "text", text: "bounded summary" }] }));
const model = getModel("openai", "gpt-5.2")!;
const reasoning = `reasoning-start-${"r".repeat(1_000_000)}-reasoning-end`;
const messages: AgentMessage[] = [
{ role: "user", content: "one large round", timestamp: 1 },
response({ content: [{ type: "thinking", thinking: reasoning }] }),
];
await expect(generateSummary(messages, model, 16_384, "test-key")).resolves.toBe("bounded summary");
const firstPrompt = completeSimple.mock.calls[0][1].messages[0].content[0].text as string;
const retryPrompt = completeSimple.mock.calls[1][1].messages[0].content[0].text as string;
expect(retryPrompt.length).toBeLessThan(firstPrompt.length);
expect(retryPrompt).toContain("summary input omitted for overflow recovery");
expect(retryPrompt).toContain("reasoning-start");
expect(retryPrompt).toContain("reasoning-end");
});
it("retries a context overflow with one oldest round removed", async () => {
completeSimple
.mockResolvedValueOnce(response({ stopReason: "error", errorMessage: "context_length_exceeded" }))
.mockResolvedValueOnce(response({ content: [{ type: "text", text: "valid summary" }] }));
const model = getModel("anthropic", "claude-sonnet-4-5")!;
await expect(generateSummary(summaryMessages(), model, 16_384, "test-key")).resolves.toBe("valid summary");
expect(completeSimple).toHaveBeenCalledTimes(2);
const firstPrompt = completeSimple.mock.calls[0][1].messages[0].content[0].text as string;
const secondPrompt = completeSimple.mock.calls[1][1].messages[0].content[0].text as string;
expect(firstPrompt).toContain("oldest round");
expect(secondPrompt).not.toContain("oldest round");
expect(secondPrompt).toContain("newest round");
});
it("uses the same overflow recovery for a split-turn prefix", async () => {
completeSimple
.mockResolvedValueOnce(response({ stopReason: "error", errorMessage: "context_length_exceeded" }))
.mockResolvedValueOnce(response({ content: [{ type: "text", text: "prefix summary" }] }));
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const preparation: CompactionPreparation = {
firstKeptEntryId: "kept-entry",
messagesToSummarize: [],
turnPrefixMessages: summaryMessages(),
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 16_384, keepRecentTokens: 20_000 },
};
const rawMessages = JSON.stringify(preparation.turnPrefixMessages);
await expect(compact(preparation, model, "test-key")).resolves.toMatchObject({
firstKeptEntryId: "kept-entry",
summary: expect.stringContaining("prefix summary"),
});
expect(completeSimple).toHaveBeenCalledTimes(2);
const retryPrompt = completeSimple.mock.calls[1][1].messages[0].content[0].text as string;
expect(retryPrompt).not.toContain("oldest round");
expect(retryPrompt).toContain("newest round");
expect(JSON.stringify(preparation.turnPrefixMessages)).toBe(rawMessages);
});
it("progressively masks tool outputs across the three bounded attempts", async () => {
completeSimple
.mockResolvedValueOnce(response({ stopReason: "error", errorMessage: "context_length_exceeded" }))
.mockResolvedValueOnce(response({ stopReason: "error", errorMessage: "context_length_exceeded" }))
.mockResolvedValueOnce(response({ content: [{ type: "text", text: "minimal summary" }] }));
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const messages: AgentMessage[] = [
{ role: "user", content: "one remaining round", timestamp: 1 },
response({
content: ["t1", "t2", "t3", "t4"].map((id) => ({
type: "toolCall",
id,
name: "ipython",
arguments: { id },
})),
stopReason: "toolUse",
}),
...["t1", "t2", "t3", "t4"].map(
(id, index): AgentMessage => ({
role: "toolResult",
toolCallId: id,
toolName: "ipython",
content: [{ type: "text", text: `exact-result-${index + 1}` }],
isError: false,
timestamp: 1,
}),
),
];
await expect(generateSummary(messages, model, 16_384, "test-key")).resolves.toBe("minimal summary");
const prompts = completeSimple.mock.calls.map((call) => call[1].messages[0].content[0].text as string);
expect(prompts[0]).not.toContain("exact-result-1");
expect(prompts[0]).toContain("exact-result-2");
expect(prompts[1]).not.toContain("exact-result-3");
expect(prompts[1]).toContain("exact-result-4");
expect(prompts[2]).not.toContain("exact-result-4");
expect(completeSimple).toHaveBeenCalledTimes(3);
});
it("preserves an assistant-only tool round while recovering from overflow", async () => {
completeSimple
.mockResolvedValueOnce(response({ stopReason: "error", errorMessage: "context_length_exceeded" }))
.mockResolvedValueOnce(response({ content: [{ type: "text", text: "tool summary" }] }));
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const messages: AgentMessage[] = [
response({
content: [{ type: "toolCall", id: "t1", name: "ipython", arguments: {} }],
stopReason: "toolUse",
}),
{
role: "toolResult",
toolCallId: "t1",
toolName: "ipython",
content: [{ type: "text", text: "irreplaceable result" }],
isError: false,
timestamp: 1,
},
];
await expect(generateSummary(messages, model, 16_384, "test-key")).resolves.toBe("tool summary");
const retryPrompt = completeSimple.mock.calls[1][1].messages[0].content[0].text as string;
expect(retryPrompt).toContain("ipython#t1");
expect(retryPrompt).toContain("irreplaceable result");
});
it.each([
{ stopReason: "stop" as const, inputRatio: 1.01 },
{ stopReason: "length" as const, inputRatio: 1 },
])("retries a silent $stopReason overflow", async ({ stopReason, inputRatio }) => {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
completeSimple
.mockResolvedValueOnce(
response({
content: [{ type: "text", text: "must not persist" }],
stopReason,
usage: {
...usage,
input: Math.ceil(model.contextWindow * inputRatio),
totalTokens: Math.ceil(model.contextWindow * inputRatio),
},
}),
)
.mockResolvedValueOnce(response({ content: [{ type: "text", text: "recovered summary" }] }));
await expect(generateSummary(summaryMessages(), model, 16_384, "test-key")).resolves.toBe("recovered summary");
expect(completeSimple).toHaveBeenCalledTimes(2);
});
it("rejects the final silent overflow instead of persisting its text", async () => {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
completeSimple.mockResolvedValue(
response({
content: [{ type: "text", text: "must not persist" }],
stopReason: "stop",
usage: { ...usage, input: model.contextWindow + 1, totalTokens: model.contextWindow + 1 },
}),
);
await expect(generateSummary(summaryMessages(), model, 16_384, "test-key")).rejects.toThrow(
"Summarization failed: Context window exceeded",
);
expect(completeSimple).toHaveBeenCalledTimes(3);
});
it("does not retry an aborted summary", async () => {
completeSimple.mockResolvedValueOnce(
response({ stopReason: "aborted", content: [{ type: "text", text: "partial summary" }] }),
);
const model = getModel("anthropic", "claude-sonnet-4-5")!;
await expect(generateSummary(summaryMessages(), model, 16_384, "test-key")).rejects.toMatchObject({
name: "AbortError",
message: "Summarization aborted",
});
expect(completeSimple).toHaveBeenCalledTimes(1);
});
it("does not retry a non-overflow provider failure", async () => {
completeSimple.mockResolvedValueOnce(response({ stopReason: "error", errorMessage: "authentication failed" }));
const model = getModel("anthropic", "claude-sonnet-4-5")!;
await expect(generateSummary(summaryMessages(), model, 16_384, "test-key")).rejects.toThrow(
"Summarization failed: authentication failed",
);
expect(completeSimple).toHaveBeenCalledTimes(1);
});
it("rejects an empty summary before it can become a compaction entry", async () => {
completeSimple.mockResolvedValueOnce(response({ content: [{ type: "text", text: " \n" }] }));
const model = getModel("anthropic", "claude-sonnet-4-5")!;
await expect(generateSummary(summaryMessages(), model, 16_384, "test-key")).rejects.toThrow(
"Summarization returned an empty summary",
);
});
});