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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class CoreClient implements AwsClients {

readonly projectManager: ProjectManager;
readonly describeBedrockAgent: DescribeBedrockAgent;
readonly fetch: CoreFetch;

constructor(config: CoreClientConfig) {
this.createControlClient = config.createControlClient;
Expand All @@ -84,6 +85,7 @@ export class CoreClient implements AwsClients {
this.createLogsClient = config.createLogsClient;
this.logger = config.logger;
const fetch = config.fetch ?? globalThis.fetch;
this.fetch = fetch;
this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" }));
this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" }));
// EvalClient shares the injected fetch: dataset content is served from a
Expand Down
Binary file added src/handlers/feedback/__fixtures__/shot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"success": true,
"id": "9385c2bc-e013-4c31-a5c4-e430f221037a",
"timestamp": "2026-08-31T20:04:07.165808755Z",
"reference": "agentcore-cli"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"status": 200,
"statusText": "OK",
"body": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"status": 200,
"statusText": "OK",
"body": "{\"reference\":\"agentcore-cli\",\"id\":\"9385c2bc-e013-4c31-a5c4-e430f221037a\",\"timestamp\":\"2026-08-31T20:04:07.165808755Z\"}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"status": 200,
"statusText": "OK",
"body": "https://aperture-forms-uploaded-files-prod-us-east-1.s3.amazonaws.com/us-east-1/AgentCore/CLI/0.1.0/31082026/3066c98d-4cda-42d5-ad1e-b00e8f69b96f.png"
}
6 changes: 6 additions & 0 deletions src/handlers/feedback/__fixtures__/submit-text.golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"success": true,
"id": "395e1470-9d77-40a8-af81-9b1181bca976",
"timestamp": "2026-08-31T20:03:36.685002013Z",
"reference": "agentcore-cli"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"status": 200,
"statusText": "OK",
"body": "{\"reference\":\"agentcore-cli\",\"id\":\"395e1470-9d77-40a8-af81-9b1181bca976\",\"timestamp\":\"2026-08-31T20:03:36.685002013Z\"}"
}
178 changes: 178 additions & 0 deletions src/handlers/feedback/feedback.fixture.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { CoreClient } from "../../core";
import { createRootHandler } from "../index";
import {
createSilentLogger,
fixtureFactories,
fixtureFetch,
matchGolden,
TestGlobalConfigAccessor,
testIO,
type TestIOOptions,
} from "../../testing";
import { UserCancellationError } from "../../errors";
import { ApertureError } from "./submit";
import type { CoreFetch } from "../../core/types";

const REGION = "us-east-1";
const FIXTURES = join(import.meta.dir, "__fixtures__");
const SHOT = join(FIXTURES, "shot.png");

const neverFetch = (async () => {
throw new Error("network should not be reached");
}) as unknown as CoreFetch;

async function run(
args: string[],
opts: { fetch?: CoreFetch; io?: TestIOOptions } = {},
): Promise<{ stdout: string; stderr: string }> {
const io = testIO(opts.io);
const core = new CoreClient({
...fixtureFactories(FIXTURES),
logger: createSilentLogger(),
fetch: opts.fetch ?? neverFetch,
});
const root = createRootHandler(core, {
io: io.io,
logger: createSilentLogger(),
globalConfigAccessor: new TestGlobalConfigAccessor(),
});
await root.route(["node", "agentcore", "feedback", ...args, "--region", REGION]);
return { stdout: io.stdout(), stderr: io.stderr() };
}

describe("feedback (fixture-backed)", () => {
test("submits text-only feedback and prints the result envelope", async () => {
const { stdout } = await run(
["[agentcore-cli golden fixture] text submit — please ignore", "--yes", "--json"],
{ fetch: fixtureFetch(join(FIXTURES, "submit-text")) },
);

matchGolden(FIXTURES, "submit-text.golden.json", stdout);
const result = JSON.parse(stdout);
expect(result.success).toBe(true);
expect(typeof result.id).toBe("string");
expect(result.reference).toBe("agentcore-cli");
}, 120_000);

test("submits feedback with a screenshot (presign → S3 PUT → form)", async () => {

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.

I do not think this golden test proves the request contract in its name. fixtureFetch keys only on method/path and ignores request headers and bodies; I replayed the PUT with no checksum/tagging headers and the form POST with {}, and both returned 200. I think we should keep the golden flow but restore a focused injected-fetch test for the checksum headers, scanstatus=NOT_SCANNED, and attachment object key.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure, I'll add a mock test for this.

const { stdout } = await run(
[
"[agentcore-cli golden fixture] screenshot submit — please ignore",
"--screenshot",
SHOT,
"--yes",
"--json",
],
{ fetch: fixtureFetch(join(FIXTURES, "submit-screenshot")) },
);

matchGolden(FIXTURES, "submit-screenshot.golden.json", stdout);
expect(JSON.parse(stdout).success).toBe(true);
}, 120_000);

test("without --yes and without a TTY it fails rather than submitting", async () => {
await expect(run(["headless", "--json"], { fetch: neverFetch })).rejects.toThrow(/--yes/);
});

test("declining the consent prompt cancels", async () => {
await expect(
run(["no thanks"], { fetch: neverFetch, io: { isTTY: true, stdin: "n\n" } }),
).rejects.toBeInstanceOf(UserCancellationError);
});

test("an empty message is rejected", async () => {
await expect(run([" ", "--yes"], { fetch: neverFetch })).rejects.toThrow(/cannot be empty/);
});

test("a message over 1000 characters is rejected", async () => {
await expect(run(["x".repeat(1001), "--yes"], { fetch: neverFetch })).rejects.toThrow(
/1000 characters/,
);
});

test("an explicitly-empty --screenshot is rejected", async () => {
await expect(run(["msg", "--screenshot", "", "--yes"], { fetch: neverFetch })).rejects.toThrow(
/--screenshot requires a file path/,
);
});
});

type Recorded = { url: string; method: string; headers: Headers; body: unknown };

function capturingFetch(canned: { presign?: string; form?: string; formStatus?: number }): {
fetch: CoreFetch;
calls: Recorded[];
} {
const calls: Recorded[] = [];
const fetch = (async (input: Parameters<CoreFetch>[0], init?: Parameters<CoreFetch>[1]) => {
const url = String(input);
calls.push({
url,
method: init?.method ?? "GET",
headers: new Headers(init?.headers),
body: init?.body,
});
if (url.includes("/presignedurl")) {
return new Response(canned.presign ?? "", { status: 200 });
}
if (url.includes("/form")) {
return new Response(canned.form ?? "{}", {
status: canned.formStatus ?? 200,
headers: { "content-type": "application/json" },
});
}
return new Response(null, { status: 200 });
}) as unknown as CoreFetch;
return { fetch, calls };
}

const OK_FORM = JSON.stringify({
reference: "agentcore-cli",
id: "id-1",
timestamp: "2026-01-01T00:00:00Z",
});
const PRESIGN_URL =
"https://bucket.s3.us-east-1.amazonaws.com/us-east-1/AgentCore/CLI/0.1.0/13052026/abc-123.png?X-Amz-Signature=sig";

describe("feedback (request contract)", () => {
test("screenshot flow sends checksum + NOT_SCANNED headers and the parsed object key", async () => {
const { fetch, calls } = capturingFetch({ presign: PRESIGN_URL, form: OK_FORM });
await run(["with shot", "--screenshot", SHOT, "--yes", "--json"], { fetch });

expect(
calls.map((c) =>
c.url.includes("/presignedurl") ? "presign" : c.url.includes("/form") ? "form" : "s3",
),
).toEqual(["presign", "s3", "form"]);

const put = calls[1]!;
expect(put.method).toBe("PUT");
expect(put.headers.get("x-amz-checksum-algorithm")).toBe("SHA256");
expect(put.headers.get("x-amz-checksum-sha256")).toBeTruthy();
expect(put.headers.get("x-amz-tagging")).toBe("scanstatus=NOT_SCANNED");

const form = JSON.parse(String(calls[2]!.body));
const attachment = form.customerResponses.find(
(r: { response: { responseType: string } }) => r.response.responseType === "fileUpload",
);
expect(attachment.response.responseValue).toEqual([
"us-east-1/AgentCore/CLI/0.1.0/13052026/abc-123.png",
]);
});

test("a malformed form response is rejected as an ApertureError", async () => {
const { fetch } = capturingFetch({ form: "{}" });
await expect(run(["hi", "--yes", "--json"], { fetch })).rejects.toBeInstanceOf(ApertureError);
});

test("an invalid presign body fails before the upload (no PUT)", async () => {
const { fetch, calls } = capturingFetch({ presign: "not-a-url", form: OK_FORM });
await expect(
run(["with shot", "--screenshot", SHOT, "--yes", "--json"], { fetch }),
).rejects.toBeInstanceOf(ApertureError);
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toContain("/presignedurl");
});
});
77 changes: 77 additions & 0 deletions src/handlers/feedback/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { createInterface } from "node:readline/promises";
import z from "zod";
import { argument, createHandler, flag } from "../../router";
import { JsonRendererKey } from "../../tui";
import { JsonKey } from "../keys.tsx";
import { InputValidationError, UserCancellationError } from "../../errors";
import { CONSENT_TEXT, submitFeedback } from "./submit";
import type { AppIO } from "../../io";
import type { Core } from "../types.tsx";

export const createFeedbackHandler = (core: Core, io: AppIO) =>
createHandler({
name: "feedback",
description: "Send feedback about the AgentCore CLI to the team.",
arguments: [argument("message", "the feedback message to send", z.string())],

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.

Is the no-argument feedback wizard intentionally out of scope? Released v0.28.1 uses optional [message] and opens FeedbackScreen; this makes <message> required, and I confirmed bare agentcore feedback now exits 2. If this is staged, I think the compatibility gap should be tracked or called out; otherwise this should preserve the optional route.

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.

Let's not increase scope. Leaving out the TUI makes sense here.

flags: [
flag(
"screenshot",
"path to a PNG or JPG screenshot to attach (max 100MB)",
z.string().optional(),
),
flag(
"yes",
"accept the AWS Customer Agreement and skip the consent prompt",
z.boolean().default(false),
),
],
handle: async (ctx, flags, args) => {
const screenshotPath = flags["screenshot"];
if (screenshotPath !== undefined && screenshotPath.trim() === "") {
throw new InputValidationError("--screenshot requires a file path");
}

await confirmConsent(io, ctx.require(JsonKey), flags.yes);

const result = await submitFeedback(
{
message: args["message"],
screenshot: screenshotPath ? { path: screenshotPath } : undefined,
},
core.fetch,
);

ctx.require(JsonRendererKey).renderJson({ success: true, ...result });
},
});

async function confirmConsent(io: AppIO, jsonOutput: boolean, confirmed: boolean): Promise<void> {
if (confirmed) return;
const canPrompt = !jsonOutput && io.stdin.isTTY && io.stdout.isTTY && io.stderr.isTTY;
if (!canPrompt) {
throw new InputValidationError(
"submitting feedback requires accepting the AWS Customer Agreement; re-run with --yes to confirm non-interactively",
);
}
if (!(await promptForConsent(io))) {
throw new UserCancellationError();
}
}

async function promptForConsent(io: AppIO): Promise<boolean> {
const readline = createInterface({ input: io.stdin, output: io.stderr });
try {
const cancelled = new Promise<never>((_resolve, reject) => {
const cancel = () => reject(new UserCancellationError());
readline.once("SIGINT", cancel);
readline.once("close", cancel);
});
const answer = await Promise.race([
readline.question(`\n${CONSENT_TEXT}\n\nSubmit feedback? (y/N) `),
cancelled,
]);
return /^(?:y|yes)$/i.test(answer.trim());
} finally {
readline.close();
}
}
Loading
Loading