From b0b589301cc25cac9f274b4f451feebd04815bec Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 11 Aug 2026 22:47:53 +0000 Subject: [PATCH 1/9] feat(gateway): add interactive invoke console --- README.md | 42 +- src/components/GatewayPicker.tsx | 4 +- .../MultilineInput.tsx} | 15 +- src/components/Root.tsx | 9 + src/handlers/gateway/gateway.screen.test.tsx | 2 +- src/handlers/gateway/index.tsx | 2 +- src/handlers/gateway/invoke/index.tsx | 55 +- .../gateway/invoke/invoke.screen.test.tsx | 741 ++++++++++++++++++ src/handlers/gateway/invoke/invoke.test.tsx | 90 ++- src/handlers/gateway/invoke/launchContext.ts | 14 + src/handlers/gateway/invoke/request.test.ts | 25 +- src/handlers/gateway/invoke/request.ts | 19 + src/handlers/gateway/invoke/screen.tsx | 536 +++++++++++++ src/handlers/runtime/invoke/screen.tsx | 5 +- 14 files changed, 1536 insertions(+), 23 deletions(-) rename src/{handlers/runtime/invoke/RuntimePayloadInput.tsx => components/MultilineInput.tsx} (89%) create mode 100644 src/handlers/gateway/invoke/invoke.screen.test.tsx create mode 100644 src/handlers/gateway/invoke/launchContext.ts create mode 100644 src/handlers/gateway/invoke/screen.tsx diff --git a/README.md b/README.md index a8a2336b3..78388d7de 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ agentcore # interactive TUI ├── gateway # inspect AgentCore Gateways │ ├── get # get a Gateway by id │ ├── list # list Gateways (server-side paginated) -│ ├── invoke # send a headless request through a Gateway +│ ├── invoke # invoke a Gateway headlessly or in a persistent console │ ├── target │ │ ├── get # get a Target under a Gateway │ │ └── list # list Targets under a Gateway @@ -161,6 +161,7 @@ agentcore memory record list --memory --namespace --max-r agentcore gateway get --id agentcore gateway list --max-results 20 agentcore gateway invoke --id --payload file://request.json +agentcore gateway invoke --id # open the persistent JSON console agentcore gateway target get --gateway-id --target-id agentcore gateway target list --gateway-id --max-results 20 agentcore gateway connector get --gateway-id --id @@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline, ### Invoke a Gateway -Gateway Invoke is a headless, project-independent HTTP request command. It gets -the Gateway by ID, uses the returned HTTPS origin, selects authentication from -the Gateway's authorizer, and preserves the request and response bodies. +Gateway Invoke is a project-independent HTTP request command with headless and +interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin, +selects authentication from the Gateway's authorizer, and preserves the request +and response bodies. ```bash # MCP Gateway: use the exact gatewayUrl returned by GetGateway. @@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed. Non-2xx response bodies use the selected output mode before the command exits with a failure status. -Gateway Invoke V1 has no TUI, required request-type selector, tool/model -discovery command, or protocol-specific payload builder. Callers provide the -Gateway-relative route and protocol payload directly. +Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare +invoke opens the Gateway picker, while `--id` opens the selected Gateway +directly. `--path`, `--session-id`, MCP session flags, `--header`, and +`--bearer-token` seed the console. Interactive bearer tokens may be inline or +`file://` sources, but not stdin. Explicit headless-only flags such as +`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the +command headless. + +The console generates and displays a Runtime session ID, adopts returned Runtime +and MCP sessions, and streams textual responses as they arrive. An empty path +uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and +`Ctrl+T` switches Gateways. Switching Gateways clears request context, while +changing paths preserves the draft and Gateway authentication but starts fresh +sessions. + +| Shortcut | Action | +| ------------- | -------------------------------------------- | +| `Enter` | Send the JSON request | +| `Shift+Enter` | Insert a newline | +| `Ctrl+P` | Edit the Gateway-relative path | +| `Ctrl+T` | Change Gateway | +| `Ctrl+V` | Toggle raw and pretty completed JSON | +| `Esc` | Interrupt an active request or navigate back | +| `↑`/`↓` | Scroll response history | + +Gateway Invoke V1 has no request-type selector, target/path discovery, +tool/model discovery command, authentication editor, or protocol-specific +payload builder. Callers provide the Gateway-relative route and protocol payload +directly. GET and DELETE remain available through headless invoke. ### Invoke a Runtime diff --git a/src/components/GatewayPicker.tsx b/src/components/GatewayPicker.tsx index 312055667..9da7ff6a1 100644 --- a/src/components/GatewayPicker.tsx +++ b/src/components/GatewayPicker.tsx @@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps { breadcrumb: string[]; description?: string; onSelect: (gatewayId: string) => void; + onEscape?: () => void; } export function GatewayPicker({ @@ -51,6 +52,7 @@ export function GatewayPicker({ breadcrumb, description, onSelect, + onEscape, }: GatewayPickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); @@ -71,7 +73,7 @@ export function GatewayPicker({ columns={gatewayColumns} getValue={(row) => row.gatewayId} onSelect={onSelect} - onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))} + onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))} loadingMessage="Loading Gateways…" errorMessage={(error) => `Error: ${error.message}`} emptyMessage="No Gateways found in this Region." diff --git a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx b/src/components/MultilineInput.tsx similarity index 89% rename from src/handlers/runtime/invoke/RuntimePayloadInput.tsx rename to src/components/MultilineInput.tsx index 4c4fc5087..e37660e5f 100644 --- a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx +++ b/src/components/MultilineInput.tsx @@ -1,15 +1,15 @@ import { useState } from "react"; import { Box, Text, useInput } from "ink"; -import { darkTheme } from "../../../components/ui/_core.js"; +import { darkTheme } from "./ui/_core.js"; const theme = darkTheme; const PREVIEW_LINES = 4; -const PLACEHOLDER = "Enter JSON payload"; -interface RuntimePayloadInputProps { +export interface MultilineInputProps { value: string; onChange: (value: string) => void; onSubmit: () => void; + placeholder?: string; submitDisabled?: boolean; } @@ -21,12 +21,13 @@ function Cursor({ character }: { character: string }) { ); } -export function RuntimePayloadInput({ +export function MultilineInput({ value, onChange, onSubmit, + placeholder = "Enter text", submitDisabled = false, -}: RuntimePayloadInputProps) { +}: MultilineInputProps) { const [rawCursor, setRawCursor] = useState(value.length); const cursor = Math.min(rawCursor, value.length); @@ -67,8 +68,8 @@ export function RuntimePayloadInput({ if (value === "") { return ( - - {PLACEHOLDER.slice(1)} + + {placeholder.slice(1)} ); } diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 7d52644a1..b58e81a9f 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx"; import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; +import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/gateway/get/:gatewayId/json" element={} /> + } + /> + } + /> } diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index e997c0ec3..ece9b52f7 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -110,7 +110,7 @@ describe("Gateway menu and list", () => { await waitForText(screen.lastFrame, "inspect AgentCore Gateways"); const frame = screen.lastFrame()!; - for (const command of ["get", "list", "target", "connector", "rule"]) { + for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) { expect(frame).toContain(command); } expect(frame).not.toMatch(/\bcreate\b/); diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 0ac3e6e97..c953c3d2c 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) - .supportedTuiCommands("get", "list", "target", "connector", "rule") + .supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule") .handler(createCreateGatewayHandler(core, io)) .handler(createUpdateGatewayHandler(core, io)) .handler(createGetGatewayHandler(core)) diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx index 14febc0a5..88e151751 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -3,10 +3,12 @@ import { GatewayInvokeInterruptedError, GatewayInvokeResponseError, InputValidationError, + InvalidEnvironmentError, } from "../../../errors"; import type { AppIO } from "../../../io"; import { ExitCode } from "../../../runnable"; -import { createHandler, flag } from "../../../router"; +import { createHandler, flag, PathKey } from "../../../router"; +import { renderTuiAt } from "../../../tui"; import { JsonKey } from "../../keys"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -16,8 +18,10 @@ import { normalizeGatewayInvokeRequest, parseGatewayInvokeHeaders, resolveGatewayInvokeSources, + resolveGatewayInvokeTuiBearerToken, } from "./request"; import { writeGatewayInvokeResponse } from "./response"; +import { GatewayInvokeLaunchContextKey } from "./launchContext"; export const createInvokeGatewayHandler = (core: Core, io: AppIO) => createHandler({ @@ -58,6 +62,55 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) => } 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"], + 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, + }; + try { + await renderTuiAt( + `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, + ctx.withValue(GatewayInvokeLaunchContextKey, launchContext), + core, + io, + ); + } catch (error) { + if (error instanceof InvalidEnvironmentError) { + throw new InputValidationError(error.message, { + cause: error, + exitCode: ExitCode.USAGE, + }); + } + throw error; + } + return; + } + } + if (jsonOutput && flags["output-file"] !== undefined) { throw new InputValidationError("--json cannot be used with --output-file"); } diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx new file mode 100644 index 000000000..94ae5717e --- /dev/null +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -0,0 +1,741 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { GatewaySummary, GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; +import type { GatewayInvokeRequest } from "../types"; +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 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); + +function gatewaySummary(overrides: Partial = {}): GatewaySummary { + return { + gatewayId: GATEWAY_ID, + name: "checkout-gateway", + status: "READY", + authorizerType: "NONE", + protocolType: "MCP", + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + ...overrides, + }; +} + +function gatewayDetail(overrides: Partial = {}): GetGatewayResponse { + return { + gatewayId: GATEWAY_ID, + gatewayUrl: GATEWAY_URL, + name: "checkout-gateway", + status: "READY", + authorizerType: "NONE", + protocolType: "MCP", + ...overrides, + } as GetGatewayResponse; +} + +function responseBody(...chunks: Uint8Array[]): AsyncIterable { + return (async function* () { + yield* chunks; + })(); +} + +function invokeRequests(core: TestCoreClient): GatewayInvokeRequest[] { + return core.gateway.calls + .filter((call) => call.method === "invokeGateway") + .map((call) => call.args[0] as GatewayInvokeRequest); +} + +function displayedSessionId(frame: string | undefined): string | undefined { + return frame?.match(/Session ID: ([^ ·\n]+)/)?.[1]; +} + +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 = renderScreen("/agentcore/gateway/invoke", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + + await waitForText(screen.lastFrame, `agentcore → gateway → invoke → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, "Enter JSON payload"); + expect(screen.lastFrame()).toContain("Path: default"); + expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); + }); + + test("shows Gateway lookup failures and aborts lookup on unmount", async () => { + let signal: AbortSignal | undefined; + const core = new TestCoreClient(); + core.gateway.getGateway = async (_id, _options, nextSignal) => { + signal = nextSignal; + throw Object.assign(new Error("not authorized for this Gateway"), { + name: "AccessDeniedException", + }); + }; + const errorScreen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(errorScreen.lastFrame, "AccessDeniedException"); + await waitForText(errorScreen.lastFrame, "not authorized for this Gateway"); + expect(signal?.aborted).toBe(false); + errorScreen.unmount(); + + const pendingCore = new TestCoreClient(); + pendingCore.gateway.getGateway = async (_id, _options, nextSignal) => { + signal = nextSignal; + return new Promise(() => {}); + }; + const pendingScreen = renderScreen(CONSOLE_PATH, { core: pendingCore }); + await waitFor(() => signal !== undefined && !signal.aborted); + pendingScreen.unmount(); + await waitFor(() => signal!.aborted); + }); +}); + +describe("Gateway invoke JSON console", () => { + test("sends POST JSON with broad response acceptance and a generated session", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Enter JSON payload"); + const sessionId = displayedSessionId(screen.lastFrame()); + expect(sessionId).toMatch(UUID_PATTERN); + await screen.write('{"prompt":"hello"}'); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + + expect(invokeRequests(core)[0]).toMatchObject({ + gatewayId: GATEWAY_ID, + url: GATEWAY_URL, + method: "POST", + authorizerType: "NONE", + contentType: "application/json", + accept: "application/json, text/event-stream, */*;q=0.1", + runtimeSessionId: sessionId, + }); + expect(new TextDecoder().decode(invokeRequests(core)[0]!.payload)).toBe('{"prompt":"hello"}'); + await waitForText(screen.lastFrame, "complete · 2 bytes"); + }); + + test.each(["NONE", "AWS_IAM", "AUTHENTICATE_ONLY"] as const)( + "uses %s ingress authentication without a bearer token", + async (authorizerType) => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail({ authorizerType })).setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, `Auth: ${authorizerType}`); + await screen.write("{}"); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + + expect(invokeRequests(core)[0]).toMatchObject({ authorizerType }); + expect(invokeRequests(core)[0]!.bearerToken).toBeUndefined(); + }, + ); + + test("rejects invalid JSON without clearing the editor or invoking", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write('{"prompt":'); + await screen.press("return"); + + await waitForText(screen.lastFrame, "Enter a valid JSON payload"); + expect(screen.lastFrame()).toContain('{"prompt":'); + expect(invokeRequests(core)).toHaveLength(0); + }); + + test("keeps the draft out of history when request normalization fails", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(GatewayInvokeLaunchContextKey, { + gatewayId: GATEWAY_ID, + path: "https://evil.example/path", + }), + }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write('{"important":"draft"}'); + await screen.press("return"); + + await waitForText(screen.lastFrame, "--path must be relative to the Gateway"); + expect(screen.lastFrame()).toContain('{"important":"draft"}'); + expect(screen.lastFrame()).not.toContain("Request\n"); + expect(invokeRequests(core)).toHaveLength(0); + }); + + test("seeds path, sessions, authentication, and headers without exposing secrets", async () => { + const token = "secret-bearer-token"; + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail({ authorizerType: "CUSTOM_JWT" })).setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: responseBody(Buffer.from('{"ok":true}')), + }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(GatewayInvokeLaunchContextKey, { + gatewayId: GATEWAY_ID, + path: "runtime/invocations", + runtimeSessionId: "seeded-runtime", + mcpSessionId: "seeded-mcp", + mcpProtocolVersion: "2025-06-18", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: token, + }), + }); + + await waitForText(screen.lastFrame, "Path: runtime/invocations"); + expect(screen.lastFrame()).toContain("Session ID: seeded-runtime"); + expect(screen.lastFrame()).toContain("Context: JWT/1h"); + expect(screen.lastFrame()).not.toContain(token); + expect(screen.lastFrame()).not.toContain("retail"); + + await screen.write("{}"); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + expect(invokeRequests(core)[0]).toMatchObject({ + url: "https://gateway-123.gateway.example.test/runtime/invocations", + runtimeSessionId: "seeded-runtime", + mcpSessionId: "seeded-mcp", + mcpProtocolVersion: "2025-06-18", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: token, + }); + }); + + test("proactively blocks CUSTOM_JWT submission without a bearer token", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail({ authorizerType: "CUSTOM_JWT" })); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "CUSTOM_JWT Gateway requires --bearer-token"); + await screen.write("{}"); + await screen.press("return"); + + expect(invokeRequests(core)).toHaveLength(0); + expect(screen.lastFrame()).toContain("{}"); + expect(screen.lastFrame()).toContain("[ctl+p] path"); + expect(screen.lastFrame()).toContain("[ctl+t] gateway"); + }); + + test("blocks a non-READY Gateway with its current status", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail({ status: "FAILED" })); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Gateway is FAILED; invocation requires READY"); + await screen.write("{}"); + await screen.press("return"); + + expect(invokeRequests(core)).toHaveLength(0); + }); + + test("edits and cancels paths while preserving the JSON draft", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + const initialSession = displayedSessionId(screen.lastFrame()); + await screen.write('{"turn":1}'); + await screen.write("\x10"); + await waitForText(screen.lastFrame, "edit the Gateway-relative path"); + await screen.write("ignored/path"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "Path: default"); + expect(screen.lastFrame()).toContain('{"turn":1}'); + expect(displayedSessionId(screen.lastFrame())).toBe(initialSession); + + await screen.write("\x10"); + await screen.write("runtime/invocations?trace=true"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Path: runtime/invocations?trace=true"); + expect(screen.lastFrame()).toContain('{"turn":1}'); + const nextSession = displayedSessionId(screen.lastFrame()); + expect(nextSession).toMatch(UUID_PATTERN); + expect(nextSession).not.toBe(initialSession); + + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + expect(invokeRequests(core)[0]).toMatchObject({ + url: "https://gateway-123.gateway.example.test/runtime/invocations?trace=true", + runtimeSessionId: nextSession, + }); + }); + + test("clears a seeded path back to the exact Gateway URL", async () => { + const seededPath = "runtime/invocations"; + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(GatewayInvokeLaunchContextKey, { + gatewayId: GATEWAY_ID, + path: seededPath, + runtimeSessionId: "seeded-session", + }), + }); + + await waitForText(screen.lastFrame, `Path: ${seededPath}`); + await screen.write("\x10"); + for (let index = 0; index < seededPath.length; index++) { + await screen.write("\x7f"); + } + await screen.press("return"); + + await waitForText(screen.lastFrame, "Path: default"); + expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); + expect(displayedSessionId(screen.lastFrame())).not.toBe("seeded-session"); + }); + + test("path changes clear transcript and returned sessions but retain Gateway context", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail({ authorizerType: "CUSTOM_JWT" })).setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + runtimeSessionId: "returned-runtime", + mcpSessionId: "returned-mcp", + mcpProtocolVersion: "2025-06-18", + body: responseBody(Buffer.from("old response")), + }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(GatewayInvokeLaunchContextKey, { + gatewayId: GATEWAY_ID, + bearerToken: "secret-token", + applicationHeaders: [["X-Tenant", "retail"]], + }), + }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write('{"turn":1}'); + await screen.press("return"); + await waitForText(screen.lastFrame, "Session ID: returned-runtime"); + await screen.write('{"turn":2}'); + + await screen.write("\x10"); + await screen.write("new/path"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Path: new/path"); + + expect(screen.lastFrame()).not.toContain("old response"); + expect(screen.lastFrame()).not.toContain("returned-mcp"); + expect(screen.lastFrame()).toContain('{"turn":2}'); + expect(screen.lastFrame()).toContain("Context: JWT/1h"); + expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); + }); + + test("switching Gateways clears path, draft, transcript, sessions, and request context", async () => { + const nextGatewayId = "gateway-next"; + const core = new TestCoreClient(); + core.gateway + .setGetResponse(gatewayDetail({ authorizerType: "CUSTOM_JWT" })) + .setListResponse({ + items: [ + gatewaySummary(), + gatewaySummary({ + gatewayId: nextGatewayId, + name: "next-gateway", + authorizerType: "NONE", + }), + ], + }) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + runtimeSessionId: "returned-runtime", + mcpSessionId: "returned-mcp", + body: responseBody(Buffer.from("old response")), + }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(GatewayInvokeLaunchContextKey, { + gatewayId: GATEWAY_ID, + path: "old/path", + bearerToken: "secret-token", + applicationHeaders: [["X-Tenant", "retail"]], + }), + }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write('{"turn":1}'); + await screen.press("return"); + await waitForText(screen.lastFrame, "old response"); + await screen.write('{"draft":true}'); + core.gateway.setGetResponse( + gatewayDetail({ + gatewayId: nextGatewayId, + name: "next-gateway", + authorizerType: "NONE", + }), + ); + + await screen.write("\x14"); + 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, "Ready"); + + expect(screen.lastFrame()).toContain("Path: default"); + expect(screen.lastFrame()).not.toContain("old response"); + expect(screen.lastFrame()).not.toContain('{"draft":true}'); + expect(screen.lastFrame()).not.toContain("returned-mcp"); + expect(screen.lastFrame()).not.toContain("Context:"); + expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); + }); + + test("cancelling Gateway switching preserves the complete console state", async () => { + const core = new TestCoreClient(); + core.gateway + .setGetResponse(gatewayDetail({ authorizerType: "CUSTOM_JWT" })) + .setListResponse({ items: [gatewaySummary()] }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(GatewayInvokeLaunchContextKey, { + gatewayId: GATEWAY_ID, + path: "kept/path", + runtimeSessionId: "kept-session", + bearerToken: "secret-token", + applicationHeaders: [["X-Tenant", "retail"]], + }), + }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write('{"kept":"draft"}'); + await screen.write("\x14"); + await waitForText(screen.lastFrame, "choose another Gateway"); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "Path: kept/path"); + expect(screen.lastFrame()).toContain("Session ID: kept-session"); + expect(screen.lastFrame()).toContain('{"kept":"draft"}'); + expect(screen.lastFrame()).toContain("Context: JWT/1h"); + }); + + test("renders streamed chunks before completion and adopts returned sessions afterward", async () => { + const release = Promise.withResolvers(); + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType: "text/event-stream", + runtimeSessionId: "returned-runtime", + mcpSessionId: "returned-mcp", + mcpProtocolVersion: "2025-06-18", + body: (async function* () { + yield Buffer.from("data: first\n"); + await release.promise; + yield Buffer.from("data: second\n"); + })(), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + const initialSession = displayedSessionId(screen.lastFrame()); + await screen.write("{}"); + await screen.press("return"); + await waitForText(screen.lastFrame, "data: first"); + + expect(screen.lastFrame()).toContain("streaming"); + expect(screen.lastFrame()).not.toContain("returned-runtime"); + release.resolve(); + await waitForText(screen.lastFrame, "data: second"); + await waitForText(screen.lastFrame, "complete · 25 bytes"); + expect(screen.lastFrame()).toContain("Session ID: returned-runtime"); + expect(screen.lastFrame()).toContain("MCP session ID: returned-mcp"); + expect(initialSession).not.toBe("returned-runtime"); + + core.gateway.setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("continued")), + }); + await screen.write('{"turn":2}'); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 2); + expect(invokeRequests(core)[1]).toMatchObject({ + runtimeSessionId: "returned-runtime", + mcpSessionId: "returned-mcp", + mcpProtocolVersion: "2025-06-18", + }); + }); + + test("preserves non-2xx bodies and marks the exchange failed", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 422, + contentType: "application/problem+json", + requestId: "request-422", + runtimeSessionId: "error-runtime", + mcpSessionId: "error-mcp", + mcpProtocolVersion: "2025-06-18", + body: responseBody(Buffer.from('{"message":"invalid"}')), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, '{"message":"invalid"}'); + expect(screen.lastFrame()).toContain("Response · 422 · application/problem+json"); + expect(screen.lastFrame()).toContain("request-422"); + expect(screen.lastFrame()).toContain("HTTP 422"); + expect(screen.lastFrame()).toContain("failed · 21 bytes"); + + core.gateway.setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("continued")), + }); + await screen.write('{"turn":2}'); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 2); + expect(invokeRequests(core)[1]).toMatchObject({ + runtimeSessionId: "error-runtime", + mcpSessionId: "error-mcp", + mcpProtocolVersion: "2025-06-18", + }); + }); + + test("preserves manual redirect bodies and marks the exchange failed", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 307, + contentType: "text/plain", + body: responseBody(Buffer.from("Temporary Redirect")), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "Temporary Redirect"); + expect(screen.lastFrame()).toContain("Response · 307 · text/plain"); + expect(screen.lastFrame()).toContain("HTTP 307"); + expect(screen.lastFrame()).toContain("failed · 18 bytes"); + }); + + test("toggles completed JSON between raw and pretty text", async () => { + const raw = '{"z":1,"nested":{"ok":true}}'; + const pretty = JSON.stringify(JSON.parse(raw), null, 2); + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: responseBody(Buffer.from(raw)), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + await waitForText(screen.lastFrame, raw); + await screen.write("\x16"); + await waitForText(screen.lastFrame, pretty); + await screen.write("\x16"); + await waitForText(screen.lastFrame, raw); + }); + + test.each([ + [ + "invalid UTF-8 text", + "text/plain", + Buffer.from([0xff]), + "Non-renderable responses require headless invoke with --output-file.", + ], + [ + "invalid JSON", + "application/json", + Buffer.from("{not-json"), + "Invalid JSON response; showing raw text.", + ], + ])("explains %s responses", async (_case, contentType, bytes, expected) => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType, + body: responseBody(bytes), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, expected); + if (_case === "invalid UTF-8 text") { + expect(screen.lastFrame()).toContain(`failed · ${bytes.byteLength} bytes`); + expect(screen.lastFrame()).not.toContain("�"); + } else { + expect(screen.lastFrame()).toContain(`complete · ${bytes.byteLength} bytes`); + } + }); + + test("preserves partial text when response iteration fails", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: (async function* () { + yield Buffer.from("partial"); + throw Object.assign(new Error("stream failed"), { + name: "StreamReadError", + }); + })(), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "StreamReadError"); + expect(screen.lastFrame()).toContain("stream failed"); + expect(screen.lastFrame()).toContain("partial"); + expect(screen.lastFrame()).toContain("failed · 7 bytes"); + }); + + test("rejects binary console responses without consuming their bodies", async () => { + let iterations = 0; + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType: "application/octet-stream", + body: (async function* () { + iterations++; + yield Buffer.from([0, 255]); + })(), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText( + screen.lastFrame, + "Binary or unknown responses require headless invoke with --output-file.", + ); + expect(iterations).toBe(0); + }); + + test("Escape interrupts streaming and preserves partial output without adopting sessions", async () => { + let signal: AbortSignal | undefined; + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + core.gateway.invokeGateway = async (_request, _options, nextSignal) => { + signal = nextSignal; + return { + statusCode: 200, + contentType: "text/event-stream", + runtimeSessionId: "interrupted-runtime", + mcpSessionId: "interrupted-mcp", + body: (async function* () { + yield Buffer.from("data: partial\n"); + await new Promise((_resolve, reject) => { + nextSignal?.addEventListener( + "abort", + () => + reject( + Object.assign(new Error("The operation was aborted"), { + name: "AbortError", + }), + ), + { once: true }, + ); + }); + })(), + }; + }; + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + const initialSession = displayedSessionId(screen.lastFrame()); + await screen.write("{}"); + await screen.press("return"); + await waitForText(screen.lastFrame, "data: partial"); + await screen.press("escape"); + + await waitFor(() => signal?.aborted === true); + await waitForText(screen.lastFrame, "interrupted · 14 bytes"); + expect(screen.lastFrame()).toContain("data: partial"); + const readyLine = screen + .lastFrame()! + .split("\n") + .find((line) => line.includes("Ready · Session ID:")); + expect(readyLine).toContain(`Session ID: ${initialSession}`); + expect(readyLine).not.toContain("interrupted-mcp"); + }); + + test("keeps status and shortcuts stable at narrow terminal widths", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.resize(80, 24); + expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); + expect(screen.lastFrame()).toContain("[ctl+p] path"); + + await screen.resize(60, 24); + expect(screen.lastFrame()).toContain("[enter] send"); + expect(screen.lastFrame()).toContain("[esc] back"); + }); + + test("scrolls completed response history", async () => { + const response = Array.from({ length: 12 }, (_, index) => `response-line-${index}`).join("\n"); + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from(response)), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + await screen.resize(80, 16); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + await waitForText(screen.lastFrame, "response-line-11"); + for (let index = 0; index < 8; index++) await screen.press("up"); + expect(screen.lastFrame()).toContain("response-line-3"); + for (let index = 0; index < 8; index++) await screen.press("down"); + expect(screen.lastFrame()).toContain("response-line-11"); + }); +}); diff --git a/src/handlers/gateway/invoke/invoke.test.tsx b/src/handlers/gateway/invoke/invoke.test.tsx index 07ed590f5..b9a88492d 100644 --- a/src/handlers/gateway/invoke/invoke.test.tsx +++ b/src/handlers/gateway/invoke/invoke.test.tsx @@ -1,11 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import type { AppIO } from "../../../io"; +import { InvalidEnvironmentError } from "../../../errors"; import { ExitCode, runWithExitCode } from "../../../runnable"; +import * as tui from "../../../tui"; import { createSilentLogger, TestCoreClient, @@ -14,6 +16,7 @@ import { } from "../../../testing"; import { createRootHandler } from "../../index"; import type { GatewayInvokeRequest } from "../types"; +import { GatewayInvokeLaunchContextKey } from "./launchContext"; const REGION = "us-west-2"; const GATEWAY_ID = "gateway-123"; @@ -339,7 +342,9 @@ describe("gateway invoke", () => { test.each([ [["gateway", "invoke", "--payload", "{}"], /--id/], - [["gateway", "invoke", "--id", GATEWAY_ID], /--payload/], + [["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", @@ -377,6 +382,87 @@ describe("gateway invoke", () => { expect(core.gateway.calls).toEqual([]); }); + test("a bare command enters existing TUI middleware without Gateway Core calls", async () => { + const core = configuredCore(); + const output = captureIO(); + + await expect(runCommand(core, output.io, ["gateway", "invoke"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + expect(core.gateway.calls).toEqual([]); + }); + + test("deep-links an id-only invoke and seeds interactive request context", async () => { + const core = configuredCore(); + const output = captureIO(); + const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); + + try { + await runCommand(core, output.io, [ + "gateway", + "invoke", + "--id", + "gateway/blue one", + "--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", + ]); + + expect(render).toHaveBeenCalledTimes(1); + expect(render.mock.calls[0]![0]).toBe("/agentcore/gateway/invoke/gateway%2Fblue%20one"); + expect(render.mock.calls[0]![1].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(core.gateway.calls).toEqual([]); + } finally { + render.mockRestore(); + } + }); + + test("rejects stdin bearer tokens when launching the TUI", async () => { + const core = configuredCore(); + const output = captureIO(Buffer.from("secret-token")); + + await expect( + runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID, "--bearer-token", "-"]), + ).rejects.toThrow("stdin bearer tokens are not available"); + expect(core.gateway.calls).toEqual([]); + }); + + test("classifies an unavailable interactive environment as usage", async () => { + const core = configuredCore(); + const output = captureIO(); + const render = spyOn(tui, "renderTuiAt").mockRejectedValue( + new InvalidEnvironmentError("interactive mode requires a TTY on stdin and stdout"), + ); + + try { + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID]), + ); + + expect(code).toBe(ExitCode.USAGE); + expect(core.gateway.calls).toEqual([]); + } finally { + render.mockRestore(); + } + }); + test("SIGINT aborts lookup and invocation through the same signal", async () => { const core = configuredCore(); const output = captureIO(); diff --git a/src/handlers/gateway/invoke/launchContext.ts b/src/handlers/gateway/invoke/launchContext.ts new file mode 100644 index 000000000..254480a30 --- /dev/null +++ b/src/handlers/gateway/invoke/launchContext.ts @@ -0,0 +1,14 @@ +import { contextKey } from "../../../router"; + +export type GatewayInvokeLaunchContext = { + gatewayId: string; + path?: string; + runtimeSessionId?: string; + mcpSessionId?: string; + mcpProtocolVersion?: string; + applicationHeaders?: [string, string][]; + bearerToken?: string; +}; + +export const GatewayInvokeLaunchContextKey = + contextKey("gateway.invoke.launch"); diff --git a/src/handlers/gateway/invoke/request.test.ts b/src/handlers/gateway/invoke/request.test.ts index 6970200d2..7f2978df7 100644 --- a/src/handlers/gateway/invoke/request.test.ts +++ b/src/handlers/gateway/invoke/request.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { PassThrough } from "node:stream"; import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError, SourceResolutionError } from "../../../errors"; +import { SourceResolver } from "../../../io"; import { normalizeGatewayInvokeRequest, parseGatewayInvokeHeaders, resolveGatewayInvokeSources, + resolveGatewayInvokeTuiBearerToken, } from "./request"; const GATEWAY_ID = "gateway-123"; @@ -65,6 +68,26 @@ describe("Gateway invoke sources", () => { }); }); +test("brands TUI bearer-token file failures as input errors", async () => { + const missing = `file:///tmp/missing-gateway-token-${process.pid}`; + const error = await resolveGatewayInvokeTuiBearerToken(missing, stdin()).catch((error) => error); + + expect(error).toBeInstanceOf(InputValidationError); + expect(error.message).toContain("could not read '--bearer-token' from file"); + expect(error.cause).toBeInstanceOf(SourceResolutionError); +}); + +test("preserves unexpected TUI bearer-token source failures", async () => { + const failure = new TypeError("source failed"); + const resolve = spyOn(SourceResolver.prototype, "resolveText").mockRejectedValue(failure); + + try { + await expect(resolveGatewayInvokeTuiBearerToken("token", stdin())).rejects.toBe(failure); + } finally { + resolve.mockRestore(); + } +}); + describe("Gateway invoke headers", () => { test("parses ordered header values containing additional colons", () => { expect(parseGatewayInvokeHeaders(["X-One: 1", "X-Url: https://example.test/a:b"])).toEqual([ diff --git a/src/handlers/gateway/invoke/request.ts b/src/handlers/gateway/invoke/request.ts index 7a4a2a6c1..4d84536ec 100644 --- a/src/handlers/gateway/invoke/request.ts +++ b/src/handlers/gateway/invoke/request.ts @@ -62,6 +62,25 @@ export async function resolveGatewayInvokeSources( } } +export async function resolveGatewayInvokeTuiBearerToken( + source: string | undefined, + stdin: NodeJS.ReadStream, +): Promise { + if (source === "-") { + throw new InputValidationError( + "stdin bearer tokens are not available when launching the interactive console", + ); + } + try { + return await new SourceResolver({ stdin }).resolveText("bearer-token", source); + } catch (error) { + if (error instanceof SourceResolutionError) { + throw new InputValidationError(error.message, { cause: error }); + } + throw error; + } +} + export function parseGatewayInvokeHeaders(values: string[] = []): [string, string][] { const seen = new Set(); diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx new file mode 100644 index 000000000..43b331367 --- /dev/null +++ b/src/handlers/gateway/invoke/screen.tsx @@ -0,0 +1,536 @@ +import { randomUUID } from "node:crypto"; +import { ServiceException } from "@smithy/core/client"; +import { useQuery } from "@tanstack/react-query"; +import cliTruncate from "cli-truncate"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { GatewayPicker } from "../../../components/GatewayPicker"; +import { Layout } from "../../../components/Layout"; +import { MultilineInput } from "../../../components/MultilineInput"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { Divider } from "../../../components/ui/divider"; +import { Spinner } from "../../../components/ui/spinner"; +import { TextInput } from "../../../components/ui/text-input"; +import { classifyStreamingResponse } from "../../../io"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; +import type { GatewayInvokeRequest, GatewayInvokeResponse } from "../types"; +import { GatewayInvokeLaunchContextKey, type GatewayInvokeLaunchContext } from "./launchContext"; +import { normalizeGatewayInvokeRequest } from "./request"; + +const theme = darkTheme; +const ACCEPT = "application/json, text/event-stream, */*;q=0.1"; + +type ExchangeState = "connecting" | "streaming" | "complete" | "interrupted" | "failed"; + +type ErrorDetails = { + name: string; + message?: string; + statusCode?: number; + requestId?: string; +}; + +type Exchange = { + payload: string; + response: string; + error?: ErrorDetails; + pretty?: string; + note?: string; + heading?: string; + metadata?: string; + byteCount: number; + state: ExchangeState; +}; + +const invokePath = (...parts: string[]) => + ["/agentcore/gateway/invoke", ...parts.map(encodeURIComponent)].join("/"); + +const metadata = (response: GatewayInvokeResponse) => + [ + ["Session ID:", response.runtimeSessionId], + ["MCP session ID:", response.mcpSessionId], + ["MCP protocol version:", response.mcpProtocolVersion], + ["Request ID:", response.requestId], + ] + .filter((entry) => entry[1]) + .map((entry) => entry.join(" ")) + .join(" · "); + +function errorDetails(error: unknown): ErrorDetails { + const reported = error instanceof Error ? error : new Error(String(error)); + const display = ServiceException.isInstance(reported.cause) ? reported.cause : reported; + return { + name: display.name, + message: display.message || undefined, + ...(ServiceException.isInstance(display) && { + statusCode: display.$metadata.httpStatusCode, + requestId: display.$metadata.requestId, + }), + }; +} + +function ErrorBlock({ details }: { details: ErrorDetails }) { + return ( + + + {details.name} + {details.statusCode ? ` · HTTP ${details.statusCode}` : ""} + + {details.message ? {details.message} : null} + {details.requestId ? ( + Request ID: {details.requestId} + ) : null} + + ); +} + +function PathEditor({ + gatewayId, + value, + onChange, + onSave, + onCancel, +}: { + gatewayId: string; + value: string; + onChange: (value: string) => void; + onSave: () => void; + onCancel: () => void; +}) { + useInput((_input, key) => { + if (key.escape) onCancel(); + }); + + return ( + + + + + + ); +} + +export function GatewayInvokeScreen(props: ScreenProps) { + const { gatewayId } = useParams(); + const navigate = useNavigate(); + const launchContext = props.ctx.value(GatewayInvokeLaunchContextKey); + const initialContext = launchContext?.gatewayId === gatewayId ? launchContext : undefined; + + if (!gatewayId) { + return ( + navigate(invokePath(id))} + /> + ); + } + + return ; +} + +type GatewayInvokeConsoleProps = ScreenProps & { + gatewayId: string; + initialContext?: GatewayInvokeLaunchContext; +}; + +function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayInvokeConsoleProps) { + const opts = coreOptsFromCtx(ctx); + const { columns, rows } = useWindowSize(); + const [targetGatewayId, setTargetGatewayId] = useState(gatewayId); + const [pickingGateway, setPickingGateway] = useState(false); + const [editingPath, setEditingPath] = useState(false); + const [path, setPath] = useState(initialContext?.path ?? ""); + const [pathDraft, setPathDraft] = useState(path); + const [payload, setPayload] = useState(""); + const [inputError, setInputError] = useState(); + const [requestContext, setRequestContext] = useState(initialContext); + const [runtimeSessionId, setRuntimeSessionId] = useState( + () => initialContext?.runtimeSessionId ?? randomUUID(), + ); + const [mcpSessionId, setMcpSessionId] = useState(initialContext?.mcpSessionId); + const [mcpProtocolVersion, setMcpProtocolVersion] = useState(initialContext?.mcpProtocolVersion); + const [history, setHistory] = useState([]); + const [prettyJson, setPrettyJson] = useState(false); + const abortRef = useRef(null); + const scrollRef = useRef(null); + const stickRef = useRef(true); + const detail = useQuery({ + queryKey: ["gateway", opts.region, targetGatewayId], + queryFn: ({ signal }) => core.gateway.getGateway(targetGatewayId, opts, signal), + }); + const missingBearerToken = + detail.data?.authorizerType === "CUSTOM_JWT" && !requestContext?.bearerToken; + const unavailableStatus = + detail.data?.status !== undefined && detail.data.status !== "READY" + ? detail.data.status + : undefined; + + const keepScrolledToBottom = useCallback(() => { + if (stickRef.current) scrollRef.current?.scrollToBottom(); + }, []); + + useEffect(() => () => abortRef.current?.abort(), []); + + const updateExchange = (patch: Partial) => { + setHistory((current) => current.slice(0, -1).concat({ ...current.at(-1)!, ...patch })); + }; + + const send = async () => { + if (abortRef.current || !detail.data || missingBearerToken || unavailableStatus) return; + const requestPayload = payload; + try { + JSON.parse(requestPayload); + } catch { + setInputError("Enter a valid JSON payload"); + return; + } + + let request: GatewayInvokeRequest; + try { + request = normalizeGatewayInvokeRequest(detail.data, { + gatewayId: targetGatewayId, + path: path || undefined, + method: "POST", + payload: new TextEncoder().encode(requestPayload), + contentType: "application/json", + accept: ACCEPT, + applicationHeaders: requestContext?.applicationHeaders, + bearerToken: requestContext?.bearerToken, + runtimeSessionId, + mcpSessionId, + mcpProtocolVersion, + }); + } catch (error) { + const details = errorDetails(error); + setInputError(details.message ?? details.name); + return; + } + + setInputError(undefined); + stickRef.current = true; + setPayload(""); + setHistory((current) => [ + ...current, + { payload: requestPayload, response: "", byteCount: 0, state: "connecting" }, + ]); + setPrettyJson(false); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const response = await core.gateway.invokeGateway(request, opts, controller.signal); + updateExchange({ + heading: `Response · ${response.statusCode} · ${response.contentType || "-"}`, + metadata: metadata(response), + state: "streaming", + }); + const kind = classifyStreamingResponse(response.contentType); + if (kind === "binary") { + controller.abort(); + updateExchange({ + response: "Binary or unknown responses require headless invoke with --output-file.", + state: "failed", + }); + return; + } + + let byteCount = 0; + const decoder = new TextDecoder("utf-8", { fatal: true }); + let responseText = ""; + for await (const chunk of response.body) { + const snapshot = Uint8Array.from(chunk); + byteCount += snapshot.byteLength; + try { + responseText += decoder.decode(snapshot, { stream: true }); + } catch { + controller.abort(); + updateExchange({ + response: responseText, + byteCount, + note: "Non-renderable responses require headless invoke with --output-file.", + state: "failed", + }); + return; + } + updateExchange({ response: responseText, byteCount }); + } + try { + responseText += decoder.decode(); + } catch { + controller.abort(); + updateExchange({ + response: responseText, + byteCount, + note: "Non-renderable responses require headless invoke with --output-file.", + state: "failed", + }); + return; + } + updateExchange({ response: responseText }); + + if (kind === "json") { + try { + updateExchange({ pretty: JSON.stringify(JSON.parse(responseText), null, 2) }); + } catch { + updateExchange({ note: "Invalid JSON response; showing raw text." }); + } + } + + const success = response.statusCode >= 200 && response.statusCode < 300; + if (response.runtimeSessionId) setRuntimeSessionId(response.runtimeSessionId); + if (response.mcpSessionId) setMcpSessionId(response.mcpSessionId); + if (response.mcpProtocolVersion) setMcpProtocolVersion(response.mcpProtocolVersion); + updateExchange({ + ...(success ? {} : { note: `HTTP ${response.statusCode}` }), + state: success ? "complete" : "failed", + }); + } catch (error) { + if (controller.signal.aborted || (error as Error)?.name === "AbortError") { + updateExchange({ note: "interrupted", state: "interrupted" }); + } else { + updateExchange({ error: errorDetails(error), state: "failed" }); + } + } finally { + abortRef.current = null; + } + }; + + const resetSessions = () => { + setRuntimeSessionId(randomUUID()); + setMcpSessionId(undefined); + setMcpProtocolVersion(undefined); + }; + + const savePath = () => { + if (pathDraft !== path) { + setPath(pathDraft); + resetSessions(); + setHistory([]); + setPrettyJson(false); + setInputError(undefined); + } + setEditingPath(false); + }; + + const selectGateway = (selectedGatewayId: string) => { + if (selectedGatewayId !== targetGatewayId) { + setTargetGatewayId(selectedGatewayId); + setPath(""); + setPathDraft(""); + setPayload(""); + setRequestContext(undefined); + resetSessions(); + setHistory([]); + setPrettyJson(false); + setInputError(undefined); + } + setPickingGateway(false); + }; + + const liveState = history.at(-1)?.state; + const busy = liveState === "connecting" || liveState === "streaming"; + const inputRows = Math.min(4, Math.max(1, payload.split("\n").length)); + const transcriptHeight = Math.max(1, rows - 8 - inputRows); + const canPrettyJson = history.some((exchange) => exchange.pretty !== undefined); + const requestContextSummary = [ + requestContext?.bearerToken ? "JWT" : undefined, + requestContext?.applicationHeaders?.length + ? `${requestContext.applicationHeaders.length}h` + : undefined, + ] + .filter(Boolean) + .join("/"); + + useInput( + (input, key) => { + if (key.ctrl) { + if (input === "v" && !abortRef.current) setPrettyJson((current) => !current); + else if (input === "t" && !abortRef.current) setPickingGateway(true); + else if (input === "p" && !abortRef.current) { + setPathDraft(path); + setEditingPath(true); + } + return; + } + if (key.escape) { + if (abortRef.current) abortRef.current.abort(); + else setPickingGateway(true); + return; + } + const view = scrollRef.current; + if (!view) return; + if (key.upArrow) { + const offset = view.getScrollOffset(); + const next = Math.max(0, offset - 1); + view.scrollTo(next); + if (next < view.getBottomOffset()) stickRef.current = false; + } + if (key.downArrow) { + const offset = view.getScrollOffset(); + const bottom = view.getBottomOffset(); + const next = Math.min(bottom, offset + 1); + view.scrollTo(next); + if (next >= bottom) stickRef.current = true; + } + }, + { isActive: !pickingGateway && !editingPath }, + ); + + if (pickingGateway) { + return ( + setPickingGateway(false)} + /> + ); + } + + if (editingPath) { + return ( + setEditingPath(false)} + /> + ); + } + + return ( + + + {detail.isPending ? ( + + ) : detail.isError ? ( + + ) : ( + + + + {history.map((exchange, index) => ( + + Request + {exchange.payload} + {exchange.heading ?? "Response"} + + {(prettyJson && exchange.pretty + ? exchange.pretty + : exchange.response + ).replace(/[\r\n]+$/, "")} + + {exchange.state !== "connecting" && exchange.state !== "streaming" ? ( + <> + {exchange.metadata ? ( + {exchange.metadata} + ) : null} + {exchange.error ? : null} + {exchange.note ? ( + {exchange.note} + ) : null} + + {exchange.state} · {exchange.byteCount} bytes + + + ) : null} + + ))} + + + + { + setPayload(value); + setInputError(undefined); + }} + onSubmit={() => void send()} + placeholder="Enter JSON payload" + submitDisabled={busy || missingBearerToken || unavailableStatus !== undefined} + /> + + + {inputError ? ( + {inputError} + ) : missingBearerToken ? ( + + CUSTOM_JWT Gateway requires --bearer-token; relaunch with the flag. + + ) : unavailableStatus ? ( + + Gateway is {unavailableStatus}; invocation requires READY. + + ) : busy ? ( + + ) : ( + <> + + {cliTruncate( + `Ready · Session ID: ${runtimeSessionId} · Path: ${path || "default"}`, + columns, + )} + + + {cliTruncate( + `Auth: ${detail.data?.authorizerType ?? "-"}` + + `${requestContextSummary ? ` · Context: ${requestContextSummary}` : ""}` + + `${mcpSessionId ? ` · MCP session ID: ${mcpSessionId}` : ""}`, + columns, + )} + + + )} + + + )} + + + ); +} diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index eab299d8c..b73cc7862 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -9,6 +9,7 @@ import cliTruncate from "cli-truncate"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import { Layout } from "../../../components/Layout"; +import { MultilineInput } from "../../../components/MultilineInput"; import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker"; import { RuntimePicker } from "../../../components/RuntimePicker"; import { darkTheme } from "../../../components/ui/_core.js"; @@ -16,7 +17,6 @@ import { Divider } from "../../../components/ui/divider"; import { Spinner } from "../../../components/ui/spinner"; import type { RuntimeInvokeResponse } from "../types"; import { normalizeRuntimeInvokeRequest } from "./request"; -import { RuntimePayloadInput } from "./RuntimePayloadInput"; import { classifyRuntimeResponse } from "./response"; import { RuntimeInvokeLaunchContextKey, type RuntimeInvokeLaunchContext } from "./launchContext"; @@ -435,13 +435,14 @@ function RuntimeInvokeConsole({ - { setPayload(value); setInputError(undefined); }} onSubmit={() => void send()} + placeholder="Enter JSON payload" submitDisabled={busy} /> From 900d0af83431a22d6624f7df396e214c83064cac Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 11 Aug 2026 23:06:55 +0000 Subject: [PATCH 2/9] fix(tui): keep long multiline input within its rows --- src/components/MultilineInput.tsx | 22 +++++++++++++------ .../gateway/invoke/invoke.screen.test.tsx | 21 ++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/MultilineInput.tsx b/src/components/MultilineInput.tsx index e37660e5f..caa9c573f 100644 --- a/src/components/MultilineInput.tsx +++ b/src/components/MultilineInput.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { Box, Text, useInput } from "ink"; +import cliTruncate from "cli-truncate"; +import { Box, Text, useInput, useWindowSize } from "ink"; import { darkTheme } from "./ui/_core.js"; const theme = darkTheme; @@ -28,6 +29,7 @@ export function MultilineInput({ placeholder = "Enter text", submitDisabled = false, }: MultilineInputProps) { + const { columns } = useWindowSize(); const [rawCursor, setRawCursor] = useState(value.length); const cursor = Math.min(rawCursor, value.length); @@ -89,19 +91,25 @@ export function MultilineInput({ const prefix = index === 0 && start > 0 ? "… " : ""; if (lineIndex !== cursorLine) { return ( - - {prefix} - {line || " "} + + {cliTruncate(`${prefix}${line || " "}`, columns)} ); } - const before = line.slice(0, cursorColumn); + const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : ""; + const available = Math.max(1, columns - prefix.length - horizontalMarker.length); + const offset = Math.max(0, cursorColumn - available + 1); + const before = line.slice(offset, cursorColumn); const at = line[cursorColumn] ?? " "; - const after = line.slice(cursorColumn + 1); + const after = line.slice( + cursorColumn + 1, + cursorColumn + 1 + Math.max(0, available - before.length - 1), + ); return ( - + {prefix} + {horizontalMarker} {before ? {before} : null} {after ? {after} : null} diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx index 94ae5717e..86923dfc7 100644 --- a/src/handlers/gateway/invoke/invoke.screen.test.tsx +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -718,6 +718,27 @@ describe("Gateway invoke JSON console", () => { expect(screen.lastFrame()).toContain("[esc] back"); }); + test("horizontally windows long single-line JSON without corrupting status rows", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen(CONSOLE_PATH, { core }); + await screen.resize(100, 24); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write( + JSON.stringify({ + model: "provider/model", + messages: [{ role: "user", content: "x".repeat(180) }], + }), + ); + + const frame = screen.lastFrame()!; + expect(frame.split("\n")).toHaveLength(24); + expect(frame).toContain("Ready · Session ID:"); + expect(frame).toContain("Auth: NONE"); + expect(frame).not.toContain("NONEon ID"); + }); + test("scrolls completed response history", async () => { const response = Array.from({ length: 12 }, (_, index) => `response-line-${index}`).join("\n"); const core = new TestCoreClient(); From a53685139d5c52165b88883eade44fc1574e028b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 12 Aug 2026 01:49:18 +0000 Subject: [PATCH 3/9] fix(gateway): refine path editor and session labels --- src/components/MultilineInput.tsx | 63 ++++++----- .../gateway/invoke/invoke.screen.test.tsx | 47 +++----- src/handlers/gateway/invoke/screen.tsx | 104 ++++++++++-------- 3 files changed, 113 insertions(+), 101 deletions(-) diff --git a/src/components/MultilineInput.tsx b/src/components/MultilineInput.tsx index caa9c573f..3fe70fc92 100644 --- a/src/components/MultilineInput.tsx +++ b/src/components/MultilineInput.tsx @@ -12,6 +12,7 @@ export interface MultilineInputProps { onSubmit: () => void; placeholder?: string; submitDisabled?: boolean; + focus?: boolean; } function Cursor({ character }: { character: string }) { @@ -28,44 +29,48 @@ export function MultilineInput({ onSubmit, placeholder = "Enter text", submitDisabled = false, + focus = true, }: MultilineInputProps) { const { columns } = useWindowSize(); const [rawCursor, setRawCursor] = useState(value.length); const cursor = Math.min(rawCursor, value.length); - useInput((input, key) => { - if (key.leftArrow) { - setRawCursor(Math.max(0, cursor - 1)); - return; - } - if (key.rightArrow) { - setRawCursor(Math.min(value.length, cursor + 1)); - return; - } - if (key.upArrow || key.downArrow) return; + useInput( + (input, key) => { + if (key.leftArrow) { + setRawCursor(Math.max(0, cursor - 1)); + return; + } + if (key.rightArrow) { + setRawCursor(Math.min(value.length, cursor + 1)); + return; + } + if (key.upArrow || key.downArrow) return; - if (key.backspace || key.delete) { - if (cursor === 0) return; - onChange(value.slice(0, cursor - 1) + value.slice(cursor)); - setRawCursor(cursor - 1); - return; - } + if (key.backspace || key.delete) { + if (cursor === 0) return; + onChange(value.slice(0, cursor - 1) + value.slice(cursor)); + setRawCursor(cursor - 1); + return; + } - if (key.return) { - if (key.shift || key.meta) { - onChange(value.slice(0, cursor) + "\n" + value.slice(cursor)); - setRawCursor(cursor + 1); - } else if (!submitDisabled) { - onSubmit(); + if (key.return) { + if (key.shift || key.meta) { + onChange(value.slice(0, cursor) + "\n" + value.slice(cursor)); + setRawCursor(cursor + 1); + } else if (!submitDisabled) { + onSubmit(); + } + return; } - return; - } - if (key.ctrl || key.meta || key.escape || input === "") return; + if (key.ctrl || key.meta || key.escape || input === "") return; - const next = input.replace(/\r/g, "\n"); - onChange(value.slice(0, cursor) + next + value.slice(cursor)); - setRawCursor(cursor + next.length); - }); + const next = input.replace(/\r/g, "\n"); + onChange(value.slice(0, cursor) + next + value.slice(cursor)); + setRawCursor(cursor + next.length); + }, + { isActive: focus }, + ); if (value === "") { return ( diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx index 86923dfc7..79b36287a 100644 --- a/src/handlers/gateway/invoke/invoke.screen.test.tsx +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -55,7 +55,7 @@ function invokeRequests(core: TestCoreClient): GatewayInvokeRequest[] { } function displayedSessionId(frame: string | undefined): string | undefined { - return frame?.match(/Session ID: ([^ ·\n]+)/)?.[1]; + return frame?.match(/Runtime session ID: ([^ ·\n]+)/)?.[1]; } describe("Gateway invoke routing", () => { @@ -69,7 +69,7 @@ describe("Gateway invoke routing", () => { await waitForText(screen.lastFrame, `agentcore → gateway → invoke → ${GATEWAY_ID}`); await waitForText(screen.lastFrame, "Enter JSON payload"); - expect(screen.lastFrame()).toContain("Path: default"); + expect(screen.lastFrame()).toContain("Path: /mcp (Gateway URL)"); expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); }); @@ -211,7 +211,7 @@ describe("Gateway invoke JSON console", () => { }); await waitForText(screen.lastFrame, "Path: runtime/invocations"); - expect(screen.lastFrame()).toContain("Session ID: seeded-runtime"); + expect(screen.lastFrame()).toContain("Runtime session ID: seeded-runtime"); expect(screen.lastFrame()).toContain("Context: JWT/1h"); expect(screen.lastFrame()).not.toContain(token); expect(screen.lastFrame()).not.toContain("retail"); @@ -269,28 +269,16 @@ describe("Gateway invoke JSON console", () => { const initialSession = displayedSessionId(screen.lastFrame()); await screen.write('{"turn":1}'); await screen.write("\x10"); - await waitForText(screen.lastFrame, "edit the Gateway-relative path"); + await waitForText(screen.lastFrame, "Edit path"); + expect(screen.lastFrame()).toContain("Ready · Runtime session ID:"); + expect(screen.lastFrame()).toContain('{"turn":1}'); + expect(screen.lastFrame()).toContain("[enter] save"); + expect(screen.lastFrame()).not.toContain("→ path →"); await screen.write("ignored/path"); await screen.press("escape"); - await waitForText(screen.lastFrame, "Path: default"); + await waitForText(screen.lastFrame, "Path: /mcp (Gateway URL)"); expect(screen.lastFrame()).toContain('{"turn":1}'); expect(displayedSessionId(screen.lastFrame())).toBe(initialSession); - - await screen.write("\x10"); - await screen.write("runtime/invocations?trace=true"); - await screen.press("return"); - await waitForText(screen.lastFrame, "Path: runtime/invocations?trace=true"); - expect(screen.lastFrame()).toContain('{"turn":1}'); - const nextSession = displayedSessionId(screen.lastFrame()); - expect(nextSession).toMatch(UUID_PATTERN); - expect(nextSession).not.toBe(initialSession); - - await screen.press("return"); - await waitFor(() => invokeRequests(core).length === 1); - expect(invokeRequests(core)[0]).toMatchObject({ - url: "https://gateway-123.gateway.example.test/runtime/invocations?trace=true", - runtimeSessionId: nextSession, - }); }); test("clears a seeded path back to the exact Gateway URL", async () => { @@ -313,8 +301,9 @@ describe("Gateway invoke JSON console", () => { await screen.write("\x7f"); } await screen.press("return"); + await waitForText(screen.lastFrame, "[enter] send"); - await waitForText(screen.lastFrame, "Path: default"); + await waitForText(screen.lastFrame, "Path: /mcp (Gateway URL)"); expect(displayedSessionId(screen.lastFrame())).toMatch(UUID_PATTERN); expect(displayedSessionId(screen.lastFrame())).not.toBe("seeded-session"); }); @@ -342,7 +331,7 @@ describe("Gateway invoke JSON console", () => { await waitForText(screen.lastFrame, "Ready"); await screen.write('{"turn":1}'); await screen.press("return"); - await waitForText(screen.lastFrame, "Session ID: returned-runtime"); + await waitForText(screen.lastFrame, "Runtime session ID: returned-runtime"); await screen.write('{"turn":2}'); await screen.write("\x10"); @@ -410,7 +399,7 @@ describe("Gateway invoke JSON console", () => { await waitForText(screen.lastFrame, `agentcore → gateway → invoke → ${nextGatewayId}`); await waitForText(screen.lastFrame, "Ready"); - expect(screen.lastFrame()).toContain("Path: default"); + expect(screen.lastFrame()).toContain("Path: /mcp (Gateway URL)"); expect(screen.lastFrame()).not.toContain("old response"); expect(screen.lastFrame()).not.toContain('{"draft":true}'); expect(screen.lastFrame()).not.toContain("returned-mcp"); @@ -442,7 +431,7 @@ describe("Gateway invoke JSON console", () => { await screen.press("escape"); await waitForText(screen.lastFrame, "Path: kept/path"); - expect(screen.lastFrame()).toContain("Session ID: kept-session"); + expect(screen.lastFrame()).toContain("Runtime session ID: kept-session"); expect(screen.lastFrame()).toContain('{"kept":"draft"}'); expect(screen.lastFrame()).toContain("Context: JWT/1h"); }); @@ -475,7 +464,7 @@ describe("Gateway invoke JSON console", () => { release.resolve(); await waitForText(screen.lastFrame, "data: second"); await waitForText(screen.lastFrame, "complete · 25 bytes"); - expect(screen.lastFrame()).toContain("Session ID: returned-runtime"); + expect(screen.lastFrame()).toContain("Runtime session ID: returned-runtime"); expect(screen.lastFrame()).toContain("MCP session ID: returned-mcp"); expect(initialSession).not.toBe("returned-runtime"); @@ -698,8 +687,8 @@ describe("Gateway invoke JSON console", () => { const readyLine = screen .lastFrame()! .split("\n") - .find((line) => line.includes("Ready · Session ID:")); - expect(readyLine).toContain(`Session ID: ${initialSession}`); + .find((line) => line.includes("Ready · Runtime session ID:")); + expect(readyLine).toContain(`Runtime session ID: ${initialSession}`); expect(readyLine).not.toContain("interrupted-mcp"); }); @@ -734,7 +723,7 @@ describe("Gateway invoke JSON console", () => { const frame = screen.lastFrame()!; expect(frame.split("\n")).toHaveLength(24); - expect(frame).toContain("Ready · Session ID:"); + expect(frame).toContain("Ready · Runtime session ID:"); expect(frame).toContain("Auth: NONE"); expect(frame).not.toContain("NONEon ID"); }); diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 43b331367..5df8f17a0 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -49,7 +49,7 @@ const invokePath = (...parts: string[]) => const metadata = (response: GatewayInvokeResponse) => [ - ["Session ID:", response.runtimeSessionId], + ["Runtime session ID:", response.runtimeSessionId], ["MCP session ID:", response.mcpSessionId], ["MCP protocol version:", response.mcpProtocolVersion], ["Request ID:", response.requestId], @@ -58,6 +58,16 @@ const metadata = (response: GatewayInvokeResponse) => .map((entry) => entry.join(" ")) .join(" · "); +function displayPath(path: string, gatewayUrl?: string): string { + if (path) return path; + try { + const url = new URL(gatewayUrl ?? ""); + return `${url.pathname || "/"}${url.search} (Gateway URL)`; + } catch { + return "Gateway URL"; + } +} + function errorDetails(error: unknown): ErrorDetails { const reported = error instanceof Error ? error : new Error(String(error)); const display = ServiceException.isInstance(reported.cause) ? reported.cause : reported; @@ -87,42 +97,44 @@ function ErrorBlock({ details }: { details: ErrorDetails }) { } function PathEditor({ - gatewayId, value, onChange, onSave, onCancel, }: { - gatewayId: string; value: string; onChange: (value: string) => void; onSave: () => void; onCancel: () => void; }) { + const { columns } = useWindowSize(); useInput((_input, key) => { if (key.escape) onCancel(); }); return ( - - + + + + Edit path + - + ); } @@ -407,42 +419,36 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI ); } - if (editingPath) { - return ( - setEditingPath(false)} - /> - ); - } - return ( - + {detail.isPending ? ( ) : detail.isError ? ( @@ -494,6 +500,7 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI onSubmit={() => void send()} placeholder="Enter JSON payload" submitDisabled={busy || missingBearerToken || unavailableStatus !== undefined} + focus={!editingPath} /> @@ -513,7 +520,10 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI <> {cliTruncate( - `Ready · Session ID: ${runtimeSessionId} · Path: ${path || "default"}`, + `Ready · Runtime session ID: ${runtimeSessionId} · Path: ${displayPath( + path, + detail.data?.gatewayUrl, + )}`, columns, )} @@ -530,6 +540,14 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI )} + {editingPath ? ( + setEditingPath(false)} + /> + ) : null} ); From cad95f340bc466902e37aa91752bcc6d2970e023 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 12 Aug 2026 02:20:26 +0000 Subject: [PATCH 4/9] style(gateway): use neutral path dialog border --- src/handlers/gateway/invoke/screen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 5df8f17a0..57e40e7a1 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -118,7 +118,7 @@ function PathEditor({ width={Math.max(32, Math.min(72, columns - 4))} flexDirection="column" borderStyle="round" - borderColor={theme.colors.focus} + borderColor={theme.colors.border} backgroundColor="black" paddingX={1} paddingY={1} From 418fe96631512743343ecb01dfc929e244ced708 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 19:15:09 +0000 Subject: [PATCH 5/9] fix(gateway): make idle escape navigate back --- .../gateway/invoke/invoke.screen.test.tsx | 15 +++++++++++++++ src/handlers/gateway/invoke/screen.tsx | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx index 79b36287a..45b0905aa 100644 --- a/src/handlers/gateway/invoke/invoke.screen.test.tsx +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -99,6 +99,21 @@ describe("Gateway invoke routing", () => { pendingScreen.unmount(); await waitFor(() => signal!.aborted); }); + + 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 = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a Gateway to invoke"); + await waitForText(screen.lastFrame, "checkout-gateway"); + expect(screen.lastFrame()).toContain("checkout-gateway"); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "inspect AgentCore Gateways"); + }); }); describe("Gateway invoke JSON console", () => { diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 57e40e7a1..0e3d9a606 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -164,6 +164,7 @@ type GatewayInvokeConsoleProps = ScreenProps & { }; function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayInvokeConsoleProps) { + const navigate = useNavigate(); const opts = coreOptsFromCtx(ctx); const { columns, rows } = useWindowSize(); const [targetGatewayId, setTargetGatewayId] = useState(gatewayId); @@ -384,7 +385,7 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI } if (key.escape) { if (abortRef.current) abortRef.current.abort(); - else setPickingGateway(true); + else navigate(invokePath()); return; } const view = scrollRef.current; From fdbfe3a1e291f5414d353cee6c484f2da2ed96c7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 20:34:49 +0000 Subject: [PATCH 6/9] fix(gateway): allow no-content responses in TUI --- .../gateway/invoke/invoke.screen.test.tsx | 21 +++++++++++++++++++ src/handlers/gateway/invoke/screen.tsx | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/handlers/gateway/invoke/invoke.screen.test.tsx b/src/handlers/gateway/invoke/invoke.screen.test.tsx index 45b0905aa..d072ec9d8 100644 --- a/src/handlers/gateway/invoke/invoke.screen.test.tsx +++ b/src/handlers/gateway/invoke/invoke.screen.test.tsx @@ -635,6 +635,27 @@ describe("Gateway invoke JSON console", () => { expect(screen.lastFrame()).toContain("failed · 7 bytes"); }); + test.each([204, 205])( + "accepts HTTP %s without a content type or response body", + async (statusCode) => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()).setInvokeResponse({ + statusCode, + contentType: "", + body: responseBody(), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "complete · 0 bytes"); + expect(screen.lastFrame()).toContain(`Response · ${statusCode} · -`); + expect(screen.lastFrame()).not.toContain("Binary or unknown responses"); + }, + ); + test("rejects binary console responses without consuming their bodies", async () => { let iterations = 0; const core = new TestCoreClient(); diff --git a/src/handlers/gateway/invoke/screen.tsx b/src/handlers/gateway/invoke/screen.tsx index 0e3d9a606..15477ce3e 100644 --- a/src/handlers/gateway/invoke/screen.tsx +++ b/src/handlers/gateway/invoke/screen.tsx @@ -256,7 +256,7 @@ function GatewayInvokeConsole({ ctx, core, gatewayId, initialContext }: GatewayI state: "streaming", }); const kind = classifyStreamingResponse(response.contentType); - if (kind === "binary") { + if (response.statusCode !== 204 && response.statusCode !== 205 && kind === "binary") { controller.abort(); updateExchange({ response: "Binary or unknown responses require headless invoke with --output-file.", From 3e0bb8830a47f6f84aadfd0cf6e35aa39c52964b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 14 Aug 2026 17:26:49 +0000 Subject: [PATCH 7/9] refactor(invoke): simplify TUI launch errors --- src/handlers/gateway/invoke/index.tsx | 27 ++--- src/handlers/gateway/invoke/invoke.test.tsx | 109 +++++++++++--------- src/handlers/gateway/invoke/request.test.ts | 23 ++--- src/handlers/gateway/invoke/request.ts | 11 +- src/handlers/runtime/invoke/index.tsx | 28 ++--- src/handlers/runtime/invoke/invoke.test.tsx | 17 +-- src/tui/index.tsx | 5 +- src/tui/tui.test.tsx | 5 +- 8 files changed, 93 insertions(+), 132 deletions(-) diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx index 88e151751..f41830a70 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -3,9 +3,8 @@ import { GatewayInvokeInterruptedError, GatewayInvokeResponseError, InputValidationError, - InvalidEnvironmentError, } from "../../../errors"; -import type { AppIO } from "../../../io"; +import { SourceResolver, type AppIO } from "../../../io"; import { ExitCode } from "../../../runnable"; import { createHandler, flag, PathKey } from "../../../router"; import { renderTuiAt } from "../../../tui"; @@ -80,7 +79,7 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) => const applicationHeaders = parseGatewayInvokeHeaders(flags.header); const bearerToken = await resolveGatewayInvokeTuiBearerToken( flags["bearer-token"], - io.stdin, + new SourceResolver({ stdin: io.stdin }), ); const launchContext = { gatewayId: flags.id, @@ -91,22 +90,12 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) => applicationHeaders, bearerToken, }; - try { - await renderTuiAt( - `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, - ctx.withValue(GatewayInvokeLaunchContextKey, launchContext), - core, - io, - ); - } catch (error) { - if (error instanceof InvalidEnvironmentError) { - throw new InputValidationError(error.message, { - cause: error, - exitCode: ExitCode.USAGE, - }); - } - throw error; - } + await renderTuiAt( + `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, + ctx.withValue(GatewayInvokeLaunchContextKey, launchContext), + core, + io, + ); return; } } diff --git a/src/handlers/gateway/invoke/invoke.test.tsx b/src/handlers/gateway/invoke/invoke.test.tsx index b9a88492d..7c17aca48 100644 --- a/src/handlers/gateway/invoke/invoke.test.tsx +++ b/src/handlers/gateway/invoke/invoke.test.tsx @@ -1,22 +1,20 @@ -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"; import { PassThrough } from "node:stream"; import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import type { AppIO } from "../../../io"; -import { InvalidEnvironmentError } from "../../../errors"; import { ExitCode, runWithExitCode } from "../../../runnable"; -import * as tui from "../../../tui"; import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, + testIO, waitFor, } from "../../../testing"; import { createRootHandler } from "../../index"; import type { GatewayInvokeRequest } from "../types"; -import { GatewayInvokeLaunchContextKey } from "./launchContext"; const REGION = "us-west-2"; const GATEWAY_ID = "gateway-123"; @@ -48,6 +46,29 @@ function captureIO(input?: Uint8Array) { }; } +interface TtyInput extends NodeJS.ReadStream { + write(chunk: string): boolean; +} + +function ttyTestIO() { + const streams = testIO({ isTTY: true }); + const stdin = streams.io.stdin as TtyInput; + stdin.setRawMode = function () { + return this; + }; + stdin.ref = function () { + return this; + }; + stdin.unref = function () { + return this; + }; + Object.defineProperties(streams.io.stdout, { + columns: { configurable: true, value: 100 }, + rows: { configurable: true, value: 40 }, + }); + return { streams, stdin }; +} + async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Promise { const root = createRootHandler(core, { io, @@ -394,43 +415,37 @@ describe("gateway invoke", () => { test("deep-links an id-only invoke and seeds interactive request context", async () => { const core = configuredCore(); - const output = captureIO(); - const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); - + const { streams, stdin } = ttyTestIO(); + const route = runCommand(core, streams.io, [ + "gateway", + "invoke", + "--id", + "gateway/blue one", + "--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", + ]); try { - await runCommand(core, output.io, [ - "gateway", - "invoke", - "--id", - "gateway/blue one", - "--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", - ]); - - expect(render).toHaveBeenCalledTimes(1); - expect(render.mock.calls[0]![0]).toBe("/agentcore/gateway/invoke/gateway%2Fblue%20one"); - expect(render.mock.calls[0]![1].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(core.gateway.calls).toEqual([]); + await waitFor(() => streams.stdout().includes("Path: runtime/invocations?trace=true")); + expect(streams.stdout()).toContain("Runtime session ID: runtime-session"); + expect(streams.stdout()).toContain("MCP session ID: mcp-session"); + expect(streams.stdout()).toContain("Context: JWT/1h"); + expect(streams.stdout()).not.toContain("secret-token"); + expect(streams.stdout()).not.toContain("retail"); + expect(core.gateway.calls.some((call) => call.method === "getGateway")).toBe(true); + expect(core.gateway.calls.some((call) => call.method === "invokeGateway")).toBe(false); } finally { - render.mockRestore(); + stdin.write(String.fromCharCode(3)); + await route; } }); @@ -447,20 +462,12 @@ describe("gateway invoke", () => { test("classifies an unavailable interactive environment as usage", async () => { const core = configuredCore(); const output = captureIO(); - const render = spyOn(tui, "renderTuiAt").mockRejectedValue( - new InvalidEnvironmentError("interactive mode requires a TTY on stdin and stdout"), + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID]), ); - try { - const code = await runWithExitCode(async () => - runCommand(core, output.io, ["gateway", "invoke", "--id", GATEWAY_ID]), - ); - - expect(code).toBe(ExitCode.USAGE); - expect(core.gateway.calls).toEqual([]); - } finally { - render.mockRestore(); - } + expect(code).toBe(ExitCode.USAGE); + expect(core.gateway.calls).toEqual([]); }); test("SIGINT aborts lookup and invocation through the same signal", async () => { diff --git a/src/handlers/gateway/invoke/request.test.ts b/src/handlers/gateway/invoke/request.test.ts index 7f2978df7..6da524963 100644 --- a/src/handlers/gateway/invoke/request.test.ts +++ b/src/handlers/gateway/invoke/request.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, spyOn, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { PassThrough } from "node:stream"; import type { GetGatewayResponse } from "@aws-sdk/client-bedrock-agentcore-control"; -import { InputValidationError, SourceResolutionError } from "../../../errors"; +import { SourceResolutionError } from "../../../errors"; import { SourceResolver } from "../../../io"; import { normalizeGatewayInvokeRequest, @@ -68,24 +68,13 @@ describe("Gateway invoke sources", () => { }); }); -test("brands TUI bearer-token file failures as input errors", async () => { +test("preserves TUI bearer-token source errors", async () => { const missing = `file:///tmp/missing-gateway-token-${process.pid}`; - const error = await resolveGatewayInvokeTuiBearerToken(missing, stdin()).catch((error) => error); + const resolver = new SourceResolver({ stdin: stdin() }); + const error = await resolveGatewayInvokeTuiBearerToken(missing, resolver).catch((error) => error); - expect(error).toBeInstanceOf(InputValidationError); + expect(error).toBeInstanceOf(SourceResolutionError); expect(error.message).toContain("could not read '--bearer-token' from file"); - expect(error.cause).toBeInstanceOf(SourceResolutionError); -}); - -test("preserves unexpected TUI bearer-token source failures", async () => { - const failure = new TypeError("source failed"); - const resolve = spyOn(SourceResolver.prototype, "resolveText").mockRejectedValue(failure); - - try { - await expect(resolveGatewayInvokeTuiBearerToken("token", stdin())).rejects.toBe(failure); - } finally { - resolve.mockRestore(); - } }); describe("Gateway invoke headers", () => { diff --git a/src/handlers/gateway/invoke/request.ts b/src/handlers/gateway/invoke/request.ts index 4d84536ec..0ae5aad7a 100644 --- a/src/handlers/gateway/invoke/request.ts +++ b/src/handlers/gateway/invoke/request.ts @@ -64,21 +64,14 @@ export async function resolveGatewayInvokeSources( export async function resolveGatewayInvokeTuiBearerToken( source: string | undefined, - stdin: NodeJS.ReadStream, + resolver: SourceResolver, ): Promise { if (source === "-") { throw new InputValidationError( "stdin bearer tokens are not available when launching the interactive console", ); } - try { - return await new SourceResolver({ stdin }).resolveText("bearer-token", source); - } catch (error) { - if (error instanceof SourceResolutionError) { - throw new InputValidationError(error.message, { cause: error }); - } - throw error; - } + return resolver.resolveText("bearer-token", source); } export function parseGatewayInvokeHeaders(values: string[] = []): [string, string][] { diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 0110273a3..dc1ecb017 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -1,9 +1,5 @@ import z from "zod"; -import { - InputValidationError, - InvalidEnvironmentError, - RuntimeInvokeInterruptedError, -} from "../../../errors"; +import { InputValidationError, RuntimeInvokeInterruptedError } from "../../../errors"; import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; @@ -95,22 +91,12 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => applicationHeaders, bearerToken, }; - try { - await renderTuiAt( - path, - ctx.withValue(RuntimeInvokeLaunchContextKey, launchContext), - core, - io, - ); - } catch (error) { - if (error instanceof InvalidEnvironmentError) { - throw new InputValidationError(error.message, { - cause: error, - exitCode: ExitCode.USAGE, - }); - } - throw error; - } + await renderTuiAt( + path, + ctx.withValue(RuntimeInvokeLaunchContextKey, launchContext), + core, + io, + ); return; } diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index fa4d4859d..f76672ebc 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -12,7 +12,6 @@ import { waitFor, } from "../../../testing"; import { ExitCode, runWithExitCode } from "../../../runnable"; -import { InvalidEnvironmentError } from "../../../errors"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; import { RuntimeInvokeLaunchContextKey } from "./launchContext"; @@ -404,20 +403,12 @@ describe("runtime invoke", () => { test("classifies the TUI requirement as usage at the handler boundary", async () => { const core = new TestCoreClient(); const output = captureIO(); - const render = spyOn(tui, "renderTuiAt").mockRejectedValue( - new InvalidEnvironmentError("interactive mode requires a TTY on stdin and stdout"), + const code = await runWithExitCode(async () => + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), ); - try { - const code = await runWithExitCode(async () => - runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), - ); - - expect(code).toBe(ExitCode.USAGE); - expect(core.runtime.calls).toEqual([]); - } finally { - render.mockRestore(); - } + expect(code).toBe(ExitCode.USAGE); + expect(core.runtime.calls).toEqual([]); }); test("preserves unexpected TUI rendering failures", async () => { diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 8555a53f0..fc7d10eed 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -13,6 +13,7 @@ import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; import { JsonKey } from "../handlers/keys"; import { InvalidEnvironmentError } from "../errors"; +import { ExitCode } from "../runnable"; // renderJson pretty-prints a value as indented JSON. It is the output // counterpart to renderTui: handlers call it to emit machine-readable results @@ -47,7 +48,9 @@ export async function renderTuiAt( ctx.value(CommandRunMetricEventKey)?.setAttributes({ is_tui: true }); if (!io.stdin.isTTY || !io.stdout.isTTY) { - throw new InvalidEnvironmentError("interactive mode requires a TTY on stdin and stdout"); + throw new InvalidEnvironmentError("interactive mode requires a TTY on stdin and stdout", { + exitCode: ExitCode.USAGE, + }); } // alternateScreen switches the terminal to its alternate buffer so the TUI diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 8003df8ca..6238ac6cc 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -10,6 +10,7 @@ import { tick, waitFor, } from "../testing"; +import { ExitCode } from "../runnable"; interface TtyInput extends NodeJS.ReadStream { write(chunk: string): boolean; @@ -90,7 +91,9 @@ describe("TUI stream boundary", () => { globalConfigAccessor: new TestGlobalConfigAccessor(), }); - await expect(root.route(["node", "agentcore"])).rejects.toThrow(InvalidEnvironmentError); + const error = await root.route(["node", "agentcore"]).catch((error) => error); + expect(error).toBeInstanceOf(InvalidEnvironmentError); + expect(error.exitCode).toBe(ExitCode.USAGE); }, ); From 53ba0d3fd7cb46261354c1c490e9a2c8a396096d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 14 Aug 2026 17:29:30 +0000 Subject: [PATCH 8/9] test(gateway): allow TUI startup on slow runners --- src/handlers/gateway/invoke/invoke.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/gateway/invoke/invoke.test.tsx b/src/handlers/gateway/invoke/invoke.test.tsx index 7c17aca48..fbfdcea28 100644 --- a/src/handlers/gateway/invoke/invoke.test.tsx +++ b/src/handlers/gateway/invoke/invoke.test.tsx @@ -435,7 +435,7 @@ describe("gateway invoke", () => { "2025-06-18", ]); try { - await waitFor(() => streams.stdout().includes("Path: runtime/invocations?trace=true")); + await waitFor(() => streams.stdout().includes("Path: runtime/invocations?trace=true"), 5_000); expect(streams.stdout()).toContain("Runtime session ID: runtime-session"); expect(streams.stdout()).toContain("MCP session ID: mcp-session"); expect(streams.stdout()).toContain("Context: JWT/1h"); From 6489a5d78744cc719a9972e935180ec1d7264a75 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 14 Aug 2026 18:57:53 +0000 Subject: [PATCH 9/9] test(gateway): inject invoke TUI renderer --- src/handlers/gateway/invoke/index.tsx | 8 +- src/handlers/gateway/invoke/invoke.test.tsx | 102 +++++++++----------- 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/src/handlers/gateway/invoke/index.tsx b/src/handlers/gateway/invoke/index.tsx index f41830a70..de43905dc 100644 --- a/src/handlers/gateway/invoke/index.tsx +++ b/src/handlers/gateway/invoke/index.tsx @@ -22,7 +22,11 @@ import { import { writeGatewayInvokeResponse } from "./response"; import { GatewayInvokeLaunchContextKey } from "./launchContext"; -export const createInvokeGatewayHandler = (core: Core, io: AppIO) => +export const createInvokeGatewayHandler = ( + core: Core, + io: AppIO, + renderInvokeTui: typeof renderTuiAt = renderTuiAt, +) => createHandler({ name: "invoke", description: "invoke an AgentCore Gateway", @@ -90,7 +94,7 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) => applicationHeaders, bearerToken, }; - await renderTuiAt( + await renderInvokeTui( `${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`, ctx.withValue(GatewayInvokeLaunchContextKey, launchContext), core, diff --git a/src/handlers/gateway/invoke/invoke.test.tsx b/src/handlers/gateway/invoke/invoke.test.tsx index fbfdcea28..8bb8e4167 100644 --- a/src/handlers/gateway/invoke/invoke.test.tsx +++ b/src/handlers/gateway/invoke/invoke.test.tsx @@ -10,11 +10,14 @@ import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, - testIO, waitFor, } from "../../../testing"; +import { PathKey, ValueContext } from "../../../router"; import { createRootHandler } from "../../index"; +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"; @@ -46,29 +49,6 @@ function captureIO(input?: Uint8Array) { }; } -interface TtyInput extends NodeJS.ReadStream { - write(chunk: string): boolean; -} - -function ttyTestIO() { - const streams = testIO({ isTTY: true }); - const stdin = streams.io.stdin as TtyInput; - stdin.setRawMode = function () { - return this; - }; - stdin.ref = function () { - return this; - }; - stdin.unref = function () { - return this; - }; - Object.defineProperties(streams.io.stdout, { - columns: { configurable: true, value: 100 }, - rows: { configurable: true, value: 40 }, - }); - return { streams, stdin }; -} - async function runCommand(core: TestCoreClient, io: AppIO, args: string[]): Promise { const root = createRootHandler(core, { io, @@ -415,38 +395,48 @@ describe("gateway invoke", () => { test("deep-links an id-only invoke and seeds interactive request context", async () => { const core = configuredCore(); - const { streams, stdin } = ttyTestIO(); - const route = runCommand(core, streams.io, [ - "gateway", - "invoke", - "--id", - "gateway/blue one", - "--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", - ]); - try { - await waitFor(() => streams.stdout().includes("Path: runtime/invocations?trace=true"), 5_000); - expect(streams.stdout()).toContain("Runtime session ID: runtime-session"); - expect(streams.stdout()).toContain("MCP session ID: mcp-session"); - expect(streams.stdout()).toContain("Context: JWT/1h"); - expect(streams.stdout()).not.toContain("secret-token"); - expect(streams.stdout()).not.toContain("retail"); - expect(core.gateway.calls.some((call) => call.method === "getGateway")).toBe(true); - expect(core.gateway.calls.some((call) => call.method === "invokeGateway")).toBe(false); - } finally { - stdin.write(String.fromCharCode(3)); - await route; - } + 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", + 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 () => {