From fb257f0ad76d2baea9458fe4629fcd166b01eda7 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 24 Sep 2026 20:27:32 +0000 Subject: [PATCH 1/7] feat(invoke)!: overload invoke for project and existing Runtimes, harnesses, and Gateways BREAKING CHANGE: the invoke runtime, invoke harness, runtime invoke, harness invoke, and gateway invoke commands are removed. Use agentcore invoke with --runtime, --harness, or --gateway, which accept a project name, ID, or ARN. --- CONTRIBUTING.md | 2 +- README.md | 6 +- e2eTest/project/templates.test.ts | 8 +- .../agent-python-langchain/README.md | 4 +- .../templates/agent-python-minimal/README.md | 2 +- .../templates/agent-python-strands/README.md | 2 +- .../agent-typescript-strands/README.md | 2 +- .../templates/agui-python-strands/README.md | 2 +- .../templates/export-harness-python/README.md | 2 +- .../templates/mcp-python-fastmcp/README.md | 2 +- src/components/CliOnlyScreen.test.tsx | 12 +- src/components/Root.tsx | 16 +- src/components/RouterScreen.test.tsx | 2 +- .../eval/batch-evaluation/simulate/index.tsx | 2 +- src/handlers/eval/types.tsx | 2 +- .../gateway/gateway.mutations.test.tsx | 1 - src/handlers/gateway/gateway.screen.test.tsx | 2 +- src/handlers/gateway/gateway.test.tsx | 1 - src/handlers/gateway/index.tsx | 4 +- src/handlers/gateway/invoke/index.tsx | 151 ----- .../gateway/invoke/invoke.screen.test.tsx | 12 +- src/handlers/gateway/invoke/request.ts | 5 - src/handlers/gateway/invoke/screen.tsx | 18 +- .../harness/exec/exec.screen.test.tsx | 2 +- src/handlers/harness/get/get.screen.test.tsx | 2 +- src/handlers/harness/get/screen.tsx | 2 +- src/handlers/harness/index.tsx | 3 - src/handlers/harness/invoke/index.tsx | 68 -- .../harness/invoke/invoke.screen.test.tsx | 18 +- src/handlers/harness/invoke/screen.tsx | 14 +- .../invoke/gateway.test.tsx} | 193 +++--- src/handlers/project/invoke/gateway.tsx | 113 ++++ .../invoke/harness.test.tsx} | 45 +- src/handlers/project/invoke/harness.tsx | 103 ++- src/handlers/project/invoke/index.test.tsx | 591 ++++++++---------- src/handlers/project/invoke/index.tsx | 289 ++++++++- .../project/invoke/invoke.screen.test.tsx | 110 ++-- .../invoke/runtime.test.tsx} | 245 +++----- src/handlers/project/invoke/runtime.tsx | 430 ++++++------- src/handlers/project/invoke/screen.tsx | 76 ++- src/handlers/project/selection.ts | 21 +- src/handlers/project/types.ts | 9 +- .../runtime/endpoint/endpoint.screen.test.tsx | 2 +- src/handlers/runtime/endpoint/get/screen.tsx | 2 +- src/handlers/runtime/get/screen.tsx | 2 +- src/handlers/runtime/index.tsx | 4 +- src/handlers/runtime/invoke/index.tsx | 158 ----- .../runtime/invoke/invoke.screen.test.tsx | 20 +- src/handlers/runtime/invoke/screen.tsx | 12 +- src/handlers/runtime/runtime.test.tsx | 1 - src/handlers/utils.test.tsx | 156 +++++ src/handlers/utils.tsx | 62 +- src/middleware/withProject.tsx | 5 +- src/router/flags.tsx | 8 +- src/router/handler.tsx | 22 +- src/router/index.tsx | 3 + src/router/router.test.ts | 21 + src/router/router.tsx | 15 +- 58 files changed, 1559 insertions(+), 1528 deletions(-) delete mode 100644 src/handlers/gateway/invoke/index.tsx delete mode 100644 src/handlers/harness/invoke/index.tsx rename src/handlers/{gateway/invoke/invoke.test.tsx => project/invoke/gateway.test.tsx} (78%) create mode 100644 src/handlers/project/invoke/gateway.tsx rename src/handlers/{harness/invoke/invoke.test.tsx => project/invoke/harness.test.tsx} (84%) rename src/handlers/{runtime/invoke/invoke.test.tsx => project/invoke/runtime.test.tsx} (79%) delete mode 100644 src/handlers/runtime/invoke/index.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54c108477d..a4a3703178 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -363,7 +363,7 @@ The interactive UI is built with [Ink](https://github.com/vadimdemedes/ink) (React for the terminal). `renderTui` mounts the `Root` component (`src/components/Root.tsx`) — a MemoryRouter over the app's route table plus a react-query client — seeded at the command's path. Because routes map to the -same handler paths as the CLI, deep-linking works: `harness invoke --id X` opens +same handler paths as the CLI, deep-linking works: `invoke --harness X` opens the chat screen at that harness. Ink reads and writes through the injected IO streams, so the TUI is fully testable without a real terminal. diff --git a/README.md b/README.md index 3845176a7a..c6aff66a69 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Create a managed Harness project, deploy it, and send a prompt: agentcore create --name MyAssistant cd MyAssistant agentcore deploy -agentcore invoke harness --prompt "Hey, what can you do for me?" +agentcore invoke --prompt "Hey, what can you do for me?" ``` To start with code you own instead, create a Runtime project from a template. @@ -64,8 +64,8 @@ agentcore invoke --help ``` Supported bare commands open their interactive flows in a terminal. Operation -flags select headless behavior for most commands. Project invoke commands can -use `--name` and `--target` to select a deployed agent for an interactive session. +flags select headless behavior for most commands. `invoke` can use `--runtime`, +`--harness`, `--gateway`, and `--target` to select a resource for an interactive session. Run `agentcore create` for guided setup. To create a default project without the wizard, run `agentcore create --name MyAssistant`. diff --git a/e2eTest/project/templates.test.ts b/e2eTest/project/templates.test.ts index 66d2e98aa2..e4b9b4764e 100644 --- a/e2eTest/project/templates.test.ts +++ b/e2eTest/project/templates.test.ts @@ -244,10 +244,9 @@ describe( await cli.run( [ "invoke", - "runtime", - "--local", - "--name", + "--runtime", runtime.name, + "--local", "--port", String(port), "--session-id", @@ -288,8 +287,7 @@ describe( await cli.run( [ "invoke", - "runtime", - "--name", + "--runtime", runtime.name, "--session-id", sessionId, diff --git a/src/assets/templates/agent-python-langchain/README.md b/src/assets/templates/agent-python-langchain/README.md index fe41ce72f7..b7c9082140 100644 --- a/src/assets/templates/agent-python-langchain/README.md +++ b/src/assets/templates/agent-python-langchain/README.md @@ -22,7 +22,7 @@ Run the agent locally from the project root: ```bash agentcore dev -agentcore invoke runtime --local --name {{name}} --payload '{"prompt":"What is 2 plus 3?"}' +agentcore invoke --runtime {{name}} --local --payload '{"prompt":"What is 2 plus 3?"}' ``` Environment variables for local development go in `agentcore/.env.local` @@ -32,7 +32,7 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore deploy -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --payload '{"prompt":"Hello!"}' ``` Traces are collected automatically: AgentCore Runtime starts the agent under diff --git a/src/assets/templates/agent-python-minimal/README.md b/src/assets/templates/agent-python-minimal/README.md index b8bc638f7b..728b04aead 100644 --- a/src/assets/templates/agent-python-minimal/README.md +++ b/src/assets/templates/agent-python-minimal/README.md @@ -27,5 +27,5 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore deploy -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-python-strands/README.md b/src/assets/templates/agent-python-strands/README.md index 98120c90b3..828cec5323 100644 --- a/src/assets/templates/agent-python-strands/README.md +++ b/src/assets/templates/agent-python-strands/README.md @@ -56,5 +56,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-typescript-strands/README.md b/src/assets/templates/agent-typescript-strands/README.md index 07d4845fb8..1285628124 100644 --- a/src/assets/templates/agent-typescript-strands/README.md +++ b/src/assets/templates/agent-typescript-strands/README.md @@ -46,5 +46,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke runtime --payload '{"prompt":"Hello!","actorId":"user-123"}' +agentcore invoke --payload '{"prompt":"Hello!","actorId":"user-123"}' ``` diff --git a/src/assets/templates/agui-python-strands/README.md b/src/assets/templates/agui-python-strands/README.md index 7b84681720..b1662331f5 100644 --- a/src/assets/templates/agui-python-strands/README.md +++ b/src/assets/templates/agui-python-strands/README.md @@ -32,7 +32,7 @@ health at `http://127.0.0.1:8080/ping`. runtime with an AG-UI `RunAgentInput` payload: ```bash -agentcore invoke runtime --name {{ name }} \ +agentcore invoke --runtime {{ name }} \ --payload '{"threadId":"t1","runId":"r1","state":{},"messages":[{"id":"m1","role":"user","content":"Hello!"}],"tools":[],"context":[],"forwardedProps":{}}' ``` diff --git a/src/assets/templates/export-harness-python/README.md b/src/assets/templates/export-harness-python/README.md index 391f294ea2..7be28ca474 100644 --- a/src/assets/templates/export-harness-python/README.md +++ b/src/assets/templates/export-harness-python/README.md @@ -42,5 +42,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke runtime --payload '{"prompt":"Hello!"}' +agentcore invoke --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/mcp-python-fastmcp/README.md b/src/assets/templates/mcp-python-fastmcp/README.md index 1497916786..06e54def75 100644 --- a/src/assets/templates/mcp-python-fastmcp/README.md +++ b/src/assets/templates/mcp-python-fastmcp/README.md @@ -29,4 +29,4 @@ sending JSON-RPC to `http://127.0.0.1:8000/mcp`. ## Deployment `agentcore deploy` deploys the server into Amazon Bedrock AgentCore. Invoke it with -`agentcore invoke runtime`, supplying an MCP JSON-RPC payload (e.g. `tools/list`, `tools/call`). +`agentcore invoke`, supplying an MCP JSON-RPC payload (e.g. `tools/list`, `tools/call`). diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index 66e0ecff06..872b89ad53 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -70,17 +70,7 @@ describe("menus list command-line-only subcommands below a divider", () => { await waitForText(r.lastFrame, "command line only"); expect(menuEntries(r.lastFrame()!)).toEqual({ - screens: [ - "create", - "get", - "list", - "update", - "delete", - "invoke", - "exec", - "endpoint", - "version", - ], + screens: ["create", "get", "list", "update", "delete", "exec", "endpoint", "version"], cliOnly: ["logs", "traces"], }); r.unmount(); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index f86b1b93d6..ef83b66507 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -281,16 +281,16 @@ function RouteTable({ ctx, core }: ScreenProps) { element={} /> } /> } /> {/* Deep link that resumes an existing runtime session in the chat. */} } /> } /> @@ -457,15 +457,15 @@ function RouteTable({ ctx, core }: ScreenProps) { element={} /> } /> } /> } /> } /> } /> } /> { await waitForText(r.lastFrame, "list"); const frame = r.lastFrame()!; - for (const sub of ["get", "list", "create", "update", "delete", "invoke", "exec"]) { + for (const sub of ["get", "list", "create", "update", "delete", "exec"]) { expect(frame).toContain(sub); } r.unmount(); diff --git a/src/handlers/eval/batch-evaluation/simulate/index.tsx b/src/handlers/eval/batch-evaluation/simulate/index.tsx index 64b2353efb..45adcaa954 100644 --- a/src/handlers/eval/batch-evaluation/simulate/index.tsx +++ b/src/handlers/eval/batch-evaluation/simulate/index.tsx @@ -23,7 +23,7 @@ Example: --payload-template '{"messages":[{"role":"user","content":"{input}"}],"stream":false}'`; // Composes invokeDataset (replay) → startBatchEvaluation (grade). Invoke flags mirror -// `runtime invoke`. +// `invoke --runtime`. export const createSimulateBatchEvaluationHandler = (core: Core, io: AppIO) => createHandler({ name: "simulate", diff --git a/src/handlers/eval/types.tsx b/src/handlers/eval/types.tsx index 0b362d5269..f78d28aca1 100644 --- a/src/handlers/eval/types.tsx +++ b/src/handlers/eval/types.tsx @@ -307,7 +307,7 @@ export type StartBatchInsightsInput = { // InvokeDatasetInput is the runtime-level shape for replaying a dataset: invoke each // example against the runtime, one client-generated session per example. Runtime fields // only — no evaluator/name/kms (those belong to the grader the handler composes on top, -// e.g. startBatchEvaluation). Invoke fields mirror `runtime invoke`. +// e.g. startBatchEvaluation). Invoke fields mirror `invoke --runtime`. export type InvokeDatasetInput = { runtimeId: string; qualifier?: string; diff --git a/src/handlers/gateway/gateway.mutations.test.tsx b/src/handlers/gateway/gateway.mutations.test.tsx index 8297e5f2f2..9d2d4a3328 100644 --- a/src/handlers/gateway/gateway.mutations.test.tsx +++ b/src/handlers/gateway/gateway.mutations.test.tsx @@ -90,7 +90,6 @@ describe("Gateway imperative mutation availability", () => { expect(names).toContain("get"); expect(names).toContain("list"); } - expect(gateway.commands.map((child) => child.name())).toContain("invoke"); expect(gateway.commands.find((child) => child.name() === "policy")?.commands[0]?.name()).toBe( "generate", ); diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index e1b0a4afc2..1de7e9aca7 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -116,7 +116,7 @@ describe("Gateway menu and list", () => { await waitForText(screen.lastFrame, "manage AgentCore Gateways"); expect(menuEntries(screen.lastFrame()!)).toEqual({ - screens: ["create", "get", "list", "invoke", "target", "connector", "rule", "policy"], + screens: ["create", "get", "list", "target", "connector", "rule", "policy"], cliOnly: [], }); expect(screen.core.gateway.calls).toEqual([]); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 6b3f50e6fa..1f5f80132f 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -88,7 +88,6 @@ describe("gateway command hierarchy", () => { "get", "list", "delete", - "invoke", "target", "connector", "rule", diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 98e3977649..0b76c787e8 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -8,7 +8,6 @@ import { createGatewayConnectorHandler } from "./connector"; import { createCreateGatewayHandler } from "./create"; import { createDeleteGatewayHandler } from "./delete"; import { createGetGatewayHandler } from "./get"; -import { createInvokeGatewayHandler } from "./invoke"; import { createListGatewaysHandler } from "./list"; import { createGatewayPolicyHandler } from "./policy"; import { createGatewayRuleHandler } from "./rule"; @@ -20,7 +19,7 @@ export function createGatewayHandler(core: Core, io: AppIO, globalConfig: Global const router = new Router("gateway", "manage AgentCore Gateways") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) - .supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule", "policy"); + .supportedTuiCommands("get", "list", "target", "connector", "rule", "policy"); if (imperativeMutationCommands) { router .handler(createCreateGatewayHandler(core, io)) @@ -29,7 +28,6 @@ export function createGatewayHandler(core: Core, io: AppIO, globalConfig: Global router.handler(createGetGatewayHandler(core)).handler(createListGatewaysHandler(core)); if (imperativeMutationCommands) router.handler(createDeleteGatewayHandler(core)); return router - .handler(createInvokeGatewayHandler(core, io)) .handler(createGatewayTargetHandler(core, io, imperativeMutationCommands)) .handler(createGatewayConnectorHandler(core, io, imperativeMutationCommands)) .handler(createGatewayRuleHandler(core, io, imperativeMutationCommands)) diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx deleted file mode 100644 index 30614c5273..0000000000 --- a/src/handlers/gateway/invoke/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import z from "zod"; -import { GatewayInvokeResponseError, InputValidationError } from "../../../errors"; -import { SourceResolver, type AppIO } from "../../../io"; -import { withUserCancellation } from "../../../runnable"; -import { createHandler, flag, PathKey } from "../../../router"; -import { renderTuiAt } from "../../../tui"; -import { runWithProgress } from "../../../tui/progress"; -import { JsonKey } from "../../keys"; -import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; -import type { GatewayInvokeMethod } from "../types"; -import { - gatewayIdSchema, - normalizeGatewayInvokeRequest, - parseGatewayInvokeHeaders, - resolveGatewayInvokeSources, - resolveGatewayInvokeTuiBearerToken, -} from "./request"; -import { writeGatewayInvokeResponse } from "./response"; -import { GatewayInvokeLaunchContextKey } from "./launchContext"; - -export const createInvokeGatewayHandler = ( - core: Core, - io: AppIO, - renderInvokeTui: typeof renderTuiAt = renderTuiAt, -) => - createHandler({ - name: "invoke", - description: "invoke an AgentCore Gateway", - flags: [ - flag("id", "the ID of the Gateway", gatewayIdSchema), - flag( - "path", - "the path relative to the Gateway origin", - z.string().min(1, "requires a nonempty path").optional(), - { sensitive: true }, - ), - flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional()), - flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the Gateway bearer token", z.string().optional(), { - sensitive: true, - }), - flag("session-id", "the Runtime target session ID", z.string().optional()), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], - handle: async (ctx, flags) => { - const jsonOutput = ctx.require(JsonKey); - if (flags.payload === undefined) { - const hasHeadlessOnlyFlag = Object.entries(flags).some( - ([name, value]) => - ![ - "id", - "path", - "payload", - "header", - "bearer-token", - "session-id", - "mcp-session-id", - "mcp-protocol-version", - ].includes(name) && value !== undefined, - ); - if (!jsonOutput && !hasHeadlessOnlyFlag) { - const applicationHeaders = parseGatewayInvokeHeaders(flags.header); - const bearerToken = await resolveGatewayInvokeTuiBearerToken( - flags["bearer-token"], - new SourceResolver({ stdin: io.stdin }), - ); - const launchContext = { - gatewayId: flags.id, - path: flags.path, - runtimeSessionId: flags["session-id"], - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - applicationHeaders, - bearerToken, - }; - await renderInvokeTui( - `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, - ctx.withValue(GatewayInvokeLaunchContextKey, launchContext), - core, - io, - ); - return; - } - } - - if (jsonOutput && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } - const gatewayId = flags.id; - const payload = flags.payload; - const applicationHeaders = parseGatewayInvokeHeaders(flags.header); - - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const options = coreOptsFromCtx(ctx); - const gateway = await core.gateway.getGateway(gatewayId, options, signal); - const request = normalizeGatewayInvokeRequest(gateway, { - gatewayId, - path: flags.path, - method: flags.method as GatewayInvokeMethod | undefined, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - applicationHeaders, - bearerToken: sources.bearerToken, - runtimeSessionId: flags["session-id"], - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - }); - const response = await core.gateway.invokeGateway(request, options, signal); - await writeGatewayInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new GatewayInvokeResponseError(`HTTP ${response.statusCode}`); - } - }; - await withUserCancellation(async (signal) => { - const sources = await resolveGatewayInvokeSources( - { payload, bearerToken: flags["bearer-token"] }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking gateway...", - interactive: !jsonOutput, - }); - }); - }, - }); diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx index 6065e4c9e2..07bb3bb9a9 100644 --- a/src/handlers/gateway/invoke/invoke.screen.test.tsx +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -12,7 +12,7 @@ import { GatewayInvokeLaunchContextKey } from "./launchContext"; const GATEWAY_ID = "gateway-123"; const GATEWAY_URL = "https://gateway-123.gateway.example.test/mcp"; -const CONSOLE_PATH = `/agentcore/gateway/invoke/${GATEWAY_ID}`; +const CONSOLE_PATH = `/agentcore/invoke/gateway/${GATEWAY_ID}`; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; afterEach(cleanupScreens); @@ -62,12 +62,12 @@ describe("Gateway invoke routing", () => { test("selects a Gateway before opening the JSON console", async () => { const core = new TestCoreClient(); core.gateway.setListResponse({ items: [gatewaySummary()] }).setGetResponse(gatewayDetail()); - const screen = renderImperativeScreen("/agentcore/gateway/invoke", { core }); + const screen = renderImperativeScreen("/agentcore/invoke/gateway", { core }); await waitForText(screen.lastFrame, "checkout-gateway"); await screen.press("return"); - await waitForText(screen.lastFrame, `agentcore → gateway → invoke → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, `agentcore → invoke → gateway → ${GATEWAY_ID}`); await waitForText(screen.lastFrame, "Enter JSON payload"); expect(screen.lastFrame()).toContain("Path: /mcp (Gateway URL)"); expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); @@ -100,7 +100,7 @@ describe("Gateway invoke routing", () => { await waitFor(() => signal!.aborted); }); - test("idle Escape returns through the invoke picker to the Gateway menu", async () => { + test("idle Escape returns through the Gateway picker to the project invoke picker", async () => { const core = new TestCoreClient(); core.gateway.setGetResponse(gatewayDetail()).setListResponse({ items: [gatewaySummary()] }); const screen = renderImperativeScreen(CONSOLE_PATH, { core }); @@ -112,7 +112,7 @@ describe("Gateway invoke routing", () => { expect(screen.lastFrame()).toContain("checkout-gateway"); await screen.press("escape"); - await waitForText(screen.lastFrame, "manage AgentCore Gateways"); + await waitForText(screen.lastFrame, "invoke a Runtime, harness, or Gateway from the current project"); }); }); @@ -411,7 +411,7 @@ describe("Gateway invoke JSON console", () => { await waitForText(screen.lastFrame, "next-gateway"); await screen.press("down"); await screen.press("return"); - await waitForText(screen.lastFrame, `agentcore → gateway → invoke → ${nextGatewayId}`); + await waitForText(screen.lastFrame, `agentcore → invoke → gateway → ${nextGatewayId}`); await waitForText(screen.lastFrame, "Ready"); expect(screen.lastFrame()).toContain("Path: /mcp (Gateway URL)"); diff --git a/src/handlers/gateway/invoke/request.ts b/src/handlers/gateway/invoke/request.ts index 0ae5aad7aa..808bd41daa 100644 --- a/src/handlers/gateway/invoke/request.ts +++ b/src/handlers/gateway/invoke/request.ts @@ -1,14 +1,9 @@ import { validateHeaderName, validateHeaderValue } from "node:http"; import type { AuthorizerType, GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; -import z from "zod"; import { InputValidationError, SourceResolutionError } from "../../../errors"; import { SourceResolver } from "../../../io"; import type { GatewayInvokeMethod, GatewayInvokeRequest } from "../types"; -export const gatewayIdSchema = z - .string() - .refine((value) => !value.startsWith("arn:"), "must be a Gateway ID, not an ARN"); - type GatewayInvokeInput = { gatewayId: string; path?: string; diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 6cdd905e91..16d86a8b83 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -45,7 +45,7 @@ type Exchange = { }; const invokePath = (...parts: string[]) => - ["/agentcore/gateway/invoke", ...parts.map(encodeURIComponent)].join("/"); + ["/agentcore/invoke/gateway", ...parts.map(encodeURIComponent)].join("/"); const metadata = (response: GatewayInvokeResponse) => [ @@ -148,7 +148,7 @@ export function GatewayInvokeScreen(props: ScreenProps) { return ( navigate(invokePath(id))} /> @@ -161,9 +161,16 @@ export function GatewayInvokeScreen(props: ScreenProps) { type GatewayInvokeConsoleProps = ScreenProps & { gatewayId: string; initialContext?: GatewayInvokeLaunchContext; + onBack?: () => void; }; -function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayInvokeConsoleProps) { +export function GatewayInvokeConsole({ + ctx, + core, + gatewayId, + initialContext, + onBack, +}: GatewayInvokeConsoleProps) { const navigate = useNavigate(); const opts = coreOptsFromCtx(ctx); const { columns, rows } = useWindowSize(); @@ -385,6 +392,7 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI } if (key.escape) { if (abortRef.current) abortRef.current.abort(); + else if (onBack) onBack(); else navigate(invokePath()); return; } @@ -412,7 +420,7 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI setPickingGateway(false)} @@ -422,7 +430,7 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI return ( { test("chat turns and exec commands share one session and one transcript", async () => { const core = execCore(); // Start on the invoke route (chat mode), then toggle into exec mode. - const r = renderImperativeScreen("/agentcore/harness/invoke/MyHarness-abc123", { core }); + const r = renderImperativeScreen("/agentcore/invoke/harness/MyHarness-abc123", { core }); await waitForText(r.lastFrame, "send a message…"); await type(r, "hi agent"); diff --git a/src/handlers/harness/get/get.screen.test.tsx b/src/handlers/harness/get/get.screen.test.tsx index 58479cab8f..ff75097154 100644 --- a/src/handlers/harness/get/get.screen.test.tsx +++ b/src/handlers/harness/get/get.screen.test.tsx @@ -165,7 +165,7 @@ describe("harness hub screen", () => { await r.press("down"); // versions await r.press("down"); // invoke await r.press("return"); - await waitForText(r.lastFrame, "agentcore → harness → invoke → MyHarness-abc123"); + await waitForText(r.lastFrame, "agentcore → invoke → harness → MyHarness-abc123"); r.unmount(); }); diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index e50378b0bd..4c6679bdf8 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -41,7 +41,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string } { name: "invoke", description: "chat with this harness", - to: (id) => `/agentcore/harness/invoke/${id}`, + to: (id) => `/agentcore/invoke/harness/${id}`, }, { name: "exec", diff --git a/src/handlers/harness/index.tsx b/src/handlers/harness/index.tsx index 9c29e98968..67ab473825 100644 --- a/src/handlers/harness/index.tsx +++ b/src/handlers/harness/index.tsx @@ -8,7 +8,6 @@ import { createListHarnessHandler } from "./list"; import { createCreateHarnessHandler } from "./create"; import { createUpdateHarnessHandler } from "./update"; import { createDeleteHarnessHandler } from "./delete"; -import { createInvokeHarnessHandler } from "./invoke"; import { createExecHarnessHandler } from "./exec"; import { createEndpointHandler } from "./endpoint"; import { createVersionHandler } from "./version"; @@ -28,7 +27,6 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { "list", "update", "delete", - "invoke", "exec", "endpoint", "version", @@ -40,7 +38,6 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { harness.handler(createListHarnessHandler(core)); harness.handler(createUpdateHarnessHandler(core)); harness.handler(createDeleteHarnessHandler(core)); - harness.handler(createInvokeHarnessHandler(core, io)); harness.handler(createExecHarnessHandler(core, io)); harness.handler(createHarnessLogsHandler(core, io)); harness.handler(createHarnessTracesHandler(core, io)); diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx deleted file mode 100644 index a18e263dfb..0000000000 --- a/src/handlers/harness/invoke/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import z from "zod"; -import { createHandler, flag, PathKey } from "../../../router"; -import type { AppIO } from "../../../io"; -import type { Core } from "../../types.tsx"; -import { coreOptsFromCtx } from "../../utils.tsx"; -import { JsonKey } from "../../keys.tsx"; -import { JsonRendererKey, renderTuiAt } from "../../../tui"; -import { runWithProgress } from "../../../tui/progress"; -import { InputValidationError } from "../../../errors"; -import { invokeHarnessTurn } from "./operation.ts"; - -export const createInvokeHarnessHandler = (core: Core, io: AppIO) => - createHandler({ - name: "invoke", - description: "invoke a harness", - flags: [ - flag("id", "the ID of the harness", z.string().min(1).max(48)), - flag("prompt", "the message to send to the harness", z.string().optional()), - flag( - "session-id", - "the Runtime session ID to continue (33-100 characters)", - z.string().min(33).max(100).optional(), - ), - flag( - "qualifier", - "the harness endpoint qualifier to invoke (default DEFAULT)", - z.string().optional(), - ), - ], - handle: async (ctx, flags) => { - // Without a prompt, open the interactive chat at this harness — resuming - // the given session and targeting the given qualifier when passed. The - // one-shot CLI transcript below needs --prompt (and is the only shape - // JSON mode supports). - if (!flags["prompt"]) { - if (ctx.require(JsonKey)) { - throw new InputValidationError("required option '--prompt ' not specified"); - } - let path = `${ctx.require(PathKey)}/${flags["id"]}`; - if (flags["session-id"]) path += `/${flags["session-id"]}`; - if (flags["qualifier"]) path += `?qualifier=${encodeURIComponent(flags["qualifier"])}`; - await renderTuiAt(path, ctx, core, io); - return; - } - - const opts = coreOptsFromCtx(ctx); - const prompt = flags["prompt"]; - const invoke = () => - invokeHarnessTurn( - core.harness, - { - harnessId: flags["id"], - prompt, - qualifier: flags["qualifier"] ?? "DEFAULT", - sessionId: flags["session-id"], - }, - opts, - ); - const result = await runWithProgress(invoke, { - io, - label: "Invoking harness...", - interactive: !ctx.require(JsonKey), - }); - ctx.require(JsonRendererKey).renderJson(result); - }, - }); - -export { HarnessInvokeScreen } from "./screen.tsx"; diff --git a/src/handlers/harness/invoke/invoke.screen.test.tsx b/src/handlers/harness/invoke/invoke.screen.test.tsx index e3714cc435..07aec6b1d4 100644 --- a/src/handlers/harness/invoke/invoke.screen.test.tsx +++ b/src/handlers/harness/invoke/invoke.screen.test.tsx @@ -21,7 +21,7 @@ afterEach(cleanupScreens); // when a test needs to hold the stream open and drive it by hand. const ARN = "arn:aws:bedrock-agentcore:us-east-1:123:harness/MyHarness-abc123"; -const CHAT_PATH = "/agentcore/harness/invoke/MyHarness-abc123"; +const CHAT_PATH = "/agentcore/invoke/harness/MyHarness-abc123"; function summary(overrides: Partial = {}): HarnessSummary { return { @@ -71,7 +71,7 @@ describe("invoke picker screen", () => { summary({ harnessName: "beta", harnessId: "beta-2" }), ], }); - const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); + const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); await waitForText(r.lastFrame, "alpha"); expect(r.lastFrame()).toContain("beta"); @@ -82,12 +82,12 @@ describe("invoke picker screen", () => { test("selecting a harness opens its chat", async () => { const core = chatCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); + const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); await waitForText(r.lastFrame, "MyHarness"); await r.press("return"); // The chat screen's breadcrumb carries the harness id and the prompt mounts. - await waitForText(r.lastFrame, "invoke → MyHarness-abc123"); + await waitForText(r.lastFrame, "invoke → harness → MyHarness-abc123"); await waitForText(r.lastFrame, "send a message…"); r.unmount(); }); @@ -95,21 +95,21 @@ describe("invoke picker screen", () => { test("shows the error message when the list call fails", async () => { const core = new TestCoreClient(); core.harness.setError(new Error("access denied")); - const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); + const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); await waitForText(r.lastFrame, "Error:"); expect(r.lastFrame()).toContain("access denied"); r.unmount(); }); - test("esc returns to the harness menu", async () => { + test("esc returns to the project invoke picker", async () => { const core = new TestCoreClient(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); + const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); await waitForText(r.lastFrame, "MyHarness"); await r.press("escape"); - await waitForText(r.lastFrame, "manage AgentCore harnesses"); + await waitForText(r.lastFrame, "invoke a Runtime, harness, or Gateway from the current project"); r.unmount(); }); }); @@ -401,7 +401,7 @@ describe("invoke chat screen", () => { test("esc while idle pops back to the picker", async () => { const core = chatCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); + const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); await waitForText(r.lastFrame, "MyHarness"); await r.press("return"); diff --git a/src/handlers/harness/invoke/screen.tsx b/src/handlers/harness/invoke/screen.tsx index 838e85668a..a27652b108 100644 --- a/src/handlers/harness/invoke/screen.tsx +++ b/src/handlers/harness/invoke/screen.tsx @@ -29,7 +29,7 @@ import { const theme = darkTheme; -// HarnessInvokeScreen is the interactive chat for `harness invoke`. Without a +// HarnessInvokeScreen is the interactive chat for `invoke --harness`. Without a // `:harnessId` route value it renders a picker (choose which harness to chat // with); with one it renders the chat itself. A `:sessionId` route value // resumes that runtime session instead of starting a fresh one, and a @@ -43,9 +43,9 @@ export function HarnessInvokeScreen(props: ScreenProps) { return ( navigate(`/agentcore/harness/invoke/${id}`)} + onSelect={(id) => navigate(`/agentcore/invoke/harness/${id}`)} /> ); } @@ -302,13 +302,17 @@ export function HarnessChat({ // The ctrl+t endpoint switcher: pick an endpoint and later sends target its // qualifier; esc closes the overlay with the qualifier unchanged. + const breadcrumb = + variant === "invoke" + ? ["agentcore", "invoke", "harness", harnessId] + : ["agentcore", "harness", "exec", harnessId]; if (pickingEndpoint) { return ( { setQualifier(endpointName); @@ -321,7 +325,7 @@ export function HarnessChat({ return ( { return (async function* () { @@ -73,6 +72,7 @@ function configuredCore(gateway: Partial = {}): TestCoreClie core.gateway .setGetResponse({ gatewayId: GATEWAY_ID, + gatewayArn: GATEWAY_ARN, gatewayUrl: GATEWAY_URL, authorizerType: "NONE", ...gateway, @@ -86,7 +86,9 @@ function configuredCore(gateway: Partial = {}): TestCoreClie return core; } -describe("gateway invoke", () => { +const methods = (core: TestCoreClient) => core.gateway.calls.map(({ method }) => method); + +describe("invoke --gateway", () => { test.each(["payload", "bearer-token"] as const)( "reads TTY %s before starting progress", async (input) => { @@ -98,9 +100,8 @@ describe("gateway invoke", () => { const io = testIO({ isTTY: true }); const stdin = io.io.stdin as unknown as PassThrough; const pending = runCommand(core, io.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", input === "payload" ? "-" : "{}", @@ -111,7 +112,7 @@ describe("gateway invoke", () => { stdin.write(input === "payload" ? "{}" : "test-token"); await tick(120); expect(io.stderr()).toBe(""); - expect(core.gateway.calls).toEqual([]); + expect(methods(core)).toEqual(["getGateway"]); stdin.end(); await waitFor(() => io.stderr().includes("Invoking gateway...")); } finally { @@ -130,9 +131,8 @@ describe("gateway invoke", () => { core.gateway.setInvokeResponse({ statusCode: 200, contentType: "text/plain", body: stream }); const io = testIO({ isTTY: true }); const pending = runCommand(core, io.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", "{}", @@ -162,17 +162,16 @@ describe("gateway invoke", () => { const output = captureIO(); await runCommand(core, output.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", '{"jsonrpc":"2.0","id":1,"method":"tools/list"}', ]); - expect(core.gateway.calls.map((call) => call.method)).toEqual(["getGateway", "invokeGateway"]); - const lookup = core.gateway.calls[0]!; - const invoke = core.gateway.calls[1]!; + expect(methods(core)).toEqual(["getGateway", "getGateway", "invokeGateway"]); + const lookup = core.gateway.calls[1]!; + const invoke = core.gateway.calls[2]!; expect(lookup.args.slice(0, 2)).toEqual([GATEWAY_ID, { region: REGION }]); expect(lookup.args[2]).toBe(invoke.args[2]); expect(invoke.args[1]).toEqual({ region: REGION }); @@ -193,9 +192,8 @@ describe("gateway invoke", () => { const output = captureIO(); await runCommand(core, output.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--path", "target/invocations?trace=true", @@ -241,9 +239,8 @@ describe("gateway invoke", () => { const output = captureIO(); await runCommand(core, output.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--method", "GET", @@ -263,7 +260,7 @@ describe("gateway invoke", () => { const core = configuredCore(); const output = captureIO(); - await runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID, "--payload", ""]); + await runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--payload", ""]); const request = core.gateway.calls.find((call) => call.method === "invokeGateway")! .args[0] as GatewayInvokeRequest; @@ -275,9 +272,8 @@ describe("gateway invoke", () => { const output = captureIO(Buffer.from("secret-token")); await runCommand(core, output.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", "{}", @@ -301,9 +297,8 @@ describe("gateway invoke", () => { const output = captureIO(); await runCommand(core, output.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", "{}", @@ -331,7 +326,7 @@ describe("gateway invoke", () => { const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID, "--method", "GET"]), + runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--method", "GET"]), ); expect(code).toBe(ExitCode.FAILURE); @@ -353,15 +348,7 @@ describe("gateway invoke", () => { const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, [ - "gateway", - "invoke", - "--id", - GATEWAY_ID, - "--payload", - "{}", - "--json", - ]), + runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--payload", "{}", "--json"]), ); expect(code).toBe(ExitCode.FAILURE); @@ -392,9 +379,8 @@ describe("gateway invoke", () => { const code = await runWithExitCode(async () => runCommand(core, output.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", "{}", @@ -417,15 +403,13 @@ describe("gateway invoke", () => { }); test.each([ - [["gateway", "invoke", "--payload", "{}"], /--id/], - [["gateway", "invoke", "--id", GATEWAY_ID, "--json"], /--payload/], - [["gateway", "invoke", "--id", GATEWAY_ID, "--method", "POST"], /--payload/], - [["gateway", "invoke", "--id", GATEWAY_ID, "--output-file", "response.bin"], /--payload/], + [["invoke", "--gateway", GATEWAY_ID, "--json"], /--payload/], + [["invoke", "--gateway", GATEWAY_ID, "--method", "POST"], /--payload/], + [["invoke", "--gateway", GATEWAY_ID, "--output-file", "response.bin"], /--payload/], [ [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", "{}", @@ -445,16 +429,7 @@ describe("gateway invoke", () => { test("rejects an unknown option before invocation", async () => { const core = configuredCore(); const output = captureIO(); - const args = [ - "gateway", - "invoke", - "--id", - GATEWAY_ID, - "--payload", - "{}", - "--request-type", - "mcp", - ]; + const args = ["invoke", "--gateway", GATEWAY_ID, "--payload", "{}", "--request-type", "mcp"]; await expectError( runCommand(core, output.io, args), @@ -464,60 +439,48 @@ describe("gateway invoke", () => { expect(core.gateway.calls.some((call) => call.method === "invokeGateway")).toBe(false); }); - test("a bare command enters existing TUI middleware without Gateway Core calls", async () => { - const core = configuredCore(); + test("deep-links an id-only invoke and seeds interactive request context", async () => { + const core = configuredCore({ + gatewayArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:gateway/gateway/blue one`, + }); const output = captureIO(); + const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); - await expect(runCommand(core, output.io, ["gateway", "invoke"])).rejects.toThrow( - "interactive mode requires a TTY on stdin and stdout", - ); - expect(core.gateway.calls).toEqual([]); - }); + try { + await runCommand(core, output.io, [ + "invoke", + "--gateway", + GATEWAY_ID, + "--path", + "runtime/invocations?trace=true", + "--header", + "X-Tenant: retail", + "--bearer-token", + "secret-token", + "--session-id", + "runtime-session", + "--mcp-session-id", + "mcp-session", + "--mcp-protocol-version", + "2025-06-18", + ]); - test("deep-links an id-only invoke and seeds interactive request context", async () => { - const core = configuredCore(); - const output = captureIO(); - let renderCount = 0; - const handler = createInvokeGatewayHandler( - core, - output.io, - async (path, ctx, renderedCore, renderedIo) => { - renderCount++; - expect(path).toBe("/agentcore/gateway/invoke/gateway%2Fblue%20one"); - expect(ctx.value(GatewayInvokeLaunchContextKey)).toEqual({ - gatewayId: "gateway/blue one", - path: "runtime/invocations?trace=true", - runtimeSessionId: "runtime-session", - mcpSessionId: "mcp-session", - mcpProtocolVersion: "2025-06-18", - applicationHeaders: [["X-Tenant", "retail"]], - bearerToken: "secret-token", - }); - expect(renderedCore).toBe(core); - expect(renderedIo).toBe(output.io); - }, - ); - const ctx = ValueContext.EmptyContext() - .withValue(PathKey, "/agentcore/gateway/invoke") - .withValue(JsonKey, false); - - await handler.handle( - ctx, - { - id: "gateway/blue one", + expect(render).toHaveBeenCalledTimes(1); + const [path, ctx] = render.mock.calls[0]!; + expect(path).toBe("/agentcore/invoke/gateway/gateway%2Fblue%20one"); + expect(ctx.value(GatewayInvokeLaunchContextKey)).toEqual({ + gatewayId: "gateway/blue one", path: "runtime/invocations?trace=true", - payload: undefined, - header: ["X-Tenant: retail"], - "bearer-token": "secret-token", - "session-id": "runtime-session", - "mcp-session-id": "mcp-session", - "mcp-protocol-version": "2025-06-18", - }, - {}, - ); - - expect(renderCount).toBe(1); - expect(core.gateway.calls).toEqual([]); + runtimeSessionId: "runtime-session", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: "secret-token", + }); + expect(methods(core)).toEqual(["getGateway"]); + } finally { + render.mockRestore(); + } }); test("rejects stdin bearer tokens when launching the TUI", async () => { @@ -525,20 +488,20 @@ describe("gateway invoke", () => { const output = captureIO(Buffer.from("secret-token")); await expect( - runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID, "--bearer-token", "-"]), + runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--bearer-token", "-"]), ).rejects.toThrow("stdin bearer tokens are not available"); - expect(core.gateway.calls).toEqual([]); + expect(methods(core)).toEqual(["getGateway"]); }); test("classifies an unavailable interactive environment as usage", async () => { const core = configuredCore(); const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID]), + runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID]), ); expect(code).toBe(ExitCode.USAGE); - expect(core.gateway.calls).toEqual([]); + expect(methods(core)).toEqual(["getGateway"]); }); test("SIGINT aborts lookup and invocation through the same signal", async () => { @@ -554,9 +517,8 @@ describe("gateway invoke", () => { }); }; const pending = runCommand(core, output.io, [ - "gateway", "invoke", - "--id", + "--gateway", GATEWAY_ID, "--payload", "{}", @@ -566,8 +528,8 @@ describe("gateway invoke", () => { await waitFor(() => core.gateway.calls.some((call) => call.method === "invokeGateway")); process.emit("SIGINT", "SIGINT"); - const lookupSignal = core.gateway.calls[0]!.args[2] as AbortSignal; - const invokeSignal = core.gateway.calls[1]!.args[2] as AbortSignal; + const lookupSignal = core.gateway.calls[1]!.args[2] as AbortSignal; + const invokeSignal = core.gateway.calls[2]!.args[2] as AbortSignal; expect(lookupSignal).toBe(invokeSignal); expect(invokeSignal.aborted).toBe(true); expect(invokeSignal.reason).toBeInstanceOf(UserCancellationError); @@ -577,7 +539,7 @@ describe("gateway invoke", () => { } }); - test("registers a headless invoke leaf without a request-type flag", () => { + test("redacts --path from debug logs", () => { const core = configuredCore(); const output = captureIO(); const root = createRootHandler(core, { @@ -588,11 +550,8 @@ describe("gateway invoke", () => { initialConfigData: IMPERATIVE_GLOBAL_CONFIG, }), }); - const gateway = root.children().find((child) => child.name() === "gateway"); - const invoke = gateway?.children().find((child) => child.name() === "invoke"); + const invoke = root.children().find((child) => child.name() === "invoke"); - expect(invoke).toBeDefined(); - expect(invoke?.flags().map((registered) => registered.name)).not.toContain("request-type"); expect(invoke?.flags().find((registered) => registered.name === "path")?.sensitive).toBe(true); }); }); diff --git a/src/handlers/project/invoke/gateway.tsx b/src/handlers/project/invoke/gateway.tsx new file mode 100644 index 0000000000..d93afe1335 --- /dev/null +++ b/src/handlers/project/invoke/gateway.tsx @@ -0,0 +1,113 @@ +import { GatewayInvokeResponseError } from "../../../errors"; +import { SourceResolver, type AppIO } from "../../../io"; +import type { Context } from "../../../router"; +import { withUserCancellation } from "../../../runnable"; +import type { renderTuiAt } from "../../../tui"; +import { runWithProgress } from "../../../tui/progress"; +import { GatewayInvokeLaunchContextKey } from "../../gateway/invoke/launchContext"; +import { + normalizeGatewayInvokeRequest, + parseGatewayInvokeHeaders, + resolveGatewayInvokeSources, + resolveGatewayInvokeTuiBearerToken, +} from "../../gateway/invoke/request"; +import { writeGatewayInvokeResponse } from "../../gateway/invoke/response"; +import { JsonKey } from "../../keys"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import type { InvokeFlags } from "."; + +const TUI_FLAGS = [ + "gateway", + "target", + "local", + "path", + "header", + "bearer-token", + "session-id", + "mcp-session-id", + "mcp-protocol-version", +]; + +export async function invokeGateway( + core: Core, + io: AppIO, + ctx: Context, + gatewayId: string, + flags: InvokeFlags, + renderInvokeTui: typeof renderTuiAt, +): Promise { + const jsonOutput = ctx.require(JsonKey); + const hasHeadlessOnlyFlag = Object.entries(flags).some( + ([name, value]) => !TUI_FLAGS.includes(name) && value !== undefined, + ); + if (!jsonOutput && !hasHeadlessOnlyFlag) { + const applicationHeaders = parseGatewayInvokeHeaders(flags.header); + const bearerToken = await resolveGatewayInvokeTuiBearerToken( + flags["bearer-token"], + new SourceResolver({ stdin: io.stdin }), + ); + await renderInvokeTui( + `/agentcore/invoke/gateway/${encodeURIComponent(gatewayId)}`, + ctx.withValue(GatewayInvokeLaunchContextKey, { + gatewayId, + path: flags.path, + runtimeSessionId: flags["session-id"], + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + applicationHeaders, + bearerToken, + }), + core, + io, + ); + return; + } + + const applicationHeaders = parseGatewayInvokeHeaders(flags.header); + const invoke = async ( + signal: AbortSignal, + beforeOutput: () => Promise, + sources: Awaited>, + ) => { + const options = coreOptsFromCtx(ctx); + const gateway = await core.gateway.getGateway(gatewayId, options, signal); + const request = normalizeGatewayInvokeRequest(gateway, { + gatewayId, + path: flags.path, + method: flags.method, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + applicationHeaders, + bearerToken: sources.bearerToken, + runtimeSessionId: flags["session-id"], + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + }); + const response = await core.gateway.invokeGateway(request, options, signal); + await writeGatewayInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal, + beforeOutput, + }); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new GatewayInvokeResponseError(`HTTP ${response.statusCode}`); + } + }; + await withUserCancellation(async (signal) => { + const sources = await resolveGatewayInvokeSources( + { payload: flags.payload, bearerToken: flags["bearer-token"] }, + io.stdin, + signal, + ); + return runWithProgress((stop) => invoke(signal, stop, sources), { + io, + label: "Invoking gateway...", + interactive: !jsonOutput, + }); + }); +} diff --git a/src/handlers/harness/invoke/invoke.test.tsx b/src/handlers/project/invoke/harness.test.tsx similarity index 84% rename from src/handlers/harness/invoke/invoke.test.tsx rename to src/handlers/project/invoke/harness.test.tsx index 7b91fe8c8d..269b3c22b0 100644 --- a/src/handlers/harness/invoke/invoke.test.tsx +++ b/src/handlers/project/invoke/harness.test.tsx @@ -8,7 +8,6 @@ import { createRootHandler } from "../../index"; import { IMPERATIVE_GLOBAL_CONFIG, createSilentLogger, - expectError, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -16,9 +15,8 @@ import { tick, waitFor, } from "../../../testing"; -import { InputValidationError } from "../../../errors"; -// Command-flow tests for `harness invoke`, driven through the real root handler +// Command-flow tests for `invoke --harness`, driven through the real root handler // exactly as the CLI runs it. Unlike the get/list suites these use a // TestCoreClient rather than recorded fixtures: the invoke response is an // AsyncIterable stream, which the fixture serialization cannot capture. @@ -63,22 +61,14 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void, i return { core, stdout: io.stdout() }; } -describe("harness invoke", () => { +describe("invoke --harness", () => { test.each([false, true])( "waits for the transcript and respects JSON mode (json=%s)", async (json) => { const io = testIO({ isTTY: true }); const stream = new StreamController(); const pending = run( - [ - "harness", - "invoke", - "--id", - "MyHarness-abc123", - "--prompt", - "hi", - ...(json ? ["--json"] : []), - ], + ["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi", ...(json ? ["--json"] : [])], (core) => core.harness.queueInvokeStream(stream), io, ); @@ -98,14 +88,7 @@ describe("harness invoke", () => { ); test("folds the stream into a JSON transcript", async () => { - const { stdout } = await run([ - "harness", - "invoke", - "--id", - "MyHarness-abc123", - "--prompt", - "hi", - ]); + const { stdout } = await run(["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi"]); const out = JSON.parse(stdout); expect(out.stopReason).toBe("end_turn"); @@ -119,7 +102,7 @@ describe("harness invoke", () => { }); test("resolves the harness ARN and sends a single user message with a fresh session id", async () => { - const { core } = await run(["harness", "invoke", "--id", "MyHarness-abc123", "--prompt", "hi"]); + const { core } = await run(["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi"]); const get = core.harness.calls.find((c) => c.method === "getHarness")!; expect(get.args[0]).toBe("MyHarness-abc123"); @@ -138,9 +121,8 @@ describe("harness invoke", () => { test("--session-id is passed through and echoed in the output", async () => { const sessionId = "custom-session-id-that-is-long-enough"; const { core, stdout } = await run([ - "harness", "invoke", - "--id", + "--harness", "MyHarness-abc123", "--prompt", "hi", @@ -155,15 +137,14 @@ describe("harness invoke", () => { test("--session-id shorter than 33 characters is rejected", async () => { await expect( - run(["harness", "invoke", "--id", "X", "--prompt", "hi", "--session-id", "too-short"]), - ).rejects.toThrow(); + run(["invoke", "--harness", "X", "--prompt", "hi", "--session-id", "too-short"]), + ).rejects.toThrow("--session-id must be 33-100 characters for a harness"); }); test("--qualifier is passed through on the request", async () => { const { core } = await run([ - "harness", "invoke", - "--id", + "--harness", "MyHarness-abc123", "--prompt", "hi", @@ -177,7 +158,7 @@ describe("harness invoke", () => { test("a stream-borne validationException becomes an error transcript item", async () => { const { stdout } = await run( - ["harness", "invoke", "--id", "MyHarness-abc123", "--prompt", "hi"], + ["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi"], (core) => core.harness.setInvokeEvents({ validationException: { message: "session id malformed" }, @@ -190,15 +171,11 @@ describe("harness invoke", () => { ]); }); - test("errors when --id is omitted", async () => { - await expectError(run(["harness", "invoke", "--prompt", "hi"]), /--id/, InputValidationError); - }); - // Without --prompt (and outside JSON mode) the handler opens the interactive // chat instead — that path is covered by the screen tests, since the test IO // streams cannot host an Ink render. test("errors when --prompt is omitted in JSON mode", async () => { - await expect(run(["harness", "invoke", "--id", "MyHarness-abc123", "--json"])).rejects.toThrow( + await expect(run(["invoke", "--harness", "MyHarness-abc123", "--json"])).rejects.toThrow( /--prompt/, ); }); diff --git a/src/handlers/project/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx index 0821e6b38f..3fcff2eba2 100644 --- a/src/handlers/project/invoke/harness.tsx +++ b/src/handlers/project/invoke/harness.tsx @@ -1,78 +1,49 @@ -import z from "zod"; import { InputValidationError } from "../../../errors"; import type { AppIO } from "../../../io"; -import { createHandler, flag, ProjectKey } from "../../../router"; -import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import type { Context } from "../../../router"; +import { JsonRendererKey, type renderTuiAt } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; -import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; +import { JsonKey } from "../../keys"; import { invokeHarnessTurn } from "../../harness/invoke/operation"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { selectProjectResource } from "../selection"; +import type { InvokeFlags } from "."; -export const createProjectInvokeHarnessHandler = ( +export async function invokeHarness( core: Core, io: AppIO, - renderInvokeTui: typeof renderTuiAt = renderTuiAt, -) => - createHandler({ - name: "harness", - description: "invoke a harness from the current project", - flags: [ - flag("name", "the logical project harness name", z.string().optional()), - flag("target", "project deployment target", z.string().default("default")), - flag("prompt", "the message to send to the harness", z.string().optional()), - flag( - "session-id", - "the Runtime session ID to continue (33-100 characters)", - z.string().min(33).max(100).optional(), - ), - flag( - "qualifier", - "the harness endpoint qualifier to invoke (default DEFAULT)", - z.string().optional(), - ), - ], - handle: async (ctx, flags) => { - const project = ctx.require(ProjectKey); - const name = selectProjectResource(project, "harness", flags.name, "invoke"); - const deployed = await core.projectManager.resolveDeployedResource(project, { - target: flags.target, - resourceType: "harness", - name, - }); - const invokeCtx = ctx - .withValue(RegionKey, deployed.target.region) - .withValue(AwsCredentialProviderKey, deployed.credentialProvider); + ctx: Context, + harnessId: string, + flags: InvokeFlags, + renderInvokeTui: typeof renderTuiAt, +): Promise { + const sessionId = flags["session-id"]; + if (sessionId !== undefined && (sessionId.length < 33 || sessionId.length > 100)) { + throw new InputValidationError("--session-id must be 33-100 characters for a harness"); + } - if (!flags.prompt) { - if (invokeCtx.require(JsonKey)) { - throw new InputValidationError("required option '--prompt ' not specified"); - } - let path = `/agentcore/harness/invoke/${encodeURIComponent(deployed.id)}`; - if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; - if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; - await renderInvokeTui(path, invokeCtx, core, io); - return; - } + if (!flags.prompt) { + if (ctx.require(JsonKey)) { + throw new InputValidationError("required option '--prompt ' not specified"); + } + let path = `/agentcore/invoke/harness/${encodeURIComponent(harnessId)}`; + if (sessionId) path += `/${encodeURIComponent(sessionId)}`; + if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; + await renderInvokeTui(path, ctx, core, io); + return; + } - const prompt = flags.prompt; - const invoke = () => - invokeHarnessTurn( - core.harness, - { - harnessId: deployed.id, - prompt, - qualifier: flags.qualifier, - sessionId: flags["session-id"], - }, - coreOptsFromCtx(invokeCtx), - ); - const result = await runWithProgress(invoke, { - io, - label: "Invoking harness...", - interactive: !ctx.require(JsonKey), - }); - invokeCtx.require(JsonRendererKey).renderJson(result); - }, + const prompt = flags.prompt; + const invoke = () => + invokeHarnessTurn( + core.harness, + { harnessId, prompt, qualifier: flags.qualifier, sessionId }, + coreOptsFromCtx(ctx), + ); + const result = await runWithProgress(invoke, { + io, + label: "Invoking harness...", + interactive: !ctx.require(JsonKey), }); + ctx.require(JsonRendererKey).renderJson(result); +} diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index b8125865db..c822858dff 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -2,17 +2,16 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { PassThrough } from "node:stream"; -import type { InvokeHarnessRequest } from "@aws-sdk/client-bedrock-agentcore"; import type { GetAgentRuntimeResponse, + GetGatewayResponse, GetHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { ProjectBackend, ResolveDeployedResourcesBackendInput } from "../../../core/project"; +import type { ProjectBackend } from "../../../core/project"; import { ExitCode } from "../../../errors"; import { startHttpServer, type HttpServerHandle } from "../../../io"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { runWithExitCode } from "../../../runnable"; -import { ProjectKey, ValueContext, type Context } from "../../../router"; import { createSilentLogger, TestCoreClient, @@ -23,14 +22,9 @@ import { tick, waitFor, } from "../../../testing"; +import * as tui from "../../../tui"; import { createRootHandler } from "../../index"; -import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; -import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; -import type { RuntimeInvokeRequest } from "../../runtime/types"; -import type { Project } from "../types"; -import { createProjectInvokeHandler } from "."; -import { createProjectInvokeHarnessHandler } from "./harness"; -import { createProjectInvokeRuntimeHandler } from "./runtime"; +import { RegionKey } from "../../keys"; const servers: HttpServerHandle[] = []; const cleanups: Array<() => Promise> = []; @@ -40,14 +34,11 @@ const TARGET = { account: "111122223333", region: "eu-west-1", } as const; -const TARGET_CREDENTIALS = async () => ({ - accessKeyId: "target-access-key", - secretAccessKey: "target-secret-key", -}); +const arn = (resource: string) => + `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:${resource}`; const RUNTIME_ID = "checkout-AbCdEf1234"; -const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:runtime/${RUNTIME_ID}`; const HARNESS_ID = "support-AbCdEf1234"; -const HARNESS_ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:harness/${HARNESS_ID}`; +const GATEWAY_ID = "tools-AbCdEf1234"; const RUNTIME = { name: "checkout", build: "CodeZip", @@ -56,33 +47,23 @@ const RUNTIME = { runtimeVersion: "PYTHON_3_14", } as const; const HARNESS = { name: "support", path: "app/support" } as const; +const GATEWAY = { name: "tools", targets: [] } as const; -function body(...chunks: Uint8Array[]): AsyncIterable { - return (async function* () { - yield* chunks; - })(); -} +type Resources = { + runtimes?: readonly unknown[]; + harnesses?: readonly unknown[]; + agentCoreGateways?: readonly unknown[]; +}; function header(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value.join(", ") : value; } -async function inProject( - resources: { - runtimes?: unknown[]; - harnesses?: unknown[]; - }, - options: { writeTargets?: boolean } = {}, -): Promise { - const { path: root, cleanup } = await inTempDirectory("agentcore-project-invoke-reduced-"); +async function inProject(resources: Resources, options: { writeTargets?: boolean } = {}) { + const { path: root, cleanup } = await inTempDirectory("agentcore-project-invoke-"); cleanups.push(cleanup); await mkdir(join(root, "agentcore"), { recursive: true }); - const spec = ProjectSpecSchema.parse({ - name: "orders", - version: 2, - runtimes: resources.runtimes ?? [], - harnesses: resources.harnesses ?? [], - }); + const spec = ProjectSpecSchema.parse({ name: "orders", version: 2, ...resources }); await writeFile(join(root, "agentcore", "agentcore.json"), JSON.stringify(spec)); if (options.writeTargets !== false) { await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify([TARGET])); @@ -90,62 +71,96 @@ async function inProject( } function backend() { - const calls: ResolveDeployedResourcesBackendInput[] = []; + const targets: string[] = []; const value: ProjectBackend = { async *build() {}, async *deploy() { yield* []; return { outputs: {} }; }, - async resolveDeployedResources(project, input) { - calls.push(input); + async resolveDeployedResources() { + throw new Error("invoke resolves project resources, not deployed resources"); + }, + async resolveProjectResources(project, input) { + targets.push(input.target.name); return [ ...project.spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name, - id: RUNTIME_ID, - target: input.target, - credentialProvider: TARGET_CREDENTIALS, + deploymentState: "deployed" as const, + arn: arn(`runtime/${RUNTIME_ID}`), })), ...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name, - id: HARNESS_ID, - target: input.target, - credentialProvider: TARGET_CREDENTIALS, + deploymentState: "deployed" as const, + arn: arn(`harness/${HARNESS_ID}`), + })), + ...project.spec.agentCoreGateways.map(({ name }) => ({ + resourceType: "gateway" as const, + name, + deploymentState: "deployed" as const, + arn: arn(`gateway/${GATEWAY_ID}`), })), ]; }, - async resolveProjectResources() { - throw new Error("project invoke resolves deployed resources, not project resources"); - }, }; - return { calls, value }; + return { targets, value }; } -function configureCore(core: TestCoreClient): void { +async function routedCommand( + args: readonly string[], + resources: Resources | undefined, + options: { writeTargets?: boolean; isTTY?: boolean } = {}, +) { + if (resources) { + await inProject(resources, options); + } else { + cleanups.push((await inTempDirectory("agentcore-invoke-outside-")).cleanup); + } + const resolved = backend(); + const core = new TestCoreClient({ backends: { CDK: resolved.value } }); core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setGetResponse({ agentRuntimeArn: arn(`runtime/${RUNTIME_ID}`) } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "text/plain", - body: body(Buffer.from("runtime response")), + body: (async function* () { + yield Buffer.from("runtime response"); + })(), }); core.harness - .setGetResponse({ - harness: { harnessId: HARNESS_ID, harnessName: "support", arn: HARNESS_ARN }, - } as GetHarnessResponse) + .setGetResponse({ harness: { arn: arn(`harness/${HARNESS_ID}`) } } as GetHarnessResponse) .setInvokeEvents( { messageStart: { role: "assistant" } }, - { contentBlockDelta: { contentBlockIndex: 0, delta: { text: "harness response" } } }, - { contentBlockStop: { contentBlockIndex: 0 } }, { messageStop: { stopReason: "end_turn" } }, ); + core.gateway + .setGetResponse({ + gatewayArn: arn(`gateway/${GATEWAY_ID}`), + gatewayUrl: "https://tools.gateway.example.test/mcp", + authorizerType: "NONE", + } as GetGatewayResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: (async function* () { + yield Buffer.from("gateway response"); + })(), + }); + const io = testIO({ isTTY: options.isTTY }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const route = () => root.route(["node", "agentcore", "invoke", ...args, "--region", "us-east-1"]); + return { core, io, resolved, route }; } async function run( - args: string[], - resources: { runtimes?: unknown[]; harnesses?: unknown[] }, + args: readonly string[], + resources: Resources | undefined, options: { writeTargets?: boolean } = {}, ) { const subject = await routedCommand(args, resources, options); @@ -153,30 +168,17 @@ async function run( return subject; } -async function routedCommand( - args: string[], - resources: { runtimes?: unknown[]; harnesses?: unknown[] }, - options: { writeTargets?: boolean; isTTY?: boolean } = {}, -) { - await inProject(resources, options); - const resolved = backend(); - const core = new TestCoreClient({ backends: { CDK: resolved.value } }); - configureCore(core); - const io = testIO({ isTTY: options.isTTY }); - const root = createRootHandler(core, { - io: io.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - const route = () => root.route(["node", "agentcore", "invoke", ...args]); - return { core, io, resolved, route }; -} - -function context(project: Project): Context { - return ValueContext.EmptyContext() - .withValue(ProjectKey, project) - .withValue(JsonKey, false) - .withValue(RegionKey, "us-east-1"); +async function launches(args: readonly string[], resources: Resources | undefined) { + const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); + try { + const subject = await run(args, resources); + return { + ...subject, + launches: render.mock.calls.map(([path, ctx]) => ({ path, region: ctx.value(RegionKey) })), + }; + } finally { + render.mockRestore(); + } } afterEach(async () => { @@ -184,70 +186,173 @@ afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); -describe("project invoke", () => { - test.each([false, true])( - "reads stdin before progress, then hands off to the first Runtime chunk (local=%s)", - async (local) => { - const stream = new StreamController(); - const server = local - ? await startHttpServer(() => ({ - status: 200, - headers: { "Content-Type": "text/plain" }, - body: stream, - })) - : undefined; - if (server) servers.push(server); - const subject = await routedCommand( - [ - "runtime", - "--payload", - "-", - ...(server ? ["--local", "--port", String(server.port)] : []), - ], - { runtimes: [RUNTIME] }, - { isTTY: true }, - ); - subject.core.runtime.setInvokeResponse({ - statusCode: 200, - contentType: "text/plain", - body: stream, +describe("invoke", () => { + test.each([ + ["runtime", ["--runtime", "checkout", "--payload", "{}"], "runtime", "invokeRuntime"], + ["harness", ["--harness", "support", "--prompt", "hi"], "harness", "invokeHarness"], + [ + "gateway", + ["--gateway", "tools", "--path", "/mcp", "--payload", "{}"], + "gateway", + "invokeGateway", + ], + ] as const)( + "invokes a project %s by name in its deployed region", + async (_type, args, client, method) => { + const { core, resolved } = await run([...args], { + runtimes: [RUNTIME], + harnesses: [HARNESS], + agentCoreGateways: [GATEWAY], }); - const stdin = subject.io.io.stdin as unknown as PassThrough; - const pending = subject.route(); - try { - await waitFor(() => stdin.listenerCount("readable") > 0); - stdin.write("{}"); - await tick(120); - expect(subject.io.stderr()).toBe(""); - stdin.end(); - await waitFor(() => subject.io.stderr().includes("Invoking runtime...")); - expect(subject.io.stdout()).toBe(""); - stream.emit(Buffer.from("first")); - await waitFor(() => subject.io.stdout() === "first"); - const afterFirstChunk = subject.io.stderr(); - await tick(120); - expect(subject.io.stderr()).toBe(afterFirstChunk); - } finally { - stdin.end(); - stream.emit(Buffer.from("second")); - stream.end(); - await pending; - } - expect(subject.io.stdout()).toBe("firstsecond"); + + const invoke = core[client].calls.find((call) => call.method === method)!; + expect(invoke.args[1]).toEqual({ region: TARGET.region }); + expect(resolved.targets).toEqual(["default"]); }, ); - test.each([false, true])("keeps machine output free of progress (json=%s)", async (json) => { + test.each([ + [["--runtime", "checkout"], `/agentcore/invoke/runtime/${RUNTIME_ID}`], + [ + ["--harness", "support", "--qualifier", "prod"], + `/agentcore/invoke/harness/${HARNESS_ID}?qualifier=prod`, + ], + [["--gateway", "tools"], `/agentcore/invoke/gateway/${GATEWAY_ID}`], + [[], "/agentcore/invoke"], + ])("opens the TUI for %j", async (args, path) => { + const subject = await launches(args, { + runtimes: [RUNTIME], + harnesses: [HARNESS], + agentCoreGateways: [GATEWAY], + }); + + expect(subject.launches).toEqual([ + { path, region: path === "/agentcore/invoke" ? "us-east-1" : TARGET.region }, + ]); + }); + + test.each([ + ["the sole Runtime", { runtimes: [RUNTIME] }, `/agentcore/invoke/runtime/${RUNTIME_ID}`], + [ + "the sole Gateway", + { agentCoreGateways: [GATEWAY] }, + `/agentcore/invoke/gateway/${GATEWAY_ID}`, + ], + ["nothing outside a project", undefined, "/agentcore/invoke"], + ] as const)("a bare invoke selects %s", async (_name, resources, path) => { + const subject = await launches([], resources); + + expect(subject.launches.map((launch) => launch.path)).toEqual([path]); + }); + + test("resolves the --target deployment target for project names", async () => { + const { resolved } = await run( + ["--runtime", "checkout", "--payload", "{}", "--target", "default"], + { + runtimes: [RUNTIME], + }, + ); + + expect(resolved.targets).toEqual(["default"]); + }); + + test.each([ + { + name: "more than one resource flag", + args: ["--runtime", "checkout", "--harness", "support"], + resources: { runtimes: [RUNTIME], harnesses: [HARNESS] }, + message: "--runtime, --harness are mutually exclusive", + }, + { + name: "a bare --json invoke with several resources", + args: ["--json"], + resources: { runtimes: [RUNTIME], harnesses: [HARNESS] }, + message: "Choose a resource to invoke: --runtime checkout, --harness support.", + }, + { + name: "a bare --json invoke in an empty project", + args: ["--json"], + resources: {}, + message: "This project has no Runtimes, harnesses, or Gateways to invoke.", + }, + { + name: "a bare --json invoke outside a project", + args: ["--json"], + resources: undefined, + message: + "or any parent directory. Run from inside a project, or pass --runtime, --harness, or --gateway with an ID or ARN.", + }, + { + name: "a flag that belongs to another resource type", + args: ["--runtime", "checkout", "--prompt", "hi"], + resources: { runtimes: [RUNTIME] }, + message: "--prompt does not apply to a Runtime", + }, + { + name: "--local for a harness", + args: ["--harness", "support", "--local"], + resources: { harnesses: [HARNESS] }, + message: "--local does not apply to a Harness", + }, + { + name: "--target with an ID", + args: ["--gateway", GATEWAY_ID, "--target", "default"], + resources: { agentCoreGateways: [GATEWAY] }, + message: "--target only applies to project resources", + }, + { + name: "--local outside a project", + args: ["--runtime", RUNTIME_ID, "--local", "--payload", "{}"], + resources: undefined, + message: "--local only applies to project resources", + }, + { + name: "a harness session ID that is too short", + args: ["--harness", "support", "--prompt", "hi", "--session-id", "short"], + resources: { harnesses: [HARNESS] }, + message: "--session-id must be 33-100 characters for a harness", + }, + ])("rejects $name", async ({ args, resources, message }) => { + const subject = await routedCommand(args, resources); + + await expect(subject.route()).rejects.toThrow(message); + }); + + test("reads stdin before progress, then hands off to the first local Runtime chunk", async () => { + const stream = new StreamController(); + const server = await startHttpServer(() => ({ + status: 200, + headers: { "Content-Type": "text/plain" }, + body: stream, + })); + servers.push(server); const subject = await routedCommand( - ["runtime", "--payload", "{}", ...(json ? ["--json"] : [])], + ["--local", "--port", String(server.port), "--payload", "-"], { runtimes: [RUNTIME] }, - { isTTY: json }, + { isTTY: true }, ); - await subject.route(); - expect(subject.io.stderr()).not.toContain("Invoking"); - expect(subject.io.stderr()).not.toContain("\u001b"); - if (json) expect(JSON.parse(subject.io.stdout()).body).toBe("runtime response"); - else expect(subject.io.stdout()).toBe("runtime response"); + const stdin = subject.io.io.stdin as unknown as PassThrough; + const pending = subject.route(); + try { + await waitFor(() => stdin.listenerCount("readable") > 0); + stdin.write("{}"); + await tick(120); + expect(subject.io.stderr()).toBe(""); + stdin.end(); + await waitFor(() => subject.io.stderr().includes("Invoking runtime...")); + expect(subject.io.stdout()).toBe(""); + stream.emit(Buffer.from("first")); + await waitFor(() => subject.io.stdout() === "first"); + const afterFirstChunk = subject.io.stderr(); + await tick(120); + expect(subject.io.stderr()).toBe(afterFirstChunk); + } finally { + stdin.end(); + stream.emit(Buffer.from("second")); + stream.end(); + await pending; + } + expect(subject.io.stdout()).toBe("firstsecond"); }); test("auto-selects the sole local Runtime without resolving deployed resources", async () => { @@ -282,7 +387,7 @@ describe("project invoke", () => { const payload = '{"prompt":"hi"}'; const { core, io, resolved } = await run( - ["runtime", "--local", "--port", String(server.port), "--payload", payload], + ["--local", "--port", String(server.port), "--payload", payload], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -300,7 +405,7 @@ describe("project invoke", () => { }); expect(io.stdout()).toBe("local response"); expect(io.stderr()).toContain(`runtime-session-id=${request?.sessionId}`); - expect(resolved.calls).toEqual([]); + expect(resolved.targets).toEqual([]); expect(core.runtime.calls).toEqual([]); }); @@ -346,10 +451,9 @@ describe("project invoke", () => { await run( [ - "runtime", - "--local", - "--name", + "--runtime", RUNTIME.name, + "--local", "--port", String(server.port), "--payload", @@ -392,7 +496,7 @@ describe("project invoke", () => { try { const code = await runWithExitCode(async () => { await run( - ["runtime", "--local", "--name", RUNTIME.name, "--port", String(port), "--payload", "{}"], + ["--runtime", RUNTIME.name, "--local", "--port", String(port), "--payload", "{}"], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -416,16 +520,7 @@ describe("project invoke", () => { })); servers.push(server); const subject = await routedCommand( - [ - "runtime", - "--local", - "--name", - RUNTIME.name, - "--port", - String(server.port), - "--payload", - "{}", - ], + ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", "{}"], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -489,10 +584,9 @@ describe("project invoke", () => { await run( [ - "runtime", - "--local", - "--name", + "--runtime", name, + "--local", "--port", String(server.port), "--payload", @@ -517,31 +611,22 @@ describe("project invoke", () => { test.each([ { name: "requires --local with --port", - args: ["runtime", "--port", "8081", "--payload", "{}"], + args: ["--port", "8081", "--payload", "{}"], message: "--port requires --local", }, { name: "rejects deployed-only flags locally", - args: ["runtime", "--local", "--name", RUNTIME.name, "--payload", "{}", "--target", "prod"], + args: ["--runtime", RUNTIME.name, "--local", "--payload", "{}", "--target", "prod"], message: "--target cannot be used with --local", }, { name: "requires a local payload", - args: ["runtime", "--local", "--name", RUNTIME.name], + args: ["--runtime", RUNTIME.name, "--local"], message: "required option '--payload ' not specified", }, { name: "rejects MCP options for a local non-MCP Runtime", - args: [ - "runtime", - "--local", - "--name", - RUNTIME.name, - "--payload", - "{}", - "--mcp-method", - "tools/list", - ], + args: ["--runtime", RUNTIME.name, "--local", "--payload", "{}", "--mcp-method", "tools/list"], message: "MCP options are only valid for MCP Runtimes", }, ])("$name", async ({ args, message }) => { @@ -549,168 +634,4 @@ describe("project invoke", () => { message, ); }); - - test("requires --name when invoking one of multiple local Runtimes", async () => { - await expect( - run( - ["runtime", "--local", "--payload", "{}"], - { - runtimes: [RUNTIME, { ...RUNTIME, name: "inventory" }], - }, - { writeTargets: false }, - ), - ).rejects.toThrow("Project has multiple Runtimes. Specify --name: checkout, inventory."); - }); - - test("invokes the sole Runtime with its existing payload contract in the target region", async () => { - const payload = '{"custom":"wire shape"}'; - const { core, io, resolved } = await run( - ["runtime", "--payload", payload, "--content-type", "application/custom+json"], - { runtimes: [RUNTIME] }, - ); - - const request = core.runtime.calls.find(({ method }) => method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(new TextDecoder().decode(request.payload)).toBe(payload); - expect(request.contentType).toBe("application/custom+json"); - expect(request.runtimeUserId).toBe("default"); - expect(core.runtime.calls.at(-1)!.args[1]).toEqual({ - region: TARGET.region, - credentials: TARGET_CREDENTIALS, - }); - expect(io.stdout()).toBe("runtime response"); - expect(resolved.calls).toEqual([{ target: TARGET }]); - }); - - test("invokes a named Harness with its existing prompt contract in the target region", async () => { - const { core, io } = await run(["harness", "--name", "support", "--prompt", "hello"], { - harnesses: [HARNESS], - }); - - const request = core.harness.calls.find(({ method }) => method === "invokeHarness")! - .args[0] as InvokeHarnessRequest; - expect(request).toMatchObject({ - harnessArn: HARNESS_ARN, - qualifier: "DEFAULT", - messages: [{ role: "user", content: [{ text: "hello" }] }], - }); - expect(core.harness.calls.at(-1)!.args[1]).toEqual({ - region: TARGET.region, - credentials: TARGET_CREDENTIALS, - }); - expect(JSON.parse(io.stdout()).transcript).toContainEqual({ - kind: "text", - text: "harness response", - streaming: false, - }); - }); - - test("requires --name when the project has multiple Runtimes", async () => { - await expect( - run(["runtime", "--payload", "{}"], { - runtimes: [RUNTIME, { ...RUNTIME, name: "inventory" }], - }), - ).rejects.toThrow(/multiple Runtimes.*--name.*checkout, inventory/s); - }); - - test("opens the existing Runtime TUI for bare and TUI-compatible Runtime invokes", async () => { - await inProject({ runtimes: [RUNTIME] }); - const resolved = backend(); - const core = new TestCoreClient({ backends: { CDK: resolved.value } }); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const launches: { path: string; context: Context }[] = []; - const handler = createProjectInvokeRuntimeHandler(core, testIO().io, async (path, ctx) => { - launches.push({ path, context: ctx }); - }); - - const bareFlags = { - name: undefined, - local: false, - port: undefined, - target: undefined, - payload: undefined, - qualifier: undefined, - "content-type": undefined, - accept: undefined, - "session-id": undefined, - "user-id": undefined, - header: undefined, - "bearer-token": undefined, - "mcp-session-id": undefined, - "mcp-protocol-version": undefined, - "mcp-method": undefined, - "mcp-name": undefined, - "trace-id": undefined, - "trace-parent": undefined, - "trace-state": undefined, - baggage: undefined, - "output-file": undefined, - }; - - await handler.handle(context(project!), bareFlags, {}); - - expect(launches[0]!.path).toBe(`/agentcore/runtime/invoke/${RUNTIME_ID}`); - expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); - expect(launches[0]!.context.require(AwsCredentialProviderKey)).toBe(TARGET_CREDENTIALS); - expect(launches[0]!.context.require(RuntimeInvokeLaunchContextKey)).toMatchObject({ - runtimeId: RUNTIME_ID, - }); - - await handler.handle( - context(project!), - { - ...bareFlags, - name: "checkout", - target: "default", - "session-id": "project-session", - }, - {}, - ); - - expect(launches[1]!.context.require(RuntimeInvokeLaunchContextKey)).toMatchObject({ - runtimeId: RUNTIME_ID, - runtimeSessionId: "project-session", - }); - }); - - test("opens the existing Harness TUI with the resolved project Harness", async () => { - await inProject({ harnesses: [HARNESS] }); - const resolved = backend(); - const core = new TestCoreClient({ backends: { CDK: resolved.value } }); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const launches: { path: string; context: Context }[] = []; - const handler = createProjectInvokeHarnessHandler(core, testIO().io, async (path, ctx) => { - launches.push({ path, context: ctx }); - }); - - await handler.handle( - context(project!), - { - name: "support", - target: "default", - prompt: undefined, - "session-id": undefined, - qualifier: "prod", - }, - {}, - ); - - expect(launches[0]!.path).toBe(`/agentcore/harness/invoke/${HARNESS_ID}?qualifier=prod`); - expect(launches[0]!.context.require(RegionKey)).toBe(TARGET.region); - expect(launches[0]!.context.require(AwsCredentialProviderKey)).toBe(TARGET_CREDENTIALS); - }); - - test("bare project invoke opens the project resource picker", async () => { - await inProject({ runtimes: [RUNTIME], harnesses: [HARNESS] }); - const core = new TestCoreClient(); - const project = await core.projectManager.resolve({ filePath: process.cwd() }); - const launches: string[] = []; - const handler = createProjectInvokeHandler(core, testIO().io, async (path) => { - launches.push(path); - }); - - await handler.defaultHandler()!.handle(context(project!), {}, {}); - - expect(launches).toEqual(["/agentcore/invoke"]); - }); }); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 3dbdfc82a3..324d164fa0 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -1,28 +1,285 @@ +import z from "zod"; +import { regionFromArn, serviceIdFromArn } from "../../../core/arn"; +import { InputValidationError, ProjectStateError } from "../../../errors"; import type { AppIO } from "../../../io"; -import { InputValidationError } from "../../../errors"; -import { Router } from "../../../router"; -import { renderTuiAt } from "../../../tui"; import { withProject } from "../../../middleware"; -import { JsonKey } from "../../keys"; +import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { createHandler, flag, ProjectKey, ProjectTargetKey, type FlagsOf } from "../../../router"; +import { renderTuiAt } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; -import { createProjectInvokeHarnessHandler } from "./harness"; -import { createProjectInvokeRuntimeHandler } from "./runtime"; +import { assertMutuallyExclusiveFlags, toResourceArn } from "../../utils"; +import { projectResourceNames, RESOURCE_LABELS } from "../selection"; +import type { Project, ProjectInvokableResource } from "../types"; +import { invokeGateway } from "./gateway"; +import { invokeHarness } from "./harness"; +import { invokeProjectRuntimeLocally, invokeRuntime } from "./runtime"; + +const RESOURCE = "Resource options:"; +const REQUEST = "Request options:"; +const RUNTIME = "Runtime options:"; +const GATEWAY = "Gateway options:"; +const MCP = "MCP options (Runtime, Gateway):"; + +const invokeFlags = [ + flag( + "runtime", + "the Runtime to invoke: a project name, ID, or ARN", + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag( + "harness", + "the harness to invoke: a project name, ID, or ARN", + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag( + "gateway", + "the Gateway to invoke: a project name, ID, or ARN", + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag( + "target", + `project deployment target (default: "${DEFAULT_TARGET_NAME}")`, + z.string().min(1).optional(), + { group: RESOURCE }, + ), + flag("local", "invoke the local development server (project Runtime only)", z.boolean(), { + group: RESOURCE, + }), + flag( + "port", + "local development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000)", + z.coerce.number().int().min(1).max(65535).optional(), + { group: RESOURCE }, + ), + flag("payload", "the inline payload to send", z.string().optional(), { + sensitive: true, + group: REQUEST, + }), + flag("prompt", "the message to send to a harness", z.string().optional(), { group: REQUEST }), + flag( + "session-id", + "the session ID to continue (33-100 characters for a harness)", + z.string().optional(), + { group: REQUEST }, + ), + flag("qualifier", "the endpoint qualifier (default DEFAULT)", z.string().optional(), { + group: REQUEST, + }), + flag("content-type", "the payload content type", z.string().optional(), { group: REQUEST }), + flag("accept", "the accepted response content type", z.string().optional(), { group: REQUEST }), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + group: REQUEST, + }), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + group: REQUEST, + }), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + { group: REQUEST }, + ), + flag("user-id", 'the Runtime user ID (default "default")', z.string().optional(), { + group: RUNTIME, + }), + flag("mcp-method", "the MCP method", z.string().optional(), { group: RUNTIME }), + flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional(), { + group: RUNTIME, + }), + flag("trace-id", "the X-Ray trace ID", z.string().optional(), { group: RUNTIME }), + flag("trace-parent", "the W3C trace parent", z.string().optional(), { group: RUNTIME }), + flag("trace-state", "the W3C trace state", z.string().optional(), { group: RUNTIME }), + flag("baggage", "the W3C baggage", z.string().optional(), { group: RUNTIME }), + flag( + "path", + "the path relative to the Gateway origin", + z.string().min(1, "requires a nonempty path").optional(), + { sensitive: true, group: GATEWAY }, + ), + flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional(), { + group: GATEWAY, + }), + flag("mcp-session-id", "the MCP session ID", z.string().optional(), { group: MCP }), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional(), { + group: MCP, + }), +] as const; + +export type InvokeFlags = FlagsOf; +type InvokeFlagName = keyof InvokeFlags; + +const RESOURCE_TYPES = ["runtime", "harness", "gateway"] as const; + +const REQUEST_FLAGS: Record = { + runtime: [ + "local", + "port", + "payload", + "session-id", + "qualifier", + "content-type", + "accept", + "header", + "bearer-token", + "output-file", + "user-id", + "mcp-method", + "mcp-name", + "trace-id", + "trace-parent", + "trace-state", + "baggage", + "mcp-session-id", + "mcp-protocol-version", + ], + harness: ["prompt", "session-id", "qualifier"], + gateway: [ + "payload", + "session-id", + "content-type", + "accept", + "header", + "bearer-token", + "output-file", + "path", + "method", + "mcp-session-id", + "mcp-protocol-version", + ], +}; + +const isSet = (value: unknown) => value !== undefined && value !== false; + +function selectResource( + project: Project | undefined, + flags: InvokeFlags, + headless: boolean, +): [ProjectInvokableResource, string] | undefined { + assertMutuallyExclusiveFlags(flags, RESOURCE_TYPES); + const given = RESOURCE_TYPES.find((resourceType) => flags[resourceType] !== undefined); + if (given) return [given, flags[given]!]; + + if (!project) { + if (!headless) return undefined; + throw new ProjectStateError( + `No AgentCore project found at ${process.cwd()} or any parent directory. ` + + "Run from inside a project, or pass --runtime, --harness, or --gateway with an ID or ARN.", + ); + } + + const declared = RESOURCE_TYPES.flatMap((resourceType) => + projectResourceNames(project, resourceType).map( + (name) => [resourceType, name] as [ProjectInvokableResource, string], + ), + ); + if (declared.length === 1) return declared[0]; + if (!headless) return undefined; + if (declared.length === 0) { + throw new InputValidationError( + "This project has no Runtimes, harnesses, or Gateways to invoke.", + ); + } + throw new InputValidationError( + `Choose a resource to invoke: ${declared.map(([type, name]) => `--${type} ${name}`).join(", ")}.`, + ); +} export function createProjectInvokeHandler( core: Core, io: AppIO, renderInvokeTui: typeof renderTuiAt = renderTuiAt, -): Router { - return new Router("invoke", "invoke a Runtime or harness from the current project") - .use(withProject({ projectManager: core.projectManager })) - .handler(createProjectInvokeRuntimeHandler(core, io, renderInvokeTui)) - .handler(createProjectInvokeHarnessHandler(core, io, renderInvokeTui)) - .default((ctx) => { - if (ctx.require(JsonKey)) { +) { + return createHandler({ + name: "invoke", + description: "invoke a Runtime, harness, or Gateway", + flags: invokeFlags, + middlewares: [withProject({ projectManager: core.projectManager, optional: true })], + examples: [ + { description: "Choose a project resource to invoke", command: "agentcore invoke" }, + { + description: "Send a payload to a project Runtime", + command: `agentcore invoke --runtime checkout --payload '{"prompt":"Hello"}'`, + }, + { + description: "Send a prompt to a harness by ID", + command: `agentcore invoke --harness support-AbCdEf1234 --prompt "Hello"`, + }, + { + description: "List the tools on a Gateway by ARN", + command: + "agentcore invoke --gateway arn:aws:bedrock-agentcore:us-west-2:111122223333:gateway/tools-AbCdEf1234 " + + `--path /mcp --payload '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'`, + }, + { + description: "Invoke a Runtime on the local development server", + command: `agentcore invoke --runtime checkout --local --payload '{"prompt":"Hello"}'`, + }, + ], + handle: async (ctx, flags) => { + const project = ctx.value(ProjectKey); + const headless = ctx.require(JsonKey) || Object.values(flags).some(isSet); + const selected = selectResource(project, flags, headless); + if (!selected) { + await renderInvokeTui("/agentcore/invoke", ctx, core, io); + return; + } + + const [resourceType, identifier] = selected; + const allowed: readonly string[] = [ + ...RESOURCE_TYPES, + "target", + ...REQUEST_FLAGS[resourceType], + ]; + const misplaced = Object.entries(flags).find( + ([name, value]) => isSet(value) && !allowed.includes(name), + ); + if (misplaced) { throw new InputValidationError( - "a Runtime or harness invoke subcommand is required with --json", + `--${misplaced[0]} does not apply to a ${RESOURCE_LABELS[resourceType]}`, ); } - return renderInvokeTui("/agentcore/invoke", ctx, core, io); - }); + const projectName = + project && projectResourceNames(project, resourceType).includes(identifier); + for (const name of ["target", "local"] as const) { + if (isSet(flags[name]) && !projectName) { + throw new InputValidationError(`--${name} only applies to project resources`); + } + } + if (ctx.require(JsonKey) && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } + if (!flags.local && flags.port !== undefined) { + throw new InputValidationError("--port requires --local"); + } + if (flags.local) { + const runtime = project!.spec.runtimes.find(({ name }) => name === identifier)!; + await invokeProjectRuntimeLocally(io, ctx, runtime, flags); + return; + } + + const arn = await toResourceArn( + core, + ctx.withValue(ProjectTargetKey, flags.target ?? DEFAULT_TARGET_NAME), + resourceType, + identifier, + ); + const invoke = { runtime: invokeRuntime, harness: invokeHarness, gateway: invokeGateway }[ + resourceType + ]; + await invoke( + core, + io, + ctx.withValue(RegionKey, regionFromArn(arn)!), + serviceIdFromArn(arn), + flags, + renderInvokeTui, + ); + }, + }); } diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index f93760d815..40f9171938 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AgentRuntimeEndpoint, GetAgentRuntimeResponse, + GetGatewayResponse, GetHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; @@ -16,7 +17,7 @@ import { waitForFlatText, waitForText, } from "../../../testing"; -import type { Project, ResolvedDeployedResource } from "../types"; +import type { Project, ResolvedProjectResource } from "../types"; const cleanups: Array<() => Promise> = []; afterEach(cleanupScreens); @@ -38,6 +39,7 @@ const project: Project = { }, ], harnesses: [{ name: "support", path: "app/support" }], + agentCoreGateways: [{ name: "tools", targets: [] }], }), }; @@ -59,42 +61,38 @@ function endpoint(name: string): AgentRuntimeEndpoint { // the invoke flows have to fetch where the project deployed. const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const; const STAGING = { name: "staging", account: "444455556666", region: "eu-central-1" } as const; -const TARGET_CREDENTIALS = async () => ({ - accessKeyId: "target-access-key", - secretAccessKey: "target-secret-key", -}); - -const DEPLOYED_RESOURCES: ResolvedDeployedResource[] = [ - { - resourceType: "runtime", - name: "checkout", - id: "runtime-123", - target: TARGET, - credentialProvider: TARGET_CREDENTIALS, - }, - { - resourceType: "harness", - name: "support", - id: "harness-123", - target: TARGET, - credentialProvider: TARGET_CREDENTIALS, - }, -]; +function deployed(region: string = TARGET.region): ResolvedProjectResource[] { + const arn = (resource: string) => `arn:aws:bedrock-agentcore:${region}:111122223333:${resource}`; + return [ + { + resourceType: "runtime", + name: "checkout", + deploymentState: "deployed", + arn: arn("runtime/runtime-123"), + }, + { + resourceType: "harness", + name: "support", + deploymentState: "deployed", + arn: arn("harness/harness-123"), + }, + { + resourceType: "gateway", + name: "tools", + deploymentState: "deployed", + arn: arn("gateway/gateway-123"), + }, + { resourceType: "memory", name: "notes", deploymentState: "deployed", arn: arn("memory/m") }, + ]; +} function core( - resources: ResolvedDeployedResource[] = DEPLOYED_RESOURCES, + resources: ResolvedProjectResource[] = deployed(), targets: AwsDeploymentTarget[] = [TARGET], ): TestCoreClient { const value = new TestCoreClient(); value.projectManager.listTargets = async () => targets; - value.projectManager.resolveDeployedResource = async (_project, input) => ({ - resourceType: input.resourceType, - name: input.name, - id: input.resourceType === "runtime" ? "runtime-123" : "harness-123", - target: TARGET, - credentialProvider: TARGET_CREDENTIALS, - }); - value.projectManager.resolveDeployedResources = async () => ({ resources, target: TARGET }); + value.projectManager.resolveProjectResources = async () => ({ resources, target: TARGET }); value.runtime .setListEndpointsResponse({ runtimeEndpoints: [endpoint("DEFAULT")] }) .setGetResponse({ @@ -107,6 +105,12 @@ function core( arn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:harness/harness-123", }, } as GetHarnessResponse); + value.gateway.setGetResponse({ + gatewayId: "gateway-123", + gatewayUrl: "https://gateway-123.gateway.example.test/mcp", + authorizerType: "NONE", + status: "READY", + } as GetGatewayResponse); return value; } @@ -114,13 +118,8 @@ describe("project invoke picker", () => { test("lists only resources present in the deployed target", async () => { const screen = renderScreen("/agentcore/invoke", { core: core([ - { - resourceType: "harness", - name: "support", - id: "harness-123", - target: TARGET, - credentialProvider: TARGET_CREDENTIALS, - }, + { resourceType: "runtime", name: "checkout", deploymentState: "local-only" }, + ...deployed().filter(({ resourceType }) => resourceType === "harness"), ]), withContext: (ctx) => ctx.withValue(ProjectKey, project), }); @@ -142,11 +141,11 @@ describe("project invoke picker", () => { }); test("several targets: asks which, then invokes on the chosen one in its region", async () => { - const value = core(DEPLOYED_RESOURCES, [TARGET, STAGING]); + const value = core(deployed(), [TARGET, STAGING]); let requested: string | undefined; - value.projectManager.resolveDeployedResources = async (_project, { target }) => { + value.projectManager.resolveProjectResources = async (_project, { target }) => { requested = target; - return { resources: DEPLOYED_RESOURCES, target: STAGING }; + return { resources: deployed(STAGING.region), target: STAGING }; }; const screen = renderScreen("/agentcore/invoke", { core: value, @@ -171,7 +170,7 @@ describe("project invoke picker", () => { test("shows deployment errors without listing configured resources", async () => { const value = core(); - value.projectManager.resolveDeployedResources = async () => { + value.projectManager.resolveProjectResources = async () => { throw new Error("No deployment targets are configured for project 'orders'."); }; const screen = renderScreen("/agentcore/invoke", { @@ -209,7 +208,7 @@ describe("project invoke picker", () => { expect(screen.lastFrame()).toContain("support"); }); - test("lists project Runtime and Harness resources", async () => { + test("lists project Runtime, Harness, and Gateway resources", async () => { const screen = renderScreen("/agentcore/invoke", { core: core(), withContext: (ctx) => ctx.withValue(ProjectKey, project), @@ -222,6 +221,9 @@ describe("project invoke picker", () => { expect(screen.lastFrame()).toContain("support"); expect(screen.lastFrame()).toContain("Harness"); expect(screen.lastFrame()).toContain("app/support"); + expect(screen.lastFrame()).toContain("tools"); + expect(screen.lastFrame()).toContain("Gateway"); + expect(screen.lastFrame()).not.toContain("notes"); }); test("opens the selected Harness chat in the same TUI", async () => { @@ -239,10 +241,29 @@ describe("project invoke picker", () => { expect(value.harness.calls.find(({ method }) => method === "getHarness")?.args[1]).toEqual({ region: TARGET.region, endpointUrl: undefined, - credentials: TARGET_CREDENTIALS, }); }); + test("opens the selected Gateway console and returns to the picker on esc", async () => { + const value = core(); + const screen = renderScreen("/agentcore/invoke", { + core: value, + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "checkout"); + await screen.press("down"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "gateway-123"); + expect(value.gateway.calls.find(({ method }) => method === "getGateway")?.args[1]).toEqual({ + region: TARGET.region, + endpointUrl: undefined, + }); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a project resource to invoke"); + }); + test("uses the existing Runtime endpoint picker before its JSON console", async () => { const value = core(); const screen = renderScreen("/agentcore/invoke", { @@ -261,7 +282,6 @@ describe("project invoke picker", () => { ).toEqual({ region: TARGET.region, endpointUrl: undefined, - credentials: TARGET_CREDENTIALS, }); }); }); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/project/invoke/runtime.test.tsx similarity index 79% rename from src/handlers/runtime/invoke/invoke.test.tsx rename to src/handlers/project/invoke/runtime.test.tsx index db1022a821..012d681bf0 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/project/invoke/runtime.test.tsx @@ -4,11 +4,10 @@ import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import type { AppIO } from "../../../io"; -import type { RuntimeInvokeRequest } from "../types"; +import type { RuntimeInvokeRequest } from "../../runtime/types"; import { IMPERATIVE_GLOBAL_CONFIG, createSilentLogger, - expectError, TestCoreClient, TestGlobalConfigAccessor, StreamController, @@ -17,10 +16,10 @@ import { waitFor, } from "../../../testing"; import { runWithExitCode } from "../../../runnable"; -import { ExitCode, InputValidationError, UserCancellationError } from "../../../errors"; +import { ExitCode, UserCancellationError } from "../../../errors"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; -import { RuntimeInvokeLaunchContextKey } from "./launchContext"; +import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; const REGION = "us-west-2"; const RUNTIME_ID = "runtime-123"; @@ -74,6 +73,14 @@ async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Prom await root.route(["node", "agentcore", ...args, "--region", REGION]); } +function runtimeCore(): TestCoreClient { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + return core; +} + +const methods = (core: TestCoreClient) => core.runtime.calls.map(({ method }) => method); + async function run( args: string[], { getResponse = { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse } = {}, @@ -91,11 +98,11 @@ async function run( return { core, output }; } -describe("runtime invoke", () => { +describe("invoke --runtime", () => { test.each(["payload", "bearer-token"] as const)( "reads TTY %s before starting progress", async (input) => { - const core = new TestCoreClient(); + const core = runtimeCore(); const stream = new StreamController(); core.runtime .setGetResponse({ @@ -106,9 +113,8 @@ describe("runtime invoke", () => { const io = testIO({ isTTY: true }); const stdin = io.io.stdin as unknown as PassThrough; const pending = runCommand(core, io.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", input === "payload" ? "-" : "{}", @@ -119,7 +125,7 @@ describe("runtime invoke", () => { stdin.write(input === "payload" ? "{}" : "test-token"); await tick(120); expect(io.stderr()).toBe(""); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); stdin.end(); await waitFor(() => io.stderr().includes("Invoking runtime...")); } finally { @@ -133,16 +139,15 @@ describe("runtime invoke", () => { test.each([false, true])( "hands progress to streamed output and respects JSON mode (json=%s)", async (json) => { - const core = new TestCoreClient(); + const core = runtimeCore(); const stream = new StreamController(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "text/plain", body: stream }); const io = testIO({ isTTY: true }); const pending = runCommand(core, io.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -169,19 +174,22 @@ describe("runtime invoke", () => { test("resolves the Runtime, invokes its ID in the current account, and writes exact bytes", async () => { const { core, output } = await run([ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", '{"prompt":"hello"}', ]); - expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime", "invokeRuntime"]); - const lookup = core.runtime.calls[0]!; + expect(core.runtime.calls.map((call) => call.method)).toEqual([ + "getRuntime", + "getRuntime", + "invokeRuntime", + ]); + const lookup = core.runtime.calls[1]!; expect(lookup.args.slice(0, 2)).toEqual([RUNTIME_ID, { region: REGION }]); - const invoke = core.runtime.calls[1]!; + const invoke = core.runtime.calls[2]!; const request = invoke.args[0] as RuntimeInvokeRequest; expect(request).toEqual({ runtimeId: RUNTIME_ID, @@ -200,9 +208,8 @@ describe("runtime invoke", () => { test("passes the public request flags through the shared normalizer", async () => { const { core } = await run( [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -244,23 +251,22 @@ describe("runtime invoke", () => { test.each([ ["--content-type", "text/plain"], ["--output-file", "response.bin"], - ])("rejects request option %s without a payload before Core calls", async (flagName, value) => { - const core = new TestCoreClient(); + ])("rejects request option %s without a payload before invoking", async (flagName, value) => { + const core = runtimeCore(); const output = captureIO(); await expect( - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, flagName, value]), + runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, flagName, value]), ).rejects.toThrow(/--payload/); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); }); test("rejects an empty --output-file before Core calls", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); await expect( runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -272,13 +278,12 @@ describe("runtime invoke", () => { }); test("rejects --json with --output-file before Core calls", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); await expect( runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -292,7 +297,7 @@ describe("runtime invoke", () => { test("buffers a streaming response when --json is requested", async () => { let iterations = 0; - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) @@ -306,9 +311,8 @@ describe("runtime invoke", () => { }); await runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -326,20 +330,19 @@ describe("runtime invoke", () => { }); test("passes an explicitly empty payload as zero bytes", async () => { - const { core } = await run(["runtime", "invoke", "--id", RUNTIME_ID, "--payload", ""]); + const { core } = await run(["invoke", "--runtime", RUNTIME_ID, "--payload", ""]); const invoke = core.runtime.calls.find((call) => call.method === "invokeRuntime")!; expect((invoke.args[0] as RuntimeInvokeRequest).payload).toEqual(new Uint8Array()); }); test("SIGINT cancels payload stdin resolution with the typed reason", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); const initialListeners = process.listenerCount("SIGINT"); const pending = runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "-", @@ -350,17 +353,18 @@ describe("runtime invoke", () => { process.emit("SIGINT", "SIGINT"); await expect(pending).rejects.toBeInstanceOf(UserCancellationError); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); } finally { await pending.catch(() => undefined); } }); test("SIGINT replaces a raw Runtime lookup abort with the typed reason", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); core.runtime.getRuntime = async (id, options, signal) => { core.runtime.calls.push({ method: "getRuntime", args: [id, options, signal] }); + if (!signal) return { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse; return new Promise((_, reject) => { const abort = () => reject(Object.assign(new Error("lookup aborted"), { name: "AbortError" })); @@ -369,29 +373,28 @@ describe("runtime invoke", () => { }); }; const pending = runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", ]); try { - await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntime")); + await waitFor(() => core.runtime.calls.length === 2); process.emit("SIGINT", "SIGINT"); - const signal = core.runtime.calls[0]!.args[2] as AbortSignal; + const signal = core.runtime.calls[1]!.args[2] as AbortSignal; expect(signal.reason).toBeInstanceOf(UserCancellationError); await expect(pending).rejects.toBe(signal.reason); - expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); + expect(methods(core)).toEqual(["getRuntime", "getRuntime"]); } finally { await pending.catch(() => undefined); } }); test("SIGINT aborts an active headless invocation after preserving emitted bytes", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); core.runtime.invokeRuntime = async (request, options, signal) => { @@ -411,9 +414,8 @@ describe("runtime invoke", () => { }; }; const pending = runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -436,7 +438,7 @@ describe("runtime invoke", () => { }); test("replaces a raw Core abort with the typed SIGINT reason", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); const rawAbort = Object.assign(new Error("transport aborted"), { name: "AbortError" }); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); @@ -449,9 +451,8 @@ describe("runtime invoke", () => { }); }; const pending = runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -470,33 +471,9 @@ describe("runtime invoke", () => { } }); - test("rejects an invalid Runtime ARN before Core calls", async () => { - const core = new TestCoreClient(); - const output = captureIO(); - - const code = await runWithExitCode(async () => - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ARN, "--payload", "{}"]), - ); - - expect(code).toBe(ExitCode.USAGE); - expect(core.runtime.calls).toEqual([]); - }); - - test("rejects a missing --id before Core calls", async () => { - const core = new TestCoreClient(); - const output = captureIO(); - - await expectError( - runCommand(core, output.io, ["runtime", "invoke", "--payload", "{}"]), - /--id/, - InputValidationError, - ); - expect(core.runtime.calls).toEqual([]); - }); - test("aborts an established request when a TTY refuses binary output", async () => { let iterations = 0; - const core = new TestCoreClient(); + const core = runtimeCore(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ @@ -511,7 +488,7 @@ describe("runtime invoke", () => { Object.defineProperty(output.io.stdout, "isTTY", { value: true }); await expect( - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), + runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--payload", "{}"]), ).rejects.toThrow("Binary or unknown response content requires --output-file or --json"); const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! @@ -521,82 +498,72 @@ describe("runtime invoke", () => { }); test("rejects a resolved Runtime ARN without an account ID before invoke", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); core.runtime.setGetResponse({ agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}::runtime/${RUNTIME_ID}`, } as GetAgentRuntimeResponse); const output = captureIO(); await expect( - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), + runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--payload", "{}"]), ).rejects.toThrow("Runtime returned an invalid ARN"); - expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); - }); - - test("a bare command enters existing TUI middleware without Runtime Core calls", async () => { - const core = new TestCoreClient(); - const output = captureIO(); - - await expect(runCommand(core, output.io, ["runtime", "invoke"])).rejects.toThrow( - "interactive mode requires a TTY on stdin and stdout", - ); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime", "getRuntime"]); }); test("classifies the TUI requirement as usage at the handler boundary", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), + runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID]), ); expect(code).toBe(ExitCode.USAGE); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); }); test("preserves unexpected TUI rendering failures", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); const failure = new TypeError("render failed"); const render = spyOn(tui, "renderTuiAt").mockRejectedValue(failure); try { - await expect( - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), - ).rejects.toBe(failure); - expect(core.runtime.calls).toEqual([]); + await expect(runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID])).rejects.toBe( + failure, + ); + expect(methods(core)).toEqual(["getRuntime"]); } finally { render.mockRestore(); } }); test("handler deep-links id-only and qualified invokes with encoded path segments", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); + core.runtime.setGetResponse({ + agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:runtime/runtime/blue one`, + } as GetAgentRuntimeResponse); const output = captureIO(); const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); try { - await runCommand(core, output.io, ["runtime", "invoke", "--id", "runtime/blue one"]); + await runCommand(core, output.io, ["invoke", "--runtime", "runtime/blue one"]); await runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", "runtime/blue one", "--session-id", "session/one two", ]); await runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", "runtime/blue one", "--qualifier", "prod/green one", ]); await runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", "runtime/blue one", "--qualifier", "prod/green one", @@ -605,10 +572,10 @@ describe("runtime invoke", () => { ]); expect(render.mock.calls.map(([path]) => path)).toEqual([ - "/agentcore/runtime/invoke/runtime%2Fblue%20one", - "/agentcore/runtime/invoke/runtime%2Fblue%20one", - "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", - "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", + "/agentcore/invoke/runtime/runtime%2Fblue%20one", + "/agentcore/invoke/runtime/runtime%2Fblue%20one", + "/agentcore/invoke/runtime/runtime%2Fblue%20one/prod%2Fgreen%20one", + "/agentcore/invoke/runtime/runtime%2Fblue%20one/prod%2Fgreen%20one", ]); expect(render.mock.calls[1]![1].value(RuntimeInvokeLaunchContextKey)).toMatchObject({ runtimeId: "runtime/blue one", @@ -624,15 +591,14 @@ describe("runtime invoke", () => { }); test("passes launch identity, authentication, and headers to the TUI without a payload", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); try { await runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--user-id", "user-123", @@ -650,61 +616,57 @@ describe("runtime invoke", () => { applicationHeaders: [["X-Tenant", "retail"]], bearerToken: "secret-token", }); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); } finally { render.mockRestore(); } }); test("rejects a stdin bearer token when launching the TUI", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); await expect( - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--bearer-token", "-"]), + runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--bearer-token", "-"]), ).rejects.toThrow("stdin bearer tokens are not available"); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); }); test("handler keeps JSON mode without a payload as a usage error", async () => { - const core = new TestCoreClient(); + const core = runtimeCore(); const output = captureIO(); await expect( - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--json"]), + runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--json"]), ).rejects.toThrow(/--payload/); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); }); test.each<[string, ...string[]]>([ ["malformed", "missing separator"], ["duplicate", "X-Test: one", "x-test: two"], ["reserved", "Authorization: secret"], - ])( - "rejects %s headers as input failures before Runtime Core calls", - async (_name, ...headers) => { - const core = new TestCoreClient(); - const output = captureIO(); - const args = ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]; - for (const header of headers) args.push("--header", header); - - const code = await runWithExitCode(async () => runCommand(core, output.io, args)); - - expect(code).toBe(ExitCode.USAGE); - expect(core.runtime.calls).toEqual([]); - }, - ); + ])("rejects %s headers as input failures before invoking", async (_name, ...headers) => { + const core = runtimeCore(); + const output = captureIO(); + const args = ["invoke", "--runtime", RUNTIME_ID, "--payload", "{}"]; + for (const header of headers) args.push("--header", header); - test("reports an unreadable payload file as an input failure before Runtime Core calls", async () => { - const core = new TestCoreClient(); + const code = await runWithExitCode(async () => runCommand(core, output.io, args)); + + expect(code).toBe(ExitCode.USAGE); + expect(methods(core)).toEqual(["getRuntime"]); + }); + + test("reports an unreadable payload file as an input failure before invoking", async () => { + const core = runtimeCore(); const output = captureIO(); const missing = join(tmpdir(), `missing-runtime-payload-${process.pid}`); await expect( runCommand(core, output.io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", `file://${missing}`, @@ -714,7 +676,7 @@ describe("runtime invoke", () => { message: `could not read '--payload' from file '${missing}'`, exitCode: ExitCode.USAGE, }); - expect(core.runtime.calls).toEqual([]); + expect(methods(core)).toEqual(["getRuntime"]); }); test("keeps Core, transport, and output TypeErrors as failures", async () => { @@ -738,9 +700,8 @@ describe("runtime invoke", () => { const lookupCode = await runWithExitCode(async () => runCommand(lookupCore, captureIO().io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -748,9 +709,8 @@ describe("runtime invoke", () => { ); const transportCode = await runWithExitCode(async () => runCommand(transportCore, captureIO().io, [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", @@ -758,9 +718,8 @@ describe("runtime invoke", () => { ); const outputCode = await runWithExitCode(async () => runCommand(outputCore, failingStdoutIO(), [ - "runtime", "invoke", - "--id", + "--runtime", RUNTIME_ID, "--payload", "{}", diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx index 113f0c63df..2f4432d686 100644 --- a/src/handlers/project/invoke/runtime.tsx +++ b/src/handlers/project/invoke/runtime.tsx @@ -1,13 +1,12 @@ -import z from "zod"; import { ExitCode, InputValidationError, RuntimeInvokeResponseError } from "../../../errors"; import { invokeLocalRuntime } from "../../../core/dev/localInvoke"; import { DEV_PORTS } from "../../../core/dev/port"; import type { AppIO } from "../../../io"; +import type { Context } from "../../../router"; import { withUserCancellation } from "../../../runnable"; -import { createHandler, flag, ProjectKey } from "../../../router"; -import { renderTuiAt } from "../../../tui"; +import type { renderTuiAt } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; -import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; +import { JsonKey } from "../../keys"; import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; import { invokeRuntimeTarget } from "../../runtime/invoke/operation"; import { @@ -18,250 +17,193 @@ import { import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { selectProjectResource } from "../selection"; +import type { Project } from "../types"; +import type { InvokeFlags } from "."; -export const createProjectInvokeRuntimeHandler = ( - core: Core, - io: AppIO, - renderInvokeTui: typeof renderTuiAt = renderTuiAt, -) => - createHandler({ - name: "runtime", - description: "invoke a Runtime from the current project", - flags: [ - flag("name", "the logical project Runtime name", z.string().optional()), - flag("local", "invoke a local Runtime development server", z.boolean()), - flag( - "port", - "local Runtime development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000)", - z.coerce.number().int().min(1).max(65535).optional(), - ), - flag("target", "project deployment target (default: default)", z.string().optional()), - flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), - flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("session-id", "the Runtime session ID", z.string().optional()), - flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { - sensitive: true, - }), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag("mcp-method", "the MCP method", z.string().optional()), - flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), - flag("trace-id", "the X-Ray trace ID", z.string().optional()), - flag("trace-parent", "the W3C trace parent", z.string().optional()), - flag("trace-state", "the W3C trace state", z.string().optional()), - flag("baggage", "the W3C baggage", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], - handle: async (ctx, flags) => { - const project = ctx.require(ProjectKey); - const jsonOutput = ctx.require(JsonKey); +const TUI_FLAGS = [ + "runtime", + "target", + "local", + "qualifier", + "session-id", + "user-id", + "header", + "bearer-token", +]; - if (!flags.local && flags.port !== undefined) { - throw new InputValidationError("--port requires --local"); - } - if (flags.local) { - if (jsonOutput && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } - const name = selectProjectResource(project, "runtime", flags.name, "invoke"); - const runtime = project.spec.runtimes.find((candidate) => candidate.name === name)!; - const protocol = runtime.protocol ?? "HTTP"; - const unsupportedFlag = Object.entries({ - target: flags.target, - qualifier: flags.qualifier, - "bearer-token": flags["bearer-token"], - }).find(([, value]) => value !== undefined)?.[0]; - if (unsupportedFlag !== undefined) { - throw new InputValidationError(`--${unsupportedFlag} cannot be used with --local`); - } - if ( - protocol !== "MCP" && - [ - flags["mcp-session-id"], - flags["mcp-protocol-version"], - flags["mcp-method"], - flags["mcp-name"], - ].some((value) => value !== undefined) - ) { - throw new InputValidationError("MCP options are only valid for MCP Runtimes"); - } - if (flags.payload === undefined) { - throw new InputValidationError("required option '--payload ' not specified", { - exitCode: ExitCode.USAGE, - }); - } +export async function invokeProjectRuntimeLocally( + io: AppIO, + ctx: Context, + runtime: Project["spec"]["runtimes"][number], + flags: InvokeFlags, +): Promise { + const jsonOutput = ctx.require(JsonKey); + const protocol = runtime.protocol ?? "HTTP"; + const unsupportedFlag = Object.entries({ + target: flags.target, + qualifier: flags.qualifier, + "bearer-token": flags["bearer-token"], + }).find(([, value]) => value !== undefined)?.[0]; + if (unsupportedFlag !== undefined) { + throw new InputValidationError(`--${unsupportedFlag} cannot be used with --local`); + } + if ( + protocol !== "MCP" && + [ + flags["mcp-session-id"], + flags["mcp-protocol-version"], + flags["mcp-method"], + flags["mcp-name"], + ].some((value) => value !== undefined) + ) { + throw new InputValidationError("MCP options are only valid for MCP Runtimes"); + } + if (flags.payload === undefined) { + throw new InputValidationError("required option '--payload ' not specified", { + exitCode: ExitCode.USAGE, + }); + } - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const response = await invokeLocalRuntime( - { - port: flags.port ?? DEV_PORTS[protocol], - protocol, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }, - signal, - ); - // Local agent error bodies are useful diagnostics, so write them before returning nonzero. - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new RuntimeInvokeResponseError(`HTTP ${response.statusCode}`); - } - }; - await withUserCancellation(async (signal) => { - const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload! }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking runtime...", - interactive: !jsonOutput, - }); - }); - return; - } + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const invoke = async ( + signal: AbortSignal, + beforeOutput: () => Promise, + sources: Awaited>, + ) => { + const response = await invokeLocalRuntime( + { + port: flags.port ?? DEV_PORTS[protocol], + protocol, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }, + signal, + ); + // Local agent error bodies are useful diagnostics, so write them before returning nonzero. + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal, + beforeOutput, + }); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new RuntimeInvokeResponseError(`HTTP ${response.statusCode}`); + } + }; + await withUserCancellation(async (signal) => { + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload! }, + io.stdin, + signal, + ); + return runWithProgress((stop) => invoke(signal, stop, sources), { + io, + label: "Invoking runtime...", + interactive: !jsonOutput, + }); + }); +} - const name = selectProjectResource(project, "runtime", flags.name, "invoke"); - const deployed = await core.projectManager.resolveDeployedResource(project, { - target: flags.target ?? "default", - resourceType: "runtime", - name, +export async function invokeRuntime( + core: Core, + io: AppIO, + ctx: Context, + runtimeId: string, + flags: InvokeFlags, + renderInvokeTui: typeof renderTuiAt, +): Promise { + const jsonOutput = ctx.require(JsonKey); + if (flags.payload === undefined) { + const hasHeadlessOnlyFlag = Object.entries(flags).some( + ([flagName, value]) => !TUI_FLAGS.includes(flagName) && value !== undefined, + ); + if (jsonOutput || hasHeadlessOnlyFlag) { + throw new InputValidationError("required option '--payload ' not specified", { + exitCode: ExitCode.USAGE, }); - const invokeCtx = ctx - .withValue(RegionKey, deployed.target.region) - .withValue(AwsCredentialProviderKey, deployed.credentialProvider); - - if (flags.payload === undefined) { - const hasHeadlessOnlyFlag = Object.entries(flags).some( - ([flagName, value]) => - ![ - "name", - "local", - "target", - "qualifier", - "payload", - "session-id", - "user-id", - "header", - "bearer-token", - ].includes(flagName) && value !== undefined, - ); - if (invokeCtx.require(JsonKey) || hasHeadlessOnlyFlag) { - throw new InputValidationError("required option '--payload ' not specified", { - exitCode: ExitCode.USAGE, - }); - } - let path = `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}`; - if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const bearerToken = await resolveRuntimeInvokeTuiBearerToken( - flags["bearer-token"], - io.stdin, - ); - await renderInvokeTui( - path, - invokeCtx.withValue(RuntimeInvokeLaunchContextKey, { - runtimeId: deployed.id, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken, - }), - core, - io, - ); - return; - } + } + let path = `/agentcore/invoke/runtime/${encodeURIComponent(runtimeId)}`; + if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const bearerToken = await resolveRuntimeInvokeTuiBearerToken(flags["bearer-token"], io.stdin); + await renderInvokeTui( + path, + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken, + }), + core, + io, + ); + return; + } - if (jsonOutput && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const response = await invokeRuntimeTarget( - core.runtime, - { - runtimeId: deployed.id, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }, - coreOptsFromCtx(invokeCtx), - signal, - ); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - }; - await withUserCancellation(async (signal) => { - const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload!, bearerToken: flags["bearer-token"] }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking runtime...", - interactive: !jsonOutput, - }); - }); - }, + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const invoke = async ( + signal: AbortSignal, + beforeOutput: () => Promise, + sources: Awaited>, + ) => { + const response = await invokeRuntimeTarget( + core.runtime, + { + runtimeId, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }, + coreOptsFromCtx(ctx), + signal, + ); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal, + beforeOutput, + }); + }; + await withUserCancellation(async (signal) => { + const sources = await resolveRuntimeInvokeSources( + { payload: flags.payload!, bearerToken: flags["bearer-token"] }, + io.stdin, + signal, + ); + return runWithProgress((stop) => invoke(signal, stop, sources), { + io, + label: "Invoking runtime...", + interactive: !jsonOutput, + }); }); +} diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 96ea15319b..808dbae426 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -7,19 +7,22 @@ import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; import { Spinner } from "../../../components/ui/spinner"; import { glyphs } from "../../../components/ui/_core.js"; +import { regionFromArn, serviceIdFromArn } from "../../../core/arn"; import { ProjectKey, type Context } from "../../../router"; +import { GatewayInvokeConsole } from "../../gateway/invoke/screen"; import { HarnessChat } from "../../harness/invoke/screen"; -import { AwsCredentialProviderKey } from "../../keys"; +import { RegionKey } from "../../keys"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; -import type { Project, ResolvedDeployedResources } from "../types"; +import { RESOURCE_LABELS } from "../selection"; +import type { Project, ProjectInvokableResource, ResolvedProjectResources } from "../types"; import { ProjectGate } from "../ProjectGate"; type ProjectInvokableRow = Record & { - resourceType: "runtime" | "harness"; - type: "Runtime" | "Harness"; + resourceType: ProjectInvokableResource; + type: string; name: string; - id: string; + arn: string; protocol: string; source: string; }; @@ -33,10 +36,10 @@ const columns = [ type Destination = | { resourceType: "runtime"; id: string; ctx: Context; qualifier?: string } - | { resourceType: "harness"; id: string; ctx: Context }; + | { resourceType: "harness" | "gateway"; id: string; ctx: Context }; const BREADCRUMB = ["agentcore", "invoke"]; -const DESCRIPTION = "invoke a Runtime or harness from the current project"; +const DESCRIPTION = "invoke a Runtime, harness, or Gateway from the current project"; const PROJECT_MENU = "/agentcore"; // The project comes from the launch context when a project command opened the @@ -87,14 +90,14 @@ function ProjectInvokePicker({ targetName: string; onBack: () => void; }) { - const [deployed, setDeployed] = useState(); + const [deployed, setDeployed] = useState(); const [destination, setDestination] = useState(); const [error, setError] = useState(); useEffect(() => { let active = true; void core.projectManager - .resolveDeployedResources(project, { target: targetName }) + .resolveProjectResources(project, { target: targetName }) .then((resolved) => { if (active) setDeployed(resolved); }) @@ -108,23 +111,25 @@ function ProjectInvokePicker({ const rows = useMemo( () => - (deployed?.resources ?? []).map((resource) => { - if (resource.resourceType === "runtime") { - const configured = project.spec.runtimes.find(({ name }) => name === resource.name); - return { - ...resource, - type: "Runtime" as const, - protocol: configured?.protocol ?? "HTTP", - source: configured?.codeLocation ?? "-", - }; - } - const configured = project.spec.harnesses.find(({ name }) => name === resource.name); - return { - ...resource, - type: "Harness" as const, - protocol: "-", - source: configured?.path ?? "-", - }; + (deployed?.resources ?? []).flatMap((resource): ProjectInvokableRow[] => { + if (!(resource.resourceType in RESOURCE_LABELS) || !("arn" in resource)) return []; + const resourceType = resource.resourceType as ProjectInvokableResource; + const { name, arn } = resource; + const runtime = project.spec.runtimes.find((candidate) => candidate.name === name); + const harness = project.spec.harnesses.find((candidate) => candidate.name === name); + return [ + { + resourceType, + type: RESOURCE_LABELS[resourceType], + name, + arn, + protocol: resourceType === "runtime" ? (runtime?.protocol ?? "HTTP") : "-", + source: + (resourceType === "runtime" && runtime?.codeLocation) || + (resourceType === "harness" && harness?.path) || + "-", + }, + ]; }), [deployed, project], ); @@ -132,8 +137,8 @@ function ProjectInvokePicker({ const select = (row: ProjectInvokableRow) => { setDestination({ resourceType: row.resourceType, - id: row.id, - ctx: ctx.withValue(AwsCredentialProviderKey, row.credentialProvider), + id: serviceIdFromArn(row.arn), + ctx: ctx.withValue(RegionKey, regionFromArn(row.arn)!), }); }; @@ -148,7 +153,7 @@ function ProjectInvokePicker({ ctx={destination.ctx} core={core} runtimeId={destination.id} - breadcrumb={["agentcore", "runtime", "invoke", destination.id]} + breadcrumb={["agentcore", "invoke", "runtime", destination.id]} description="choose an endpoint to invoke" onSelect={(qualifier) => setDestination({ ...destination, qualifier })} onEscape={() => setDestination(undefined)} @@ -178,6 +183,17 @@ function ProjectInvokePicker({ ); } + if (destination?.resourceType === "gateway") { + return ( + setDestination(undefined)} + /> + ); + } + if (error !== undefined) { return ( diff --git a/src/handlers/project/selection.ts b/src/handlers/project/selection.ts index e83b460120..1fa4028eef 100644 --- a/src/handlers/project/selection.ts +++ b/src/handlers/project/selection.ts @@ -1,23 +1,32 @@ import { InputValidationError, ResourceNotFoundError } from "../../errors"; -import type { Project, ProjectInvokableResource } from "./types"; +import type { Project, ProjectInvokableResource, ProjectObservableResource } from "./types"; + +export const RESOURCE_LABELS: Record = { + runtime: "Runtime", + harness: "Harness", + gateway: "Gateway", +}; export function projectResourceNames( project: Project, resourceType: ProjectInvokableResource, ): string[] { - return (resourceType === "runtime" ? project.spec.runtimes : project.spec.harnesses).map( - ({ name }) => name, - ); + const resources = { + runtime: project.spec.runtimes, + harness: project.spec.harnesses, + gateway: project.spec.agentCoreGateways, + }[resourceType]; + return resources.map(({ name }) => name); } export function selectProjectResource( project: Project, - resourceType: ProjectInvokableResource, + resourceType: ProjectObservableResource, name: string | undefined, operation: string, ): string { const names = projectResourceNames(project, resourceType); - const label = resourceType === "runtime" ? "Runtime" : "Harness"; + const label = RESOURCE_LABELS[resourceType]; if (name !== undefined) { if (names.includes(name)) return name; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index fdd0e4ff47..0b38560bac 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -203,7 +203,7 @@ export type ResolveTargetInput = { export type ResolveDeployedResourceInput = { target: string; - resourceType: ProjectInvokableResource; + resourceType: ProjectObservableResource; name: string; }; @@ -212,7 +212,7 @@ export type ResolveDeployedResourcesInput = { }; export type ResolvedDeployedResource = { - resourceType: ProjectInvokableResource; + resourceType: ProjectObservableResource; name: string; id: string; target: AwsDeploymentTarget; @@ -233,7 +233,7 @@ export type ResolveProjectResourcesInput = { /** * Every resource type a project can declare and deploy. Broader than * {@link ProjectInvokableResource}: status reports all of them, while invoke only - * addresses the two that accept a payload. + * addresses the three that accept a request. */ export type DeployableResource = | "runtime" @@ -398,7 +398,8 @@ export type ExportHarnessResult = { notes: ExportNote[]; }; -export type ProjectInvokableResource = Extract; +export type ProjectInvokableResource = Extract; +export type ProjectObservableResource = Extract; export type RemoveResourceInput = | { diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index b7ff3e9421..4bbd008a1b 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -306,7 +306,7 @@ describe("Runtime endpoint flow", () => { await r.press("return"); await waitForText(r.lastFrame, "invoke this Runtime endpoint"); await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → invoke → runtime-123 → prod"); + await waitForText(r.lastFrame, "agentcore → invoke → runtime → runtime-123 → prod"); await waitForText(r.lastFrame, "Enter JSON payload"); await r.press("escape"); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index eec3e0ad29..250d3bec47 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -52,7 +52,7 @@ export function RuntimeGetEndpointScreen(props: ScreenProps) { description: "invoke this Runtime endpoint", onSelect: () => navigate( - `/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, + `/agentcore/invoke/runtime/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, { state: { returnOnEscape: true } }, ), }, diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index bbf4747040..fd910c2816 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -9,7 +9,7 @@ const ACTIONS = [ { name: "invoke", description: "invoke this Runtime", - to: (id: string) => `/agentcore/runtime/invoke/${encodeURIComponent(id)}`, + to: (id: string) => `/agentcore/invoke/runtime/${encodeURIComponent(id)}`, returnsToDetails: true, }, { diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 469b6cd074..2090106c53 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -5,7 +5,6 @@ import type { AppIO } from "../../io"; import type { Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; -import { createInvokeRuntimeHandler } from "./invoke"; import { createListRuntimesHandler } from "./list"; import { createRuntimeLogsHandler } from "./logs"; import { createRuntimeShellHandler } from "./shell"; @@ -16,10 +15,9 @@ export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) - .supportedTuiCommands("get", "list", "invoke", "shell", "version", "endpoint") + .supportedTuiCommands("get", "list", "shell", "version", "endpoint") .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) - .handler(createInvokeRuntimeHandler(core, io)) .handler(createRuntimeShellHandler(core, io)) .handler(createRuntimeVersionHandler(core, io)) .handler(createRuntimeEndpointHandler(core, io)) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx deleted file mode 100644 index 9ee0d9c77f..0000000000 --- a/src/handlers/runtime/invoke/index.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import z from "zod"; -import { ExitCode, InputValidationError } from "../../../errors"; -import { createHandler, flag, PathKey } from "../../../router"; -import type { AppIO } from "../../../io"; -import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; -import { JsonKey } from "../../keys"; -import { withUserCancellation } from "../../../runnable"; -import { renderTuiAt } from "../../../tui"; -import { runWithProgress } from "../../../tui/progress"; -import { - parseRuntimeInvokeHeaders, - resolveRuntimeInvokeSources, - resolveRuntimeInvokeTuiBearerToken, - runtimeIdSchema, -} from "./request"; -import { writeRuntimeInvokeResponse } from "./response"; -import { RuntimeInvokeLaunchContextKey } from "./launchContext"; -import { invokeRuntimeTarget } from "./operation"; - -export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => - createHandler({ - name: "invoke", - description: "invoke a Runtime", - flags: [ - flag("id", "the ID of the Runtime", runtimeIdSchema), - flag("payload", "the inline payload to send", z.string().optional(), { - sensitive: true, - }), - flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("session-id", "the Runtime session ID", z.string().optional()), - flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { - sensitive: true, - }), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag("mcp-method", "the MCP method", z.string().optional()), - flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), - flag("trace-id", "the X-Ray trace ID", z.string().optional()), - flag("trace-parent", "the W3C trace parent", z.string().optional()), - flag("trace-state", "the W3C trace state", z.string().optional()), - flag("baggage", "the W3C baggage", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], - handle: async (ctx, flags) => { - if (flags.payload === undefined) { - const hasHeadlessOnlyFlag = Object.entries(flags).some( - ([name, value]) => - ![ - "id", - "qualifier", - "payload", - "session-id", - "user-id", - "header", - "bearer-token", - ].includes(name) && value !== undefined, - ); - if (ctx.require(JsonKey) || hasHeadlessOnlyFlag) { - throw new InputValidationError("required option '--payload ' not specified", { - exitCode: ExitCode.USAGE, - }); - } - let path = `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`; - if (flags.qualifier !== undefined) { - path += `/${encodeURIComponent(flags.qualifier)}`; - } - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const bearerToken = await resolveRuntimeInvokeTuiBearerToken( - flags["bearer-token"], - io.stdin, - ); - const launchContext = { - runtimeId: flags.id, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken, - }; - await renderTuiAt( - path, - ctx.withValue(RuntimeInvokeLaunchContextKey, launchContext), - core, - io, - ); - return; - } - - const jsonOutput = ctx.require(JsonKey); - if (jsonOutput && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } - const runtimeId = flags.id; - const payload = flags.payload; - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const options = coreOptsFromCtx(ctx); - const response = await invokeRuntimeTarget( - core.runtime, - { - runtimeId, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }, - options, - signal, - ); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - }; - await withUserCancellation(async (signal) => { - const sources = await resolveRuntimeInvokeSources( - { payload, bearerToken: flags["bearer-token"] }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking runtime...", - interactive: !jsonOutput, - }); - }); - }, - }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 840d02e02e..f0d3891812 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -19,7 +19,7 @@ const REGION = "us-east-1"; const RUNTIME_ID = "runtime-123"; const QUALIFIER = "prod"; const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${RUNTIME_ID}`; -const CONSOLE_PATH = `/agentcore/runtime/invoke/${RUNTIME_ID}/${QUALIFIER}`; +const CONSOLE_PATH = `/agentcore/invoke/runtime/${RUNTIME_ID}/${QUALIFIER}`; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; afterEach(cleanupScreens); @@ -90,7 +90,7 @@ describe("Runtime invoke routing", () => { .setGetResponse({ agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${runtimeId}`, } as GetAgentRuntimeResponse); - const screen = renderImperativeScreen("/agentcore/runtime/invoke", { core }); + const screen = renderImperativeScreen("/agentcore/invoke/runtime", { core }); await waitForText(screen.lastFrame, runtimeId); await screen.press("return"); @@ -99,7 +99,7 @@ describe("Runtime invoke routing", () => { await waitForText( screen.lastFrame, - `agentcore → runtime → invoke → ${runtimeId} → ${qualifier}`, + `agentcore → invoke → runtime → ${runtimeId} → ${qualifier}`, ); await waitForText(screen.lastFrame, "Enter JSON payload"); }); @@ -109,13 +109,13 @@ describe("Runtime invoke routing", () => { core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }).setListResponse({ agentRuntimes: [runtime({ agentRuntimeId: "back-to-runtime-picker" })], }); - const screen = renderImperativeScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { core }); + const screen = renderImperativeScreen(`/agentcore/invoke/runtime/${RUNTIME_ID}`, { core }); await waitForText(screen.lastFrame, QUALIFIER); await screen.press("escape"); await waitForText(screen.lastFrame, "back-to-runtime-picker"); - expect(screen.lastFrame()).toContain("agentcore → runtime → invoke"); + expect(screen.lastFrame()).toContain("agentcore → invoke → runtime"); }); test("keeps a CLI-selected session while choosing an endpoint", async () => { @@ -124,7 +124,7 @@ describe("Runtime invoke routing", () => { core.runtime .setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }) .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderImperativeScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { + const screen = renderImperativeScreen(`/agentcore/invoke/runtime/${RUNTIME_ID}`, { core, withContext: (ctx) => ctx.withValue(RuntimeInvokeLaunchContextKey, { @@ -168,7 +168,7 @@ describe("Runtime invoke routing", () => { await screen.press("return"); await waitForText( screen.lastFrame, - `agentcore → runtime → invoke → ${RUNTIME_ID} → ${nextQualifier}`, + `agentcore → invoke → runtime → ${RUNTIME_ID} → ${nextQualifier}`, ); const nextSessionId = displayedSessionId(screen.lastFrame()); expect(nextSessionId).toMatch(UUID_PATTERN); @@ -338,7 +338,7 @@ describe("Runtime invoke JSON console", () => { core.runtime.setGetResponse({ agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${runtimeId}`, } as GetAgentRuntimeResponse); - const screen = renderImperativeScreen(`/agentcore/runtime/invoke/${runtimeId}/${QUALIFIER}`, { + const screen = renderImperativeScreen(`/agentcore/invoke/runtime/${runtimeId}/${QUALIFIER}`, { core, }); @@ -728,7 +728,7 @@ describe("Runtime invoke JSON console", () => { await screen.press("return"); await waitForText( screen.lastFrame, - `agentcore → runtime → invoke → ${RUNTIME_ID} → ${nextQualifier}`, + `agentcore → invoke → runtime → ${RUNTIME_ID} → ${nextQualifier}`, ); expect(screen.lastFrame()).not.toContain("old response"); const nextSessionId = displayedSessionId(screen.lastFrame()); @@ -803,7 +803,7 @@ describe("Runtime invoke JSON console", () => { await screen.press("return"); await waitForText( screen.lastFrame, - `agentcore → runtime → invoke → ${nextRuntimeId} → ${nextQualifier}`, + `agentcore → invoke → runtime → ${nextRuntimeId} → ${nextQualifier}`, ); expect(screen.lastFrame()).not.toContain("Context"); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 625edd52ac..86797a0727 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -50,7 +50,7 @@ type Exchange = { }; const invokePath = (...parts: string[]) => - ["/agentcore/runtime/invoke", ...parts.map(encodeURIComponent)].join("/"); + ["/agentcore/invoke/runtime", ...parts.map(encodeURIComponent)].join("/"); const metadata = (response: RuntimeInvokeResponse) => [ @@ -104,7 +104,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { return ( navigate(invokePath(id))} /> @@ -116,7 +116,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { navigate(invokePath(runtimeId, selected), { @@ -338,7 +338,7 @@ export function RuntimeInvokeConsole({ setTargetPicker({ stage: "endpoint", runtimeId: selectedRuntimeId }) @@ -355,7 +355,7 @@ export function RuntimeInvokeConsole({ ctx={ctx} core={core} runtimeId={nextRuntimeId} - breadcrumb={["agentcore", "runtime", "invoke", nextRuntimeId]} + breadcrumb={["agentcore", "invoke", "runtime", nextRuntimeId]} description="choose another endpoint" onSelect={(selected) => { if (nextRuntimeId !== target.runtimeId || selected !== target.qualifier) { @@ -378,7 +378,7 @@ export function RuntimeInvokeConsole({ return ( { expect(runtime?.children().map((child) => child.name())).toEqual([ "get", "list", - "invoke", "shell", "version", "endpoint", diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx index 52ba6071b4..a803eccf3f 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,9 +1,21 @@ import { describe, expect, test } from "bun:test"; +import type { + GetAgentRuntimeResponse, + GetGatewayResponse, + GetHarnessResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { ResourceNotFoundException } from "@aws-sdk/client-bedrock-agentcore-control"; +import { ProjectSpecSchema } from "../projectSchemas/project"; +import { ProjectKey, ProjectTargetKey, ValueContext } from "../router"; +import { TestCoreClient } from "../testing"; +import { RegionKey } from "./keys"; +import type { ProjectManager, ResolvedProjectResource } from "./project/types"; import { assertMutuallyExclusiveFlags, parseJsonArrayFlag, parseJsonObjectFlag, parseTags, + toResourceArn, } from "./utils"; describe("structured JSON flags", () => { @@ -115,3 +127,147 @@ describe("assertMutuallyExclusiveFlags", () => { expect(() => assertMutuallyExclusiveFlags(flags, names, options)).toThrow(message); }); }); + +describe("toResourceArn", () => { + const arn = (region: string, resource: string) => + `arn:aws:bedrock-agentcore:${region}:111122223333:${resource}`; + const project = { + name: "orders", + rootPath: "/orders", + spec: ProjectSpecSchema.parse({ + name: "orders", + version: 2, + runtimes: [ + { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", + }, + ], + harnesses: [{ name: "support", path: "app/support" }], + agentCoreGateways: [{ name: "tools", targets: [] }], + }), + }; + const deployed: ResolvedProjectResource[] = [ + { resourceType: "runtime", name: "checkout", deploymentState: "local-only" }, + { + resourceType: "harness", + name: "support", + deploymentState: "deployed", + arn: arn("eu-west-1", "harness/support-AbCd"), + }, + { + resourceType: "gateway", + name: "tools", + deploymentState: "deployed", + arn: arn("eu-west-1", "gateway/tools-AbCd"), + }, + ]; + const notFound = new ResourceNotFoundException({ message: "not found", $metadata: {} }); + + function setup(options: { inProject: boolean; error?: Error }) { + const core = new TestCoreClient(); + const targets: string[] = []; + Object.assign(core, { + projectManager: { + resolveProjectResources: async (_project, { target }) => { + targets.push(target); + return { resources: deployed, target: { name: target } }; + }, + } as Partial, + }); + core.runtime + .setGetResponse({ + agentRuntimeArn: arn("us-west-2", "runtime/rt"), + } as GetAgentRuntimeResponse) + .setError(options.error); + core.harness + .setGetResponse({ harness: { arn: arn("us-west-2", "harness/hs") } } as GetHarnessResponse) + .setError(options.error); + core.gateway + .setGetResponse({ gatewayArn: arn("us-west-2", "gateway/gw") } as GetGatewayResponse) + .setError(options.error); + let ctx = ValueContext.EmptyContext().withValue(RegionKey, "us-west-2"); + if (options.inProject) { + ctx = ctx.withValue(ProjectKey, project).withValue(ProjectTargetKey, "prod"); + } + const lookups = () => + [...core.runtime.calls, ...core.harness.calls, ...core.gateway.calls] + .filter(({ method }) => method.startsWith("get")) + .map(({ args }) => [args[0], (args[1] as { region: string }).region]); + return { core, ctx, targets, lookups }; + } + + test.each([ + [ + "a project harness by name", + "harness", + "support", + arn("eu-west-1", "harness/support-AbCd"), + [], + ], + ["a project gateway by name", "gateway", "tools", arn("eu-west-1", "gateway/tools-AbCd"), []], + ["a Runtime ID", "runtime", "rt", arn("us-west-2", "runtime/rt"), [["rt", "us-west-2"]]], + ["a harness ID", "harness", "hs", arn("us-west-2", "harness/hs"), [["hs", "us-west-2"]]], + ["a Gateway ID", "gateway", "gw", arn("us-west-2", "gateway/gw"), [["gw", "us-west-2"]]], + [ + "an ARN, in its own region", + "runtime", + arn("ap-south-1", "runtime/rt"), + arn("us-west-2", "runtime/rt"), + [["rt", "ap-south-1"]], + ], + ] as const)("resolves %s", async (_name, resourceType, identifier, expected, lookups) => { + const fixture = setup({ inProject: true }); + + expect(await toResourceArn(fixture.core, fixture.ctx, resourceType, identifier)).toBe(expected); + expect(fixture.lookups()).toEqual(lookups as unknown as string[][]); + expect(fixture.targets).toEqual(lookups.length === 0 ? ["prod"] : []); + }); + + test.each([ + [ + "a project name that is not deployed", + { inProject: true }, + "runtime", + "checkout", + "Runtime 'checkout' is not deployed to target 'prod'. Run 'agentcore deploy --target prod' first.", + ], + [ + "an unknown value in a project", + { inProject: true, error: notFound }, + "harness", + "missing", + "Harness 'missing' is not a project Harness (available: support) and no Harness with ID 'missing' exists in us-west-2.", + ], + [ + "an unknown ID outside a project", + { inProject: false, error: notFound }, + "gateway", + "missing", + "No Gateway with ID 'missing' exists in us-west-2. Run from inside a project to use a project name, or pass a Gateway ID or ARN.", + ], + [ + "an unknown ARN", + { inProject: false, error: notFound }, + "runtime", + arn("us-west-2", "runtime/missing"), + "not found", + ], + [ + "a service error", + { inProject: false, error: new Error("AccessDenied") }, + "runtime", + "rt", + "AccessDenied", + ], + ] as const)("rejects %s", async (_name, options, resourceType, identifier, message) => { + const fixture = setup(options); + + await expect( + toResourceArn(fixture.core, fixture.ctx, resourceType, identifier), + ).rejects.toThrow(new Error(message)); + }); +}); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index 82d18ae391..e356613158 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,12 +1,23 @@ import { createContext, useContext, useEffect } from "react"; -import type { Context } from "../router"; +import { ResourceNotFoundException } from "@aws-sdk/client-bedrock-agentcore-control"; +import { ProjectKey, ProjectTargetKey, type Context } from "../router"; import type z from "zod"; import type { CoreOptions } from "../core/types"; import type { AppIO } from "../io"; -import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../errors"; +import { regionFromArn, serviceIdFromArn } from "../core/arn"; +import { + AgentCoreCLIError, + InputValidationError, + ProjectStateError, + ResourceNotFoundError, + SilentCLIError, +} from "../errors"; import { formatZodError } from "../router/schema"; import { AwsCredentialProviderKey, EndpointKey, JsonKey, RegionKey } from "./keys"; import { JsonRendererKey } from "../tui"; +import { projectResourceNames, RESOURCE_LABELS } from "./project/selection"; +import type { ProjectInvokableResource } from "./project/types"; +import type { Core } from "./types"; // coreOptsFromCtx builds the standard CoreOptions handed to Core operations from // the values pinned on the context: the resolved region (always present, see the @@ -22,6 +33,53 @@ export function coreOptsFromCtx(ctx: Context): CoreOptions { }; } +export async function toResourceArn( + core: Core, + ctx: Context, + resourceType: ProjectInvokableResource, + identifier: string, +): Promise { + const label = RESOURCE_LABELS[resourceType]; + const project = ctx.value(ProjectKey); + const names = project ? projectResourceNames(project, resourceType) : []; + if (project && names.includes(identifier)) { + const target = ctx.require(ProjectTargetKey); + const { resources } = await core.projectManager.resolveProjectResources(project, { target }); + const resource = resources.find( + (candidate) => candidate.resourceType === resourceType && candidate.name === identifier, + ); + if (resource && "arn" in resource) return resource.arn; + throw new ProjectStateError( + `${label} '${identifier}' is not deployed to target '${target}'. ` + + `Run 'agentcore deploy --target ${target}' first.`, + ); + } + + const options = coreOptsFromCtx(ctx); + const arnRegion = regionFromArn(identifier); + const id = serviceIdFromArn(identifier); + const lookupOptions = { ...options, region: arnRegion ?? options.region }; + try { + switch (resourceType) { + case "runtime": + return (await core.runtime.getRuntime(id, lookupOptions)).agentRuntimeArn!; + case "harness": + return (await core.harness.getHarness(id, lookupOptions)).harness!.arn!; + case "gateway": + return (await core.gateway.getGateway(id, lookupOptions)).gatewayArn!; + } + } catch (error) { + if (arnRegion || !(error instanceof ResourceNotFoundException)) throw error; + const missing = `${label} with ID '${identifier}' exists in ${options.region}`; + throw new ResourceNotFoundError( + project + ? `${label} '${identifier}' is not a project ${label} (available: ${names.join(", ") || "none"}) and no ${missing}.` + : `No ${missing}. Run from inside a project to use a project name, or pass a ${label} ID or ARN.`, + { cause: error }, + ); + } +} + // A pinned region replaces RegionKey on every route's context, so a screen that // shows a resource living outside the launch region pins it and everything it // opens next fetches there. The pin lasts until the user navigates back past diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx index d49f8ccc6d..4ef7ef5416 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -6,6 +6,7 @@ interface WithProjectConfig { projectManager: ProjectManager; /** Directory to search upwards from. Defaults to the cwd at invocation time. */ cwd?: string; + optional?: boolean; } /** @@ -37,10 +38,10 @@ export function withProject(config: WithProjectConfig): Middleware { // actually ran in is the one searched. const from = config.cwd ?? process.cwd(); const project = await config.projectManager.resolve({ filePath: from }); - if (!project) { + if (!project && !config.optional) { throw new ProjectStateError(projectNotFoundMessage(from)); } - await h.handle(ctx.withValue(ProjectKey, project), flags, args); + await h.handle(project ? ctx.withValue(ProjectKey, project) : ctx, flags, args); }, }); } diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 0e40a9326a..d8ee8a5a95 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -1,7 +1,7 @@ import { Option } from "commander"; import { InputValidationError } from "../errors"; import type { Context } from "./context"; -import type { Flag, GlobalFlag } from "./handler"; +import type { Flag, GlobalFlag, HelpExample } from "./handler"; import { coerce, formatZodError, inspect } from "./schema"; // toOption builds a Commander Option from a flag's schema. A boolean that defaults @@ -54,6 +54,12 @@ export function formatParameterDetails(flags: Flag[]): string | undefined { return `\nParameter details:\n\n${sections.join("\n\n")}\n`; } +export function formatExamples(examples: HelpExample[]): string | undefined { + if (examples.length === 0) return undefined; + const sections = examples.map(({ description, command }) => ` ${description}\n $ ${command}`); + return `\nExamples:\n\n${sections.join("\n\n")}\n`; +} + // attributeName mirrors how Commander camelCases an option name into the key it // stores on the parsed options object (e.g. "harness-id" -> "harnessId"). export function attributeName(name: string): string { diff --git a/src/router/handler.tsx b/src/router/handler.tsx index 5fd343ad7b..8073d49109 100644 --- a/src/router/handler.tsx +++ b/src/router/handler.tsx @@ -60,7 +60,7 @@ export function globalFlag( // FlagsOf maps a tuple of Flags to a typed object keyed by each flag's literal // name, with values typed by z.infer of each schema. -type FlagsOf[]> = { +export type FlagsOf[]> = { [E in F[number] as E["name"]]: E extends Flag ? T : never; }; @@ -101,6 +101,19 @@ export interface Handler { children(): Handler[]; } +export interface HelpExample { + description: string; + command: string; +} + +export interface ExamplesProvider { + examples(): HelpExample[]; +} + +export function isExamplesProvider(h: Handler): h is Handler & ExamplesProvider { + return typeof (h as Partial).examples === "function"; +} + type CreateHandlerInput< F extends readonly Flag[], A extends readonly Argument[], @@ -112,6 +125,7 @@ type CreateHandlerInput< handle?: HandleFn; children?: Handler[]; middlewares?: Middleware[]; + examples?: HelpExample[]; }; const noOpHandler = async (_ctx: Context, _flags: any, _args: any): Promise => {}; @@ -124,6 +138,7 @@ class BaseHandler implements Handler { _handle: HandleFn; _children: Handler[]; _middlewares: Middleware[]; + _examples: HelpExample[]; constructor( input: CreateHandlerInput[], readonly Argument[]>, @@ -135,6 +150,7 @@ class BaseHandler implements Handler { this._handle = (input.handle ?? noOpHandler) as HandleFn; this._children = input.children ?? []; this._middlewares = input.middlewares ?? []; + this._examples = input.examples ?? []; } name(): string { @@ -168,6 +184,10 @@ class BaseHandler implements Handler { middlewares(): Middleware[] { return this._middlewares; } + + examples(): HelpExample[] { + return this._examples; + } } // createHandler infers the flags tuple from `flags` (the `const` type parameter diff --git a/src/router/index.tsx b/src/router/index.tsx index f1c6b78a4a..986e7360cd 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -8,6 +8,7 @@ export { GlobalConfigAccessorKey, CommandRunMetricEventKey, ProjectKey, + ProjectTargetKey, type DefaultHandle, type DefaultHandlerProvider, isDefaultHandlerProvider, @@ -19,6 +20,8 @@ export { type Flag, type GlobalFlag, type Argument, + type FlagsOf, + type HelpExample, createHandler, flag, globalFlag, diff --git a/src/router/router.test.ts b/src/router/router.test.ts index e7134d1084..3426f89637 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -810,6 +810,27 @@ test("commands without long-form flag help have no Parameter details section", a expect(out).toContain("--id"); expect(out).not.toContain("Parameter details:"); + expect(out).not.toContain("Examples:"); +}); + +test("handlers with examples render an Examples section", async () => { + const get = createHandler({ + name: "get", + description: "", + examples: [ + { description: "Get a thing", command: "app get --id a" }, + { description: "Get it as JSON", command: "app get --id a --json" }, + ], + handle: async () => {}, + }); + const root = new Router("app"); + root.handler(get); + + const out = await helpOutput(root, ["app", "get", "--help"]); + + expect(out).toContain( + "Examples:\n\n Get a thing\n $ app get --id a\n\n Get it as JSON\n $ app get --id a --json\n", + ); }); // --- telemetry: command path recording ------------------------------------- diff --git a/src/router/router.tsx b/src/router/router.tsx index ac528ed096..b75c62abae 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -1,9 +1,16 @@ -import type { Argument, Flag, GlobalFlag, Handler } from "./handler"; +import { + isExamplesProvider, + type Argument, + type Flag, + type GlobalFlag, + type Handler, +} from "./handler"; import { type Middleware, type MiddlewareProvider, isMiddlewareProvider } from "./middleware"; import { type Context, type ContextKey, ValueContext, contextKey } from "./context"; import { applyGlobalFlags, attributeName, + formatExamples, formatParameterDetails, parseFlags, toOption, @@ -30,6 +37,7 @@ export const CommandRunMetricEventKey = export const GlobalConfigAccessorKey: ContextKey = contextKey("globalConfigAccessor"); export const ProjectKey = contextKey("project"); +export const ProjectTargetKey = contextKey("projectTarget"); // RoutedCommand keeps the compiled handler and Commander command tree together. // TUI consumers can therefore read handler metadata without module-level state. @@ -227,6 +235,11 @@ export function compile( ); } + const examples = formatExamples(isExamplesProvider(node) ? node.examples() : []); + if (examples) { + c.addHelpText("after", examples); + } + // Flags with long-form documentation get a "Parameter details" section after // the option list in `--help` output. const parameterDetails = formatParameterDetails(ownFlags); From 333d58e44768955361c49d28b23a802de4779029 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 24 Sep 2026 20:33:41 +0000 Subject: [PATCH 2/7] docs: regenerate command reference for invoke --- command.md | 55 +++++++++++++++++------------------------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/command.md b/command.md index 88b03fdb33..a2b5b8add1 100644 --- a/command.md +++ b/command.md @@ -38,8 +38,6 @@ This reference was generated from `agentcore --help` for version `1.0.0-rc.4`. - [`agentcore dev`](#agentcore-dev) - [`agentcore deploy`](#agentcore-deploy) - [`agentcore invoke`](#agentcore-invoke) - - [`agentcore invoke runtime`](#agentcore-invoke-runtime) - - [`agentcore invoke harness`](#agentcore-invoke-harness) - [`agentcore log`](#agentcore-log) - [`agentcore log runtime`](#agentcore-log-runtime) - [`agentcore log harness`](#agentcore-log-harness) @@ -647,58 +645,39 @@ deploy the project to AWS ### `agentcore invoke` ```text -agentcore invoke [options] [command] +agentcore invoke [options] ``` -invoke a Runtime or harness from the current project - -#### `agentcore invoke runtime` - -```text -agentcore invoke runtime [options] -``` - -invoke a Runtime from the current project +invoke a Runtime, harness, or Gateway **Options** -- `--name `: the logical project Runtime name -- `--local`: invoke a local Runtime development server (default: false) -- `--port `: local Runtime development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000) -- `--target `: project deployment target (default: default) +- `--runtime `: the Runtime to invoke: a project name, ID, or ARN +- `--harness `: the harness to invoke: a project name, ID, or ARN +- `--gateway `: the Gateway to invoke: a project name, ID, or ARN +- `--target `: project deployment target (default: "default") +- `--local`: invoke the local development server (project Runtime only) (default: false) +- `--port `: local development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000) - `--payload `: the inline payload to send -- `--qualifier `: the Runtime endpoint qualifier +- `--prompt `: the message to send to a harness +- `--session-id `: the session ID to continue (33-100 characters for a harness) +- `--qualifier `: the endpoint qualifier (default DEFAULT) - `--content-type `: the payload content type - `--accept `: the accepted response content type -- `--session-id `: the Runtime session ID -- `--user-id `: the Runtime user ID (default "default") - `--header `: an ordered application header - `--bearer-token `: the CUSTOM\_JWT bearer token -- `--mcp-session-id `: the MCP session ID -- `--mcp-protocol-version `: the MCP protocol version +- `--output-file `: the response output file +- `--user-id `: the Runtime user ID (default "default") - `--mcp-method `: the MCP method - `--mcp-name `: the MCP tool, resource, or prompt name - `--trace-id `: the X-Ray trace ID - `--trace-parent `: the W3C trace parent - `--trace-state `: the W3C trace state - `--baggage `: the W3C baggage -- `--output-file `: the response output file - -#### `agentcore invoke harness` - -```text -agentcore invoke harness [options] -``` - -invoke a harness from the current project - -**Options** - -- `--name `: the logical project harness name -- `--target `: project deployment target (default: "default") -- `--prompt `: the message to send to the harness -- `--session-id `: the Runtime session ID to continue (33-100 characters) -- `--qualifier `: the harness endpoint qualifier to invoke (default DEFAULT) +- `--path `: the path relative to the Gateway origin +- `--method `: the HTTP request method +- `--mcp-session-id `: the MCP session ID +- `--mcp-protocol-version `: the MCP protocol version ### `agentcore log` From 75210ddde9aaf702956851a023629257eb79adcc Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 24 Sep 2026 20:44:11 +0000 Subject: [PATCH 3/7] fix(invoke): keep the standalone invoke commands and delegate to them The runtime, harness, and gateway invoke commands stay behind the imperative-commands setting with their routes unchanged. agentcore invoke resolves the resource ARN, then runs the matching standalone handler with the service ID and the ARN's region on the context. --- CONTRIBUTING.md | 2 +- src/components/CliOnlyScreen.test.tsx | 12 +- src/components/Root.tsx | 16 +- src/components/RouterScreen.test.tsx | 2 +- .../eval/batch-evaluation/simulate/index.tsx | 2 +- src/handlers/eval/types.tsx | 2 +- .../gateway/gateway.mutations.test.tsx | 1 + src/handlers/gateway/gateway.screen.test.tsx | 2 +- src/handlers/gateway/gateway.test.tsx | 1 + src/handlers/gateway/index.tsx | 4 +- src/handlers/gateway/invoke/index.tsx | 151 +++++++++++ .../gateway/invoke/invoke.screen.test.tsx | 12 +- .../invoke/invoke.test.tsx} | 193 ++++++++------ src/handlers/gateway/invoke/request.ts | 5 + src/handlers/gateway/invoke/screen.tsx | 8 +- .../harness/exec/exec.screen.test.tsx | 2 +- src/handlers/harness/get/get.screen.test.tsx | 2 +- src/handlers/harness/get/screen.tsx | 2 +- src/handlers/harness/index.tsx | 3 + src/handlers/harness/invoke/index.tsx | 68 +++++ .../harness/invoke/invoke.screen.test.tsx | 18 +- .../invoke/invoke.test.tsx} | 45 +++- src/handlers/harness/invoke/screen.tsx | 14 +- src/handlers/project/invoke/gateway.tsx | 113 -------- src/handlers/project/invoke/harness.tsx | 49 ---- src/handlers/project/invoke/index.test.tsx | 12 +- src/handlers/project/invoke/index.tsx | 100 +++---- src/handlers/project/invoke/runtime.tsx | 110 +------- src/handlers/project/invoke/screen.tsx | 2 +- .../runtime/endpoint/endpoint.screen.test.tsx | 2 +- src/handlers/runtime/endpoint/get/screen.tsx | 2 +- src/handlers/runtime/get/screen.tsx | 2 +- src/handlers/runtime/index.tsx | 4 +- src/handlers/runtime/invoke/index.tsx | 158 +++++++++++ .../runtime/invoke/invoke.screen.test.tsx | 20 +- .../invoke/invoke.test.tsx} | 245 ++++++++++-------- src/handlers/runtime/invoke/screen.tsx | 12 +- src/handlers/runtime/runtime.test.tsx | 1 + 38 files changed, 804 insertions(+), 595 deletions(-) create mode 100644 src/handlers/gateway/invoke/index.tsx rename src/handlers/{project/invoke/gateway.test.tsx => gateway/invoke/invoke.test.tsx} (78%) create mode 100644 src/handlers/harness/invoke/index.tsx rename src/handlers/{project/invoke/harness.test.tsx => harness/invoke/invoke.test.tsx} (84%) delete mode 100644 src/handlers/project/invoke/gateway.tsx delete mode 100644 src/handlers/project/invoke/harness.tsx create mode 100644 src/handlers/runtime/invoke/index.tsx rename src/handlers/{project/invoke/runtime.test.tsx => runtime/invoke/invoke.test.tsx} (79%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4a3703178..54c108477d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -363,7 +363,7 @@ The interactive UI is built with [Ink](https://github.com/vadimdemedes/ink) (React for the terminal). `renderTui` mounts the `Root` component (`src/components/Root.tsx`) — a MemoryRouter over the app's route table plus a react-query client — seeded at the command's path. Because routes map to the -same handler paths as the CLI, deep-linking works: `invoke --harness X` opens +same handler paths as the CLI, deep-linking works: `harness invoke --id X` opens the chat screen at that harness. Ink reads and writes through the injected IO streams, so the TUI is fully testable without a real terminal. diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index 872b89ad53..66e0ecff06 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -70,7 +70,17 @@ describe("menus list command-line-only subcommands below a divider", () => { await waitForText(r.lastFrame, "command line only"); expect(menuEntries(r.lastFrame()!)).toEqual({ - screens: ["create", "get", "list", "update", "delete", "exec", "endpoint", "version"], + screens: [ + "create", + "get", + "list", + "update", + "delete", + "invoke", + "exec", + "endpoint", + "version", + ], cliOnly: ["logs", "traces"], }); r.unmount(); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ef83b66507..f86b1b93d6 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -281,16 +281,16 @@ function RouteTable({ ctx, core }: ScreenProps) { element={} /> } /> } /> {/* Deep link that resumes an existing runtime session in the chat. */} } /> } /> @@ -457,15 +457,15 @@ function RouteTable({ ctx, core }: ScreenProps) { element={} /> } /> } /> } /> } /> } /> } /> { await waitForText(r.lastFrame, "list"); const frame = r.lastFrame()!; - for (const sub of ["get", "list", "create", "update", "delete", "exec"]) { + for (const sub of ["get", "list", "create", "update", "delete", "invoke", "exec"]) { expect(frame).toContain(sub); } r.unmount(); diff --git a/src/handlers/eval/batch-evaluation/simulate/index.tsx b/src/handlers/eval/batch-evaluation/simulate/index.tsx index 45adcaa954..64b2353efb 100644 --- a/src/handlers/eval/batch-evaluation/simulate/index.tsx +++ b/src/handlers/eval/batch-evaluation/simulate/index.tsx @@ -23,7 +23,7 @@ Example: --payload-template '{"messages":[{"role":"user","content":"{input}"}],"stream":false}'`; // Composes invokeDataset (replay) → startBatchEvaluation (grade). Invoke flags mirror -// `invoke --runtime`. +// `runtime invoke`. export const createSimulateBatchEvaluationHandler = (core: Core, io: AppIO) => createHandler({ name: "simulate", diff --git a/src/handlers/eval/types.tsx b/src/handlers/eval/types.tsx index f78d28aca1..0b362d5269 100644 --- a/src/handlers/eval/types.tsx +++ b/src/handlers/eval/types.tsx @@ -307,7 +307,7 @@ export type StartBatchInsightsInput = { // InvokeDatasetInput is the runtime-level shape for replaying a dataset: invoke each // example against the runtime, one client-generated session per example. Runtime fields // only — no evaluator/name/kms (those belong to the grader the handler composes on top, -// e.g. startBatchEvaluation). Invoke fields mirror `invoke --runtime`. +// e.g. startBatchEvaluation). Invoke fields mirror `runtime invoke`. export type InvokeDatasetInput = { runtimeId: string; qualifier?: string; diff --git a/src/handlers/gateway/gateway.mutations.test.tsx b/src/handlers/gateway/gateway.mutations.test.tsx index 9d2d4a3328..8297e5f2f2 100644 --- a/src/handlers/gateway/gateway.mutations.test.tsx +++ b/src/handlers/gateway/gateway.mutations.test.tsx @@ -90,6 +90,7 @@ describe("Gateway imperative mutation availability", () => { expect(names).toContain("get"); expect(names).toContain("list"); } + expect(gateway.commands.map((child) => child.name())).toContain("invoke"); expect(gateway.commands.find((child) => child.name() === "policy")?.commands[0]?.name()).toBe( "generate", ); diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index 1de7e9aca7..e1b0a4afc2 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -116,7 +116,7 @@ describe("Gateway menu and list", () => { await waitForText(screen.lastFrame, "manage AgentCore Gateways"); expect(menuEntries(screen.lastFrame()!)).toEqual({ - screens: ["create", "get", "list", "target", "connector", "rule", "policy"], + screens: ["create", "get", "list", "invoke", "target", "connector", "rule", "policy"], cliOnly: [], }); expect(screen.core.gateway.calls).toEqual([]); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 1f5f80132f..6b3f50e6fa 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -88,6 +88,7 @@ describe("gateway command hierarchy", () => { "get", "list", "delete", + "invoke", "target", "connector", "rule", diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 0b76c787e8..98e3977649 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -8,6 +8,7 @@ import { createGatewayConnectorHandler } from "./connector"; import { createCreateGatewayHandler } from "./create"; import { createDeleteGatewayHandler } from "./delete"; import { createGetGatewayHandler } from "./get"; +import { createInvokeGatewayHandler } from "./invoke"; import { createListGatewaysHandler } from "./list"; import { createGatewayPolicyHandler } from "./policy"; import { createGatewayRuleHandler } from "./rule"; @@ -19,7 +20,7 @@ export function createGatewayHandler(core: Core, io: AppIO, globalConfig: Global const router = new Router("gateway", "manage AgentCore Gateways") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) - .supportedTuiCommands("get", "list", "target", "connector", "rule", "policy"); + .supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule", "policy"); if (imperativeMutationCommands) { router .handler(createCreateGatewayHandler(core, io)) @@ -28,6 +29,7 @@ export function createGatewayHandler(core: Core, io: AppIO, globalConfig: Global router.handler(createGetGatewayHandler(core)).handler(createListGatewaysHandler(core)); if (imperativeMutationCommands) router.handler(createDeleteGatewayHandler(core)); return router + .handler(createInvokeGatewayHandler(core, io)) .handler(createGatewayTargetHandler(core, io, imperativeMutationCommands)) .handler(createGatewayConnectorHandler(core, io, imperativeMutationCommands)) .handler(createGatewayRuleHandler(core, io, imperativeMutationCommands)) diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx new file mode 100644 index 0000000000..30614c5273 --- /dev/null +++ b/src/handlers/gateway/invoke/index.tsx @@ -0,0 +1,151 @@ +import z from "zod"; +import { GatewayInvokeResponseError, InputValidationError } from "../../../errors"; +import { SourceResolver, type AppIO } from "../../../io"; +import { withUserCancellation } from "../../../runnable"; +import { createHandler, flag, PathKey } from "../../../router"; +import { renderTuiAt } from "../../../tui"; +import { runWithProgress } from "../../../tui/progress"; +import { JsonKey } from "../../keys"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import type { GatewayInvokeMethod } from "../types"; +import { + gatewayIdSchema, + normalizeGatewayInvokeRequest, + parseGatewayInvokeHeaders, + resolveGatewayInvokeSources, + resolveGatewayInvokeTuiBearerToken, +} from "./request"; +import { writeGatewayInvokeResponse } from "./response"; +import { GatewayInvokeLaunchContextKey } from "./launchContext"; + +export const createInvokeGatewayHandler = ( + core: Core, + io: AppIO, + renderInvokeTui: typeof renderTuiAt = renderTuiAt, +) => + createHandler({ + name: "invoke", + description: "invoke an AgentCore Gateway", + flags: [ + flag("id", "the ID of the Gateway", gatewayIdSchema), + flag( + "path", + "the path relative to the Gateway origin", + z.string().min(1, "requires a nonempty path").optional(), + { sensitive: true }, + ), + flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional()), + flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the Gateway bearer token", z.string().optional(), { + sensitive: true, + }), + flag("session-id", "the Runtime target session ID", z.string().optional()), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), + ], + handle: async (ctx, flags) => { + const jsonOutput = ctx.require(JsonKey); + if (flags.payload === undefined) { + const hasHeadlessOnlyFlag = Object.entries(flags).some( + ([name, value]) => + ![ + "id", + "path", + "payload", + "header", + "bearer-token", + "session-id", + "mcp-session-id", + "mcp-protocol-version", + ].includes(name) && value !== undefined, + ); + if (!jsonOutput && !hasHeadlessOnlyFlag) { + const applicationHeaders = parseGatewayInvokeHeaders(flags.header); + const bearerToken = await resolveGatewayInvokeTuiBearerToken( + flags["bearer-token"], + new SourceResolver({ stdin: io.stdin }), + ); + const launchContext = { + gatewayId: flags.id, + path: flags.path, + runtimeSessionId: flags["session-id"], + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + applicationHeaders, + bearerToken, + }; + await renderInvokeTui( + `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, + ctx.withValue(GatewayInvokeLaunchContextKey, launchContext), + core, + io, + ); + return; + } + } + + if (jsonOutput && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } + const gatewayId = flags.id; + const payload = flags.payload; + const applicationHeaders = parseGatewayInvokeHeaders(flags.header); + + const invoke = async ( + signal: AbortSignal, + beforeOutput: () => Promise, + sources: Awaited>, + ) => { + const options = coreOptsFromCtx(ctx); + const gateway = await core.gateway.getGateway(gatewayId, options, signal); + const request = normalizeGatewayInvokeRequest(gateway, { + gatewayId, + path: flags.path, + method: flags.method as GatewayInvokeMethod | undefined, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + applicationHeaders, + bearerToken: sources.bearerToken, + runtimeSessionId: flags["session-id"], + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + }); + const response = await core.gateway.invokeGateway(request, options, signal); + await writeGatewayInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal, + beforeOutput, + }); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new GatewayInvokeResponseError(`HTTP ${response.statusCode}`); + } + }; + await withUserCancellation(async (signal) => { + const sources = await resolveGatewayInvokeSources( + { payload, bearerToken: flags["bearer-token"] }, + io.stdin, + signal, + ); + return runWithProgress((stop) => invoke(signal, stop, sources), { + io, + label: "Invoking gateway...", + interactive: !jsonOutput, + }); + }); + }, + }); diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx index 07bb3bb9a9..6065e4c9e2 100644 --- a/src/handlers/gateway/invoke/invoke.screen.test.tsx +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -12,7 +12,7 @@ import { GatewayInvokeLaunchContextKey } from "./launchContext"; const GATEWAY_ID = "gateway-123"; const GATEWAY_URL = "https://gateway-123.gateway.example.test/mcp"; -const CONSOLE_PATH = `/agentcore/invoke/gateway/${GATEWAY_ID}`; +const CONSOLE_PATH = `/agentcore/gateway/invoke/${GATEWAY_ID}`; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; afterEach(cleanupScreens); @@ -62,12 +62,12 @@ describe("Gateway invoke routing", () => { test("selects a Gateway before opening the JSON console", async () => { const core = new TestCoreClient(); core.gateway.setListResponse({ items: [gatewaySummary()] }).setGetResponse(gatewayDetail()); - const screen = renderImperativeScreen("/agentcore/invoke/gateway", { core }); + const screen = renderImperativeScreen("/agentcore/gateway/invoke", { core }); await waitForText(screen.lastFrame, "checkout-gateway"); await screen.press("return"); - await waitForText(screen.lastFrame, `agentcore → invoke → gateway → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, `agentcore → gateway → invoke → ${GATEWAY_ID}`); await waitForText(screen.lastFrame, "Enter JSON payload"); expect(screen.lastFrame()).toContain("Path: /mcp (Gateway URL)"); expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); @@ -100,7 +100,7 @@ describe("Gateway invoke routing", () => { await waitFor(() => signal!.aborted); }); - test("idle Escape returns through the Gateway picker to the project invoke picker", async () => { + test("idle Escape returns through the invoke picker to the Gateway menu", async () => { const core = new TestCoreClient(); core.gateway.setGetResponse(gatewayDetail()).setListResponse({ items: [gatewaySummary()] }); const screen = renderImperativeScreen(CONSOLE_PATH, { core }); @@ -112,7 +112,7 @@ describe("Gateway invoke routing", () => { expect(screen.lastFrame()).toContain("checkout-gateway"); await screen.press("escape"); - await waitForText(screen.lastFrame, "invoke a Runtime, harness, or Gateway from the current project"); + await waitForText(screen.lastFrame, "manage AgentCore Gateways"); }); }); @@ -411,7 +411,7 @@ describe("Gateway invoke JSON console", () => { await waitForText(screen.lastFrame, "next-gateway"); await screen.press("down"); await screen.press("return"); - await waitForText(screen.lastFrame, `agentcore → invoke → gateway → ${nextGatewayId}`); + await waitForText(screen.lastFrame, `agentcore → gateway → invoke → ${nextGatewayId}`); await waitForText(screen.lastFrame, "Ready"); expect(screen.lastFrame()).toContain("Path: /mcp (Gateway URL)"); diff --git a/src/handlers/project/invoke/gateway.test.tsx b/src/handlers/gateway/invoke/invoke.test.tsx similarity index 78% rename from src/handlers/project/invoke/gateway.test.tsx rename to src/handlers/gateway/invoke/invoke.test.tsx index d52e3cadad..3c87701597 100644 --- a/src/handlers/project/invoke/gateway.test.tsx +++ b/src/handlers/gateway/invoke/invoke.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, spyOn, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,15 +19,16 @@ import { tick, waitFor, } from "../../../testing"; +import { PathKey, ValueContext } from "../../../router"; import { createRootHandler } from "../../index"; -import type { GatewayInvokeRequest } from "../../gateway/types"; -import * as tui from "../../../tui"; -import { GatewayInvokeLaunchContextKey } from "../../gateway/invoke/launchContext"; +import { JsonKey } from "../../keys"; +import type { GatewayInvokeRequest } from "../types"; +import { createInvokeGatewayHandler } from "./index"; +import { GatewayInvokeLaunchContextKey } from "./launchContext"; const REGION = "us-west-2"; const GATEWAY_ID = "gateway-123"; const GATEWAY_URL = "https://gateway-123.gateway.example.test/mcp"; -const GATEWAY_ARN = `arn:aws:bedrock-agentcore:${REGION}:123456789012:gateway/${GATEWAY_ID}`; function body(...chunks: Uint8Array[]): AsyncIterable { return (async function* () { @@ -72,7 +73,6 @@ function configuredCore(gateway: Partial = {}): TestCoreClie core.gateway .setGetResponse({ gatewayId: GATEWAY_ID, - gatewayArn: GATEWAY_ARN, gatewayUrl: GATEWAY_URL, authorizerType: "NONE", ...gateway, @@ -86,9 +86,7 @@ function configuredCore(gateway: Partial = {}): TestCoreClie return core; } -const methods = (core: TestCoreClient) => core.gateway.calls.map(({ method }) => method); - -describe("invoke --gateway", () => { +describe("gateway invoke", () => { test.each(["payload", "bearer-token"] as const)( "reads TTY %s before starting progress", async (input) => { @@ -100,8 +98,9 @@ describe("invoke --gateway", () => { const io = testIO({ isTTY: true }); const stdin = io.io.stdin as unknown as PassThrough; const pending = runCommand(core, io.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", input === "payload" ? "-" : "{}", @@ -112,7 +111,7 @@ describe("invoke --gateway", () => { stdin.write(input === "payload" ? "{}" : "test-token"); await tick(120); expect(io.stderr()).toBe(""); - expect(methods(core)).toEqual(["getGateway"]); + expect(core.gateway.calls).toEqual([]); stdin.end(); await waitFor(() => io.stderr().includes("Invoking gateway...")); } finally { @@ -131,8 +130,9 @@ describe("invoke --gateway", () => { core.gateway.setInvokeResponse({ statusCode: 200, contentType: "text/plain", body: stream }); const io = testIO({ isTTY: true }); const pending = runCommand(core, io.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", "{}", @@ -162,16 +162,17 @@ describe("invoke --gateway", () => { const output = captureIO(); await runCommand(core, output.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", '{"jsonrpc":"2.0","id":1,"method":"tools/list"}', ]); - expect(methods(core)).toEqual(["getGateway", "getGateway", "invokeGateway"]); - const lookup = core.gateway.calls[1]!; - const invoke = core.gateway.calls[2]!; + expect(core.gateway.calls.map((call) => call.method)).toEqual(["getGateway", "invokeGateway"]); + const lookup = core.gateway.calls[0]!; + const invoke = core.gateway.calls[1]!; expect(lookup.args.slice(0, 2)).toEqual([GATEWAY_ID, { region: REGION }]); expect(lookup.args[2]).toBe(invoke.args[2]); expect(invoke.args[1]).toEqual({ region: REGION }); @@ -192,8 +193,9 @@ describe("invoke --gateway", () => { const output = captureIO(); await runCommand(core, output.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--path", "target/invocations?trace=true", @@ -239,8 +241,9 @@ describe("invoke --gateway", () => { const output = captureIO(); await runCommand(core, output.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--method", "GET", @@ -260,7 +263,7 @@ describe("invoke --gateway", () => { const core = configuredCore(); const output = captureIO(); - await runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--payload", ""]); + await runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID, "--payload", ""]); const request = core.gateway.calls.find((call) => call.method === "invokeGateway")! .args[0] as GatewayInvokeRequest; @@ -272,8 +275,9 @@ describe("invoke --gateway", () => { const output = captureIO(Buffer.from("secret-token")); await runCommand(core, output.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", "{}", @@ -297,8 +301,9 @@ describe("invoke --gateway", () => { const output = captureIO(); await runCommand(core, output.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", "{}", @@ -326,7 +331,7 @@ describe("invoke --gateway", () => { const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--method", "GET"]), + runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID, "--method", "GET"]), ); expect(code).toBe(ExitCode.FAILURE); @@ -348,7 +353,15 @@ describe("invoke --gateway", () => { const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--payload", "{}", "--json"]), + runCommand(core, output.io, [ + "gateway", + "invoke", + "--id", + GATEWAY_ID, + "--payload", + "{}", + "--json", + ]), ); expect(code).toBe(ExitCode.FAILURE); @@ -379,8 +392,9 @@ describe("invoke --gateway", () => { const code = await runWithExitCode(async () => runCommand(core, output.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", "{}", @@ -403,13 +417,15 @@ describe("invoke --gateway", () => { }); test.each([ - [["invoke", "--gateway", GATEWAY_ID, "--json"], /--payload/], - [["invoke", "--gateway", GATEWAY_ID, "--method", "POST"], /--payload/], - [["invoke", "--gateway", GATEWAY_ID, "--output-file", "response.bin"], /--payload/], + [["gateway", "invoke", "--payload", "{}"], /--id/], + [["gateway", "invoke", "--id", GATEWAY_ID, "--json"], /--payload/], + [["gateway", "invoke", "--id", GATEWAY_ID, "--method", "POST"], /--payload/], + [["gateway", "invoke", "--id", GATEWAY_ID, "--output-file", "response.bin"], /--payload/], [ [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", "{}", @@ -429,7 +445,16 @@ describe("invoke --gateway", () => { test("rejects an unknown option before invocation", async () => { const core = configuredCore(); const output = captureIO(); - const args = ["invoke", "--gateway", GATEWAY_ID, "--payload", "{}", "--request-type", "mcp"]; + const args = [ + "gateway", + "invoke", + "--id", + GATEWAY_ID, + "--payload", + "{}", + "--request-type", + "mcp", + ]; await expectError( runCommand(core, output.io, args), @@ -439,48 +464,60 @@ describe("invoke --gateway", () => { expect(core.gateway.calls.some((call) => call.method === "invokeGateway")).toBe(false); }); - test("deep-links an id-only invoke and seeds interactive request context", async () => { - const core = configuredCore({ - gatewayArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:gateway/gateway/blue one`, - }); + test("a bare command enters existing TUI middleware without Gateway Core calls", async () => { + const core = configuredCore(); const output = captureIO(); - const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); - try { - await runCommand(core, output.io, [ - "invoke", - "--gateway", - GATEWAY_ID, - "--path", - "runtime/invocations?trace=true", - "--header", - "X-Tenant: retail", - "--bearer-token", - "secret-token", - "--session-id", - "runtime-session", - "--mcp-session-id", - "mcp-session", - "--mcp-protocol-version", - "2025-06-18", - ]); + await expect(runCommand(core, output.io, ["gateway", "invoke"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + expect(core.gateway.calls).toEqual([]); + }); - expect(render).toHaveBeenCalledTimes(1); - const [path, ctx] = render.mock.calls[0]!; - expect(path).toBe("/agentcore/invoke/gateway/gateway%2Fblue%20one"); - expect(ctx.value(GatewayInvokeLaunchContextKey)).toEqual({ - gatewayId: "gateway/blue one", + test("deep-links an id-only invoke and seeds interactive request context", async () => { + const core = configuredCore(); + const output = captureIO(); + let renderCount = 0; + const handler = createInvokeGatewayHandler( + core, + output.io, + async (path, ctx, renderedCore, renderedIo) => { + renderCount++; + expect(path).toBe("/agentcore/gateway/invoke/gateway%2Fblue%20one"); + expect(ctx.value(GatewayInvokeLaunchContextKey)).toEqual({ + gatewayId: "gateway/blue one", + path: "runtime/invocations?trace=true", + runtimeSessionId: "runtime-session", + mcpSessionId: "mcp-session", + mcpProtocolVersion: "2025-06-18", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: "secret-token", + }); + expect(renderedCore).toBe(core); + expect(renderedIo).toBe(output.io); + }, + ); + const ctx = ValueContext.EmptyContext() + .withValue(PathKey, "/agentcore/gateway/invoke") + .withValue(JsonKey, false); + + await handler.handle( + ctx, + { + id: "gateway/blue one", path: "runtime/invocations?trace=true", - runtimeSessionId: "runtime-session", - mcpSessionId: "mcp-session", - mcpProtocolVersion: "2025-06-18", - applicationHeaders: [["X-Tenant", "retail"]], - bearerToken: "secret-token", - }); - expect(methods(core)).toEqual(["getGateway"]); - } finally { - render.mockRestore(); - } + payload: undefined, + header: ["X-Tenant: retail"], + "bearer-token": "secret-token", + "session-id": "runtime-session", + "mcp-session-id": "mcp-session", + "mcp-protocol-version": "2025-06-18", + }, + {}, + ); + + expect(renderCount).toBe(1); + expect(core.gateway.calls).toEqual([]); }); test("rejects stdin bearer tokens when launching the TUI", async () => { @@ -488,20 +525,20 @@ describe("invoke --gateway", () => { const output = captureIO(Buffer.from("secret-token")); await expect( - runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID, "--bearer-token", "-"]), + runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID, "--bearer-token", "-"]), ).rejects.toThrow("stdin bearer tokens are not available"); - expect(methods(core)).toEqual(["getGateway"]); + expect(core.gateway.calls).toEqual([]); }); test("classifies an unavailable interactive environment as usage", async () => { const core = configuredCore(); const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, ["invoke", "--gateway", GATEWAY_ID]), + runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID]), ); expect(code).toBe(ExitCode.USAGE); - expect(methods(core)).toEqual(["getGateway"]); + expect(core.gateway.calls).toEqual([]); }); test("SIGINT aborts lookup and invocation through the same signal", async () => { @@ -517,8 +554,9 @@ describe("invoke --gateway", () => { }); }; const pending = runCommand(core, output.io, [ + "gateway", "invoke", - "--gateway", + "--id", GATEWAY_ID, "--payload", "{}", @@ -528,8 +566,8 @@ describe("invoke --gateway", () => { await waitFor(() => core.gateway.calls.some((call) => call.method === "invokeGateway")); process.emit("SIGINT", "SIGINT"); - const lookupSignal = core.gateway.calls[1]!.args[2] as AbortSignal; - const invokeSignal = core.gateway.calls[2]!.args[2] as AbortSignal; + const lookupSignal = core.gateway.calls[0]!.args[2] as AbortSignal; + const invokeSignal = core.gateway.calls[1]!.args[2] as AbortSignal; expect(lookupSignal).toBe(invokeSignal); expect(invokeSignal.aborted).toBe(true); expect(invokeSignal.reason).toBeInstanceOf(UserCancellationError); @@ -539,7 +577,7 @@ describe("invoke --gateway", () => { } }); - test("redacts --path from debug logs", () => { + test("registers a headless invoke leaf without a request-type flag", () => { const core = configuredCore(); const output = captureIO(); const root = createRootHandler(core, { @@ -550,8 +588,11 @@ describe("invoke --gateway", () => { initialConfigData: IMPERATIVE_GLOBAL_CONFIG, }), }); - const invoke = root.children().find((child) => child.name() === "invoke"); + const gateway = root.children().find((child) => child.name() === "gateway"); + const invoke = gateway?.children().find((child) => child.name() === "invoke"); + expect(invoke).toBeDefined(); + expect(invoke?.flags().map((registered) => registered.name)).not.toContain("request-type"); expect(invoke?.flags().find((registered) => registered.name === "path")?.sensitive).toBe(true); }); }); diff --git a/src/handlers/gateway/invoke/request.ts b/src/handlers/gateway/invoke/request.ts index 808bd41daa..0ae5aad7aa 100644 --- a/src/handlers/gateway/invoke/request.ts +++ b/src/handlers/gateway/invoke/request.ts @@ -1,9 +1,14 @@ import { validateHeaderName, validateHeaderValue } from "node:http"; import type { AuthorizerType, GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; import { InputValidationError, SourceResolutionError } from "../../../errors"; import { SourceResolver } from "../../../io"; import type { GatewayInvokeMethod, GatewayInvokeRequest } from "../types"; +export const gatewayIdSchema = z + .string() + .refine((value) => !value.startsWith("arn:"), "must be a Gateway ID, not an ARN"); + type GatewayInvokeInput = { gatewayId: string; path?: string; diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 16d86a8b83..28fd900fb4 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -45,7 +45,7 @@ type Exchange = { }; const invokePath = (...parts: string[]) => - ["/agentcore/invoke/gateway", ...parts.map(encodeURIComponent)].join("/"); + ["/agentcore/gateway/invoke", ...parts.map(encodeURIComponent)].join("/"); const metadata = (response: GatewayInvokeResponse) => [ @@ -148,7 +148,7 @@ export function GatewayInvokeScreen(props: ScreenProps) { return ( navigate(invokePath(id))} /> @@ -420,7 +420,7 @@ export function GatewayInvokeConsole({ setPickingGateway(false)} @@ -430,7 +430,7 @@ export function GatewayInvokeConsole({ return ( { test("chat turns and exec commands share one session and one transcript", async () => { const core = execCore(); // Start on the invoke route (chat mode), then toggle into exec mode. - const r = renderImperativeScreen("/agentcore/invoke/harness/MyHarness-abc123", { core }); + const r = renderImperativeScreen("/agentcore/harness/invoke/MyHarness-abc123", { core }); await waitForText(r.lastFrame, "send a message…"); await type(r, "hi agent"); diff --git a/src/handlers/harness/get/get.screen.test.tsx b/src/handlers/harness/get/get.screen.test.tsx index ff75097154..58479cab8f 100644 --- a/src/handlers/harness/get/get.screen.test.tsx +++ b/src/handlers/harness/get/get.screen.test.tsx @@ -165,7 +165,7 @@ describe("harness hub screen", () => { await r.press("down"); // versions await r.press("down"); // invoke await r.press("return"); - await waitForText(r.lastFrame, "agentcore → invoke → harness → MyHarness-abc123"); + await waitForText(r.lastFrame, "agentcore → harness → invoke → MyHarness-abc123"); r.unmount(); }); diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index 4c6679bdf8..e50378b0bd 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -41,7 +41,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string } { name: "invoke", description: "chat with this harness", - to: (id) => `/agentcore/invoke/harness/${id}`, + to: (id) => `/agentcore/harness/invoke/${id}`, }, { name: "exec", diff --git a/src/handlers/harness/index.tsx b/src/handlers/harness/index.tsx index 67ab473825..9c29e98968 100644 --- a/src/handlers/harness/index.tsx +++ b/src/handlers/harness/index.tsx @@ -8,6 +8,7 @@ import { createListHarnessHandler } from "./list"; import { createCreateHarnessHandler } from "./create"; import { createUpdateHarnessHandler } from "./update"; import { createDeleteHarnessHandler } from "./delete"; +import { createInvokeHarnessHandler } from "./invoke"; import { createExecHarnessHandler } from "./exec"; import { createEndpointHandler } from "./endpoint"; import { createVersionHandler } from "./version"; @@ -27,6 +28,7 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { "list", "update", "delete", + "invoke", "exec", "endpoint", "version", @@ -38,6 +40,7 @@ export function createHarnessHandler(core: Core, io: AppIO): Router { harness.handler(createListHarnessHandler(core)); harness.handler(createUpdateHarnessHandler(core)); harness.handler(createDeleteHarnessHandler(core)); + harness.handler(createInvokeHarnessHandler(core, io)); harness.handler(createExecHarnessHandler(core, io)); harness.handler(createHarnessLogsHandler(core, io)); harness.handler(createHarnessTracesHandler(core, io)); diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx new file mode 100644 index 0000000000..a18e263dfb --- /dev/null +++ b/src/handlers/harness/invoke/index.tsx @@ -0,0 +1,68 @@ +import z from "zod"; +import { createHandler, flag, PathKey } from "../../../router"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types.tsx"; +import { coreOptsFromCtx } from "../../utils.tsx"; +import { JsonKey } from "../../keys.tsx"; +import { JsonRendererKey, renderTuiAt } from "../../../tui"; +import { runWithProgress } from "../../../tui/progress"; +import { InputValidationError } from "../../../errors"; +import { invokeHarnessTurn } from "./operation.ts"; + +export const createInvokeHarnessHandler = (core: Core, io: AppIO) => + createHandler({ + name: "invoke", + description: "invoke a harness", + flags: [ + flag("id", "the ID of the harness", z.string().min(1).max(48)), + flag("prompt", "the message to send to the harness", z.string().optional()), + flag( + "session-id", + "the Runtime session ID to continue (33-100 characters)", + z.string().min(33).max(100).optional(), + ), + flag( + "qualifier", + "the harness endpoint qualifier to invoke (default DEFAULT)", + z.string().optional(), + ), + ], + handle: async (ctx, flags) => { + // Without a prompt, open the interactive chat at this harness — resuming + // the given session and targeting the given qualifier when passed. The + // one-shot CLI transcript below needs --prompt (and is the only shape + // JSON mode supports). + if (!flags["prompt"]) { + if (ctx.require(JsonKey)) { + throw new InputValidationError("required option '--prompt ' not specified"); + } + let path = `${ctx.require(PathKey)}/${flags["id"]}`; + if (flags["session-id"]) path += `/${flags["session-id"]}`; + if (flags["qualifier"]) path += `?qualifier=${encodeURIComponent(flags["qualifier"])}`; + await renderTuiAt(path, ctx, core, io); + return; + } + + const opts = coreOptsFromCtx(ctx); + const prompt = flags["prompt"]; + const invoke = () => + invokeHarnessTurn( + core.harness, + { + harnessId: flags["id"], + prompt, + qualifier: flags["qualifier"] ?? "DEFAULT", + sessionId: flags["session-id"], + }, + opts, + ); + const result = await runWithProgress(invoke, { + io, + label: "Invoking harness...", + interactive: !ctx.require(JsonKey), + }); + ctx.require(JsonRendererKey).renderJson(result); + }, + }); + +export { HarnessInvokeScreen } from "./screen.tsx"; diff --git a/src/handlers/harness/invoke/invoke.screen.test.tsx b/src/handlers/harness/invoke/invoke.screen.test.tsx index 07aec6b1d4..e3714cc435 100644 --- a/src/handlers/harness/invoke/invoke.screen.test.tsx +++ b/src/handlers/harness/invoke/invoke.screen.test.tsx @@ -21,7 +21,7 @@ afterEach(cleanupScreens); // when a test needs to hold the stream open and drive it by hand. const ARN = "arn:aws:bedrock-agentcore:us-east-1:123:harness/MyHarness-abc123"; -const CHAT_PATH = "/agentcore/invoke/harness/MyHarness-abc123"; +const CHAT_PATH = "/agentcore/harness/invoke/MyHarness-abc123"; function summary(overrides: Partial = {}): HarnessSummary { return { @@ -71,7 +71,7 @@ describe("invoke picker screen", () => { summary({ harnessName: "beta", harnessId: "beta-2" }), ], }); - const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); + const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); await waitForText(r.lastFrame, "alpha"); expect(r.lastFrame()).toContain("beta"); @@ -82,12 +82,12 @@ describe("invoke picker screen", () => { test("selecting a harness opens its chat", async () => { const core = chatCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); + const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); await waitForText(r.lastFrame, "MyHarness"); await r.press("return"); // The chat screen's breadcrumb carries the harness id and the prompt mounts. - await waitForText(r.lastFrame, "invoke → harness → MyHarness-abc123"); + await waitForText(r.lastFrame, "invoke → MyHarness-abc123"); await waitForText(r.lastFrame, "send a message…"); r.unmount(); }); @@ -95,21 +95,21 @@ describe("invoke picker screen", () => { test("shows the error message when the list call fails", async () => { const core = new TestCoreClient(); core.harness.setError(new Error("access denied")); - const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); + const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); await waitForText(r.lastFrame, "Error:"); expect(r.lastFrame()).toContain("access denied"); r.unmount(); }); - test("esc returns to the project invoke picker", async () => { + test("esc returns to the harness menu", async () => { const core = new TestCoreClient(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); + const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); await waitForText(r.lastFrame, "MyHarness"); await r.press("escape"); - await waitForText(r.lastFrame, "invoke a Runtime, harness, or Gateway from the current project"); + await waitForText(r.lastFrame, "manage AgentCore harnesses"); r.unmount(); }); }); @@ -401,7 +401,7 @@ describe("invoke chat screen", () => { test("esc while idle pops back to the picker", async () => { const core = chatCore(); core.harness.setListResponse({ harnesses: [summary()] }); - const r = renderImperativeScreen("/agentcore/invoke/harness", { core }); + const r = renderImperativeScreen("/agentcore/harness/invoke", { core }); await waitForText(r.lastFrame, "MyHarness"); await r.press("return"); diff --git a/src/handlers/project/invoke/harness.test.tsx b/src/handlers/harness/invoke/invoke.test.tsx similarity index 84% rename from src/handlers/project/invoke/harness.test.tsx rename to src/handlers/harness/invoke/invoke.test.tsx index 269b3c22b0..7b91fe8c8d 100644 --- a/src/handlers/project/invoke/harness.test.tsx +++ b/src/handlers/harness/invoke/invoke.test.tsx @@ -8,6 +8,7 @@ import { createRootHandler } from "../../index"; import { IMPERATIVE_GLOBAL_CONFIG, createSilentLogger, + expectError, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -15,8 +16,9 @@ import { tick, waitFor, } from "../../../testing"; +import { InputValidationError } from "../../../errors"; -// Command-flow tests for `invoke --harness`, driven through the real root handler +// Command-flow tests for `harness invoke`, driven through the real root handler // exactly as the CLI runs it. Unlike the get/list suites these use a // TestCoreClient rather than recorded fixtures: the invoke response is an // AsyncIterable stream, which the fixture serialization cannot capture. @@ -61,14 +63,22 @@ async function run(args: string[], configure?: (core: TestCoreClient) => void, i return { core, stdout: io.stdout() }; } -describe("invoke --harness", () => { +describe("harness invoke", () => { test.each([false, true])( "waits for the transcript and respects JSON mode (json=%s)", async (json) => { const io = testIO({ isTTY: true }); const stream = new StreamController(); const pending = run( - ["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi", ...(json ? ["--json"] : [])], + [ + "harness", + "invoke", + "--id", + "MyHarness-abc123", + "--prompt", + "hi", + ...(json ? ["--json"] : []), + ], (core) => core.harness.queueInvokeStream(stream), io, ); @@ -88,7 +98,14 @@ describe("invoke --harness", () => { ); test("folds the stream into a JSON transcript", async () => { - const { stdout } = await run(["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi"]); + const { stdout } = await run([ + "harness", + "invoke", + "--id", + "MyHarness-abc123", + "--prompt", + "hi", + ]); const out = JSON.parse(stdout); expect(out.stopReason).toBe("end_turn"); @@ -102,7 +119,7 @@ describe("invoke --harness", () => { }); test("resolves the harness ARN and sends a single user message with a fresh session id", async () => { - const { core } = await run(["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi"]); + const { core } = await run(["harness", "invoke", "--id", "MyHarness-abc123", "--prompt", "hi"]); const get = core.harness.calls.find((c) => c.method === "getHarness")!; expect(get.args[0]).toBe("MyHarness-abc123"); @@ -121,8 +138,9 @@ describe("invoke --harness", () => { test("--session-id is passed through and echoed in the output", async () => { const sessionId = "custom-session-id-that-is-long-enough"; const { core, stdout } = await run([ + "harness", "invoke", - "--harness", + "--id", "MyHarness-abc123", "--prompt", "hi", @@ -137,14 +155,15 @@ describe("invoke --harness", () => { test("--session-id shorter than 33 characters is rejected", async () => { await expect( - run(["invoke", "--harness", "X", "--prompt", "hi", "--session-id", "too-short"]), - ).rejects.toThrow("--session-id must be 33-100 characters for a harness"); + run(["harness", "invoke", "--id", "X", "--prompt", "hi", "--session-id", "too-short"]), + ).rejects.toThrow(); }); test("--qualifier is passed through on the request", async () => { const { core } = await run([ + "harness", "invoke", - "--harness", + "--id", "MyHarness-abc123", "--prompt", "hi", @@ -158,7 +177,7 @@ describe("invoke --harness", () => { test("a stream-borne validationException becomes an error transcript item", async () => { const { stdout } = await run( - ["invoke", "--harness", "MyHarness-abc123", "--prompt", "hi"], + ["harness", "invoke", "--id", "MyHarness-abc123", "--prompt", "hi"], (core) => core.harness.setInvokeEvents({ validationException: { message: "session id malformed" }, @@ -171,11 +190,15 @@ describe("invoke --harness", () => { ]); }); + test("errors when --id is omitted", async () => { + await expectError(run(["harness", "invoke", "--prompt", "hi"]), /--id/, InputValidationError); + }); + // Without --prompt (and outside JSON mode) the handler opens the interactive // chat instead — that path is covered by the screen tests, since the test IO // streams cannot host an Ink render. test("errors when --prompt is omitted in JSON mode", async () => { - await expect(run(["invoke", "--harness", "MyHarness-abc123", "--json"])).rejects.toThrow( + await expect(run(["harness", "invoke", "--id", "MyHarness-abc123", "--json"])).rejects.toThrow( /--prompt/, ); }); diff --git a/src/handlers/harness/invoke/screen.tsx b/src/handlers/harness/invoke/screen.tsx index a27652b108..838e85668a 100644 --- a/src/handlers/harness/invoke/screen.tsx +++ b/src/handlers/harness/invoke/screen.tsx @@ -29,7 +29,7 @@ import { const theme = darkTheme; -// HarnessInvokeScreen is the interactive chat for `invoke --harness`. Without a +// HarnessInvokeScreen is the interactive chat for `harness invoke`. Without a // `:harnessId` route value it renders a picker (choose which harness to chat // with); with one it renders the chat itself. A `:sessionId` route value // resumes that runtime session instead of starting a fresh one, and a @@ -43,9 +43,9 @@ export function HarnessInvokeScreen(props: ScreenProps) { return ( navigate(`/agentcore/invoke/harness/${id}`)} + onSelect={(id) => navigate(`/agentcore/harness/invoke/${id}`)} /> ); } @@ -302,17 +302,13 @@ export function HarnessChat({ // The ctrl+t endpoint switcher: pick an endpoint and later sends target its // qualifier; esc closes the overlay with the qualifier unchanged. - const breadcrumb = - variant === "invoke" - ? ["agentcore", "invoke", "harness", harnessId] - : ["agentcore", "harness", "exec", harnessId]; if (pickingEndpoint) { return ( { setQualifier(endpointName); @@ -325,7 +321,7 @@ export function HarnessChat({ return ( { - const jsonOutput = ctx.require(JsonKey); - const hasHeadlessOnlyFlag = Object.entries(flags).some( - ([name, value]) => !TUI_FLAGS.includes(name) && value !== undefined, - ); - if (!jsonOutput && !hasHeadlessOnlyFlag) { - const applicationHeaders = parseGatewayInvokeHeaders(flags.header); - const bearerToken = await resolveGatewayInvokeTuiBearerToken( - flags["bearer-token"], - new SourceResolver({ stdin: io.stdin }), - ); - await renderInvokeTui( - `/agentcore/invoke/gateway/${encodeURIComponent(gatewayId)}`, - ctx.withValue(GatewayInvokeLaunchContextKey, { - gatewayId, - path: flags.path, - runtimeSessionId: flags["session-id"], - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - applicationHeaders, - bearerToken, - }), - core, - io, - ); - return; - } - - const applicationHeaders = parseGatewayInvokeHeaders(flags.header); - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const options = coreOptsFromCtx(ctx); - const gateway = await core.gateway.getGateway(gatewayId, options, signal); - const request = normalizeGatewayInvokeRequest(gateway, { - gatewayId, - path: flags.path, - method: flags.method, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - applicationHeaders, - bearerToken: sources.bearerToken, - runtimeSessionId: flags["session-id"], - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - }); - const response = await core.gateway.invokeGateway(request, options, signal); - await writeGatewayInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new GatewayInvokeResponseError(`HTTP ${response.statusCode}`); - } - }; - await withUserCancellation(async (signal) => { - const sources = await resolveGatewayInvokeSources( - { payload: flags.payload, bearerToken: flags["bearer-token"] }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking gateway...", - interactive: !jsonOutput, - }); - }); -} diff --git a/src/handlers/project/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx deleted file mode 100644 index 3fcff2eba2..0000000000 --- a/src/handlers/project/invoke/harness.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { InputValidationError } from "../../../errors"; -import type { AppIO } from "../../../io"; -import type { Context } from "../../../router"; -import { JsonRendererKey, type renderTuiAt } from "../../../tui"; -import { runWithProgress } from "../../../tui/progress"; -import { JsonKey } from "../../keys"; -import { invokeHarnessTurn } from "../../harness/invoke/operation"; -import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; -import type { InvokeFlags } from "."; - -export async function invokeHarness( - core: Core, - io: AppIO, - ctx: Context, - harnessId: string, - flags: InvokeFlags, - renderInvokeTui: typeof renderTuiAt, -): Promise { - const sessionId = flags["session-id"]; - if (sessionId !== undefined && (sessionId.length < 33 || sessionId.length > 100)) { - throw new InputValidationError("--session-id must be 33-100 characters for a harness"); - } - - if (!flags.prompt) { - if (ctx.require(JsonKey)) { - throw new InputValidationError("required option '--prompt ' not specified"); - } - let path = `/agentcore/invoke/harness/${encodeURIComponent(harnessId)}`; - if (sessionId) path += `/${encodeURIComponent(sessionId)}`; - if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; - await renderInvokeTui(path, ctx, core, io); - return; - } - - const prompt = flags.prompt; - const invoke = () => - invokeHarnessTurn( - core.harness, - { harnessId, prompt, qualifier: flags.qualifier, sessionId }, - coreOptsFromCtx(ctx), - ); - const result = await runWithProgress(invoke, { - io, - label: "Invoking harness...", - interactive: !ctx.require(JsonKey), - }); - ctx.require(JsonRendererKey).renderJson(result); -} diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index c822858dff..89c2666414 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -212,12 +212,12 @@ describe("invoke", () => { ); test.each([ - [["--runtime", "checkout"], `/agentcore/invoke/runtime/${RUNTIME_ID}`], + [["--runtime", "checkout"], `/agentcore/runtime/invoke/${RUNTIME_ID}`], [ ["--harness", "support", "--qualifier", "prod"], - `/agentcore/invoke/harness/${HARNESS_ID}?qualifier=prod`, + `/agentcore/harness/invoke/${HARNESS_ID}?qualifier=prod`, ], - [["--gateway", "tools"], `/agentcore/invoke/gateway/${GATEWAY_ID}`], + [["--gateway", "tools"], `/agentcore/gateway/invoke/${GATEWAY_ID}`], [[], "/agentcore/invoke"], ])("opens the TUI for %j", async (args, path) => { const subject = await launches(args, { @@ -232,11 +232,11 @@ describe("invoke", () => { }); test.each([ - ["the sole Runtime", { runtimes: [RUNTIME] }, `/agentcore/invoke/runtime/${RUNTIME_ID}`], + ["the sole Runtime", { runtimes: [RUNTIME] }, `/agentcore/runtime/invoke/${RUNTIME_ID}`], [ "the sole Gateway", { agentCoreGateways: [GATEWAY] }, - `/agentcore/invoke/gateway/${GATEWAY_ID}`, + `/agentcore/gateway/invoke/${GATEWAY_ID}`, ], ["nothing outside a project", undefined, "/agentcore/invoke"], ] as const)("a bare invoke selects %s", async (_name, resources, path) => { @@ -310,7 +310,7 @@ describe("invoke", () => { name: "a harness session ID that is too short", args: ["--harness", "support", "--prompt", "hi", "--session-id", "short"], resources: { harnesses: [HARNESS] }, - message: "--session-id must be 33-100 characters for a harness", + message: "Invalid value for option '--session-id'", }, ])("rejects $name", async ({ args, resources, message }) => { const subject = await routedCommand(args, resources); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 324d164fa0..194a2321fd 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -4,16 +4,26 @@ import { InputValidationError, ProjectStateError } from "../../../errors"; import type { AppIO } from "../../../io"; import { withProject } from "../../../middleware"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; -import { createHandler, flag, ProjectKey, ProjectTargetKey, type FlagsOf } from "../../../router"; +import { + createHandler, + flag, + PathKey, + ProjectKey, + ProjectTargetKey, + type FlagsOf, + type Handler, +} from "../../../router"; +import { attributeName, parseFlags } from "../../../router/flags"; import { renderTuiAt } from "../../../tui"; +import { createInvokeGatewayHandler } from "../../gateway/invoke"; +import { createInvokeHarnessHandler } from "../../harness/invoke"; import { JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; import { assertMutuallyExclusiveFlags, toResourceArn } from "../../utils"; import { projectResourceNames, RESOURCE_LABELS } from "../selection"; import type { Project, ProjectInvokableResource } from "../types"; -import { invokeGateway } from "./gateway"; -import { invokeHarness } from "./harness"; -import { invokeProjectRuntimeLocally, invokeRuntime } from "./runtime"; +import { createInvokeRuntimeHandler } from "../../runtime/invoke"; +import { invokeProjectRuntimeLocally } from "./runtime"; const RESOURCE = "Resource options:"; const REQUEST = "Request options:"; @@ -112,48 +122,9 @@ const invokeFlags = [ ] as const; export type InvokeFlags = FlagsOf; -type InvokeFlagName = keyof InvokeFlags; const RESOURCE_TYPES = ["runtime", "harness", "gateway"] as const; -const REQUEST_FLAGS: Record = { - runtime: [ - "local", - "port", - "payload", - "session-id", - "qualifier", - "content-type", - "accept", - "header", - "bearer-token", - "output-file", - "user-id", - "mcp-method", - "mcp-name", - "trace-id", - "trace-parent", - "trace-state", - "baggage", - "mcp-session-id", - "mcp-protocol-version", - ], - harness: ["prompt", "session-id", "qualifier"], - gateway: [ - "payload", - "session-id", - "content-type", - "accept", - "header", - "bearer-token", - "output-file", - "path", - "method", - "mcp-session-id", - "mcp-protocol-version", - ], -}; - const isSet = (value: unknown) => value !== undefined && value !== false; function selectResource( @@ -190,11 +161,12 @@ function selectResource( ); } -export function createProjectInvokeHandler( - core: Core, - io: AppIO, - renderInvokeTui: typeof renderTuiAt = renderTuiAt, -) { +export function createProjectInvokeHandler(core: Core, io: AppIO) { + const invokers: Record = { + runtime: createInvokeRuntimeHandler(core, io), + harness: createInvokeHarnessHandler(core, io), + gateway: createInvokeGatewayHandler(core, io), + }; return createHandler({ name: "invoke", description: "invoke a Runtime, harness, or Gateway", @@ -226,15 +198,17 @@ export function createProjectInvokeHandler( const headless = ctx.require(JsonKey) || Object.values(flags).some(isSet); const selected = selectResource(project, flags, headless); if (!selected) { - await renderInvokeTui("/agentcore/invoke", ctx, core, io); + await renderTuiAt("/agentcore/invoke", ctx, core, io); return; } const [resourceType, identifier] = selected; - const allowed: readonly string[] = [ + const invoker = invokers[resourceType]; + const allowed = [ ...RESOURCE_TYPES, "target", - ...REQUEST_FLAGS[resourceType], + ...(resourceType === "runtime" ? ["local", "port"] : []), + ...invoker.flags().map(({ name }) => name), ]; const misplaced = Object.entries(flags).find( ([name, value]) => isSet(value) && !allowed.includes(name), @@ -251,9 +225,6 @@ export function createProjectInvokeHandler( throw new InputValidationError(`--${name} only applies to project resources`); } } - if (ctx.require(JsonKey) && flags["output-file"] !== undefined) { - throw new InputValidationError("--json cannot be used with --output-file"); - } if (!flags.local && flags.port !== undefined) { throw new InputValidationError("--port requires --local"); } @@ -269,16 +240,17 @@ export function createProjectInvokeHandler( resourceType, identifier, ); - const invoke = { runtime: invokeRuntime, harness: invokeHarness, gateway: invokeGateway }[ - resourceType - ]; - await invoke( - core, - io, - ctx.withValue(RegionKey, regionFromArn(arn)!), - serviceIdFromArn(arn), - flags, - renderInvokeTui, + const values: Record = { ...flags, id: serviceIdFromArn(arn) }; + const request = parseFlags( + invoker.flags(), + Object.fromEntries(invoker.flags().map(({ name }) => [attributeName(name), values[name]])), + ); + await invoker.handle( + ctx + .withValue(RegionKey, regionFromArn(arn)!) + .withValue(PathKey, `/agentcore/${resourceType}/invoke`), + request, + {}, ); }, }); diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx index 2f4432d686..c43c60a91f 100644 --- a/src/handlers/project/invoke/runtime.tsx +++ b/src/handlers/project/invoke/runtime.tsx @@ -4,33 +4,16 @@ import { DEV_PORTS } from "../../../core/dev/port"; import type { AppIO } from "../../../io"; import type { Context } from "../../../router"; import { withUserCancellation } from "../../../runnable"; -import type { renderTuiAt } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; import { JsonKey } from "../../keys"; -import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; -import { invokeRuntimeTarget } from "../../runtime/invoke/operation"; import { parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, - resolveRuntimeInvokeTuiBearerToken, } from "../../runtime/invoke/request"; import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; -import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; import type { Project } from "../types"; import type { InvokeFlags } from "."; -const TUI_FLAGS = [ - "runtime", - "target", - "local", - "qualifier", - "session-id", - "user-id", - "header", - "bearer-token", -]; - export async function invokeProjectRuntimeLocally( io: AppIO, ctx: Context, @@ -38,6 +21,9 @@ export async function invokeProjectRuntimeLocally( flags: InvokeFlags, ): Promise { const jsonOutput = ctx.require(JsonKey); + if (jsonOutput && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } const protocol = runtime.protocol ?? "HTTP"; const unsupportedFlag = Object.entries({ target: flags.target, @@ -117,93 +103,3 @@ export async function invokeProjectRuntimeLocally( }); }); } - -export async function invokeRuntime( - core: Core, - io: AppIO, - ctx: Context, - runtimeId: string, - flags: InvokeFlags, - renderInvokeTui: typeof renderTuiAt, -): Promise { - const jsonOutput = ctx.require(JsonKey); - if (flags.payload === undefined) { - const hasHeadlessOnlyFlag = Object.entries(flags).some( - ([flagName, value]) => !TUI_FLAGS.includes(flagName) && value !== undefined, - ); - if (jsonOutput || hasHeadlessOnlyFlag) { - throw new InputValidationError("required option '--payload ' not specified", { - exitCode: ExitCode.USAGE, - }); - } - let path = `/agentcore/invoke/runtime/${encodeURIComponent(runtimeId)}`; - if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const bearerToken = await resolveRuntimeInvokeTuiBearerToken(flags["bearer-token"], io.stdin); - await renderInvokeTui( - path, - ctx.withValue(RuntimeInvokeLaunchContextKey, { - runtimeId, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken, - }), - core, - io, - ); - return; - } - - const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); - const invoke = async ( - signal: AbortSignal, - beforeOutput: () => Promise, - sources: Awaited>, - ) => { - const response = await invokeRuntimeTarget( - core.runtime, - { - runtimeId, - qualifier: flags.qualifier, - payload: sources.payload, - contentType: flags["content-type"], - accept: flags.accept, - runtimeSessionId: flags["session-id"], - runtimeUserId: flags["user-id"], - applicationHeaders, - bearerToken: sources.bearerToken, - mcpSessionId: flags["mcp-session-id"], - mcpProtocolVersion: flags["mcp-protocol-version"], - mcpMethod: flags["mcp-method"], - mcpName: flags["mcp-name"], - traceId: flags["trace-id"], - traceParent: flags["trace-parent"], - traceState: flags["trace-state"], - baggage: flags.baggage, - }, - coreOptsFromCtx(ctx), - signal, - ); - await writeRuntimeInvokeResponse(response, { - stdout: io.stdout, - stderr: io.stderr, - outputFile: flags["output-file"], - json: jsonOutput, - signal, - beforeOutput, - }); - }; - await withUserCancellation(async (signal) => { - const sources = await resolveRuntimeInvokeSources( - { payload: flags.payload!, bearerToken: flags["bearer-token"] }, - io.stdin, - signal, - ); - return runWithProgress((stop) => invoke(signal, stop, sources), { - io, - label: "Invoking runtime...", - interactive: !jsonOutput, - }); - }); -} diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 808dbae426..fede2ef4f6 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -153,7 +153,7 @@ function ProjectInvokePicker({ ctx={destination.ctx} core={core} runtimeId={destination.id} - breadcrumb={["agentcore", "invoke", "runtime", destination.id]} + breadcrumb={["agentcore", "runtime", "invoke", destination.id]} description="choose an endpoint to invoke" onSelect={(qualifier) => setDestination({ ...destination, qualifier })} onEscape={() => setDestination(undefined)} diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 4bbd008a1b..b7ff3e9421 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -306,7 +306,7 @@ describe("Runtime endpoint flow", () => { await r.press("return"); await waitForText(r.lastFrame, "invoke this Runtime endpoint"); await r.press("return"); - await waitForText(r.lastFrame, "agentcore → invoke → runtime → runtime-123 → prod"); + await waitForText(r.lastFrame, "agentcore → runtime → invoke → runtime-123 → prod"); await waitForText(r.lastFrame, "Enter JSON payload"); await r.press("escape"); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index 250d3bec47..eec3e0ad29 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -52,7 +52,7 @@ export function RuntimeGetEndpointScreen(props: ScreenProps) { description: "invoke this Runtime endpoint", onSelect: () => navigate( - `/agentcore/invoke/runtime/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, + `/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, { state: { returnOnEscape: true } }, ), }, diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index fd910c2816..bbf4747040 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -9,7 +9,7 @@ const ACTIONS = [ { name: "invoke", description: "invoke this Runtime", - to: (id: string) => `/agentcore/invoke/runtime/${encodeURIComponent(id)}`, + to: (id: string) => `/agentcore/runtime/invoke/${encodeURIComponent(id)}`, returnsToDetails: true, }, { diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 2090106c53..469b6cd074 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -5,6 +5,7 @@ import type { AppIO } from "../../io"; import type { Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; +import { createInvokeRuntimeHandler } from "./invoke"; import { createListRuntimesHandler } from "./list"; import { createRuntimeLogsHandler } from "./logs"; import { createRuntimeShellHandler } from "./shell"; @@ -15,9 +16,10 @@ export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) - .supportedTuiCommands("get", "list", "shell", "version", "endpoint") + .supportedTuiCommands("get", "list", "invoke", "shell", "version", "endpoint") .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) + .handler(createInvokeRuntimeHandler(core, io)) .handler(createRuntimeShellHandler(core, io)) .handler(createRuntimeVersionHandler(core, io)) .handler(createRuntimeEndpointHandler(core, io)) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx new file mode 100644 index 0000000000..9ee0d9c77f --- /dev/null +++ b/src/handlers/runtime/invoke/index.tsx @@ -0,0 +1,158 @@ +import z from "zod"; +import { ExitCode, InputValidationError } from "../../../errors"; +import { createHandler, flag, PathKey } from "../../../router"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import { JsonKey } from "../../keys"; +import { withUserCancellation } from "../../../runnable"; +import { renderTuiAt } from "../../../tui"; +import { runWithProgress } from "../../../tui/progress"; +import { + parseRuntimeInvokeHeaders, + resolveRuntimeInvokeSources, + resolveRuntimeInvokeTuiBearerToken, + runtimeIdSchema, +} from "./request"; +import { writeRuntimeInvokeResponse } from "./response"; +import { RuntimeInvokeLaunchContextKey } from "./launchContext"; +import { invokeRuntimeTarget } from "./operation"; + +export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => + createHandler({ + name: "invoke", + description: "invoke a Runtime", + flags: [ + flag("id", "the ID of the Runtime", runtimeIdSchema), + flag("payload", "the inline payload to send", z.string().optional(), { + sensitive: true, + }), + flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("session-id", "the Runtime session ID", z.string().optional()), + flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag("mcp-method", "the MCP method", z.string().optional()), + flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), + flag("trace-id", "the X-Ray trace ID", z.string().optional()), + flag("trace-parent", "the W3C trace parent", z.string().optional()), + flag("trace-state", "the W3C trace state", z.string().optional()), + flag("baggage", "the W3C baggage", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), + ], + handle: async (ctx, flags) => { + if (flags.payload === undefined) { + const hasHeadlessOnlyFlag = Object.entries(flags).some( + ([name, value]) => + ![ + "id", + "qualifier", + "payload", + "session-id", + "user-id", + "header", + "bearer-token", + ].includes(name) && value !== undefined, + ); + if (ctx.require(JsonKey) || hasHeadlessOnlyFlag) { + throw new InputValidationError("required option '--payload ' not specified", { + exitCode: ExitCode.USAGE, + }); + } + let path = `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`; + if (flags.qualifier !== undefined) { + path += `/${encodeURIComponent(flags.qualifier)}`; + } + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const bearerToken = await resolveRuntimeInvokeTuiBearerToken( + flags["bearer-token"], + io.stdin, + ); + const launchContext = { + runtimeId: flags.id, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken, + }; + await renderTuiAt( + path, + ctx.withValue(RuntimeInvokeLaunchContextKey, launchContext), + core, + io, + ); + return; + } + + const jsonOutput = ctx.require(JsonKey); + if (jsonOutput && flags["output-file"] !== undefined) { + throw new InputValidationError("--json cannot be used with --output-file"); + } + const runtimeId = flags.id; + const payload = flags.payload; + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const invoke = async ( + signal: AbortSignal, + beforeOutput: () => Promise, + sources: Awaited>, + ) => { + const options = coreOptsFromCtx(ctx); + const response = await invokeRuntimeTarget( + core.runtime, + { + runtimeId, + qualifier: flags.qualifier, + payload: sources.payload, + contentType: flags["content-type"], + accept: flags.accept, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken: sources.bearerToken, + mcpSessionId: flags["mcp-session-id"], + mcpProtocolVersion: flags["mcp-protocol-version"], + mcpMethod: flags["mcp-method"], + mcpName: flags["mcp-name"], + traceId: flags["trace-id"], + traceParent: flags["trace-parent"], + traceState: flags["trace-state"], + baggage: flags.baggage, + }, + options, + signal, + ); + await writeRuntimeInvokeResponse(response, { + stdout: io.stdout, + stderr: io.stderr, + outputFile: flags["output-file"], + json: jsonOutput, + signal, + beforeOutput, + }); + }; + await withUserCancellation(async (signal) => { + const sources = await resolveRuntimeInvokeSources( + { payload, bearerToken: flags["bearer-token"] }, + io.stdin, + signal, + ); + return runWithProgress((stop) => invoke(signal, stop, sources), { + io, + label: "Invoking runtime...", + interactive: !jsonOutput, + }); + }); + }, + }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index f0d3891812..840d02e02e 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -19,7 +19,7 @@ const REGION = "us-east-1"; const RUNTIME_ID = "runtime-123"; const QUALIFIER = "prod"; const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${RUNTIME_ID}`; -const CONSOLE_PATH = `/agentcore/invoke/runtime/${RUNTIME_ID}/${QUALIFIER}`; +const CONSOLE_PATH = `/agentcore/runtime/invoke/${RUNTIME_ID}/${QUALIFIER}`; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; afterEach(cleanupScreens); @@ -90,7 +90,7 @@ describe("Runtime invoke routing", () => { .setGetResponse({ agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${runtimeId}`, } as GetAgentRuntimeResponse); - const screen = renderImperativeScreen("/agentcore/invoke/runtime", { core }); + const screen = renderImperativeScreen("/agentcore/runtime/invoke", { core }); await waitForText(screen.lastFrame, runtimeId); await screen.press("return"); @@ -99,7 +99,7 @@ describe("Runtime invoke routing", () => { await waitForText( screen.lastFrame, - `agentcore → invoke → runtime → ${runtimeId} → ${qualifier}`, + `agentcore → runtime → invoke → ${runtimeId} → ${qualifier}`, ); await waitForText(screen.lastFrame, "Enter JSON payload"); }); @@ -109,13 +109,13 @@ describe("Runtime invoke routing", () => { core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }).setListResponse({ agentRuntimes: [runtime({ agentRuntimeId: "back-to-runtime-picker" })], }); - const screen = renderImperativeScreen(`/agentcore/invoke/runtime/${RUNTIME_ID}`, { core }); + const screen = renderImperativeScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { core }); await waitForText(screen.lastFrame, QUALIFIER); await screen.press("escape"); await waitForText(screen.lastFrame, "back-to-runtime-picker"); - expect(screen.lastFrame()).toContain("agentcore → invoke → runtime"); + expect(screen.lastFrame()).toContain("agentcore → runtime → invoke"); }); test("keeps a CLI-selected session while choosing an endpoint", async () => { @@ -124,7 +124,7 @@ describe("Runtime invoke routing", () => { core.runtime .setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }) .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderImperativeScreen(`/agentcore/invoke/runtime/${RUNTIME_ID}`, { + const screen = renderImperativeScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { core, withContext: (ctx) => ctx.withValue(RuntimeInvokeLaunchContextKey, { @@ -168,7 +168,7 @@ describe("Runtime invoke routing", () => { await screen.press("return"); await waitForText( screen.lastFrame, - `agentcore → invoke → runtime → ${RUNTIME_ID} → ${nextQualifier}`, + `agentcore → runtime → invoke → ${RUNTIME_ID} → ${nextQualifier}`, ); const nextSessionId = displayedSessionId(screen.lastFrame()); expect(nextSessionId).toMatch(UUID_PATTERN); @@ -338,7 +338,7 @@ describe("Runtime invoke JSON console", () => { core.runtime.setGetResponse({ agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${runtimeId}`, } as GetAgentRuntimeResponse); - const screen = renderImperativeScreen(`/agentcore/invoke/runtime/${runtimeId}/${QUALIFIER}`, { + const screen = renderImperativeScreen(`/agentcore/runtime/invoke/${runtimeId}/${QUALIFIER}`, { core, }); @@ -728,7 +728,7 @@ describe("Runtime invoke JSON console", () => { await screen.press("return"); await waitForText( screen.lastFrame, - `agentcore → invoke → runtime → ${RUNTIME_ID} → ${nextQualifier}`, + `agentcore → runtime → invoke → ${RUNTIME_ID} → ${nextQualifier}`, ); expect(screen.lastFrame()).not.toContain("old response"); const nextSessionId = displayedSessionId(screen.lastFrame()); @@ -803,7 +803,7 @@ describe("Runtime invoke JSON console", () => { await screen.press("return"); await waitForText( screen.lastFrame, - `agentcore → invoke → runtime → ${nextRuntimeId} → ${nextQualifier}`, + `agentcore → runtime → invoke → ${nextRuntimeId} → ${nextQualifier}`, ); expect(screen.lastFrame()).not.toContain("Context"); diff --git a/src/handlers/project/invoke/runtime.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx similarity index 79% rename from src/handlers/project/invoke/runtime.test.tsx rename to src/handlers/runtime/invoke/invoke.test.tsx index 012d681bf0..db1022a821 100644 --- a/src/handlers/project/invoke/runtime.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -4,10 +4,11 @@ import { join } from "node:path"; import { PassThrough, Writable } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import type { AppIO } from "../../../io"; -import type { RuntimeInvokeRequest } from "../../runtime/types"; +import type { RuntimeInvokeRequest } from "../types"; import { IMPERATIVE_GLOBAL_CONFIG, createSilentLogger, + expectError, TestCoreClient, TestGlobalConfigAccessor, StreamController, @@ -16,10 +17,10 @@ import { waitFor, } from "../../../testing"; import { runWithExitCode } from "../../../runnable"; -import { ExitCode, UserCancellationError } from "../../../errors"; +import { ExitCode, InputValidationError, UserCancellationError } from "../../../errors"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; -import { RuntimeInvokeLaunchContextKey } from "../../runtime/invoke/launchContext"; +import { RuntimeInvokeLaunchContextKey } from "./launchContext"; const REGION = "us-west-2"; const RUNTIME_ID = "runtime-123"; @@ -73,14 +74,6 @@ async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Prom await root.route(["node", "agentcore", ...args, "--region", REGION]); } -function runtimeCore(): TestCoreClient { - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - return core; -} - -const methods = (core: TestCoreClient) => core.runtime.calls.map(({ method }) => method); - async function run( args: string[], { getResponse = { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse } = {}, @@ -98,11 +91,11 @@ async function run( return { core, output }; } -describe("invoke --runtime", () => { +describe("runtime invoke", () => { test.each(["payload", "bearer-token"] as const)( "reads TTY %s before starting progress", async (input) => { - const core = runtimeCore(); + const core = new TestCoreClient(); const stream = new StreamController(); core.runtime .setGetResponse({ @@ -113,8 +106,9 @@ describe("invoke --runtime", () => { const io = testIO({ isTTY: true }); const stdin = io.io.stdin as unknown as PassThrough; const pending = runCommand(core, io.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", input === "payload" ? "-" : "{}", @@ -125,7 +119,7 @@ describe("invoke --runtime", () => { stdin.write(input === "payload" ? "{}" : "test-token"); await tick(120); expect(io.stderr()).toBe(""); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); stdin.end(); await waitFor(() => io.stderr().includes("Invoking runtime...")); } finally { @@ -139,15 +133,16 @@ describe("invoke --runtime", () => { test.each([false, true])( "hands progress to streamed output and respects JSON mode (json=%s)", async (json) => { - const core = runtimeCore(); + const core = new TestCoreClient(); const stream = new StreamController(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "text/plain", body: stream }); const io = testIO({ isTTY: true }); const pending = runCommand(core, io.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -174,22 +169,19 @@ describe("invoke --runtime", () => { test("resolves the Runtime, invokes its ID in the current account, and writes exact bytes", async () => { const { core, output } = await run([ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", '{"prompt":"hello"}', ]); - expect(core.runtime.calls.map((call) => call.method)).toEqual([ - "getRuntime", - "getRuntime", - "invokeRuntime", - ]); - const lookup = core.runtime.calls[1]!; + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime", "invokeRuntime"]); + const lookup = core.runtime.calls[0]!; expect(lookup.args.slice(0, 2)).toEqual([RUNTIME_ID, { region: REGION }]); - const invoke = core.runtime.calls[2]!; + const invoke = core.runtime.calls[1]!; const request = invoke.args[0] as RuntimeInvokeRequest; expect(request).toEqual({ runtimeId: RUNTIME_ID, @@ -208,8 +200,9 @@ describe("invoke --runtime", () => { test("passes the public request flags through the shared normalizer", async () => { const { core } = await run( [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -251,22 +244,23 @@ describe("invoke --runtime", () => { test.each([ ["--content-type", "text/plain"], ["--output-file", "response.bin"], - ])("rejects request option %s without a payload before invoking", async (flagName, value) => { - const core = runtimeCore(); + ])("rejects request option %s without a payload before Core calls", async (flagName, value) => { + const core = new TestCoreClient(); const output = captureIO(); await expect( - runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, flagName, value]), + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, flagName, value]), ).rejects.toThrow(/--payload/); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); }); test("rejects an empty --output-file before Core calls", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); await expect( runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -278,12 +272,13 @@ describe("invoke --runtime", () => { }); test("rejects --json with --output-file before Core calls", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); await expect( runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -297,7 +292,7 @@ describe("invoke --runtime", () => { test("buffers a streaming response when --json is requested", async () => { let iterations = 0; - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) @@ -311,8 +306,9 @@ describe("invoke --runtime", () => { }); await runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -330,19 +326,20 @@ describe("invoke --runtime", () => { }); test("passes an explicitly empty payload as zero bytes", async () => { - const { core } = await run(["invoke", "--runtime", RUNTIME_ID, "--payload", ""]); + const { core } = await run(["runtime", "invoke", "--id", RUNTIME_ID, "--payload", ""]); const invoke = core.runtime.calls.find((call) => call.method === "invokeRuntime")!; expect((invoke.args[0] as RuntimeInvokeRequest).payload).toEqual(new Uint8Array()); }); test("SIGINT cancels payload stdin resolution with the typed reason", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); const initialListeners = process.listenerCount("SIGINT"); const pending = runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "-", @@ -353,18 +350,17 @@ describe("invoke --runtime", () => { process.emit("SIGINT", "SIGINT"); await expect(pending).rejects.toBeInstanceOf(UserCancellationError); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); } finally { await pending.catch(() => undefined); } }); test("SIGINT replaces a raw Runtime lookup abort with the typed reason", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); core.runtime.getRuntime = async (id, options, signal) => { core.runtime.calls.push({ method: "getRuntime", args: [id, options, signal] }); - if (!signal) return { agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse; return new Promise((_, reject) => { const abort = () => reject(Object.assign(new Error("lookup aborted"), { name: "AbortError" })); @@ -373,28 +369,29 @@ describe("invoke --runtime", () => { }); }; const pending = runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", ]); try { - await waitFor(() => core.runtime.calls.length === 2); + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntime")); process.emit("SIGINT", "SIGINT"); - const signal = core.runtime.calls[1]!.args[2] as AbortSignal; + const signal = core.runtime.calls[0]!.args[2] as AbortSignal; expect(signal.reason).toBeInstanceOf(UserCancellationError); await expect(pending).rejects.toBe(signal.reason); - expect(methods(core)).toEqual(["getRuntime", "getRuntime"]); + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); } finally { await pending.catch(() => undefined); } }); test("SIGINT aborts an active headless invocation after preserving emitted bytes", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); core.runtime.invokeRuntime = async (request, options, signal) => { @@ -414,8 +411,9 @@ describe("invoke --runtime", () => { }; }; const pending = runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -438,7 +436,7 @@ describe("invoke --runtime", () => { }); test("replaces a raw Core abort with the typed SIGINT reason", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); const rawAbort = Object.assign(new Error("transport aborted"), { name: "AbortError" }); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); @@ -451,8 +449,9 @@ describe("invoke --runtime", () => { }); }; const pending = runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -471,9 +470,33 @@ describe("invoke --runtime", () => { } }); + test("rejects an invalid Runtime ARN before Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ARN, "--payload", "{}"]), + ); + + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); + }); + + test("rejects a missing --id before Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + + await expectError( + runCommand(core, output.io, ["runtime", "invoke", "--payload", "{}"]), + /--id/, + InputValidationError, + ); + expect(core.runtime.calls).toEqual([]); + }); + test("aborts an established request when a TTY refuses binary output", async () => { let iterations = 0; - const core = runtimeCore(); + const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ @@ -488,7 +511,7 @@ describe("invoke --runtime", () => { Object.defineProperty(output.io.stdout, "isTTY", { value: true }); await expect( - runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--payload", "{}"]), + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), ).rejects.toThrow("Binary or unknown response content requires --output-file or --json"); const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! @@ -498,72 +521,82 @@ describe("invoke --runtime", () => { }); test("rejects a resolved Runtime ARN without an account ID before invoke", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}::runtime/${RUNTIME_ID}`, } as GetAgentRuntimeResponse); const output = captureIO(); await expect( - runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--payload", "{}"]), + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]), ).rejects.toThrow("Runtime returned an invalid ARN"); - expect(methods(core)).toEqual(["getRuntime", "getRuntime"]); + expect(core.runtime.calls.map((call) => call.method)).toEqual(["getRuntime"]); + }); + + test("a bare command enters existing TUI middleware without Runtime Core calls", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + + await expect(runCommand(core, output.io, ["runtime", "invoke"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + expect(core.runtime.calls).toEqual([]); }); test("classifies the TUI requirement as usage at the handler boundary", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); const code = await runWithExitCode(async () => - runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID]), + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), ); expect(code).toBe(ExitCode.USAGE); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); }); test("preserves unexpected TUI rendering failures", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); const failure = new TypeError("render failed"); const render = spyOn(tui, "renderTuiAt").mockRejectedValue(failure); try { - await expect(runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID])).rejects.toBe( - failure, - ); - expect(methods(core)).toEqual(["getRuntime"]); + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), + ).rejects.toBe(failure); + expect(core.runtime.calls).toEqual([]); } finally { render.mockRestore(); } }); test("handler deep-links id-only and qualified invokes with encoded path segments", async () => { - const core = runtimeCore(); - core.runtime.setGetResponse({ - agentRuntimeArn: `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT_ID}:runtime/runtime/blue one`, - } as GetAgentRuntimeResponse); + const core = new TestCoreClient(); const output = captureIO(); const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); try { - await runCommand(core, output.io, ["invoke", "--runtime", "runtime/blue one"]); + await runCommand(core, output.io, ["runtime", "invoke", "--id", "runtime/blue one"]); await runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", "runtime/blue one", "--session-id", "session/one two", ]); await runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", "runtime/blue one", "--qualifier", "prod/green one", ]); await runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", "runtime/blue one", "--qualifier", "prod/green one", @@ -572,10 +605,10 @@ describe("invoke --runtime", () => { ]); expect(render.mock.calls.map(([path]) => path)).toEqual([ - "/agentcore/invoke/runtime/runtime%2Fblue%20one", - "/agentcore/invoke/runtime/runtime%2Fblue%20one", - "/agentcore/invoke/runtime/runtime%2Fblue%20one/prod%2Fgreen%20one", - "/agentcore/invoke/runtime/runtime%2Fblue%20one/prod%2Fgreen%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", ]); expect(render.mock.calls[1]![1].value(RuntimeInvokeLaunchContextKey)).toMatchObject({ runtimeId: "runtime/blue one", @@ -591,14 +624,15 @@ describe("invoke --runtime", () => { }); test("passes launch identity, authentication, and headers to the TUI without a payload", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); try { await runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--user-id", "user-123", @@ -616,57 +650,61 @@ describe("invoke --runtime", () => { applicationHeaders: [["X-Tenant", "retail"]], bearerToken: "secret-token", }); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); } finally { render.mockRestore(); } }); test("rejects a stdin bearer token when launching the TUI", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); await expect( - runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--bearer-token", "-"]), + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--bearer-token", "-"]), ).rejects.toThrow("stdin bearer tokens are not available"); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); }); test("handler keeps JSON mode without a payload as a usage error", async () => { - const core = runtimeCore(); + const core = new TestCoreClient(); const output = captureIO(); await expect( - runCommand(core, output.io, ["invoke", "--runtime", RUNTIME_ID, "--json"]), + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--json"]), ).rejects.toThrow(/--payload/); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); }); test.each<[string, ...string[]]>([ ["malformed", "missing separator"], ["duplicate", "X-Test: one", "x-test: two"], ["reserved", "Authorization: secret"], - ])("rejects %s headers as input failures before invoking", async (_name, ...headers) => { - const core = runtimeCore(); - const output = captureIO(); - const args = ["invoke", "--runtime", RUNTIME_ID, "--payload", "{}"]; - for (const header of headers) args.push("--header", header); - - const code = await runWithExitCode(async () => runCommand(core, output.io, args)); - - expect(code).toBe(ExitCode.USAGE); - expect(methods(core)).toEqual(["getRuntime"]); - }); + ])( + "rejects %s headers as input failures before Runtime Core calls", + async (_name, ...headers) => { + const core = new TestCoreClient(); + const output = captureIO(); + const args = ["runtime", "invoke", "--id", RUNTIME_ID, "--payload", "{}"]; + for (const header of headers) args.push("--header", header); + + const code = await runWithExitCode(async () => runCommand(core, output.io, args)); + + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); + }, + ); - test("reports an unreadable payload file as an input failure before invoking", async () => { - const core = runtimeCore(); + test("reports an unreadable payload file as an input failure before Runtime Core calls", async () => { + const core = new TestCoreClient(); const output = captureIO(); const missing = join(tmpdir(), `missing-runtime-payload-${process.pid}`); await expect( runCommand(core, output.io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", `file://${missing}`, @@ -676,7 +714,7 @@ describe("invoke --runtime", () => { message: `could not read '--payload' from file '${missing}'`, exitCode: ExitCode.USAGE, }); - expect(methods(core)).toEqual(["getRuntime"]); + expect(core.runtime.calls).toEqual([]); }); test("keeps Core, transport, and output TypeErrors as failures", async () => { @@ -700,8 +738,9 @@ describe("invoke --runtime", () => { const lookupCode = await runWithExitCode(async () => runCommand(lookupCore, captureIO().io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -709,8 +748,9 @@ describe("invoke --runtime", () => { ); const transportCode = await runWithExitCode(async () => runCommand(transportCore, captureIO().io, [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", @@ -718,8 +758,9 @@ describe("invoke --runtime", () => { ); const outputCode = await runWithExitCode(async () => runCommand(outputCore, failingStdoutIO(), [ + "runtime", "invoke", - "--runtime", + "--id", RUNTIME_ID, "--payload", "{}", diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 86797a0727..625edd52ac 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -50,7 +50,7 @@ type Exchange = { }; const invokePath = (...parts: string[]) => - ["/agentcore/invoke/runtime", ...parts.map(encodeURIComponent)].join("/"); + ["/agentcore/runtime/invoke", ...parts.map(encodeURIComponent)].join("/"); const metadata = (response: RuntimeInvokeResponse) => [ @@ -104,7 +104,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { return ( navigate(invokePath(id))} /> @@ -116,7 +116,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { navigate(invokePath(runtimeId, selected), { @@ -338,7 +338,7 @@ export function RuntimeInvokeConsole({ setTargetPicker({ stage: "endpoint", runtimeId: selectedRuntimeId }) @@ -355,7 +355,7 @@ export function RuntimeInvokeConsole({ ctx={ctx} core={core} runtimeId={nextRuntimeId} - breadcrumb={["agentcore", "invoke", "runtime", nextRuntimeId]} + breadcrumb={["agentcore", "runtime", "invoke", nextRuntimeId]} description="choose another endpoint" onSelect={(selected) => { if (nextRuntimeId !== target.runtimeId || selected !== target.qualifier) { @@ -378,7 +378,7 @@ export function RuntimeInvokeConsole({ return ( { expect(runtime?.children().map((child) => child.name())).toEqual([ "get", "list", + "invoke", "shell", "version", "endpoint", From 82be99101c5e97782b30a6ec1d4bdab081aeba3e Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 24 Sep 2026 21:15:38 +0000 Subject: [PATCH 4/7] refactor(invoke): derive invoke flags from the standalone handlers Merge the runtime, harness, and gateway invoke flag lists instead of copying them. An interactive bare invoke always opens the picker so the deployment target is chosen there. Simplify picker rows and update a stale comment. --- command.md | 10 +- src/core/project/manager.tsx | 6 +- src/handlers/gateway/invoke/index.tsx | 56 +++++------ src/handlers/harness/invoke/index.tsx | 30 +++--- src/handlers/project/invoke/index.test.tsx | 19 ++-- src/handlers/project/invoke/index.tsx | 103 ++++++++------------- src/handlers/project/invoke/screen.tsx | 45 ++++----- src/handlers/project/selection.ts | 4 + src/handlers/runtime/invoke/index.tsx | 62 +++++++------ 9 files changed, 161 insertions(+), 174 deletions(-) diff --git a/command.md b/command.md index a2b5b8add1..c7b4cda4d1 100644 --- a/command.md +++ b/command.md @@ -659,13 +659,14 @@ invoke a Runtime, harness, or Gateway - `--local`: invoke the local development server (project Runtime only) (default: false) - `--port `: local development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000) - `--payload `: the inline payload to send -- `--prompt `: the message to send to a harness -- `--session-id `: the session ID to continue (33-100 characters for a harness) -- `--qualifier `: the endpoint qualifier (default DEFAULT) +- `--qualifier `: the Runtime endpoint qualifier - `--content-type `: the payload content type - `--accept `: the accepted response content type +- `--session-id `: the Runtime session ID - `--header `: an ordered application header - `--bearer-token `: the CUSTOM\_JWT bearer token +- `--mcp-session-id `: the MCP session ID +- `--mcp-protocol-version `: the MCP protocol version - `--output-file `: the response output file - `--user-id `: the Runtime user ID (default "default") - `--mcp-method `: the MCP method @@ -674,10 +675,9 @@ invoke a Runtime, harness, or Gateway - `--trace-parent `: the W3C trace parent - `--trace-state `: the W3C trace state - `--baggage `: the W3C baggage +- `--prompt `: the message to send to the harness - `--path `: the path relative to the Gateway origin - `--method `: the HTTP request method -- `--mcp-session-id `: the MCP session ID -- `--mcp-protocol-version `: the MCP protocol version ### `agentcore log` diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 22f6b8f03a..f85f286f53 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1044,9 +1044,9 @@ export class FsProjectManager implements ProjectManager { ({ resourceType, name }) => resourceType === input.resourceType && name === input.name, ); // The declared target wins over the copy on the item: the manager resolved it - // from aws-targets.json, and both invoke handlers pin the AWS region from this - // value while reusing the backend's verified credential provider. Trusting a - // backend's target echo would let it redirect the call. + // from aws-targets.json, and the log and traces handlers pin the AWS region from + // this value while reusing the backend's verified credential provider. Trusting + // a backend's target echo would let it redirect the call. if (resource) return { ...resource, target: resolved.target }; const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx index 30614c5273..a025429c67 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -19,6 +19,34 @@ import { import { writeGatewayInvokeResponse } from "./response"; import { GatewayInvokeLaunchContextKey } from "./launchContext"; +export const invokeGatewayFlags = [ + flag("id", "the ID of the Gateway", gatewayIdSchema), + flag( + "path", + "the path relative to the Gateway origin", + z.string().min(1, "requires a nonempty path").optional(), + { sensitive: true }, + ), + flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional()), + flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the Gateway bearer token", z.string().optional(), { + sensitive: true, + }), + flag("session-id", "the Runtime target session ID", z.string().optional()), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), +] as const; + export const createInvokeGatewayHandler = ( core: Core, io: AppIO, @@ -27,33 +55,7 @@ export const createInvokeGatewayHandler = ( createHandler({ name: "invoke", description: "invoke an AgentCore Gateway", - flags: [ - flag("id", "the ID of the Gateway", gatewayIdSchema), - flag( - "path", - "the path relative to the Gateway origin", - z.string().min(1, "requires a nonempty path").optional(), - { sensitive: true }, - ), - flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional()), - flag("payload", "the inline payload to send", z.string().optional(), { sensitive: true }), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the Gateway bearer token", z.string().optional(), { - sensitive: true, - }), - flag("session-id", "the Runtime target session ID", z.string().optional()), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], + flags: invokeGatewayFlags, handle: async (ctx, flags) => { const jsonOutput = ctx.require(JsonKey); if (flags.payload === undefined) { diff --git a/src/handlers/harness/invoke/index.tsx b/src/handlers/harness/invoke/index.tsx index a18e263dfb..0eacc44ab9 100644 --- a/src/handlers/harness/invoke/index.tsx +++ b/src/handlers/harness/invoke/index.tsx @@ -9,24 +9,26 @@ import { runWithProgress } from "../../../tui/progress"; import { InputValidationError } from "../../../errors"; import { invokeHarnessTurn } from "./operation.ts"; +export const invokeHarnessFlags = [ + flag("id", "the ID of the harness", z.string().min(1).max(48)), + flag("prompt", "the message to send to the harness", z.string().optional()), + flag( + "session-id", + "the Runtime session ID to continue (33-100 characters)", + z.string().min(33).max(100).optional(), + ), + flag( + "qualifier", + "the harness endpoint qualifier to invoke (default DEFAULT)", + z.string().optional(), + ), +] as const; + export const createInvokeHarnessHandler = (core: Core, io: AppIO) => createHandler({ name: "invoke", description: "invoke a harness", - flags: [ - flag("id", "the ID of the harness", z.string().min(1).max(48)), - flag("prompt", "the message to send to the harness", z.string().optional()), - flag( - "session-id", - "the Runtime session ID to continue (33-100 characters)", - z.string().min(33).max(100).optional(), - ), - flag( - "qualifier", - "the harness endpoint qualifier to invoke (default DEFAULT)", - z.string().optional(), - ), - ], + flags: invokeHarnessFlags, handle: async (ctx, flags) => { // Without a prompt, open the interactive chat at this harness — resuming // the given session and targeting the given qualifier when passed. The diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 89c2666414..a58a9a5d68 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -232,17 +232,18 @@ describe("invoke", () => { }); test.each([ - ["the sole Runtime", { runtimes: [RUNTIME] }, `/agentcore/runtime/invoke/${RUNTIME_ID}`], - [ - "the sole Gateway", - { agentCoreGateways: [GATEWAY] }, - `/agentcore/gateway/invoke/${GATEWAY_ID}`, - ], - ["nothing outside a project", undefined, "/agentcore/invoke"], - ] as const)("a bare invoke selects %s", async (_name, resources, path) => { + ["one resource", { runtimes: [RUNTIME] }], + ["no project", undefined], + ] as const)("a bare interactive invoke opens the picker with %s", async (_name, resources) => { const subject = await launches([], resources); - expect(subject.launches.map((launch) => launch.path)).toEqual([path]); + expect(subject.launches.map((launch) => launch.path)).toEqual(["/agentcore/invoke"]); + }); + + test("a headless invoke selects the sole resource", async () => { + const { core } = await run(["--payload", "{}"], { agentCoreGateways: [GATEWAY] }); + + expect(core.gateway.calls.some(({ method }) => method === "invokeGateway")).toBe(true); }); test("resolves the --target deployment target for project names", async () => { diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 194a2321fd..2ad166ca6e 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -15,23 +15,20 @@ import { } from "../../../router"; import { attributeName, parseFlags } from "../../../router/flags"; import { renderTuiAt } from "../../../tui"; -import { createInvokeGatewayHandler } from "../../gateway/invoke"; -import { createInvokeHarnessHandler } from "../../harness/invoke"; +import { createInvokeGatewayHandler, invokeGatewayFlags } from "../../gateway/invoke"; +import { createInvokeHarnessHandler, invokeHarnessFlags } from "../../harness/invoke"; import { JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; import { assertMutuallyExclusiveFlags, toResourceArn } from "../../utils"; import { projectResourceNames, RESOURCE_LABELS } from "../selection"; import type { Project, ProjectInvokableResource } from "../types"; -import { createInvokeRuntimeHandler } from "../../runtime/invoke"; +import { createInvokeRuntimeHandler, invokeRuntimeFlags } from "../../runtime/invoke"; import { invokeProjectRuntimeLocally } from "./runtime"; const RESOURCE = "Resource options:"; -const REQUEST = "Request options:"; -const RUNTIME = "Runtime options:"; -const GATEWAY = "Gateway options:"; -const MCP = "MCP options (Runtime, Gateway):"; +const RESOURCE_TYPES = ["runtime", "harness", "gateway"] as const; -const invokeFlags = [ +const resourceFlags = [ flag( "runtime", "the Runtime to invoke: a project name, ID, or ARN", @@ -65,65 +62,41 @@ const invokeFlags = [ z.coerce.number().int().min(1).max(65535).optional(), { group: RESOURCE }, ), - flag("payload", "the inline payload to send", z.string().optional(), { - sensitive: true, - group: REQUEST, - }), - flag("prompt", "the message to send to a harness", z.string().optional(), { group: REQUEST }), - flag( - "session-id", - "the session ID to continue (33-100 characters for a harness)", - z.string().optional(), - { group: REQUEST }, - ), - flag("qualifier", "the endpoint qualifier (default DEFAULT)", z.string().optional(), { - group: REQUEST, - }), - flag("content-type", "the payload content type", z.string().optional(), { group: REQUEST }), - flag("accept", "the accepted response content type", z.string().optional(), { group: REQUEST }), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - group: REQUEST, - }), - flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { - sensitive: true, - group: REQUEST, - }), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - { group: REQUEST }, - ), - flag("user-id", 'the Runtime user ID (default "default")', z.string().optional(), { - group: RUNTIME, - }), - flag("mcp-method", "the MCP method", z.string().optional(), { group: RUNTIME }), - flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional(), { - group: RUNTIME, - }), - flag("trace-id", "the X-Ray trace ID", z.string().optional(), { group: RUNTIME }), - flag("trace-parent", "the W3C trace parent", z.string().optional(), { group: RUNTIME }), - flag("trace-state", "the W3C trace state", z.string().optional(), { group: RUNTIME }), - flag("baggage", "the W3C baggage", z.string().optional(), { group: RUNTIME }), - flag( - "path", - "the path relative to the Gateway origin", - z.string().min(1, "requires a nonempty path").optional(), - { sensitive: true, group: GATEWAY }, - ), - flag("method", "the HTTP request method", z.enum(["GET", "POST", "DELETE"]).optional(), { - group: GATEWAY, - }), - flag("mcp-session-id", "the MCP session ID", z.string().optional(), { group: MCP }), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional(), { - group: MCP, - }), ] as const; -export type InvokeFlags = FlagsOf; +const INVOKE_FLAGS = { + runtime: invokeRuntimeFlags, + harness: invokeHarnessFlags, + gateway: invokeGatewayFlags, +} as const; -const RESOURCE_TYPES = ["runtime", "harness", "gateway"] as const; +type RequestFlag = Exclude<(typeof INVOKE_FLAGS)[ProjectInvokableResource][number], { name: "id" }>; + +/** + One flag per name, in the help group of the resource types that accept it. +**/ +function requestFlags(): RequestFlag[] { + const merged = new Map(); + for (const resourceType of RESOURCE_TYPES) { + for (const requestFlag of INVOKE_FLAGS[resourceType]) { + if (requestFlag.name === "id") continue; + const entry = merged.get(requestFlag.name) ?? { flag: requestFlag, owners: [] }; + entry.owners.push(resourceType); + merged.set(requestFlag.name, entry); + } + } + return [...merged.values()].map(({ flag, owners }) => ({ + ...flag, + group: owners.length === 1 ? `${RESOURCE_LABELS[owners[0]!]} options:` : "Request options:", + })); +} + +const invokeFlags: readonly ((typeof resourceFlags)[number] | RequestFlag)[] = [ + ...resourceFlags, + ...requestFlags(), +]; + +export type InvokeFlags = FlagsOf; const isSet = (value: unknown) => value !== undefined && value !== false; @@ -149,8 +122,8 @@ function selectResource( (name) => [resourceType, name] as [ProjectInvokableResource, string], ), ); - if (declared.length === 1) return declared[0]; if (!headless) return undefined; + if (declared.length === 1) return declared[0]; if (declared.length === 0) { throw new InputValidationError( "This project has no Runtimes, harnesses, or Gateways to invoke.", diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index fede2ef4f6..cdf5aed29b 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -14,7 +14,7 @@ import { HarnessChat } from "../../harness/invoke/screen"; import { RegionKey } from "../../keys"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; -import { RESOURCE_LABELS } from "../selection"; +import { isProjectInvokableResource, RESOURCE_LABELS } from "../selection"; import type { Project, ProjectInvokableResource, ResolvedProjectResources } from "../types"; import { ProjectGate } from "../ProjectGate"; @@ -111,26 +111,29 @@ function ProjectInvokePicker({ const rows = useMemo( () => - (deployed?.resources ?? []).flatMap((resource): ProjectInvokableRow[] => { - if (!(resource.resourceType in RESOURCE_LABELS) || !("arn" in resource)) return []; - const resourceType = resource.resourceType as ProjectInvokableResource; - const { name, arn } = resource; - const runtime = project.spec.runtimes.find((candidate) => candidate.name === name); - const harness = project.spec.harnesses.find((candidate) => candidate.name === name); - return [ - { - resourceType, - type: RESOURCE_LABELS[resourceType], - name, - arn, - protocol: resourceType === "runtime" ? (runtime?.protocol ?? "HTTP") : "-", - source: - (resourceType === "runtime" && runtime?.codeLocation) || - (resourceType === "harness" && harness?.path) || - "-", - }, - ]; - }), + (deployed?.resources ?? []).flatMap( + ({ resourceType, name, ...state }): ProjectInvokableRow[] => { + if (!isProjectInvokableResource(resourceType) || !("arn" in state)) return []; + const runtime = + resourceType === "runtime" + ? project.spec.runtimes.find((candidate) => candidate.name === name) + : undefined; + const harness = + resourceType === "harness" + ? project.spec.harnesses.find((candidate) => candidate.name === name) + : undefined; + return [ + { + resourceType, + type: RESOURCE_LABELS[resourceType], + name, + arn: state.arn, + protocol: runtime ? (runtime.protocol ?? "HTTP") : "-", + source: runtime?.codeLocation ?? harness?.path ?? "-", + }, + ]; + }, + ), [deployed, project], ); diff --git a/src/handlers/project/selection.ts b/src/handlers/project/selection.ts index 1fa4028eef..b585bcb225 100644 --- a/src/handlers/project/selection.ts +++ b/src/handlers/project/selection.ts @@ -7,6 +7,10 @@ export const RESOURCE_LABELS: Record = { gateway: "Gateway", }; +export function isProjectInvokableResource(value: string): value is ProjectInvokableResource { + return value in RESOURCE_LABELS; +} + export function projectResourceNames( project: Project, resourceType: ProjectInvokableResource, diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 9ee0d9c77f..35203e3a1b 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -18,40 +18,42 @@ import { writeRuntimeInvokeResponse } from "./response"; import { RuntimeInvokeLaunchContextKey } from "./launchContext"; import { invokeRuntimeTarget } from "./operation"; +export const invokeRuntimeFlags = [ + flag("id", "the ID of the Runtime", runtimeIdSchema), + flag("payload", "the inline payload to send", z.string().optional(), { + sensitive: true, + }), + flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), + flag("content-type", "the payload content type", z.string().optional()), + flag("accept", "the accepted response content type", z.string().optional()), + flag("session-id", "the Runtime session ID", z.string().optional()), + flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), + flag("header", "an ordered application header", z.array(z.string()).optional(), { + sensitive: true, + }), + flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { + sensitive: true, + }), + flag("mcp-session-id", "the MCP session ID", z.string().optional()), + flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), + flag("mcp-method", "the MCP method", z.string().optional()), + flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), + flag("trace-id", "the X-Ray trace ID", z.string().optional()), + flag("trace-parent", "the W3C trace parent", z.string().optional()), + flag("trace-state", "the W3C trace state", z.string().optional()), + flag("baggage", "the W3C baggage", z.string().optional()), + flag( + "output-file", + "the response output file", + z.string().min(1, "requires a nonempty path").optional(), + ), +] as const; + export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => createHandler({ name: "invoke", description: "invoke a Runtime", - flags: [ - flag("id", "the ID of the Runtime", runtimeIdSchema), - flag("payload", "the inline payload to send", z.string().optional(), { - sensitive: true, - }), - flag("qualifier", "the Runtime endpoint qualifier", z.string().optional()), - flag("content-type", "the payload content type", z.string().optional()), - flag("accept", "the accepted response content type", z.string().optional()), - flag("session-id", "the Runtime session ID", z.string().optional()), - flag("user-id", 'the Runtime user ID (default "default")', z.string().optional()), - flag("header", "an ordered application header", z.array(z.string()).optional(), { - sensitive: true, - }), - flag("bearer-token", "the CUSTOM_JWT bearer token", z.string().optional(), { - sensitive: true, - }), - flag("mcp-session-id", "the MCP session ID", z.string().optional()), - flag("mcp-protocol-version", "the MCP protocol version", z.string().optional()), - flag("mcp-method", "the MCP method", z.string().optional()), - flag("mcp-name", "the MCP tool, resource, or prompt name", z.string().optional()), - flag("trace-id", "the X-Ray trace ID", z.string().optional()), - flag("trace-parent", "the W3C trace parent", z.string().optional()), - flag("trace-state", "the W3C trace state", z.string().optional()), - flag("baggage", "the W3C baggage", z.string().optional()), - flag( - "output-file", - "the response output file", - z.string().min(1, "requires a nonempty path").optional(), - ), - ], + flags: invokeRuntimeFlags, handle: async (ctx, flags) => { if (flags.payload === undefined) { const hasHeadlessOnlyFlag = Object.entries(flags).some( From 517d9acf2d3d7127f59f450fea68abe0597ea957 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Thu, 24 Sep 2026 22:07:13 +0000 Subject: [PATCH 5/7] fix(invoke): keep verified project credentials and skip redundant lookups Project names resolve through resolveDeployedResource, now covering Gateways, so the delegated handler gets the target's region and account-verified credential provider. IDs and ARNs resolve locally and the existing handler does its single lookup. The deployment target is passed explicitly instead of through a router context key, and the picker keeps the target credentials and region. --- src/core/project/backends/cdk.test.ts | 12 ++ src/core/project/backends/cdk.ts | 13 +- src/core/project/manager.tsx | 10 +- src/handlers/project/invoke/index.test.tsx | 46 ++--- src/handlers/project/invoke/index.tsx | 27 ++- .../project/invoke/invoke.screen.test.tsx | 106 +++++++----- src/handlers/project/invoke/screen.tsx | 70 ++++---- src/handlers/project/selection.ts | 8 +- src/handlers/project/types.ts | 7 +- src/handlers/utils.test.tsx | 162 ++++-------------- src/handlers/utils.tsx | 78 +++------ src/router/index.tsx | 1 - src/router/router.tsx | 1 - 13 files changed, 226 insertions(+), 315 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 59ddd6cc6e..588c35afaf 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1015,6 +1015,7 @@ describe("CdkBackend.resolveDeployedResources", () => { }, ], harnesses: [{ name: "support_agent", path: "app/support_agent" }], + agentCoreGateways: [{ name: "tools-gateway", targets: [] }], }); await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); const subject = harness({ @@ -1031,6 +1032,10 @@ describe("CdkBackend.resolveDeployedResources", () => { ExportName: "AgentCore-example-default-Harness-support-agent-Id", OutputValue: "support_agent-AbCdEf1234", }, + { + ExportName: "AgentCore-example-default-Gateway-tools-gateway-Id", + OutputValue: "tools-gateway-abcdef1234", + }, ], }, }); @@ -1052,6 +1057,13 @@ describe("CdkBackend.resolveDeployedResources", () => { target: TARGET, credentialProvider: subject.credentials, }, + { + resourceType: "gateway", + name: "tools-gateway", + id: "tools-gateway-abcdef1234", + target: TARGET, + credentialProvider: subject.credentials, + }, ]); expect(subject.stackReads).toHaveLength(1); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 2ffeae274a..bc4fb90ed7 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -108,10 +108,11 @@ function findDeployedResourceId( ): string | undefined { if (!stack.StackName) return undefined; const exportResourceName = input.name.replaceAll("_", "-"); - const exportName = - input.resourceType === "runtime" - ? `${stack.StackName}-${exportResourceName}-RuntimeId` - : `${stack.StackName}-Harness-${exportResourceName}-Id`; + const exportName = { + runtime: `${stack.StackName}-${exportResourceName}-RuntimeId`, + harness: `${stack.StackName}-Harness-${exportResourceName}-Id`, + gateway: `${stack.StackName}-Gateway-${exportResourceName}-Id`, + }[input.resourceType]; return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; } @@ -506,6 +507,10 @@ export class CdkBackend implements ProjectBackend { const resources = [ ...project.spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })), ...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })), + ...project.spec.agentCoreGateways.map(({ name }) => ({ + resourceType: "gateway" as const, + name, + })), ]; return resources.flatMap((resource) => { const id = findDeployedResourceId(stack, resource); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index f85f286f53..0d0c8387d7 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1044,12 +1044,14 @@ export class FsProjectManager implements ProjectManager { ({ resourceType, name }) => resourceType === input.resourceType && name === input.name, ); // The declared target wins over the copy on the item: the manager resolved it - // from aws-targets.json, and the log and traces handlers pin the AWS region from - // this value while reusing the backend's verified credential provider. Trusting - // a backend's target echo would let it redirect the call. + // from aws-targets.json, and the invoke, log, and traces handlers pin the AWS + // region from this value while reusing the backend's verified credential + // provider. Trusting a backend's target echo would let it redirect the call. if (resource) return { ...resource, target: resolved.target }; - const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; + const label = { runtime: "Runtime", harness: "Harness", gateway: "Gateway" }[ + input.resourceType + ]; throw new ProjectStateError( `${label} '${input.name}' is not deployed to target '${input.target}'. ` + `Run 'agentcore deploy --target ${input.target}' first.`, diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index a58a9a5d68..3a75221163 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -36,6 +36,10 @@ const TARGET = { } as const; const arn = (resource: string) => `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}:${resource}`; +const TARGET_CREDENTIALS = async () => ({ + accessKeyId: "target-access-key", + secretAccessKey: "target-secret-key", +}); const RUNTIME_ID = "checkout-AbCdEf1234"; const HARNESS_ID = "support-AbCdEf1234"; const GATEWAY_ID = "tools-AbCdEf1234"; @@ -78,31 +82,27 @@ function backend() { yield* []; return { outputs: {} }; }, - async resolveDeployedResources() { - throw new Error("invoke resolves project resources, not deployed resources"); - }, - async resolveProjectResources(project, input) { + async resolveDeployedResources(project, input) { targets.push(input.target.name); - return [ - ...project.spec.runtimes.map(({ name }) => ({ - resourceType: "runtime" as const, - name, - deploymentState: "deployed" as const, - arn: arn(`runtime/${RUNTIME_ID}`), - })), - ...project.spec.harnesses.map(({ name }) => ({ - resourceType: "harness" as const, - name, - deploymentState: "deployed" as const, - arn: arn(`harness/${HARNESS_ID}`), - })), - ...project.spec.agentCoreGateways.map(({ name }) => ({ - resourceType: "gateway" as const, + const ids = { runtime: RUNTIME_ID, harness: HARNESS_ID, gateway: GATEWAY_ID }; + return ( + [ + ["runtime", project.spec.runtimes], + ["harness", project.spec.harnesses], + ["gateway", project.spec.agentCoreGateways], + ] as const + ).flatMap(([resourceType, resources]) => + resources.map(({ name }) => ({ + resourceType, name, - deploymentState: "deployed" as const, - arn: arn(`gateway/${GATEWAY_ID}`), + id: ids[resourceType], + target: input.target, + credentialProvider: TARGET_CREDENTIALS, })), - ]; + ); + }, + async resolveProjectResources() { + throw new Error("invoke resolves deployed resources, not project resources"); }, }; return { targets, value }; @@ -206,7 +206,7 @@ describe("invoke", () => { }); const invoke = core[client].calls.find((call) => call.method === method)!; - expect(invoke.args[1]).toEqual({ region: TARGET.region }); + expect(invoke.args[1]).toEqual({ region: TARGET.region, credentials: TARGET_CREDENTIALS }); expect(resolved.targets).toEqual(["default"]); }, ); diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 2ad166ca6e..a704050b58 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -1,5 +1,4 @@ import z from "zod"; -import { regionFromArn, serviceIdFromArn } from "../../../core/arn"; import { InputValidationError, ProjectStateError } from "../../../errors"; import type { AppIO } from "../../../io"; import { withProject } from "../../../middleware"; @@ -9,7 +8,6 @@ import { flag, PathKey, ProjectKey, - ProjectTargetKey, type FlagsOf, type Handler, } from "../../../router"; @@ -17,9 +15,9 @@ import { attributeName, parseFlags } from "../../../router/flags"; import { renderTuiAt } from "../../../tui"; import { createInvokeGatewayHandler, invokeGatewayFlags } from "../../gateway/invoke"; import { createInvokeHarnessHandler, invokeHarnessFlags } from "../../harness/invoke"; -import { JsonKey, RegionKey } from "../../keys"; +import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; -import { assertMutuallyExclusiveFlags, toResourceArn } from "../../utils"; +import { assertMutuallyExclusiveFlags, resolveResource } from "../../utils"; import { projectResourceNames, RESOURCE_LABELS } from "../selection"; import type { Project, ProjectInvokableResource } from "../types"; import { createInvokeRuntimeHandler, invokeRuntimeFlags } from "../../runtime/invoke"; @@ -207,24 +205,25 @@ export function createProjectInvokeHandler(core: Core, io: AppIO) { return; } - const arn = await toResourceArn( + const resolved = await resolveResource( core, - ctx.withValue(ProjectTargetKey, flags.target ?? DEFAULT_TARGET_NAME), + ctx, resourceType, identifier, + flags.target ?? DEFAULT_TARGET_NAME, ); - const values: Record = { ...flags, id: serviceIdFromArn(arn) }; + const values: Record = { ...flags, id: resolved.id }; const request = parseFlags( invoker.flags(), Object.fromEntries(invoker.flags().map(({ name }) => [attributeName(name), values[name]])), ); - await invoker.handle( - ctx - .withValue(RegionKey, regionFromArn(arn)!) - .withValue(PathKey, `/agentcore/${resourceType}/invoke`), - request, - {}, - ); + let invokeCtx = ctx + .withValue(RegionKey, resolved.region) + .withValue(PathKey, `/agentcore/${resourceType}/invoke`); + if (resolved.credentials) { + invokeCtx = invokeCtx.withValue(AwsCredentialProviderKey, resolved.credentials); + } + await invoker.handle(invokeCtx, request, {}); }, }); } diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index 40f9171938..91a8da4762 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -17,7 +17,7 @@ import { waitForFlatText, waitForText, } from "../../../testing"; -import type { Project, ResolvedProjectResource } from "../types"; +import type { Project, ResolvedDeployedResource } from "../types"; const cleanups: Array<() => Promise> = []; afterEach(cleanupScreens); @@ -61,38 +61,49 @@ function endpoint(name: string): AgentRuntimeEndpoint { // the invoke flows have to fetch where the project deployed. const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const; const STAGING = { name: "staging", account: "444455556666", region: "eu-central-1" } as const; -function deployed(region: string = TARGET.region): ResolvedProjectResource[] { - const arn = (resource: string) => `arn:aws:bedrock-agentcore:${region}:111122223333:${resource}`; - return [ - { - resourceType: "runtime", - name: "checkout", - deploymentState: "deployed", - arn: arn("runtime/runtime-123"), - }, - { - resourceType: "harness", - name: "support", - deploymentState: "deployed", - arn: arn("harness/harness-123"), - }, - { - resourceType: "gateway", - name: "tools", - deploymentState: "deployed", - arn: arn("gateway/gateway-123"), - }, - { resourceType: "memory", name: "notes", deploymentState: "deployed", arn: arn("memory/m") }, - ]; -} +const TARGET_CREDENTIALS = async () => ({ + accessKeyId: "target-access-key", + secretAccessKey: "target-secret-key", +}); + +const DEPLOYED_RESOURCES: ResolvedDeployedResource[] = [ + { + resourceType: "runtime", + name: "checkout", + id: "runtime-123", + target: TARGET, + credentialProvider: TARGET_CREDENTIALS, + }, + { + resourceType: "harness", + name: "support", + id: "harness-123", + target: TARGET, + credentialProvider: TARGET_CREDENTIALS, + }, + { + resourceType: "gateway", + name: "tools", + id: "gateway-123", + target: TARGET, + credentialProvider: TARGET_CREDENTIALS, + }, +]; function core( - resources: ResolvedProjectResource[] = deployed(), + resources: ResolvedDeployedResource[] = DEPLOYED_RESOURCES, targets: AwsDeploymentTarget[] = [TARGET], ): TestCoreClient { const value = new TestCoreClient(); value.projectManager.listTargets = async () => targets; - value.projectManager.resolveProjectResources = async () => ({ resources, target: TARGET }); + value.projectManager.resolveDeployedResource = async (_project, input) => ({ + resourceType: input.resourceType, + name: input.name, + id: input.resourceType === "runtime" ? "runtime-123" : "harness-123", + target: TARGET, + credentialProvider: TARGET_CREDENTIALS, + }); + value.projectManager.resolveDeployedResources = async () => ({ resources, target: TARGET }); value.runtime .setListEndpointsResponse({ runtimeEndpoints: [endpoint("DEFAULT")] }) .setGetResponse({ @@ -118,8 +129,13 @@ describe("project invoke picker", () => { test("lists only resources present in the deployed target", async () => { const screen = renderScreen("/agentcore/invoke", { core: core([ - { resourceType: "runtime", name: "checkout", deploymentState: "local-only" }, - ...deployed().filter(({ resourceType }) => resourceType === "harness"), + { + resourceType: "harness", + name: "support", + id: "harness-123", + target: TARGET, + credentialProvider: TARGET_CREDENTIALS, + }, ]), withContext: (ctx) => ctx.withValue(ProjectKey, project), }); @@ -141,11 +157,11 @@ describe("project invoke picker", () => { }); test("several targets: asks which, then invokes on the chosen one in its region", async () => { - const value = core(deployed(), [TARGET, STAGING]); + const value = core(DEPLOYED_RESOURCES, [TARGET, STAGING]); let requested: string | undefined; - value.projectManager.resolveProjectResources = async (_project, { target }) => { + value.projectManager.resolveDeployedResources = async (_project, { target }) => { requested = target; - return { resources: deployed(STAGING.region), target: STAGING }; + return { resources: DEPLOYED_RESOURCES, target: STAGING }; }; const screen = renderScreen("/agentcore/invoke", { core: value, @@ -170,7 +186,7 @@ describe("project invoke picker", () => { test("shows deployment errors without listing configured resources", async () => { const value = core(); - value.projectManager.resolveProjectResources = async () => { + value.projectManager.resolveDeployedResources = async () => { throw new Error("No deployment targets are configured for project 'orders'."); }; const screen = renderScreen("/agentcore/invoke", { @@ -223,10 +239,9 @@ describe("project invoke picker", () => { expect(screen.lastFrame()).toContain("app/support"); expect(screen.lastFrame()).toContain("tools"); expect(screen.lastFrame()).toContain("Gateway"); - expect(screen.lastFrame()).not.toContain("notes"); }); - test("opens the selected Harness chat in the same TUI", async () => { + test("opens the selected Gateway console with the target's credentials", async () => { const value = core(); const screen = renderScreen("/agentcore/invoke", { core: value, @@ -235,16 +250,19 @@ describe("project invoke picker", () => { await waitForText(screen.lastFrame, "checkout"); await screen.press("down"); + await screen.press("down"); await screen.press("return"); - await waitForText(screen.lastFrame, "send a message…"); - expect(screen.lastFrame()).toContain("harness-123"); - expect(value.harness.calls.find(({ method }) => method === "getHarness")?.args[1]).toEqual({ + await waitForText(screen.lastFrame, "gateway-123"); + expect(value.gateway.calls.find(({ method }) => method === "getGateway")?.args[1]).toEqual({ region: TARGET.region, endpointUrl: undefined, + credentials: TARGET_CREDENTIALS, }); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a project resource to invoke"); }); - test("opens the selected Gateway console and returns to the picker on esc", async () => { + test("opens the selected Harness chat in the same TUI", async () => { const value = core(); const screen = renderScreen("/agentcore/invoke", { core: value, @@ -253,15 +271,14 @@ describe("project invoke picker", () => { await waitForText(screen.lastFrame, "checkout"); await screen.press("down"); - await screen.press("down"); await screen.press("return"); - await waitForText(screen.lastFrame, "gateway-123"); - expect(value.gateway.calls.find(({ method }) => method === "getGateway")?.args[1]).toEqual({ + await waitForText(screen.lastFrame, "send a message…"); + expect(screen.lastFrame()).toContain("harness-123"); + expect(value.harness.calls.find(({ method }) => method === "getHarness")?.args[1]).toEqual({ region: TARGET.region, endpointUrl: undefined, + credentials: TARGET_CREDENTIALS, }); - await screen.press("escape"); - await waitForText(screen.lastFrame, "choose a project resource to invoke"); }); test("uses the existing Runtime endpoint picker before its JSON console", async () => { @@ -282,6 +299,7 @@ describe("project invoke picker", () => { ).toEqual({ region: TARGET.region, endpointUrl: undefined, + credentials: TARGET_CREDENTIALS, }); }); }); diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index cdf5aed29b..3c70558711 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -7,25 +7,22 @@ import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; import { Spinner } from "../../../components/ui/spinner"; import { glyphs } from "../../../components/ui/_core.js"; -import { regionFromArn, serviceIdFromArn } from "../../../core/arn"; import { ProjectKey, type Context } from "../../../router"; import { GatewayInvokeConsole } from "../../gateway/invoke/screen"; import { HarnessChat } from "../../harness/invoke/screen"; -import { RegionKey } from "../../keys"; +import { AwsCredentialProviderKey, RegionKey } from "../../keys"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; -import { isProjectInvokableResource, RESOURCE_LABELS } from "../selection"; -import type { Project, ProjectInvokableResource, ResolvedProjectResources } from "../types"; +import { RESOURCE_LABELS } from "../selection"; +import type { Project, ResolvedDeployedResource, ResolvedDeployedResources } from "../types"; import { ProjectGate } from "../ProjectGate"; -type ProjectInvokableRow = Record & { - resourceType: ProjectInvokableResource; - type: string; - name: string; - arn: string; - protocol: string; - source: string; -}; +type ProjectInvokableRow = Record & + ResolvedDeployedResource & { + type: string; + protocol: string; + source: string; + }; const columns = [ { key: "type", header: "type", width: 10 }, @@ -90,14 +87,14 @@ function ProjectInvokePicker({ targetName: string; onBack: () => void; }) { - const [deployed, setDeployed] = useState(); + const [deployed, setDeployed] = useState(); const [destination, setDestination] = useState(); const [error, setError] = useState(); useEffect(() => { let active = true; void core.projectManager - .resolveProjectResources(project, { target: targetName }) + .resolveDeployedResources(project, { target: targetName }) .then((resolved) => { if (active) setDeployed(resolved); }) @@ -111,37 +108,32 @@ function ProjectInvokePicker({ const rows = useMemo( () => - (deployed?.resources ?? []).flatMap( - ({ resourceType, name, ...state }): ProjectInvokableRow[] => { - if (!isProjectInvokableResource(resourceType) || !("arn" in state)) return []; - const runtime = - resourceType === "runtime" - ? project.spec.runtimes.find((candidate) => candidate.name === name) - : undefined; - const harness = - resourceType === "harness" - ? project.spec.harnesses.find((candidate) => candidate.name === name) - : undefined; - return [ - { - resourceType, - type: RESOURCE_LABELS[resourceType], - name, - arn: state.arn, - protocol: runtime ? (runtime.protocol ?? "HTTP") : "-", - source: runtime?.codeLocation ?? harness?.path ?? "-", - }, - ]; - }, - ), + (deployed?.resources ?? []).map((resource) => { + const runtime = + resource.resourceType === "runtime" + ? project.spec.runtimes.find(({ name }) => name === resource.name) + : undefined; + const harness = + resource.resourceType === "harness" + ? project.spec.harnesses.find(({ name }) => name === resource.name) + : undefined; + return { + ...resource, + type: RESOURCE_LABELS[resource.resourceType], + protocol: runtime ? (runtime.protocol ?? "HTTP") : "-", + source: runtime?.codeLocation ?? harness?.path ?? "-", + }; + }), [deployed, project], ); const select = (row: ProjectInvokableRow) => { setDestination({ resourceType: row.resourceType, - id: serviceIdFromArn(row.arn), - ctx: ctx.withValue(RegionKey, regionFromArn(row.arn)!), + id: row.id, + ctx: ctx + .withValue(RegionKey, deployed!.target.region) + .withValue(AwsCredentialProviderKey, row.credentialProvider), }); }; diff --git a/src/handlers/project/selection.ts b/src/handlers/project/selection.ts index b585bcb225..aacc18ca34 100644 --- a/src/handlers/project/selection.ts +++ b/src/handlers/project/selection.ts @@ -1,5 +1,5 @@ import { InputValidationError, ResourceNotFoundError } from "../../errors"; -import type { Project, ProjectInvokableResource, ProjectObservableResource } from "./types"; +import type { Project, ProjectInvokableResource } from "./types"; export const RESOURCE_LABELS: Record = { runtime: "Runtime", @@ -7,10 +7,6 @@ export const RESOURCE_LABELS: Record = { gateway: "Gateway", }; -export function isProjectInvokableResource(value: string): value is ProjectInvokableResource { - return value in RESOURCE_LABELS; -} - export function projectResourceNames( project: Project, resourceType: ProjectInvokableResource, @@ -25,7 +21,7 @@ export function projectResourceNames( export function selectProjectResource( project: Project, - resourceType: ProjectObservableResource, + resourceType: ProjectInvokableResource, name: string | undefined, operation: string, ): string { diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 0b38560bac..873f09f352 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -203,7 +203,7 @@ export type ResolveTargetInput = { export type ResolveDeployedResourceInput = { target: string; - resourceType: ProjectObservableResource; + resourceType: ProjectInvokableResource; name: string; }; @@ -212,7 +212,7 @@ export type ResolveDeployedResourcesInput = { }; export type ResolvedDeployedResource = { - resourceType: ProjectObservableResource; + resourceType: ProjectInvokableResource; name: string; id: string; target: AwsDeploymentTarget; @@ -399,7 +399,6 @@ export type ExportHarnessResult = { }; export type ProjectInvokableResource = Extract; -export type ProjectObservableResource = Extract; export type RemoveResourceInput = | { @@ -488,7 +487,7 @@ export interface ProjectManager { input: ResolveDeployedResourceInput, ): Promise; - /** Resolve every configured Runtime and Harness present in the deployed target stack. */ + /** Resolve every configured Runtime, Harness, and Gateway present in the deployed target stack. */ resolveDeployedResources( project: Project, input: ResolveDeployedResourcesInput, diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx index a803eccf3f..a0b834d3fb 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,21 +1,15 @@ import { describe, expect, test } from "bun:test"; -import type { - GetAgentRuntimeResponse, - GetGatewayResponse, - GetHarnessResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { ResourceNotFoundException } from "@aws-sdk/client-bedrock-agentcore-control"; import { ProjectSpecSchema } from "../projectSchemas/project"; -import { ProjectKey, ProjectTargetKey, ValueContext } from "../router"; +import { ProjectKey, ValueContext } from "../router"; import { TestCoreClient } from "../testing"; import { RegionKey } from "./keys"; -import type { ProjectManager, ResolvedProjectResource } from "./project/types"; +import type { ProjectManager, ResolveDeployedResourceInput } from "./project/types"; import { assertMutuallyExclusiveFlags, parseJsonArrayFlag, parseJsonObjectFlag, parseTags, - toResourceArn, + resolveResource, } from "./utils"; describe("structured JSON flags", () => { @@ -128,146 +122,64 @@ describe("assertMutuallyExclusiveFlags", () => { }); }); -describe("toResourceArn", () => { - const arn = (region: string, resource: string) => - `arn:aws:bedrock-agentcore:${region}:111122223333:${resource}`; +describe("resolveResource", () => { + const credentials = async () => ({ accessKeyId: "target", secretAccessKey: "secret" }); const project = { name: "orders", rootPath: "/orders", spec: ProjectSpecSchema.parse({ name: "orders", version: 2, - runtimes: [ - { - name: "checkout", - build: "CodeZip", - entrypoint: "main.py", - codeLocation: "app/checkout", - runtimeVersion: "PYTHON_3_14", - }, - ], - harnesses: [{ name: "support", path: "app/support" }], agentCoreGateways: [{ name: "tools", targets: [] }], }), }; - const deployed: ResolvedProjectResource[] = [ - { resourceType: "runtime", name: "checkout", deploymentState: "local-only" }, - { - resourceType: "harness", - name: "support", - deploymentState: "deployed", - arn: arn("eu-west-1", "harness/support-AbCd"), - }, - { - resourceType: "gateway", - name: "tools", - deploymentState: "deployed", - arn: arn("eu-west-1", "gateway/tools-AbCd"), - }, - ]; - const notFound = new ResourceNotFoundException({ message: "not found", $metadata: {} }); - function setup(options: { inProject: boolean; error?: Error }) { + function setup() { const core = new TestCoreClient(); - const targets: string[] = []; + const lookups: ResolveDeployedResourceInput[] = []; Object.assign(core, { projectManager: { - resolveProjectResources: async (_project, { target }) => { - targets.push(target); - return { resources: deployed, target: { name: target } }; + resolveDeployedResource: async (_project, input) => { + lookups.push(input); + return { + resourceType: input.resourceType, + name: input.name, + id: "tools-AbCd", + target: { name: input.target, account: "111122223333", region: "eu-west-1" }, + credentialProvider: credentials, + }; }, } as Partial, }); - core.runtime - .setGetResponse({ - agentRuntimeArn: arn("us-west-2", "runtime/rt"), - } as GetAgentRuntimeResponse) - .setError(options.error); - core.harness - .setGetResponse({ harness: { arn: arn("us-west-2", "harness/hs") } } as GetHarnessResponse) - .setError(options.error); - core.gateway - .setGetResponse({ gatewayArn: arn("us-west-2", "gateway/gw") } as GetGatewayResponse) - .setError(options.error); - let ctx = ValueContext.EmptyContext().withValue(RegionKey, "us-west-2"); - if (options.inProject) { - ctx = ctx.withValue(ProjectKey, project).withValue(ProjectTargetKey, "prod"); - } - const lookups = () => - [...core.runtime.calls, ...core.harness.calls, ...core.gateway.calls] - .filter(({ method }) => method.startsWith("get")) - .map(({ args }) => [args[0], (args[1] as { region: string }).region]); - return { core, ctx, targets, lookups }; + const ctx = ValueContext.EmptyContext() + .withValue(RegionKey, "us-west-2") + .withValue(ProjectKey, project); + return { core, ctx, lookups }; } - test.each([ - [ - "a project harness by name", - "harness", - "support", - arn("eu-west-1", "harness/support-AbCd"), - [], - ], - ["a project gateway by name", "gateway", "tools", arn("eu-west-1", "gateway/tools-AbCd"), []], - ["a Runtime ID", "runtime", "rt", arn("us-west-2", "runtime/rt"), [["rt", "us-west-2"]]], - ["a harness ID", "harness", "hs", arn("us-west-2", "harness/hs"), [["hs", "us-west-2"]]], - ["a Gateway ID", "gateway", "gw", arn("us-west-2", "gateway/gw"), [["gw", "us-west-2"]]], - [ - "an ARN, in its own region", - "runtime", - arn("ap-south-1", "runtime/rt"), - arn("us-west-2", "runtime/rt"), - [["rt", "ap-south-1"]], - ], - ] as const)("resolves %s", async (_name, resourceType, identifier, expected, lookups) => { - const fixture = setup({ inProject: true }); + test("resolves a project name with the target's region and credentials", async () => { + const { core, ctx, lookups } = setup(); - expect(await toResourceArn(fixture.core, fixture.ctx, resourceType, identifier)).toBe(expected); - expect(fixture.lookups()).toEqual(lookups as unknown as string[][]); - expect(fixture.targets).toEqual(lookups.length === 0 ? ["prod"] : []); + expect(await resolveResource(core, ctx, "gateway", "tools", "prod")).toEqual({ + id: "tools-AbCd", + region: "eu-west-1", + credentials, + }); + expect(lookups).toEqual([{ target: "prod", resourceType: "gateway", name: "tools" }]); }); test.each([ + ["an ID in the current region", "gw-1", { id: "gw-1", region: "us-west-2" }], [ - "a project name that is not deployed", - { inProject: true }, - "runtime", - "checkout", - "Runtime 'checkout' is not deployed to target 'prod'. Run 'agentcore deploy --target prod' first.", - ], - [ - "an unknown value in a project", - { inProject: true, error: notFound }, - "harness", - "missing", - "Harness 'missing' is not a project Harness (available: support) and no Harness with ID 'missing' exists in us-west-2.", - ], - [ - "an unknown ID outside a project", - { inProject: false, error: notFound }, - "gateway", - "missing", - "No Gateway with ID 'missing' exists in us-west-2. Run from inside a project to use a project name, or pass a Gateway ID or ARN.", - ], - [ - "an unknown ARN", - { inProject: false, error: notFound }, - "runtime", - arn("us-west-2", "runtime/missing"), - "not found", - ], - [ - "a service error", - { inProject: false, error: new Error("AccessDenied") }, - "runtime", - "rt", - "AccessDenied", + "an ARN in its own region", + "arn:aws:bedrock-agentcore:ap-south-1:111122223333:gateway/gw-1", + { id: "gw-1", region: "ap-south-1" }, ], - ] as const)("rejects %s", async (_name, options, resourceType, identifier, message) => { - const fixture = setup(options); + ])("resolves %s without any lookup", async (_name, identifier, expected) => { + const { core, ctx, lookups } = setup(); - await expect( - toResourceArn(fixture.core, fixture.ctx, resourceType, identifier), - ).rejects.toThrow(new Error(message)); + expect(await resolveResource(core, ctx, "gateway", identifier, "prod")).toEqual(expected); + expect(lookups).toEqual([]); + expect(core.gateway.calls).toEqual([]); }); }); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index e356613158..5ff4dccf4b 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,21 +1,14 @@ import { createContext, useContext, useEffect } from "react"; -import { ResourceNotFoundException } from "@aws-sdk/client-bedrock-agentcore-control"; -import { ProjectKey, ProjectTargetKey, type Context } from "../router"; +import { ProjectKey, type Context } from "../router"; import type z from "zod"; -import type { CoreOptions } from "../core/types"; +import type { AwsCredentialProvider, CoreOptions } from "../core/types"; import type { AppIO } from "../io"; import { regionFromArn, serviceIdFromArn } from "../core/arn"; -import { - AgentCoreCLIError, - InputValidationError, - ProjectStateError, - ResourceNotFoundError, - SilentCLIError, -} from "../errors"; +import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../errors"; import { formatZodError } from "../router/schema"; import { AwsCredentialProviderKey, EndpointKey, JsonKey, RegionKey } from "./keys"; import { JsonRendererKey } from "../tui"; -import { projectResourceNames, RESOURCE_LABELS } from "./project/selection"; +import { projectResourceNames } from "./project/selection"; import type { ProjectInvokableResource } from "./project/types"; import type { Core } from "./types"; @@ -33,51 +26,36 @@ export function coreOptsFromCtx(ctx: Context): CoreOptions { }; } -export async function toResourceArn( +export type ResolvedResource = { + id: string; + region: string; + credentials?: AwsCredentialProvider; +}; + +export async function resolveResource( core: Core, ctx: Context, resourceType: ProjectInvokableResource, identifier: string, -): Promise { - const label = RESOURCE_LABELS[resourceType]; + target: string, +): Promise { const project = ctx.value(ProjectKey); - const names = project ? projectResourceNames(project, resourceType) : []; - if (project && names.includes(identifier)) { - const target = ctx.require(ProjectTargetKey); - const { resources } = await core.projectManager.resolveProjectResources(project, { target }); - const resource = resources.find( - (candidate) => candidate.resourceType === resourceType && candidate.name === identifier, - ); - if (resource && "arn" in resource) return resource.arn; - throw new ProjectStateError( - `${label} '${identifier}' is not deployed to target '${target}'. ` + - `Run 'agentcore deploy --target ${target}' first.`, - ); - } - - const options = coreOptsFromCtx(ctx); - const arnRegion = regionFromArn(identifier); - const id = serviceIdFromArn(identifier); - const lookupOptions = { ...options, region: arnRegion ?? options.region }; - try { - switch (resourceType) { - case "runtime": - return (await core.runtime.getRuntime(id, lookupOptions)).agentRuntimeArn!; - case "harness": - return (await core.harness.getHarness(id, lookupOptions)).harness!.arn!; - case "gateway": - return (await core.gateway.getGateway(id, lookupOptions)).gatewayArn!; - } - } catch (error) { - if (arnRegion || !(error instanceof ResourceNotFoundException)) throw error; - const missing = `${label} with ID '${identifier}' exists in ${options.region}`; - throw new ResourceNotFoundError( - project - ? `${label} '${identifier}' is not a project ${label} (available: ${names.join(", ") || "none"}) and no ${missing}.` - : `No ${missing}. Run from inside a project to use a project name, or pass a ${label} ID or ARN.`, - { cause: error }, - ); + if (project && projectResourceNames(project, resourceType).includes(identifier)) { + const deployed = await core.projectManager.resolveDeployedResource(project, { + target, + resourceType, + name: identifier, + }); + return { + id: deployed.id, + region: deployed.target.region, + credentials: deployed.credentialProvider, + }; } + return { + id: serviceIdFromArn(identifier), + region: regionFromArn(identifier) ?? ctx.require(RegionKey), + }; } // A pinned region replaces RegionKey on every route's context, so a screen that diff --git a/src/router/index.tsx b/src/router/index.tsx index 986e7360cd..8777f0c621 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -8,7 +8,6 @@ export { GlobalConfigAccessorKey, CommandRunMetricEventKey, ProjectKey, - ProjectTargetKey, type DefaultHandle, type DefaultHandlerProvider, isDefaultHandlerProvider, diff --git a/src/router/router.tsx b/src/router/router.tsx index b75c62abae..fd8a23efe8 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -37,7 +37,6 @@ export const CommandRunMetricEventKey = export const GlobalConfigAccessorKey: ContextKey = contextKey("globalConfigAccessor"); export const ProjectKey = contextKey("project"); -export const ProjectTargetKey = contextKey("projectTarget"); // RoutedCommand keeps the compiled handler and Commander command tree together. // TUI consumers can therefore read handler metadata without module-level state. From fc0a769f140cedb01d29ccc253831c4b06cff172 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 25 Sep 2026 17:52:37 +0000 Subject: [PATCH 6/7] refactor(invoke): address review feedback Split resource selection so it no longer depends on the TUI, rename the forwarded flags to handlerFlags, keep one RESOURCE_LABELS map in the project types, reword the resource flag help, and move the help examples feature to its own PR. --- command.md | 6 +- src/core/project/manager.tsx | 5 +- src/handlers/project/invoke/index.tsx | 134 +++++++++++-------------- src/handlers/project/invoke/screen.tsx | 8 +- src/handlers/project/selection.ts | 8 +- src/handlers/project/types.ts | 6 ++ src/router/flags.tsx | 8 +- src/router/handler.tsx | 20 ---- src/router/index.tsx | 1 - src/router/router.test.ts | 21 ---- src/router/router.tsx | 14 +-- 11 files changed, 77 insertions(+), 154 deletions(-) diff --git a/command.md b/command.md index c7b4cda4d1..65d3cd898b 100644 --- a/command.md +++ b/command.md @@ -652,9 +652,9 @@ invoke a Runtime, harness, or Gateway **Options** -- `--runtime `: the Runtime to invoke: a project name, ID, or ARN -- `--harness `: the harness to invoke: a project name, ID, or ARN -- `--gateway `: the Gateway to invoke: a project name, ID, or ARN +- `--runtime `: the Runtime to invoke: its name in this project, its ID, or its ARN +- `--harness `: the harness to invoke: its name in this project, its ID, or its ARN +- `--gateway `: the Gateway to invoke: its name in this project, its ID, or its ARN - `--target `: project deployment target (default: "default") - `--local`: invoke the local development server (project Runtime only) (default: false) - `--port `: local development server port (defaults: HTTP/AG-UI 8080, MCP 8000, A2A 9000) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 0d0c8387d7..927be565fb 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -24,6 +24,7 @@ import type { RemoveResourceResult, RemoveResourcesResult, } from "../../handlers/project/types"; +import { RESOURCE_LABELS } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { FsReadWriteJson, @@ -1049,9 +1050,7 @@ export class FsProjectManager implements ProjectManager { // provider. Trusting a backend's target echo would let it redirect the call. if (resource) return { ...resource, target: resolved.target }; - const label = { runtime: "Runtime", harness: "Harness", gateway: "Gateway" }[ - input.resourceType - ]; + const label = RESOURCE_LABELS[input.resourceType]; throw new ProjectStateError( `${label} '${input.name}' is not deployed to target '${input.target}'. ` + `Run 'agentcore deploy --target ${input.target}' first.`, diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index a704050b58..9b03216fe8 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -18,30 +18,30 @@ import { createInvokeHarnessHandler, invokeHarnessFlags } from "../../harness/in import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; import { assertMutuallyExclusiveFlags, resolveResource } from "../../utils"; -import { projectResourceNames, RESOURCE_LABELS } from "../selection"; -import type { Project, ProjectInvokableResource } from "../types"; +import { projectResourceNames } from "../selection"; +import { RESOURCE_LABELS, type Project, type ProjectInvokableResource } from "../types"; import { createInvokeRuntimeHandler, invokeRuntimeFlags } from "../../runtime/invoke"; import { invokeProjectRuntimeLocally } from "./runtime"; const RESOURCE = "Resource options:"; const RESOURCE_TYPES = ["runtime", "harness", "gateway"] as const; -const resourceFlags = [ +const selectionFlags = [ flag( "runtime", - "the Runtime to invoke: a project name, ID, or ARN", + "the Runtime to invoke: its name in this project, its ID, or its ARN", z.string().min(1).optional(), { group: RESOURCE }, ), flag( "harness", - "the harness to invoke: a project name, ID, or ARN", + "the harness to invoke: its name in this project, its ID, or its ARN", z.string().min(1).optional(), { group: RESOURCE }, ), flag( "gateway", - "the Gateway to invoke: a project name, ID, or ARN", + "the Gateway to invoke: its name in this project, its ID, or its ARN", z.string().min(1).optional(), { group: RESOURCE }, ), @@ -62,25 +62,28 @@ const resourceFlags = [ ), ] as const; -const INVOKE_FLAGS = { +const HANDLER_FLAGS = { runtime: invokeRuntimeFlags, harness: invokeHarnessFlags, gateway: invokeGatewayFlags, } as const; -type RequestFlag = Exclude<(typeof INVOKE_FLAGS)[ProjectInvokableResource][number], { name: "id" }>; +type HandlerFlag = Exclude< + (typeof HANDLER_FLAGS)[ProjectInvokableResource][number], + { name: "id" } +>; /** - One flag per name, in the help group of the resource types that accept it. + The flags of the runtime, harness, and gateway invoke commands, one per name, grouped by the types that accept them. **/ -function requestFlags(): RequestFlag[] { - const merged = new Map(); +function handlerFlags(): HandlerFlag[] { + const merged = new Map(); for (const resourceType of RESOURCE_TYPES) { - for (const requestFlag of INVOKE_FLAGS[resourceType]) { - if (requestFlag.name === "id") continue; - const entry = merged.get(requestFlag.name) ?? { flag: requestFlag, owners: [] }; + for (const handlerFlag of HANDLER_FLAGS[resourceType]) { + if (handlerFlag.name === "id") continue; + const entry = merged.get(handlerFlag.name) ?? { flag: handlerFlag, owners: [] }; entry.owners.push(resourceType); - merged.set(requestFlag.name, entry); + merged.set(handlerFlag.name, entry); } } return [...merged.values()].map(({ flag, owners }) => ({ @@ -89,51 +92,46 @@ function requestFlags(): RequestFlag[] { })); } -const invokeFlags: readonly ((typeof resourceFlags)[number] | RequestFlag)[] = [ - ...resourceFlags, - ...requestFlags(), +const invokeFlags: readonly ((typeof selectionFlags)[number] | HandlerFlag)[] = [ + ...selectionFlags, + ...handlerFlags(), ]; export type InvokeFlags = FlagsOf; +type ResourceSelection = { resourceType: ProjectInvokableResource; identifier: string }; + const isSet = (value: unknown) => value !== undefined && value !== false; -function selectResource( - project: Project | undefined, - flags: InvokeFlags, - headless: boolean, -): [ProjectInvokableResource, string] | undefined { +function flaggedResource(flags: InvokeFlags): ResourceSelection | undefined { assertMutuallyExclusiveFlags(flags, RESOURCE_TYPES); - const given = RESOURCE_TYPES.find((resourceType) => flags[resourceType] !== undefined); - if (given) return [given, flags[given]!]; + const resourceType = RESOURCE_TYPES.find((type) => flags[type] !== undefined); + return resourceType && { resourceType, identifier: flags[resourceType]! }; +} +function soleProjectResource(project: Project | undefined): ResourceSelection { if (!project) { - if (!headless) return undefined; throw new ProjectStateError( `No AgentCore project found at ${process.cwd()} or any parent directory. ` + "Run from inside a project, or pass --runtime, --harness, or --gateway with an ID or ARN.", ); } - const declared = RESOURCE_TYPES.flatMap((resourceType) => - projectResourceNames(project, resourceType).map( - (name) => [resourceType, name] as [ProjectInvokableResource, string], - ), + projectResourceNames(project, resourceType).map((identifier) => ({ resourceType, identifier })), ); - if (!headless) return undefined; - if (declared.length === 1) return declared[0]; + if (declared.length === 1) return declared[0]!; if (declared.length === 0) { throw new InputValidationError( "This project has no Runtimes, harnesses, or Gateways to invoke.", ); } throw new InputValidationError( - `Choose a resource to invoke: ${declared.map(([type, name]) => `--${type} ${name}`).join(", ")}.`, + `Choose a resource to invoke: ${declared.map(({ resourceType, identifier }) => `--${resourceType} ${identifier}`).join(", ")}.`, ); } export function createProjectInvokeHandler(core: Core, io: AppIO) { - const invokers: Record = { + const handlers: Record = { runtime: createInvokeRuntimeHandler(core, io), harness: createInvokeHarnessHandler(core, io), gateway: createInvokeGatewayHandler(core, io), @@ -143,56 +141,36 @@ export function createProjectInvokeHandler(core: Core, io: AppIO) { description: "invoke a Runtime, harness, or Gateway", flags: invokeFlags, middlewares: [withProject({ projectManager: core.projectManager, optional: true })], - examples: [ - { description: "Choose a project resource to invoke", command: "agentcore invoke" }, - { - description: "Send a payload to a project Runtime", - command: `agentcore invoke --runtime checkout --payload '{"prompt":"Hello"}'`, - }, - { - description: "Send a prompt to a harness by ID", - command: `agentcore invoke --harness support-AbCdEf1234 --prompt "Hello"`, - }, - { - description: "List the tools on a Gateway by ARN", - command: - "agentcore invoke --gateway arn:aws:bedrock-agentcore:us-west-2:111122223333:gateway/tools-AbCdEf1234 " + - `--path /mcp --payload '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'`, - }, - { - description: "Invoke a Runtime on the local development server", - command: `agentcore invoke --runtime checkout --local --payload '{"prompt":"Hello"}'`, - }, - ], handle: async (ctx, flags) => { const project = ctx.value(ProjectKey); const headless = ctx.require(JsonKey) || Object.values(flags).some(isSet); - const selected = selectResource(project, flags, headless); - if (!selected) { + const selection = + flaggedResource(flags) ?? (headless ? soleProjectResource(project) : undefined); + if (!selection) { await renderTuiAt("/agentcore/invoke", ctx, core, io); return; } - const [resourceType, identifier] = selected; - const invoker = invokers[resourceType]; - const allowed = [ + const { resourceType, identifier } = selection; + const handler = handlers[resourceType]; + const acceptedFlags = [ ...RESOURCE_TYPES, "target", ...(resourceType === "runtime" ? ["local", "port"] : []), - ...invoker.flags().map(({ name }) => name), + ...handler.flags().map(({ name }) => name), ]; - const misplaced = Object.entries(flags).find( - ([name, value]) => isSet(value) && !allowed.includes(name), + const unsupported = Object.entries(flags).find( + ([name, value]) => isSet(value) && !acceptedFlags.includes(name), ); - if (misplaced) { + if (unsupported) { throw new InputValidationError( - `--${misplaced[0]} does not apply to a ${RESOURCE_LABELS[resourceType]}`, + `--${unsupported[0]} does not apply to a ${RESOURCE_LABELS[resourceType]}`, ); } - const projectName = + const isProjectName = project && projectResourceNames(project, resourceType).includes(identifier); for (const name of ["target", "local"] as const) { - if (isSet(flags[name]) && !projectName) { + if (isSet(flags[name]) && !isProjectName) { throw new InputValidationError(`--${name} only applies to project resources`); } } @@ -205,25 +183,27 @@ export function createProjectInvokeHandler(core: Core, io: AppIO) { return; } - const resolved = await resolveResource( + const resource = await resolveResource( core, ctx, resourceType, identifier, flags.target ?? DEFAULT_TARGET_NAME, ); - const values: Record = { ...flags, id: resolved.id }; - const request = parseFlags( - invoker.flags(), - Object.fromEntries(invoker.flags().map(({ name }) => [attributeName(name), values[name]])), + const rawHandlerFlags: Record = { ...flags, id: resource.id }; + const parsedHandlerFlags = parseFlags( + handler.flags(), + Object.fromEntries( + handler.flags().map(({ name }) => [attributeName(name), rawHandlerFlags[name]]), + ), ); - let invokeCtx = ctx - .withValue(RegionKey, resolved.region) + let handlerCtx = ctx + .withValue(RegionKey, resource.region) .withValue(PathKey, `/agentcore/${resourceType}/invoke`); - if (resolved.credentials) { - invokeCtx = invokeCtx.withValue(AwsCredentialProviderKey, resolved.credentials); + if (resource.credentials) { + handlerCtx = handlerCtx.withValue(AwsCredentialProviderKey, resource.credentials); } - await invoker.handle(invokeCtx, request, {}); + await handler.handle(handlerCtx, parsedHandlerFlags, {}); }, }); } diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 3c70558711..c004a069e6 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -13,8 +13,12 @@ import { HarnessChat } from "../../harness/invoke/screen"; import { AwsCredentialProviderKey, RegionKey } from "../../keys"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; -import { RESOURCE_LABELS } from "../selection"; -import type { Project, ResolvedDeployedResource, ResolvedDeployedResources } from "../types"; +import { + RESOURCE_LABELS, + type Project, + type ResolvedDeployedResource, + type ResolvedDeployedResources, +} from "../types"; import { ProjectGate } from "../ProjectGate"; type ProjectInvokableRow = Record & diff --git a/src/handlers/project/selection.ts b/src/handlers/project/selection.ts index aacc18ca34..449ccd7ec7 100644 --- a/src/handlers/project/selection.ts +++ b/src/handlers/project/selection.ts @@ -1,11 +1,5 @@ import { InputValidationError, ResourceNotFoundError } from "../../errors"; -import type { Project, ProjectInvokableResource } from "./types"; - -export const RESOURCE_LABELS: Record = { - runtime: "Runtime", - harness: "Harness", - gateway: "Gateway", -}; +import { RESOURCE_LABELS, type Project, type ProjectInvokableResource } from "./types"; export function projectResourceNames( project: Project, diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 873f09f352..43439ea7d4 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -400,6 +400,12 @@ export type ExportHarnessResult = { export type ProjectInvokableResource = Extract; +export const RESOURCE_LABELS: Record = { + runtime: "Runtime", + harness: "Harness", + gateway: "Gateway", +}; + export type RemoveResourceInput = | { resourceType: diff --git a/src/router/flags.tsx b/src/router/flags.tsx index d8ee8a5a95..0e40a9326a 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -1,7 +1,7 @@ import { Option } from "commander"; import { InputValidationError } from "../errors"; import type { Context } from "./context"; -import type { Flag, GlobalFlag, HelpExample } from "./handler"; +import type { Flag, GlobalFlag } from "./handler"; import { coerce, formatZodError, inspect } from "./schema"; // toOption builds a Commander Option from a flag's schema. A boolean that defaults @@ -54,12 +54,6 @@ export function formatParameterDetails(flags: Flag[]): string | undefined { return `\nParameter details:\n\n${sections.join("\n\n")}\n`; } -export function formatExamples(examples: HelpExample[]): string | undefined { - if (examples.length === 0) return undefined; - const sections = examples.map(({ description, command }) => ` ${description}\n $ ${command}`); - return `\nExamples:\n\n${sections.join("\n\n")}\n`; -} - // attributeName mirrors how Commander camelCases an option name into the key it // stores on the parsed options object (e.g. "harness-id" -> "harnessId"). export function attributeName(name: string): string { diff --git a/src/router/handler.tsx b/src/router/handler.tsx index 8073d49109..95f63a1a6a 100644 --- a/src/router/handler.tsx +++ b/src/router/handler.tsx @@ -101,19 +101,6 @@ export interface Handler { children(): Handler[]; } -export interface HelpExample { - description: string; - command: string; -} - -export interface ExamplesProvider { - examples(): HelpExample[]; -} - -export function isExamplesProvider(h: Handler): h is Handler & ExamplesProvider { - return typeof (h as Partial).examples === "function"; -} - type CreateHandlerInput< F extends readonly Flag[], A extends readonly Argument[], @@ -125,7 +112,6 @@ type CreateHandlerInput< handle?: HandleFn; children?: Handler[]; middlewares?: Middleware[]; - examples?: HelpExample[]; }; const noOpHandler = async (_ctx: Context, _flags: any, _args: any): Promise => {}; @@ -138,7 +124,6 @@ class BaseHandler implements Handler { _handle: HandleFn; _children: Handler[]; _middlewares: Middleware[]; - _examples: HelpExample[]; constructor( input: CreateHandlerInput[], readonly Argument[]>, @@ -150,7 +135,6 @@ class BaseHandler implements Handler { this._handle = (input.handle ?? noOpHandler) as HandleFn; this._children = input.children ?? []; this._middlewares = input.middlewares ?? []; - this._examples = input.examples ?? []; } name(): string { @@ -184,10 +168,6 @@ class BaseHandler implements Handler { middlewares(): Middleware[] { return this._middlewares; } - - examples(): HelpExample[] { - return this._examples; - } } // createHandler infers the flags tuple from `flags` (the `const` type parameter diff --git a/src/router/index.tsx b/src/router/index.tsx index 8777f0c621..b0d7fe9e0f 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -20,7 +20,6 @@ export { type GlobalFlag, type Argument, type FlagsOf, - type HelpExample, createHandler, flag, globalFlag, diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 3426f89637..e7134d1084 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -810,27 +810,6 @@ test("commands without long-form flag help have no Parameter details section", a expect(out).toContain("--id"); expect(out).not.toContain("Parameter details:"); - expect(out).not.toContain("Examples:"); -}); - -test("handlers with examples render an Examples section", async () => { - const get = createHandler({ - name: "get", - description: "", - examples: [ - { description: "Get a thing", command: "app get --id a" }, - { description: "Get it as JSON", command: "app get --id a --json" }, - ], - handle: async () => {}, - }); - const root = new Router("app"); - root.handler(get); - - const out = await helpOutput(root, ["app", "get", "--help"]); - - expect(out).toContain( - "Examples:\n\n Get a thing\n $ app get --id a\n\n Get it as JSON\n $ app get --id a --json\n", - ); }); // --- telemetry: command path recording ------------------------------------- diff --git a/src/router/router.tsx b/src/router/router.tsx index fd8a23efe8..ac528ed096 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -1,16 +1,9 @@ -import { - isExamplesProvider, - type Argument, - type Flag, - type GlobalFlag, - type Handler, -} from "./handler"; +import type { Argument, Flag, GlobalFlag, Handler } from "./handler"; import { type Middleware, type MiddlewareProvider, isMiddlewareProvider } from "./middleware"; import { type Context, type ContextKey, ValueContext, contextKey } from "./context"; import { applyGlobalFlags, attributeName, - formatExamples, formatParameterDetails, parseFlags, toOption, @@ -234,11 +227,6 @@ export function compile( ); } - const examples = formatExamples(isExamplesProvider(node) ? node.examples() : []); - if (examples) { - c.addHelpText("after", examples); - } - // Flags with long-form documentation get a "Parameter details" section after // the option list in `--help` output. const parameterDetails = formatParameterDetails(ownFlags); From 43c89e92990087452f4c8eb145aef9b0a2e8b532 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 25 Sep 2026 19:31:11 +0000 Subject: [PATCH 7/7] refactor(invoke): drop auto-selecting the only project resource A bare interactive invoke always opens the picker, and a headless invoke needs exactly one of --runtime, --harness, or --gateway. Docs and template READMEs name the resource explicitly, and the minimal template now renders its README. --- README.md | 2 +- .../agent-python-langchain/README.md | 2 +- .../templates/agent-python-minimal/README.md | 2 +- .../templates/agent-python-strands/README.md | 2 +- .../agent-typescript-strands/README.md | 2 +- .../templates/export-harness-python/README.md | 2 +- .../templates/mcp-python-fastmcp/README.md | 2 +- src/core/project/templates/runtime.ts | 7 +++- src/handlers/project/invoke/index.test.tsx | 38 ++++++------------- src/handlers/project/invoke/index.tsx | 32 +++------------- 10 files changed, 29 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index c6aff66a69..3135b17154 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Create a managed Harness project, deploy it, and send a prompt: agentcore create --name MyAssistant cd MyAssistant agentcore deploy -agentcore invoke --prompt "Hey, what can you do for me?" +agentcore invoke --harness MyAssistant --prompt "Hey, what can you do for me?" ``` To start with code you own instead, create a Runtime project from a template. diff --git a/src/assets/templates/agent-python-langchain/README.md b/src/assets/templates/agent-python-langchain/README.md index b7c9082140..edda56d76b 100644 --- a/src/assets/templates/agent-python-langchain/README.md +++ b/src/assets/templates/agent-python-langchain/README.md @@ -32,7 +32,7 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore deploy -agentcore invoke --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` Traces are collected automatically: AgentCore Runtime starts the agent under diff --git a/src/assets/templates/agent-python-minimal/README.md b/src/assets/templates/agent-python-minimal/README.md index 728b04aead..1843460390 100644 --- a/src/assets/templates/agent-python-minimal/README.md +++ b/src/assets/templates/agent-python-minimal/README.md @@ -27,5 +27,5 @@ Environment variables for local development go in `agentcore/.env.local` ```bash agentcore deploy -agentcore invoke --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-python-strands/README.md b/src/assets/templates/agent-python-strands/README.md index 828cec5323..51db8b5ddd 100644 --- a/src/assets/templates/agent-python-strands/README.md +++ b/src/assets/templates/agent-python-strands/README.md @@ -56,5 +56,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/agent-typescript-strands/README.md b/src/assets/templates/agent-typescript-strands/README.md index 1285628124..f92e83925f 100644 --- a/src/assets/templates/agent-typescript-strands/README.md +++ b/src/assets/templates/agent-typescript-strands/README.md @@ -46,5 +46,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke --payload '{"prompt":"Hello!","actorId":"user-123"}' +agentcore invoke --runtime {{runtimeName}} --payload '{"prompt":"Hello!","actorId":"user-123"}' ``` diff --git a/src/assets/templates/export-harness-python/README.md b/src/assets/templates/export-harness-python/README.md index 7be28ca474..bf32c7616d 100644 --- a/src/assets/templates/export-harness-python/README.md +++ b/src/assets/templates/export-harness-python/README.md @@ -42,5 +42,5 @@ After providing credentials, `agentcore deploy` will deploy your project into Am Invoke the deployed Runtime with its native payload: ```bash -agentcore invoke --payload '{"prompt":"Hello!"}' +agentcore invoke --runtime {{name}} --payload '{"prompt":"Hello!"}' ``` diff --git a/src/assets/templates/mcp-python-fastmcp/README.md b/src/assets/templates/mcp-python-fastmcp/README.md index 06e54def75..2fca1de442 100644 --- a/src/assets/templates/mcp-python-fastmcp/README.md +++ b/src/assets/templates/mcp-python-fastmcp/README.md @@ -29,4 +29,4 @@ sending JSON-RPC to `http://127.0.0.1:8000/mcp`. ## Deployment `agentcore deploy` deploys the server into Amazon Bedrock AgentCore. Invoke it with -`agentcore invoke`, supplying an MCP JSON-RPC payload (e.g. `tools/list`, `tools/call`). +`agentcore invoke --runtime {{name}}`, supplying an MCP JSON-RPC payload (e.g. `tools/list`, `tools/call`). diff --git a/src/core/project/templates/runtime.ts b/src/core/project/templates/runtime.ts index 15db1fef0a..59322b1797 100644 --- a/src/core/project/templates/runtime.ts +++ b/src/core/project/templates/runtime.ts @@ -122,7 +122,11 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa const tree = await FsTreeNode.fromAssetSource( { assetSource }, { assetDir: "templates/agent-python-minimal" }, - { rootDirName: input.name }, + { + rootDirName: input.name, + transformContent: (raw) => + templateRenderer.render(raw, { name: toPythonPackageName(input.name) }), + }, ); return { tree, spec: { runtimes: [buildRuntimeSpec(input)] } }; }, @@ -186,6 +190,7 @@ const getTemplateResolvers = (assetSource: AssetSource, templateRenderer: Templa const modelScaffold = resolveModelProviderScaffold(input); const context = { name: toNpmPackageName(input.name), + runtimeName: input.name, memoryEnvVarName: memory ? memoryEnvVarName(memory.name) : undefined, ...modelScaffold.templateRenderContext, }; diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 3a75221163..031586280a 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -218,7 +218,6 @@ describe("invoke", () => { `/agentcore/harness/invoke/${HARNESS_ID}?qualifier=prod`, ], [["--gateway", "tools"], `/agentcore/gateway/invoke/${GATEWAY_ID}`], - [[], "/agentcore/invoke"], ])("opens the TUI for %j", async (args, path) => { const subject = await launches(args, { runtimes: [RUNTIME], @@ -226,9 +225,7 @@ describe("invoke", () => { agentCoreGateways: [GATEWAY], }); - expect(subject.launches).toEqual([ - { path, region: path === "/agentcore/invoke" ? "us-east-1" : TARGET.region }, - ]); + expect(subject.launches).toEqual([{ path, region: TARGET.region }]); }); test.each([ @@ -240,12 +237,6 @@ describe("invoke", () => { expect(subject.launches.map((launch) => launch.path)).toEqual(["/agentcore/invoke"]); }); - test("a headless invoke selects the sole resource", async () => { - const { core } = await run(["--payload", "{}"], { agentCoreGateways: [GATEWAY] }); - - expect(core.gateway.calls.some(({ method }) => method === "invokeGateway")).toBe(true); - }); - test("resolves the --target deployment target for project names", async () => { const { resolved } = await run( ["--runtime", "checkout", "--payload", "{}", "--target", "default"], @@ -262,26 +253,19 @@ describe("invoke", () => { name: "more than one resource flag", args: ["--runtime", "checkout", "--harness", "support"], resources: { runtimes: [RUNTIME], harnesses: [HARNESS] }, - message: "--runtime, --harness are mutually exclusive", + message: "specify exactly one of --runtime, --harness, --gateway", }, { - name: "a bare --json invoke with several resources", - args: ["--json"], - resources: { runtimes: [RUNTIME], harnesses: [HARNESS] }, - message: "Choose a resource to invoke: --runtime checkout, --harness support.", - }, - { - name: "a bare --json invoke in an empty project", - args: ["--json"], - resources: {}, - message: "This project has no Runtimes, harnesses, or Gateways to invoke.", + name: "a headless invoke with no resource flag, even when the project has one resource", + args: ["--payload", "{}"], + resources: { runtimes: [RUNTIME] }, + message: "specify exactly one of --runtime, --harness, --gateway", }, { name: "a bare --json invoke outside a project", args: ["--json"], resources: undefined, - message: - "or any parent directory. Run from inside a project, or pass --runtime, --harness, or --gateway with an ID or ARN.", + message: "specify exactly one of --runtime, --harness, --gateway", }, { name: "a flag that belongs to another resource type", @@ -328,7 +312,7 @@ describe("invoke", () => { })); servers.push(server); const subject = await routedCommand( - ["--local", "--port", String(server.port), "--payload", "-"], + ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", "-"], { runtimes: [RUNTIME] }, { isTTY: true }, ); @@ -356,7 +340,7 @@ describe("invoke", () => { expect(subject.io.stdout()).toBe("firstsecond"); }); - test("auto-selects the sole local Runtime without resolving deployed resources", async () => { + test("invokes a local Runtime without resolving deployed resources", async () => { let request: | { method: string; @@ -388,7 +372,7 @@ describe("invoke", () => { const payload = '{"prompt":"hi"}'; const { core, io, resolved } = await run( - ["--local", "--port", String(server.port), "--payload", payload], + ["--runtime", RUNTIME.name, "--local", "--port", String(server.port), "--payload", payload], { runtimes: [RUNTIME] }, { writeTargets: false }, ); @@ -612,7 +596,7 @@ describe("invoke", () => { test.each([ { name: "requires --local with --port", - args: ["--port", "8081", "--payload", "{}"], + args: ["--runtime", RUNTIME.name, "--port", "8081", "--payload", "{}"], message: "--port requires --local", }, { diff --git a/src/handlers/project/invoke/index.tsx b/src/handlers/project/invoke/index.tsx index 9b03216fe8..c9bd1d8d27 100644 --- a/src/handlers/project/invoke/index.tsx +++ b/src/handlers/project/invoke/index.tsx @@ -1,5 +1,5 @@ import z from "zod"; -import { InputValidationError, ProjectStateError } from "../../../errors"; +import { InputValidationError } from "../../../errors"; import type { AppIO } from "../../../io"; import { withProject } from "../../../middleware"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; @@ -19,7 +19,7 @@ import { AwsCredentialProviderKey, JsonKey, RegionKey } from "../../keys"; import type { Core } from "../../types"; import { assertMutuallyExclusiveFlags, resolveResource } from "../../utils"; import { projectResourceNames } from "../selection"; -import { RESOURCE_LABELS, type Project, type ProjectInvokableResource } from "../types"; +import { RESOURCE_LABELS, type ProjectInvokableResource } from "../types"; import { createInvokeRuntimeHandler, invokeRuntimeFlags } from "../../runtime/invoke"; import { invokeProjectRuntimeLocally } from "./runtime"; @@ -103,33 +103,12 @@ type ResourceSelection = { resourceType: ProjectInvokableResource; identifier: s const isSet = (value: unknown) => value !== undefined && value !== false; -function flaggedResource(flags: InvokeFlags): ResourceSelection | undefined { - assertMutuallyExclusiveFlags(flags, RESOURCE_TYPES); +function flaggedResource(flags: InvokeFlags, headless: boolean): ResourceSelection | undefined { + assertMutuallyExclusiveFlags(flags, RESOURCE_TYPES, { exactlyOne: headless }); const resourceType = RESOURCE_TYPES.find((type) => flags[type] !== undefined); return resourceType && { resourceType, identifier: flags[resourceType]! }; } -function soleProjectResource(project: Project | undefined): ResourceSelection { - if (!project) { - throw new ProjectStateError( - `No AgentCore project found at ${process.cwd()} or any parent directory. ` + - "Run from inside a project, or pass --runtime, --harness, or --gateway with an ID or ARN.", - ); - } - const declared = RESOURCE_TYPES.flatMap((resourceType) => - projectResourceNames(project, resourceType).map((identifier) => ({ resourceType, identifier })), - ); - if (declared.length === 1) return declared[0]!; - if (declared.length === 0) { - throw new InputValidationError( - "This project has no Runtimes, harnesses, or Gateways to invoke.", - ); - } - throw new InputValidationError( - `Choose a resource to invoke: ${declared.map(({ resourceType, identifier }) => `--${resourceType} ${identifier}`).join(", ")}.`, - ); -} - export function createProjectInvokeHandler(core: Core, io: AppIO) { const handlers: Record = { runtime: createInvokeRuntimeHandler(core, io), @@ -144,8 +123,7 @@ export function createProjectInvokeHandler(core: Core, io: AppIO) { handle: async (ctx, flags) => { const project = ctx.value(ProjectKey); const headless = ctx.require(JsonKey) || Object.values(flags).some(isSet); - const selection = - flaggedResource(flags) ?? (headless ? soleProjectResource(project) : undefined); + const selection = flaggedResource(flags, headless); if (!selection) { await renderTuiAt("/agentcore/invoke", ctx, core, io); return;