From a5241d6bf66cef391e668a0642b4abc4f395e580 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 12 Aug 2026 20:54:55 +0000 Subject: [PATCH 1/6] feat(errors): add shared user cancellation error --- src/errors/errors.test.tsx | 27 ++++++++++- src/errors/errors.tsx | 38 ++++++++++----- src/errors/index.tsx | 2 +- src/handlers/eval/dataset/dataset.test.tsx | 37 +++++++++++++++ src/handlers/eval/dataset/get/index.tsx | 4 +- src/handlers/runtime/invoke/index.tsx | 9 ++-- src/handlers/runtime/invoke/invoke.test.tsx | 14 +++--- src/handlers/runtime/invoke/response.test.ts | 8 ++-- src/handlers/runtime/invoke/response.ts | 15 ++++-- src/index.ts | 14 +++--- src/runnable/index.test.ts | 49 ++++---------------- src/runnable/index.tsx | 34 ++------------ 12 files changed, 136 insertions(+), 115 deletions(-) diff --git a/src/errors/errors.test.tsx b/src/errors/errors.test.tsx index 090bcd4fe..ec2aca07a 100644 --- a/src/errors/errors.test.tsx +++ b/src/errors/errors.test.tsx @@ -4,7 +4,8 @@ import { ValidationException, InternalServerException, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { AgentCoreCLIError, InputValidationError } from "./errors"; +import { CommanderError } from "commander"; +import { AgentCoreCLIError, InputValidationError, UserCancellationError } from "./errors"; describe("AgentCoreCLIError", () => { test("fromError preserves existing AgentCoreCLIError instances", () => { @@ -17,6 +18,29 @@ describe("AgentCoreCLIError", () => { expect(AgentCoreCLIError.fromError(err)).toBe(err); }); + test.each([ + ["parse failures", new CommanderError(1, "commander.invalidArgument", "invalid option"), 2], + ["help", new CommanderError(0, "commander.helpDisplayed", "help displayed"), 0], + ])("fromError classifies Commander %s", (_label, err, exitCode) => { + expect(AgentCoreCLIError.fromError(err).json()).toMatchObject({ + name: "CommanderError", + source: "user", + exitCode, + displayMessage: false, + meta: { code: err.code }, + }); + }); + + test("UserCancellationError is a silent user interruption", () => { + expect(new UserCancellationError().json()).toMatchObject({ + name: "UserCancellationError", + message: "Operation cancelled by user", + source: "user", + exitCode: 130, + displayMessage: false, + }); + }); + test.each([ [ "AccessDeniedException (403)", @@ -61,6 +85,7 @@ describe("AgentCoreCLIError", () => { name: "AgentCoreCLIError", source: "internal", message: expectedMessage, + displayMessage: true, }); }); }); diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index d7a6f06e8..cb9a96c80 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -1,4 +1,5 @@ import { ServiceException } from "@smithy/core/client"; +import { CommanderError } from "commander"; import { join } from "node:path"; import { ERROR_SOURCE, type ErrorSource } from "./types"; @@ -11,6 +12,8 @@ export interface AgentCoreCLIErrorOptions extends ErrorOptions { exitCode?: number; /** Describes the name of the underlying error, defaults to AgentCoreCLIError */ name?: string; + /** Whether the root handler should display this error's message */ + displayMessage?: boolean; } /** Base error for CLI failures, including their source, metadata, and process exit code. */ @@ -18,6 +21,7 @@ export class AgentCoreCLIError extends Error { readonly source: ErrorSource; readonly meta: Record; readonly exitCode: number; + readonly displayMessage: boolean; constructor(message?: string, options?: AgentCoreCLIErrorOptions) { super(message, options); @@ -25,6 +29,7 @@ export class AgentCoreCLIError extends Error { this.source = options?.source ?? ERROR_SOURCE.INTERNAL; this.meta = options?.meta ?? {}; this.exitCode = options?.exitCode ?? 1; + this.displayMessage = options?.displayMessage ?? true; } /** Convert the error into an object with its attributes enumerated as keys **/ json(): Record { @@ -33,6 +38,7 @@ export class AgentCoreCLIError extends Error { message: this.message, stack: this.stack, exitCode: this.exitCode, + displayMessage: this.displayMessage, meta: this.meta, source: this.source, }; @@ -41,6 +47,17 @@ export class AgentCoreCLIError extends Error { static fromError(error: unknown): AgentCoreCLIError { if (error instanceof AgentCoreCLIError) return error; + if (error instanceof CommanderError) { + return new AgentCoreCLIError(error.message, { + cause: error, + source: ERROR_SOURCE.USER, + name: error.name, + meta: { code: error.code }, + exitCode: error.exitCode === 0 ? 0 : 2, + displayMessage: false, + }); + } + if (ServiceException.isInstance(error)) { const httpStatusCode = error.$metadata.httpStatusCode; const source = @@ -138,23 +155,20 @@ export class EmbeddedAssetNotFoundError extends AgentCoreCLIError { } } -export class CommandInterruptedError extends AgentCoreCLIError { - readonly reported: boolean; - - constructor(cause?: unknown, reported = false) { - super("The operation was aborted", { cause, exitCode: 130 }); - this.name = "AbortError"; - this.reported = reported; +/** Raised when a user intentionally cancels a headless CLI operation. */ +export class UserCancellationError extends AgentCoreCLIError { + constructor() { + super("Operation cancelled by user", { + source: ERROR_SOURCE.USER, + exitCode: 130, + displayMessage: false, + }); } } -export class RuntimeInvokeInterruptedError extends CommandInterruptedError {} - export class RuntimeInvokeResponseError extends AgentCoreCLIError { - readonly reported = true; - constructor(message: string, cause?: unknown) { - super(message, { cause }); + super(message, { cause, displayMessage: false }); } } diff --git a/src/errors/index.tsx b/src/errors/index.tsx index babdfbe1d..7380cb860 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -15,9 +15,9 @@ export { ProjectFileExistsError, ResourceNotFoundError, ResultTruncationError, - RuntimeInvokeInterruptedError, RuntimeInvokeResponseError, SourceResolutionError, + UserCancellationError, type AgentCoreCLIErrorOptions, } from "./errors"; export { ERROR_SOURCE } from "./types"; diff --git a/src/handlers/eval/dataset/dataset.test.tsx b/src/handlers/eval/dataset/dataset.test.tsx index bfb3b5242..11f8bc231 100644 --- a/src/handlers/eval/dataset/dataset.test.tsx +++ b/src/handlers/eval/dataset/dataset.test.tsx @@ -8,7 +8,9 @@ import { TestCoreClient, TestGlobalConfigAccessor, testIO, + waitFor, } from "../../../testing"; +import { UserCancellationError } from "../../../errors"; import { createRootHandler } from "../../index"; import type { CreateDatasetInput } from "../types"; @@ -442,6 +444,41 @@ describe("dataset get", () => { expect(call?.args.slice(0, 3)).toEqual(["dataset-orders-abc123", "2", "/tmp/v2.jsonl"]); }); + test("SIGINT cancels a download with the shared user cancellation error", async () => { + const { core, route } = testDatasetCommand(); + core.eval.downloadDataset = async (id, version, filePath, options, signal) => { + core.eval.calls.push({ + method: "downloadDataset", + args: [id, version, filePath, options, signal], + }); + return new Promise((_, reject) => { + const abort = () => reject(signal?.reason); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }; + const pending = route([ + "eval", + "dataset", + "get", + "--id", + "dataset-orders-abc123", + "--file-path", + "/tmp/out.jsonl", + ]); + + try { + await waitFor(() => core.eval.calls.some((call) => call.method === "downloadDataset")); + process.emit("SIGINT", "SIGINT"); + + const signal = core.eval.calls[0]!.args[4] as AbortSignal; + expect(signal.reason).toBeInstanceOf(UserCancellationError); + await expect(pending).rejects.toBe(signal.reason); + } finally { + await pending.catch(() => undefined); + } + }); + test("requires --id", async () => { const { core, route } = testDatasetCommand(); diff --git a/src/handlers/eval/dataset/get/index.tsx b/src/handlers/eval/dataset/get/index.tsx index 4c2e11c81..8b88a95d9 100644 --- a/src/handlers/eval/dataset/get/index.tsx +++ b/src/handlers/eval/dataset/get/index.tsx @@ -1,6 +1,6 @@ import z from "zod"; import { createHandler, flag } from "../../../../router"; -import { InputValidationError } from "../../../../errors"; +import { InputValidationError, UserCancellationError } from "../../../../errors"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -37,7 +37,7 @@ export const createGetDatasetHandler = (core: Core) => // --file-path downloads the contents via the presigned download URL in metadata const controller = new AbortController(); - const interrupt = () => controller.abort(); + const interrupt = () => controller.abort(new UserCancellationError()); process.once("SIGINT", interrupt); try { const response = await core.eval.downloadDataset( diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index dc1ecb017..291bc8a7a 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { InputValidationError, RuntimeInvokeInterruptedError } from "../../../errors"; +import { InputValidationError, UserCancellationError } from "../../../errors"; import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; @@ -105,7 +105,7 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => throw new InputValidationError("--json cannot be used with --output-file"); } const controller = new AbortController(); - const interrupt = () => controller.abort(); + const interrupt = () => controller.abort(new UserCancellationError()); process.once("SIGINT", interrupt); try { const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); @@ -144,10 +144,7 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => signal: controller.signal, }); } catch (error) { - if (controller.signal.aborted && (error as Error)?.name === "AbortError") { - if (error instanceof RuntimeInvokeInterruptedError) throw error; - throw new RuntimeInvokeInterruptedError(error); - } + controller.signal.throwIfAborted(); throw error; } finally { controller.abort(); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index f76672ebc..a0004bb54 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -12,6 +12,7 @@ import { waitFor, } from "../../../testing"; import { ExitCode, runWithExitCode } from "../../../runnable"; +import { UserCancellationError } from "../../../errors"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; import { RuntimeInvokeLaunchContextKey } from "./launchContext"; @@ -284,14 +285,14 @@ describe("runtime invoke", () => { process.emit("SIGINT", "SIGINT"); expect(signal!.aborted).toBe(true); - await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + await expect(pending).rejects.toBeInstanceOf(UserCancellationError); expect(output.bytes().toString()).toBe("partial"); } finally { await pending.catch(() => undefined); } }); - test("wraps a raw Core abort after SIGINT", async () => { + test("replaces a raw Core abort with the typed SIGINT reason", async () => { const core = new TestCoreClient(); const output = captureIO(); const rawAbort = Object.assign(new Error("transport aborted"), { name: "AbortError" }); @@ -317,11 +318,10 @@ describe("runtime invoke", () => { await waitFor(() => core.runtime.calls.some((call) => call.method === "invokeRuntime")); process.emit("SIGINT", "SIGINT"); - await expect(pending).rejects.toMatchObject({ - name: "AbortError", - cause: rawAbort, - reported: false, - }); + const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[2] as AbortSignal; + expect(signal.reason).toBeInstanceOf(UserCancellationError); + await expect(pending).rejects.toBe(signal.reason); } finally { await pending.catch(() => undefined); } diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index d918b2350..aa0a6a645 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -3,6 +3,7 @@ import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; +import { UserCancellationError } from "../../../errors"; import { waitFor } from "../../../testing"; import type { RuntimeInvokeResponse } from "../types"; import { writeRuntimeInvokeResponse } from "./response"; @@ -128,7 +129,7 @@ describe("Runtime invoke response output", () => { }), ).rejects.toMatchObject({ message: "response stream failed", - reported: true, + displayMessage: false, }); }); @@ -266,11 +267,12 @@ describe("Runtime invoke response output", () => { test("writes an interruption summary after the output signal is aborted", async () => { const controller = new AbortController(); + const cancellation = new UserCancellationError(); const stdout = capture(); const stderr = capture(); const source = (async function* () { yield Buffer.from("partial"); - controller.abort(); + controller.abort(cancellation); throw Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); })(); @@ -280,7 +282,7 @@ describe("Runtime invoke response output", () => { stderr: stderr.stream, signal: controller.signal, }), - ).rejects.toMatchObject({ name: "AbortError" }); + ).rejects.toBe(cancellation); expect(stdout.bytes().toString()).toBe("partial"); expect(stderr.bytes().toString()).toBe( "status=200 content-type=text/event-stream runtime-session-id=- mcp-session-id=- " + diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index 190138c4f..c9f191dae 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,4 +1,4 @@ -import { RuntimeInvokeInterruptedError, RuntimeInvokeResponseError } from "../../../errors"; +import { RuntimeInvokeResponseError, UserCancellationError } from "../../../errors"; import { classifyStreamingResponse, writeStreamingResponse, @@ -22,9 +22,14 @@ export async function writeRuntimeInvokeFile( await writeStreamingResponseFile(response, path, signal, onBytes); } -function failure(error: unknown): never { - const interrupted = (error as Error)?.name === "AbortError"; - if (interrupted) throw new RuntimeInvokeInterruptedError(error, true); +function userCancellation(error: unknown, signal?: AbortSignal): UserCancellationError | undefined { + if (error instanceof UserCancellationError) return error; + return signal?.reason instanceof UserCancellationError ? signal.reason : undefined; +} + +function failure(error: unknown, signal?: AbortSignal): never { + const cancellation = userCancellation(error, signal); + if (cancellation) throw cancellation; throw new RuntimeInvokeResponseError(RESPONSE_STREAM_FAILED, error); } @@ -53,7 +58,7 @@ export async function writeRuntimeInvokeResponse( await writeStreamingResponse(response, output, { metadata: ({ body: _body, ...metadata }) => metadata, summary, - fail: failure, + fail: (error) => failure(error, output.signal), binaryTtyError: "Binary or unknown response content requires --output-file or --json", }); } diff --git a/src/index.ts b/src/index.ts index aa2834b1b..f626f066d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,12 +91,14 @@ process.exit( await rootHandler.route(argv, context); } catch (e) { const error = AgentCoreCLIError.fromError(e); - rootLogger.child({ error: error.json() }).error(); - commandRunMetricEvent.setAttributes({ - exit_reason: "failure", - error_name: error.name, - error_source: error.source, - }); + if (error.exitCode !== 0) { + rootLogger.child({ error: error.json() }).error(); + commandRunMetricEvent.setAttributes({ + exit_reason: "failure", + error_name: error.name, + error_source: error.source, + }); + } throw error; } finally { try { diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index a695ca09e..5a282c42c 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { CommanderError } from "commander"; -import { AgentCoreCLIError, CommandInterruptedError, InputValidationError } from "../errors"; +import { AgentCoreCLIError, InputValidationError, UserCancellationError } from "../errors"; import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; async function captureErrors(run: () => Promise) { @@ -75,25 +75,12 @@ test.each([ ExitCode.USAGE, ["Error: bad request"], ], + ["user cancellation", new UserCancellationError(), ExitCode.INTERRUPTED, []], [ - "interruption", + "raw AbortError", Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), - ExitCode.INTERRUPTED, - ["AbortError: The operation was aborted"], - ], - [ - "classified interruption", - AgentCoreCLIError.fromError( - Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), - ), - ExitCode.INTERRUPTED, - ["AbortError: The operation was aborted"], - ], - [ - "reported command interruption", - new CommandInterruptedError(undefined, true), - ExitCode.INTERRUPTED, - [], + ExitCode.FAILURE, + ["Error: The operation was aborted"], ], [ "Commander parse failure", @@ -101,14 +88,6 @@ test.each([ ExitCode.USAGE, [], ], - [ - "classified Commander parse failure", - AgentCoreCLIError.fromError( - new CommanderError(1, "commander.invalidArgument", "invalid option"), - ), - ExitCode.USAGE, - [], - ], [ "Commander help", new CommanderError(0, "commander.helpDisplayed", "help displayed"), @@ -116,20 +95,8 @@ test.each([ [], ], [ - "classified Commander help", - AgentCoreCLIError.fromError(new CommanderError(0, "commander.helpDisplayed", "help displayed")), - ExitCode.SUCCESS, - [], - ], - [ - "classified reported failure", - AgentCoreCLIError.fromError(Object.assign(new Error("already reported"), { reported: true })), - ExitCode.FAILURE, - [], - ], - [ - "reported failure", - Object.assign(new Error("already reported"), { reported: true }), + "hidden failure", + new AgentCoreCLIError("already displayed", { displayMessage: false }), ExitCode.FAILURE, [], ], @@ -137,7 +104,7 @@ test.each([ "arbitrary TypeError", new TypeError("transport failed"), ExitCode.FAILURE, - ["TypeError: transport failed"], + ["Error: transport failed"], ], ])("runWithExitCode maps %s", async (_name, error, expected, expectedErrors) => { const result = await captureErrors(() => runWithExitCode(async () => Promise.reject(error))); diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 5cfc399f2..6977770c3 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,4 +1,3 @@ -import { CommanderError } from "commander"; import { AgentCoreCLIError } from "../errors"; // ExitCode provides names for default Unix exit codes. @@ -9,21 +8,6 @@ export enum ExitCode { INTERRUPTED = 130, } -function externallyHandledError(error: unknown): unknown { - if ((error as { reported?: boolean } | null)?.reported === true) return error; - if (!(error instanceof AgentCoreCLIError)) return error; - - const cause = error.cause; - if ( - cause instanceof CommanderError || - (cause as { reported?: boolean } | null)?.reported === true || - (cause as Error)?.name === "AbortError" - ) { - return cause; - } - return error; -} - // Runnable can be implemented by any application's main entrypoint. export interface Runnable { run(argv: string[]): Promise; @@ -48,20 +32,8 @@ export async function runWithExitCode( await fn(argv); return ExitCode.SUCCESS; } catch (caught) { - const error = externallyHandledError(caught); - if ( - !(error instanceof CommanderError) && - (error as { reported?: boolean } | null)?.reported !== true - ) { - const reported = error instanceof Error ? error : new Error(String(error)); - const name = reported instanceof AgentCoreCLIError ? "Error" : reported.name; - console.error(`${name}: ${reported.message}`); - } - if (error instanceof CommanderError) { - return error.exitCode === 0 ? ExitCode.SUCCESS : ExitCode.USAGE; - } - if ((error as Error)?.name === "AbortError") return ExitCode.INTERRUPTED; - if (caught instanceof AgentCoreCLIError) return caught.exitCode; - return ExitCode.FAILURE; + const error = AgentCoreCLIError.fromError(caught); + if (error.displayMessage) console.error(`Error: ${error.message}`); + return error.exitCode; } } From 118e2a61b3e794edf2aa7f0fc9a2e1e90a23136c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 12 Aug 2026 21:08:22 +0000 Subject: [PATCH 2/6] test(runtime): cover cancellation across invoke stages --- src/handlers/runtime/invoke/invoke.test.tsx | 58 ++++++++++++++++++++ src/handlers/runtime/invoke/response.test.ts | 27 +++++++++ 2 files changed, 85 insertions(+) diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index a0004bb54..2d732d097 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -247,6 +247,64 @@ describe("runtime invoke", () => { expect((invoke.args[0] as RuntimeInvokeRequest).payload).toEqual(new Uint8Array()); }); + test("SIGINT cancels payload stdin resolution with the typed reason", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const initialListeners = process.listenerCount("SIGINT"); + const pending = runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "-", + ]); + + try { + await waitFor(() => process.listenerCount("SIGINT") > initialListeners); + process.emit("SIGINT", "SIGINT"); + + await expect(pending).rejects.toBeInstanceOf(UserCancellationError); + expect(core.runtime.calls).toEqual([]); + } finally { + await pending.catch(() => undefined); + } + }); + + test("SIGINT replaces a raw Runtime lookup abort with the typed reason", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + core.runtime.getRuntime = async (id, options, signal) => { + core.runtime.calls.push({ method: "getRuntime", args: [id, options, signal] }); + return new Promise((_, reject) => { + const abort = () => + reject(Object.assign(new Error("lookup aborted"), { name: "AbortError" })); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }; + const pending = runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--payload", + "{}", + ]); + + try { + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntime")); + process.emit("SIGINT", "SIGINT"); + + const signal = core.runtime.calls[0]!.args[2] as AbortSignal; + expect(signal.reason).toBeInstanceOf(UserCancellationError); + await expect(pending).rejects.toBe(signal.reason); + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); + } finally { + await pending.catch(() => undefined); + } + }); + test("SIGINT aborts an active headless invocation after preserving emitted bytes", async () => { const core = new TestCoreClient(); const output = captureIO(); diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index aa0a6a645..a7b08f025 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -291,6 +291,33 @@ describe("Runtime invoke response output", () => { ); }); + test("JSON cancellation emits no partial envelope and preserves the typed reason", async () => { + const controller = new AbortController(); + const cancellation = new UserCancellationError(); + const stdout = capture(); + const stderr = capture(); + const source = (async function* () { + yield Buffer.from("partial"); + controller.abort(cancellation); + throw Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); + })(); + + await expect( + writeRuntimeInvokeResponse(response({ body: source }), { + stdout: stdout.stream, + stderr: stderr.stream, + json: true, + signal: controller.signal, + }), + ).rejects.toBe(cancellation); + expect(stdout.bytes()).toHaveLength(0); + expect(stderr.bytes().toString()).toBe( + "status=200 content-type=text/plain runtime-session-id=- mcp-session-id=- " + + "mcp-protocol-version=- trace-id=- trace-parent=- trace-state=- baggage=- " + + "complete=false bytes=7 error=interrupted\n", + ); + }); + test("preserves partial raw output regardless of response media type", async () => { const stdout = capture(); const stderr = capture(); From f8d25466473116634265fc5126a435cdae63edf4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 00:19:11 +0000 Subject: [PATCH 3/6] refactor(errors): model silent CLI errors by type --- src/errors/errors.test.tsx | 18 ++++++++++++------ src/errors/errors.tsx | 18 +++++++----------- src/errors/index.tsx | 1 + src/handlers/runtime/invoke/response.test.ts | 16 +++++++--------- src/runnable/index.test.ts | 14 +++++++------- src/runnable/index.tsx | 4 ++-- 6 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/errors/errors.test.tsx b/src/errors/errors.test.tsx index ec2aca07a..1cf7a8717 100644 --- a/src/errors/errors.test.tsx +++ b/src/errors/errors.test.tsx @@ -5,7 +5,12 @@ import { InternalServerException, } from "@aws-sdk/client-bedrock-agentcore-control"; import { CommanderError } from "commander"; -import { AgentCoreCLIError, InputValidationError, UserCancellationError } from "./errors"; +import { + AgentCoreCLIError, + InputValidationError, + SilentCLIError, + UserCancellationError, +} from "./errors"; describe("AgentCoreCLIError", () => { test("fromError preserves existing AgentCoreCLIError instances", () => { @@ -22,22 +27,24 @@ describe("AgentCoreCLIError", () => { ["parse failures", new CommanderError(1, "commander.invalidArgument", "invalid option"), 2], ["help", new CommanderError(0, "commander.helpDisplayed", "help displayed"), 0], ])("fromError classifies Commander %s", (_label, err, exitCode) => { - expect(AgentCoreCLIError.fromError(err).json()).toMatchObject({ + const result = AgentCoreCLIError.fromError(err); + expect(result).toBeInstanceOf(SilentCLIError); + expect(result.json()).toMatchObject({ name: "CommanderError", source: "user", exitCode, - displayMessage: false, meta: { code: err.code }, }); }); test("UserCancellationError is a silent user interruption", () => { - expect(new UserCancellationError().json()).toMatchObject({ + const error = new UserCancellationError(); + expect(error).toBeInstanceOf(SilentCLIError); + expect(error.json()).toMatchObject({ name: "UserCancellationError", message: "Operation cancelled by user", source: "user", exitCode: 130, - displayMessage: false, }); }); @@ -85,7 +92,6 @@ describe("AgentCoreCLIError", () => { name: "AgentCoreCLIError", source: "internal", message: expectedMessage, - displayMessage: true, }); }); }); diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index cb9a96c80..d755e2224 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -12,8 +12,6 @@ export interface AgentCoreCLIErrorOptions extends ErrorOptions { exitCode?: number; /** Describes the name of the underlying error, defaults to AgentCoreCLIError */ name?: string; - /** Whether the root handler should display this error's message */ - displayMessage?: boolean; } /** Base error for CLI failures, including their source, metadata, and process exit code. */ @@ -21,7 +19,6 @@ export class AgentCoreCLIError extends Error { readonly source: ErrorSource; readonly meta: Record; readonly exitCode: number; - readonly displayMessage: boolean; constructor(message?: string, options?: AgentCoreCLIErrorOptions) { super(message, options); @@ -29,7 +26,6 @@ export class AgentCoreCLIError extends Error { this.source = options?.source ?? ERROR_SOURCE.INTERNAL; this.meta = options?.meta ?? {}; this.exitCode = options?.exitCode ?? 1; - this.displayMessage = options?.displayMessage ?? true; } /** Convert the error into an object with its attributes enumerated as keys **/ json(): Record { @@ -38,7 +34,6 @@ export class AgentCoreCLIError extends Error { message: this.message, stack: this.stack, exitCode: this.exitCode, - displayMessage: this.displayMessage, meta: this.meta, source: this.source, }; @@ -48,13 +43,12 @@ export class AgentCoreCLIError extends Error { if (error instanceof AgentCoreCLIError) return error; if (error instanceof CommanderError) { - return new AgentCoreCLIError(error.message, { + return new SilentCLIError(error.message, { cause: error, source: ERROR_SOURCE.USER, name: error.name, meta: { code: error.code }, exitCode: error.exitCode === 0 ? 0 : 2, - displayMessage: false, }); } @@ -79,6 +73,9 @@ export class AgentCoreCLIError extends Error { } } +/** Base for CLI errors intentionally omitted from root stderr output. */ +export class SilentCLIError extends AgentCoreCLIError {} + /** Error raised for invalid user input. */ export class InputValidationError extends AgentCoreCLIError { constructor(message?: string, options?: Omit) { @@ -156,19 +153,18 @@ export class EmbeddedAssetNotFoundError extends AgentCoreCLIError { } /** Raised when a user intentionally cancels a headless CLI operation. */ -export class UserCancellationError extends AgentCoreCLIError { +export class UserCancellationError extends SilentCLIError { constructor() { super("Operation cancelled by user", { source: ERROR_SOURCE.USER, exitCode: 130, - displayMessage: false, }); } } -export class RuntimeInvokeResponseError extends AgentCoreCLIError { +export class RuntimeInvokeResponseError extends SilentCLIError { constructor(message: string, cause?: unknown) { - super(message, { cause, displayMessage: false }); + super(message, { cause }); } } diff --git a/src/errors/index.tsx b/src/errors/index.tsx index 7380cb860..ad57238a2 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -16,6 +16,7 @@ export { ResourceNotFoundError, ResultTruncationError, RuntimeInvokeResponseError, + SilentCLIError, SourceResolutionError, UserCancellationError, type AgentCoreCLIErrorOptions, diff --git a/src/handlers/runtime/invoke/response.test.ts b/src/handlers/runtime/invoke/response.test.ts index a7b08f025..8b2850ca5 100644 --- a/src/handlers/runtime/invoke/response.test.ts +++ b/src/handlers/runtime/invoke/response.test.ts @@ -3,7 +3,7 @@ import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; -import { UserCancellationError } from "../../../errors"; +import { SilentCLIError, UserCancellationError } from "../../../errors"; import { waitFor } from "../../../testing"; import type { RuntimeInvokeResponse } from "../types"; import { writeRuntimeInvokeResponse } from "./response"; @@ -122,15 +122,13 @@ describe("Runtime invoke response output", () => { }, }) as unknown as NodeJS.WriteStream; - await expect( - writeRuntimeInvokeResponse(response(), { - stdout: stdout.stream, - stderr, - }), - ).rejects.toMatchObject({ - message: "response stream failed", - displayMessage: false, + const pending = writeRuntimeInvokeResponse(response(), { + stdout: stdout.stream, + stderr, }); + + await expect(pending).rejects.toBeInstanceOf(SilentCLIError); + await expect(pending).rejects.toThrow("response stream failed"); }); test("streams exact bytes to a file and leaves stdout empty", async () => { diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index 5a282c42c..d8c781e0a 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,7 +1,12 @@ import { expect, spyOn, test } from "bun:test"; import { CommanderError } from "commander"; -import { AgentCoreCLIError, InputValidationError, UserCancellationError } from "../errors"; +import { + AgentCoreCLIError, + InputValidationError, + SilentCLIError, + UserCancellationError, +} from "../errors"; import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; async function captureErrors(run: () => Promise) { @@ -94,12 +99,7 @@ test.each([ ExitCode.SUCCESS, [], ], - [ - "hidden failure", - new AgentCoreCLIError("already displayed", { displayMessage: false }), - ExitCode.FAILURE, - [], - ], + ["hidden failure", new SilentCLIError("already displayed"), ExitCode.FAILURE, []], [ "arbitrary TypeError", new TypeError("transport failed"), diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index 6977770c3..c2df8aaf3 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,4 +1,4 @@ -import { AgentCoreCLIError } from "../errors"; +import { AgentCoreCLIError, SilentCLIError } from "../errors"; // ExitCode provides names for default Unix exit codes. export enum ExitCode { @@ -33,7 +33,7 @@ export async function runWithExitCode( return ExitCode.SUCCESS; } catch (caught) { const error = AgentCoreCLIError.fromError(caught); - if (error.displayMessage) console.error(`Error: ${error.message}`); + if (!(error instanceof SilentCLIError)) console.error(`Error: ${error.message}`); return error.exitCode; } } From 9071f25108370461f11f7af36592603164dbe2fd Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 17 Aug 2026 17:34:39 +0000 Subject: [PATCH 4/6] refactor(errors): unify headless cancellation paths --- src/errors/errors.test.tsx | 12 +++++ src/errors/errors.tsx | 19 +++----- src/errors/index.tsx | 1 - src/handlers/eval/dataset/dataset.test.tsx | 36 +++++++++++++++ src/handlers/eval/dataset/update/index.tsx | 4 +- src/handlers/gateway/invoke/index.tsx | 9 ++-- src/handlers/gateway/invoke/invoke.test.tsx | 4 +- src/handlers/gateway/invoke/response.test.ts | 46 +++++++++++++++----- src/handlers/gateway/invoke/response.ts | 10 ++--- src/handlers/runtime/invoke/response.ts | 7 +-- src/io/streamingResponse.ts | 4 +- 11 files changed, 106 insertions(+), 46 deletions(-) diff --git a/src/errors/errors.test.tsx b/src/errors/errors.test.tsx index 1cf7a8717..3a21cf258 100644 --- a/src/errors/errors.test.tsx +++ b/src/errors/errors.test.tsx @@ -48,6 +48,18 @@ describe("AgentCoreCLIError", () => { }); }); + test("UserCancellationError resolves direct and signal-propagated cancellation", () => { + const cancellation = new UserCancellationError(); + const controller = new AbortController(); + controller.abort(cancellation); + + expect(UserCancellationError.resolve(cancellation)).toBe(cancellation); + expect(UserCancellationError.resolve(new Error("transport aborted"), controller.signal)).toBe( + cancellation, + ); + expect(UserCancellationError.resolve(new Error("failed"))).toBeUndefined(); + }); + test.each([ [ "AccessDeniedException (403)", diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index d755e2224..3a741ff24 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -160,6 +160,11 @@ export class UserCancellationError extends SilentCLIError { exitCode: 130, }); } + + static resolve(error: unknown, signal?: AbortSignal): UserCancellationError | undefined { + if (error instanceof UserCancellationError) return error; + return signal?.reason instanceof UserCancellationError ? signal.reason : undefined; + } } export class RuntimeInvokeResponseError extends SilentCLIError { @@ -168,19 +173,7 @@ export class RuntimeInvokeResponseError extends SilentCLIError { } } -export class GatewayInvokeInterruptedError extends AgentCoreCLIError { - readonly reported: boolean; - - constructor(cause?: unknown, reported = false) { - super("The operation was aborted", { cause, exitCode: 130 }); - this.name = "AbortError"; - this.reported = reported; - } -} - -export class GatewayInvokeResponseError extends AgentCoreCLIError { - readonly reported = true; - +export class GatewayInvokeResponseError extends SilentCLIError { constructor(message: string, cause?: unknown) { super(message, { cause }); } diff --git a/src/errors/index.tsx b/src/errors/index.tsx index ad57238a2..bf9b4b26d 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -4,7 +4,6 @@ export { DeserializationError, EmbeddedAssetNotFoundError, FileWriteError, - GatewayInvokeInterruptedError, GatewayInvokeResponseError, InputValidationError, InvalidEnvironmentError, diff --git a/src/handlers/eval/dataset/dataset.test.tsx b/src/handlers/eval/dataset/dataset.test.tsx index 11f8bc231..f746d3404 100644 --- a/src/handlers/eval/dataset/dataset.test.tsx +++ b/src/handlers/eval/dataset/dataset.test.tsx @@ -658,6 +658,42 @@ describe("dataset update", () => { }); }); + test("SIGINT cancels an update with the shared user cancellation error", async () => { + const path = writeTempJsonl(EXAMPLE_A); + const { core, route } = testDatasetCommand(); + core.eval.updateDatasetExamples = async (id, filePath, options, signal, onProgress) => { + core.eval.calls.push({ + method: "updateDatasetExamples", + args: [id, filePath, options, signal, onProgress], + }); + return new Promise((_, reject) => { + const abort = () => reject(signal?.reason); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }; + const pending = route([ + "eval", + "dataset", + "update", + "--id", + "dataset-orders-abc123", + "--file-path", + path, + ]); + + try { + await waitFor(() => core.eval.calls.some((call) => call.method === "updateDatasetExamples")); + process.emit("SIGINT", "SIGINT"); + + const signal = core.eval.calls[0]!.args[3] as AbortSignal; + expect(signal.reason).toBeInstanceOf(UserCancellationError); + await expect(pending).rejects.toBe(signal.reason); + } finally { + await pending.catch(() => undefined); + } + }); + test("takes only --id and --file-path", async () => { const root = createRootHandler(new TestCoreClient(), { io: testIO().io, diff --git a/src/handlers/eval/dataset/update/index.tsx b/src/handlers/eval/dataset/update/index.tsx index 9be5624e1..354b18c81 100644 --- a/src/handlers/eval/dataset/update/index.tsx +++ b/src/handlers/eval/dataset/update/index.tsx @@ -1,6 +1,6 @@ import z from "zod"; import { createHandler, flag } from "../../../../router"; -import { InputValidationError } from "../../../../errors"; +import { InputValidationError, UserCancellationError } from "../../../../errors"; import type { AppIO } from "../../../../io"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -21,7 +21,7 @@ export const createUpdateDatasetHandler = (core: Core, io: AppIO) => } const controller = new AbortController(); - const interrupt = () => controller.abort(); + const interrupt = () => controller.abort(new UserCancellationError()); process.once("SIGINT", interrupt); try { ctx diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx index de43905dc..e6c1f3df6 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -1,8 +1,8 @@ import z from "zod"; import { - GatewayInvokeInterruptedError, GatewayInvokeResponseError, InputValidationError, + UserCancellationError, } from "../../../errors"; import { SourceResolver, type AppIO } from "../../../io"; import { ExitCode } from "../../../runnable"; @@ -109,7 +109,7 @@ export const createInvokeGatewayHandler = ( } const controller = new AbortController(); - const interrupt = () => controller.abort(); + const interrupt = () => controller.abort(new UserCancellationError()); process.once("SIGINT", interrupt); try { const applicationHeaders = parseGatewayInvokeHeaders(flags.header); @@ -145,10 +145,7 @@ export const createInvokeGatewayHandler = ( throw new GatewayInvokeResponseError(`HTTP ${response.statusCode}`); } } catch (error) { - if (controller.signal.aborted && (error as Error)?.name === "AbortError") { - if (error instanceof GatewayInvokeInterruptedError) throw error; - throw new GatewayInvokeInterruptedError(error); - } + controller.signal.throwIfAborted(); throw error; } finally { controller.abort(); diff --git a/src/handlers/gateway/invoke/invoke.test.tsx b/src/handlers/gateway/invoke/invoke.test.tsx index 8bb8e4167..8155de60f 100644 --- a/src/handlers/gateway/invoke/invoke.test.tsx +++ b/src/handlers/gateway/invoke/invoke.test.tsx @@ -5,6 +5,7 @@ import { join } from "node:path"; import { PassThrough } from "node:stream"; import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import type { AppIO } from "../../../io"; +import { UserCancellationError } from "../../../errors"; import { ExitCode, runWithExitCode } from "../../../runnable"; import { createSilentLogger, @@ -485,11 +486,12 @@ describe("gateway invoke", () => { await waitFor(() => core.gateway.calls.some((call) => call.method === "invokeGateway")); process.emit("SIGINT", "SIGINT"); - await expect(pending).rejects.toMatchObject({ name: "AbortError", reported: false }); const lookupSignal = core.gateway.calls[0]!.args[2] as AbortSignal; const invokeSignal = core.gateway.calls[1]!.args[2] as AbortSignal; expect(lookupSignal).toBe(invokeSignal); expect(invokeSignal.aborted).toBe(true); + expect(invokeSignal.reason).toBeInstanceOf(UserCancellationError); + await expect(pending).rejects.toBe(invokeSignal.reason); } finally { await pending.catch(() => undefined); } diff --git a/src/handlers/gateway/invoke/response.test.ts b/src/handlers/gateway/invoke/response.test.ts index 1d6bf3dde..579240ac7 100644 --- a/src/handlers/gateway/invoke/response.test.ts +++ b/src/handlers/gateway/invoke/response.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { PassThrough } from "node:stream"; +import { SilentCLIError, UserCancellationError } from "../../../errors"; import type { GatewayInvokeResponse } from "../types"; import { writeGatewayInvokeResponse } from "./response"; @@ -103,17 +104,18 @@ describe("Gateway invoke response output", () => { const stderr = capture(); const upstream = new Error("secret upstream response"); - await expect( - writeGatewayInvokeResponse( - response({ - body: (async function* () { - yield Buffer.from("partial"); - throw upstream; - })(), - }), - { stdout: stdout.stream, stderr: stderr.stream }, - ), - ).rejects.toMatchObject({ message: "response stream failed", reported: true }); + const pending = writeGatewayInvokeResponse( + response({ + body: (async function* () { + yield Buffer.from("partial"); + throw upstream; + })(), + }), + { stdout: stdout.stream, stderr: stderr.stream }, + ); + + await expect(pending).rejects.toBeInstanceOf(SilentCLIError); + await expect(pending).rejects.toThrow("response stream failed"); expect(stdout.bytes().toString()).toBe("partial"); expect(stderr.bytes().toString()).toContain( @@ -121,4 +123,26 @@ describe("Gateway invoke response output", () => { ); expect(stderr.bytes().toString()).not.toContain(upstream.message); }); + + test("preserves the shared cancellation reason after reporting an interruption", async () => { + const controller = new AbortController(); + const cancellation = new UserCancellationError(); + const stdout = capture(); + const stderr = capture(); + const source = (async function* () { + yield Buffer.from("partial"); + controller.abort(cancellation); + throw Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); + })(); + + await expect( + writeGatewayInvokeResponse(response({ body: source }), { + stdout: stdout.stream, + stderr: stderr.stream, + signal: controller.signal, + }), + ).rejects.toBe(cancellation); + expect(stdout.bytes().toString()).toBe("partial"); + expect(stderr.bytes().toString()).toContain("complete=false bytes=7 error=interrupted"); + }); }); diff --git a/src/handlers/gateway/invoke/response.ts b/src/handlers/gateway/invoke/response.ts index 46672f9a1..b670d0b24 100644 --- a/src/handlers/gateway/invoke/response.ts +++ b/src/handlers/gateway/invoke/response.ts @@ -1,12 +1,12 @@ -import { GatewayInvokeInterruptedError, GatewayInvokeResponseError } from "../../../errors"; +import { GatewayInvokeResponseError, UserCancellationError } from "../../../errors"; import { writeStreamingResponse, type StreamingResponseOutput } from "../../../io"; import type { GatewayInvokeResponse } from "../types"; const RESPONSE_STREAM_FAILED = "response stream failed"; -function failure(error: unknown): never { - const interrupted = (error as Error)?.name === "AbortError"; - if (interrupted) throw new GatewayInvokeInterruptedError(error, true); +function failure(error: unknown, signal?: AbortSignal): never { + const cancellation = UserCancellationError.resolve(error, signal); + if (cancellation) throw cancellation; throw new GatewayInvokeResponseError(RESPONSE_STREAM_FAILED, error); } @@ -34,7 +34,7 @@ export async function writeGatewayInvokeResponse( await writeStreamingResponse(response, output, { metadata: ({ body: _body, ...metadata }) => metadata, summary, - fail: failure, + fail: (error) => failure(error, output.signal), binaryTtyError: "Binary or unknown response content requires --output-file or --json", }); } diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index c9f191dae..9c51911cd 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -22,13 +22,8 @@ export async function writeRuntimeInvokeFile( await writeStreamingResponseFile(response, path, signal, onBytes); } -function userCancellation(error: unknown, signal?: AbortSignal): UserCancellationError | undefined { - if (error instanceof UserCancellationError) return error; - return signal?.reason instanceof UserCancellationError ? signal.reason : undefined; -} - function failure(error: unknown, signal?: AbortSignal): never { - const cancellation = userCancellation(error, signal); + const cancellation = UserCancellationError.resolve(error, signal); if (cancellation) throw cancellation; throw new RuntimeInvokeResponseError(RESPONSE_STREAM_FAILED, error); } diff --git a/src/io/streamingResponse.ts b/src/io/streamingResponse.ts index e34d56529..80e997b7c 100644 --- a/src/io/streamingResponse.ts +++ b/src/io/streamingResponse.ts @@ -163,7 +163,9 @@ export async function writeStreamingResponse( response, byteCount, false, - (error as Error)?.name === "AbortError" ? "interrupted" : "response-stream-failed", + output.signal?.aborted || (error as Error)?.name === "AbortError" + ? "interrupted" + : "response-stream-failed", ), output.signal, writer.fail, From 5be9d9f1eccd1add1da931cd4412392b33d47cf9 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 17 Aug 2026 17:48:46 +0000 Subject: [PATCH 5/6] refactor(runnable): centralize user cancellation --- src/handlers/eval/dataset/get/index.tsx | 27 +++++------- src/handlers/eval/dataset/update/index.tsx | 30 ++++++------- src/handlers/gateway/invoke/index.tsx | 35 +++++---------- src/handlers/runtime/invoke/index.tsx | 31 +++++-------- src/runnable/index.test.ts | 51 +++++++++++++++++++++- src/runnable/index.tsx | 20 ++++++++- 6 files changed, 119 insertions(+), 75 deletions(-) diff --git a/src/handlers/eval/dataset/get/index.tsx b/src/handlers/eval/dataset/get/index.tsx index 8b88a95d9..4ee6be647 100644 --- a/src/handlers/eval/dataset/get/index.tsx +++ b/src/handlers/eval/dataset/get/index.tsx @@ -1,7 +1,8 @@ import z from "zod"; import { createHandler, flag } from "../../../../router"; -import { InputValidationError, UserCancellationError } from "../../../../errors"; +import { InputValidationError } from "../../../../errors"; import { JsonRendererKey } from "../../../../tui"; +import { withUserCancellation } from "../../../../runnable"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -24,33 +25,29 @@ export const createGetDatasetHandler = (core: Core) => ], handle: async (ctx, flags) => { if (!flags["id"]) throw new InputValidationError("required option '--id ' not specified"); + const datasetId = flags["id"]; const filePath = flags["file-path"]; if (!filePath) { ctx .require(JsonRendererKey) .renderJson( - await core.eval.getDataset(flags["id"], flags["version"], coreOptsFromCtx(ctx)), + await core.eval.getDataset(datasetId, flags["version"], coreOptsFromCtx(ctx)), ); return; } // --file-path downloads the contents via the presigned download URL in metadata - const controller = new AbortController(); - const interrupt = () => controller.abort(new UserCancellationError()); - process.once("SIGINT", interrupt); - try { - const response = await core.eval.downloadDataset( - flags["id"], + const response = await withUserCancellation((signal) => + core.eval.downloadDataset( + datasetId, flags["version"], filePath, coreOptsFromCtx(ctx), - controller.signal, - ); - // file is written in addition to the normal metadata output - ctx.require(JsonRendererKey).renderJson({ ...response, filePath }); - } finally { - process.removeListener("SIGINT", interrupt); - } + signal, + ), + ); + // file is written in addition to the normal metadata output + ctx.require(JsonRendererKey).renderJson({ ...response, filePath }); }, }); diff --git a/src/handlers/eval/dataset/update/index.tsx b/src/handlers/eval/dataset/update/index.tsx index 354b18c81..b0909dbb2 100644 --- a/src/handlers/eval/dataset/update/index.tsx +++ b/src/handlers/eval/dataset/update/index.tsx @@ -1,8 +1,9 @@ import z from "zod"; import { createHandler, flag } from "../../../../router"; -import { InputValidationError, UserCancellationError } from "../../../../errors"; +import { InputValidationError } from "../../../../errors"; import type { AppIO } from "../../../../io"; import { JsonRendererKey } from "../../../../tui"; +import { withUserCancellation } from "../../../../runnable"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -16,27 +17,24 @@ export const createUpdateDatasetHandler = (core: Core, io: AppIO) => ], handle: async (ctx, flags) => { if (!flags["id"]) throw new InputValidationError("required option '--id ' not specified"); + const datasetId = flags["id"]; if (!flags["file-path"]) { throw new InputValidationError("required option '--file-path ' not specified"); } + const filePath = flags["file-path"]; - const controller = new AbortController(); - const interrupt = () => controller.abort(new UserCancellationError()); - process.once("SIGINT", interrupt); - try { - ctx - .require(JsonRendererKey) - .renderJson( - await core.eval.updateDatasetExamples( - flags["id"], - flags["file-path"], + ctx + .require(JsonRendererKey) + .renderJson( + await withUserCancellation((signal) => + core.eval.updateDatasetExamples( + datasetId, + filePath, coreOptsFromCtx(ctx), - controller.signal, + signal, (event) => io.stderr.write(`${event.message}\n`), ), - ); - } finally { - process.removeListener("SIGINT", interrupt); - } + ), + ); }, }); diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx index e6c1f3df6..926fb98ab 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -1,11 +1,7 @@ import z from "zod"; -import { - GatewayInvokeResponseError, - InputValidationError, - UserCancellationError, -} from "../../../errors"; +import { GatewayInvokeResponseError, InputValidationError } from "../../../errors"; import { SourceResolver, type AppIO } from "../../../io"; -import { ExitCode } from "../../../runnable"; +import { ExitCode, withUserCancellation } from "../../../runnable"; import { createHandler, flag, PathKey } from "../../../router"; import { renderTuiAt } from "../../../tui"; import { JsonKey } from "../../keys"; @@ -107,21 +103,20 @@ export const createInvokeGatewayHandler = ( if (jsonOutput && flags["output-file"] !== undefined) { throw new InputValidationError("--json cannot be used with --output-file"); } + const gatewayId = flags.id; + const payload = flags.payload; - const controller = new AbortController(); - const interrupt = () => controller.abort(new UserCancellationError()); - process.once("SIGINT", interrupt); - try { + await withUserCancellation(async (signal) => { const applicationHeaders = parseGatewayInvokeHeaders(flags.header); const sources = await resolveGatewayInvokeSources( - { payload: flags.payload, bearerToken: flags["bearer-token"] }, + { payload, bearerToken: flags["bearer-token"] }, io.stdin, - controller.signal, + signal, ); const options = coreOptsFromCtx(ctx); - const gateway = await core.gateway.getGateway(flags.id, options, controller.signal); + const gateway = await core.gateway.getGateway(gatewayId, options, signal); const request = normalizeGatewayInvokeRequest(gateway, { - gatewayId: flags.id, + gatewayId, path: flags.path, method: flags.method as GatewayInvokeMethod | undefined, payload: sources.payload, @@ -133,23 +128,17 @@ export const createInvokeGatewayHandler = ( mcpSessionId: flags["mcp-session-id"], mcpProtocolVersion: flags["mcp-protocol-version"], }); - const response = await core.gateway.invokeGateway(request, options, controller.signal); + const response = await core.gateway.invokeGateway(request, options, signal); await writeGatewayInvokeResponse(response, { stdout: io.stdout, stderr: io.stderr, outputFile: flags["output-file"], json: jsonOutput, - signal: controller.signal, + signal, }); if (response.statusCode < 200 || response.statusCode >= 300) { throw new GatewayInvokeResponseError(`HTTP ${response.statusCode}`); } - } catch (error) { - controller.signal.throwIfAborted(); - throw error; - } finally { - controller.abort(); - process.off("SIGINT", interrupt); - } + }); }, }); diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 291bc8a7a..d19ad0103 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -1,11 +1,11 @@ import z from "zod"; -import { InputValidationError, UserCancellationError } from "../../../errors"; +import { InputValidationError } from "../../../errors"; import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import { JsonKey } from "../../keys"; -import { ExitCode } from "../../../runnable"; +import { ExitCode, withUserCancellation } from "../../../runnable"; import { renderTuiAt } from "../../../tui"; import { normalizeRuntimeInvokeRequest, @@ -104,20 +104,19 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => if (jsonOutput && flags["output-file"] !== undefined) { throw new InputValidationError("--json cannot be used with --output-file"); } - const controller = new AbortController(); - const interrupt = () => controller.abort(new UserCancellationError()); - process.once("SIGINT", interrupt); - try { + const runtimeId = flags.id; + const payload = flags.payload; + await withUserCancellation(async (signal) => { const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload, bearerToken: flags["bearer-token"] }, + { payload, bearerToken: flags["bearer-token"] }, io.stdin, - controller.signal, + signal, ); const options = coreOptsFromCtx(ctx); - const runtime = await core.runtime.getRuntime(flags.id, options, controller.signal); + const runtime = await core.runtime.getRuntime(runtimeId, options, signal); const request = normalizeRuntimeInvokeRequest(runtime, { - runtimeId: flags.id, + runtimeId, qualifier: flags.qualifier, payload: sources.payload, contentType: flags["content-type"], @@ -135,20 +134,14 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => traceState: flags["trace-state"], baggage: flags.baggage, }); - const response = await core.runtime.invokeRuntime(request, options, controller.signal); + const response = await core.runtime.invokeRuntime(request, options, signal); await writeRuntimeInvokeResponse(response, { stdout: io.stdout, stderr: io.stderr, outputFile: flags["output-file"], json: jsonOutput, - signal: controller.signal, + signal, }); - } catch (error) { - controller.signal.throwIfAborted(); - throw error; - } finally { - controller.abort(); - process.off("SIGINT", interrupt); - } + }); }, }); diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index d8c781e0a..7df59aa9e 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -7,7 +7,13 @@ import { SilentCLIError, UserCancellationError, } from "../errors"; -import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; +import { + ExitCode, + runRunnable, + runWithExitCode, + withUserCancellation, + type Runnable, +} from "./index.tsx"; async function captureErrors(run: () => Promise) { const errors: string[] = []; @@ -73,6 +79,49 @@ test("respects custom errors codes from known errors", async () => { expect(errors).toEqual(["Error: custom failure"]); }); +test("withUserCancellation returns the result and removes its SIGINT listener", async () => { + const initialListeners = process.listenerCount("SIGINT"); + let signal: AbortSignal | undefined; + + const result = await withUserCancellation(async (current) => { + signal = current; + return "done"; + }); + + expect(result).toBe("done"); + expect(signal?.aborted).toBe(true); + expect(process.listenerCount("SIGINT")).toBe(initialListeners); +}); + +test("withUserCancellation replaces transport aborts with the shared reason", async () => { + const initialListeners = process.listenerCount("SIGINT"); + let signal: AbortSignal | undefined; + const pending = withUserCancellation((current) => { + signal = current; + return new Promise((_, reject) => { + const abort = () => reject(new Error("transport aborted")); + if (current.aborted) abort(); + else current.addEventListener("abort", abort, { once: true }); + }); + }); + + process.emit("SIGINT", "SIGINT"); + + expect(signal?.reason).toBeInstanceOf(UserCancellationError); + await expect(pending).rejects.toBe(signal?.reason); + expect(process.listenerCount("SIGINT")).toBe(initialListeners); +}); + +test("withUserCancellation preserves non-cancellation failures", async () => { + const failure = new TypeError("operation failed"); + + await expect( + withUserCancellation(async () => { + throw failure; + }), + ).rejects.toBe(failure); +}); + test.each([ [ "explicit usage", diff --git a/src/runnable/index.tsx b/src/runnable/index.tsx index c2df8aaf3..f90ea2447 100644 --- a/src/runnable/index.tsx +++ b/src/runnable/index.tsx @@ -1,4 +1,4 @@ -import { AgentCoreCLIError, SilentCLIError } from "../errors"; +import { AgentCoreCLIError, SilentCLIError, UserCancellationError } from "../errors"; // ExitCode provides names for default Unix exit codes. export enum ExitCode { @@ -8,6 +8,24 @@ export enum ExitCode { INTERRUPTED = 130, } +/** Runs a headless operation with process SIGINT mapped to UserCancellationError. */ +export async function withUserCancellation(fn: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const interrupt = () => controller.abort(new UserCancellationError()); + process.once("SIGINT", interrupt); + try { + const result = await fn(controller.signal); + controller.signal.throwIfAborted(); + return result; + } catch (error) { + controller.signal.throwIfAborted(); + throw error; + } finally { + controller.abort(); + process.off("SIGINT", interrupt); + } +} + // Runnable can be implemented by any application's main entrypoint. export interface Runnable { run(argv: string[]): Promise; From 2e3832f76b539af6411c43ca9d880c8d1366591d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 17 Aug 2026 20:54:13 +0000 Subject: [PATCH 6/6] refactor(project): use shared cancellation error --- src/errors/index.tsx | 1 - src/handlers/project/dev/index.test.ts | 14 ++++++++------ src/handlers/project/dev/index.ts | 8 ++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/errors/index.tsx b/src/errors/index.tsx index bf9b4b26d..96bcab7ec 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -1,6 +1,5 @@ export { AgentCoreCLIError, - CommandInterruptedError, DeserializationError, EmbeddedAssetNotFoundError, FileWriteError, diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 5540bfde1..e345d0eb8 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; -import { InputValidationError, ResourceNotFoundError } from "../../../errors"; +import { + InputValidationError, + ResourceNotFoundError, + UserCancellationError, +} from "../../../errors"; import type { PortChecker } from "../../../io"; import { ProjectKey, ValueContext } from "../../../router"; import { testIO } from "../../../testing"; @@ -192,11 +196,9 @@ describe("project dev interruption", () => { codeZip.release(); expect(input.signal.aborted).toBe(true); - await expect(pending).rejects.toMatchObject({ - name: "AbortError", - reported: true, - exitCode: 130, - }); + expect(input.signal.reason).toBeInstanceOf(UserCancellationError); + await expect(pending).rejects.toBe(input.signal.reason); + expect((input.signal.reason as UserCancellationError).exitCode).toBe(130); expect(subject.io.stderr()).toBe("Shutting down…"); expect(process.listenerCount(signal)).toBe(before); }, diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 9787a0422..2492db672 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -2,9 +2,9 @@ import z from "zod"; import { resolveDevPort } from "../../../core/dev/port"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { - CommandInterruptedError, InputValidationError, ResourceNotFoundError, + UserCancellationError, } from "../../../errors"; import type { AppIO, PortChecker } from "../../../io"; import { createHandler, flag, ProjectKey } from "../../../router"; @@ -71,7 +71,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => const interrupt = () => { if (controller.signal.aborted) return; config.io.stderr.write("Shutting down…\n"); - controller.abort(); + controller.abort(new UserCancellationError()); }; const signals = ["SIGINT", "SIGTERM"] as const; @@ -114,8 +114,8 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => renderEvent(config.io, event, json); } } catch (error) { - if (!controller.signal.aborted) throw error; - throw new CommandInterruptedError(error, true); + controller.signal.throwIfAborted(); + throw error; } finally { for (const signal of signals) process.removeListener(signal, interrupt); }