diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eb571b1e..2e6d42a5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,13 +20,15 @@ - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry` - `appendCompaction()` now accepts optional `details` parameter - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - - `prepareCompaction()` now returns `firstKeptEntryId` in its result + - `prepareCompaction(pathEntries, settings)` now takes path entries (from `getPath()`) and settings only + - `CompactionPreparation` restructured: removed `cutPoint`, `messagesToKeep`, `boundaryStart`; added `turnPrefixMessages`, `isSplitTurn`, `previousSummary`, `fileOps`, `settings` + - `compact(preparation, model, apiKey, customInstructions?, signal?)` now takes preparation and execution context separately - **Hook types**: - - `SessionEventBase` no longer has `sessionManager`/`modelRegistry` - access them via `HookEventContext` instead - - `HookEventContext` now has `sessionManager` and `modelRegistry` (moved from events) - - `HookEventContext` no longer has `exec()` - use `pi.exec()` instead - - `HookCommandContext` no longer has `exec()` - use `pi.exec()` instead - - `before_compact` event passes `preparation: CompactionPreparation` and `previousCompactions: CompactionEntry[]` (newest first) + - `HookEventContext` renamed to `HookContext` + - `HookContext` now has `sessionManager`, `modelRegistry`, and `model` (current model, may be undefined) + - `HookCommandContext` removed - `RegisteredCommand.handler` now takes `(args: string, ctx: HookContext)` + - `before_compact` event: removed `previousCompactions` and `model`, added `branchEntries: SessionEntry[]` (hooks extract what they need) + - `before_tree` event: removed `model` (use `ctx.model` instead) - `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) @@ -38,7 +40,7 @@ - New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`) - `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null` - Renderers return inner content; the TUI wraps it in a styled Box - - New types: `HookMessage`, `RegisteredCommand`, `HookCommandContext` + - New types: `HookMessage`, `RegisteredCommand`, `HookContext` - Handler types renamed: `SendHandler` → `SendMessageHandler`, new `AppendEntryHandler` - **SessionManager**: - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) @@ -58,7 +60,7 @@ - `FileOperations`, `collectEntriesForBranchSummary`, `prepareBranchEntries`, `generateBranchSummary` - branch summarization utilities - `CompactionPreparation`, `CompactionDetails` - compaction preparation types - `ReadonlySessionManager` - read-only session manager interface for hooks - - `HookMessage`, `HookCommandContext`, `HookMessageRenderOptions` - hook types + - `HookMessage`, `HookContext`, `HookMessageRenderOptions` - hook types - `isHookMessage`, `createHookMessage` - hook message utilities ### Added @@ -93,6 +95,7 @@ - **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus)) - **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded - **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings +- **Compaction with branched sessions**: Fixed compaction incorrectly including entries from abandoned branches, causing token overflow errors. Compaction now uses `sessionManager.getPath()` to work only on the current branch path, eliminating 80+ lines of duplicate entry collection logic between `prepareCompaction()` and `compact()` ## [0.30.2] - 2025-12-26 diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index f912a3d6..be7795b2 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -3,7 +3,7 @@ * * Replaces the default compaction behavior with a full summary of the entire context. * Instead of keeping the last 20k tokens of conversation turns, this hook: - * 1. Summarizes ALL messages (both messagesToSummarize and messagesToKeep and previousSummary) + * 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages) * 2. Discards all old turns completely, keeping only the summary * * This example also demonstrates using a different model (Gemini Flash) for summarization, @@ -14,6 +14,7 @@ */ import { complete, getModel } from "@mariozechner/pi-ai"; +import type { CompactionEntry } from "@mariozechner/pi-coding-agent"; import { convertToLlm } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; @@ -21,11 +22,8 @@ export default function (pi: HookAPI) { pi.on("session_before_compact", async (event, ctx) => { ctx.ui.notify("Custom compaction hook triggered", "info"); - const { preparation, previousCompactions, signal } = event; - const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId } = preparation; - - // Get previous summary from most recent compaction (if any) - const previousSummary = previousCompactions[0]?.summary; + const { preparation, branchEntries, signal } = event; + const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation; // Use Gemini Flash for summarization (cheaper/faster than most conversation models) const model = getModel("google", "gemini-2.5-flash"); @@ -42,7 +40,7 @@ export default function (pi: HookAPI) { } // Combine all messages for full summary - const allMessages = [...messagesToSummarize, ...messagesToKeep]; + const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; ctx.ui.notify( `Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`, diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts index 134667e3..a0031d57 100644 --- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts +++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts @@ -5,13 +5,9 @@ * Useful to ensure work is committed before switching context. */ -import type { HookAPI, HookEventContext } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; -async function checkDirtyRepo( - pi: HookAPI, - ctx: HookEventContext, - action: string, -): Promise<{ cancel: boolean } | undefined> { +async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> { // Check for uncommitted changes const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 3b5ffe8d..0837e185 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -310,7 +310,7 @@ export default function (pi: HookAPI) { pi.registerCommand("snake", { description: "Play Snake!", - handler: async (ctx) => { + handler: async (_args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("Snake requires interactive mode", "error"); return; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 89fe5395..58fe2def 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -30,7 +30,7 @@ import { import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; import type { - HookCommandContext, + HookContext, HookRunner, SessionBeforeBranchResult, SessionBeforeCompactResult, @@ -552,17 +552,17 @@ export class AgentSession { // Build command context const cwd = process.cwd(); - const ctx: HookCommandContext = { - args, + const ctx: HookContext = { ui: uiContext, hasUI: this._hookRunner.getHasUI(), cwd, sessionManager: this.sessionManager, modelRegistry: this._modelRegistry, + model: this.model, }; try { - await command.handler(ctx); + await command.handler(args, ctx); return true; } catch (err) { // Emit error via hook runner @@ -895,13 +895,13 @@ export class AgentSession { throw new Error(`No API key for ${this.model.provider}`); } - const entries = this.sessionManager.getEntries(); + const pathEntries = this.sessionManager.getPath(); const settings = this.settingsManager.getCompactionSettings(); - const preparation = prepareCompaction(entries, settings); + const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { // Check why we can't compact - const lastEntry = entries[entries.length - 1]; + const lastEntry = pathEntries[pathEntries.length - 1]; if (lastEntry?.type === "compaction") { throw new Error("Already compacted"); } @@ -912,15 +912,11 @@ export class AgentSession { let fromHook = false; if (this._hookRunner?.hasHandlers("session_before_compact")) { - // 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_before_compact", preparation, - previousCompactions, + branchEntries: pathEntries, customInstructions, - model: this.model, signal: this._compactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; @@ -948,12 +944,11 @@ export class AgentSession { } else { // Generate compaction result const result = await compact( - entries, + preparation, this.model, - settings, apiKey, - this._compactionAbortController.signal, customInstructions, + this._compactionAbortController.signal, ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -1073,9 +1068,9 @@ export class AgentSession { return; } - const entries = this.sessionManager.getEntries(); + const pathEntries = this.sessionManager.getPath(); - const preparation = prepareCompaction(entries, settings); + const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; @@ -1085,15 +1080,11 @@ export class AgentSession { let fromHook = false; if (this._hookRunner?.hasHandlers("session_before_compact")) { - // 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_before_compact", preparation, - previousCompactions, + branchEntries: pathEntries, customInstructions: undefined, - model: this.model, signal: this._autoCompactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; @@ -1122,10 +1113,10 @@ export class AgentSession { } else { // Generate compaction result const compactResult = await compact( - entries, + preparation, this.model, - settings, apiKey, + undefined, this._autoCompactionAbortController.signal, ); summary = compactResult.summary; @@ -1628,7 +1619,6 @@ export class AgentSession { const result = (await this._hookRunner.emit({ type: "session_before_tree", preparation, - model: this.model!, // Checked above if summarize is true signal: this._branchSummaryAbortController.signal, })) as SessionBeforeTreeResult | undefined; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index afa99152..d46bdfb7 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -519,42 +519,48 @@ export async function generateSummary( // ============================================================================ export interface CompactionPreparation { - cutPoint: CutPointResult; /** UUID of first entry to keep */ firstKeptEntryId: string; /** Messages that will be summarized and discarded */ messagesToSummarize: AgentMessage[]; - /** Messages that will be kept after the summary (recent turns) */ - messagesToKeep: AgentMessage[]; + /** Messages that will be turned into turn prefix summary (if splitting) */ + turnPrefixMessages: AgentMessage[]; + /** Whether this is a split turn (cut point in middle of turn) */ + isSplitTurn: boolean; tokensBefore: number; - boundaryStart: number; + /** Summary from previous compaction, for iterative update */ + previousSummary?: string; + /** File operations extracted from messagesToSummarize */ + fileOps: FileOperations; + /** Compaction settions from settings.jsonl */ + settings: CompactionSettings; } export function prepareCompaction( - entries: SessionEntry[], + pathEntries: SessionEntry[], settings: CompactionSettings, ): CompactionPreparation | undefined { - if (entries.length > 0 && entries[entries.length - 1].type === "compaction") { + if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") { return undefined; } let prevCompactionIndex = -1; - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type === "compaction") { + for (let i = pathEntries.length - 1; i >= 0; i--) { + if (pathEntries[i].type === "compaction") { prevCompactionIndex = i; break; } } const boundaryStart = prevCompactionIndex + 1; - const boundaryEnd = entries.length; + const boundaryEnd = pathEntries.length; - const lastUsage = getLastAssistantUsage(entries); + const lastUsage = getLastAssistantUsage(pathEntries); const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0; - const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); // Get UUID of first kept entry - const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex]; + const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; if (!firstKeptEntry?.id) { return undefined; // Session needs migration } @@ -565,18 +571,46 @@ export function prepareCompaction( // Messages to summarize (will be discarded after summary) const messagesToSummarize: AgentMessage[] = []; for (let i = boundaryStart; i < historyEnd; i++) { - const msg = getMessageFromEntry(entries[i]); + const msg = getMessageFromEntry(pathEntries[i]); if (msg) messagesToSummarize.push(msg); } - // Messages to keep (recent turns, kept after summary) - const messagesToKeep: AgentMessage[] = []; - for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) { - const msg = getMessageFromEntry(entries[i]); - if (msg) messagesToKeep.push(msg); + // Messages for turn prefix summary (if splitting a turn) + const turnPrefixMessages: AgentMessage[] = []; + if (cutPoint.isSplitTurn) { + for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { + const msg = getMessageFromEntry(pathEntries[i]); + if (msg) turnPrefixMessages.push(msg); + } } - return { cutPoint, firstKeptEntryId, messagesToSummarize, messagesToKeep, tokensBefore, boundaryStart }; + // Get previous summary for iterative update + let previousSummary: string | undefined; + if (prevCompactionIndex >= 0) { + const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; + previousSummary = prevCompaction.summary; + } + + // Extract file operations from messages and previous compaction + const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); + + // Also extract file ops from turn prefix if splitting + if (cutPoint.isSplitTurn) { + for (const msg of turnPrefixMessages) { + extractFileOpsFromMessage(msg, fileOps); + } + } + + return { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + }; } // ============================================================================ @@ -599,87 +633,39 @@ Summarize the prefix to provide context for the retained suffix: Be concise. Focus on what's needed to understand the kept suffix.`; /** - * Calculate compaction and generate summary. + * Generate summaries for compaction using prepared data. * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. * - * @param entries - All session entries (must have uuid fields for v2) - * @param model - Model to use for summarization - * @param settings - Compaction settings - * @param apiKey - API key for LLM - * @param signal - Optional abort signal + * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary */ export async function compact( - entries: SessionEntry[], + preparation: CompactionPreparation, model: Model, - settings: CompactionSettings, apiKey: string, - signal?: AbortSignal, customInstructions?: string, + signal?: AbortSignal, ): Promise { - // Don't compact if the last entry is already a compaction - if (entries.length > 0 && entries[entries.length - 1].type === "compaction") { - throw new Error("Already compacted"); - } - - // Find previous compaction boundary - 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; - - // Get token count before compaction - const lastUsage = getLastAssistantUsage(entries); - const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0; - - // Find cut point (entry index) within the valid range - const cutResult = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens); - - // Extract messages for history summary (before the turn that contains the cut point) - const historyEnd = cutResult.isSplitTurn ? cutResult.turnStartIndex : cutResult.firstKeptEntryIndex; - const historyMessages: AgentMessage[] = []; - for (let i = boundaryStart; i < historyEnd; i++) { - const msg = getMessageFromEntry(entries[i]); - if (msg) historyMessages.push(msg); - } - - // Get previous summary for iterative update (if not from hook) - let previousSummary: string | undefined; - if (prevCompactionIndex >= 0) { - const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; - previousSummary = prevCompaction.summary; - } - - // Extract file operations from messages and previous compaction - const fileOps = extractFileOperations(historyMessages, entries, prevCompactionIndex); - - // Extract messages for turn prefix summary (if splitting a turn) - const turnPrefixMessages: AgentMessage[] = []; - if (cutResult.isSplitTurn) { - for (let i = cutResult.turnStartIndex; i < cutResult.firstKeptEntryIndex; i++) { - const msg = getMessageFromEntry(entries[i]); - if (msg) turnPrefixMessages.push(msg); - } - // Also extract file ops from turn prefix - for (const msg of turnPrefixMessages) { - extractFileOpsFromMessage(msg, fileOps); - } - } + const { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + } = preparation; // Generate summaries (can be parallel if both needed) and merge into one let summary: string; - if (cutResult.isSplitTurn && turnPrefixMessages.length > 0) { + if (isSplitTurn && turnPrefixMessages.length > 0) { // Generate both summaries in parallel const [historyResult, turnPrefixResult] = await Promise.all([ - historyMessages.length > 0 + messagesToSummarize.length > 0 ? generateSummary( - historyMessages, + messagesToSummarize, model, settings.reserveTokens, apiKey, @@ -695,7 +681,7 @@ export async function compact( } else { // Just generate history summary summary = await generateSummary( - historyMessages, + messagesToSummarize, model, settings.reserveTokens, apiKey, @@ -709,9 +695,6 @@ export async function compact( const { readFiles, modifiedFiles } = computeFileLists(fileOps); summary += formatFileOperations(readFiles, modifiedFiles); - // Get UUID of first kept entry - const firstKeptEntry = entries[cutResult.firstKeptEntryIndex]; - const firstKeptEntryId = firstKeptEntry.id; if (!firstKeptEntryId) { throw new Error("First kept entry has no UUID - session may need migration"); } diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index fb908cf8..04a7eae3 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -3,6 +3,7 @@ */ import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; @@ -11,9 +12,9 @@ import type { BeforeAgentStartEventResult, ContextEvent, ContextEventResult, + HookContext, HookError, HookEvent, - HookEventContext, HookMessageRenderer, HookUIContext, RegisteredCommand, @@ -72,6 +73,7 @@ export class HookRunner { private modelRegistry: ModelRegistry; private timeout: number; private errorListeners: Set = new Set(); + private getModel: () => Model | undefined = () => undefined; constructor( hooks: LoadedHook[], @@ -89,6 +91,30 @@ export class HookRunner { this.timeout = timeout; } + /** + * Initialize HookRunner with all required context. + * Modes call this once the agent session is fully set up. + */ + initialize(options: { + /** Function to get the current model */ + getModel: () => Model | undefined; + /** Handler for hooks to send messages */ + sendMessageHandler: SendMessageHandler; + /** Handler for hooks to append entries */ + appendEntryHandler: AppendEntryHandler; + /** UI context for interactive prompts */ + uiContext?: HookUIContext; + /** Whether UI is available */ + hasUI?: boolean; + }): void { + this.getModel = options.getModel; + this.setSendMessageHandler(options.sendMessageHandler); + this.setAppendEntryHandler(options.appendEntryHandler); + if (options.uiContext) { + this.setUIContext(options.uiContext, options.hasUI ?? false); + } + } + /** * Set the UI context for hooks. * Call this when the mode initializes and UI is available. @@ -217,13 +243,14 @@ export class HookRunner { /** * Create the event context for handlers. */ - private createContext(): HookEventContext { + private createContext(): HookContext { return { ui: this.uiContext, hasUI: this.hasUI, cwd: this.cwd, sessionManager: this.sessionManager, modelRegistry: this.modelRegistry, + model: this.getModel(), }; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index e8dd689e..2d3cd141 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -88,9 +88,9 @@ export interface HookUIContext { } /** - * Context passed to hook event handlers. + * Context passed to hook event and command handlers. */ -export interface HookEventContext { +export interface HookContext { /** UI methods for user interaction */ ui: HookUIContext; /** Whether UI is available (false in print mode) */ @@ -101,6 +101,8 @@ export interface HookEventContext { sessionManager: ReadonlySessionManager; /** Model registry - use for API key resolution and model retrieval */ modelRegistry: ModelRegistry; + /** Current model (may be undefined if no model is selected yet) */ + model: Model | undefined; } // ============================================================================ @@ -152,14 +154,12 @@ export interface SessionBranchEvent { /** Fired before context compaction (can be cancelled or customized) */ export interface SessionBeforeCompactEvent { type: "session_before_compact"; - /** Compaction preparation with cut point, messages to summarize/keep, etc. */ + /** Compaction preparation with messages to summarize, file ops, previous summary, etc. */ preparation: CompactionPreparation; - /** Previous compaction entries, newest first. Use for iterative summarization. */ - previousCompactions: CompactionEntry[]; + /** Branch entries (root to current leaf). Use to inspect custom state or previous compactions. */ + branchEntries: SessionEntry[]; /** Optional user-provided instructions for the summary */ customInstructions?: string; - /** Current model */ - model: Model; /** Abort signal - hooks should pass this to LLM calls and check it periodically */ signal: AbortSignal; } @@ -196,9 +196,7 @@ export interface SessionBeforeTreeEvent { type: "session_before_tree"; /** Preparation data for the navigation */ preparation: TreePreparation; - /** Model to use for summarization (conversation model) */ - model: Model; - /** Abort signal - honors Escape during summarization */ + /** Abort signal - honors Escape during summarization (model available via ctx.model) */ signal: AbortSignal; } @@ -529,7 +527,7 @@ export interface SessionBeforeTreeResult { * Handlers can return R, undefined, or void (bare return statements). */ // biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers -export type HookHandler = (event: E, ctx: HookEventContext) => Promise | R | void; +export type HookHandler = (event: E, ctx: HookContext) => Promise | R | void; export interface HookMessageRenderOptions { /** Whether the view is expanded */ @@ -546,24 +544,6 @@ export type HookMessageRenderer = ( theme: Theme, ) => Component | undefined; -/** - * Context passed to hook command handlers. - */ -export interface HookCommandContext { - /** Arguments after the command name */ - args: string; - /** UI methods for user interaction */ - ui: HookUIContext; - /** Whether UI is available (false in print mode) */ - hasUI: boolean; - /** Current working directory */ - cwd: string; - /** Session manager (read-only) - use pi.sendMessage()/pi.appendEntry() for writes */ - sessionManager: ReadonlySessionManager; - /** Model registry for API keys */ - modelRegistry: ModelRegistry; -} - /** * Command registration options. */ @@ -571,7 +551,7 @@ export interface RegisteredCommand { name: string; description?: string; - handler: (ctx: HookCommandContext) => Promise; + handler: (args: string, ctx: HookContext) => Promise; } /** diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index b25df418..55e51d99 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -27,9 +27,9 @@ export { } from "./custom-tools/index.js"; export { type HookAPI, + type HookContext, type HookError, type HookEvent, - type HookEventContext, type HookFactory, HookRunner, type HookUIContext, diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index fbf1dadd..deceaeb5 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -11,23 +11,27 @@ describe("Documentation example", () => { const exampleHook = (pi: HookAPI) => { pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => { // All these should be accessible on the event - const { preparation, previousCompactions, model } = event; - // sessionManager and modelRegistry come from ctx, not event - const { sessionManager, modelRegistry } = ctx; - const { messagesToSummarize, messagesToKeep, tokensBefore, firstKeptEntryId, cutPoint } = preparation; - - // Get previous summary from most recent compaction - const _previousSummary = previousCompactions[0]?.summary; + const { preparation, branchEntries, signal } = event; + // sessionManager, modelRegistry, and model come from ctx + const { sessionManager, modelRegistry, model } = ctx; + const { + messagesToSummarize, + turnPrefixMessages, + tokensBefore, + firstKeptEntryId, + isSplitTurn, + previousSummary, + } = preparation; // Verify types expect(Array.isArray(messagesToSummarize)).toBe(true); - expect(Array.isArray(messagesToKeep)).toBe(true); - expect(typeof cutPoint.firstKeptEntryIndex).toBe("number"); + expect(Array.isArray(turnPrefixMessages)).toBe(true); + expect(typeof isSplitTurn).toBe("boolean"); expect(typeof tokensBefore).toBe("number"); - expect(model).toBeDefined(); expect(typeof sessionManager.getEntries).toBe("function"); expect(typeof modelRegistry.getApiKey).toBe("function"); expect(typeof firstKeptEntryId).toBe("string"); + expect(Array.isArray(branchEntries)).toBe(true); const summary = messagesToSummarize .filter((m) => m.role === "user") diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 9ca29281..b0b65511 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -143,12 +143,12 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const beforeEvent = beforeCompactEvents[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.turnPrefixMessages).toBeDefined(); expect(beforeEvent.preparation.tokensBefore).toBeGreaterThanOrEqual(0); - expect(beforeEvent.model).toBeDefined(); - // sessionManager and modelRegistry are now on ctx, not event + expect(typeof beforeEvent.preparation.isSplitTurn).toBe("boolean"); + expect(beforeEvent.branchEntries).toBeDefined(); + // sessionManager, modelRegistry, and model are now on ctx, not event const afterEvent = compactEvents[0]; expect(afterEvent.compactionEntry).toBeDefined(); @@ -363,19 +363,17 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(capturedBeforeEvent).not.toBeNull(); const event = capturedBeforeEvent!; - expect(event.preparation.cutPoint).toHaveProperty("firstKeptEntryIndex"); - expect(event.preparation.cutPoint).toHaveProperty("isSplitTurn"); - expect(event.preparation.cutPoint).toHaveProperty("turnStartIndex"); + expect(typeof event.preparation.isSplitTurn).toBe("boolean"); + expect(event.preparation.firstKeptEntryId).toBeDefined(); expect(Array.isArray(event.preparation.messagesToSummarize)).toBe(true); - expect(Array.isArray(event.preparation.messagesToKeep)).toBe(true); + expect(Array.isArray(event.preparation.turnPrefixMessages)).toBe(true); expect(typeof event.preparation.tokensBefore).toBe("number"); - expect(event.model).toHaveProperty("provider"); - expect(event.model).toHaveProperty("id"); + expect(Array.isArray(event.branchEntries)).toBe(true); - // sessionManager and modelRegistry are now on ctx, not event + // sessionManager, modelRegistry, and model are now on ctx, not event // Verify they're accessible via session expect(typeof session.sessionManager.getEntries).toBe("function"); expect(typeof session.modelRegistry.getApiKey).toBe("function"); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 0468a582..4eac8b1d 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -11,6 +11,7 @@ import { DEFAULT_COMPACTION_SETTINGS, findCutPoint, getLastAssistantUsage, + prepareCompaction, shouldCompact, } from "../src/core/compaction/index.js"; import { @@ -398,12 +399,10 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { const entries = loadLargeSessionEntries(); const model = getModel("anthropic", "claude-sonnet-4-5")!; - const compactionResult = await compact( - entries, - model, - DEFAULT_COMPACTION_SETTINGS, - process.env.ANTHROPIC_OAUTH_TOKEN!, - ); + const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS); + expect(preparation).toBeDefined(); + + const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!); expect(compactionResult.summary.length).toBeGreaterThan(100); expect(compactionResult.firstKeptEntryId).toBeTruthy(); @@ -421,12 +420,10 @@ describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("LLM summarization", () => { const loaded = buildSessionContext(entries); const model = getModel("anthropic", "claude-sonnet-4-5")!; - const compactionResult = await compact( - entries, - model, - DEFAULT_COMPACTION_SETTINGS, - process.env.ANTHROPIC_OAUTH_TOKEN!, - ); + const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS); + expect(preparation).toBeDefined(); + + const compactionResult = await compact(preparation!, model, process.env.ANTHROPIC_OAUTH_TOKEN!); // Simulate appending compaction to entries by creating a proper entry const lastEntry = entries[entries.length - 1];