import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type AssistantMessage, fauxAssistantMessage, fauxToolCall, type Message, type SimpleStreamOptions, type TranscriptContext, Type, type UserMessage, } from "@earendil-works/pi-ai"; import { type AgentEvent, type CompactionPolicy, type CompactionResult, CompactionTask, type ContextView, type Conversation, defineTask, defineTool, type EntryRecord, type Harness, LiveDoc, MemoryStorage, ProviderDoc, type TaskId, UsageDoc, watchEvents, } from "@earendil-works/pi-durable"; import { afterEach, describe, expect, it } from "vitest"; import { selectCut, serializeConversation } from "../src/harness/compaction.ts"; import { orderToolResults } from "../src/harness/context.ts"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { allEntries, type ChatSetup, chatSetup, openChat, textOf, toolsNamed, waitFor } from "./chat-support.ts"; import { addHooks, addSection, addTask, addTool } from "./harness-support.ts"; import { ControlledStorage, context } from "./session-support.ts"; import { aborted, type Deferred, deferred } from "./task-support.ts"; const directories = new Set(); afterEach(async () => { for (const directory of directories) await rm(directory, { recursive: true, force: true }); directories.clear(); }); async function sqlitePath(): Promise { const directory = await mkdtemp(join(tmpdir(), "pi-durable-compaction-")); directories.add(directory); return join(directory, "session.sqlite"); } /** Text of about `tokens` estimated tokens, starting with `label`. */ function text(label: string, tokens: number): string { return `${label} ${"x".repeat(Math.max(0, tokens * 4 - label.length - 1))}`; } type Request = { readonly messages: readonly Message[]; readonly options: SimpleStreamOptions | undefined; readonly model: string; }; type Step = AssistantMessage | ((request: Request) => AssistantMessage | Promise); /** Scripted faux model that answers agent requests and summarization requests from separate queues. */ type Script = { readonly agent: Step[]; readonly summaries: Step[]; readonly agentRequests: Request[]; readonly summaryRequests: Request[]; }; function isSummaryRequest(messages: readonly Message[]): boolean { const first = messages[0]; return ( first?.role === "system" && typeof first.content === "string" && first.content.startsWith("You are a context summarization assistant") ); } function script(setup: ChatSetup): Script { const result: Script = { agent: [], summaries: [], agentRequests: [], summaryRequests: [] }; setup.faux.setResponses( Array.from( { length: 500 }, () => async ( transcript: TranscriptContext, options?: SimpleStreamOptions, _state?: unknown, model?: { id: string }, ) => { const request = { messages: [...transcript.messages], options, model: model!.id }; const summary = isSummaryRequest(request.messages); (summary ? result.summaryRequests : result.agentRequests).push(request); const step = (summary ? result.summaries : result.agent).shift(); if (step === undefined) throw new Error(`No scripted ${summary ? "summary" : "agent"} response`); return typeof step === "function" ? step(request) : step; }, ), ); return result; } /** A step that waits for `gate`, or rejects when the request is cancelled. */ function gated(gate: Deferred, message: AssistantMessage, reached?: Deferred): Step { return async ({ options }) => { reached?.resolve(); await Promise.race([gate.promise, aborted(options!.signal!)]); return message; }; } const answer = (content: string) => fauxAssistantMessage(content); const failure = (errorMessage: string) => fauxAssistantMessage("", { stopReason: "error", errorMessage }); const OVERFLOW = "prompt is too long: 250000 tokens > 200000 maximum"; /** Small thresholds: no automatic compaction unless a test enables it. */ const MANUAL: CompactionPolicy = { enabled: false, reserveTokens: 1000, keepRecentTokens: 150, backgroundTokens: 0 }; type Chat = { readonly harness: Harness; readonly root: Conversation; readonly setup: ChatSetup; readonly faux: Script; }; async function open( options: { readonly policy?: CompactionPolicy; readonly contextWindow?: number; readonly storage?: MemoryStorage; } = {}, setup = chatSetup({ models: [{ id: "faux-1", contextWindow: options.contextWindow ?? 100_000, maxTokens: 900 }] }), faux = script(setup), ): Promise { addSection(setup.registry, "preamble", () => "You are helpful.", { tag: false }); const { harness, root } = await openChat(options.storage ?? new MemoryStorage(), setup); // These tests exercise compaction accounting and thresholds, not the faux provider's cache simulation. setup.settings.stream = { cacheRetention: "none" }; setup.settings.compaction = options.policy ?? MANUAL; setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 1 }; harness.resume(); return { harness, root, setup, faux }; } /** Run one turn: `user` answered by `reply`. */ async function turn(chat: Chat, user: string, reply: string): Promise { chat.faux.agent.push(answer(reply)); const submission = await chat.root.submit({ type: "input", content: user }, context); expect((await submission.wait(context)).status).toBe("done"); } /** Three turns of about 100-token messages. */ async function history(chat: Chat): Promise { await turn(chat, text("u1", 100), text("a1", 100)); await turn(chat, text("u2", 100), text("a2", 100)); await turn(chat, text("u3", 100), text("a3", 100)); } const summary = (content = "SUMMARY") => answer(content); async function result(chat: Chat, id: TaskId) { const record = await chat.harness.waitForTask(id, context); return record.state.outcome; } async function kinds(conversation: Conversation): Promise { return (await allEntries(conversation)).map((entry) => entry.kind); } async function live(chat: Chat) { return (await chat.harness.snapshot(LiveDoc, chat.root.id, context)) ?? {}; } function userText(message: Message | undefined): string { return textOf(message) ?? ""; } /** Text of the first user message; a leading baseline system message comes before it. */ function firstUserText(messages: readonly Message[]): string { return userText(messages.find((message) => message.role === "user")); } // ─── Range selection ────────────────────────────────────────────────────── let nextId = 1; function entry(kind: string, model: Message[], head?: number): EntryRecord { return { id: nextId++, conversationId: 1, kind, model, ...(head === undefined ? {} : { head }), } as unknown as EntryRecord; } const user = (content: string): UserMessage => ({ role: "user", content, timestamp: 0 }); const assistant = (content: string, calls: string[] = [], stopReason: AssistantMessage["stopReason"] = "stop") => ({ ...fauxAssistantMessage([ { type: "text", text: content }, ...calls.map((id) => fauxToolCall("read", {}, { id })), ]), stopReason, }) as AssistantMessage; const toolResult = (callId: string, content: string): Message => ({ role: "toolResult", toolCallId: callId, toolName: "read", content: [{ type: "text", text: content }], isError: false, timestamp: 0, }); /** A view over `entries` whose contributions are their models, with excluded assistants removed. */ function view(entries: EntryRecord[], head?: EntryRecord): ContextView { const all = head === undefined ? entries : [head, ...entries]; const contributions = all.map((record) => (record.model ?? []).filter( (message) => message.role !== "assistant" || !["error", "aborted", "deferred"].includes(message.stopReason), ), ); return { head, entries: all, contributions, messages: orderToolResults(contributions.flat()) }; } describe("range selection", () => { it("keeps about keepRecentTokens and cuts at the first candidate at or after the budget (spec §8.7 example)", () => { const entries = [ entry("pi.user", [user(text("1", 10))]), entry("pi.assistant", [assistant("2", ["c1"])]), entry("pi.tool-result", [toolResult("c1", text("3", 3000))]), entry("pi.assistant", [assistant(text("4", 10))]), entry("pi.user", [user(text("5", 10))]), entry("pi.assistant", [assistant(text("6", 10))]), ]; expect(selectCut(view(entries), 2000)).toBe(3); }); it("cuts at a user entry", () => { const entries = [ entry("pi.user", [user(text("u1", 100))]), entry("pi.assistant", [assistant(text("a1", 100))]), entry("pi.user", [user(text("u2", 100))]), entry("pi.assistant", [assistant(text("a2", 100))]), ]; expect(selectCut(view(entries), 150)).toBe(2); }); it("cuts at an assistant in the middle of one long run and never at a tool result", () => { const entries = [entry("pi.user", [user("do it")])]; for (let index = 0; index < 5; index++) { entries.push(entry("pi.assistant", [assistant(`step ${index}`, [`c${index}`])])); entries.push(entry("pi.tool-result", [toolResult(`c${index}`, text(`r${index}`, 100))])); } const cut = selectCut(view(entries), 150)!; // The budget is reached at the fourth result; the cut is the last call, whose result it keeps. expect(entries[cut]!.kind).toBe("pi.assistant"); expect(cut).toBe(9); }); it("never cuts at a display-only entry where the budget is reached (#10407)", () => { const entries = [ entry("pi.user", [user(text("u1", 100))]), entry("pi.assistant", [assistant(text("a1", 100))]), entry("chat.message", []), entry("pi.assistant", [assistant(text("a2", 100))]), ]; // The budget is reached at a1; the display-only entry after it is skipped for the assistant a2. expect(selectCut(view(entries), 150)).toBe(1); expect(selectCut(view(entries), 50)).toBe(3); }); it("keeps a huge last tool result together with its assistant", () => { const entries = [ entry("pi.user", [user("u")]), entry("pi.assistant", [assistant("a", ["c"])]), entry("pi.tool-result", [toolResult("c", text("big", 5000))]), ]; expect(selectCut(view(entries), 100)).toBe(1); }); it("never cuts at a system entry or an excluded error or aborted answer", () => { const entries = [ entry("pi.user", [user(text("u1", 100))]), entry("pi.assistant", [assistant(text("a1", 100))]), entry("pi.system", [{ role: "system", content: "", sections: { s: text("s", 100) }, timestamp: 0 }]), entry("pi.assistant", [assistant(text("err", 100), [], "error")]), entry("pi.assistant", [assistant(text("stopped", 100), [], "aborted")]), entry("pi.assistant", [assistant(text("a2", 100))]), ]; // The walk reaches 150 at the system entry; the excluded answers after it contribute nothing. expect(selectCut(view(entries), 150)).toBe(5); }); it("follows edited contributions: an omitted entry adds nothing and is no candidate", () => { const entries = [ entry("pi.user", [user(text("u1", 100))]), entry("pi.assistant", [assistant(text("a1", 100))]), entry("pi.user", [user(text("u2", 100))]), entry("pi.assistant", [assistant(text("a2", 100))]), ]; const plain = view(entries); expect(selectCut(plain, 150)).toBe(2); const contributions = plain.contributions.map((messages, index) => (index === 2 ? [] : messages)); const omitted = { ...plain, contributions, messages: orderToolResults(contributions.flat()) }; expect(selectCut(omitted, 150)).toBe(1); }); it("does not cut at a user entry that a result of the preceding call still follows", () => { const entries = [ entry("pi.user", [user(text("u1", 100))]), entry("pi.assistant", [assistant("a", ["c"])]), entry("pi.user", [user(text("steer", 100))]), entry("pi.tool-result", [toolResult("c", text("r", 100))]), entry("pi.assistant", [assistant(text("a2", 100))]), ]; // The budget is reached at the steer; its result follows it, so the cut moves to the next assistant. expect(selectCut(view(entries), 250)).toBe(4); }); it("finds nothing when the budget is never reached or only the marker precedes the cut", () => { const small = [entry("pi.user", [user("hi")]), entry("pi.assistant", [assistant("hello")])]; expect(selectCut(view(small), 150)).toBeUndefined(); const marker = entry("pi.compaction", [user("summary")], 0); // The budget is reached at the only entry after the marker, so the marker alone would be summarized. expect(selectCut(view([entry("pi.user", [user(text("u", 200))])], marker), 150)).toBeUndefined(); }); it("summarizes an earlier summary marker first", () => { const marker = entry("pi.compaction", [user("EARLIER")], 0); const kept = [ entry("pi.user", [user(text("u1", 100))]), entry("pi.assistant", [assistant(text("a1", 100))]), entry("pi.user", [user(text("u2", 100))]), entry("pi.assistant", [assistant(text("a2", 100))]), ]; const selected = view(kept, marker); expect(selectCut(selected, 150)).toBe(3); expect(serializeConversation(selected.contributions.slice(0, 3).flat())).toMatch(/^\[User\]: EARLIER/); }); }); describe("serialization", () => { it("writes a transcript, truncates tool results, and omits system messages and thinking", () => { const call = fauxToolCall("read", { path: "a.ts" }, { id: "c" }); const messages: Message[] = [ { role: "system", content: "", sections: { s: "hidden" }, timestamp: 0 }, user("hello"), { ...fauxAssistantMessage([{ type: "thinking", thinking: "hmm" }, { type: "text", text: "sure" }, call]), }, toolResult("c", "y".repeat(2500)), ]; const serialized = serializeConversation(messages); expect(serialized).not.toContain("hidden"); expect(serialized).toContain("[User]: hello"); expect(serialized).not.toContain("hmm"); expect(serialized).not.toContain("[Assistant thinking]"); expect(serialized).toContain("[Assistant]: sure"); expect(serialized).toContain('[Assistant tool calls]: read(path="a.ts")'); expect(serialized).toContain(`[Tool result]: ${"y".repeat(2000)}\n\n[... 500 more characters truncated]`); }); }); // ─── Manual compaction ──────────────────────────────────────────────────── describe("manual compaction", () => { it("places the summary at once when idle and keeps raw history", async () => { const chat = await open(); await history(chat); const before = await allEntries(chat.root); const usageBefore = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; chat.faux.summaries.push(summary()); const id = await chat.root.compact("focus on files", context); const outcome = await result(chat, id); expect(outcome.status).toBe("completed"); const submissionId = outcome.status === "completed" ? outcome.result.submissionId : undefined; const placed = await (await chat.harness.submission(submissionId!, context))!.wait(context); expect(placed.status).toBe("done"); // Raw history is unchanged; one summary entry heads the first kept entry, u3. const after = await allEntries(chat.root); expect(after.slice(0, before.length)).toEqual(before); const marker = after.at(-1)!; const u3 = before.find((record) => userText(record.model?.[0]).startsWith("u3"))!; expect(marker).toMatchObject({ kind: "pi.compaction", head: u3.id, data: { reason: "manual" } }); expect(placed.status === "done" && placed.entry).toBe(marker.id); expect(userText(marker.model?.[0])).toBe( "The conversation history before this point was compacted into the following summary:\n\n\nSUMMARY\n", ); // The model context is the summary followed by the kept entries. const view = await chat.root.context(context); expect(view.messages.map(userText)).toEqual([userText(marker.model?.[0]), text("u3", 100), text("a3", 100)]); // The summarizer saw the serialized prefix, the prompt, and the instructions, without tools or caching. const request = chat.faux.summaryRequests[0]!; expect(request.messages).toHaveLength(2); const prompt = userText(request.messages[1]); expect(prompt).toMatch(/^\n\[User\]: u1 /); expect(prompt).toContain("[Assistant]: a2 "); expect(prompt).not.toContain("u3 "); expect(prompt).toContain("## Goal"); expect(prompt.endsWith("\n\nAdditional focus: focus on files")).toBe(true); expect(request.options).toMatchObject({ cacheRetention: "none", maxTokens: 800, sessionId: (await chat.harness.snapshot(ProviderDoc, chat.root.id, context))!.sessionId, }); expect(request.options?.deferred).toBeUndefined(); // The summarizer's spend is in the ledger, and nothing counts it again later. const usage = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; expect(usage.input).toBeGreaterThan(usageBefore.input); await turn(chat, "next", "done"); const agent = chat.faux.agentRequests.at(-1)!.messages; // The next request: one complete system baseline leading the summary, the kept turn, and the new input (#10904). expect(agent.map((message) => message.role)).toEqual(["system", "user", "user", "assistant", "user"]); expect(agent[0]).toMatchObject({ sections: { preamble: "You are helpful." } }); await chat.harness.close(context); }); // Regression coverage for #10424. it("creates provider state before a legacy conversation's summarization request", async () => { const chat = await open(); await history(chat); await chat.root.commit((tx) => tx.retireDoc(ProviderDoc, chat.root.id), context); expect(await chat.harness.snapshot(ProviderDoc, chat.root.id, context)).toBeUndefined(); chat.faux.summaries.push(summary()); expect((await result(chat, await chat.root.compact(undefined, context))).status).toBe("completed"); const stored = await chat.harness.snapshot(ProviderDoc, chat.root.id, context); expect(stored?.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u); expect(chat.faux.summaryRequests[0]!.options?.sessionId).toBe(stored?.sessionId); await chat.harness.close(context); }); it("keeps working while busy and places the summary at the next final boundary", async () => { const chat = await open(); await history(chat); const gate = deferred(); const reached = deferred(); chat.faux.agent.push(gated(gate, answer("late answer"), reached)); const input = await chat.root.submit({ type: "input", content: "busy" }, context); await reached.promise; chat.faux.summaries.push(summary()); const outcome = await result(chat, await chat.root.compact(undefined, context)); const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; expect((await (await chat.harness.submission(submissionId!, context))!.status(context)).status).toBe("queued"); gate.resolve(); expect((await input.wait(context)).status).toBe("done"); const placed = await (await chat.harness.submission(submissionId!, context))!.wait(context); expect(placed.status).toBe("done"); // The summary follows the answer; the kept range still includes the busy turn. expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.user", "pi.assistant", "pi.compaction"]); await chat.harness.close(context); }); it("places a queued summary at postTools and the run continues in the compacted context", async () => { const chat = await open(); addTool( chat.setup.registry, defineTool({ name: "wait", description: "wait", parameters: Type.Object({}), execute: async () => ({ output: [{ type: "text", text: "waited" }] }), }), ); await chat.root.configure({ tools: toolsNamed(chat.setup, "wait") }, context); await history(chat); const gate = deferred(); const reached = deferred(); chat.faux.agent.push( gated(gate, fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }), reached), ); chat.faux.agent.push(answer("after tools")); const input = await chat.root.submit({ type: "input", content: "use a tool" }, context); await reached.promise; chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); gate.resolve(); expect((await input.wait(context)).status).toBe("done"); // The continuation request starts with the new baseline, then the summary, ahead of the kept tool call (#10904). const continuation = chat.faux.agentRequests.at(-1)!.messages; expect(continuation[0]?.role).toBe("system"); expect(userText(continuation[1])).toContain("\nSUMMARY\n"); expect((await kinds(chat.root)).slice(-4)).toEqual([ "pi.tool-result", "pi.compaction", "pi.system", "pi.assistant", ]); await chat.harness.close(context); }); it("runs follow-ups left by a failed run after placing the summary", async () => { const chat = await open(); await history(chat); const gate = deferred(); const reached = deferred(); chat.faux.agent.push(gated(gate, failure("bad request"), reached)); const failed = await chat.root.submit({ type: "input", content: "fails" }, context); await reached.promise; const followUp = await chat.root.submit({ type: "input", content: "follow-up" }, context); gate.resolve(); expect((await failed.wait(context)).status).toBe("unanswered"); expect((await followUp.status(context)).status).toBe("queued"); chat.faux.summaries.push(summary()); chat.faux.agent.push(answer("followed")); await result(chat, await chat.root.compact(undefined, context)); expect((await followUp.wait(context)).status).toBe("done"); const request = chat.faux.agentRequests.at(-1)!.messages; expect(firstUserText(request)).toContain("SUMMARY"); expect(request.some((message) => userText(message) === "follow-up")).toBe(true); await chat.harness.close(context); }); it("settles stale when a reset lands while it summarizes", async () => { const chat = await open(); await history(chat); const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); const id = await chat.root.compact(undefined, context); await reached.promise; await chat.root.reset(undefined, context); gate.resolve(); const outcome = await result(chat, id); const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; expect(await (await chat.harness.submission(submissionId!, context))!.status(context)).toMatchObject({ status: "unanswered", reason: "stale", }); expect((await kinds(chat.root)).at(-1)).toBe("pi.reset"); await chat.harness.close(context); }); it("does not make the conversation busy: a submission during summarization starts its run at once", async () => { const chat = await open(); await history(chat); const summaryGate = deferred(); const summaryReached = deferred(); chat.faux.summaries.push(gated(summaryGate, summary(), summaryReached)); const id = await chat.root.compact(undefined, context); await summaryReached.promise; const answerGate = deferred(); const answerReached = deferred(); chat.faux.agent.push(gated(answerGate, answer("a4"), answerReached)); const input = await chat.root.submit({ type: "input", content: "u4" }, context); // Placed and answered immediately with the uncompacted context, not queued behind the compaction. expect((await input.status(context)).status).toBe("placed"); await answerReached.promise; expect(firstUserText(chat.faux.agentRequests.at(-1)!.messages)).toBe(text("u1", 100)); // The summary is ready while the run is busy, so it queues and lands after the answer. summaryGate.resolve(); const outcome = await result(chat, id); const submission = (await chat.harness.submission( (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, context, ))!; expect((await submission.status(context)).status).toBe("queued"); answerGate.resolve(); expect((await input.wait(context)).status).toBe("done"); expect((await submission.wait(context)).status).toBe("done"); expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.user", "pi.assistant", "pi.compaction"]); await chat.harness.close(context); }); it("counts the spend of a summary that ends stale, and writes no entry for it", async () => { const chat = await open(); await history(chat); const input = async () => (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!.input; const before = await input(); const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); const id = await chat.root.compact(undefined, context); await reached.promise; await chat.root.reset(undefined, context); gate.resolve(); const outcome = await result(chat, id); const submission = (await chat.harness.submission( (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, context, ))!; expect(await submission.status(context)).toMatchObject({ status: "unanswered", reason: "stale" }); expect(await input()).toBeGreaterThan(before); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); await chat.harness.close(context); }); it("lets the compaction that cuts furthest win, whatever finishes first", async () => { const chat = await open(); await history(chat); const first = deferred(); const firstReached = deferred(); chat.faux.summaries.push(gated(first, summary("FIRST"), firstReached)); const early = await chat.root.compact(undefined, context); await firstReached.promise; await turn(chat, text("u4", 100), text("a4", 100)); chat.faux.summaries.push(summary("SECOND")); const later = await result(chat, await chat.root.compact(undefined, context)); expect(later.status).toBe("completed"); first.resolve(); const outcome = await result(chat, early); const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; // The early compaction cut at u3, before the later cut at u4. expect(await (await chat.harness.submission(submissionId!, context))!.status(context)).toMatchObject({ status: "unanswered", reason: "stale", }); expect(firstUserText((await chat.root.context(context)).messages)).toContain("SECOND"); await chat.harness.close(context); }); it("places an older-selected summary that cuts later than the newer one", async () => { const chat = await open(); await history(chat); // A: small budget, late cut; selected first, finishes last. const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary("A"), reached)); const a = await chat.root.compact(undefined, context); await reached.promise; // B: larger budget, earlier cut; placed first. chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: 350 }; chat.faux.summaries.push(summary("B")); await result(chat, await chat.root.compact(undefined, context)); expect(firstUserText((await chat.root.context(context)).messages)).toContain("B"); gate.resolve(); const outcome = await result(chat, a); const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; expect((await (await chat.harness.submission(submissionId!, context))!.status(context)).status).toBe("done"); const messages = (await chat.root.context(context)).messages; expect(firstUserText(messages)).toContain("\nA\n"); expect(messages.slice(1).map(userText)).toEqual([text("u3", 100), text("a3", 100)]); await chat.harness.close(context); }); for (const [order, keeps, stale] of [ ["before", [150, 350], true], ["at", [150, 150], false], ["after", [350, 150], false], ] as const) { it(`places two queued summaries in one boundary when the second cuts ${order} the first`, async () => { const chat = await open(); await history(chat); const gate = deferred(); const reached = deferred(); chat.faux.agent.push(gated(gate, answer("done"), reached)); const input = await chat.root.submit({ type: "input", content: "busy" }, context); await reached.promise; const submissions = []; for (const [index, keep] of keeps.entries()) { chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: keep }; chat.faux.summaries.push(summary(`S${index}`)); const outcome = await result(chat, await chat.root.compact(undefined, context)); submissions.push(outcome.status === "completed" ? outcome.result.submissionId! : undefined); } gate.resolve(); await input.wait(context); const statuses = []; for (const id of submissions) statuses.push((await (await chat.harness.submission(id!, context))!.wait(context)).status); expect(statuses).toEqual(["done", stale ? "unanswered" : "done"]); await chat.harness.close(context); }); } it("is aborted by Conversation.abort(); an already queued summary survives it", async () => { const chat = await open(); await history(chat); const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); const id = await chat.root.compact(undefined, context); await reached.promise; expect((await live(chat)).compactions).toHaveLength(1); await chat.root.abort(context); expect((await result(chat, id)).status).toBe("aborted"); expect((await live(chat)).compactions).toBeUndefined(); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); // Queued while busy, then Esc: the queued write stays and lands with the next run's boundary. const gate = deferred(); const busy = deferred(); chat.faux.agent.push(gated(gate, answer("never"), busy)); await chat.root.submit({ type: "input", content: "busy" }, context); await busy.promise; chat.faux.summaries.push(summary()); const queued = await result(chat, await chat.root.compact(undefined, context)); await chat.root.abort(context); const submissionId = queued.status === "completed" ? queued.result.submissionId! : undefined; const submission = (await chat.harness.submission(submissionId!, context))!; expect((await submission.status(context)).status).toBe("queued"); expect(await submission.abort(context)).toBe("aborted"); await turn(chat, "next", "ok"); expect(await submission.status(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); await chat.harness.close(context); }); it("is ordinary work: idle waits include it", async () => { const chat = await open(); await history(chat); const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); const id = await chat.root.compact(undefined, context); await reached.promise; let idle = false; const wait = chat.root.waitForIdle(context).then(() => { idle = true; }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(idle).toBe(false); gate.resolve(); await wait; expect((await chat.harness.getTask(id, context))?.state.status).toBe("terminal"); await chat.harness.close(context); }); it("enables scheduling right after open", async () => { const setup = chatSetup(); const faux = script(setup); const { harness, root } = await openChat(new MemoryStorage(), setup); setup.settings.compaction = { ...MANUAL, keepRecentTokens: 10 }; const id = await root.compact(undefined, context); // getTask() only reads, so progress here comes from compact() itself. await waitFor(async () => (await harness.getTask(id, context))?.state.status === "terminal"); expect((await harness.getTask(id, context))?.state).toMatchObject({ outcome: { status: "completed", result: {} }, }); expect(faux.summaryRequests).toHaveLength(0); await harness.close(context); }); }); describe("compaction outcomes", () => { it("completes without a summary when there is nothing to compact", async () => { const chat = await open(); await turn(chat, "hi", "hello"); const outcome = await result(chat, await chat.root.compact(undefined, context)); expect(outcome).toEqual({ status: "completed", result: {} }); expect(chat.faux.summaryRequests).toHaveLength(0); expect((await live(chat)).compactions).toBeUndefined(); await chat.harness.close(context); }); it("asks beforeCompact: the first decision wins, a throw is reported and skipped", async () => { const chat = await open(); const seen: unknown[] = []; addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => { throw new Error("hook broke"); }, }); addHooks(chat.setup.registry, CompactionTask, { beforeCompact: (compaction) => { seen.push(compaction); return { summary: "FROM HOOK" }; }, }); addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }); await history(chat); const outcome = await result(chat, await chat.root.compact("why", context)); expect(outcome.status).toBe("completed"); expect(chat.faux.summaryRequests).toHaveLength(0); expect(firstUserText((await chat.root.context(context)).messages)).toContain("\nFROM HOOK\n"); expect(chat.setup.reports).toEqual([expect.objectContaining({ message: "hook broke" })]); const compaction = seen[0] as { entries: EntryRecord[]; messages: Message[]; firstKept: number }; expect(compaction).toMatchObject({ reason: "manual", instructions: "why" }); expect(compaction.entries.map((record) => record.kind)).toEqual([ "pi.user", "pi.system", "pi.assistant", "pi.user", "pi.assistant", ]); expect(compaction.messages.filter((message) => message.role !== "system").map(userText)).toEqual([ text("u1", 100), text("a1", 100), text("u2", 100), text("a2", 100), ]); await chat.harness.close(context); }); it("completes without a summary when a hook declines", async () => { const chat = await open(); addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }); await history(chat); expect(await result(chat, await chat.root.compact(undefined, context))).toEqual({ status: "completed", result: {}, }); expect(chat.faux.summaryRequests).toHaveLength(0); await chat.harness.close(context); }); it("fails with no_model without a configured model", async () => { const chat = await open(); await history(chat); await chat.root.configure({ model: null }, context); const outcome = await result(chat, await chat.root.compact(undefined, context)); expect(outcome).toMatchObject({ status: "failed", error: { detail: { reason: "no_model" } } }); expect((await live(chat)).compactions).toBeUndefined(); await chat.harness.close(context); }); /** Input tokens a compaction added to the ledger. */ async function compactionInput(chat: Chat, run: () => Promise): Promise { const input = async () => (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!.input; const before = await input(); await run(); return (await input()) - before; } it("retries a retryable error with the pinned request and counts every attempt once", async () => { const single = await open(); await history(single); await single.root.configure({ thinkingLevel: "high" }, context); single.faux.summaries.push(summary()); const once = await compactionInput(single, async () => result(single, await single.root.compact(undefined, context)), ); expect(once).toBeGreaterThan(0); await single.harness.close(context); const chat = await open(); await history(chat); await chat.root.configure({ thinkingLevel: "high" }, context); chat.setup.settings.stream = { timeoutMs: 1234, deferred: true }; chat.faux.summaries.push(async () => { // Changed during the attempt: the retry still uses the pinned request. await chat.root.configure({ thinkingLevel: "low" }, context); chat.setup.settings.stream = { timeoutMs: 1 }; return failure("overloaded"); }); chat.faux.summaries.push(summary()); const twice = await compactionInput(chat, async () => { expect((await result(chat, await chat.root.compact(undefined, context))).status).toBe("completed"); }); expect(chat.faux.summaryRequests).toHaveLength(2); expect(twice).toBe(2 * once); const sessionId = (await chat.harness.snapshot(ProviderDoc, chat.root.id, context))!.sessionId; for (const request of chat.faux.summaryRequests) { expect(request.options).toMatchObject({ reasoning: "high", timeoutMs: 1234, cacheRetention: "none", sessionId, }); expect(request.options?.deferred).toBeUndefined(); } await chat.harness.close(context); }); it("adds no usage when a hook declines or supplies the summary", async () => { for (const decision of [{ decline: true } as const, { summary: "HOOK" }]) { const chat = await open(); addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => decision }); await history(chat); expect( await compactionInput(chat, async () => result(chat, await chat.root.compact(undefined, context))), ).toBe(0); await chat.harness.close(context); } }); it("caps maxTokens at the model's output limit and sends no tools", async () => { const chat = await open(); addTool( chat.setup.registry, defineTool({ name: "read", description: "read", parameters: Type.Object({}), execute: async () => ({ output: [] }), }), ); await chat.root.configure({ tools: toolsNamed(chat.setup, "read") }, context); await history(chat); chat.setup.settings.compaction = { ...MANUAL, reserveTokens: 2000 }; chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); const request = chat.faux.summaryRequests[0]!; // 0.8 * 2000 = 1600, above the model's 900. expect(request.options?.maxTokens).toBe(900); expect(request.messages).toHaveLength(2); expect(request.messages.some((message) => message.role === "system" && message.toolsAdded !== undefined)).toBe( false, ); await chat.harness.close(context); }); for (const [name, response, message] of [ ["retries run out", failure("overloaded"), "Summarization failed: overloaded"], ["a non-retryable error", failure("bad request"), "Summarization failed: bad request"], [ "a length stop", fauxAssistantMessage("partial", { stopReason: "length" }), "Summarization hit the token limit; the summary is incomplete", ], [ "a tool call", fauxAssistantMessage([fauxToolCall("read", {})], { stopReason: "stop" }), "Summarization attempted to call a tool", ], ["empty text", answer(" "), "Summarization produced no text"], ] as const) { it(`fails with model_error on ${name}`, async () => { const chat = await open(); await history(chat); for (let index = 0; index < 3; index++) chat.faux.summaries.push(response); const outcome = await result(chat, await chat.root.compact(undefined, context)); expect(outcome).toMatchObject({ status: "failed", error: { message, detail: { reason: "model_error" } } }); // The retry policy allows two retries after the first attempt. expect(chat.faux.summaryRequests).toHaveLength(name === "retries run out" ? 3 : 1); expect((await live(chat)).compactions).toBeUndefined(); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); await chat.harness.close(context); }); } }); // ─── Automatic compaction ───────────────────────────────────────────────── /** Background threshold at 500 and blocking threshold at 1500 tokens of a 2000-token window. */ const BACKGROUND: CompactionPolicy = { enabled: true, reserveTokens: 500, keepRecentTokens: 150, backgroundTokens: 1000, }; /** Blocking threshold at 700 tokens of a 1000-token window, no background compaction. */ const BLOCKING: CompactionPolicy = { enabled: true, reserveTokens: 300, keepRecentTokens: 150, backgroundTokens: 0 }; async function compactionTasks(chat: Chat) { const inspection = await chat.harness.inspect(context); return inspection.tasks.map((task) => task.record).filter((record) => record.kind === "pi.compaction"); } describe("background threshold compaction", () => { it("starts above the background threshold without blocking the run; idle waits and Esc ignore it", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = BACKGROUND; const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); await turn(chat, text("u4", 100), text("a4", 100)); await reached.promise; const [task] = await compactionTasks(chat); expect(task).toMatchObject({ background: true, input: { reason: "threshold" } }); expect(task!.owner).toBeUndefined(); expect((await live(chat)).compactions).toEqual([ { taskId: task!.id, reason: "threshold", blocking: false, attempt: 1 }, ]); // Background work: neither conversation idle nor Esc waits for or stops it. await chat.root.waitForIdle(context); await chat.root.abort(context); expect((await chat.harness.getTask(task!.id, context))?.state.status).toBe("running"); gate.resolve(); const outcome = await result(chat, task!.id as TaskId); expect(outcome.status).toBe("completed"); expect((await kinds(chat.root)).at(-1)).toBe("pi.compaction"); expect(firstUserText((await chat.root.context(context)).messages)).toContain("SUMMARY"); await chat.harness.close(context); }); for (const [name, policy] of [ ["disabled", { ...BACKGROUND, enabled: false }], ["backgroundTokens is 0", { ...BACKGROUND, backgroundTokens: 0 }], ["there is no cut", { ...BACKGROUND, keepRecentTokens: 100_000 }], ] as const) { it(`does not start when ${name}`, async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = policy; await turn(chat, text("u4", 100), text("a4", 100)); expect(await compactionTasks(chat)).toEqual([]); expect(chat.faux.summaryRequests).toHaveLength(0); await chat.harness.close(context); }); } it("does not start while another compaction is listed", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); const manual = await chat.root.compact(undefined, context); await reached.promise; chat.setup.settings.compaction = BACKGROUND; await turn(chat, text("u4", 100), text("a4", 100)); expect((await compactionTasks(chat)).map((task) => task.id)).toEqual([manual]); await chat.harness.abortTask(manual, context); await chat.harness.close(context); }); it("records a failure in pi.live, the snapshot, and compaction_end until a summary is produced", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = BACKGROUND; const events: AgentEvent[] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { events.push(...batch); }); chat.faux.summaries.push(failure("bad request")); await turn(chat, text("u4", 100), text("a4", 100)); await waitFor(() => events.some((event) => event.type === "compaction_end")); const recorded = (await live(chat)).compactionFailure; expect(recorded).toMatchObject({ reason: "threshold", error: "Summarization failed: bad request" }); expect(events.find((event) => event.type === "compaction_end")).toEqual({ type: "compaction_end", taskId: recorded!.taskId, reason: "threshold", error: "Summarization failed: bad request", }); const reopened = await watchEvents(chat.harness, chat.root.id, context); expect(reopened.snapshot.compactionFailure).toEqual(recorded); await reopened.stop(); // A compaction that produces a summary removes the failure, and ends without an error. chat.setup.settings.compaction = MANUAL; chat.faux.summaries.push(summary()); const manual = await chat.root.compact(undefined, context); expect((await result(chat, manual)).status).toBe("completed"); expect((await live(chat)).compactionFailure).toBeUndefined(); await waitFor(() => events.some((event) => event.type === "compaction_end" && event.taskId === manual)); expect(events.find((event) => event.type === "compaction_end" && event.taskId === manual)).not.toHaveProperty( "error", ); await stream.stop(); await chat.harness.close(context); }); it("stops through abortTask() and Conversation.abort() with background", async () => { for (const stop of ["task", "conversation"] as const) { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = BACKGROUND; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); await turn(chat, text("u4", 100), text("a4", 100)); await reached.promise; const [task] = await compactionTasks(chat); if (stop === "task") await chat.harness.abortTask(task!.id, context); else await chat.root.abort(context, { background: true }); expect((await result(chat, task!.id as TaskId)).status).toBe("aborted"); expect((await live(chat)).compactions).toBeUndefined(); await chat.harness.close(context); } }); }); describe("blocking threshold compaction", () => { it("waits for its compaction, which appends the summary before the request", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; // A new section: preparation has a system entry to append, but must not append it before the wait. addSection(chat.setup.registry, "extra", () => "EXTRA"); const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); chat.faux.agent.push(answer("a4")); const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); await reached.promise; const [child] = await compactionTasks(chat); const generation = (await live(chat)).run!.taskId; expect(child).toMatchObject({ owner: generation, background: false, input: { reason: "threshold" } }); expect((await chat.harness.getTask(generation, context))?.state).toMatchObject({ status: "waiting", on: [child!.id], checkpoint: { phase: "prepare", attempt: 1, compacted: child!.id }, }); expect((await live(chat)).compactions).toEqual([ { taskId: child!.id, reason: "threshold", blocking: true, attempt: 1 }, ]); // Nothing was appended before the wait. expect((await kinds(chat.root)).at(-1)).toBe("pi.user"); gate.resolve(); expect((await input.wait(context)).status).toBe("done"); expect((await result(chat, child!.id as TaskId)).status).toBe("completed"); expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.compaction", "pi.system", "pi.assistant"]); const request = chat.faux.agentRequests.at(-1)!.messages; expect(firstUserText(request)).toContain("SUMMARY"); const systems = request.filter((message) => message.role === "system"); expect(systems).toHaveLength(1); expect(systems[0]).toMatchObject({ sections: { preamble: "You are helpful.", extra: "\nEXTRA\n" }, }); await chat.harness.close(context); }); it("sends the request once, without a second compaction, when the kept part is still above the threshold", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = { ...BLOCKING, keepRecentTokens: 700 }; chat.faux.summaries.push(summary()); await turn(chat, text("u4", 400), "a4"); expect(chat.faux.summaryRequests).toHaveLength(1); expect((await kinds(chat.root)).filter((kind) => kind === "pi.compaction")).toHaveLength(1); await chat.harness.close(context); }); it("sends the request anyway when its compaction declines", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }); await turn(chat, text("u4", 200), "a4"); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); expect(firstUserText(chat.faux.agentRequests.at(-1)!.messages)).toBe(text("u1", 100)); expect((await live(chat)).compactionFailure).toBeUndefined(); await chat.harness.close(context); }); // A failed compaction leaves a context it judged too large; sending it once filled a 1M window and got an empty // `length` answer on every turn while the failure stayed invisible. it("fails the run with the compaction's error, without a request, when its compaction fails", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; chat.faux.summaries.push(failure("bad request")); const requests = chat.faux.agentRequests.length; const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error", detail: "Compaction failed: Summarization failed: bad request", }); expect(chat.faux.agentRequests).toHaveLength(requests); expect((await kinds(chat.root)).at(-1)).toBe("pi.user"); const state = await live(chat); expect(state.compactions).toBeUndefined(); expect(state.compactionFailure).toMatchObject({ reason: "threshold", error: "Summarization failed: bad request", }); await chat.harness.close(context); }); it("fails the run when its compaction is aborted directly", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); const requests = chat.faux.agentRequests.length; const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); await reached.promise; const [child] = await compactionTasks(chat); await chat.harness.abortTask(child!.id, context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error", detail: "Compaction was aborted", }); expect(chat.faux.agentRequests).toHaveLength(requests); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); // Aborting is not a failure. expect((await live(chat)).compactionFailure).toBeUndefined(); await chat.harness.close(context); }); it("is aborted with its generation by Esc, before the generation's abort handler", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); const events: AgentEvent[] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { events.push(...batch); }); const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); await reached.promise; const [child] = await compactionTasks(chat); await chat.root.abort(context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); expect((await chat.harness.getTask(child!.id, context))?.state).toMatchObject({ outcome: { status: "aborted" } }); await waitFor(() => events.some((event) => event.type === "run_end")); const types = events.map((event) => event.type); expect(types.indexOf("compaction_end")).toBeLessThan(types.indexOf("run_end")); await stream.stop(); await chat.harness.close(context); }); it("wins over a background compaction still in flight, which then settles stale", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = BACKGROUND; const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary("BACKGROUND"), reached)); await turn(chat, text("u4", 100), text("a4", 100)); await reached.promise; const [background] = await compactionTasks(chat); chat.faux.summaries.push(summary("BLOCKING")); await turn(chat, text("u5", 1000), "a5"); expect(firstUserText((await chat.root.context(context)).messages)).toContain("BLOCKING"); gate.resolve(); const outcome = await result(chat, background!.id as TaskId); const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; expect(await (await chat.harness.submission(submissionId!, context))!.status(context)).toMatchObject({ status: "unanswered", reason: "stale", }); await chat.harness.close(context); }); }); describe("overflow compaction", () => { const ENABLED: CompactionPolicy = { ...MANUAL, enabled: true }; it("compacts and retries with the same attempt, leaving the error out of the retry", async () => { const chat = await open(); await history(chat); chat.setup.settings.compaction = ENABLED; chat.faux.summaries.push(summary()); let attempt: number | undefined; chat.faux.agent.push(failure(OVERFLOW)); chat.faux.agent.push(async () => { attempt = (await live(chat)).generation?.attempt; return answer("fits"); }); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); expect((await input.wait(context)).status).toBe("done"); expect(attempt).toBe(1); expect((await kinds(chat.root)).slice(-5)).toEqual([ "pi.user", "pi.assistant", "pi.compaction", "pi.system", "pi.assistant", ]); const [compaction] = (await allEntries(chat.root)).filter((record) => record.kind === "pi.compaction"); expect(compaction).toMatchObject({ data: { reason: "overflow" } }); const retry = chat.faux.agentRequests.at(-1)!.messages; expect(retry[0]?.role).toBe("system"); expect(userText(retry[1])).toContain("SUMMARY"); expect(retry.some((message) => message.role === "assistant" && message.stopReason === "error")).toBe(false); await chat.harness.close(context); }); it("fails a second overflow with its error entry", async () => { const chat = await open(); await history(chat); chat.setup.settings.compaction = ENABLED; chat.faux.summaries.push(summary()); chat.faux.agent.push(failure(OVERFLOW), failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error", detail: OVERFLOW, }); expect((await kinds(chat.root)).filter((kind) => kind === "pi.compaction")).toHaveLength(1); expect((await kinds(chat.root)).at(-1)).toBe("pi.assistant"); await chat.harness.close(context); }); it("fails without compacting when compaction is disabled, even for a retryable-looking overflow", async () => { const chat = await open(); await history(chat); chat.faux.agent.push(failure(`overloaded: ${OVERFLOW}`)); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error" }); expect(chat.faux.agentRequests).toHaveLength(4); expect(chat.faux.summaryRequests).toHaveLength(0); await chat.harness.close(context); }); it("fails after a blocking threshold compaction in the same generation", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; chat.faux.summaries.push(summary()); chat.faux.agent.push(failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error" }); expect(chat.faux.summaryRequests).toHaveLength(1); await chat.harness.close(context); }); for (const [name, prepare, requests] of [ [ "declines", (chat: Chat) => addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }), 0, ], ["fails", (chat: Chat) => chat.faux.summaries.push(failure("bad request")), 1], // Classification finds no cut, so no compaction starts and the ordinary failure carries the text. [ "cannot cut", (chat: Chat) => { chat.setup.settings.compaction = { ...ENABLED, keepRecentTokens: 100_000 }; }, 0, ], ] as const) { it(`fails with the overflow text when compaction ${name}`, async () => { const chat = await open(); await turn(chat, text("u1", 100), text("a1", 100)); chat.setup.settings.compaction = ENABLED; await prepare(chat); chat.faux.agent.push(failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error", detail: OVERFLOW, }); expect(chat.faux.summaryRequests).toHaveLength(requests); await chat.harness.close(context); }); } }); describe("compaction estimates and interactions", () => { for (const clock of ["real", "fixed"] as const) { it(`ignores usage measured before a summary placed mid-run (${clock} clock)`, async () => { const setup = chatSetup({ models: [{ id: "faux-1", contextWindow: 2000, maxTokens: 900 }] }); if (clock === "fixed") setup.now = () => 1_000; const chat = await open({ contextWindow: 2000 }, setup); const toolGate = deferred(); const toolReached = deferred(); addTool( chat.setup.registry, defineTool({ name: "slow", description: "slow", parameters: Type.Object({}), execute: async () => { toolReached.resolve(); await toolGate.promise; return { output: [{ type: "text", text: text("result", 200) }] }; }, }), ); await chat.root.configure({ tools: toolsNamed(chat.setup, "slow") }, context); await turn(chat, text("u1", 220), text("a1", 220)); await turn(chat, text("u2", 220), text("a2", 220)); await turn(chat, text("u3", 220), text("a3", 220)); // Background at 900, blocking at 1500: the tool call's usage plus its result would cross 1500. chat.setup.settings.compaction = { ...BACKGROUND, backgroundTokens: 600 }; // The request starts a background compaction; its tool call's usage measures the whole context. chat.faux.agent.push(fauxAssistantMessage([fauxToolCall("slow", {})], { stopReason: "toolUse" })); chat.faux.agent.push(answer("done")); const summaryGate = deferred(); const summaryReached = deferred(); chat.faux.summaries.push(gated(summaryGate, summary(), summaryReached)); const input = await chat.root.submit({ type: "input", content: text("u4", 50) }, context); await Promise.all([toolReached.promise, summaryReached.promise]); const [background] = await compactionTasks(chat); summaryGate.resolve(); // Queued: the run is busy in its tool round. const queued = await result(chat, background!.id as TaskId); const submissionId = queued.status === "completed" ? queued.result.submissionId! : undefined; expect((await (await chat.harness.submission(submissionId!, context))!.status(context)).status).toBe("queued"); toolGate.resolve(); expect((await input.wait(context)).status).toBe("done"); // The summary landed at postTools; the successor saw a small context and did not compact again. expect(chat.faux.summaryRequests).toHaveLength(1); expect(await compactionTasks(chat)).toEqual([]); expect(firstUserText(chat.faux.agentRequests.at(-1)!.messages)).toContain("SUMMARY"); await chat.harness.close(context); }); } it("rebaselines the system prompt over kept system deltas", async () => { const chat = await open(); let mood = "cheerful"; addSection(chat.setup.registry, "mood", () => mood, { tag: false }); await turn(chat, text("u1", 100), text("a1", 100)); await turn(chat, text("u2", 100), text("a2", 100)); mood = "terse"; await turn(chat, text("u3", 100), text("a3", 100)); chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: 250 }; chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); // The kept range holds the delta for the terse mood; the next request has one complete baseline. expect((await chat.root.context(context)).entries.some((record) => record.kind === "pi.system")).toBe(true); await turn(chat, "next", "ok"); const systems = chat.faux.agentRequests.at(-1)!.messages.filter((message) => message.role === "system"); expect(systems).toHaveLength(1); expect(systems[0]).toMatchObject({ sections: { preamble: "You are helpful.", mood: "terse" } }); await chat.harness.close(context); }); it("summarizes a replaced entry's replacement and shows it to the hook", async () => { const chat = await open(); await history(chat); const [u1] = await allEntries(chat.root); await chat.root.submit( { type: "write", entry: { kind: "app.redact", edits: [ { target: u1!.id, action: "replace", messages: [{ role: "user", content: "REDACTED", timestamp: 0 }], }, ], }, }, context, ); let messages: readonly Message[] = []; addHooks(chat.setup.registry, CompactionTask, { beforeCompact: (compaction) => { messages = compaction.messages; return undefined; }, }); chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); expect(firstUserText(messages)).toBe("REDACTED"); expect(userText(chat.faux.summaryRequests[0]!.messages[1])).toContain("[User]: REDACTED"); await chat.harness.close(context); }); it("compacts a fork whose cut falls on a parent entry", async () => { const chat = await open(); await history(chat); const entries = await allEntries(chat.root); const fork = await chat.root.fork(entries.at(-1)!.id, { ownership: { kind: "ownerless" } }, context); chat.faux.summaries.push(summary()); const outcome = await result(chat, await fork.compact(undefined, context)); const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; expect((await (await chat.harness.submission(submissionId!, context))!.wait(context)).status).toBe("done"); const u3 = entries.find((record) => userText(record.model?.[0]).startsWith("u3"))!; const view = await fork.context(context); expect(view.head).toMatchObject({ kind: "pi.compaction", head: u3.id, conversationId: fork.id }); expect(view.messages.slice(1).map(userText)).toEqual([text("u3", 100), text("a3", 100)]); // The parent is untouched. expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); // The fork's view keeps the parent entries the summary kept. const state = await fork.viewState(context); expect(state.value.entries).toEqual(view.entries); state.dispose(); await chat.harness.close(context); }); it("settles stale in a fork reset while it summarizes", async () => { const chat = await open(); await history(chat); const entries = await allEntries(chat.root); const fork = await chat.root.fork(entries.at(-1)!.id, { ownership: { kind: "ownerless" } }, context); const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); const id = await fork.compact(undefined, context); await reached.promise; await fork.reset(undefined, context); gate.resolve(); const outcome = await result(chat, id); const submission = (await chat.harness.submission( (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, context, ))!; expect(await submission.status(context)).toMatchObject({ status: "unanswered", reason: "stale" }); expect((await fork.context(context)).head?.kind).toBe("pi.reset"); await chat.harness.close(context); }); it("places an older queued summary and the current one together when idle admission drains the inbox", async () => { const chat = await open(); await history(chat); const gate = deferred(); const reached = deferred(); chat.faux.agent.push(gated(gate, failure("bad request"), reached)); const failed = await chat.root.submit({ type: "input", content: "fails" }, context); await reached.promise; chat.faux.summaries.push(summary("OLDER")); const older = await result(chat, await chat.root.compact(undefined, context)); gate.resolve(); await failed.wait(context); // Idle now, with the older summary still queued; the current one queues behind it and a final boundary runs. chat.faux.summaries.push(summary("CURRENT")); const current = await result(chat, await chat.root.compact(undefined, context)); const statuses = []; for (const outcome of [older, current]) { const id = outcome.status === "completed" ? outcome.result.submissionId! : undefined; statuses.push((await (await chat.harness.submission(id!, context))!.status(context)).status); } expect(statuses).toEqual(["done", "done"]); expect(firstUserText((await chat.root.context(context)).messages)).toContain("CURRENT"); await chat.harness.close(context); }); it("places a hook's summary and holds while work the hook created runs", async () => { const chat = await open(); const childGate = deferred(); const Child = defineChildTask(childGate); addTask(chat.setup.registry, Child); addHooks(chat.setup.registry, CompactionTask, { beforeCompact: async (_compaction, api, hookContext) => { await chat.harness.commit(async (tx) => { await tx.createTask( Child, {}, { ownership: { kind: "task", taskId: api.taskId }, conversationId: chat.root.id }, ); }, hookContext); return { summary: "HOOK" }; }, }); await history(chat); const id = await chat.root.compact(undefined, context); await waitFor(async () => (await chat.harness.getTask(id, context))?.state.status === "completing"); // The summary and the status removal landed at the hold. expect((await kinds(chat.root)).at(-1)).toBe("pi.compaction"); expect((await live(chat)).compactions).toBeUndefined(); childGate.resolve(); expect((await result(chat, id)).status).toBe("completed"); await chat.harness.close(context); }); it("removes the status of a faulted compaction", async () => { const chat = await open(); await history(chat); const getModel = chat.setup.models.getModel.bind(chat.setup.models); chat.setup.models.getModel = () => { throw new Error("models broke"); }; const outcome = await result(chat, await chat.root.compact(undefined, context)); chat.setup.models.getModel = getModel; expect(outcome).toMatchObject({ status: "faulted", error: { message: "models broke" } }); expect((await live(chat)).compactions).toBeUndefined(); await chat.harness.close(context); }); }); function defineChildTask(gate: Deferred) { return defineTask, { phase: "run" }, null>({ name: "test.child", version: 1, initial: () => ({ phase: "run" }), phases: { run: async (_task, runtime, taskContext) => { await gate.promise; await runtime.commit( () => ({ status: "terminal", outcome: { status: "completed", result: null } }), taskContext, ); }, }, abort: async (_task, runtime, taskContext) => { await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext); }, }); } describe("compaction events and live status", () => { it("reports start and end, and the retry backoff in a late joiner's snapshot", async () => { const chat = await open(); await history(chat); chat.setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 60_000 }; const events: AgentEvent[] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { events.push(...batch); }); chat.faux.summaries.push(failure("overloaded")); const id = await chat.root.compact(undefined, context); await waitFor(async () => (await live(chat)).compactions?.[0]?.retry !== undefined); const late = await watchEvents(chat.harness, chat.root.id, context); expect(late.snapshot.compactions).toEqual([ { taskId: id, reason: "manual", blocking: false, attempt: 1, retry: { at: expect.any(Number), error: "overloaded" }, }, ]); await late.stop(); await chat.harness.abortTask(id, context); await waitFor(() => events.some((event) => event.type === "compaction_end")); expect(events.filter((event) => event.type.startsWith("compaction_"))).toEqual([ { type: "compaction_start", taskId: id, reason: "manual", blocking: false }, { type: "compaction_end", taskId: id, reason: "manual" }, ]); await stream.stop(); await chat.harness.close(context); }); it("lists concurrent compactions in task ID order", async () => { const chat = await open(); await history(chat); const first = deferred(); const second = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), first), gated(deferred(), summary(), second)); const a = await chat.root.compact(undefined, context); const b = await chat.root.compact(undefined, context); await Promise.all([first.promise, second.promise]); expect((await live(chat)).compactions?.map((status) => status.taskId)).toEqual([a, b]); await chat.root.abort(context); expect((await live(chat)).compactions).toBeUndefined(); await chat.harness.close(context); }); }); // ─── Recovery ───────────────────────────────────────────────────────────── describe("compaction recovery", () => { async function reopen(path: string, setup: ChatSetup, faux: Script): Promise { const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); harness.resume(); return { harness, root, setup, faux }; } async function first(path: string) { const setup = chatSetup(); addSection(setup.registry, "preamble", () => "You are helpful.", { tag: false }); const faux = script(setup); const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); setup.settings.compaction = MANUAL; setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 1 }; harness.resume(); const chat = { harness, root, setup, faux }; await history(chat); return chat; } it("repeats nothing after reopen once the summary is placed", async () => { const path = await sqlitePath(); let chat = await first(path); chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); const entries = await allEntries(chat.root); const usage = await chat.harness.snapshot(UsageDoc, chat.root.id, context); await chat.harness.close(context); chat = await reopen(path, chat.setup, chat.faux); await chat.harness.waitForIdle(context); expect(chat.faux.summaryRequests).toHaveLength(1); expect(await allEntries(chat.root)).toEqual(entries); expect(await chat.harness.snapshot(UsageDoc, chat.root.id, context)).toEqual(usage); const inspection = await chat.harness.inspect(context); expect([inspection.tasks, inspection.submissions]).toEqual([[], []]); await chat.harness.close(context); }); it("fails an overflow run with its text when its compaction fails after reopen", async () => { const path = await sqlitePath(); let chat = await first(path); chat.setup.settings.compaction = { ...MANUAL, enabled: true }; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); chat.faux.agent.push(failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); await reached.promise; const generation = (await live(chat)).run!.taskId; expect((await chat.harness.getTask(generation, context))?.state).toMatchObject({ status: "waiting", checkpoint: { phase: "prepare", overflow: OVERFLOW }, }); await chat.harness.close(context); chat = await reopen(path, chat.setup, chat.faux); chat.faux.summaries.push(failure("bad request")); const settled = await (await chat.harness.submission(input.id, context))!.wait(context); expect(settled).toMatchObject({ status: "unanswered", reason: "model_error", detail: OVERFLOW }); await chat.harness.close(context); }); it("reruns select and its hook after a crash in select", async () => { const path = await sqlitePath(); let chat = await first(path); let calls = 0; const reached = deferred(); addHooks(chat.setup.registry, CompactionTask, { beforeCompact: async (_compaction, _api, hookContext) => { calls++; if (calls === 1) { reached.resolve(); await aborted(hookContext.abortSignal!); } return undefined; }, }); const id = await chat.root.compact(undefined, context); await reached.promise; await chat.harness.close(context); chat = await reopen(path, chat.setup, chat.faux); chat.faux.summaries.push(summary()); expect((await result(chat, id)).status).toBe("completed"); expect(calls).toBe(2); await chat.harness.close(context); }); it("resends an interrupted summarization once and counts only the answered attempt", async () => { const path = await sqlitePath(); let chat = await first(path); const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); const id = await chat.root.compact(undefined, context); await reached.promise; const usageBefore = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; expect((await chat.harness.getTask(id, context))?.state).toMatchObject({ checkpoint: { phase: "summarize" } }); await chat.harness.close(context); chat = await reopen(path, chat.setup, chat.faux); chat.faux.summaries.push(summary()); expect((await result(chat, id)).status).toBe("completed"); expect(chat.faux.summaryRequests).toHaveLength(2); const [, resent] = chat.faux.summaryRequests; expect(resent!.messages).toEqual( chat.faux.summaryRequests[0]!.messages.map((message) => ({ ...message, timestamp: expect.any(Number) })), ); const usage = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]!; expect(usage.input).toBeGreaterThan(usageBefore.input); expect(usage.output - usageBefore.output).toBe(2); await chat.harness.close(context); }); it("resumes a retry backoff after reopen", async () => { const path = await sqlitePath(); let chat = await first(path); let now = Date.now(); chat.setup.now = () => now; chat.setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 60_000 }; chat.faux.summaries.push(failure("overloaded")); const id = await chat.root.compact(undefined, context); await waitFor(async () => (await live(chat)).compactions?.[0]?.retry !== undefined); expect((await chat.harness.getTask(id, context))?.state).toMatchObject({ checkpoint: { phase: "retry", attempt: 1 }, }); await chat.harness.close(context); now += 120_000; chat = await reopen(path, chat.setup, chat.faux); chat.faux.summaries.push(summary()); expect((await result(chat, id)).status).toBe("completed"); expect(chat.faux.summaryRequests).toHaveLength(2); await chat.harness.close(context); }); it("keeps a generation waiting on its blocking compaction across reopen", async () => { const path = await sqlitePath(); let chat = await first(path); const setup = chat.setup; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); // The faux model window is 128k; this blocking threshold needs a small context window. chat.setup.settings.compaction = { ...BLOCKING, reserveTokens: 128_000 - 700 }; const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); await reached.promise; await chat.harness.close(context); chat = await reopen(path, setup, chat.faux); chat.faux.summaries.push(summary()); chat.faux.agent.push(answer("a4")); expect((await (await chat.harness.submission(input.id, context))!.wait(context)).status).toBe("done"); expect((await kinds(chat.root)).slice(-3)).toEqual(["pi.compaction", "pi.system", "pi.assistant"]); await chat.harness.close(context); }); it("keeps a queued summary across reopen and places it at the next boundary", async () => { const path = await sqlitePath(); let chat = await first(path); const reached = deferred(); chat.faux.agent.push(gated(deferred(), answer("never"), reached)); const input = await chat.root.submit({ type: "input", content: "busy" }, context); await reached.promise; chat.faux.summaries.push(summary()); const outcome = await result(chat, await chat.root.compact(undefined, context)); await chat.harness.close(context); chat = await reopen(path, chat.setup, chat.faux); chat.faux.agent.push(answer("answered")); expect((await (await chat.harness.submission(input.id, context))!.wait(context)).status).toBe("done"); const submissionId = outcome.status === "completed" ? outcome.result.submissionId! : undefined; expect((await (await chat.harness.submission(submissionId!, context))!.wait(context)).status).toBe("done"); await chat.harness.close(context); }); }); describe("compaction and the inbox", () => { async function queuedSummary(chat: Chat, content = "SUMMARY") { chat.faux.summaries.push(summary(content)); const outcome = await result(chat, await chat.root.compact(undefined, context)); return (await chat.harness.submission( (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, context, ))!; } async function busy(chat: Chat, reply = answer("done")) { const gate = deferred(); const reached = deferred(); chat.faux.agent.push(gated(gate, reply, reached)); const input = await chat.root.submit({ type: "input", content: "busy" }, context); await reached.promise; return { input, release: () => gate.resolve() }; } it("places a reset queued after the summary last, and makes a summary queued after a reset stale", async () => { for (const order of ["summary first", "reset first"] as const) { const chat = await open(); await history(chat); const run = await busy(chat); let submission: Awaited>; if (order === "summary first") { submission = await queuedSummary(chat); await chat.root.reset(undefined, context); } else { await chat.root.reset(undefined, context); submission = await queuedSummary(chat); } run.release(); await run.input.wait(context); const settled = await submission.wait(context); expect(settled.status).toBe(order === "summary first" ? "done" : "unanswered"); expect((await kinds(chat.root)).at(-1)).toBe("pi.reset"); expect((await chat.root.context(context)).head?.kind).toBe("pi.reset"); await chat.harness.close(context); } }); it("places a summary left queued by a failed run at the next submission, before its input", async () => { const chat = await open(); await history(chat); const run = await busy(chat, failure("bad request")); const submission = await queuedSummary(chat); run.release(); expect((await run.input.wait(context)).status).toBe("unanswered"); expect((await submission.status(context)).status).toBe("queued"); await turn(chat, "again", "ok"); expect((await submission.status(context)).status).toBe("done"); const request = chat.faux.agentRequests.at(-1)!.messages; expect(firstUserText(request)).toContain("SUMMARY"); expect(request.map(userText)).toContain("again"); await chat.harness.close(context); }); it("keeps the full retry budget after an overflow compaction", async () => { const chat = await open(); await history(chat); chat.setup.settings.compaction = { ...MANUAL, enabled: true }; chat.faux.summaries.push(summary()); chat.faux.agent.push( failure("prompt is too long"), failure("overloaded"), failure("overloaded"), answer("finally"), ); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); expect((await input.wait(context)).status).toBe("done"); await chat.harness.close(context); }); it("loses an application edit placed while a compaction summarizes (spec §12)", async () => { const chat = await open(); await history(chat); const [u1] = await allEntries(chat.root); const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); const id = await chat.root.compact(undefined, context); await reached.promise; const edit = { target: u1!.id, action: "replace", messages: [user("REDACTED")] } as const; await chat.root.submit({ type: "write", entry: { kind: "app.redact", edits: [edit] } }, context); gate.resolve(); await result(chat, id); // The summary was made from the unredacted entry, and the edit's target left the range. expect(userText(chat.faux.summaryRequests[0]!.messages[1])).toContain("[User]: u1 "); expect((await chat.root.context(context)).messages.map(userText)).not.toContain("REDACTED"); await chat.harness.close(context); }); it("keeps the kept entries mounted in the conversation view", async () => { const chat = await open(); await history(chat); const state = await chat.root.viewState(context); chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); await waitFor(() => state.value.entries[0]?.kind === "pi.compaction"); expect(state.value.entries).toEqual((await chat.root.context(context)).entries); state.dispose(); await chat.harness.close(context); }); }); describe("compaction edge cases", () => { it("keeps the one-compaction limit through a retry backoff", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; let asked = 0; addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => { asked++; return { decline: true }; }, }); chat.faux.agent.push(failure("overloaded")); await turn(chat, text("u4", 200), "a4"); // One blocking compaction, declined; the retried preparation, still above the threshold, started no other. expect(asked).toBe(1); expect(chat.faux.agentRequests).toHaveLength(5); await chat.harness.close(context); }); it("does not start a background compaction when a manual one was admitted during preparation", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = BACKGROUND; const rendering = deferred(); const release = deferred(); let hold = true; addSection(chat.setup.registry, "slow", async () => { if (hold) { hold = false; rendering.resolve(); await release.promise; } return "slow"; }); const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); chat.faux.agent.push(answer("a4")); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); await rendering.promise; const manual = await chat.root.compact(undefined, context); await reached.promise; release.resolve(); expect((await input.wait(context)).status).toBe("done"); expect((await compactionTasks(chat)).map((task) => task.id)).toEqual([manual]); // A background compaction would have sent its own summarization request. expect(chat.faux.summaryRequests).toHaveLength(1); await chat.harness.abortTask(manual, context); await chat.harness.close(context); }); it("starts no background compaction after a blocking one in the same generation", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); // Blocking at 1500, background at 200: the kept part stays above the background threshold. chat.setup.settings.compaction = { ...BACKGROUND, backgroundTokens: 1300, keepRecentTokens: 400 }; chat.faux.summaries.push(summary()); await turn(chat, text("u4", 1000), "a4"); expect(chat.faux.summaryRequests).toHaveLength(1); expect(await compactionTasks(chat)).toEqual([]); await chat.harness.close(context); }); it("fails the run when its blocking compaction faults, and records the fault", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; const completeSimple = chat.setup.models.completeSimple.bind(chat.setup.models); chat.setup.models.completeSimple = () => { throw new Error("no credentials"); }; const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error", detail: "Compaction failed: no credentials", }); chat.setup.models.completeSimple = completeSimple; expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); const state = await live(chat); expect(state.compactions).toBeUndefined(); expect(state.compactionFailure).toMatchObject({ reason: "threshold", error: "no credentials" }); await chat.harness.close(context); }); it("fails an overflow run with its text when its compaction is aborted directly or itself overflows", async () => { for (const how of ["abort", "overflow"] as const) { const chat = await open(); await history(chat); chat.setup.settings.compaction = { ...MANUAL, enabled: true }; const reached = deferred(); if (how === "abort") chat.faux.summaries.push(gated(deferred(), summary(), reached)); else chat.faux.summaries.push(failure("prompt is too long for the summary")); chat.faux.agent.push(failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); if (how === "abort") { await reached.promise; const [child] = await compactionTasks(chat); await chat.harness.abortTask(child!.id, context); } expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error", detail: OVERFLOW, }); expect((await live(chat)).compactions).toBeUndefined(); await chat.harness.close(context); } }); it("treats a length stop as an ordinary answer, not an overflow", async () => { const chat = await open(); await history(chat); chat.setup.settings.compaction = { ...MANUAL, enabled: true }; chat.faux.agent.push(fauxAssistantMessage("cut short", { stopReason: "length", errorMessage: OVERFLOW })); const input = await chat.root.submit({ type: "input", content: "go" }, context); expect((await input.wait(context)).status).toBe("done"); expect(chat.faux.summaryRequests).toHaveLength(0); await chat.harness.close(context); }); it("orders the events of a summary placed at once", async () => { const chat = await open(); await history(chat); const events: AgentEvent[] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { events.push(...batch); }); chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); await waitFor(() => events.some((event) => event.type === "compaction_end")); const end = events.findIndex((event) => event.type === "compaction_end"); const batch = events.slice(end - 2, end + 3).map((event) => event.type); expect(batch).toEqual(["message_start", "message_end", "compaction_end", "submission", "usage_changed"]); await stream.stop(); await chat.harness.close(context); }); it("puts compaction_start last in the batch of the preparation commit", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = BACKGROUND; const events: AgentEvent[][] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { events.push([...batch]); }); // A new section makes the preparation commit append a system entry next to the compaction's status. addSection(chat.setup.registry, "extra", () => "extra"); chat.faux.summaries.push(gated(deferred(), summary())); await turn(chat, text("u4", 100), "a4"); await waitFor(() => events.some((batch) => batch.some((event) => event.type === "compaction_start"))); const batch = events.find((candidate) => candidate.some((event) => event.type === "compaction_start"))!; expect(batch.map((event) => event.type)).toEqual(["message_start", "message_end", "compaction_start"]); await stream.stop(); await chat.harness.abortTask((await compactionTasks(chat))[0]!.id, context); await chat.harness.close(context); }); it("keeps a background summary queued through a retry backoff while a blocking compaction wins", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); chat.setup.settings.compaction = BACKGROUND; chat.setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 300 }; const summaryGate = deferred(); const summaryReached = deferred(); chat.faux.summaries.push(gated(summaryGate, summary("BACKGROUND"), summaryReached)); chat.faux.summaries.push(summary("BLOCKING")); const failed = deferred(); chat.faux.agent.push(async () => { failed.resolve(); return failure("overloaded"); }); chat.faux.agent.push(answer("a4")); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); await Promise.all([failed.promise, summaryReached.promise]); // During the backoff: the background summary queues, and the thresholds drop so the retry blocks. await waitFor(async () => (await live(chat)).generation?.retry !== undefined); const [background] = await compactionTasks(chat); summaryGate.resolve(); const queued = await result(chat, background!.id as TaskId); const submission = (await chat.harness.submission( (queued.status === "completed" ? queued.result.submissionId : undefined)!, context, ))!; expect((await submission.status(context)).status).toBe("queued"); chat.setup.settings.compaction = { ...BACKGROUND, reserveTokens: 1500, keepRecentTokens: 50 }; expect((await input.wait(context)).status).toBe("done"); expect(firstUserText(chat.faux.agentRequests.at(-1)!.messages)).toContain("BLOCKING"); expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "stale" }); await chat.harness.close(context); }); it("summarizes the previous summary in a second compaction", async () => { const chat = await open(); await history(chat); chat.faux.summaries.push(summary("FIRST")); await result(chat, await chat.root.compact(undefined, context)); await turn(chat, text("u4", 100), text("a4", 100)); await turn(chat, text("u5", 100), text("a5", 100)); chat.faux.summaries.push(summary("SECOND")); await result(chat, await chat.root.compact(undefined, context)); const prompt = userText(chat.faux.summaryRequests[1]!.messages[1]); expect(prompt).toMatch(/^\n\[User\]: The conversation history before this point was compacted/); expect(prompt).toContain("FIRST"); expect(firstUserText((await chat.root.context(context)).messages)).toContain("SECOND"); await chat.harness.close(context); }); it("leaves no usage, submission, or summary when the classifying commit fails, which fails the Harness", async () => { const storage = new ControlledStorage(); const chat = await open({ storage }); await history(chat); const usage = async () => (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models["faux/faux-1"]; const before = await usage(); const failure = new Error("disk gone"); chat.faux.summaries.push(async () => { storage.failNextCommit(failure); return summary(); }); const id = await chat.root.compact(undefined, context); expect(await chat.harness.closed).toEqual({ reason: "failed", error: failure }); await chat.harness.close(context); // Nothing of the failed commit landed: the task is still live, without usage, entry, or submission. storage.reopen(); expect(await storage.submissionByRequest(chat.root.id, `compaction:${id}`, context)).toBeUndefined(); expect((await storage.task(id, context))?.state.status).not.toBe("terminal"); const reopened = await openChat(storage, chat.setup); expect((await reopened.harness.snapshot(UsageDoc, reopened.root.id, context))!.models["faux/faux-1"]).toEqual( before, ); expect((await kinds(reopened.root)).includes("pi.compaction")).toBe(false); await reopened.harness.close(context); }); }); describe("blocking and manual compaction together", () => { /** A run whose generation waits on a blocking compaction whose summary is held; returns the input and gate. */ async function blockingRun(chat: Chat) { await history(chat); chat.setup.settings.compaction = BLOCKING; const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary("BLOCKING"), reached)); const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); await reached.promise; const [blocking] = await compactionTasks(chat); return { input, gate, blocking: blocking!.id as TaskId }; } it("places a manual summary selected before the blocking one landed; its equal cut replaces it", async () => { const chat = await open({ contextWindow: 1000 }); const run = await blockingRun(chat); // Selected from the same context as the blocking compaction, so it cuts at the same entry. chat.faux.summaries.push(summary("MANUAL")); const manual = await result(chat, await chat.root.compact(undefined, context)); const submission = (await chat.harness.submission( (manual.status === "completed" ? manual.result.submissionId : undefined)!, context, ))!; expect((await submission.status(context)).status).toBe("queued"); chat.faux.agent.push(answer("a4")); run.gate.resolve(); expect((await run.input.wait(context)).status).toBe("done"); // The request after the blocking compaction used its summary; the manual one landed at the final boundary. expect(firstUserText(chat.faux.agentRequests.at(-1)!.messages)).toContain("BLOCKING"); expect((await submission.wait(context)).status).toBe("done"); const markers = (await allEntries(chat.root)).filter((record) => record.kind === "pi.compaction"); expect(markers).toHaveLength(2); expect(markers[1]!.head).toBe(markers[0]!.head); const messages = (await chat.root.context(context)).messages; expect(firstUserText(messages)).toContain("MANUAL"); expect(messages.some((message) => userText(message).includes("BLOCKING"))).toBe(false); await chat.harness.close(context); }); it("finds nothing to compact for a manual compaction selected after the blocking summary landed", async () => { const chat = await open({ contextWindow: 1000 }); const run = await blockingRun(chat); const answerGate = deferred(); const answerReached = deferred(); chat.faux.agent.push(gated(answerGate, answer("a4"), answerReached)); run.gate.resolve(); await answerReached.promise; expect(await result(chat, await chat.root.compact(undefined, context))).toEqual({ status: "completed", result: {}, }); expect(chat.faux.summaryRequests).toHaveLength(1); answerGate.resolve(); expect((await run.input.wait(context)).status).toBe("done"); await chat.harness.close(context); }); it("aborts the run and both compactions on Esc and appends nothing", async () => { const chat = await open({ contextWindow: 1000 }); const run = await blockingRun(chat); const manualReached = deferred(); chat.faux.summaries.push(gated(deferred(), summary("MANUAL"), manualReached)); const manual = await chat.root.compact(undefined, context); await manualReached.promise; expect((await live(chat)).compactions?.map((status) => status.blocking)).toEqual([true, false]); await chat.root.abort(context); expect(await run.input.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); expect((await result(chat, run.blocking)).status).toBe("aborted"); expect((await result(chat, manual)).status).toBe("aborted"); expect((await live(chat)).compactions).toBeUndefined(); expect((await live(chat)).run).toBeUndefined(); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); expect((await chat.harness.inspect(context)).submissions).toEqual([]); await chat.harness.close(context); }); it("places a summary that survived Esc at the next submission, before its input", async () => { const chat = await open(); await history(chat); const reached = deferred(); chat.faux.agent.push(gated(deferred(), answer("never"), reached)); const busy = await chat.root.submit({ type: "input", content: "busy" }, context); await reached.promise; chat.faux.summaries.push(summary()); const outcome = await result(chat, await chat.root.compact(undefined, context)); const submission = (await chat.harness.submission( (outcome.status === "completed" ? outcome.result.submissionId : undefined)!, context, ))!; await chat.root.abort(context); expect(await busy.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" }); expect((await submission.status(context)).status).toBe("queued"); await turn(chat, "u5", "a5"); expect((await submission.status(context)).status).toBe("done"); const request = chat.faux.agentRequests.at(-1)!.messages; expect(firstUserText(request)).toContain("SUMMARY"); expect(request.map(userText)).toContain("u5"); const tail = (await allEntries(chat.root)).slice(-4).map((record) => record.kind); expect(tail).toEqual(["pi.compaction", "pi.user", "pi.system", "pi.assistant"]); await chat.harness.close(context); }); }); describe("compaction pinning, silent overflow, and late policy changes", () => { it("keeps the pinned model through a retry and advances the live attempt", async () => { const setup = chatSetup({ models: [ { id: "faux-1", contextWindow: 100_000, maxTokens: 900 }, { id: "faux-2", contextWindow: 100_000, maxTokens: 900 }, ], }); const chat = await open({}, setup); await history(chat); let attempt: number | undefined; chat.faux.summaries.push(async () => { // Switched during the attempt: the retry still uses the pinned model. await chat.root.configure({ model: { provider: "faux", modelId: "faux-2" } }, context); return failure("overloaded"); }); chat.faux.summaries.push(async () => { attempt = (await live(chat)).compactions?.[0]?.attempt; return summary(); }); expect((await result(chat, await chat.root.compact(undefined, context))).status).toBe("completed"); expect(chat.faux.summaryRequests.map((request) => request.model)).toEqual(["faux-1", "faux-1"]); expect(attempt).toBe(2); const usage = (await chat.harness.snapshot(UsageDoc, chat.root.id, context))!.models; expect(Object.keys(usage)).toEqual(["faux/faux-1"]); await chat.harness.close(context); }); it("shows a late joiner a compaction that is summarizing", async () => { const chat = await open(); await history(chat); const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); const id = await chat.root.compact(undefined, context); await reached.promise; const stream = await watchEvents(chat.harness, chat.root.id, context); expect(stream.snapshot.compactions).toEqual([{ taskId: id, reason: "manual", blocking: false, attempt: 1 }]); await stream.stop(); await chat.harness.abortTask(id, context); await chat.harness.close(context); }); for (const [name, response] of [ ["a stop whose input exceeds the window", answer("fine")], ["a length stop that fills the window without output", fauxAssistantMessage("", { stopReason: "length" })], ] as const) { it(`treats silent overflow as an ordinary answer: ${name}`, async () => { const chat = await open({ contextWindow: 300 }); await history(chat); // Thresholds out of reach, so only overflow classification could compact. chat.setup.settings.compaction = { enabled: true, reserveTokens: -100_000, keepRecentTokens: 150, backgroundTokens: 0, }; chat.faux.agent.push(response); const input = await chat.root.submit({ type: "input", content: "go" }, context); expect((await input.wait(context)).status).toBe("done"); const [last] = (await allEntries(chat.root)).slice(-1); const message = last!.model![0] as AssistantMessage; expect(message.usage.input).toBeGreaterThanOrEqual(300); expect(chat.faux.summaryRequests).toHaveLength(0); await chat.harness.close(context); }); } it("sends the request when a blocking compaction finds nothing under a policy changed after preparation", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); chat.setup.settings.compaction = BLOCKING; let changed = false; addSection(chat.setup.registry, "policy", async () => { // Rendering runs after preparation read the policy; the compaction reads this one. if (!changed) { changed = true; chat.setup.settings.compaction = { ...BLOCKING, keepRecentTokens: 100_000 }; } return "p"; }); await turn(chat, text("u4", 200), "a4"); const compactions = (await chat.harness.inspect(context)).tasks.filter( (task) => task.record.kind === "pi.compaction", ); expect(compactions).toEqual([]); expect(chat.faux.summaryRequests).toHaveLength(0); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); await chat.harness.close(context); }); }); describe("blocked compaction", () => { it("survives reopen blocked and is orphaned on abort with its status removed", async () => { const path = await sqlitePath(); const setup = chatSetup(); let { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); // A compaction stored by a newer version than this process registers, for example after a downgrade. const newer = { definition: { ...CompactionTask.definition, version: 2 } } as typeof CompactionTask; const id = await root.commit(async (tx) => { const taskId = await tx.createTask(newer, { reason: "manual" }, { ownership: { kind: "conversation" } }); const live = await tx.doc(LiveDoc, root.id); live.compactions = [{ taskId, reason: "manual", blocking: false, attempt: 1 }]; return taskId; }, context); await harness.close(context); ({ harness, root } = await openChat(await openNodeSqliteStorage(path), setup)); harness.resume(); const chat = { harness, root, setup, faux: script(setup) }; const events: AgentEvent[] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { events.push(...batch); }); const inspection = await chat.harness.inspect(context); expect(inspection.tasks.find((task) => task.record.id === id)?.state).toEqual({ kind: "blocked", reason: "task_too_old", }); await chat.harness.abortTask(id, context); expect((await chat.harness.waitForTask(id, context)).state.outcome).toEqual({ status: "orphaned", reason: "task_too_old", }); expect((await live(chat)).compactions).toBeUndefined(); await waitFor(() => events.some((event) => event.type === "compaction_end")); await stream.stop(); await chat.harness.close(context); }); }); describe("context contributions", () => { it("apply edits carried by an older head marker in the range", async () => { const chat = await open(); const ids = await chat.root.commit(async (tx) => { const a = await tx.appendEntry(chat.root.id, { kind: "app.note", model: [user("a")] }); const b = await tx.appendEntry(chat.root.id, { kind: "app.note", model: [user("b")] }); // An older marker that omits b, then a newer one whose range still contains the older marker. await tx.appendEntry(chat.root.id, { kind: "app.head", head: a.id, edits: [{ target: b.id, action: "omit" }], }); const c = await tx.appendEntry(chat.root.id, { kind: "app.note", model: [user("c")] }); await tx.appendEntry(chat.root.id, { kind: "app.head", head: a.id, model: [user("H")] }); return { a: a.id, b: b.id, c: c.id }; }, context); const view = await chat.root.context(context); expect(view.entries.map((record) => record.id).slice(1)).toEqual([ids.a, ids.b, ids.c]); expect(view.contributions.map((messages) => messages.map(userText))).toEqual([["H"], ["a"], [], ["c"]]); expect(view.messages.map(userText)).toEqual(["H", "a", "c"]); // The summarizer sees the same contributions: the omitted entry stays out. chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: 1 }; chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); const prompt = userText(chat.faux.summaryRequests[0]!.messages[1]); expect(prompt).toContain("\n[User]: H\n\n[User]: a\n"); await chat.harness.close(context); }); });