feat(coding-agent/session): typed CompactionCancelledError sentinel and CompactionOutcome

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<CompactionOutcome>`; the catch classifies via
`instanceof CompactionCancelledError`. Existing callers (`/compact`,
loop runner, auto-compact) ignore the return value — non-breaking.

Op: extend
This commit is contained in:
cognitive
2026-05-12 22:58:27 +00:00
parent ce045f02a2
commit bad4c133e7
6 changed files with 98 additions and 29 deletions
@@ -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<void> {
async handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome> {
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<void> {
@@ -1098,7 +1099,10 @@ export class CommandController {
}
}
async executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto = false): Promise<void> {
async executeCompaction(
customInstructionsOrOptions?: string | CompactOptions,
isAuto = false,
): Promise<CompactionOutcome> {
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<void> {
@@ -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<void> {
handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome> {
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<void> {
executeCompaction(
customInstructionsOrOptions?: string | CompactOptions,
isAuto?: boolean,
): Promise<CompactionOutcome> {
return this.#commandController.executeCompaction(customInstructionsOrOptions, isAuto);
}
+6 -2
View File
@@ -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<void>;
handleMCPCommand(text: string): Promise<void>;
handleSSHCommand(text: string): Promise<void>;
handleCompactCommand(customInstructions?: string): Promise<void>;
handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome>;
handleHandoffCommand(customInstructions?: string): Promise<void>;
handleMoveCommand(targetPath: string): Promise<void>;
handleRenameCommand(title: string): Promise<void>;
handleMemoryCommand(text: string): Promise<void>;
handleSTTToggle(): Promise<void>;
executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;
executeCompaction(
customInstructionsOrOptions?: string | CompactOptions,
isAuto?: boolean,
): Promise<CompactionOutcome>;
openInBrowser(urlOrPath: string): void;
refreshSlashCommandState(cwd?: string): Promise<void>;
@@ -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(
@@ -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";
@@ -4,4 +4,5 @@
export * from "./branch-summarization";
export * from "./compaction";
export * from "./errors";
export * from "./utils";