diff --git a/src/core/feedback.tsx b/src/core/feedback.tsx new file mode 100644 index 000000000..136ccd822 --- /dev/null +++ b/src/core/feedback.tsx @@ -0,0 +1,332 @@ +import { createHash } from "node:crypto"; +import { stat, readFile } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { AgentCoreCLIError, ERROR_SOURCE, InputValidationError } from "../errors"; +import { PACKAGE_VERSION } from "../constants"; +import type { CoreFetch, CoreOptions } from "./types"; +import type { + CoreFeedbackClient, + FeedbackSubmissionResult, + SubmitFeedbackInput, +} from "../handlers/feedback/types"; + +// Aperture public feedback API. These are commercial-partition (.aws.dev) endpoints +// with no partition variant, so feedback is unavailable in GovCloud/China — carried +// over from the pre-refactor CLI, flagged here rather than silently. +const INGESTION_URL = "https://ingestion.aperture-public-api.feedback.console.aws.dev/form"; +const PRESIGN_URL = + "https://presignedurl.aperture-public-api.feedback.console.aws.dev/presignedurl"; +const FORM_CATEGORY = "AgentCore"; +const FORM_NAME = "CLI"; +const FORM_VERSION = "0.1.0"; +const LOCALE = "en_US"; +const REFERENCE = "agentcore-cli"; +const MESSAGE_QUESTION = "What feedback do you have for the AgentCore CLI"; +const ATTACHMENT_QUESTION = "Attachments"; +const MESSAGE_MAX_LENGTH = 1000; +const MAX_SCREENSHOT_BYTES = 100 * 1024 * 1024; +const ALLOWED_SCREENSHOT_EXTENSIONS = [".png", ".jpg", ".jpeg"] as const; + +// Rendered by the feedback command's consent prompt before every submission. +export const CONSENT_TEXT = + "All feedback submissions, including any uploaded text and images, are subject " + + "to the AWS Customer Agreement (https://aws.amazon.com/agreement/). By submitting " + + 'feedback, you agree that your submissions constitute "Suggestions" as defined ' + + "in the AWS Customer Agreement."; + +// Extends the CLI error hierarchy (the pre-refactor ApertureError extended plain +// Error, so telemetry classified it as unknown) so failures record error_source=service. +export class ApertureError extends AgentCoreCLIError { + constructor( + message: string, + readonly status?: number, + readonly body?: string, + ) { + super(message, { source: ERROR_SOURCE.SERVICE, name: "ApertureError" }); + } +} + +interface LoadedScreenshot { + buffer: Uint8Array; + fileName: string; + contentType: string; + sha256Base64: string; + size: number; +} + +interface ApertureCustomerResponse { + question: string; + pii: boolean; + response: + | { responseType: "textArea"; responseValue: string } + | { responseType: "fileUpload"; responseValue: string[] }; +} + +interface ApertureFormPayload { + category: string; + name: string; + version: string; + locale: string; + reference: string; + location: string; + customerResponses: ApertureCustomerResponse[]; + metadataList: { key: string; value: string }[]; +} + +export class FeedbackClient implements CoreFeedbackClient { + // Feedback posts to the Aperture public API via the injected fetch only; it makes + // no AWS SDK calls, so it does not take the AwsClients aggregate its siblings do. + constructor(private readonly fetch: CoreFetch) {} + + async submitFeedback( + input: SubmitFeedbackInput, + _options: CoreOptions, + ): Promise { + const message = input.message.trim(); + if (!message) { + throw new InputValidationError("Feedback message cannot be empty."); + } + if (message.length > MESSAGE_MAX_LENGTH) { + throw new InputValidationError( + `Feedback message must be ${MESSAGE_MAX_LENGTH} characters or fewer.`, + ); + } + + const userAgent = `AgentCoreCLI/${PACKAGE_VERSION} (${process.platform} ${os.release()}; node/${process.version})`; + + let screenshotReference: string | undefined; + if (input.screenshot) { + const file = await this.loadScreenshot(input.screenshot.path); + const presignedUrl = await this.fetchPresignedUrl( + { + category: FORM_CATEGORY, + name: FORM_NAME, + version: FORM_VERSION, + fileName: file.fileName, + fileSize: file.size, + uploadFileSHA256: file.sha256Base64, + }, + userAgent, + ); + await this.uploadFileToS3( + presignedUrl, + file.buffer, + file.contentType, + file.sha256Base64, + userAgent, + ); + screenshotReference = objectKeyFromPresignedUrl(presignedUrl); + } + + const payload = buildFeedbackPayload({ message, screenshotReference }); + const response = await this.submitForm(payload, userAgent); + return { + id: response.id, + timestamp: response.timestamp, + reference: response.reference, + }; + } + + // Aperture returns the presigned URL as a plain-text body (not JSON). + private async fetchPresignedUrl( + request: { + category: string; + name: string; + version: string; + fileName: string; + fileSize: number; + uploadFileSHA256: string; + }, + userAgent: string, + ): Promise { + const response = await this.fetch(PRESIGN_URL, { + method: "POST", + headers: { "content-type": "application/json", "user-agent": userAgent }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw new ApertureError( + `Failed to fetch screenshot upload URL (HTTP ${response.status}).`, + response.status, + await readBody(response), + ); + } + return (await response.text()).trim(); + } + + // Aperture's bucket policy requires the SHA-256 checksum headers and a tag + // marking the object as not yet AV-scanned; omitting either is rejected. + private async uploadFileToS3( + presignedUrl: string, + fileBuffer: Uint8Array, + contentType: string, + base64Sha256: string, + userAgent: string, + ): Promise { + const response = await this.fetch(presignedUrl, { + method: "PUT", + headers: { + "content-type": contentType, + "x-amz-checksum-algorithm": "SHA256", + "x-amz-checksum-sha256": base64Sha256, + "x-amz-tagging": "scanstatus=NOT_SCANNED", + "user-agent": userAgent, + }, + body: fileBuffer, + }); + if (!response.ok) { + throw new ApertureError( + `Failed to upload screenshot (HTTP ${response.status}).`, + response.status, + await readBody(response), + ); + } + } + + private async submitForm( + payload: ApertureFormPayload, + userAgent: string, + ): Promise { + const response = await this.fetch(INGESTION_URL, { + method: "POST", + headers: { "content-type": "application/json", "user-agent": userAgent }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const body = await readBody(response); + throw new ApertureError(mapStatusToMessage(response.status, body), response.status, body); + } + return (await response.json()) as FeedbackSubmissionResult; + } + + private async loadScreenshot(rawFilePath: string): Promise { + const filePath = expandTilde(rawFilePath); + + let stats: Awaited>; + try { + stats = await stat(filePath); + } catch (err) { + throw new InputValidationError( + `Could not read screenshot at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (stats.isDirectory()) { + throw new InputValidationError(`Screenshot path is a directory, not a file: ${filePath}`); + } + if (!stats.isFile()) { + throw new InputValidationError(`Screenshot path is not a regular file: ${filePath}`); + } + // Reject oversized files from stat before readFile, so a hostile/huge file + // is never loaded into memory just to be rejected. + if (stats.size > MAX_SCREENSHOT_BYTES) { + const sizeMb = (stats.size / (1024 * 1024)).toFixed(1); + throw new InputValidationError(`Screenshot is ${sizeMb} MB; maximum allowed size is 100 MB.`); + } + + const ext = path.extname(filePath).toLowerCase(); + if ( + !ALLOWED_SCREENSHOT_EXTENSIONS.includes(ext as (typeof ALLOWED_SCREENSHOT_EXTENSIONS)[number]) + ) { + throw new InputValidationError( + `Screenshot must be one of: ${ALLOWED_SCREENSHOT_EXTENSIONS.join(", ")}.`, + ); + } + + let buffer: Buffer; + try { + buffer = await readFile(filePath); + } catch (err) { + throw new InputValidationError( + `Could not read screenshot at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return { + buffer: new Uint8Array(buffer), + fileName: path.basename(filePath), + contentType: ext === ".png" ? "image/png" : "image/jpeg", + sha256Base64: createHash("sha256").update(buffer).digest("base64"), + size: buffer.byteLength, + }; + } +} + +// Expand a leading ~ / ~/... to $HOME. Node's fs APIs don't expand tildes (the +// shell normally does), so a quoted path like "~/shot.png" would otherwise ENOENT. +function expandTilde(filePath: string): string { + if (filePath === "~") return os.homedir(); + if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2)); + return filePath; +} + +// The presigned URL's path IS the S3 object key the form must reference; +// fabricating one client-side risks pointing at a nonexistent object if +// Aperture's bucket layout or region shifts. +function objectKeyFromPresignedUrl(presignedUrl: string): string { + try { + return decodeURIComponent(new URL(presignedUrl).pathname.replace(/^\/+/, "")); + } catch { + // A 2xx presign body that isn't a URL is a service fault, not a bare TypeError — + // classify it so telemetry attributes it to the service, not internal. + throw new ApertureError("Feedback service returned an invalid screenshot upload URL."); + } +} + +function buildFeedbackPayload(input: { + message: string; + screenshotReference?: string; +}): ApertureFormPayload { + const customerResponses: ApertureCustomerResponse[] = [ + { + question: MESSAGE_QUESTION, + pii: false, + response: { responseType: "textArea", responseValue: input.message }, + }, + ]; + if (input.screenshotReference) { + customerResponses.push({ + question: ATTACHMENT_QUESTION, + pii: true, + response: { responseType: "fileUpload", responseValue: [input.screenshotReference] }, + }); + } + + // Aperture rejects unknown metadata keys with HTTP 400; only cli-version and os + // are registered in the form template, so node version + mode ride in `location`. + return { + category: FORM_CATEGORY, + name: FORM_NAME, + version: FORM_VERSION, + locale: LOCALE, + reference: REFERENCE, + location: `agentcore-cli@${PACKAGE_VERSION} (${process.platform}; node ${process.version}; cli)`, + customerResponses, + metadataList: [ + { key: "cli-version", value: PACKAGE_VERSION }, + { key: "os", value: `${process.platform} ${os.release()}` }, + ], + }; +} + +function mapStatusToMessage(status: number, body: string): string { + switch (status) { + case 400: + return `Feedback service rejected the submission (HTTP 400). ${body || "Form payload may be malformed."}`; + case 412: + return "Feedback service is missing required headers (HTTP 412)."; + case 417: + return "Feedback service rejected the request content type (HTTP 417)."; + case 500: + return "Feedback service returned an internal error (HTTP 500). Please try again later."; + default: + return `Feedback service returned HTTP ${status}.`; + } +} + +async function readBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return ""; + } +} diff --git a/src/core/index.tsx b/src/core/index.tsx index 5a6bab9fb..e561664b7 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -3,6 +3,7 @@ import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore"; import { IAMClient } from "@aws-sdk/client-iam"; import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs"; import { EvalClient } from "./eval"; +import { FeedbackClient } from "./feedback"; import { GatewayClient } from "./gateway"; import { HarnessClient } from "./harness"; import { IdentityClient } from "./identity"; @@ -72,6 +73,7 @@ export class CoreClient implements AwsClients { readonly runtime: RuntimeClient; readonly gateway: GatewayClient; readonly eval: EvalClient; + readonly feedback: FeedbackClient; readonly observability: ObservabilityClient; readonly projectManager: ProjectManager; @@ -86,6 +88,7 @@ export class CoreClient implements AwsClients { const fetch = config.fetch ?? globalThis.fetch; this.runtime = new RuntimeClient(this, fetch, this.logger.child({ module: "runtime" })); this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" })); + this.feedback = new FeedbackClient(fetch); // EvalClient shares the injected fetch: dataset content is served from a // presigned S3 URL, outside the SDK seam the other operations use. The logger // is used for batch-evaluation result-log diagnostics. diff --git a/src/handlers/feedback/__fixtures__/shot.png b/src/handlers/feedback/__fixtures__/shot.png new file mode 100644 index 000000000..875245de1 Binary files /dev/null and b/src/handlers/feedback/__fixtures__/shot.png differ diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot.golden.json b/src/handlers/feedback/__fixtures__/submit-screenshot.golden.json new file mode 100644 index 000000000..a72012723 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot.golden.json @@ -0,0 +1,6 @@ +{ + "success": true, + "id": "9385c2bc-e013-4c31-a5c4-e430f221037a", + "timestamp": "2026-08-31T20:04:07.165808755Z", + "reference": "agentcore-cli" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.499b0768d488bbcf.json b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.499b0768d488bbcf.json new file mode 100644 index 000000000..0e218e8c7 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.499b0768d488bbcf.json @@ -0,0 +1,5 @@ +{ + "status": 200, + "statusText": "OK", + "body": "" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.92c60dabfb9be7f2.json b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.92c60dabfb9be7f2.json new file mode 100644 index 000000000..676884db3 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.92c60dabfb9be7f2.json @@ -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\"}" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.f7c65f1fef88f718.json b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.f7c65f1fef88f718.json new file mode 100644 index 000000000..7c0298d14 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-screenshot/Fetch.f7c65f1fef88f718.json @@ -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" +} diff --git a/src/handlers/feedback/__fixtures__/submit-text.golden.json b/src/handlers/feedback/__fixtures__/submit-text.golden.json new file mode 100644 index 000000000..ecd3e34af --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-text.golden.json @@ -0,0 +1,6 @@ +{ + "success": true, + "id": "395e1470-9d77-40a8-af81-9b1181bca976", + "timestamp": "2026-08-31T20:03:36.685002013Z", + "reference": "agentcore-cli" +} \ No newline at end of file diff --git a/src/handlers/feedback/__fixtures__/submit-text/Fetch.92c60dabfb9be7f2.json b/src/handlers/feedback/__fixtures__/submit-text/Fetch.92c60dabfb9be7f2.json new file mode 100644 index 000000000..f82879966 --- /dev/null +++ b/src/handlers/feedback/__fixtures__/submit-text/Fetch.92c60dabfb9be7f2.json @@ -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\"}" +} \ No newline at end of file diff --git a/src/handlers/feedback/feedback.fixture.test.tsx b/src/handlers/feedback/feedback.fixture.test.tsx new file mode 100644 index 000000000..728850dea --- /dev/null +++ b/src/handlers/feedback/feedback.fixture.test.tsx @@ -0,0 +1,118 @@ +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"; + +// Record with: RECORD=1 bun test src/handlers/feedback/feedback.fixture.test.tsx +// +// The two "submits …" tests are WRITES: a record run posts a REAL feedback +// submission to the Aperture public API (and, for the screenshot case, uploads +// shot.png through a real presigned S3 PUT) — there is no undo, same as the +// batch-evaluation evaluate/simulate fixtures that submit real jobs. After a +// record run, strip the X-Amz-* query from the recorded presign Fetch fixture +// so no signed URL is committed (the object-path key it replays on is unchanged). +// Every other run replays the committed fixtures offline. +// +// Aperture is the one Core path outside the AWS SDK `.send()` seam, so it is +// driven through the injected `fetch` (fixtureFetch) rather than the SDK +// factories. Each submit test uses its own fixture subdir because fixtureFetch +// keys on method+path only, and both submits POST to the same /form path. +const REGION = "us-east-1"; +const FIXTURES = join(import.meta.dir, "__fixtures__"); +const SHOT = join(FIXTURES, "shot.png"); + +function createFixtureCore(fetchDir: string): CoreClient { + const { createControlClient, createDataClient, createIamClient, createLogsClient } = + fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + createLogsClient, + logger: createSilentLogger(), + fetch: fixtureFetch(join(FIXTURES, fetchDir)), + }); +} + +// run drives the real router (parsing → consent → handler → CoreClient → +// Aperture fetch) against the fixture-backed clients and returns captured IO. +async function run( + args: string[], + opts: { fetchDir?: string; io?: TestIOOptions } = {}, +): Promise<{ stdout: string; stderr: string }> { + const io = testIO(opts.io); + const root = createRootHandler(createFixtureCore(opts.fetchDir ?? "unused"), { + 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"], + { fetchDir: "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 () => { + const { stdout } = await run( + [ + "[agentcore-cli golden fixture] screenshot submit — please ignore", + "--screenshot", + SHOT, + "--yes", + "--json", + ], + { fetchDir: "submit-screenshot" }, + ); + + matchGolden(FIXTURES, "submit-screenshot.golden.json", stdout); + expect(JSON.parse(stdout).success).toBe(true); + }, 120_000); + + // ── validation / consent errors (no network, no fixtures — like batch-evaluation's not-found) ── + + test("without --yes and without a TTY it fails rather than submitting", async () => { + await expect(run(["headless", "--json"])).rejects.toThrow(/--yes/); + }); + + test("declining the consent prompt cancels", async () => { + await expect(run(["no thanks"], { io: { isTTY: true, stdin: "n\n" } })).rejects.toBeInstanceOf( + UserCancellationError, + ); + }); + + test("an empty message is rejected", async () => { + await expect(run([" ", "--yes"])).rejects.toThrow(/cannot be empty/); + }); + + test("a message over 1000 characters is rejected", async () => { + await expect(run(["x".repeat(1001), "--yes"])).rejects.toThrow(/1000 characters/); + }); + + test("an explicitly-empty --screenshot is rejected", async () => { + await expect(run(["msg", "--screenshot", "", "--yes"])).rejects.toThrow( + /--screenshot requires a file path/, + ); + }); +}); diff --git a/src/handlers/feedback/index.tsx b/src/handlers/feedback/index.tsx new file mode 100644 index 000000000..d2f4ddaba --- /dev/null +++ b/src/handlers/feedback/index.tsx @@ -0,0 +1,86 @@ +import { createInterface } from "node:readline/promises"; +import z from "zod"; +import { argument, createHandler, flag } from "../../router"; +import { JsonRendererKey } from "../../tui"; +import { InputValidationError, UserCancellationError } from "../../errors"; +import { coreOptsFromCtx } from "../utils.tsx"; +import { JsonKey } from "../keys.tsx"; +import { CONSENT_TEXT } from "../../core/feedback"; +import type { Core } from "../types.tsx"; +import type { AppIO } from "../../io"; + +export const createFeedbackHandler = (core: Core, io: AppIO) => + createHandler({ + name: "feedback", + description: "Send feedback about the AgentCore CLI to the team.", + // Length/empty validation lives solely in core.submitFeedback so one code path + // guards every caller; the arg is unconstrained here beyond being a string. + arguments: [argument("message", "the feedback message to send", z.string())], + 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) => { + // An explicitly-empty --screenshot "" is a mistake, not "no screenshot": + // reject it rather than silently submitting without an attachment. + 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 core.feedback.submitFeedback( + { + message: args["message"], + screenshot: screenshotPath ? { path: screenshotPath } : undefined, + }, + coreOptsFromCtx(ctx), + ); + + ctx.require(JsonRendererKey).renderJson({ success: true, ...result }); + }, + }); + +// Mirrors project/remove's confirmRemoveAll: --yes bypasses the prompt, a +// non-interactive session (or --json) fails rather than submitting without +// consent, and a decline (or SIGINT) raises UserCancellationError. +async function confirmConsent(io: AppIO, jsonOutput: boolean, confirmed: boolean): Promise { + 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 { + // Prompt on stderr so --json / piped stdout stays a clean machine-readable stream. + const readline = createInterface({ input: io.stdin, output: io.stderr }); + try { + const cancelled = new Promise((_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(); + } +} diff --git a/src/handlers/feedback/types.tsx b/src/handlers/feedback/types.tsx new file mode 100644 index 000000000..f5536cdd9 --- /dev/null +++ b/src/handlers/feedback/types.tsx @@ -0,0 +1,26 @@ +import type { CoreOptions } from "../../core/types"; + +export interface ScreenshotInput { + path: string; +} + +export interface SubmitFeedbackInput { + message: string; + screenshot?: ScreenshotInput; +} + +export interface FeedbackSubmissionResult { + id: string; + timestamp: string; + reference: string; +} + +// Consumer-defined interface (dependency inversion): the handler depends on this, +// src/core/feedback.tsx implements it. Message/screenshot validation happens inside +// submitFeedback so the one code path guards every caller. +export interface CoreFeedbackClient { + submitFeedback( + input: SubmitFeedbackInput, + options: CoreOptions, + ): Promise; +} diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index a2ff49e03..cd03e75f2 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -1,5 +1,6 @@ import { Router } from "../router"; import { createEvalHandler } from "./eval/index.tsx"; +import { createFeedbackHandler } from "./feedback/index.tsx"; import { createGatewayHandler } from "./gateway/index.tsx"; import { createHarnessHandler } from "./harness/index.tsx"; import { createIdentityHandler } from "./identity/index.tsx"; @@ -53,6 +54,7 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createMemoryHandler(core, io)); root.handler(createGatewayHandler(core, io)); root.handler(createEvalHandler(core, io)); + root.handler(createFeedbackHandler(core, io)); root.handler(createConfigHandler()); root.handler(createProjectHandler({ core, io })); diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index b3f4e3386..63f5dfc1c 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -17,6 +17,7 @@ describe("createRootHandler", () => { "memory", "gateway", "eval", + "feedback", "config", "project", ]); diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index 3d76827b8..ccf802421 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -1,4 +1,5 @@ import type { CoreEvalClient } from "./eval/types.tsx"; +import type { CoreFeedbackClient } from "./feedback/types.tsx"; import type { CoreGatewayClient } from "./gateway/types.tsx"; import type { CoreHarnessClient } from "./harness/types.tsx"; import type { CoreIdentityClient } from "./identity/types.tsx"; @@ -15,6 +16,7 @@ export interface Core { runtime: CoreRuntimeClient; gateway: CoreGatewayClient; eval: CoreEvalClient; + feedback: CoreFeedbackClient; observability: CoreObservabilityClient; projectManager: ProjectManager; /** Describes a Bedrock Agent + alias for `--type import`. */ diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index a6da20a9e..b4833c57b 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -126,6 +126,11 @@ import type { UpdateOauth2CredentialProviderInput, } from "../handlers/identity/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; +import type { + CoreFeedbackClient, + FeedbackSubmissionResult, + SubmitFeedbackInput, +} from "../handlers/feedback/types"; import type { CoreObservabilityClient, CoreRuntimeClient, @@ -2297,6 +2302,36 @@ export class TestObservabilityClient implements CoreObservabilityClient { } } +// TestFeedbackClient is the feedback sub-client of TestCoreClient. +export class TestFeedbackClient implements CoreFeedbackClient { + readonly calls: RecordedCall[] = []; + private response: FeedbackSubmissionResult = { + id: "feedback-test-id", + timestamp: "2026-01-01T00:00:00Z", + reference: "agentcore-cli", + }; + private error?: Error; + + setSubmitResponse(response: FeedbackSubmissionResult): this { + this.response = response; + return this; + } + + setError(error: Error | undefined): this { + this.error = error; + return this; + } + + async submitFeedback( + input: SubmitFeedbackInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "submitFeedback", args: [input, options] }); + if (this.error) throw this.error; + return this.response; + } +} + // TestCoreClient implements the Core contract with fully controllable sub-clients. export class TestCoreClient implements Core { readonly harness = new TestHarnessClient(); @@ -2305,6 +2340,7 @@ export class TestCoreClient implements Core { readonly runtime = new TestRuntimeClient(); readonly gateway = new TestGatewayClient(); readonly eval = new TestEvalClient(); + readonly feedback = new TestFeedbackClient(); readonly observability = new TestObservabilityClient(); readonly projectManager: ProjectManager;