import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai/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 { discoverAndLoadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AgentSession } 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 { TempDir } from "@oh-my-pi/pi-utils"; import { getProjectAgentDir } from "@oh-my-pi/pi-utils/dirs"; /** * Regression test: auto-compaction completion should resume the agent loop when * there are queued agent-level messages (follow-up/steering/custom). */ describe("AgentSession auto-compaction queue resume", () => { let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; beforeEach(async () => { tempDir = TempDir.createSync("@pi-auto-compaction-queue-"); vi.useFakeTimers(); // Provide an extension that short-circuits compaction so the test doesn't // make any LLM calls. const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); fs.writeFileSync( path.join(extensionsDir, "compaction-short-circuit.ts"), [ "export default function(pi) {", '\tpi.on("session_before_compact", async (event) => {', "\t\treturn {", "\t\t\tcompaction: {", '\t\t\t\tsummary: "compacted",', "\t\t\t\tshortSummary: undefined,", "\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,", "\t\t\t\ttokensBefore: event.preparation.tokensBefore,", "\t\t\t\tdetails: {},", "\t\t\t},", "\t\t};", "\t});", "}", ].join("\n"), ); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); sessionManager = SessionManager.inMemory(); const extensionsResult = await discoverAndLoadExtensions([], tempDir.path()); const extensionRunner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, tempDir.path(), sessionManager, modelRegistry, ); const model = getModel("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("Expected built-in anthropic model to exist"); } const agent = new Agent({ initialState: { model, systemPrompt: "Test", tools: [], messages: [], }, }); // Seed a minimal session branch so prepareCompaction() returns a preparation. sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now(), }); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.autoContinue": false }), modelRegistry, extensionRunner, }); }); afterEach(() => { session.dispose(); tempDir.removeSync(); vi.useRealTimers(); vi.restoreAllMocks(); }); it("resumes after threshold compaction when only agent-level queued messages exist", async () => { session.agent.followUp({ role: "custom", customType: "test", content: [{ type: "text", text: "Queued custom" }], display: false, timestamp: Date.now(), }); expect(session.agent.hasQueuedMessages()).toBe(true); const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); // Wait for auto_compaction_end event to know when the async handler is done const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); session.subscribe(event => { if (event.type === "auto_compaction_end") onCompactionDone(); }); // Build a fake AssistantMessage with high token usage to trigger threshold // compaction (contextWindow=200000, threshold ~80%). const assistantMsg = { role: "assistant" as const, content: [], api: "anthropic-messages" as const, provider: "anthropic" as const, model: "claude-sonnet-4-5", stopReason: "stop" as const, usage: { input: 190000, output: 1000, cacheRead: 0, cacheWrite: 0, totalTokens: 191000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), }; // Drive auto-compaction through the event flow: // message_end → stores #lastAssistantMessage // agent_end → #checkCompaction → shouldCompact → #runAutoCompaction session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); // Wait for the async compaction handler to finish, then advance past setTimeout(100) await compactionDone; vi.advanceTimersByTime(200); expect(continueSpy).toHaveBeenCalledTimes(1); }); });