feat(coding-agent): add loss-bounded compaction projection
123- Made compaction preserve provider-compatible reasoning in a structured digest while retaining exact recent history, masking stale tool outputs, and leaving raw session history unchanged.- Rejected empty or structurally invalid extension-provided compaction results before persistence.- Added composable compaction preparation middleware so extensions can layer projections before visual or native compaction.211 unmodified linesExpand all21221321421521621621721821922013 unmodified lines23423523623723823924024124224324414 unmodified lines25926026126226026326426226326526626726826626927026827127227327427527627727815 unmodified lines2942952962972982993003013023033043053063073083097 unmodified lines3173183193203213223233243253268 unmodified lines33533633733832433932632734034134234334434564 unmodified lines211 unmodified linesExpand allSee [`collectEntriesForBranchSummary()`, `prepareBranchEntries()`, and `generateBranchSummary()`](../src/core/compaction/branch-summarization.ts) for the implementation.
## Summary Format
Both compaction and branch summarization use the same structured format:Both compaction and branch summarization use the same structured format. The reasoning digest keeps useful conclusions and outcomes rather than copying raw reasoning verbatim. The recent tail selected by `keepRecentTokens` remains exact and outside this summary.
```markdown## Goal[What the user is trying to accomplish]13 unmodified lines
## Key Decisions- **[Decision]**: [Rationale]
## Reasoning Digest- **[Approach or hypothesis]**: [Outcome, evidence, and why it was kept or rejected]
## Next Steps1. [What should happen next]
## Critical Context14 unmodified linesBefore summarization, messages are serialized to text via [`serializeConversation()`](../src/core/compaction/utils.ts):
```[User]: What they said[Assistant thinking]: Internal reasoning[Assistant reasoning]: Readable reasoning for compatible summary models[Assistant]: Response text[Assistant tool calls]: ipython(code="open('foo.ts').read()"); edit(path="bar.ts", ...)[Tool result]: Output from tool[Assistant tool calls]: ipython#call-id(code="open('foo.ts').read()")[Tool result ipython#call-id error=false]: Output from tool```
This prevents the model from treating it as a conversation to continue.This prevents the model from treating the serialized data as a conversation to continue. Serialization builds a clone and does not modify the session entries.
Tool results are truncated to 2000 characters during serialization. Content beyond that limit is replaced with a marker indicating how many characters were truncated. This keeps summarization requests within reasonable token budgets, since tool results, especially from `ipython` and optional `bash`, are typically the largest contributors to context size.The summary projection is loss-bounded and applies only to the model-facing summarization request. It is built from cloned messages: the raw session JSONL, the retained recent tail, and the original tool results remain unchanged. The projection is intentionally destructive for that request: image bytes are replaced with `[image]`, and empty/useless or orphaned tool results are dropped. It keeps the newest three valid tool-result bodies and replaces older bodies with `[tool result]`; tool names, call IDs, and error states remain visible.
The projection keeps readable assistant reasoning for compatible models so decisions, attempted approaches, evidence, and failure causes can enter the reasoning digest. Claude-family summarizers omit it because Anthropic rejects prior assistant reasoning replayed as transcript text. If the provider still reports context overflow, compaction makes at most two smaller retries. Each retry removes one oldest complete user round when another round remains and reduces the exact tool-result bodies from three to one, then zero. Retries also apply a conservative head-and-tail bound to oversized single-round summary input so one long reasoning trace cannot block compaction. The raw session JSON remains unchanged; successful compaction only appends its summary entry.
## Custom Summarization via Extensions
Extensions can intercept and customize both compaction and branch summarization. See [`extensions/types.ts`](../src/core/extensions/types.ts) for event type definitions.
15 unmodified lines
// branchEntries - all entries on current branch (for custom state) // signal - AbortSignal (pass to LLM calls)
// Transform discarded history for later handlers and native fallback: return { preparation: { messagesToSummarize: project(preparation.messagesToSummarize), turnPrefixMessages: project(preparation.turnPrefixMessages), }, };
// Cancel: return { cancel: true };
// Custom summary:7 unmodified lines };});```
Preparation transforms run serially in extension load order. Each later `session_before_compact` handler sees the latest transformed groups; returning `preparation` replaces only the two discard-side message arrays and preserves the other preparation metadata. Return new arrays; do not mutate `event.preparation` or session messages. A handler may return both a preparation transform and a `compaction`; a valid custom result is persisted instead of invoking native compaction. If no handler supplies a custom result, native compaction receives the final groups. Returning `{ cancel: true }`, or aborting through `signal`, stops compaction before a compaction entry is written. The preparation result is an additive API requirement: hosts that do not support it ignore the transform, so an extension that needs projection must require a compatible host or project again in its own compactor.
#### Converting Messages to Text
To generate a summary with your own model, convert messages to text using `serializeConversation`:
8 unmodified lines convertToLlm(preparation.messagesToSummarize) ); // Returns: // [User]: message text // [Assistant thinking]: thinking content // [Assistant]: response text // [Assistant tool calls]: ipython(code="open('...').read()"); bash(command="...") // [Tool result]: output text // [Assistant tool calls]: ipython#call-id(code="open('...').read()") // [Tool result ipython#call-id error=false]: output text
// Now send to your model for summarization const summary = await myModel.summarize(conversationText); 64 unmodified lines415 unmodified linesExpand all41641741841942042142242342442542642742842943043111 unmodified lines4434444454464474484494504514522182 unmodified linesExpand all415 unmodified linesExpand all```typescriptpi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, signal } = event;
// Transform discarded history for later handlers and native fallback: return { preparation: { messagesToSummarize: project(preparation.messagesToSummarize), turnPrefixMessages: project(preparation.turnPrefixMessages), }, };
// Cancel: return { cancel: true };
// Custom summary:11 unmodified lines // event.fromExtension - whether extension provided it});```
`preparation` results compose serially in extension load order. A returned preparation replaces only `messagesToSummarize` and `turnPrefixMessages`, so later handlers and native fallback receive the latest discard-side projection without changing raw session entries. A handler can return preparation and a custom `compaction` together; that valid result skips native compaction. If a handler returns `{ cancel: true }` or its abort-aware work observes `signal`, no compaction entry is written. This preparation result is additive: older Prime hosts may ignore it, so extensions that depend on the projection must require a compatible host or apply their own defensive projection.
#### session_before_refine / refine_complete
Fired around continual-harness refinement (`/refine` and auto-refine). `session_before_refine` runs before the planning LLM call and can skip the round or replace the planner; rollback refinements bypass it. Proposal edits from extensions go through the same apply-time validation as built-in plans.
2182 unmodified linesExpand all23 unmodified lines2425262728293031323334353637383940411 unmodified line434445464748495051525310 unmodified lines64656667686970717232 unmodified lines105106107108109110111112113307 unmodified linesExpand all23 unmodified lines// Compactionexport { type BranchPreparation, type BranchSummaryResult, COMPACTION_IMAGE_PLACEHOLDER, COMPACTION_TOOL_RESULT_PLACEHOLDER, type CollectEntriesResult, type CompactionResult, type CutPointResult, calculateContextTokens, collectEntriesForBranchSummary, compact, DEFAULT_COMPACTION_SETTINGS, DEFAULT_SUMMARY_TOOL_RESULTS_TO_KEEP, estimateTokens, type FileOperations, findCutPoint, findTurnStartIndex,1 unmodified line generateBranchSummary, generateSummary, getLastAssistantUsage, prepareBranchEntries, prepareMessageGroupsForSummary, prepareMessagesForSummary, type SummaryMessagePreparationOptions, serializeConversation, shouldCompact,} from "./core/compaction/index.js";export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.js";10 unmodified lines BeforeAgentStartEventResult, BeforeProviderRequestEvent, BeforeProviderRequestEventResult, BuildSystemPromptOptions, CompactionMessagePreparation, CompactOptions, ContextEvent, ContextUsage, CustomToolCallEvent,32 unmodified lines RegisteredCommand, RegisteredTool, ResolvedCommand, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeForkEvent, SessionBeforeRefineEvent, SessionBeforeRefineResult, SessionBeforeSwitchEvent,307 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268import 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", ); });});112334567891011567121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162101112131415636465666768697071727374757677787980198182832122232484858687888990919293949596979899100101102103104105106107108109110111112113114115272811611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718830313233343536371891901911921931944019519642431971981992002012022034620420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024150242243525354555657585960616263646566672446924524624773747576248249250251252import type { Message } from "@earendil-works/pi-ai";import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";import { describe, expect, it } from "vitest";import { serializeConversation } from "../src/core/compaction/utils.js";import { COMPACTION_IMAGE_PLACEHOLDER, COMPACTION_TOOL_RESULT_PLACEHOLDER, prepareMessageGroupsForSummary, prepareMessagesForSummary, serializeConversation, shouldIncludeThinkingInSummary,} from "../src/core/compaction/utils.js";
describe("serializeConversation", () => { it("should truncate long tool results", () => { const longContent = "x".repeat(5000);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: "toolResult", toolCallId: "tc1", toolName: "ipython", content: [{ type: "text", text: longContent }], isError: false, timestamp: Date.now(), 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 result = serializeConversation(messages); const prepared = prepareMessagesForSummary(messages); const serialized = serializeConversation(messages);
expect(result).toContain("[Tool result]:"); expect(result).toContain("[... 3000 more characters truncated]"); expect(result).not.toContain("x".repeat(3000)); expect(result).toContain("x".repeat(2000)); 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("should not truncate short tool results", () => { const shortContent = "x".repeat(1500); 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[] = [ { role: "toolResult", toolCallId: "tc1", toolName: "ipython", content: [{ type: "text", text: shortContent }], isError: false, timestamp: Date.now(), }, assistantWithTools(["empty", "useless", "empty-error"]), toolResult("empty", ""), useless, toolResult("empty-error", "", true), ];
const result = serializeConversation(messages); const serialized = serializeConversation(messages);
expect(result).toBe(`[Tool result]: ${shortContent}`); expect(result).not.toContain("truncated"); 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("should not truncate assistant or user messages", () => { 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 }, { role: "user", ...assistantWithTools([]), content: [{ type: "text", text: longText }], timestamp: Date.now(), }, { role: "assistant", content: [{ type: "text", text: longText }], 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: "stop", timestamp: Date.now(), }, ];
const result = serializeConversation(messages);
expect(result).not.toContain("truncated"); expect(result).toContain(longText); const serialized = serializeConversation(messages); expect(serialized).toContain(longText); expect(serialized).not.toContain("truncated"); });});12234567 unmodified lines141516171819202122157 unmodified linesExpand all18018118218318418518618718818910 unmodified lines200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288399 unmodified linesExpand allimport type { AgentMessage } from "@earendil-works/pi-agent-core";import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";import { getModel } from "@earendil-works/pi-ai";import { readFileSync } from "fs";import { join } from "path";import { beforeEach, describe, expect, it } from "vitest";7 unmodified lines findCutPoint, getLastAssistantUsage, prepareCompaction, shouldCompact, truncateOldestSummaryRound,} from "../src/core/compaction/index.js";import { buildSessionContext, type CompactionEntry,157 unmodified linesExpand all it("omits user instructions block when no instructions given", () => { const prompt = buildSummarizationPrompt(); expect(prompt).not.toContain("<user-instructions>"); expect(prompt).toContain("## Goal"); expect(prompt).toContain("## Reasoning Digest"); expect(prompt).toContain("Do not copy raw reasoning verbatim"); // The kernel keeps running across compaction — the note must not claim a wipe. expect(prompt).toContain("Python kernel keeps running"); expect(prompt).not.toMatch(/wiped|restarted/); });10 unmodified lines const initial = buildSummarizationPrompt("focus on xyz"); const update = buildSummarizationPrompt("focus on xyz", "## Goal\nprevious summary"); expect(initial).not.toContain("existing summary provided in <previous-summary> tags"); expect(update).toContain("existing summary provided in <previous-summary> tags"); expect(update).toContain("PRESERVE prior Reasoning Digest entries"); expect(update).toContain("<user-instructions>"); });});
describe("truncateOldestSummaryRound", () => { it("drops one complete oldest user round", () => { const messages: Message[] = [ { role: "user", content: "first", timestamp: 1 }, createAssistantMessage("first reply"), { role: "user", content: "second", timestamp: 2 }, createAssistantMessage("second reply"), ];
expect(truncateOldestSummaryRound(messages)).toEqual(messages.slice(2)); });
it("keeps consecutive user messages in the same removed round", () => { const messages: Message[] = [ { role: "user", content: "first part", timestamp: 1 }, { role: "user", content: "second part", timestamp: 2 }, createAssistantMessage("first reply"), { role: "user", content: "next round", timestamp: 3 }, createAssistantMessage("next reply"), ];
expect(truncateOldestSummaryRound(messages)).toEqual(messages.slice(3)); });
it("preserves an assistant tool prefix while removing the oldest user round", () => { const assistantPrefix = createAssistantMessage(""); assistantPrefix.content = [{ type: "toolCall", id: "prefix-tool", name: "ipython", arguments: { code: "work" } }]; assistantPrefix.stopReason = "toolUse"; const prefixResult: Message = { role: "toolResult", toolCallId: "prefix-tool", toolName: "ipython", content: [{ type: "text", text: "prefix result" }], isError: false, timestamp: 1, }; const messages: Message[] = [ assistantPrefix, prefixResult, { role: "user", content: "first round", timestamp: 2 }, createAssistantMessage("first reply"), { role: "user", content: "second round", timestamp: 3 }, createAssistantMessage("second reply"), ];
expect(truncateOldestSummaryRound(messages)).toEqual([assistantPrefix, prefixResult, ...messages.slice(4)]); const onlyRound = messages.slice(0, 4); expect(truncateOldestSummaryRound(onlyRound)).toBe(onlyRound); });
it("does not destroy the only remaining user round", () => { const messages: Message[] = [{ role: "user", content: "only", timestamp: 1 }, createAssistantMessage("reply")];
expect(truncateOldestSummaryRound(messages)).toBe(messages); });
it("does not destroy an assistant-only tool round", () => { const assistant = createAssistantMessage(""); assistant.content = [{ type: "toolCall", id: "t1", name: "ipython", arguments: {} }]; assistant.stopReason = "toolUse"; const messages: Message[] = [ assistant, { role: "toolResult", toolCallId: "t1", toolName: "ipython", content: [{ type: "text", text: "result" }], isError: false, timestamp: 1, }, ];
expect(truncateOldestSummaryRound(messages)).toBe(messages); });});
describe("Token calculation", () => { it("should calculate total context tokens from usage", () => { const usage = createMockUsage(1000, 500, 200, 100); expect(calculateContextTokens(usage)).toBe(1800);399 unmodified linesExpand all