From bad4c133e7876cb61cf6e7e89e2b044b9bc8a7d8 Mon Sep 17 00:00:00 2001 From: cognitive <152830360+METAeuPHORIC@users.noreply.github.com> Date: Tue, 12 May 2026 22:58:27 +0000 Subject: [PATCH] feat(coding-agent/session): typed CompactionCancelledError sentinel and CompactionOutcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `CompactionCancelledError` and `CompactionOutcome` ("ok" | "cancelled" | "failed") so callers can discriminate user-driven aborts from generic failures via `instanceof`, instead of inspecting error messages or `AbortError`-name strings. `AgentSession.compact()`'s two abort-rejection sites now throw the typed sentinel; the model-call wrapper normalizes AbortError-shaped rejections to the sentinel only when the compaction's abort signal is actually set, preserving every other exception unchanged so real compaction bugs are not silently relabeled as cancellations. `CommandController.executeCompaction` and `handleCompactCommand` return `Promise`; the catch classifies via `instanceof CompactionCancelledError`. Existing callers (`/compact`, loop runner, auto-compact) ignore the return value — non-breaking. Op: extend --- .../modes/controllers/command-controller.ts | 20 +++++-- .../src/modes/interactive-mode.ts | 8 ++- packages/coding-agent/src/modes/types.ts | 8 ++- .../coding-agent/src/session/agent-session.ts | 59 +++++++++++++------ .../src/session/compaction/errors.ts | 31 ++++++++++ .../src/session/compaction/index.ts | 1 + 6 files changed, 98 insertions(+), 29 deletions(-) create mode 100644 packages/coding-agent/src/session/compaction/errors.ts diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 29ed36142..919f3a427 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -37,6 +37,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown"; import { buildToolsMarkdown } from "../../modes/utils/tools-markdown"; import type { AsyncJobSnapshotItem } from "../../session/agent-session"; import type { AuthStorage } from "../../session/auth-storage"; +import { CompactionCancelledError, type CompactionOutcome } from "../../session/compaction"; import type { NewSessionOptions } from "../../session/session-manager"; import { outputMeta } from "../../tools/output-meta"; import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils"; @@ -1071,16 +1072,16 @@ export class CommandController { this.ctx.ui.requestRender(); } - async handleCompactCommand(customInstructions?: string): Promise { + async handleCompactCommand(customInstructions?: string): Promise { const entries = this.ctx.sessionManager.getEntries(); const messageCount = entries.filter(e => e.type === "message").length; if (messageCount < 2) { this.ctx.showWarning("Nothing to compact (no messages yet)"); - return; + return "ok"; } - await this.executeCompaction(customInstructions, false); + return this.executeCompaction(customInstructions, false); } async handleSkillCommand(skillPath: string, args: string): Promise { @@ -1098,7 +1099,10 @@ export class CommandController { } } - async executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto = false): Promise { + async executeCompaction( + customInstructionsOrOptions?: string | CompactOptions, + isAuto = false, + ): Promise { if (this.ctx.loadingAnimation) { this.ctx.loadingAnimation.stop(); this.ctx.loadingAnimation = undefined; @@ -1122,6 +1126,7 @@ export class CommandController { this.ctx.statusContainer.addChild(compactingLoader); this.ctx.ui.requestRender(); + let outcome: CompactionOutcome = "ok"; try { const instructions = typeof customInstructionsOrOptions === "string" ? customInstructionsOrOptions : undefined; const options = @@ -1135,10 +1140,12 @@ export class CommandController { this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) { + if (error instanceof CompactionCancelledError) { + outcome = "cancelled"; this.ctx.showError("Compaction cancelled"); } else { + outcome = "failed"; + const message = error instanceof Error ? error.message : String(error); this.ctx.showError(`Compaction failed: ${message}`); } } finally { @@ -1147,6 +1154,7 @@ export class CommandController { this.ctx.editor.onEscape = originalOnEscape; } await this.ctx.flushCompactionQueue({ willRetry: false }); + return outcome; } async handleHandoffCommand(customInstructions?: string): Promise { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 14062cfa1..908914226 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -42,6 +42,7 @@ import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup- import { renameApprovedPlanFile } from "../plan-mode/approved-plan"; import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" }; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; +import type { CompactionOutcome } from "../session/compaction"; import { HistoryStorage } from "../session/history-storage"; import type { SessionContext, SessionManager } from "../session/session-manager"; import { getRecentSessions } from "../session/session-manager"; @@ -1727,7 +1728,7 @@ export class InteractiveMode implements InteractiveModeContext { await controller.handle(text); } - handleCompactCommand(customInstructions?: string): Promise { + handleCompactCommand(customInstructions?: string): Promise { return this.#commandController.handleCompactCommand(customInstructions); } @@ -1735,7 +1736,10 @@ export class InteractiveMode implements InteractiveModeContext { return this.#commandController.handleHandoffCommand(customInstructions); } - executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise { + executeCompaction( + customInstructionsOrOptions?: string | CompactOptions, + isAuto?: boolean, + ): Promise { return this.#commandController.executeCompaction(customInstructionsOrOptions, isAuto); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index bc5ecb759..30d6eab72 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -12,6 +12,7 @@ import type { import type { CompactOptions } from "../extensibility/extensions/types"; import type { MCPManager } from "../mcp"; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; +import type { CompactionOutcome } from "../session/compaction"; import type { HistoryStorage } from "../session/history-storage"; import type { SessionContext, SessionManager } from "../session/session-manager"; import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools"; @@ -207,13 +208,16 @@ export interface InteractiveModeContext { handlePythonCommand(code: string, excludeFromContext?: boolean): Promise; handleMCPCommand(text: string): Promise; handleSSHCommand(text: string): Promise; - handleCompactCommand(customInstructions?: string): Promise; + handleCompactCommand(customInstructions?: string): Promise; handleHandoffCommand(customInstructions?: string): Promise; handleMoveCommand(targetPath: string): Promise; handleRenameCommand(title: string): Promise; handleMemoryCommand(text: string): Promise; handleSTTToggle(): Promise; - executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise; + executeCompaction( + customInstructionsOrOptions?: string | CompactOptions, + isAuto?: boolean, + ): Promise; openInBrowser(urlOrPath: string): void; refreshSlashCommandState(cwd?: string): Promise; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 40a0ce13a..6d2e80c9d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -152,6 +152,7 @@ import { extractFileMentions, generateFileMentionMessages } from "../utils/file- import { buildNamedToolChoice } from "../utils/tool-choice"; import type { AuthStorage } from "./auth-storage"; import { + CompactionCancelledError, type CompactionPreparation, type CompactionResult, calculateContextTokens, @@ -4503,7 +4504,7 @@ export class AgentSession { })) as SessionBeforeCompactResult | undefined; if (result?.cancel) { - throw new Error("Compaction cancelled"); + throw new CompactionCancelledError(); } if (result?.compaction) { @@ -4545,27 +4546,47 @@ export class AgentSession { details = hookCompaction.details; preserveData ??= hookCompaction.preserveData; } else { - // Generate compaction result - const result = await this.#compactWithFallbackModel( - preparation, - customInstructions, - compactionAbortController.signal, - { - promptOverride: hookPrompt, - extraContext: hookContext, - remoteInstructions: this.#baseSystemPrompt.join("\n\n"), - }, - ); - summary = result.summary; - shortSummary = result.shortSummary; - firstKeptEntryId = result.firstKeptEntryId; - tokensBefore = result.tokensBefore; - details = result.details; - preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) }; + // Generate compaction result. Only convert known abort-shaped + // rejections (AbortError raised while the abort signal is set, + // or an already-typed sentinel) into `CompactionCancelledError` + // so downstream callers can discriminate cancel from generic + // failure via `instanceof` without inspecting message strings. + // Real compaction bugs (network, server, parsing, etc.) keep + // their original shape — they must not be silently relabeled + // as cancellations even if the signal happens to be aborted + // for an unrelated reason. Assignments live inside the try + // block because every catch path throws — the post-try reads + // of the result-derived locals are reachable only on success. + try { + const result = await this.#compactWithFallbackModel( + preparation, + customInstructions, + compactionAbortController.signal, + { + promptOverride: hookPrompt, + extraContext: hookContext, + remoteInstructions: this.#baseSystemPrompt.join("\n\n"), + }, + ); + summary = result.summary; + shortSummary = result.shortSummary; + firstKeptEntryId = result.firstKeptEntryId; + tokensBefore = result.tokensBefore; + details = result.details; + preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) }; + } catch (err) { + if (err instanceof CompactionCancelledError) { + throw err; + } + if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") { + throw new CompactionCancelledError(); + } + throw err; + } } if (compactionAbortController.signal.aborted) { - throw new Error("Compaction cancelled"); + throw new CompactionCancelledError(); } this.sessionManager.appendCompaction( diff --git a/packages/coding-agent/src/session/compaction/errors.ts b/packages/coding-agent/src/session/compaction/errors.ts new file mode 100644 index 000000000..f60d0926e --- /dev/null +++ b/packages/coding-agent/src/session/compaction/errors.ts @@ -0,0 +1,31 @@ +/** + * Compaction error types. + * + * `CompactionCancelledError` is the canonical signal raised when a compaction + * is explicitly aborted — operator Esc, extension hook returning `cancel`, + * programmatic `session.abortCompaction()` call, or any other deliberate + * abort source. Downstream callers (e.g. `executeCompaction`) discriminate + * cancellation from other failures via `instanceof CompactionCancelledError` + * rather than introspecting error messages or `name` fields — the typed + * sentinel makes classification source-agnostic and refactor-stable. + */ + +export class CompactionCancelledError extends Error { + readonly name = "CompactionCancelledError" as const; + + constructor(message = "Compaction cancelled") { + super(message); + } +} + +/** + * Outcome of a compaction attempt, surfaced by `CommandController.executeCompaction` + * so callers (e.g. the plan-mode approval flow) can distinguish a deliberate abort + * from an unrelated failure. + * + * "ok" — compaction completed; transcript was summarized. + * "cancelled" — `CompactionCancelledError` was raised. Operator Esc, extension + * hook, programmatic abort — all source-agnostic. + * "failed" — any other rejection from `session.compact()`. + */ +export type CompactionOutcome = "ok" | "cancelled" | "failed"; diff --git a/packages/coding-agent/src/session/compaction/index.ts b/packages/coding-agent/src/session/compaction/index.ts index 21163cd18..ff9b076ea 100644 --- a/packages/coding-agent/src/session/compaction/index.ts +++ b/packages/coding-agent/src/session/compaction/index.ts @@ -4,4 +4,5 @@ export * from "./branch-summarization"; export * from "./compaction"; +export * from "./errors"; export * from "./utils";