diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84e5735d..eaf139ae 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,9 +21,13 @@ - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - `prepareCompaction()` now returns `firstKeptEntryId` in its result - **Hook types**: - - `SessionEventResult.compactionEntry` replaced with `SessionEventResult.compaction` (content only, SessionManager adds id/parentId) - - `before_compact` event now includes `firstKeptEntryId` field for hooks that return custom compaction + - `SessionEventBase` now passes `sessionManager` and `modelRegistry` instead of `entries`, `sessionFile`, `previousSessionFile` + - `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first) + - `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile` + - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) +- **SessionManager**: + - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) ### Added diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 3c9bea18..8dfa9bbb 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -59,15 +59,17 @@ Reference: [session-tree.md](./session-tree.md) ### Compaction Refactor - [x] Use `CompactionResult` type for hook return value -- [ ] Make `CompactionEntry` generic with optional `details?: T` field for hook-specific data -- [ ] Make `CompactionResult` generic to match -- [ ] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields -- [ ] Update `before_compact` event: +- [x] Make `CompactionEntry` generic with optional `details?: T` field for hook-specific data +- [x] Make `CompactionResult` generic to match +- [x] Update `SessionEventBase` to pass `sessionManager` and `modelRegistry` instead of derived fields +- [x] Update `before_compact` event: - Pass `preparation: CompactionPreparation` instead of individual fields - Pass `previousCompactions: CompactionEntry[]` (newest first) instead of `previousSummary?: string` - Keep: `customInstructions`, `model`, `signal` - Drop: `resolveApiKey` (use `modelRegistry.getApiKey()`), `cutPoint`, `entries` -- [ ] Update hook example `custom-compaction.ts` to use new API +- [x] Update hook example `custom-compaction.ts` to use new API +- [x] Update `getSessionFile()` to return `string | undefined` for in-memory sessions +- [x] Update `before_switch` to have `targetSessionFile`, `switch` to have `previousSessionFile` Reference: [#314](https://github.com/badlogic/pi-mono/pull/314) - Structured compaction with anchored iterative summarization needs `details` field to store `ArtifactIndex` and version markers. diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts index 8d167882..daef3a92 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -20,9 +20,10 @@ export default function (pi: HookAPI) { } // Find the last assistant message for commit context + const entries = event.sessionManager.getEntries(); let lastAssistantText = ""; - for (let i = event.entries.length - 1; i >= 0; i--) { - const entry = event.entries[i]; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; if (entry.type === "message" && entry.message.role === "assistant") { const content = entry.message.content; if (Array.isArray(content)) { diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts index 61293868..72ecc9d3 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -5,6 +5,7 @@ * Demonstrates how to cancel session events using the before_* variants. */ +import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { @@ -28,7 +29,10 @@ export default function (pi: HookAPI) { if (!ctx.hasUI) return; // Check if there are unsaved changes (messages since last assistant response) - const hasUnsavedWork = event.entries.some((e) => e.type === "message" && e.message.role === "user"); + const entries = event.sessionManager.getEntries(); + const hasUnsavedWork = entries.some( + (e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user", + ); if (hasUnsavedWork) { const confirmed = await ctx.ui.confirm( diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 1eabaf57..efc2f59b 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -23,15 +23,11 @@ export default function (pi: HookAPI) { ctx.ui.notify("Custom compaction hook triggered", "info"); - const { - messagesToSummarize, - messagesToKeep, - previousSummary, - tokensBefore, - resolveApiKey, - entries: _, - signal, - } = event; + const { preparation, previousCompactions, modelRegistry, signal } = event; + const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId } = preparation; + + // Get previous summary from most recent compaction (if any) + const previousSummary = previousCompactions[0]?.summary; // Use Gemini Flash for summarization (cheaper/faster than most conversation models) const model = getModel("google", "gemini-2.5-flash"); @@ -41,7 +37,7 @@ export default function (pi: HookAPI) { } // Resolve API key for the summarization model - const apiKey = await resolveApiKey(model); + const apiKey = await modelRegistry.getApiKey(model); if (!apiKey) { ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning"); return; @@ -102,11 +98,11 @@ Format the summary as structured markdown with clear sections.`, } // Return compaction content - SessionManager adds id/parentId - // Use firstKeptEntryId from event to keep recent messages + // Use firstKeptEntryId from preparation to keep recent messages return { compaction: { summary, - firstKeptEntryId: event.firstKeptEntryId, + firstKeptEntryId, tokensBefore, }, }; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a1bac423..0281e5d5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -395,7 +395,7 @@ export class AgentSession { /** Current session file path, or null if sessions are disabled */ get sessionFile(): string | null { - return this.sessionManager.isPersisted() ? this.sessionManager.getSessionFile() : null; + return this.sessionManager.getSessionFile() ?? null; } /** Current session ID */ @@ -515,15 +515,13 @@ export class AgentSession { */ async reset(): Promise { const previousSessionFile = this.sessionFile; - const entries = this.sessionManager.getEntries(); // Emit before_new event (can be cancelled) if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_new", })) as SessionEventResult | undefined; @@ -544,9 +542,8 @@ export class AgentSession { this._hookRunner.setSessionFile(this.sessionFile); await this._hookRunner.emit({ type: "session", - entries: [], - sessionFile: this.sessionFile, - previousSessionFile, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "new", }); } @@ -762,34 +759,22 @@ export class AgentSession { throw new Error("Nothing to compact (session too small)"); } - // Find previous compaction summary if any - let previousSummary: string | undefined; - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type === "compaction") { - previousSummary = (entries[i] as CompactionEntry).summary; - break; - } - } - let hookCompaction: CompactionResult | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { + // Get previous compactions, newest first + const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse(); + const result = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_compact", - cutPoint: preparation.cutPoint, - firstKeptEntryId: preparation.firstKeptEntryId, - previousSummary, - messagesToSummarize: [...preparation.messagesToSummarize], - messagesToKeep: [...preparation.messagesToKeep], - tokensBefore: preparation.tokensBefore, + preparation, + previousCompactions, customInstructions, model: this.model, - resolveApiKey: async (m: Model) => (await this._modelRegistry.getApiKey(m)) ?? undefined, signal: this._compactionAbortController.signal, })) as SessionEventResult | undefined; @@ -847,12 +832,10 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - entries: newEntries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, - tokensBefore, fromHook, }); } @@ -948,34 +931,22 @@ export class AgentSession { return; } - // Find previous compaction summary if any - let previousSummary: string | undefined; - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type === "compaction") { - previousSummary = (entries[i] as CompactionEntry).summary; - break; - } - } - let hookCompaction: CompactionResult | undefined; let fromHook = false; if (this._hookRunner?.hasHandlers("session")) { + // Get previous compactions, newest first + const previousCompactions = entries.filter((e): e is CompactionEntry => e.type === "compaction").reverse(); + const hookResult = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_compact", - cutPoint: preparation.cutPoint, - firstKeptEntryId: preparation.firstKeptEntryId, - previousSummary, - messagesToSummarize: [...preparation.messagesToSummarize], - messagesToKeep: [...preparation.messagesToKeep], - tokensBefore: preparation.tokensBefore, + preparation, + previousCompactions, customInstructions: undefined, model: this.model, - resolveApiKey: async (m: Model) => (await this._modelRegistry.getApiKey(m)) ?? undefined, signal: this._autoCompactionAbortController.signal, })) as SessionEventResult | undefined; @@ -1034,12 +1005,10 @@ export class AgentSession { if (this._hookRunner && savedCompactionEntry) { await this._hookRunner.emit({ type: "session", - entries: newEntries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "compact", compactionEntry: savedCompactionEntry, - tokensBefore, fromHook, }); } @@ -1337,16 +1306,15 @@ export class AgentSession { */ async switchSession(sessionPath: string): Promise { const previousSessionFile = this.sessionFile; - const oldEntries = this.sessionManager.getEntries(); // Emit before_switch event (can be cancelled) if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - entries: oldEntries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_switch", + targetSessionFile: sessionPath, })) as SessionEventResult | undefined; if (result?.cancel) { @@ -1362,7 +1330,6 @@ export class AgentSession { this.sessionManager.setSessionFile(sessionPath); // Reload messages - const entries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); // Emit session event to hooks @@ -1370,10 +1337,10 @@ export class AgentSession { this._hookRunner.setSessionFile(sessionPath); await this._hookRunner.emit({ type: "session", - entries, - sessionFile: sessionPath, - previousSessionFile, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "switch", + previousSessionFile, }); } @@ -1428,9 +1395,8 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session")) { const result = (await this._hookRunner.emit({ type: "session", - entries, - sessionFile: this.sessionFile, - previousSessionFile: null, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "before_branch", targetTurnIndex: entryIndex, })) as SessionEventResult | undefined; @@ -1454,7 +1420,6 @@ export class AgentSession { } // Reload messages from entries (works for both file and in-memory mode) - const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); // Emit branch event to hooks (after branch completes) @@ -1462,9 +1427,8 @@ export class AgentSession { this._hookRunner.setSessionFile(newSessionFile); await this._hookRunner.emit({ type: "session", - entries: newEntries, - sessionFile: newSessionFile, - previousSessionFile, + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, reason: "branch", targetTurnIndex: entryIndex, }); diff --git a/packages/coding-agent/src/core/export-html.ts b/packages/coding-agent/src/core/export-html.ts index bff53487..db53d743 100644 --- a/packages/coding-agent/src/core/export-html.ts +++ b/packages/coding-agent/src/core/export-html.ts @@ -1343,6 +1343,9 @@ export function exportSessionToHtml( const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) { + throw new Error("Cannot export in-memory session to HTML"); + } const content = readFileSync(sessionFile, "utf8"); const data = parseSessionFile(content); diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 82033e21..8d621ae8 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -7,8 +7,9 @@ import type { AppMessage, Attachment } from "@mariozechner/pi-agent-core"; import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { CompactionResult, CutPointResult } from "../compaction.js"; -import type { CompactionEntry, SessionEntry } from "../session-manager.js"; +import type { CompactionPreparation, CompactionResult } from "../compaction.js"; +import type { ModelRegistry } from "../model-registry.js"; +import type { CompactionEntry, SessionManager } from "../session-manager.js"; import type { BashToolDetails, FindToolDetails, @@ -95,12 +96,10 @@ export interface HookEventContext { */ interface SessionEventBase { type: "session"; - /** All session entries (including pre-compaction history) */ - entries: SessionEntry[]; - /** Current session file path, or null in --no-session mode */ - sessionFile: string | null; - /** Previous session file path, or null for "start" and "new" */ - previousSessionFile: string | null; + /** Session manager instance - use for entries, session file, etc. */ + sessionManager: SessionManager; + /** Model registry - use for API key resolution */ + modelRegistry: ModelRegistry; } /** @@ -120,7 +119,17 @@ interface SessionEventBase { */ export type SessionEvent = | (SessionEventBase & { - reason: "start" | "switch" | "new" | "before_switch" | "before_new" | "shutdown"; + reason: "start" | "new" | "before_new" | "shutdown"; + }) + | (SessionEventBase & { + reason: "before_switch"; + /** Session file we're switching to */ + targetSessionFile: string; + }) + | (SessionEventBase & { + reason: "switch"; + /** Session file we came from */ + previousSessionFile: string | null; }) | (SessionEventBase & { reason: "branch" | "before_branch"; @@ -129,27 +138,20 @@ export type SessionEvent = }) | (SessionEventBase & { reason: "before_compact"; - cutPoint: CutPointResult; - /** ID of first entry to keep (for hooks that return CompactionEntry) */ - firstKeptEntryId: string; - /** Summary from previous compaction, if any. Include this in your summary to preserve context. */ - previousSummary?: string; - /** Messages that will be summarized and discarded */ - messagesToSummarize: AppMessage[]; - /** Messages that will be kept after the summary (recent turns) */ - messagesToKeep: AppMessage[]; - tokensBefore: number; + /** Compaction preparation with cut point, messages to summarize/keep, etc. */ + preparation: CompactionPreparation; + /** Previous compaction entries, newest first. Use for iterative summarization. */ + previousCompactions: CompactionEntry[]; + /** Optional user-provided instructions for the summary */ customInstructions?: string; + /** Current model */ model: Model; - /** Resolve API key for any model (checks settings, OAuth, env vars) */ - resolveApiKey: (model: Model) => Promise; /** Abort signal - hooks should pass this to LLM calls and check it periodically */ signal: AbortSignal; }) | (SessionEventBase & { reason: "compact"; compactionEntry: CompactionEntry; - tokensBefore: number; /** Whether the compaction entry was provided by a hook */ fromHook: boolean; }); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 0b5934b1..79d610c7 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -421,7 +421,7 @@ export function findMostRecentSession(sessionDir: string): string | null { */ export class SessionManager { private sessionId: string = ""; - private sessionFile: string = ""; + private sessionFile: string | undefined; private sessionDir: string; private cwd: string; private persist: boolean; @@ -434,10 +434,10 @@ export class SessionManager { private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { this.cwd = cwd; this.sessionDir = sessionDir; + this.persist = persist; if (persist && sessionDir && !existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } - this.persist = persist; if (sessionFile) { this.setSessionFile(sessionFile); @@ -479,8 +479,8 @@ export class SessionManager { this.byId.clear(); this.leafId = ""; this.flushed = false; - // Only generate filename if not already set (e.g., via --session flag) - if (!this.sessionFile) { + // Only generate filename if persisting and not already set (e.g., via --session flag) + if (this.persist && !this.sessionFile) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); } @@ -505,7 +505,7 @@ export class SessionManager { } private _rewriteFile(): void { - if (!this.persist) return; + if (!this.persist || !this.sessionFile) return; const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`; writeFileSync(this.sessionFile, content); } @@ -526,12 +526,12 @@ export class SessionManager { return this.sessionId; } - getSessionFile(): string { + getSessionFile(): string | undefined { return this.sessionFile; } _persist(entry: SessionEntry): void { - if (!this.persist) return; + if (!this.persist || !this.sessionFile) return; const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) return; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f0e1c01c..6da1e0fd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -384,9 +384,8 @@ export class InteractiveMode { // Emit session event await hookRunner.emit({ type: "session", - entries, - sessionFile: this.session.sessionFile, - previousSessionFile: null, + sessionManager: this.session.sessionManager, + modelRegistry: this.session.modelRegistry, reason: "start", }); } @@ -1156,12 +1155,10 @@ export class InteractiveMode { // Emit shutdown event to hooks const hookRunner = this.session.hookRunner; if (hookRunner?.hasHandlers("session")) { - const entries = this.sessionManager.getEntries(); await hookRunner.emit({ type: "session", - entries, - sessionFile: this.session.sessionFile, - previousSessionFile: null, + sessionManager: this.session.sessionManager, + modelRegistry: this.session.modelRegistry, reason: "shutdown", }); } diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 46724701..af1e48d8 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -46,9 +46,8 @@ export async function runPrintMode( // Emit session event await hookRunner.emit({ type: "session", - entries, - sessionFile: session.sessionFile, - previousSessionFile: null, + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, reason: "start", }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 0c67969b..eb0ad807 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -145,9 +145,8 @@ export async function runRpcMode(session: AgentSession): Promise { // Emit session event await hookRunner.emit({ type: "session", - entries, - sessionFile: session.sessionFile, - previousSessionFile: null, + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, reason: "start", }); } diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index 9c5f5479..0d200702 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -13,24 +13,23 @@ describe("Documentation example", () => { if (event.reason !== "before_compact") return; // After narrowing, these should all be accessible - const messages = event.messagesToSummarize; - const messagesToKeep = event.messagesToKeep; - const cutPoint = event.cutPoint; - const tokensBefore = event.tokensBefore; - const model = event.model; - const resolveApiKey = event.resolveApiKey; - const firstKeptEntryId = event.firstKeptEntryId; + const { preparation, previousCompactions, sessionManager, modelRegistry, model } = event; + const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation; + + // Get previous summary from most recent compaction + const _previousSummary = previousCompactions[0]?.summary; // Verify types - expect(Array.isArray(messages)).toBe(true); + expect(Array.isArray(messagesToSummarize)).toBe(true); expect(Array.isArray(messagesToKeep)).toBe(true); - expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); // cutPoint still uses index + expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); expect(typeof tokensBefore).toBe("number"); expect(model).toBeDefined(); - expect(typeof resolveApiKey).toBe("function"); + expect(typeof sessionManager.getEntries).toBe("function"); + expect(typeof modelRegistry.getApiKey).toBe("function"); expect(typeof firstKeptEntryId).toBe("string"); - const summary = messages + const summary = messagesToSummarize .filter((m) => m.role === "user") .map((m) => `- ${typeof m.content === "string" ? m.content.slice(0, 100) : "[complex]"}`) .join("\n"); @@ -57,12 +56,11 @@ describe("Documentation example", () => { // 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 entry.tokensBefore).toBe("number"); expect(typeof fromHook).toBe("boolean"); }); }; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 76d227bf..d056dc37 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -40,7 +40,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }); function createHook( - onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compactionEntry?: any } | undefined, + onBeforeCompact?: (event: SessionEvent) => { cancel?: boolean; compaction?: any } | undefined, onCompact?: (event: SessionEvent) => void, ): LoadedHook { const handlers = new Map Promise)[]>(); @@ -98,7 +98,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }, false, ); - hookRunner.setSessionFile(sessionManager.getSessionFile()); + hookRunner.setSessionFile(sessionManager.getSessionFile() ?? null); session = new AgentSession({ agent, @@ -131,20 +131,21 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { 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.messagesToKeep).toBeDefined(); - expect(beforeEvent.tokensBefore).toBeGreaterThanOrEqual(0); + expect(beforeEvent.preparation).toBeDefined(); + expect(beforeEvent.preparation.cutPoint.firstKeptEntryIndex).toBeGreaterThanOrEqual(0); + expect(beforeEvent.preparation.messagesToSummarize).toBeDefined(); + expect(beforeEvent.preparation.messagesToKeep).toBeDefined(); + expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0); expect(beforeEvent.model).toBeDefined(); - expect(beforeEvent.resolveApiKey).toBeDefined(); + expect(beforeEvent.sessionManager).toBeDefined(); + expect(beforeEvent.modelRegistry).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.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0); expect(afterEvent.fromHook).toBe(false); } }, 120000); @@ -162,18 +163,16 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(compactEvents.length).toBe(0); }, 120000); - it("should allow hooks to provide custom compactionEntry", async () => { + it("should allow hooks to provide custom compaction", 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(), + compaction: { summary: customSummary, - firstKeptEntryIndex: event.cutPoint.firstKeptEntryIndex, - tokensBefore: event.tokensBefore, + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, }, }; } @@ -215,7 +214,8 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const afterEvent = compactEvents[0]; if (afterEvent.reason === "compact") { - const hasCompactionEntry = afterEvent.entries.some((e) => e.type === "compaction"); + const entries = afterEvent.sessionManager.getEntries(); + const hasCompactionEntry = entries.some((e) => e.type === "compaction"); expect(hasCompactionEntry).toBe(true); } }, 120000); @@ -337,35 +337,34 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(capturedBeforeEvent).not.toBeNull(); const event = capturedBeforeEvent!; - expect(event.cutPoint).toHaveProperty("firstKeptEntryIndex"); - expect(event.cutPoint).toHaveProperty("isSplitTurn"); - expect(event.cutPoint).toHaveProperty("turnStartIndex"); + expect(event.preparation.cutPoint).toHaveProperty("firstKeptEntryIndex"); + expect(event.preparation.cutPoint).toHaveProperty("isSplitTurn"); + expect(event.preparation.cutPoint).toHaveProperty("turnStartIndex"); - expect(Array.isArray(event.messagesToSummarize)).toBe(true); - expect(Array.isArray(event.messagesToKeep)).toBe(true); + expect(Array.isArray(event.preparation.messagesToSummarize)).toBe(true); + expect(Array.isArray(event.preparation.messagesToKeep)).toBe(true); - expect(typeof event.tokensBefore).toBe("number"); + expect(typeof event.preparation.tokensBefore).toBe("number"); expect(event.model).toHaveProperty("provider"); expect(event.model).toHaveProperty("id"); - expect(typeof event.resolveApiKey).toBe("function"); + expect(typeof event.modelRegistry.getApiKey).toBe("function"); - expect(Array.isArray(event.entries)).toBe(true); - expect(event.entries.length).toBeGreaterThan(0); + const entries = event.sessionManager.getEntries(); + expect(Array.isArray(entries)).toBe(true); + expect(entries.length).toBeGreaterThan(0); }, 120000); - it("should use hook compactionEntry even with different firstKeptEntryIndex", async () => { - const customSummary = "Custom summary with modified index"; + it("should use hook compaction even with different values", async () => { + const customSummary = "Custom summary with modified values"; const hook = createHook((event) => { if (event.reason === "before_compact") { return { - compactionEntry: { - type: "compaction" as const, - timestamp: new Date().toISOString(), + compaction: { summary: customSummary, - firstKeptEntryIndex: 0, + firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: 999, }, };