Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion src/errors/errors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import {
ValidationException,
InternalServerException,
} from "@aws-sdk/client-bedrock-agentcore-control";
import { AgentCoreCLIError, InputValidationError } from "./errors";
import { CommanderError } from "commander";
import {
AgentCoreCLIError,
InputValidationError,
SilentCLIError,
UserCancellationError,
} from "./errors";

describe("AgentCoreCLIError", () => {
test("fromError preserves existing AgentCoreCLIError instances", () => {
Expand All @@ -17,6 +23,43 @@ 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) => {
const result = AgentCoreCLIError.fromError(err);
expect(result).toBeInstanceOf(SilentCLIError);
expect(result.json()).toMatchObject({
name: "CommanderError",
source: "user",
exitCode,
meta: { code: err.code },
});
});

test("UserCancellationError is a silent user interruption", () => {
const error = new UserCancellationError();
expect(error).toBeInstanceOf(SilentCLIError);
expect(error.json()).toMatchObject({
name: "UserCancellationError",
message: "Operation cancelled by user",
source: "user",
exitCode: 130,
});
});

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)",
Expand Down
51 changes: 27 additions & 24 deletions src/errors/errors.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -41,6 +42,16 @@ export class AgentCoreCLIError extends Error {
static fromError(error: unknown): AgentCoreCLIError {
if (error instanceof AgentCoreCLIError) return error;

if (error instanceof CommanderError) {
return new SilentCLIError(error.message, {
cause: error,
source: ERROR_SOURCE.USER,
name: error.name,
meta: { code: error.code },
exitCode: error.exitCode === 0 ? 0 : 2,
});
}

if (ServiceException.isInstance(error)) {
const httpStatusCode = error.$metadata.httpStatusCode;
const source =
Expand All @@ -62,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<AgentCoreCLIErrorOptions, "source">) {
Expand Down Expand Up @@ -138,39 +152,28 @@ export class EmbeddedAssetNotFoundError extends AgentCoreCLIError {
}
}

export class CommandInterruptedError extends AgentCoreCLIError {
readonly reported: boolean;
/** Raised when a user intentionally cancels a headless CLI operation. */
export class UserCancellationError extends SilentCLIError {
constructor() {
super("Operation cancelled by user", {
source: ERROR_SOURCE.USER,
exitCode: 130,
});
}

constructor(cause?: unknown, reported = false) {
super("The operation was aborted", { cause, exitCode: 130 });
this.name = "AbortError";
this.reported = reported;
static resolve(error: unknown, signal?: AbortSignal): UserCancellationError | undefined {
if (error instanceof UserCancellationError) return error;
return signal?.reason instanceof UserCancellationError ? signal.reason : undefined;
}
}

export class RuntimeInvokeInterruptedError extends CommandInterruptedError {}

export class RuntimeInvokeResponseError extends AgentCoreCLIError {
readonly reported = true;

export class RuntimeInvokeResponseError extends SilentCLIError {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are runtime invoke responses silent? I thought this was the error we get when the stream parsing fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is the error we get when response streaming fails. By the time it reaches the root, writeStreamingResponse has already written the sanitized incomplete response summary to stderr. Making this error silent just prevents a second generic Error: response stream failed line. It still goes through structured logging and telemetry.

constructor(message: string, cause?: unknown) {
super(message, { cause });
}
}

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 });
}
Expand Down
5 changes: 2 additions & 3 deletions src/errors/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
export {
AgentCoreCLIError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All @@ -15,9 +13,10 @@ export {
ProjectFileExistsError,
ResourceNotFoundError,
ResultTruncationError,
RuntimeInvokeInterruptedError,
RuntimeInvokeResponseError,
SilentCLIError,
SourceResolutionError,
UserCancellationError,
type AgentCoreCLIErrorOptions,
} from "./errors";
export { ERROR_SOURCE } from "./types";
73 changes: 73 additions & 0 deletions src/handlers/eval/dataset/dataset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
TestCoreClient,
TestGlobalConfigAccessor,
testIO,
waitFor,
} from "../../../testing";
import { UserCancellationError } from "../../../errors";
import { createRootHandler } from "../../index";
import type { CreateDatasetInput } from "../types";

Expand Down Expand Up @@ -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<never>((_, 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();

Expand Down Expand Up @@ -621,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<never>((_, 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,
Expand Down
25 changes: 11 additions & 14 deletions src/handlers/eval/dataset/get/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import z from "zod";
import { createHandler, flag } from "../../../../router";
import { InputValidationError } from "../../../../errors";
import { JsonRendererKey } from "../../../../tui";
import { withUserCancellation } from "../../../../runnable";
import type { Core } from "../../../types";
import { coreOptsFromCtx } from "../../../utils";

Expand All @@ -24,33 +25,29 @@ export const createGetDatasetHandler = (core: Core) =>
],
handle: async (ctx, flags) => {
if (!flags["id"]) throw new InputValidationError("required option '--id <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();
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 });
},
});
28 changes: 13 additions & 15 deletions src/handlers/eval/dataset/update/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router";
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";

Expand All @@ -16,27 +17,24 @@ export const createUpdateDatasetHandler = (core: Core, io: AppIO) =>
],
handle: async (ctx, flags) => {
if (!flags["id"]) throw new InputValidationError("required option '--id <id>' not specified");
const datasetId = flags["id"];
if (!flags["file-path"]) {
throw new InputValidationError("required option '--file-path <file-path>' not specified");
}
const filePath = flags["file-path"];

const controller = new AbortController();
const interrupt = () => controller.abort();
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);
}
),
);
},
});
Loading
Loading