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> {