From 1e1a92ea47edd71d18306aca5d797084577e3090 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Wed, 24 Dec 2025 02:26:29 -0800 Subject: [PATCH] Add before_compact hook event (closes #281) (#285) * Add before_compact hook event (closes #281) * Add compact hook event and documentation - Add compact event that fires after compaction completes - Update hooks.md with lifecycle diagram, field docs, and example - Add CHANGELOG entry - Add comprehensive test coverage (10 tests) for before_compact and compact events - Tests cover: event emission, cancellation, custom entry, error handling, multiple hooks --- package-lock.json | 7 +- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/docs/hooks.md | 55 ++- .../coding-agent/src/core/agent-session.ts | 154 +++++-- packages/coding-agent/src/core/compaction.ts | 43 ++ packages/coding-agent/src/core/hooks/types.ts | 24 +- .../test/compaction-hooks-example.test.ts | 70 ++++ .../test/compaction-hooks.test.ts | 379 ++++++++++++++++++ 8 files changed, 700 insertions(+), 36 deletions(-) create mode 100644 packages/coding-agent/test/compaction-hooks-example.test.ts create mode 100644 packages/coding-agent/test/compaction-hooks.test.ts diff --git a/package-lock.json b/package-lock.json index 067cc83c..c568fbd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4013,6 +4013,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz", "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4582,6 +4583,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -5702,6 +5704,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -5730,7 +5733,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -5995,6 +5999,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 177909d3..62c54bf9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Custom compaction hooks**: Added `before_compact` and `compact` session events for context compaction. `before_compact` fires before compaction with the cut point, messages to summarize, model, and API key; hooks can cancel or provide a custom `compactionEntry`. `compact` fires after with the final compaction entry and a `fromHook` flag. ([#281](https://github.com/badlogic/pi-mono/issues/281)) + ## [0.27.4] - 2025-12-24 ### Fixed diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 5580ee45..b9e062a6 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -130,6 +130,11 @@ user clears session (/clear) ├─► session (reason: "before_clear", can cancel) └─► session (reason: "clear", AFTER clear) +context compaction (auto or /compact) + │ + ├─► session (reason: "before_compact", can cancel or provide custom summary) + └─► session (reason: "compact", AFTER compaction) + user exits (double Ctrl+C or Ctrl+D) │ └─► session (reason: "shutdown") @@ -147,7 +152,7 @@ pi.on("session", async (event, ctx) => { // event.sessionFile: string | null - current session file (null with --no-session) // event.previousSessionFile: string | null - previous session file // event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" | - // "before_branch" | "branch" | "shutdown" + // "before_branch" | "branch" | "before_compact" | "compact" | "shutdown" // event.targetTurnIndex: number - only for "before_branch" and "branch" // Cancel a before_* action: @@ -168,10 +173,26 @@ pi.on("session", async (event, ctx) => { - `before_switch` / `switch`: User switched sessions (`/resume`) - `before_clear` / `clear`: User cleared the session (`/clear`) - `before_branch` / `branch`: User branched the session (`/branch`) +- `before_compact` / `compact`: Context compaction (auto or `/compact`) - `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM) For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from. +For `before_compact` events, additional fields are available: +- `event.cutPoint`: Where the context will be cut (`firstKeptEntryIndex`, `isSplitTurn`) +- `event.messagesToSummarize`: Messages that will be summarized +- `event.tokensBefore`: Current context token count +- `event.model`: Model to use for summarization +- `event.apiKey`: API key for the model +- `event.customInstructions`: Optional custom focus for summary (from `/compact` command) + +Return `{ compactionEntry }` to provide a custom summary instead of the default. The `compactionEntry` must have: `type: "compaction"`, `timestamp`, `summary`, `firstKeptEntryIndex` (from `cutPoint`), `tokensBefore`. + +For `compact` events (after compaction): +- `event.compactionEntry`: The saved compaction entry +- `event.tokensBefore`: Token count before compaction +- `event.fromHook`: Whether the compaction entry was provided by a hook + ### agent_start / agent_end Fired once per user prompt. @@ -603,6 +624,38 @@ export default function (pi: HookAPI) { } ``` +### Custom Compaction + +Use a cheaper model for summarization, or implement your own compaction strategy. + +```typescript +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { CompactionEntry } from "@mariozechner/pi-coding-agent"; + +export default function (pi: HookAPI) { + pi.on("session", async (event, ctx) => { + if (event.reason !== "before_compact") return; + + // Example: Use a simpler summarization approach + const messages = event.messagesToSummarize; + const summary = messages + .filter((m) => m.role === "user") + .map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`) + .join("\n"); + + const compactionEntry: CompactionEntry = { + type: "compaction", + timestamp: new Date().toISOString(), + summary: `User requests:\n${summary}`, + firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, + tokensBefore: event.tokensBefore, + }; + + return { compactionEntry }; + }); +} +``` + ## Mode Behavior Hooks behave differently depending on the run mode: diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 102840d9..6cde7bae 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -18,13 +18,13 @@ import type { AssistantMessage, Message, Model, TextContent } from "@mariozechne import { isContextOverflow, supportsXhigh } from "@mariozechner/pi-ai"; import { getModelsPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; -import { calculateContextTokens, compact, shouldCompact } from "./compaction.js"; +import { calculateContextTokens, compact, prepareCompaction, shouldCompact } from "./compaction.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; import type { HookRunner, SessionEventResult, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; import type { BashExecutionMessage } from "./messages.js"; import { getApiKeyForModel, getAvailableModels } from "./model-config.js"; -import { loadSessionFromEntries, type SessionManager } from "./session-manager.js"; +import { type CompactionEntry, loadSessionFromEntries, type SessionManager } from "./session-manager.js"; import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; @@ -734,11 +734,8 @@ export class AgentSession { * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { - // Abort any running operation this._disconnectFromAgent(); await this.abort(); - - // Create abort controller this._compactionAbortController = new AbortController(); try { @@ -753,24 +750,73 @@ export class AgentSession { const entries = this.sessionManager.loadEntries(); const settings = this.settingsManager.getCompactionSettings(); - const compactionEntry = await compact( - entries, - this.model, - settings, - apiKey, - this._compactionAbortController.signal, - customInstructions, - ); + + const preparation = prepareCompaction(entries, settings); + if (!preparation) { + throw new Error("Already compacted"); + } + + let compactionEntry: CompactionEntry | undefined; + let fromHook = false; + + if (this._hookRunner?.hasHandlers("session")) { + const result = (await this._hookRunner.emit({ + type: "session", + entries, + sessionFile: this.sessionFile, + previousSessionFile: null, + reason: "before_compact", + cutPoint: preparation.cutPoint, + messagesToSummarize: preparation.messagesToSummarize, + tokensBefore: preparation.tokensBefore, + customInstructions, + model: this.model, + apiKey, + })) as SessionEventResult | undefined; + + if (result?.cancel) { + throw new Error("Compaction cancelled"); + } + + if (result?.compactionEntry) { + compactionEntry = result.compactionEntry; + fromHook = true; + } + } + + if (!compactionEntry) { + compactionEntry = await compact( + entries, + this.model, + settings, + apiKey, + this._compactionAbortController.signal, + customInstructions, + ); + } if (this._compactionAbortController.signal.aborted) { throw new Error("Compaction cancelled"); } - // Save and reload this.sessionManager.saveCompaction(compactionEntry); - const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + const newEntries = this.sessionManager.loadEntries(); + const loaded = loadSessionFromEntries(newEntries); this.agent.replaceMessages(loaded.messages); + if (this._hookRunner) { + await this._hookRunner.emit({ + type: "session", + entries: newEntries, + sessionFile: this.sessionFile, + previousSessionFile: null, + reason: "compact", + compactionEntry, + tokensBefore: compactionEntry.tokensBefore, + fromHook, + }); + } + return { tokensBefore: compactionEntry.tokensBefore, summary: compactionEntry.summary, @@ -853,13 +899,51 @@ export class AgentSession { } const entries = this.sessionManager.loadEntries(); - const compactionEntry = await compact( - entries, - this.model, - settings, - apiKey, - this._autoCompactionAbortController.signal, - ); + + const preparation = prepareCompaction(entries, settings); + if (!preparation) { + this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false }); + return; + } + + let compactionEntry: CompactionEntry | undefined; + let fromHook = false; + + if (this._hookRunner?.hasHandlers("session")) { + const hookResult = (await this._hookRunner.emit({ + type: "session", + entries, + sessionFile: this.sessionFile, + previousSessionFile: null, + reason: "before_compact", + cutPoint: preparation.cutPoint, + messagesToSummarize: preparation.messagesToSummarize, + tokensBefore: preparation.tokensBefore, + customInstructions: undefined, + model: this.model, + apiKey, + })) as SessionEventResult | undefined; + + if (hookResult?.cancel) { + this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false }); + return; + } + + if (hookResult?.compactionEntry) { + compactionEntry = hookResult.compactionEntry; + fromHook = true; + } + } + + if (!compactionEntry) { + compactionEntry = await compact( + entries, + this.model, + settings, + apiKey, + this._autoCompactionAbortController.signal, + ); + } if (this._autoCompactionAbortController.signal.aborted) { this._emit({ type: "auto_compaction_end", result: null, aborted: true, willRetry: false }); @@ -867,37 +951,43 @@ export class AgentSession { } this.sessionManager.saveCompaction(compactionEntry); - const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + const newEntries = this.sessionManager.loadEntries(); + const loaded = loadSessionFromEntries(newEntries); this.agent.replaceMessages(loaded.messages); + if (this._hookRunner) { + await this._hookRunner.emit({ + type: "session", + entries: newEntries, + sessionFile: this.sessionFile, + previousSessionFile: null, + reason: "compact", + compactionEntry, + tokensBefore: compactionEntry.tokensBefore, + fromHook, + }); + } + const result: CompactionResult = { tokensBefore: compactionEntry.tokensBefore, summary: compactionEntry.summary, }; this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry }); - // Auto-retry if needed - use continue() since user message is already in context if (willRetry) { - // Remove trailing error message from agent state (it's kept in session file for history) - // This is needed because continue() requires last message to be user or toolResult const messages = this.agent.state.messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") { this.agent.replaceMessages(messages.slice(0, -1)); } - // Use setTimeout to break out of the event handler chain setTimeout(() => { - this.agent.continue().catch(() => { - // Retry failed - silently ignore, user can manually retry - }); + this.agent.continue().catch(() => {}); }, 100); } } catch (error) { - // Compaction failed - emit end event without retry this._emit({ type: "auto_compaction_end", result: null, aborted: false, willRetry: false }); - // If this was overflow recovery and compaction failed, we have a hard stop if (reason === "overflow") { throw new Error( `Context overflow: ${error instanceof Error ? error.message : "compaction failed"}. Your input may be too large for the context window.`, diff --git a/packages/coding-agent/src/core/compaction.ts b/packages/coding-agent/src/core/compaction.ts index 7ea09f44..32b21af9 100644 --- a/packages/coding-agent/src/core/compaction.ts +++ b/packages/coding-agent/src/core/compaction.ts @@ -321,6 +321,49 @@ export async function generateSummary( return textContent; } +// ============================================================================ +// Compaction Preparation (for hooks) +// ============================================================================ + +export interface CompactionPreparation { + cutPoint: CutPointResult; + messagesToSummarize: AppMessage[]; + tokensBefore: number; + boundaryStart: number; +} + +export function prepareCompaction(entries: SessionEntry[], settings: CompactionSettings): CompactionPreparation | null { + if (entries.length > 0 && entries[entries.length - 1].type === "compaction") { + return null; + } + + let prevCompactionIndex = -1; + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type === "compaction") { + prevCompactionIndex = i; + break; + } + } + const boundaryStart = prevCompactionIndex + 1; + const boundaryEnd = entries.length; + + const lastUsage = getLastAssistantUsage(entries); + const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0; + + const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; + const messagesToSummarize: AppMessage[] = []; + for (let i = boundaryStart; i < historyEnd; i++) { + const entry = entries[i]; + if (entry.type === "message") { + messagesToSummarize.push(entry.message); + } + } + + return { cutPoint, messagesToSummarize, tokensBefore, boundaryStart }; +} + // ============================================================================ // Main compaction function // ============================================================================ diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 5cfba127..9f8d7746 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -6,8 +6,9 @@ */ import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; -import type { ImageContent, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { SessionEntry } from "../session-manager.js"; +import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; +import type { CutPointResult } from "../compaction.js"; +import type { CompactionEntry, SessionEntry } from "../session-manager.js"; import type { BashToolDetails, FindToolDetails, @@ -111,6 +112,7 @@ interface SessionEventBase { * - before_switch / switch: Session switch (e.g., /resume command) * - before_clear / clear: Session clear (e.g., /clear command) * - before_branch / branch: Session branch (e.g., /branch command) + * - before_compact / compact: Before/after context compaction * - shutdown: Process exit (SIGINT/SIGTERM) * * "before_*" events fire before the action and can be cancelled via SessionEventResult. @@ -124,6 +126,22 @@ export type SessionEvent = reason: "branch" | "before_branch"; /** Index of the turn to branch from */ targetTurnIndex: number; + }) + | (SessionEventBase & { + reason: "before_compact"; + cutPoint: CutPointResult; + messagesToSummarize: AppMessage[]; + tokensBefore: number; + customInstructions?: string; + model: Model; + apiKey: string; + }) + | (SessionEventBase & { + reason: "compact"; + compactionEntry: CompactionEntry; + tokensBefore: number; + /** Whether the compaction entry was provided by a hook */ + fromHook: boolean; }); /** @@ -325,6 +343,8 @@ export interface SessionEventResult { cancel?: boolean; /** If true (for before_branch only), skip restoring conversation to branch point while still creating the branched session file */ skipConversationRestore?: boolean; + /** Custom compaction entry (for before_compact event) */ + compactionEntry?: CompactionEntry; } // ============================================================================ diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts new file mode 100644 index 00000000..2d65a6ec --- /dev/null +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -0,0 +1,70 @@ +/** + * Verify the documentation example from hooks.md compiles and works. + */ + +import { describe, expect, it } from "vitest"; +import type { HookAPI } from "../src/core/hooks/index.js"; +import type { CompactionEntry } from "../src/core/session-manager.js"; + +describe("Documentation example", () => { + it("custom compaction example should type-check correctly", () => { + // This is the example from hooks.md - verify it compiles + const exampleHook = (pi: HookAPI) => { + pi.on("session", async (event, _ctx) => { + if (event.reason !== "before_compact") return; + + // After narrowing, these should all be accessible + const messages = event.messagesToSummarize; + const cutPoint = event.cutPoint; + const tokensBefore = event.tokensBefore; + const model = event.model; + const apiKey = event.apiKey; + + // Verify types + expect(Array.isArray(messages)).toBe(true); + expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); + expect(typeof tokensBefore).toBe("number"); + expect(model).toBeDefined(); + expect(typeof apiKey).toBe("string"); + + const summary = messages + .filter((m) => m.role === "user") + .map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`) + .join("\n"); + + const compactionEntry: CompactionEntry = { + type: "compaction", + timestamp: new Date().toISOString(), + summary: `User requests:\n${summary}`, + firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, + tokensBefore: event.tokensBefore, + }; + + return { compactionEntry }; + }); + }; + + // Just verify the function exists and is callable + expect(typeof exampleHook).toBe("function"); + }); + + it("compact event should have correct fields after narrowing", () => { + const checkCompactEvent = (pi: HookAPI) => { + pi.on("session", async (event, _ctx) => { + if (event.reason !== "compact") return; + + // After narrowing, these should all be accessible + const entry = event.compactionEntry; + const tokensBefore = event.tokensBefore; + const fromHook = event.fromHook; + + expect(entry.type).toBe("compaction"); + expect(typeof entry.summary).toBe("string"); + expect(typeof tokensBefore).toBe("number"); + expect(typeof fromHook).toBe("boolean"); + }); + }; + + expect(typeof checkCompactEvent).toBe("function"); + }); +}); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts new file mode 100644 index 00000000..d39d020b --- /dev/null +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -0,0 +1,379 @@ +/** + * Tests for compaction hook events (before_compact / compact). + */ + +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent, ProviderTransport } from "@mariozechner/pi-agent-core"; +import { getModel } from "@mariozechner/pi-ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AgentSession } from "../src/core/agent-session.js"; +import { HookRunner, type LoadedHook, type SessionEvent } from "../src/core/hooks/index.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; +import { codingTools } from "../src/core/tools/index.js"; + +const API_KEY = process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_OAUTH_TOKEN; + +describe.skipIf(!API_KEY)("Compaction hooks", () => { + let session: AgentSession; + let tempDir: string; + let hookRunner: HookRunner; + let capturedEvents: SessionEvent[]; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-compaction-hooks-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + capturedEvents = []; + }); + + afterEach(async () => { + if (session) { + session.dispose(); + } + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }); + + function createHook( + onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compactionEntry?: any } | undefined, + onCompact?: (event: SessionEvent) => void, + ): LoadedHook { + const handlers = new Map Promise)[]>(); + + handlers.set("session", [ + async (event: SessionEvent) => { + capturedEvents.push(event); + + if (event.reason === "before_compact" && onBeforeCompact) { + return onBeforeCompact(event); + } + if (event.reason === "compact" && onCompact) { + onCompact(event); + } + return undefined; + }, + ]); + + return { + path: "test-hook", + resolvedPath: "/test/test-hook.ts", + handlers, + setSendHandler: () => {}, + }; + } + + function createSession(hooks: LoadedHook[]) { + const model = getModel("anthropic", "claude-sonnet-4-5")!; + + const transport = new ProviderTransport({ + getApiKey: () => API_KEY, + }); + + const agent = new Agent({ + transport, + initialState: { + model, + systemPrompt: "You are a helpful assistant. Be concise.", + tools: codingTools, + }, + }); + + const sessionManager = SessionManager.create(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); + + hookRunner = new HookRunner(hooks, tempDir); + hookRunner.setUIContext( + { + select: async () => null, + confirm: async () => false, + input: async () => null, + notify: () => {}, + }, + false, + ); + hookRunner.setSessionFile(sessionManager.getSessionFile()); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + hookRunner, + }); + + return session; + } + + it("should emit before_compact and compact events", async () => { + const hook = createHook(); + createSession([hook]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.prompt("What is 3+3? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.compact(); + + const beforeCompactEvents = capturedEvents.filter((e) => e.reason === "before_compact"); + const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + + expect(beforeCompactEvents.length).toBe(1); + expect(compactEvents.length).toBe(1); + + const beforeEvent = beforeCompactEvents[0]; + if (beforeEvent.reason === "before_compact") { + expect(beforeEvent.cutPoint).toBeDefined(); + expect(beforeEvent.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); + expect(beforeEvent.messagesToSummarize).toBeDefined(); + expect(beforeEvent.tokensBefore).toBeGreaterThanOrEqual(0); + expect(beforeEvent.model).toBeDefined(); + expect(beforeEvent.apiKey).toBeDefined(); + } + + const afterEvent = compactEvents[0]; + if (afterEvent.reason === "compact") { + expect(afterEvent.compactionEntry).toBeDefined(); + expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0); + expect(afterEvent.tokensBefore).toBeGreaterThanOrEqual(0); + expect(afterEvent.fromHook).toBe(false); + } + }, 120000); + + it("should allow hooks to cancel compaction", async () => { + const hook = createHook(() => ({ cancel: true })); + createSession([hook]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + await expect(session.compact()).rejects.toThrow("Compaction cancelled"); + + const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + expect(compactEvents.length).toBe(0); + }, 120000); + + it("should allow hooks to provide custom compactionEntry", async () => { + const customSummary = "Custom summary from hook"; + + const hook = createHook((event) => { + if (event.reason === "before_compact") { + return { + compactionEntry: { + type: "compaction" as const, + timestamp: new Date().toISOString(), + summary: customSummary, + firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, + tokensBefore: event.tokensBefore, + }, + }; + } + return undefined; + }); + createSession([hook]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.prompt("What is 3+3? Reply with just the number."); + await session.agent.waitForIdle(); + + const result = await session.compact(); + + expect(result.summary).toBe(customSummary); + + const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + expect(compactEvents.length).toBe(1); + + const afterEvent = compactEvents[0]; + if (afterEvent.reason === "compact") { + expect(afterEvent.compactionEntry.summary).toBe(customSummary); + expect(afterEvent.fromHook).toBe(true); + } + }, 120000); + + it("should include entries in compact event after compaction is saved", async () => { + const hook = createHook(); + createSession([hook]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.compact(); + + const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + expect(compactEvents.length).toBe(1); + + const afterEvent = compactEvents[0]; + if (afterEvent.reason === "compact") { + const hasCompactionEntry = afterEvent.entries.some((e) => e.type === "compaction"); + expect(hasCompactionEntry).toBe(true); + } + }, 120000); + + it("should continue with default compaction if hook throws error", async () => { + const throwingHook: LoadedHook = { + path: "throwing-hook", + resolvedPath: "/test/throwing-hook.ts", + handlers: new Map Promise)[]>([ + [ + "session", + [ + async (event: SessionEvent) => { + capturedEvents.push(event); + if (event.reason === "before_compact") { + throw new Error("Hook intentionally failed"); + } + return undefined; + }, + ], + ], + ]), + setSendHandler: () => {}, + }; + + createSession([throwingHook]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + const result = await session.compact(); + + expect(result.summary).toBeDefined(); + expect(result.summary.length).toBeGreaterThan(0); + + const compactEvents = capturedEvents.filter((e) => e.reason === "compact"); + expect(compactEvents.length).toBe(1); + + if (compactEvents[0].reason === "compact") { + expect(compactEvents[0].fromHook).toBe(false); + } + }, 120000); + + it("should call multiple hooks in order", async () => { + const callOrder: string[] = []; + + const hook1: LoadedHook = { + path: "hook1", + resolvedPath: "/test/hook1.ts", + handlers: new Map Promise)[]>([ + [ + "session", + [ + async (event: SessionEvent) => { + if (event.reason === "before_compact") { + callOrder.push("hook1-before"); + } + if (event.reason === "compact") { + callOrder.push("hook1-after"); + } + return undefined; + }, + ], + ], + ]), + setSendHandler: () => {}, + }; + + const hook2: LoadedHook = { + path: "hook2", + resolvedPath: "/test/hook2.ts", + handlers: new Map Promise)[]>([ + [ + "session", + [ + async (event: SessionEvent) => { + if (event.reason === "before_compact") { + callOrder.push("hook2-before"); + } + if (event.reason === "compact") { + callOrder.push("hook2-after"); + } + return undefined; + }, + ], + ], + ]), + setSendHandler: () => {}, + }; + + createSession([hook1, hook2]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.compact(); + + expect(callOrder).toEqual(["hook1-before", "hook2-before", "hook1-after", "hook2-after"]); + }, 120000); + + it("should pass correct data in before_compact event", async () => { + let capturedBeforeEvent: (SessionEvent & { reason: "before_compact" }) | null = null; + + const hook = createHook((event) => { + if (event.reason === "before_compact") { + capturedBeforeEvent = event; + } + return undefined; + }); + createSession([hook]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.prompt("What is 3+3? Reply with just the number."); + await session.agent.waitForIdle(); + + await session.compact(); + + expect(capturedBeforeEvent).not.toBeNull(); + const event = capturedBeforeEvent!; + expect(event.cutPoint).toHaveProperty("firstKeptEntryIndex"); + expect(event.cutPoint).toHaveProperty("isSplitTurn"); + expect(event.cutPoint).toHaveProperty("turnStartIndex"); + + expect(Array.isArray(event.messagesToSummarize)).toBe(true); + + expect(typeof event.tokensBefore).toBe("number"); + + expect(event.model).toHaveProperty("provider"); + expect(event.model).toHaveProperty("id"); + + expect(typeof event.apiKey).toBe("string"); + expect(event.apiKey.length).toBeGreaterThan(0); + + expect(Array.isArray(event.entries)).toBe(true); + expect(event.entries.length).toBeGreaterThan(0); + }, 120000); + + it("should use hook compactionEntry even with different firstKeptEntryIndex", async () => { + const customSummary = "Custom summary with modified index"; + + const hook = createHook((event) => { + if (event.reason === "before_compact") { + return { + compactionEntry: { + type: "compaction" as const, + timestamp: new Date().toISOString(), + summary: customSummary, + firstKeptEntryIndex: 0, + tokensBefore: 999, + }, + }; + } + return undefined; + }); + createSession([hook]); + + await session.prompt("What is 2+2? Reply with just the number."); + await session.agent.waitForIdle(); + + const result = await session.compact(); + + expect(result.summary).toBe(customSummary); + expect(result.tokensBefore).toBe(999); + }, 120000); +});