import { beforeEach, describe, expect, it, vi } from "vitest"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; const { hookRunner, ensureRuntimePluginsLoaded, resolveContextEngineMock, resolveModelMock, sessionCompactImpl, triggerInternalHook, sanitizeSessionHistoryMock, contextEngineCompactMock, getMemorySearchManagerMock, resolveMemorySearchConfigMock, resolveSessionAgentIdMock, estimateTokensMock, } = vi.hoisted(() => { const contextEngineCompactMock = vi.fn(async () => ({ ok: true as boolean, compacted: true as boolean, reason: undefined as string | undefined, result: { summary: "engine-summary", tokensAfter: 50 } as | { summary: string; tokensAfter: number } | undefined, })); return { hookRunner: { hasHooks: vi.fn(), runBeforeCompaction: vi.fn(), runAfterCompaction: vi.fn(), }, ensureRuntimePluginsLoaded: vi.fn(), resolveContextEngineMock: vi.fn(async () => ({ info: { ownsCompaction: true }, compact: contextEngineCompactMock, })), resolveModelMock: vi.fn(() => ({ model: { provider: "openai", api: "responses", id: "fake", input: [] }, error: null, authStorage: { setRuntimeApiKey: vi.fn() }, modelRegistry: {}, })), sessionCompactImpl: vi.fn(async () => ({ summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 120, details: { ok: true }, })), triggerInternalHook: vi.fn(), sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages), contextEngineCompactMock, getMemorySearchManagerMock: vi.fn(async () => ({ manager: { sync: vi.fn(async () => {}), }, })), resolveMemorySearchConfigMock: vi.fn(() => ({ sources: ["sessions"], sync: { sessions: { postCompactionForce: true, }, }, })), resolveSessionAgentIdMock: vi.fn(() => "main"), estimateTokensMock: vi.fn((_message?: unknown) => 10), }; }); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookRunner, })); vi.mock("../runtime-plugins.js", () => ({ ensureRuntimePluginsLoaded, })); vi.mock("../../hooks/internal-hooks.js", async () => { const actual = await vi.importActual( "../../hooks/internal-hooks.js", ); return { ...actual, triggerInternalHook, }; }); vi.mock("@mariozechner/pi-ai/oauth", () => ({ getOAuthApiKey: vi.fn(), getOAuthProviders: vi.fn(() => []), })); vi.mock("@mariozechner/pi-coding-agent", () => { return { AuthStorage: class AuthStorage {}, ModelRegistry: class ModelRegistry {}, createAgentSession: vi.fn(async () => { const session = { sessionId: "session-1", messages: [ { role: "user", content: "hello", timestamp: 1 }, { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, { role: "toolResult", toolCallId: "t1", toolName: "exec", content: [{ type: "text", text: "output" }], isError: false, timestamp: 3, }, ], agent: { replaceMessages: vi.fn((messages: unknown[]) => { session.messages = [...(messages as typeof session.messages)]; }), streamFn: vi.fn(), }, compact: vi.fn(async () => { // simulate compaction trimming to a single message session.messages.splice(1); return await sessionCompactImpl(); }), dispose: vi.fn(), }; return { session }; }), SessionManager: { open: vi.fn(() => ({})), }, SettingsManager: { create: vi.fn(() => ({})), }, estimateTokens: estimateTokensMock, }; }); vi.mock("../session-tool-result-guard-wrapper.js", () => ({ guardSessionManager: vi.fn(() => ({ flushPendingToolResults: vi.fn(), })), })); vi.mock("../pi-settings.js", () => ({ ensurePiCompactionReserveTokens: vi.fn(), resolveCompactionReserveTokensFloor: vi.fn(() => 0), })); vi.mock("../models-config.js", () => ({ ensureOpenClawModelsJson: vi.fn(async () => {}), })); vi.mock("../model-auth.js", () => ({ getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), resolveModelAuthMode: vi.fn(() => "env"), })); vi.mock("../sandbox.js", () => ({ resolveSandboxContext: vi.fn(async () => null), })); vi.mock("../session-file-repair.js", () => ({ repairSessionFileIfNeeded: vi.fn(async () => {}), })); vi.mock("../session-write-lock.js", () => ({ acquireSessionWriteLock: vi.fn(async () => ({ release: vi.fn(async () => {}) })), resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0), })); vi.mock("../../context-engine/index.js", () => ({ ensureContextEnginesInitialized: vi.fn(), resolveContextEngine: resolveContextEngineMock, })); vi.mock("../../process/command-queue.js", () => ({ enqueueCommandInLane: vi.fn((_lane: unknown, task: () => unknown) => task()), })); vi.mock("./lanes.js", () => ({ resolveSessionLane: vi.fn(() => "test-session-lane"), resolveGlobalLane: vi.fn(() => "test-global-lane"), })); vi.mock("../context-window-guard.js", () => ({ resolveContextWindowInfo: vi.fn(() => ({ tokens: 128_000 })), })); vi.mock("../bootstrap-files.js", () => ({ makeBootstrapWarn: vi.fn(() => () => {}), resolveBootstrapContextForRun: vi.fn(async () => ({ contextFiles: [] })), })); vi.mock("../docs-path.js", () => ({ resolveOpenClawDocsPath: vi.fn(async () => undefined), })); vi.mock("../channel-tools.js", () => ({ listChannelSupportedActions: vi.fn(() => undefined), resolveChannelMessageToolHints: vi.fn(() => undefined), })); vi.mock("../pi-tools.js", () => ({ createOpenClawCodingTools: vi.fn(() => []), })); vi.mock("./google.js", () => ({ logToolSchemasForGoogle: vi.fn(), sanitizeSessionHistory: sanitizeSessionHistoryMock, sanitizeToolsForGoogle: vi.fn(({ tools }: { tools: unknown[] }) => tools), })); vi.mock("./tool-split.js", () => ({ splitSdkTools: vi.fn(() => ({ builtInTools: [], customTools: [] })), })); vi.mock("../transcript-policy.js", () => ({ resolveTranscriptPolicy: vi.fn(() => ({ allowSyntheticToolResults: false, validateGeminiTurns: false, validateAnthropicTurns: false, })), })); vi.mock("./extensions.js", () => ({ buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })), })); vi.mock("./history.js", () => ({ getDmHistoryLimitFromSessionKey: vi.fn(() => undefined), limitHistoryTurns: vi.fn((msgs: unknown[]) => msgs.slice(0, 2)), })); vi.mock("../skills.js", () => ({ applySkillEnvOverrides: vi.fn(() => () => {}), applySkillEnvOverridesFromSnapshot: vi.fn(() => () => {}), loadWorkspaceSkillEntries: vi.fn(() => []), resolveSkillsPromptForRun: vi.fn(() => undefined), })); vi.mock("../agent-paths.js", () => ({ resolveOpenClawAgentDir: vi.fn(() => "/tmp"), })); vi.mock("../agent-scope.js", () => ({ resolveSessionAgentId: resolveSessionAgentIdMock, resolveSessionAgentIds: vi.fn(() => ({ defaultAgentId: "main", sessionAgentId: "main" })), })); vi.mock("../memory-search.js", () => ({ resolveMemorySearchConfig: resolveMemorySearchConfigMock, })); vi.mock("../../memory/index.js", () => ({ getMemorySearchManager: getMemorySearchManagerMock, })); vi.mock("../date-time.js", () => ({ formatUserTime: vi.fn(() => ""), resolveUserTimeFormat: vi.fn(() => ""), resolveUserTimezone: vi.fn(() => ""), })); vi.mock("../defaults.js", () => ({ DEFAULT_MODEL: "fake-model", DEFAULT_PROVIDER: "openai", DEFAULT_CONTEXT_TOKENS: 128_000, })); vi.mock("../utils.js", () => ({ resolveUserPath: vi.fn((p: string) => p), })); vi.mock("../../infra/machine-name.js", () => ({ getMachineDisplayName: vi.fn(async () => "machine"), })); vi.mock("../../config/channel-capabilities.js", () => ({ resolveChannelCapabilities: vi.fn(() => undefined), })); vi.mock("../../utils/message-channel.js", () => ({ INTERNAL_MESSAGE_CHANNEL: "webchat", normalizeMessageChannel: vi.fn(() => undefined), })); vi.mock("../pi-embedded-helpers.js", () => ({ ensureSessionHeader: vi.fn(async () => {}), validateAnthropicTurns: vi.fn((m: unknown[]) => m), validateGeminiTurns: vi.fn((m: unknown[]) => m), })); vi.mock("../pi-project-settings.js", () => ({ createPreparedEmbeddedPiSettingsManager: vi.fn(() => ({ getGlobalSettings: vi.fn(() => ({})), })), })); vi.mock("./sandbox-info.js", () => ({ buildEmbeddedSandboxInfo: vi.fn(() => undefined), })); vi.mock("./model.js", () => ({ buildModelAliasLines: vi.fn(() => []), resolveModel: resolveModelMock, })); vi.mock("./session-manager-cache.js", () => ({ prewarmSessionFile: vi.fn(async () => {}), trackSessionManagerAccess: vi.fn(), })); vi.mock("./system-prompt.js", () => ({ applySystemPromptOverrideToSession: vi.fn(), buildEmbeddedSystemPrompt: vi.fn(() => ""), createSystemPromptOverride: vi.fn(() => () => ""), })); vi.mock("./utils.js", () => ({ describeUnknownError: vi.fn((err: unknown) => String(err)), mapThinkingLevel: vi.fn(() => "off"), resolveExecToolDefaults: vi.fn(() => undefined), })); import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; import { compactEmbeddedPiSessionDirect, compactEmbeddedPiSession } from "./compact.js"; const TEST_SESSION_ID = "session-1"; const TEST_SESSION_KEY = "agent:main:session-1"; const TEST_SESSION_FILE = "/tmp/session.jsonl"; const TEST_WORKSPACE_DIR = "/tmp"; const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions"; function mockResolvedModel() { resolveModelMock.mockReset(); resolveModelMock.mockReturnValue({ model: { provider: "openai", api: "responses", id: "fake", input: [] }, error: null, authStorage: { setRuntimeApiKey: vi.fn() }, modelRegistry: {}, }); } function compactionConfig(mode: "await" | "off" | "async") { return { agents: { defaults: { compaction: { postIndexSync: mode, }, }, }, } as never; } function directCompactionArgs(overrides: Record = {}) { return { sessionId: TEST_SESSION_ID, sessionKey: TEST_SESSION_KEY, sessionFile: TEST_SESSION_FILE, workspaceDir: TEST_WORKSPACE_DIR, customInstructions: TEST_CUSTOM_INSTRUCTIONS, ...overrides, }; } function wrappedCompactionArgs(overrides: Record = {}) { return { sessionId: TEST_SESSION_ID, sessionKey: TEST_SESSION_KEY, sessionFile: TEST_SESSION_FILE, workspaceDir: TEST_WORKSPACE_DIR, customInstructions: TEST_CUSTOM_INSTRUCTIONS, enqueue: async (task: () => Promise | T) => await task(), ...overrides, }; } const sessionHook = (action: string) => triggerInternalHook.mock.calls.find( (call) => call[0]?.type === "session" && call[0]?.action === action, )?.[0]; describe("compactEmbeddedPiSessionDirect hooks", () => { beforeEach(() => { ensureRuntimePluginsLoaded.mockReset(); triggerInternalHook.mockClear(); hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); hookRunner.runAfterCompaction.mockReset(); mockResolvedModel(); sessionCompactImpl.mockReset(); sessionCompactImpl.mockResolvedValue({ summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 120, details: { ok: true }, }); sanitizeSessionHistoryMock.mockReset(); sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { return params.messages; }); getMemorySearchManagerMock.mockReset(); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync: vi.fn(async () => {}), }, }); resolveMemorySearchConfigMock.mockReset(); resolveMemorySearchConfigMock.mockReturnValue({ sources: ["sessions"], sync: { sessions: { postCompactionForce: true, }, }, }); resolveSessionAgentIdMock.mockReset(); resolveSessionAgentIdMock.mockReturnValue("main"); estimateTokensMock.mockReset(); estimateTokensMock.mockReturnValue(10); unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); }); async function runDirectCompaction(customInstructions = TEST_CUSTOM_INSTRUCTIONS) { return await compactEmbeddedPiSessionDirect( directCompactionArgs({ customInstructions, }), ); } it("bootstraps runtime plugins with the resolved workspace", async () => { await compactEmbeddedPiSessionDirect({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp/workspace", }); expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ config: undefined, workspaceDir: "/tmp/workspace", }); }); it("emits internal + plugin compaction hooks with counts", async () => { hookRunner.hasHooks.mockReturnValue(true); let sanitizedCount = 0; sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { const sanitized = params.messages.slice(1); sanitizedCount = sanitized.length; return sanitized; }); const result = await compactEmbeddedPiSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", messageChannel: "telegram", customInstructions: "focus on decisions", }); expect(result.ok).toBe(true); expect(sessionHook("compact:before")).toMatchObject({ type: "session", action: "compact:before", }); const beforeContext = sessionHook("compact:before")?.context; const afterContext = sessionHook("compact:after")?.context; expect(beforeContext).toMatchObject({ messageCount: 2, tokenCount: 20, messageCountOriginal: sanitizedCount, tokenCountOriginal: sanitizedCount * 10, }); expect(afterContext).toMatchObject({ messageCount: 1, compactedCount: 1, }); expect(afterContext?.compactedCount).toBe( (beforeContext?.messageCountOriginal as number) - (afterContext?.messageCount as number), ); expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith( expect.objectContaining({ messageCount: 2, tokenCount: 20, }), expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }), ); expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith( { messageCount: 1, tokenCount: 10, compactedCount: 1, }, expect.objectContaining({ sessionKey: "agent:main:session-1", messageProvider: "telegram" }), ); }); it("uses sessionId as hook session key fallback when sessionKey is missing", async () => { hookRunner.hasHooks.mockReturnValue(true); const result = await compactEmbeddedPiSessionDirect({ sessionId: "session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", customInstructions: "focus on decisions", }); expect(result.ok).toBe(true); expect(sessionHook("compact:before")?.sessionKey).toBe("session-1"); expect(sessionHook("compact:after")?.sessionKey).toBe("session-1"); expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ sessionKey: "session-1" }), ); expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ sessionKey: "session-1" }), ); }); it("applies validated transcript before hooks even when it becomes empty", async () => { hookRunner.hasHooks.mockReturnValue(true); sanitizeSessionHistoryMock.mockResolvedValue([]); const result = await runDirectCompaction(); expect(result.ok).toBe(true); const beforeContext = sessionHook("compact:before")?.context; expect(beforeContext).toMatchObject({ messageCountOriginal: 0, tokenCountOriginal: 0, messageCount: 0, tokenCount: 0, }); }); it("emits a transcript update after successful compaction", async () => { const listener = vi.fn(); const cleanup = onSessionTranscriptUpdate(listener); try { const result = await compactEmbeddedPiSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:session-1", sessionFile: " /tmp/session.jsonl ", workspaceDir: "/tmp", customInstructions: "focus on decisions", }); expect(result.ok).toBe(true); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ sessionFile: "/tmp/session.jsonl" }); } finally { cleanup(); } }); it("preserves tokensAfter when full-session context exceeds result.tokensBefore", async () => { estimateTokensMock.mockImplementation((message: unknown) => { const role = (message as { role?: string }).role; if (role === "user") { return 30; } if (role === "assistant") { return 20; } return 5; }); sessionCompactImpl.mockResolvedValue({ summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 20, details: { ok: true }, }); const result = await runDirectCompaction(); expect(result).toMatchObject({ ok: true, compacted: true, result: { tokensBefore: 20, tokensAfter: 30, }, }); expect(sessionHook("compact:after")?.context?.tokenCount).toBe(30); }); it("treats pre-compaction token estimation failures as a no-op sanity check", async () => { estimateTokensMock.mockImplementation((message: unknown) => { const role = (message as { role?: string }).role; if (role === "assistant") { throw new Error("legacy message"); } if (role === "user") { return 30; } return 5; }); sessionCompactImpl.mockResolvedValue({ summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 20, details: { ok: true }, }); const result = await compactEmbeddedPiSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", customInstructions: "focus on decisions", }); expect(result).toMatchObject({ ok: true, compacted: true, result: { tokensAfter: 30, }, }); expect(sessionHook("compact:after")?.context?.tokenCount).toBe(30); }); it("skips sync in await mode when postCompactionForce is false", async () => { const sync = vi.fn(async () => {}); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); resolveMemorySearchConfigMock.mockReturnValue({ sources: ["sessions"], sync: { sessions: { postCompactionForce: false, }, }, }); const result = await compactEmbeddedPiSessionDirect( directCompactionArgs({ config: compactionConfig("await"), }), ); expect(result.ok).toBe(true); expect(resolveSessionAgentIdMock).toHaveBeenCalledWith({ sessionKey: TEST_SESSION_KEY, config: expect.any(Object), }); expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); expect(sync).not.toHaveBeenCalled(); }); it("awaits post-compaction memory sync in await mode when postCompactionForce is true", async () => { let releaseSync: (() => void) | undefined; const syncGate = new Promise((resolve) => { releaseSync = resolve; }); const sync = vi.fn(() => syncGate); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); let settled = false; const resultPromise = compactEmbeddedPiSessionDirect( directCompactionArgs({ config: compactionConfig("await"), }), ); void resultPromise.then(() => { settled = true; }); await vi.waitFor(() => { expect(sync).toHaveBeenCalledWith({ reason: "post-compaction", sessionFiles: [TEST_SESSION_FILE], }); }); expect(settled).toBe(false); releaseSync?.(); const result = await resultPromise; expect(result.ok).toBe(true); expect(settled).toBe(true); }); it("skips post-compaction memory sync when the mode is off", async () => { const sync = vi.fn(async () => {}); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); const result = await compactEmbeddedPiSessionDirect( directCompactionArgs({ config: compactionConfig("off"), }), ); expect(result.ok).toBe(true); expect(resolveSessionAgentIdMock).not.toHaveBeenCalled(); expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); expect(sync).not.toHaveBeenCalled(); }); it("fires post-compaction memory sync without awaiting it in async mode", async () => { const sync = vi.fn(async () => {}); let resolveManager: ((value: { manager: { sync: typeof sync } }) => void) | undefined; const managerGate = new Promise<{ manager: { sync: typeof sync } }>((resolve) => { resolveManager = resolve; }); getMemorySearchManagerMock.mockImplementation(() => managerGate); let settled = false; const resultPromise = compactEmbeddedPiSessionDirect( directCompactionArgs({ config: compactionConfig("async"), }), ); await vi.waitFor(() => { expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(1); }); void resultPromise.then(() => { settled = true; }); await vi.waitFor(() => { expect(settled).toBe(true); }); expect(sync).not.toHaveBeenCalled(); resolveManager?.({ manager: { sync } }); await managerGate; await vi.waitFor(() => { expect(sync).toHaveBeenCalledWith({ reason: "post-compaction", sessionFiles: [TEST_SESSION_FILE], }); }); const result = await resultPromise; expect(result.ok).toBe(true); }); it("registers the Ollama api provider before compaction", async () => { resolveModelMock.mockReturnValue({ model: { provider: "ollama", api: "ollama", id: "qwen3:8b", input: ["text"], baseUrl: "http://127.0.0.1:11434", headers: { Authorization: "Bearer ollama-cloud" }, }, error: null, authStorage: { setRuntimeApiKey: vi.fn() }, modelRegistry: {}, } as never); sessionCompactImpl.mockImplementation(async () => { expect(getApiProvider("ollama" as Parameters[0])).toBeDefined(); return { summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 120, details: { ok: true }, }; }); const result = await compactEmbeddedPiSessionDirect({ sessionId: "session-1", sessionKey: "agent:main:session-1", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", customInstructions: "focus on decisions", }); expect(result.ok).toBe(true); }); }); describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { beforeEach(() => { hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); hookRunner.runAfterCompaction.mockReset(); resolveContextEngineMock.mockReset(); resolveContextEngineMock.mockResolvedValue({ info: { ownsCompaction: true }, compact: contextEngineCompactMock, }); contextEngineCompactMock.mockReset(); contextEngineCompactMock.mockResolvedValue({ ok: true, compacted: true, reason: undefined, result: { summary: "engine-summary", tokensAfter: 50 }, }); mockResolvedModel(); }); it("fires before_compaction with sentinel -1 and after_compaction on success", async () => { hookRunner.hasHooks.mockReturnValue(true); const result = await compactEmbeddedPiSession( wrappedCompactionArgs({ messageChannel: "telegram", }), ); expect(result.ok).toBe(true); expect(result.compacted).toBe(true); expect(hookRunner.runBeforeCompaction).toHaveBeenCalledWith( { messageCount: -1, sessionFile: TEST_SESSION_FILE }, expect.objectContaining({ sessionKey: TEST_SESSION_KEY, messageProvider: "telegram", }), ); expect(hookRunner.runAfterCompaction).toHaveBeenCalledWith( { messageCount: -1, compactedCount: -1, tokenCount: 50, sessionFile: TEST_SESSION_FILE, }, expect.objectContaining({ sessionKey: TEST_SESSION_KEY, messageProvider: "telegram", }), ); }); it("emits a transcript update and post-compaction memory sync on the engine-owned path", async () => { const listener = vi.fn(); const cleanup = onSessionTranscriptUpdate(listener); const sync = vi.fn(async () => {}); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); try { const result = await compactEmbeddedPiSession( wrappedCompactionArgs({ sessionFile: ` ${TEST_SESSION_FILE} `, config: compactionConfig("await"), }), ); expect(result.ok).toBe(true); expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith({ sessionFile: TEST_SESSION_FILE }); expect(sync).toHaveBeenCalledWith({ reason: "post-compaction", sessionFiles: [TEST_SESSION_FILE], }); } finally { cleanup(); } }); it("does not fire after_compaction when compaction fails", async () => { hookRunner.hasHooks.mockReturnValue(true); const sync = vi.fn(async () => {}); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); contextEngineCompactMock.mockResolvedValue({ ok: false, compacted: false, reason: "nothing to compact", result: undefined, }); const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); expect(result.ok).toBe(false); expect(hookRunner.runBeforeCompaction).toHaveBeenCalled(); expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled(); expect(sync).not.toHaveBeenCalled(); }); it("does not duplicate transcript updates or sync in the wrapper when the engine delegates compaction", async () => { const listener = vi.fn(); const cleanup = onSessionTranscriptUpdate(listener); const sync = vi.fn(async () => {}); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); resolveContextEngineMock.mockResolvedValue({ info: { ownsCompaction: false }, compact: contextEngineCompactMock, }); try { const result = await compactEmbeddedPiSession( wrappedCompactionArgs({ config: compactionConfig("await"), }), ); expect(result.ok).toBe(true); expect(listener).not.toHaveBeenCalled(); expect(sync).not.toHaveBeenCalled(); } finally { cleanup(); } }); it("catches and logs hook exceptions without aborting compaction", async () => { hookRunner.hasHooks.mockReturnValue(true); hookRunner.runBeforeCompaction.mockRejectedValue(new Error("hook boom")); const result = await compactEmbeddedPiSession(wrappedCompactionArgs()); expect(result.ok).toBe(true); expect(result.compacted).toBe(true); expect(contextEngineCompactMock).toHaveBeenCalled(); }); });