Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -137,39 +151,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
ContributorAuthor

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 numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
export {
AgentCoreCLIError,
CloudWatchQueryError,
CommandInterruptedError,
DeserializationError,
EmbeddedAssetNotFoundError,
FileWriteError,
GatewayInvokeInterruptedError,
GatewayInvokeResponseError,
InputValidationError,
InvalidEnvironmentError,
Expand All@@ -16,9 +14,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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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