import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent, type AgentMessage, type StreamFn } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ExtensionRunner, loadExtensionFromFactory, loadExtensions, } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { TempDir } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; const HANDOFF_SECRET = "HANDOFF_SECRET_TOKEN_12345"; const UNRENDERABLE_SNAPCOMPACT_TEXT = "\uE000\uE001\uE002\uE003\uE004\uE005\uE006\uE007\uE008\uE009"; describe("AgentSession handoff", () => { // Immutable across the whole file: the model registry's synchronous bundled-model // load dominates per-test setup (~100ms each), and the auth store + bundled model // never change. Build them once. Per-test mutable state (session, session file, // emitted events) is rebuilt in beforeEach. let sharedDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let model: Model; let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; let events: AgentSessionEvent[]; let obfuscator: SecretObfuscator; /** Poll `predicate` until it holds (returns as soon as the state is reached) or the * deadline elapses. Replaces blind settle sleeps for tests with a positive signal. */ async function waitFor(predicate: () => boolean, timeoutMs = 1_000): Promise { const deadline = Date.now() + timeoutMs; while (!predicate()) { if (Date.now() >= deadline) { throw new Error("Timed out waiting for condition"); } await Bun.sleep(1); } } /** Drain post-turn maintenance deterministically for negative tests (those proving * maintenance did NOT run, where there is no positive signal to poll on). Post-turn * work is scheduled fire-and-forget: a single event-loop turn lets the handler run to * its decision and register any compaction pass as a tracked post-prompt task, then * `waitForIdle()` drains that task to completion. */ async function drainMaintenance(): Promise { await Bun.sleep(0); await session.waitForIdle(); } beforeAll(async () => { sharedDir = TempDir.createSync("@pi-handoff-shared-"); authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db")); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!bundled) { throw new Error("Expected built-in anthropic model to exist"); } model = bundled; }); afterAll(async () => { authStorage.close(); try { await sharedDir.remove(); } catch {} }); beforeEach(async () => { tempDir = TempDir.createSync("@pi-handoff-"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; obfuscator = new SecretObfuscator([{ type: "plain", content: HANDOFF_SECRET }]); const agent = new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [], }, }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, }), modelRegistry, obfuscator, }); session.subscribe(event => { events.push(event); }); sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() - 2, }); sessionManager.appendMessage({ role: "assistant", content: [{ type: "text", text: "seed response" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 16, output: 8, cacheRead: 0, cacheWrite: 0, totalTokens: 24, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now() - 1, }); }); afterEach(async () => { if (session) { await session.dispose(); } try { await tempDir.remove(); } catch {} vi.restoreAllMocks(); }); it("does not run auto-compaction after handoff turn completes", async () => { const handoffText = "## Goal\nContinue from here"; const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue(handoffText); const result = await session.handoff(); await drainMaintenance(); expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(result?.document).toBe(handoffText); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0); expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0); expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); }); it("emits handoff lifecycle hooks on the outgoing and replacement sessions", async () => { const extensionsResult = await loadExtensions([], tempDir.path()); const extensionRunner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, tempDir.path(), sessionManager, modelRegistry, ); const observedEvents: Array<{ type: "session_before_switch" | "session_switch"; reason: string; previousSessionFile: string | undefined; activeSessionFile: string | undefined; messageCount: number; handoffEntryCount: number; }> = []; vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch"); const emit = extensionRunner.emit.bind(extensionRunner); vi.spyOn(extensionRunner, "emit").mockImplementation(event => { if (event.type === "session_before_switch" || event.type === "session_switch") { observedEvents.push({ type: event.type, reason: event.reason, previousSessionFile: event.type === "session_switch" ? event.previousSessionFile : undefined, activeSessionFile: session.sessionFile, messageCount: sessionManager.getBranch().filter(entry => entry.type === "message").length, handoffEntryCount: sessionManager .getBranch() .filter(entry => entry.type === "custom_message" && entry.customType === "handoff").length, }); } return emit(event); }); await session.dispose(); session = new AgentSession({ agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [], }, }), sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, }), modelRegistry, extensionRunner, obfuscator, }); const previousSessionFile = session.sessionFile; const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue("## Goal\nContinue from here"); await session.handoff(); const nextSessionFile = session.sessionFile; expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(nextSessionFile).not.toBe(previousSessionFile); expect(observedEvents).toEqual([ { type: "session_before_switch", reason: "handoff", previousSessionFile: undefined, activeSessionFile: previousSessionFile, messageCount: 2, handoffEntryCount: 0, }, { type: "session_switch", reason: "handoff", previousSessionFile, activeSessionFile: nextSessionFile, messageCount: 0, handoffEntryCount: 1, }, ]); }); it("runs handoff generation through the configured side stream function", async () => { const handoffText = "## Goal\nContinue via side stream"; let sideStreamCalls = 0; let capturedSideSessionId: string | undefined; const sideStreamFn: StreamFn = (requestModel, _context, options) => { sideStreamCalls++; capturedSideSessionId = options?.sessionId; const stream = new AssistantMessageEventStream(); queueMicrotask(() => { const message: AssistantMessage = { role: "assistant", content: [{ type: "text", text: handoffText }], api: requestModel.api, provider: requestModel.provider, model: requestModel.id, stopReason: "stop", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; stream.push({ type: "done", reason: "stop", message }); }); return stream; }; await session.dispose(); session = new AgentSession({ agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [], }, }), sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, }), modelRegistry, obfuscator, sideStreamFn, }); const preHandoffSessionId = session.sessionId; const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockImplementation(async (context, requestModel, options) => { expect(options.completeImpl).toBeDefined(); const message = await options.completeImpl!(requestModel, context, options.streamOptions); return message.content .filter(block => block.type === "text") .map(block => block.text) .join("\n"); }); const result = await session.handoff(); expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(result?.document).toBe(handoffText); expect(sideStreamCalls).toBe(1); expect(capturedSideSessionId).toStartWith(`${preHandoffSessionId}:side:`); }); it("preserves queued steering and follow-up messages across the handoff reset", async () => { // Defect 2: handoff() calls agent.reset(), which clears the core steering/follow-up // queues. Steers/follow-ups already queued (the mis-routed first compaction message, // or RPC/SDK steer()/followUp() issued during the handoff) must survive into the new // session instead of being silently dropped. vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("## Goal\nContinue"); const textOf = (message: AgentMessage): string => { if (!("content" in message)) return ""; const content = message.content; if (typeof content === "string") return content; const textBlock = content.find(block => block.type === "text"); return textBlock?.type === "text" ? textBlock.text : ""; }; const userMsg: AgentMessage = { role: "user", content: [{ type: "text", text: "keep-steer" }], attribution: "user", timestamp: Date.now(), }; // A hidden, user-attributed companion (e.g. an ultrathink notice). It is // display:false, so isUserQueuedMessage(...) is false for it: preservation must // keep it adjacent to its prompt rather than filter it out or reorder it. const companionMsg: AgentMessage = { role: "custom", customType: "ultrathink-notice", content: [{ type: "text", text: "companion" }], attribution: "user", display: false, timestamp: Date.now(), }; const followUpMsg: AgentMessage = { role: "user", content: [{ type: "text", text: "keep-followup" }], attribution: "user", timestamp: Date.now(), }; session.agent.steer(userMsg); session.agent.steer(companionMsg); session.agent.followUp(followUpMsg); expect(session.agent.hasQueuedMessages()).toBe(true); await session.handoff(); expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["keep-steer", "companion"]); expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["keep-followup"]); }); it("preserves steering and follow-up messages enqueued while the handoff is in flight", async () => { // Defect 2 in-flight window: the queue snapshot must be captured immediately before // agent.reset() (after generateHandoff resolves), NOT at handoff entry. A steer or // follow-up issued WHILE the handoff document is still generating must survive the // reset — proving capture happens late rather than at the start of handoff(). const { promise: handoffDoc, resolve: releaseHandoff } = Promise.withResolvers(); let generateHandoffCalled = false; vi.spyOn(compactionModule, "generateHandoffFromContext").mockImplementation(async () => { generateHandoffCalled = true; return handoffDoc; }); const textOf = (message: AgentMessage): string => { if (!("content" in message)) return ""; const content = message.content; if (typeof content === "string") return content; const textBlock = content.find(block => block.type === "text"); return textBlock?.type === "text" ? textBlock.text : ""; }; const handoffPromise = session.handoff(); // Block until we are genuinely mid-handoff (document generation in flight). await waitFor(() => generateHandoffCalled); // Enqueue AFTER generation started but BEFORE it resolves — the window where the old // session is still live and agent.reset() has not yet fired. session.agent.steer({ role: "user", content: [{ type: "text", text: "inflight-steer" }], attribution: "user", timestamp: Date.now(), }); session.agent.followUp({ role: "user", content: [{ type: "text", text: "inflight-followup" }], attribution: "user", timestamp: Date.now(), }); releaseHandoff("## Goal\nContinue"); await handoffPromise; expect(session.agent.peekSteeringQueue().map(textOf)).toEqual(["inflight-steer"]); expect(session.agent.peekFollowUpQueue().map(textOf)).toEqual(["inflight-followup"]); }); it("obfuscates custom instructions before generating a handoff", async () => { const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue(`## Goal\nKeep ${placeholder}`); const result = await session.handoff(`preserve ${HANDOFF_SECRET}`); const handoffCall = generateHandoffSpy.mock.calls[0]; if (!handoffCall) throw new Error("Expected generateHandoffFromContext call"); // Custom instructions are obfuscated, rendered into the handoff prompt, and // appended as the trailing context message — the raw secret never reaches // the provider. const trailing = handoffCall[0].messages.at(-1); const trailingText = typeof trailing?.content === "string" ? trailing.content : (trailing?.content ?? []).map(block => (block.type === "text" ? block.text : "")).join(""); expect(trailingText).toContain(`preserve ${placeholder}`); expect(trailingText).not.toContain(HANDOFF_SECRET); expect(result?.document).toContain(HANDOFF_SECRET); expect(result?.document).not.toContain(placeholder); }); it("obfuscates the previous compaction summary but preserves opaque replay data", async () => { session.settings.set("compaction.strategy", "context-full"); const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const entries = sessionManager.getBranch(); const lastEntryId = entries[entries.length - 1]?.id; if (!lastEntryId) throw new Error("Expected a seeded entry id"); const fixedPreparation: compactionModule.CompactionPreparation = { firstKeptEntryId: lastEntryId, messagesToSummarize: [{ role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 }], turnPrefixMessages: [], recentMessages: [], isSplitTurn: false, tokensBefore: 100, previousSummary: `summary ${HANDOFF_SECRET}`, previousPreserveData: { openaiRemoteCompaction: { replacementHistory: [{ role: "user", content: `history ${HANDOFF_SECRET}` }], }, }, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, settings: compactionModule.DEFAULT_COMPACTION_SETTINGS, }; vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation); const compactSpy = vi.spyOn(compactionModule, "compact").mockResolvedValue({ summary: "new summary", shortSummary: undefined, firstKeptEntryId: lastEntryId, tokensBefore: 100, details: {}, }); await session.compact(); const call = compactSpy.mock.calls[0]; if (!call) throw new Error("Expected compact call"); expect(call[0].previousSummary).toBe(`summary ${placeholder}`); expect(call[0].previousSummary).not.toContain(HANDOFF_SECRET); // Opaque provider-replay state (encrypted_content / replacementHistory) must pass through // byte-identical — rewriting it would corrupt OpenAI remote-compaction replay. expect(call[0].previousPreserveData).toBe(fixedPreparation.previousPreserveData); }); it("obfuscates migrated snapcompact archive text but preserves opaque replay data", async () => { session.settings.set("compaction.strategy", "context-full"); const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const entries = sessionManager.getBranch(); const lastEntryId = entries[entries.length - 1]?.id; if (!lastEntryId) throw new Error("Expected a seeded entry id"); const replaySlot = { replacementHistory: [{ role: "user", content: `history ${HANDOFF_SECRET}` }], }; const fixedPreparation: compactionModule.CompactionPreparation = { firstKeptEntryId: lastEntryId, messagesToSummarize: [{ role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 }], turnPrefixMessages: [], recentMessages: [], isSplitTurn: false, tokensBefore: 100, previousPreserveData: { openaiRemoteCompaction: replaySlot, [snapcompact.PRESERVE_KEY]: { frames: [], totalChars: 32, truncatedChars: 0, text: `archived ${HANDOFF_SECRET}`, textHead: `head ${HANDOFF_SECRET}`, }, }, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, settings: compactionModule.DEFAULT_COMPACTION_SETTINGS, }; vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation); const compactSpy = vi.spyOn(compactionModule, "compact").mockResolvedValue({ summary: "new summary", shortSummary: undefined, firstKeptEntryId: lastEntryId, tokensBefore: 100, details: {}, }); await session.compact(); const call = compactSpy.mock.calls[0]; if (!call) throw new Error("Expected compact call"); const preserve = call[0].previousPreserveData; if (!preserve) throw new Error("Expected previousPreserveData"); // The archive plaintext that compact() migrates into the summary prompt is // redacted, so the raw secret never reaches the provider. const archive = preserve[snapcompact.PRESERVE_KEY] as { text: string; textHead: string }; expect(archive.text).toBe(`archived ${placeholder}`); expect(archive.textHead).toBe(`head ${placeholder}`); expect(JSON.stringify(archive)).not.toContain(HANDOFF_SECRET); // Opaque provider-replay state stays byte-identical (same reference) — only the // snapcompact slot's text is rewritten. expect(preserve.openaiRemoteCompaction).toBe(replaySlot); }); it("does not call the LLM summarizer when manual snapcompact preflight fails", async () => { const entries = sessionManager.getBranch(); const lastEntryId = entries[entries.length - 1]?.id; if (!lastEntryId) throw new Error("Expected a seeded entry id"); const fixedPreparation: compactionModule.CompactionPreparation = { firstKeptEntryId: lastEntryId, messagesToSummarize: [ { role: "user", content: [{ type: "text", text: UNRENDERABLE_SNAPCOMPACT_TEXT.repeat(100) }], timestamp: 1, }, ], turnPrefixMessages: [], recentMessages: [], isSplitTurn: false, tokensBefore: 100, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, settings: { ...compactionModule.DEFAULT_COMPACTION_SETTINGS, strategy: "snapcompact" }, }; vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation); const compactSpy = vi.spyOn(compactionModule, "compact").mockRejectedValue(new Error("429 quota exhausted")); await expect(session.compact(undefined, { mode: "snapcompact" })).rejects.toThrow( "snapcompact cannot render this conversation locally", ); expect(compactSpy).not.toHaveBeenCalled(); }); it("downgrades auto snapcompact to context-full when local preflight rejects the transcript", async () => { session.settings.set("compaction.strategy", "snapcompact"); const entries = sessionManager.getBranch(); const lastEntryId = entries[entries.length - 1]?.id; if (!lastEntryId) throw new Error("Expected a seeded entry id"); const fixedPreparation: compactionModule.CompactionPreparation = { firstKeptEntryId: lastEntryId, messagesToSummarize: [ { role: "user", content: [{ type: "text", text: UNRENDERABLE_SNAPCOMPACT_TEXT.repeat(100) }], timestamp: 1, }, ], turnPrefixMessages: [], recentMessages: [], isSplitTurn: false, tokensBefore: 100, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, settings: { ...compactionModule.DEFAULT_COMPACTION_SETTINGS, strategy: "snapcompact" }, }; vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation); const compactSpy = vi.spyOn(compactionModule, "compact").mockResolvedValue({ summary: "compacted", shortSummary: undefined, firstKeptEntryId: lastEntryId, tokensBefore: 100, details: {}, }); await session.runIdleCompaction(); const endEvent = events.find( (event): event is Extract => event.type === "auto_compaction_end", ); expect(compactSpy).toHaveBeenCalled(); // The start event fires before the in-try preflight downgrades action, so it // still reports "snapcompact"; the end event reflects the downgraded action. expect(events).toContainEqual({ type: "auto_compaction_start", reason: "idle", action: "snapcompact" }); expect(endEvent).toMatchObject({ type: "auto_compaction_end", action: "context-full", }); expect(endEvent?.errorMessage).toBeUndefined(); const downgradeNotice = events.find( (event): event is Extract => event.type === "notice" && event.source === "compaction" && event.message.startsWith("snapcompact disabled: unsupported characters for selected snapcompact font"), ); expect(downgradeNotice?.message).toContain("using context-full auto-compaction instead."); }); it("strips hook-supplied snapcompact data when persisting context-full compaction", async () => { const localTempDir = TempDir.createSync("@pi-context-full-preserve-data-"); const localSessionManager = SessionManager.inMemory(localTempDir.path()); const firstKeptEntryId = localSessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "kept" }], timestamp: Date.now(), }); const fixedPreparation: compactionModule.CompactionPreparation = { firstKeptEntryId, messagesToSummarize: [{ role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 }], turnPrefixMessages: [], recentMessages: [], isSplitTurn: false, tokensBefore: 100, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, settings: { ...compactionModule.DEFAULT_COMPACTION_SETTINGS, strategy: "context-full" }, }; const extensionRunner = { hasHandlers: vi.fn((eventType: string) => eventType === "session.compacting"), emit: vi.fn(async (event: { type: string }) => event.type === "session.compacting" ? { preserveData: { otherState: "keep-me", [snapcompact.PRESERVE_KEY]: { frames: [], totalChars: 0, truncatedChars: 0 }, }, } : undefined, ), clearManagedTimers: vi.fn(), } as unknown as ExtensionRunner; vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation); vi.spyOn(compactionModule, "compact").mockResolvedValue({ summary: "context-full summary", shortSummary: undefined, firstKeptEntryId, tokensBefore: 100, details: {}, preserveData: { resultState: "keep-result" }, }); const localAgent = new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, }); const localSession = new AgentSession({ agent: localAgent, sessionManager: localSessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, "compaction.strategy": "context-full", }), modelRegistry, extensionRunner, }); try { await localSession.compact(); const compactionEntry = localSessionManager.getEntries().find(entry => entry.type === "compaction"); if (compactionEntry?.type !== "compaction") throw new Error("Expected persisted compaction entry"); expect(compactionEntry.preserveData).toEqual({ otherState: "keep-me", resultState: "keep-result", }); expect(compactionEntry.preserveData).not.toHaveProperty(snapcompact.PRESERVE_KEY); } finally { await localSession.dispose(); await localTempDir.remove(); } }); it("strips hook-supplied snapcompact data when persisting auto context-full compaction", async () => { const localTempDir = TempDir.createSync("@pi-auto-context-full-preserve-data-"); const localSessionManager = SessionManager.inMemory(localTempDir.path()); const firstKeptEntryId = localSessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "kept" }], timestamp: Date.now(), }); const fixedPreparation: compactionModule.CompactionPreparation = { firstKeptEntryId, messagesToSummarize: [{ role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 }], turnPrefixMessages: [], recentMessages: [], isSplitTurn: false, tokensBefore: 100, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, settings: { ...compactionModule.DEFAULT_COMPACTION_SETTINGS, strategy: "context-full" }, }; const extensionRunner = { hasHandlers: vi.fn((eventType: string) => eventType === "session.compacting"), emit: vi.fn(async (event: { type: string }) => event.type === "session.compacting" ? { preserveData: { otherState: "keep-me", [snapcompact.PRESERVE_KEY]: { frames: [], totalChars: 0, truncatedChars: 0 }, }, } : undefined, ), clearManagedTimers: vi.fn(), } as unknown as ExtensionRunner; vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation); const compactSpy = vi.spyOn(compactionModule, "compact").mockResolvedValue({ summary: "auto context-full summary", shortSummary: undefined, firstKeptEntryId, tokensBefore: 100, details: {}, preserveData: { resultState: "keep-result" }, }); const localAgent = new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, }); const localSession = new AgentSession({ agent: localAgent, sessionManager: localSessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, "compaction.strategy": "context-full", }), modelRegistry, extensionRunner, }); try { await localSession.runIdleCompaction(); expect(compactSpy).toHaveBeenCalledTimes(1); const compactionEntry = localSessionManager.getEntries().find(entry => entry.type === "compaction"); if (compactionEntry?.type !== "compaction") throw new Error("Expected persisted compaction entry"); expect(compactionEntry.preserveData).toEqual({ otherState: "keep-me", resultState: "keep-result", }); expect(compactionEntry.preserveData).not.toHaveProperty(snapcompact.PRESERVE_KEY); } finally { await localSession.dispose(); await localTempDir.remove(); } }); it("runs context maintenance before sending an oversized pending prompt", async () => { session.settings.set("compaction.strategy", "context-full"); session.settings.set("compaction.thresholdTokens", 50); session.settings.set("compaction.keepRecentTokens", 1); session.settings.set("contextPromotion.enabled", false); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ summary: "pre-prompt compacted", shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: {}, })); const promptSpy = vi.spyOn(session.agent, "prompt").mockImplementation(async () => { expect(sessionManager.getEntries().some(entry => entry.type === "compaction")).toBe(true); }); await session.prompt("pending prompt ".repeat(120)); await waitFor( () => compactSpy.mock.calls.length === 1 && events.some(event => event.type === "auto_compaction_end" && event.aborted === false), ); expect(compactSpy).toHaveBeenCalledTimes(1); expect(promptSpy).toHaveBeenCalledTimes(1); expect(events).toContainEqual({ type: "auto_compaction_start", reason: "threshold", action: "context-full" }); expect(events.some(event => event.type === "auto_compaction_end" && event.aborted === false)).toBe(true); }); it("falls back after one auto-compaction timeout instead of retrying the same model", async () => { session.settings.set("compaction.strategy", "context-full"); session.settings.set("compaction.thresholdTokens", 50); session.settings.set("compaction.keepRecentTokens", 1); session.settings.set("contextPromotion.enabled", false); session.settings.set("retry.baseDelayMs", 1); let firstCandidateKey: string | undefined; let fallbackCandidateKey: string | undefined; const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, candidate) => { const candidateKey = `${candidate.provider}/${candidate.id}`; firstCandidateKey ??= candidateKey; if (candidateKey === firstCandidateKey) { throw new Error("Summarization failed: The operation timed out."); } fallbackCandidateKey = candidateKey; return { summary: "fallback compacted", shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: {}, }; }); const promptSpy = vi.spyOn(session.agent, "prompt").mockImplementation(async () => { expect(sessionManager.getEntries().some(entry => entry.type === "compaction")).toBe(true); }); await session.prompt("pending prompt ".repeat(120)); await waitFor( () => fallbackCandidateKey !== undefined && events.some(event => event.type === "auto_compaction_end" && event.aborted === false), ); expect( compactSpy.mock.calls.filter(call => `${call[1].provider}/${call[1].id}` === firstCandidateKey), ).toHaveLength(1); expect(fallbackCandidateKey).toBeDefined(); expect(promptSpy).toHaveBeenCalledTimes(1); }); it("keeps pre-prompt context-full checks aligned with provider-anchored usage", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; const mock = createMockModel({ id: "gpt-5.5", provider: "openai", contextWindow: 10_000, responses: [ { content: ["ok"], stopReason: "stop", usage: { input: 1_005, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 1_025, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }, ], }); const seedUser: AgentMessage = { role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() - 2, }; const seedAssistant: AssistantMessage = { role: "assistant", content: [ { type: "thinking", thinking: "short reasoning", thinkingSignature: JSON.stringify({ id: "rs_repro", type: "reasoning", content: [], encrypted_content: "blob ".repeat(30_000), summary: [], }), }, { type: "text", text: "done" }, ], api: mock.api, provider: "openai", model: mock.id, stopReason: "stop", usage: { input: 1_000, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 1_010, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now() - 1, }; sessionManager.appendMessage(seedUser); sessionManager.appendMessage(seedAssistant); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model: mock, systemPrompt: ["Test"], tools: [], messages: [seedUser, seedAssistant], }, streamFn: mock.stream, }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, "compaction.strategy": "context-full", "compaction.thresholdTokens": 8_000, "contextPromotion.enabled": false, }), modelRegistry, }); session.subscribe(event => { events.push(event); }); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ summary: "pre-prompt compacted", shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: {}, })); expect(session.getContextUsage({ contextWindow: 10_000 })).toMatchObject({ tokens: 1_000, contextWindow: 10_000, percent: 10, }); await session.prompt("small pending prompt"); expect(compactSpy).not.toHaveBeenCalled(); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0); expect(mock.calls).toHaveLength(1); }); it("floors pre-prompt context-full checks by the stored conversation when provider usage is deflated", async () => { // Mirror of the provider-anchored test, but the large payload is real, on-wire- // compressible text (what a before_provider_request hook like Headroom shrinks), // NOT encrypted reasoning. The provider reports a deflated 1k prompt tokens, yet // the stored conversation is ~20k tokens — compaction MUST still fire. await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; const mock = createMockModel({ id: "gpt-5.5", provider: "openai", contextWindow: 10_000, responses: [{ content: ["ok"], stopReason: "stop" }], }); const seedUser: AgentMessage = { role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() - 2, }; // ~20k tokens of plain text in a normal text block — counted by the floor. const bulkText = "alpha beta gamma delta epsilon ".repeat(3_000); const seedAssistant: AssistantMessage = { role: "assistant", content: [{ type: "text", text: bulkText }], api: mock.api, provider: "openai", model: mock.id, stopReason: "stop", // Deflated: a before_provider_request compressor shrank the request, so the // provider only billed ~1k prompt tokens for a ~20k-token conversation. usage: { input: 1_000, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 1_010, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now() - 1, }; sessionManager.appendMessage(seedUser); sessionManager.appendMessage(seedAssistant); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model: mock, systemPrompt: ["Test"], tools: [], messages: [seedUser, seedAssistant] }, streamFn: mock.stream, }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, "compaction.strategy": "context-full", "compaction.thresholdTokens": 8_000, "contextPromotion.enabled": false, }), modelRegistry, }); session.subscribe(event => { events.push(event); }); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ summary: "pre-prompt compacted", shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: {}, })); // Display still shows the provider-anchored (deflated) usage — only the // compaction decision takes the local floor. expect(session.getContextUsage({ contextWindow: 10_000 })?.tokens).toBe(1_000); await session.prompt("small pending prompt"); // The floor (~20k from the stored text) exceeds the 8k threshold, so the // deflated 1k provider count no longer suppresses compaction. expect(compactSpy).toHaveBeenCalled(); }); it("counts current non-message token growth in provider-anchored pre-prompt checks", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; const extensionsResult = await loadExtensions([], tempDir.path()); const extensionRunner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, tempDir.path(), sessionManager, modelRegistry, ); const emitBeforeAgentStart = vi .spyOn(extensionRunner, "emitBeforeAgentStart") .mockResolvedValueOnce(undefined) .mockResolvedValueOnce({ systemPrompt: ["expanded system prompt ".repeat(30_000)] }); vi.spyOn(extensionRunner, "emit").mockResolvedValue(undefined); const mock = createMockModel({ id: "gpt-5.5", provider: "openai", contextWindow: 10_000, responses: [ { content: ["seed response"], stopReason: "stop", usage: { input: 1_000, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 1_010, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }, ], }); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model: mock, systemPrompt: ["Test"], tools: [], messages: [], }, streamFn: mock.stream, }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, "compaction.strategy": "context-full", "compaction.thresholdTokens": 8_000, "compaction.keepRecentTokens": 1, "contextPromotion.enabled": false, }), modelRegistry, extensionRunner, }); session.subscribe(event => { events.push(event); }); await session.prompt("seed prompt"); expect(mock.calls).toHaveLength(1); expect(session.getContextUsage({ contextWindow: 10_000 })).toMatchObject({ tokens: 1_000, contextWindow: 10_000, percent: 10, }); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ summary: "pre-prompt compacted", shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: {}, })); const promptSpy = vi.spyOn(session.agent, "prompt").mockImplementation(async () => { expect(sessionManager.getEntries().some(entry => entry.type === "compaction")).toBe(true); }); await session.prompt("small pending prompt"); await waitFor( () => compactSpy.mock.calls.length === 1 && events.some(event => event.type === "auto_compaction_end" && event.aborted === false), ); expect(emitBeforeAgentStart).toHaveBeenCalledTimes(2); expect(compactSpy).toHaveBeenCalledTimes(1); expect(promptSpy).toHaveBeenCalledTimes(1); expect(events).toContainEqual({ type: "auto_compaction_start", reason: "threshold", action: "context-full" }); }); it("does not double-count unchanged non-message tokens in provider-anchored pre-prompt checks", async () => { await session.dispose(); authStorage.setRuntimeApiKey("openai", "test-key"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; const mock = createMockModel({ id: "gpt-5.5", provider: "openai", contextWindow: 10_000, responses: [ { content: ["seed response"], stopReason: "stop", usage: { input: 8_500, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 8_510, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }, { content: ["ok"], stopReason: "stop" }, ], }); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model: mock, systemPrompt: ["expanded system prompt ".repeat(30_000)], tools: [], messages: [], }, streamFn: mock.stream, }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": false, "compaction.autoContinue": false, "compaction.strategy": "context-full", "compaction.thresholdTokens": 9_500, "contextPromotion.enabled": false, }), modelRegistry, }); session.subscribe(event => { events.push(event); }); await session.prompt("seed prompt"); expect(mock.calls).toHaveLength(1); session.settings.set("compaction.enabled", true); const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ summary: "pre-prompt compacted", shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: {}, })); await session.prompt("small pending prompt"); await drainMaintenance(); expect(compactSpy).not.toHaveBeenCalled(); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0); expect(mock.calls).toHaveLength(2); }); it("does not run auto maintenance after final yield", async () => { session.settings.set("compaction.strategy", "handoff"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const yieldCall: ToolCall = { type: "toolCall", id: "call_yield_done", name: "yield", arguments: { result: { data: { done: true } } }, }; const assistantMessage: AssistantMessage = { role: "assistant", content: [yieldCall], api: model.api, provider: model.provider, model: model.id, stopReason: "toolUse", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue({ document: "handoff document" }); session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "tool_execution_end", toolCallId: yieldCall.id, toolName: "yield", result: { content: [{ type: "text", text: "Result submitted." }], details: { status: "success", data: { done: true } }, }, isError: false, }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await drainMaintenance(); expect(handoffSpy).not.toHaveBeenCalled(); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0); expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0); }); it("persists handoff session immediately with previous session as parent", async () => { const previousSessionFile = session.sessionFile; if (!previousSessionFile) { throw new Error("Expected previous session file"); } const handoffText = "## Goal\nContinue from here"; vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue(handoffText); const result = await session.handoff(); const handoffSessionFile = session.sessionFile; if (!handoffSessionFile) { throw new Error("Expected handoff session file"); } type PersistedEntry = { type?: string; parentSession?: string; customType?: string; display?: boolean; }; const handoffEntries = (await Bun.file(handoffSessionFile).text()) .trim() .split("\n") .map(line => JSON.parse(line) as PersistedEntry); expect(result?.document).toBe(handoffText); expect(session.getLastAssistantText()).toBeUndefined(); expect(session.hasCopyCandidateAssistantMessage()).toBe(false); expect(session.getLastVisibleHandoffText()).toBe( `\n${handoffText}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`, ); expect(handoffSessionFile).not.toBe(previousSessionFile); expect(handoffEntries.find(entry => entry.type === "session")).toMatchObject({ type: "session", parentSession: previousSessionFile, }); expect( handoffEntries.some( entry => entry.type === "custom_message" && entry.customType === "handoff" && entry.display, ), ).toBe(true); const previousSessionText = await Bun.file(previousSessionFile).text(); expect(previousSessionText).toContain('"text":"seed"'); }); it("does not run auto maintenance when strategy is off", async () => { session.settings.set("compaction.strategy", "off"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "maintenance trigger" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; const handoffSpy = vi.spyOn(session, "handoff"); session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await drainMaintenance(); expect(handoffSpy).not.toHaveBeenCalled(); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0); expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0); }); it("restores default strategy when enabling auto-compaction from off strategy", () => { session.settings.set("compaction.enabled", true); session.settings.set("compaction.strategy", "off"); expect(session.autoCompactionEnabled).toBe(false); session.setAutoCompactionEnabled(true); expect(session.settings.get("compaction.strategy")).toBe("snapcompact"); expect(session.autoCompactionEnabled).toBe(true); }); it("falls back to context-full maintenance for overflow when strategy is handoff", async () => { session.settings.set("compaction.strategy", "handoff"); session.settings.set("contextPromotion.enabled", false); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const handoffSpy = vi.spyOn(session, "handoff"); const overflowAssistant: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "overflow" }], api: model.api, provider: model.provider, model: model.id, stopReason: "error", errorMessage: "maximum context length is 200000 tokens, however you requested 200001 tokens", usage: { input: 120_000, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 120_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; session.agent.emitExternalEvent({ type: "message_end", message: overflowAssistant }); session.agent.emitExternalEvent({ type: "agent_end", messages: [overflowAssistant] }); await waitFor(() => events.filter(event => event.type === "auto_compaction_end").length === 1); expect(handoffSpy).not.toHaveBeenCalled(); const startEvents = events.filter(event => event.type === "auto_compaction_start"); expect(startEvents).toHaveLength(1); expect(startEvents[0]).toMatchObject({ type: "auto_compaction_start", reason: "overflow" }); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); expect(endEvents[0]).not.toMatchObject({ errorMessage: "Auto-handoff failed: no handoff document was generated", }); }); it("uses handoff strategy for threshold-triggered auto maintenance", async () => { session.settings.set("compaction.strategy", "handoff"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "maintenance trigger" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue({ document: "handoff document" }); session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await waitFor( () => handoffSpy.mock.calls.length === 1 && events.filter(event => event.type === "auto_compaction_end").length === 1, ); expect(handoffSpy).toHaveBeenCalledTimes(1); expect(handoffSpy).toHaveBeenCalledWith(expect.stringContaining("Threshold-triggered maintenance"), { autoTriggered: true, signal: expect.anything(), onSwitchCancelled: expect.any(Function), }); expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(1); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); expect(endEvents[0]).toMatchObject({ type: "auto_compaction_end", aborted: false, willRetry: false }); }); it("completes threshold-triggered auto-handoff while the original prompt is still unwinding", async () => { authStorage.setRuntimeApiKey("anthropic", "test-key"); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("Expected built-in anthropic model to exist"); } await session.dispose(); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() - 2, }); sessionManager.appendMessage({ role: "assistant", content: [{ type: "text", text: "seed response" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 16, output: 8, cacheRead: 0, cacheWrite: 0, totalTokens: 24, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now() - 1, }); const mock = createMockModel({ responses: [ { content: [{ type: "text", text: "maintenance trigger" }], stopReason: "stop", usage: { input: 190_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 191_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }, ], }); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: ["Test"], tools: [], messages: [], }, streamFn: mock.stream, }); const agentEndWillContinue: Array = []; const extensionsResult = await loadExtensions([], tempDir.path()); const captureAgentEnd = await loadExtensionFromFactory( pi => { pi.on("agent_end", event => agentEndWillContinue.push(event.willContinue)); }, tempDir.path(), new EventBus(), extensionsResult.runtime, "capture-agent-end", ); const extensionRunner = new ExtensionRunner( [captureAgentEnd], extensionsResult.runtime, tempDir.path(), sessionManager, modelRegistry, ); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": false, "compaction.strategy": "handoff", "compaction.thresholdPercent": 1, "contextPromotion.enabled": false, }), extensionRunner, modelRegistry, }); session.subscribe(event => { events.push(event); }); const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue("## Goal\nContinue from here"); await session.prompt("Trigger threshold handoff"); expect(mock.calls).toHaveLength(1); expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(agentEndWillContinue).toEqual([undefined]); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); expect(endEvents[0]).toMatchObject({ type: "auto_compaction_end", action: "handoff", aborted: false }); expect(endEvents[0]).not.toMatchObject({ errorMessage: expect.any(String) }); expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); }); it("does not start agent.continue when threshold-handoff defers and todos are incomplete", async () => { // Reproduces the user-reported race: at agent_end, threshold + handoff strategy // schedules a deferred handoff and returns. The handler used to fall through to // #checkTodoCompletion, which scheduled agent.continue() — both fired concurrently, // rendering as "Auto-handoff" loader + an assistant message still streaming. session.settings.set("compaction.strategy", "handoff"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); session.settings.set("todo.enabled", true); session.settings.set("todo.reminders", true); // Active todo phase with an incomplete task so #checkTodoCompletion would normally fire. session.setTodoPhases([{ name: "Phase 1", tasks: [{ content: "unfinished work", status: "pending" }] }]); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const handoffSpy = vi .spyOn(session, "handoff") .mockResolvedValue({ document: "## Goal\nContinue", savedPath: undefined }); const continueSpy = vi.spyOn(session.agent, "continue"); const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "maintenance trigger" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await waitFor(() => handoffSpy.mock.calls.length === 1); await session.waitForIdle(); expect(handoffSpy).toHaveBeenCalledTimes(1); // The bug surfaced as agent.continue() racing the deferred handoff. With the fix, // the agent_end handler short-circuits after the deferred-handoff signal. expect(continueSpy).not.toHaveBeenCalled(); }); it("dispose unblocks the post-prompt drain when a deferred handoff is mid-flight", async () => { // Reproduces /exit / Ctrl+C-double-tap hanging when a deferred handoff is awaiting // the LLM call: dispose() now aborts the handoff controller before draining post-prompt // tasks, so Promise.allSettled() in #cancelPostPromptTasks can resolve. session.settings.set("compaction.strategy", "handoff"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const { promise: handoffPending, resolve: resolveHandoff } = Promise.withResolvers(); const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockImplementation(async (_context, _model, options) => { // Mirror the real generateHandoffFromContext contract: reject when the // caller aborts via the stream-options signal. const signal = options.streamOptions.signal; return await new Promise((resolve, reject) => { signal?.addEventListener("abort", () => reject(new Error("Handoff cancelled")), { once: true }); handoffPending.then(resolve, reject); }); }); const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "maintenance trigger" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); // Let the deferred handoff post-prompt task enter the generateHandoff await. await waitFor(() => session.isGeneratingHandoff); expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(session.isGeneratingHandoff).toBe(true); // dispose must NOT wait for the LLM call to resolve on its own — it must abort it. const disposed = Promise.race([ session.dispose().then(() => "disposed" as const), Bun.sleep(2_000).then(() => "timeout" as const), ]); await expect(disposed).resolves.toBe("disposed"); // Releasing after the fact must not leak into other tests. resolveHandoff("handoff"); }); it("falls back to context-full when handoff strategy returns no document", async () => { session.settings.set("compaction.strategy", "handoff"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "maintenance trigger" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue(undefined); session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await waitFor(() => events.filter(event => event.type === "auto_compaction_end").length === 1); expect(handoffSpy).toHaveBeenCalledTimes(1); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); expect(endEvents[0]).toMatchObject({ type: "auto_compaction_end", action: "context-full", aborted: false, willRetry: false, }); expect(endEvents[0]).not.toMatchObject({ errorMessage: "Auto-handoff failed: no handoff document was generated", }); }); it("treats a vetoed auto-handoff switch as cancelled instead of falling back", async () => { session.settings.set("compaction.strategy", "handoff"); session.settings.set("compaction.thresholdPercent", 1); session.settings.set("contextPromotion.enabled", false); const model = session.model; if (!model) { throw new Error("Expected model to be set"); } const extensionsResult = await loadExtensions([], tempDir.path()); const extensionRunner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, tempDir.path(), sessionManager, modelRegistry, ); vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch"); const emitSpy = vi.spyOn(extensionRunner, "emit").mockImplementation((async () => ({ cancel: true, })) as ExtensionRunner["emit"]); await session.dispose(); session = new AgentSession({ agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [], }, }), sessionManager, settings: session.settings, modelRegistry, extensionRunner, obfuscator, }); session.subscribe(event => { events.push(event); }); const previousSessionFile = session.sessionFile; const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue("## Goal\nContinue from here"); const assistantMessage: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "maintenance trigger" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 10_000, output: 1_000, cacheRead: 0, cacheWrite: 0, totalTokens: 11_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; session.agent.emitExternalEvent({ type: "message_end", message: assistantMessage }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMessage] }); await waitFor(() => events.filter(event => event.type === "auto_compaction_end").length === 1); expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(emitSpy).toHaveBeenCalledWith({ type: "session_before_switch", reason: "handoff" }); expect(emitSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "session_switch" })); expect(session.sessionFile).toBe(previousSessionFile); expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); const endEvents = events.filter(event => event.type === "auto_compaction_end"); expect(endEvents).toHaveLength(1); expect(endEvents[0]).toMatchObject({ type: "auto_compaction_end", action: "handoff", aborted: true, willRetry: false, }); }); it("resets to the base system prompt before generating a handoff", async () => { const model = session.model; if (!model) { throw new Error("Expected model to be set"); } await session.dispose(); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); const extensionsResult = await loadExtensions([], tempDir.path()); const extensionRunner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, tempDir.path(), sessionManager, modelRegistry, ); const emitBeforeAgentStart = vi.spyOn(extensionRunner, "emitBeforeAgentStart").mockResolvedValueOnce({ systemPrompt: ["Hook override"], }); vi.spyOn(extensionRunner, "emit").mockResolvedValue(undefined); const mock = createMockModel({ responses: [{ content: ["normal response"] }], }); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: ["Test"], tools: [], messages: [], }, streamFn: mock.stream, }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": false }), modelRegistry, extensionRunner, }); sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() - 2, }); sessionManager.appendMessage({ role: "assistant", content: [{ type: "text", text: "seed response" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 16, output: 8, cacheRead: 0, cacheWrite: 0, totalTokens: 24, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now() - 1, }); await session.prompt("hello from user"); const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue("## Goal\nContinue from here"); await session.handoff(); expect(emitBeforeAgentStart).toHaveBeenCalledTimes(1); expect(mock.calls.map(c => c.context.systemPrompt?.join("\n\n") ?? "")).toEqual(["Hook override"]); const handoffCall = generateHandoffSpy.mock.calls[0]; if (!handoffCall) throw new Error("Expected generateHandoffFromContext call"); expect(handoffCall[0].systemPrompt).toEqual(["Test"]); }); it("forwards the agent's provider prompt-cache key to the handoff request", async () => { // Cache parity: the live loop routes on the agent's promptCacheKey // (providerPromptCacheKey), so handoff must reuse it rather than this.sessionId // — otherwise sessions built with a distinct key still cold-miss the cache. await session.dispose(); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, promptCacheKey: "shared-cache-key", sessionId: "provider-session-id", }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": false }), modelRegistry, }); sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() - 2, }); sessionManager.appendMessage({ role: "assistant", content: [{ type: "text", text: "seed response" }], api: model.api, provider: model.provider, model: model.id, stopReason: "stop", usage: { input: 16, output: 8, cacheRead: 0, cacheWrite: 0, totalTokens: 24, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now() - 1, }); const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockResolvedValue("## Goal\nContinue"); await session.handoff(); const call = generateHandoffSpy.mock.calls[0]; if (!call) throw new Error("Expected generateHandoffFromContext call"); const streamOptions = call[2].streamOptions; expect(streamOptions.promptCacheKey).toBe("shared-cache-key"); // Side-request lineage stays unique so append-only provider state never mixes. expect(streamOptions.sessionId).toContain(":side:"); expect(streamOptions.sessionId).not.toBe("shared-cache-key"); }); it("saves auto-handoff document to disk when enabled", async () => { session.settings.set("compaction.handoffSaveToDisk", true); const handoffText = "## Goal\nContinue from here"; vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue(handoffText); const result = await session.handoff(undefined, { autoTriggered: true }); expect(result?.savedPath).toBeDefined(); if (!result?.savedPath) throw new Error("Expected handoff document path"); expect(result.savedPath.endsWith(".md")).toBe(true); const savedText = await Bun.file(result.savedPath).text(); expect(savedText).toContain(handoffText); }); it("does not save manual handoff document when save setting is enabled", async () => { session.settings.set("compaction.handoffSaveToDisk", true); vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("## Goal\nManual handoff"); const result = await session.handoff(); expect(result?.savedPath).toBeUndefined(); }); it("does not start handoff prompt when provided signal is already cancelled", async () => { const controller = new AbortController(); controller.abort(); const generateHandoffSpy = vi.spyOn(compactionModule, "generateHandoffFromContext"); await expect(session.handoff(undefined, { signal: controller.signal })).rejects.toThrow("Handoff cancelled"); expect(generateHandoffSpy).not.toHaveBeenCalled(); }); it("aborts handoff generation when provided signal is cancelled", async () => { const controller = new AbortController(); const started = Promise.withResolvers(); const cancelled = Promise.withResolvers(); const generateHandoffSpy = vi .spyOn(compactionModule, "generateHandoffFromContext") .mockImplementation((_context, _model, options) => { started.resolve(); const signal = options.streamOptions.signal; const onAbort = () => { const error = new Error("aborted"); error.name = "AbortError"; cancelled.reject(error); }; if (signal?.aborted) { onAbort(); } else { signal?.addEventListener("abort", onAbort, { once: true }); } return cancelled.promise; }); const handoffPromise = session.handoff(undefined, { signal: controller.signal }); await started.promise; controller.abort(); await expect(handoffPromise).rejects.toThrow("Handoff cancelled"); expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(generateHandoffSpy.mock.calls[0]?.[2]?.streamOptions?.signal?.aborted).toBe(true); }); });