import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; 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 { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import type { GoalModeState } from "@oh-my-pi/pi-coding-agent/goals/state"; 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 { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; 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"; function activeGoalState(): GoalModeState { const now = Date.now(); return { enabled: true, mode: "active", goal: { id: "goal-midrun-compaction", objective: "Ship the release", status: "active", tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now, }, }; } function highUsage(input: number) { return { input, output: 100, cacheRead: 0, cacheWrite: 0, totalTokens: input + 100, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } describe("AgentSession mid-run threshold compaction", () => { let tempDir: TempDir; const cleanups: Array<() => Promise> = []; beforeEach(() => { tempDir = TempDir.createSync("@pi-agent-goal-midrun-compaction-"); cleanups.length = 0; }); afterEach(async () => { for (const cleanup of cleanups) await cleanup(); cleanups.length = 0; tempDir.removeSync(); vi.restoreAllMocks(); }); async function createHarness( settingsOverride: Record = {}, options: { extensionRunner?: ExtensionRunner } = {}, ): Promise<{ session: AgentSession; observedContexts: string[][]; sessionManager: SessionManager; }> { const observedContexts: string[][] = []; const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`)); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`)); const settings = Settings.isolated({ "compaction.enabled": true, "compaction.strategy": "context-full", "compaction.autoContinue": true, "compaction.midTurnEnabled": true, "compaction.thresholdTokens": 1000, "compaction.thresholdPercent": -1, "contextPromotion.enabled": false, "todo.enabled": false, "todo.reminders": false, ...settingsOverride, }); const sessionManager = SessionManager.inMemory(tempDir.path()); const mockBashTool: AgentTool = { name: "bash", label: "Bash", description: "Mock bash tool", parameters: type({}), execute: async () => ({ content: [{ type: "text" as const, text: "tool output" }] }), }; let call = 0; const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: ["Test"], tools: [mockBashTool], messages: [] }, convertToLlm, streamFn: (_model, context) => { const index = call++; observedContexts.push(context.messages.map(message => JSON.stringify(message))); const stream = new AssistantMessageEventStream(); const isToolTurn = index === 0; const message = isToolTurn ? { role: "assistant" as const, content: [ { type: "toolCall" as const, id: `tc-${index}`, name: "bash", arguments: { cmd: "pwd" } }, ], api: "anthropic-messages" as const, provider: "anthropic" as const, model: "claude-sonnet-4-5", usage: highUsage(50_000), stopReason: "toolUse" as const, timestamp: Date.now(), } : { role: "assistant" as const, content: [{ type: "text" as const, text: "All done." }], api: "anthropic-messages" as const, provider: "anthropic" as const, model: "claude-sonnet-4-5", usage: highUsage(200), stopReason: "stop" as const, timestamp: Date.now(), }; queueMicrotask(() => { stream.push({ type: "start", partial: message }); stream.push({ type: "done", reason: message.stopReason, message }); }); return stream; }, }); const session = new AgentSession({ agent, sessionManager, settings, modelRegistry, toolRegistry: new Map([[mockBashTool.name, mockBashTool]]), extensionRunner: options.extensionRunner, }); cleanups.push(async () => { await session.dispose(); authStorage.close(); }); return { session, sessionManager, observedContexts }; } function mockCompaction(summary: string) { return vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ summary, shortSummary: undefined, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, details: {}, })); } it("compacts in place between tool-call turns outside goal mode", async () => { const { session, observedContexts } = await createHarness(); const compactSpy = mockCompaction("MID-RUN-COMPACTED"); await session.prompt("work on the release"); expect(compactSpy).toHaveBeenCalledTimes(1); expect(observedContexts.length).toBeGreaterThanOrEqual(2); expect(observedContexts[1].join("\n")).toContain("MID-RUN-COMPACTED"); }); it("compacts in place between tool-call turns during an active goal run", async () => { const { session, observedContexts } = await createHarness(); session.setGoalModeState(activeGoalState()); const compactSpy = mockCompaction("ACTIVE-GOAL-MID-RUN-COMPACTED"); await session.prompt("work on the release"); expect(compactSpy).toHaveBeenCalledTimes(1); expect(observedContexts.length).toBeGreaterThanOrEqual(2); expect(observedContexts[1].join("\n")).toContain("ACTIVE-GOAL-MID-RUN-COMPACTED"); }); it("falls back to in-place compaction for mid-run handoff strategy", async () => { const { session, observedContexts } = await createHarness({ "compaction.strategy": "handoff" }); const handoffSpy = vi.spyOn(session, "handoff").mockImplementation(async () => { throw new Error("mid-run compaction must not reset the session through handoff"); }); const compactSpy = mockCompaction("HANDOFF-MID-RUN-COMPACTED-IN-PLACE"); await session.prompt("work on the release"); expect(handoffSpy).not.toHaveBeenCalled(); expect(compactSpy).toHaveBeenCalledTimes(1); expect(observedContexts[1].join("\n")).toContain("HANDOFF-MID-RUN-COMPACTED-IN-PLACE"); }); it("preserves the just-finished tool turn when message_end hooks are still pending", async () => { const releaseMessageEnd = Promise.withResolvers(); const messageEndEntered = Promise.withResolvers(); const turnEndEntered = Promise.withResolvers(); const extensionRunner = { hasHandlers: vi.fn((eventType: string) => eventType === "message_end" || eventType === "turn_end"), emitBeforeAgentStart: vi.fn(async () => undefined), emit: vi.fn(async (event: { type: string; message?: AgentMessage }) => { if (event.type === "turn_end") { turnEndEntered.resolve(); return; } if ( event.type === "message_end" && event.message?.role === "assistant" && event.message.stopReason === "toolUse" ) { messageEndEntered.resolve(); await releaseMessageEnd.promise; } }), } as unknown as ExtensionRunner; const { session, sessionManager, observedContexts } = await createHarness({}, { extensionRunner }); const compactSpy = mockCompaction("MID-RUN-COMPACTED-WITH-PENDING-HOOK"); const prompt = session.prompt("work on the release"); await messageEndEntered.promise; await turnEndEntered.promise; releaseMessageEnd.resolve(); await prompt; expect(compactSpy).toHaveBeenCalledTimes(1); expect(observedContexts.length).toBeGreaterThanOrEqual(2); const nextProviderContext = observedContexts[1]; const toolUseAssistantIndex = nextProviderContext.findIndex( serialized => serialized.includes('"role":"assistant"') && serialized.includes('"stopReason":"toolUse"') && serialized.includes('"id":"tc-0"'), ); const toolResultIndex = nextProviderContext.findIndex( serialized => serialized.includes('"role":"toolResult"') && serialized.includes('"toolCallId":"tc-0"'), ); expect(toolUseAssistantIndex).toBeGreaterThanOrEqual(0); expect(toolResultIndex).toBeGreaterThan(toolUseAssistantIndex); expect(nextProviderContext.filter(serialized => serialized.includes('"id":"tc-0"'))).toHaveLength(1); expect(nextProviderContext.filter(serialized => serialized.includes('"toolCallId":"tc-0"'))).toHaveLength(1); expect(nextProviderContext.join("\n")).toContain("MID-RUN-COMPACTED-WITH-PENDING-HOOK"); expect(nextProviderContext.join("\n")).toContain("tool output"); const persistedToolTurnRoles = sessionManager .getBranch() .filter(entry => entry.type === "message") .map(entry => entry.message) .filter(message => { const serialized = JSON.stringify(message); return ( (message.role === "assistant" || message.role === "toolResult") && (serialized.includes('"id":"tc-0"') || serialized.includes('"toolCallId":"tc-0"')) ); }) .map(message => message.role); expect(persistedToolTurnRoles).toEqual(["assistant", "toolResult"]); }); it("treats same-key assistant content variants as persisted before mid-run compaction", async () => { const extensionRuntime = new ExtensionRuntime(); const extension = await loadExtensionFromFactory( pi => { pi.on("message_end", event => { if (event.message.role !== "assistant" || event.message.stopReason !== "toolUse") return; const [block] = event.message.content; if (block?.type !== "toolCall") return; event.message.content = [{ ...block, arguments: { cmd: "display-variant" } }]; }); }, tempDir.path(), new EventBus(), extensionRuntime, "assistant-display-variant", ); const extensionAuthStorage = await AuthStorage.create(path.join(tempDir.path(), "extension-auth-variant.db")); cleanups.push(async () => { extensionAuthStorage.close(); }); const extensionRunner = new ExtensionRunner( [extension], extensionRuntime, tempDir.path(), SessionManager.inMemory(), new ModelRegistry(extensionAuthStorage, path.join(tempDir.path(), "extension-models-variant.yml")), ); const { session, observedContexts } = await createHarness({}, { extensionRunner }); const compactSpy = mockCompaction("MID-RUN-COMPACTED-WITH-CONTENT-VARIANT"); await session.prompt("work on the release"); expect(compactSpy).toHaveBeenCalledTimes(1); expect(observedContexts.length).toBeGreaterThanOrEqual(2); expect(observedContexts[1].join("\n")).toContain("MID-RUN-COMPACTED-WITH-CONTENT-VARIANT"); }); it("does not compact mid-run outside goal mode when disabled", async () => { const { session } = await createHarness({ "compaction.midTurnEnabled": false }); const compactSpy = mockCompaction("SHOULD-NOT-RUN"); await session.prompt("work on the release"); expect(compactSpy).not.toHaveBeenCalled(); }); it("does not compact mid-run during active goal mode when disabled", async () => { const { session } = await createHarness({ "compaction.midTurnEnabled": false }); session.setGoalModeState(activeGoalState()); const compactSpy = mockCompaction("SHOULD-NOT-RUN"); await session.prompt("work on the release"); expect(compactSpy).not.toHaveBeenCalled(); }); });