From a6b1dbceb1af5bf8a21da0b437ef756ce9fe85e6 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:10:08 +0200 Subject: [PATCH] fix(extensions): emit compaction failed for extensions (#8241) * fix(extensions): emit compaction failed * changelog yeet --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/compaction.md | 15 +++++ packages/coding-agent/docs/extensions.md | 13 ++++- .../coding-agent/src/core/agent-session.ts | 56 ++++++++++++++++--- .../coding-agent/src/core/extensions/index.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 19 ++++++- .../suite/agent-session-compaction.test.ts | 44 +++++++++++++++ 7 files changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9440de45e..7f5ce78b4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 9595ca543..3a9dff1c6 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -346,6 +346,21 @@ pi.on("session_before_compact", async (event, ctx) => { See [custom-compaction.ts](../examples/extensions/custom-compaction.ts) for a complete example using a different model. +### session_compact_failed + +Fired when manual or automatic compaction fails or is aborted. This is useful for telemetry extensions that need to pair `session_before_compact` attempts with terminal outcomes. + +```typescript +pi.on("session_compact_failed", async (event, ctx) => { + const { reason, errorMessage, aborted, willRetry, fromExtension } = event; + // reason - "manual" (/compact), "threshold", or "overflow" + // errorMessage - present for non-abort failures + // aborted - true for cancelled/aborted compactions + // willRetry - whether the aborted turn would have retried after compaction + // fromExtension - whether extension-provided compaction content was being used +}); +``` + ### session_before_tree Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 9ec65f89b..2e069acdf 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -330,7 +330,8 @@ user sends another prompt ◄───────────────── /compact or auto-compaction ├─► session_before_compact (can cancel or customize) - └─► session_compact + ├─► session_compact (success) + └─► session_compact_failed (failure or abort) /tree navigation ├─► session_before_tree (can cancel or customize) @@ -448,7 +449,7 @@ pi.on("session_before_fork", async (event, ctx) => { After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. -#### session_before_compact / session_compact +#### session_before_compact / session_compact / session_compact_failed Fired on compaction. See [compaction.md](compaction.md) for details. @@ -479,6 +480,14 @@ pi.on("session_compact", async (event, ctx) => { // event.reason - "manual" (/compact), "threshold", or "overflow" // event.willRetry - whether the aborted turn is retried after compaction (overflow recovery) }); + +pi.on("session_compact_failed", async (event, ctx) => { + // event.reason - "manual" (/compact), "threshold", or "overflow" + // event.errorMessage - present for non-abort failures + // event.aborted - true for cancelled/aborted compactions + // event.willRetry - whether the aborted turn would have retried after compaction + // event.fromExtension - whether extension-provided compaction content was being used +}); ``` #### session_before_tree / session_tree diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8e7a59e89..5914a3712 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -81,6 +81,7 @@ import { type ReplacedSessionContext, type SessionBeforeCompactResult, type SessionBeforeTreeResult, + type SessionCompactFailedEvent, type SessionStartEvent, type ShutdownHandler, type ToolDefinition, @@ -574,6 +575,12 @@ export class AgentSession { }); } + private async _emitSessionCompactFailed(event: Omit): Promise { + if (this._extensionRunner.hasHandlers("session_compact_failed")) { + await this._extensionRunner.emit({ type: "session_compact_failed", ...event }); + } + } + private _getIdleWaitPromise(): Promise { if (!this._idleWaitPromise) { this._idleWaitPromise = new Promise((resolve) => { @@ -1801,6 +1808,7 @@ export class AgentSession { await this.abort(); this._compactionAbortController = new AbortController(); this._emit({ type: "compaction_start", reason: "manual" }); + let fromExtension = false; try { if (!this.model) { @@ -1823,7 +1831,6 @@ export class AgentSession { } let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const result = (await this._extensionRunner.emit({ @@ -1928,6 +1935,7 @@ export class AgentSession { } catch (error) { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); + const errorMessage = aborted ? undefined : `Compaction failed: ${message}`; this._compactionAbortController = undefined; this._emit({ type: "compaction_end", @@ -1935,7 +1943,14 @@ export class AgentSession { result: undefined, aborted, willRetry: false, - errorMessage: aborted ? undefined : `Compaction failed: ${message}`, + errorMessage, + }); + await this._emitSessionCompactFailed({ + reason: "manual", + errorMessage, + aborted, + willRetry: false, + fromExtension, }); throw error; } finally { @@ -2031,6 +2046,13 @@ export class AgentSession { willRetry: false, errorMessage, }); + await this._emitSessionCompactFailed({ + reason: "overflow", + errorMessage, + aborted: false, + willRetry: false, + fromExtension: false, + }); return false; } @@ -2088,6 +2110,7 @@ export class AgentSession { private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); let started = false; + let fromExtension = false; try { if (!this.model) { @@ -2108,7 +2131,6 @@ export class AgentSession { started = true; let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const extensionResult = (await this._extensionRunner.emit({ @@ -2129,6 +2151,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension: false, + }); return false; } @@ -2182,6 +2210,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension, + }); return false; } @@ -2235,16 +2269,24 @@ export class AgentSession { } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; if (started) { + const formattedErrorMessage = + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`; this._emit({ type: "compaction_end", reason, result: undefined, aborted: false, willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, + errorMessage: formattedErrorMessage, + }); + await this._emitSessionCompactFailed({ + reason, + errorMessage: formattedErrorMessage, + aborted: false, + willRetry: false, + fromExtension, }); } return false; diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index f8841aa8e..4ef89cff9 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -134,6 +134,7 @@ export type { SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, + SessionCompactFailedEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 0b8428d0e..9008d6212 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -601,7 +601,7 @@ export interface SessionBeforeCompactEvent { signal: AbortSignal; } -/** Fired after context compaction */ +/** Fired after context compaction succeeds */ export interface SessionCompactEvent { type: "session_compact"; compactionEntry: CompactionEntry; @@ -612,6 +612,21 @@ export interface SessionCompactEvent { willRetry: boolean; } +/** Fired after context compaction fails or is aborted */ +export interface SessionCompactFailedEvent { + type: "session_compact_failed"; + /** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */ + reason: "manual" | "threshold" | "overflow"; + /** Error text when compaction failed for a non-abort reason. */ + errorMessage?: string; + /** True when compaction was cancelled or aborted. */ + aborted: boolean; + /** True when the aborted turn would have been retried after this compaction (overflow recovery) */ + willRetry: boolean; + /** True when the failing compaction content came from a session_before_compact handler. */ + fromExtension: boolean; +} + /** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */ export interface SessionShutdownEvent { type: "session_shutdown"; @@ -658,6 +673,7 @@ export type SessionEvent = | SessionBeforeForkEvent | SessionBeforeCompactEvent | SessionCompactEvent + | SessionCompactFailedEvent | SessionShutdownEvent | SessionBeforeTreeEvent | SessionTreeEvent; @@ -1214,6 +1230,7 @@ export interface ExtensionAPI { handler: ExtensionHandler, ): void; on(event: "session_compact", handler: ExtensionHandler): void; + on(event: "session_compact_failed", handler: ExtensionHandler): void; on(event: "session_shutdown", handler: ExtensionHandler): void; on(event: "session_before_tree", handler: ExtensionHandler): void; on(event: "session_tree", handler: ExtensionHandler): void; diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 6a21f7f9f..5e9e7bcc8 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -278,6 +278,50 @@ describe("AgentSession compaction characterization", () => { expect(getStreamCallCount()).toBe(1); }); + it("notifies extensions when auto-compaction fails", async () => { + const failedEvents: Array<{ + reason: "manual" | "threshold" | "overflow"; + errorMessage?: string; + aborted: boolean; + willRetry: boolean; + fromExtension: boolean; + }> = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("session_compact_failed", async (event) => { + failedEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + seedCompactableSession(harness); + harness.session.agent.streamFunction = () => { + throw new Error("summary generator blew up"); + }; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + + await expect(sessionInternals._runAutoCompaction("threshold", false)).resolves.toBe(false); + + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "threshold", + aborted: false, + willRetry: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }); + expect(failedEvents).toEqual([ + expect.objectContaining({ + type: "session_compact_failed", + reason: "threshold", + aborted: false, + willRetry: false, + fromExtension: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }), + ]); + }); + it("compacts and resumes after a length stop below the desired output limit", async () => { const harness = await createHarness({ models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }],