From 91b89578c13f928cae8ee78a3454225152ac6322 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 9 Dec 2025 01:51:51 +0100 Subject: [PATCH] Add auto-compaction events to AgentSession - Add AgentSessionEvent type extending AgentEvent with auto_compaction_start/end - Emit events when auto-compaction starts and completes - TUI shows loader during auto-compaction with escape to cancel - Rebuilds chat UI when auto-compaction succeeds --- .../coding-agent/src/core/agent-session.ts | 80 ++++++++++++++----- packages/coding-agent/src/core/index.ts | 3 +- .../src/modes/interactive/interactive-mode.ts | 52 +++++++++++- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index db27d8e0..e394a1ae 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -25,8 +25,14 @@ import { loadSessionFromEntries, type SessionManager } from "./session-manager.j import type { SettingsManager } from "./settings-manager.js"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; -/** Listener function for agent events */ -export type AgentEventListener = (event: AgentEvent) => void; +/** Session-specific events that extend the core AgentEvent */ +export type AgentSessionEvent = + | AgentEvent + | { type: "auto_compaction_start" } + | { type: "auto_compaction_end"; result: CompactionResult | null; aborted: boolean }; + +/** Listener function for agent session events */ +export type AgentSessionEventListener = (event: AgentSessionEvent) => void; // ============================================================================ // Types @@ -97,13 +103,14 @@ export class AgentSession { // Event subscription state private _unsubscribeAgent?: () => void; - private _eventListeners: AgentEventListener[] = []; + private _eventListeners: AgentSessionEventListener[] = []; // Message queue state private _queuedMessages: string[] = []; // Compaction state private _compactionAbortController: AbortController | null = null; + private _autoCompactionAbortController: AbortController | null = null; // Bash execution state private _bashAbortController: AbortController | null = null; @@ -121,12 +128,17 @@ export class AgentSession { // Event Subscription // ========================================================================= - /** Internal handler for agent events - shared by subscribe and reconnect */ - private _handleAgentEvent = async (event: AgentEvent): Promise => { - // Notify all listeners + /** Emit an event to all listeners */ + private _emit(event: AgentSessionEvent): void { for (const l of this._eventListeners) { l(event); } + } + + /** Internal handler for agent events - shared by subscribe and reconnect */ + private _handleAgentEvent = async (event: AgentEvent): Promise => { + // Notify all listeners + this._emit(event); // Handle session persistence if (event.type === "message_end") { @@ -139,7 +151,7 @@ export class AgentSession { // Check auto-compaction after assistant messages if (event.message.role === "assistant") { - await this.checkAutoCompaction(); + await this._runAutoCompaction(); } } }; @@ -149,7 +161,7 @@ export class AgentSession { * Session persistence is handled internally (saves messages on message_end). * Multiple listeners can be added. Returns unsubscribe function for this listener. */ - subscribe(listener: AgentEventListener): () => void { + subscribe(listener: AgentSessionEventListener): () => void { this._eventListeners.push(listener); // Set up agent subscription if not already done @@ -561,20 +573,20 @@ export class AgentSession { } /** - * Cancel in-progress compaction. + * Cancel in-progress compaction (manual or auto). */ abortCompaction(): void { this._compactionAbortController?.abort(); + this._autoCompactionAbortController?.abort(); } /** - * Check if auto-compaction should run, and run it if so. - * Called internally after assistant messages. - * @returns Result if compaction occurred, null otherwise + * Internal: Run auto-compaction with events. + * Called after assistant messages complete. */ - async checkAutoCompaction(): Promise { + private async _runAutoCompaction(): Promise { const settings = this.settingsManager.getCompactionSettings(); - if (!settings.enabled) return null; + if (!settings.enabled) return; // Get last non-aborted assistant message const messages = this.messages; @@ -589,33 +601,57 @@ export class AgentSession { } } } - if (!lastAssistant) return null; + if (!lastAssistant) return; const contextTokens = calculateContextTokens(lastAssistant.usage); const contextWindow = this.model?.contextWindow ?? 0; - if (!shouldCompact(contextTokens, contextWindow, settings)) return null; + if (!shouldCompact(contextTokens, contextWindow, settings)) return; + + // Emit start event + this._emit({ type: "auto_compaction_start" }); + this._autoCompactionAbortController = new AbortController(); - // Perform auto-compaction (don't abort current operation for auto) try { - if (!this.model) return null; + if (!this.model) { + this._emit({ type: "auto_compaction_end", result: null, aborted: false }); + return; + } const apiKey = await getApiKeyForModel(this.model); - if (!apiKey) return null; + if (!apiKey) { + this._emit({ type: "auto_compaction_end", result: null, aborted: false }); + return; + } const entries = this.sessionManager.loadEntries(); - const compactionEntry = await compact(entries, this.model, settings, apiKey); + const 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 }); + return; + } this.sessionManager.saveCompaction(compactionEntry); const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); this.agent.replaceMessages(loaded.messages); - return { + const result: CompactionResult = { tokensBefore: compactionEntry.tokensBefore, summary: compactionEntry.summary, }; + this._emit({ type: "auto_compaction_end", result, aborted: false }); } catch { - return null; // Silently fail auto-compaction + // Silently fail auto-compaction but emit end event + this._emit({ type: "auto_compaction_end", result: null, aborted: false }); + } finally { + this._autoCompactionAbortController = null; } } diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 6ce21a1f..eefe99ca 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -3,9 +3,10 @@ */ export { - type AgentEventListener, AgentSession, type AgentSessionConfig, + type AgentSessionEvent, + type AgentSessionEventListener, type CompactionResult, type ModelCycleResult, type PromptOptions, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b1aa8f7d..c6c06095 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import type { AgentEvent, AgentState, AppMessage } from "@mariozechner/pi-agent-core"; +import type { AgentState, AppMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -24,7 +24,7 @@ import { } from "@mariozechner/pi-tui"; import { exec } from "child_process"; import { APP_NAME, getDebugLogPath, getOAuthPath } from "../../config.js"; -import type { AgentSession } from "../../core/agent-session.js"; +import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import { isBashExecutionMessage } from "../../core/messages.js"; import { invalidateOAuthCache } from "../../core/model-config.js"; import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js"; @@ -94,6 +94,10 @@ export class InteractiveMode { // Track pending bash components (shown in pending area, moved to chat on submit) private pendingBashComponents: BashExecutionComponent[] = []; + // Auto-compaction state + private autoCompactionLoader: Loader | null = null; + private autoCompactionEscapeHandler?: () => void; + // Convenience accessors private get agent() { return this.session.agent; @@ -430,7 +434,7 @@ export class InteractiveMode { }); } - private async handleEvent(event: AgentEvent, state: AgentState): Promise { + private async handleEvent(event: AgentSessionEvent, state: AgentState): Promise { if (!this.isInitialized) { await this.init(); } @@ -555,6 +559,48 @@ export class InteractiveMode { this.pendingTools.clear(); this.ui.requestRender(); break; + + case "auto_compaction_start": + // Set up escape to abort auto-compaction + this.autoCompactionEscapeHandler = this.editor.onEscape; + this.editor.onEscape = () => { + this.session.abortCompaction(); + }; + // Show compacting indicator + this.statusContainer.clear(); + this.autoCompactionLoader = new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + "Auto-compacting... (esc to cancel)", + ); + this.statusContainer.addChild(this.autoCompactionLoader); + this.ui.requestRender(); + break; + + case "auto_compaction_end": + // Restore escape handler + if (this.autoCompactionEscapeHandler) { + this.editor.onEscape = this.autoCompactionEscapeHandler; + this.autoCompactionEscapeHandler = undefined; + } + // Stop loader + if (this.autoCompactionLoader) { + this.autoCompactionLoader.stop(); + this.autoCompactionLoader = null; + this.statusContainer.clear(); + } + // Handle result + if (event.aborted) { + this.showStatus("Auto-compaction cancelled"); + } else if (event.result) { + // Rebuild chat to show compacted state + this.chatContainer.clear(); + this.rebuildChatFromMessages(); + this.showStatus(`Auto-compacted: ${event.result.tokensBefore.toLocaleString()} tokens`); + } + this.ui.requestRender(); + break; } }