From ed563851a0df84822295d9417afb44197c99ab54 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 27 Jul 2026 18:27:39 +0000 Subject: [PATCH 01/25] feat(runtime): add interactive invoke console --- README.md | 17 +- src/components/Root.tsx | 13 + src/components/RuntimeEndpointPicker.tsx | 4 +- src/components/RuntimePicker.tsx | 4 +- .../runtime/invoke/RequestOptionsScreen.tsx | 166 +++ .../runtime/invoke/RuntimePayloadInput.tsx | 112 ++ src/handlers/runtime/invoke/index.tsx | 27 +- .../runtime/invoke/invoke.screen.test.tsx | 1066 +++++++++++++++++ src/handlers/runtime/invoke/invoke.test.tsx | 78 +- src/handlers/runtime/invoke/screen.tsx | 423 +++++++ 10 files changed, 1887 insertions(+), 23 deletions(-) create mode 100644 src/handlers/runtime/invoke/RequestOptionsScreen.tsx create mode 100644 src/handlers/runtime/invoke/RuntimePayloadInput.tsx create mode 100644 src/handlers/runtime/invoke/invoke.screen.test.tsx create mode 100644 src/handlers/runtime/invoke/screen.tsx diff --git a/README.md b/README.md index 61007f88b..58735acd7 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ agentcore # interactive TUI ├── runtime # inspect deployed AgentCore Runtimes │ ├── get # fetch a Runtime by id │ ├── list # list Runtimes (server-side paginated) -│ ├── invoke # invoke a Runtime +│ ├── invoke # invoke a Runtime headlessly or in a persistent console │ ├── version │ │ ├── get # get a specific Runtime version │ │ └── list # list a Runtime's versions @@ -182,7 +182,7 @@ Source-aware values: any field flag documented as such accepts the value inline, ### Invoke a Runtime -Runtime invocation accepts inline, file, or stdin payload bytes: +Headless invocation accepts inline, file, or stdin payload bytes: ```bash # Inline @@ -254,6 +254,19 @@ agentcore runtime invoke --id --payload '{"action":"status"}' --json # {"statusCode":200,"contentType":"application/json","bodyEncoding":"utf8","body":"{\"ok\":true}","complete":true} ``` +Without `--payload`, Runtime Invoke opens a persistent console for repeated +requests. Bare invoke opens the Runtime and endpoint pickers; `--id` skips the +Runtime picker, and `--id` plus `--qualifier` opens the console directly. + +| Shortcut | Action | +| -------- | -------------------------------------------- | +| `Ctrl+D` | Send the request | +| `Ctrl+O` | Open Request Options | +| `Ctrl+T` | Change Runtime or endpoint | +| `Ctrl+V` | Toggle raw and pretty completed JSON | +| `Esc` | Interrupt an active request or navigate back | +| `↑`/`↓` | Scroll response history | + Runtime Invoke accepts Runtime IDs from the current account only. It does not accept ARNs, `--version`, `--interactive`, cross-account targets, or custom request paths. All requests use the Runtime `/invocations` route, including MCP diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 5899233ac..352cb88a8 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -31,6 +31,7 @@ import { RuntimeListVersionsScreen } from "../handlers/runtime/version/list/scre import { MemoryScreen } from "../handlers/memory/screen.tsx"; import { MemoryGetJsonScreen, MemoryGetScreen } from "../handlers/memory/get/screen.tsx"; import { MemoryListScreen } from "../handlers/memory/list/screen.tsx"; +import { RuntimeInvokeScreen } from "../handlers/runtime/invoke/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -275,6 +276,18 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/memory/get/:memoryId/json" element={} /> + } + /> + } + /> + } + /> } /> diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index 8485b46d3..fc46edcc3 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -42,6 +42,7 @@ export interface RuntimeEndpointPickerProps extends ScreenProps { breadcrumb: string[]; description?: string; onSelect: (qualifier: string) => void; + onEscape?: () => void; } export function RuntimeEndpointPicker({ @@ -51,10 +52,11 @@ export function RuntimeEndpointPicker({ breadcrumb, description, onSelect, + onEscape, }: RuntimeEndpointPickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); - const goBack = () => navigate(-1); + const goBack = onEscape ?? (() => navigate(-1)); return ( void; + onEscape?: () => void; } export function RuntimePicker({ @@ -60,10 +61,11 @@ export function RuntimePicker({ breadcrumb, description, onSelect, + onEscape, }: RuntimePickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); - const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); + const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); return ( void; + onClose: () => void; + customJwt: boolean; + mcp: boolean; +}) { + const rows = [ + { field: "payloadSource", label: "Payload source", choices: ["Inline", "File"] }, + ...(value.payloadSource === "File" + ? [{ field: "payloadPath", label: "Payload path" } as Row] + : []), + { + field: "contentType", + label: "Content type", + choices: ["application/json", "text/plain", "application/octet-stream", "Custom"], + }, + { + field: "accept", + label: "Accepted response", + choices: [ + "text/event-stream", + "application/json", + "text/plain", + "application/octet-stream", + "Custom", + ], + }, + { + field: "responseDestination", + label: "Response destination", + choices: ["Console", "File"], + }, + ...(value.responseDestination === "File" + ? [{ field: "outputPath", label: "Response path" } as Row] + : []), + { field: "runtimeSessionId", label: "Runtime session ID" }, + { field: "runtimeUserId", label: "Runtime user ID" }, + { field: "headers", label: "Application headers", multiline: true }, + ...(customJwt ? [{ field: "bearerToken", label: "Bearer JWT", secret: true } as Row] : []), + ...(mcp + ? [ + { field: "mcpSessionId", label: "MCP session ID" }, + { field: "mcpProtocolVersion", label: "MCP protocol version" }, + { + field: "mcpMethod", + label: "MCP method", + choices: ["tools/call", "tools/list", "resources/read", "prompts/get", "Custom"], + }, + { field: "mcpName", label: "MCP name" }, + ] + : []), + { field: "traceId", label: "Trace ID" }, + { field: "traceParent", label: "Traceparent" }, + { field: "traceState", label: "Tracestate" }, + { field: "baggage", label: "Baggage" }, + ] as Row[]; + const [selected, setSelected] = useState(0); + const [editing, setEditing] = useState(); + const [draft, setDraft] = useState(""); + const [custom, setCustom] = useState(false); + const row = rows[Math.min(selected, rows.length - 1)]!; + const finish = (next?: string) => { + if (next !== undefined) onChange({ ...value, [row.field]: next }); + setEditing(undefined); + setCustom(false); + }; + + useInput((input, key) => { + if (key.escape) { + if (editing) finish(); + else onClose(); + return; + } + if (editing) { + if (row.multiline && key.ctrl && input === "d") finish(draft); + return; + } + if (key.upArrow) setSelected((current) => Math.max(0, current - 1)); + if (key.downArrow) setSelected((current) => Math.min(rows.length - 1, current + 1)); + if (key.return) { + setDraft(value[row.field] ?? ""); + setCustom(false); + setEditing(row.field); + } + }); + + return ( + + Request Options + {rows.map((item, index) => ( + + {index === selected ? "› " : " "} + {item.label}:{" "} + {item.secret && value[item.field] + ? "*".repeat(value[item.field]!.length) + : value[item.field]} + + ))} + {editing === row.field && row.choices && !custom ? ( + ({ label: choice, value: choice }))} - onSelect={(item) => { - if (item.value === "Custom") { - setDraft(""); - setCustom(true); - } else { - finish(item.value); - } - }} - /> - ) : editing === row.field && row.multiline ? ( - - ) : editing === row.field ? ( - - ) : null} + Request options + {rows.map((item, index) => { + const isSelected = index === selectedIndex; + const firstInSection = index === 0 || rows[index - 1]!.section !== item.section; + const summary = optionSummary(item, value); + const empty = summary === "Not set" || summary === "Automatic"; + return ( + + {firstInSection ? ( + + {item.section} + + ) : null} + + + {isSelected ? "❯ " : " "} + + + {item.label} + + {summary} + + + ); + })} ); } diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 6e6eb2403..f0d9752d3 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -91,7 +91,7 @@ async function editCustom(screen: InvokeScreen, down: number, choice: number, va async function configureRuntimeScopedOptions(screen: InvokeScreen, token: string) { await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); await editText(screen, 5, RUNTIME_USER_ID); await screen.press("down"); await screen.press("return"); @@ -264,7 +264,7 @@ describe("Runtime invoke console", () => { expect(screen.lastFrame()).toContain("Enter JSON payload"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); await screen.press("down"); await screen.press("return"); await screen.press("down"); @@ -405,10 +405,10 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); const frame = screen.lastFrame()!; expect(frame.includes("Bearer JWT")).toBe(conditional); - expect(frame.includes("MCP method")).toBe(conditional); + expect(frame.includes("MCP")).toBe(conditional); expect( frame .split("\n") @@ -428,6 +428,7 @@ describe("Runtime invoke console", () => { .setGetResponse({ agentRuntimeArn: RUNTIME_ARN, protocolConfiguration: { serverProtocol: "MCP" }, + authorizerConfiguration: { customJWTAuthorizer: {} }, requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, } as GetAgentRuntimeResponse) .setInvokeResponse({ @@ -439,19 +440,24 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); await editCustom(screen, 1, 3, "application/vnd.test+json"); - await editCustom(screen, 1, 4, "application/vnd.test-response+json"); + await editCustom(screen, 1, 5, "application/vnd.test-response+json"); await editText(screen, 2, "runtime-session"); - await moveDown(screen, 2); + await editText(screen, 1, "runtime-user"); + await screen.press("down"); await screen.press("return"); await screen.write("X-Tenant: retail\nX-Amzn-Bedrock-AgentCore-Runtime-Custom-Mode: fast"); await screen.write("\x04"); + await editText(screen, 1, "bearer-token"); await editText(screen, 1, "mcp-session"); await editText(screen, 1, "2025-06-18"); await editCustom(screen, 1, 4, "tasks/run"); await editText(screen, 1, "task-name"); await editText(screen, 1, "trace-id"); + await editText(screen, 1, "00-trace-id-span-id-01"); + await editText(screen, 1, "vendor=value"); + await editText(screen, 1, "tenant=retail"); await screen.press("escape"); await waitForText(screen.lastFrame, "idle"); @@ -467,34 +473,39 @@ describe("Runtime invoke console", () => { contentType: "application/vnd.test+json", accept: "application/vnd.test-response+json", runtimeSessionId: "runtime-session", + runtimeUserId: "runtime-user", applicationHeaders: [ ["X-Tenant", "retail"], ["X-Amzn-Bedrock-AgentCore-Runtime-Custom-Mode", "fast"], ], + bearerToken: "bearer-token", mcpSessionId: "mcp-session", mcpProtocolVersion: "2025-06-18", mcpMethod: "tasks/run", mcpName: "task-name", traceId: "trace-id", + traceParent: "00-trace-id-span-id-01", + traceState: "vendor=value", + baggage: "tenant=retail", }); }); - test("Request Options editors save drafts and Esc cancels them", async () => { + test("Request options editors save drafts and Esc cancels them", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); await moveDown(screen, 1); await screen.press("return"); await moveDown(screen, 3); await screen.press("return"); await screen.write("application/cancelled"); await screen.press("escape"); - await waitForText(screen.lastFrame, "Request Options"); - expect(screen.lastFrame()).toContain("Content type: application/json"); + await waitForText(screen.lastFrame, "Request options"); + expect(screen.lastFrame()).toMatch(/Content type\s+application\/json/); expect(screen.lastFrame()).not.toContain("application/cancelled"); await screen.press("return"); @@ -502,19 +513,19 @@ describe("Runtime invoke console", () => { await screen.press("return"); await screen.write("application/saved"); await screen.press("return"); - await waitForText(screen.lastFrame, "Content type: application/saved"); + await waitForText(screen.lastFrame, "application/saved"); await moveDown(screen, 5); await screen.press("return"); await screen.write("X-Test: cancelled"); await screen.press("escape"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); expect(screen.lastFrame()).not.toContain("X-Test: cancelled"); await screen.press("return"); await screen.write("X-Test: saved"); await screen.write("\x04"); - await waitForText(screen.lastFrame, "Application headers: X-Test: saved"); + await waitForText(screen.lastFrame, "1 header"); }); test("Ctrl+T preserves Runtime-scoped credentials across endpoints and clears sessions", async () => { @@ -568,11 +579,13 @@ describe("Runtime invoke console", () => { expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); const endpointOptions = screen.lastFrame()!; - expect(endpointOptions).toContain(`Runtime user ID: ${RUNTIME_USER_ID}`); - expect(endpointOptions).toContain(`Application headers: ${APPLICATION_HEADER}`); - expect(endpointOptions).toContain("*".repeat(token.length)); + expect(endpointOptions).toMatch(new RegExp(`User ID\\s+${RUNTIME_USER_ID}`)); + expect(endpointOptions).toMatch(/Application headers\s+1 header/); + expect(endpointOptions).toMatch(/Bearer JWT\s+Configured/); + expect(endpointOptions).not.toContain(APPLICATION_HEADER); + expect(endpointOptions).not.toContain(token); await screen.press("escape"); core.runtime.setInvokeResponse({ @@ -659,9 +672,9 @@ describe("Runtime invoke console", () => { expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); const options = screen.lastFrame()!; - expect(options).toContain(`Runtime user ID: ${RUNTIME_USER_ID}`); + expect(options).toMatch(new RegExp(`User ID\\s+${RUNTIME_USER_ID}`)); expect(options).not.toContain("secret-header"); expect(options).not.toContain("*".repeat(token.length)); await screen.press("escape"); @@ -721,7 +734,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); await editText(screen, 7, "mcp-session"); await editText(screen, 1, "2025-06-18"); await editCustom(screen, 1, 4, "tasks/run"); @@ -864,7 +877,7 @@ describe("Runtime invoke console", () => { .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, - contentType: "application/problem+json", + contentType: "application/json", body: (async function* () { mutable.set(Buffer.from(first)); yield mutable.subarray(0, first.length); @@ -978,7 +991,7 @@ describe("Runtime invoke console", () => { try { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); for (let index = 0; index < 3; index++) await screen.press("down"); await screen.press("return"); await screen.press("down"); @@ -1006,7 +1019,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); for (let index = 0; index < 3; index++) await screen.press("down"); await screen.press("return"); await screen.press("down"); @@ -1026,7 +1039,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); await screen.press("return"); await screen.press("down"); await screen.press("return"); @@ -1074,7 +1087,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request Options"); + await waitForText(screen.lastFrame, "Request options"); await editText(screen, 7, token); await screen.press("escape"); await screen.write("{}"); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 4d7149568..1eb8cf631 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -18,7 +18,11 @@ import { parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, } from "./request"; -import { RequestOptionsScreen, type RuntimeInvokeOptions } from "./RequestOptionsScreen"; +import { + RequestOptionsScreen, + type RequestOptionsMode, + type RuntimeInvokeOptions, +} from "./RequestOptionsScreen"; import { RuntimePayloadInput } from "./RuntimePayloadInput"; import { classifyRuntimeResponse, writeRuntimeInvokeFile } from "./response"; @@ -139,6 +143,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke contentType: "application/json", }); const [showOptions, setShowOptions] = useState(false); + const [optionsMode, setOptionsMode] = useState("overview"); const [history, setHistory] = useState([]); const [prettyJson, setPrettyJson] = useState(false); const abortRef = useRef(null); @@ -366,7 +371,23 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke breadcrumb={["agentcore", "runtime", "invoke", target.runtimeId, target.qualifier]} keyHints={ showOptions - ? [{ key: "esc", label: "back" }] + ? optionsMode === "overview" + ? [ + { key: "enter", label: "edit" }, + { key: "↑↓", label: "move" }, + { key: "esc", label: "back" }, + ] + : optionsMode === "multiline" + ? [ + { key: "ctl+d", label: "save" }, + { key: "enter", label: "newline" }, + { key: "esc", label: "cancel" }, + ] + : [ + { key: "enter", label: optionsMode === "choice" ? "select" : "save" }, + ...(optionsMode === "choice" ? [{ key: "↑↓", label: "move" }] : []), + { key: "esc", label: "cancel" }, + ] : busy ? [ { key: "esc", label: "interrupt" }, @@ -399,7 +420,11 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke setShowOptions(false)} + onClose={() => { + setShowOptions(false); + setOptionsMode("overview"); + }} + onModeChange={setOptionsMode} customJwt={customJwt} mcp={mcp} /> From 9b2a515a5c7f43d20a49294c260236187dbe7556 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 15:17:50 +0000 Subject: [PATCH 09/25] test(runtime): streamline request options coverage --- .../invoke/RequestOptionsScreen.test.tsx | 103 ++++++------------ 1 file changed, 33 insertions(+), 70 deletions(-) diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx index c4b4376ab..5898fd33f 100644 --- a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx +++ b/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx @@ -59,21 +59,22 @@ async function moveDown(screen: RenderedOptions, count: number) { afterEach(cleanup); describe("Request options", () => { - test("groups the HTTP overview and summarizes empty values", () => { - const screen = render(); - const frame = screen.lastFrame()!; + test("groups context-specific options and redacts sensitive values", () => { + const httpScreen = render(); + const httpFrame = httpScreen.lastFrame()!; - expect(frame).toContain("Request options"); - expect(frame).toMatch(/Payload[\s\S]*Source\s+Inline[\s\S]*Content type\s+application\/json/); - expect(frame).toMatch(/Response[\s\S]*Accept\s+Automatic[\s\S]*Destination\s+Console/); - expect(frame).toMatch(/Runtime[\s\S]*Session ID\s+Not set[\s\S]*User ID\s+Not set/); - expect(frame).toContain("Trace"); - expect(frame).not.toContain("MCP"); - expect(frame).not.toContain("Bearer JWT"); - }); - - test("shows conditional MCP and JWT sections without exposing secrets", () => { - const screen = render( + expect(httpFrame).toContain("Request options"); + expect(httpFrame).toMatch( + /Payload[\s\S]*Source\s+Inline[\s\S]*Content type\s+application\/json/, + ); + expect(httpFrame).toMatch(/Response[\s\S]*Accept\s+Automatic[\s\S]*Destination\s+Console/); + expect(httpFrame).toMatch(/Runtime[\s\S]*Session ID\s+Not set[\s\S]*User ID\s+Not set/); + expect(httpFrame).toContain("Trace"); + expect(httpFrame).not.toContain("MCP"); + expect(httpFrame).not.toContain("Bearer JWT"); + httpScreen.unmount(); + + const mcpScreen = render( { }} />, ); - const frame = screen.lastFrame()!; - - expect(frame).toMatch(/Application headers\s+2 headers/); - expect(frame).toMatch(/Bearer JWT\s+Configured/); - expect(frame).toMatch(/MCP[\s\S]*Session ID\s+mcp-session[\s\S]*Protocol version\s+Not set/); - expect(frame).not.toContain("secret-token"); - expect(frame).not.toContain("X-Tenant"); - expect(frame).not.toContain("retail"); + const mcpFrame = mcpScreen.lastFrame()!; + + expect(mcpFrame).toMatch(/Application headers\s+2 headers/); + expect(mcpFrame).toMatch(/Bearer JWT\s+Configured/); + expect(mcpFrame).toMatch(/MCP[\s\S]*Session ID\s+mcp-session[\s\S]*Protocol version\s+Not set/); + expect(mcpFrame).not.toContain("secret-token"); + expect(mcpFrame).not.toContain("X-Tenant"); + expect(mcpFrame).not.toContain("retail"); }); - test("replaces the overview with a choice editor and highlights the saved value", async () => { + test("highlights saved choices and reveals conditional rows", async () => { const modes: RequestOptionsMode[] = []; - const screen = render( - modes.push(mode)} - />, - ); + const screen = render( modes.push(mode)} />); await press(screen, "return"); const frame = screen.lastFrame()!; expect(frame).not.toContain("Request options"); expect(frame).toContain("Source"); - expect(frame).toContain(" Inline"); - expect(frame).toContain("❯ File"); + expect(frame).toContain("❯ Inline"); + expect(frame).toContain(" File"); expect(modes).toEqual(["choice"]); - await press(screen, "escape"); - expect(screen.lastFrame()).toContain("Request options"); - expect(modes).toEqual(["choice", "overview"]); - }); - - test("saves a choice and reveals its conditional path row", async () => { - const screen = render(); - - await press(screen, "return"); await press(screen, "down"); await press(screen, "return"); - expect(screen.lastFrame()).toMatch(/Source\s+File[\s\S]*File path\s+Not set/); + expect(modes).toEqual(["choice", "overview"]); + + await press(screen, "return"); + expect(screen.lastFrame()).toContain("❯ File"); + await press(screen, "escape"); + expect(modes).toEqual(["choice", "overview", "choice", "overview"]); }); test("cancels and saves a custom media type", async () => { @@ -165,24 +157,6 @@ describe("Request options", () => { expect(screen.lastFrame()).toMatch(/Accept\s+Automatic/); }); - test("shows file source and destination paths", () => { - const screen = render( - , - ); - const frame = screen.lastFrame()!; - - expect(frame).toMatch(/Source\s+File[\s\S]*File path\s+\/tmp\/input\.json/); - expect(frame).toMatch(/Destination\s+File[\s\S]*File path\s+\/tmp\/output\.bin/); - }); - test("saves multiline headers with Ctrl+D and cancels without leaking values", async () => { const screen = render(); @@ -199,15 +173,4 @@ describe("Request options", () => { expect(screen.lastFrame()).toMatch(/Application headers\s+2 headers/); expect(screen.lastFrame()).not.toContain("X-Cancelled"); }); - - test("clamps selection when conditional rows disappear", async () => { - const screen = render(); - await moveDown(screen, 15); - expect(screen.lastFrame()).toMatch(/❯ Baggage\s+Not set/); - - screen.rerender(); - await tick(); - expect(screen.lastFrame()).toMatch(/❯ Baggage\s+Not set/); - expect(screen.lastFrame()).not.toContain("MCP"); - }); }); From 24147a626a1df0070ce6f8aefc7d8642a8b2fc8e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 16:28:55 +0000 Subject: [PATCH 10/25] feat(runtime): float invoke request options --- .../runtime/invoke/RuntimePayloadInput.tsx | 70 ++++---- .../runtime/invoke/invoke.screen.test.tsx | 39 ++++- src/handlers/runtime/invoke/screen.tsx | 158 +++++++++++------- 3 files changed, 173 insertions(+), 94 deletions(-) diff --git a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx b/src/handlers/runtime/invoke/RuntimePayloadInput.tsx index cf42d0059..08d3779a6 100644 --- a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx +++ b/src/handlers/runtime/invoke/RuntimePayloadInput.tsx @@ -9,12 +9,14 @@ interface RuntimePayloadInputProps { onChange: (value: string) => void; onSubmit: () => void; submitDisabled?: boolean; + focused?: boolean; label: string; placeholder: string; previewLines?: number; } -function Cursor({ character }: { character: string }) { +function Cursor({ character, focused }: { character: string; focused: boolean }) { + if (!focused) return {character}; return ( {character} @@ -27,6 +29,7 @@ export function RuntimePayloadInput({ onChange, onSubmit, submitDisabled = false, + focused = true, label, placeholder, previewLines = 4, @@ -34,46 +37,49 @@ export function RuntimePayloadInput({ 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: focused }, + ); if (value === "") { return ( {label} - + {placeholder.slice(1)} @@ -110,7 +116,7 @@ export function RuntimePayloadInput({ {prefix} {before ? {before} : null} - + {after ? {after} : null} ); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index f0d9752d3..9132ea015 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -408,7 +408,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "Request options"); const frame = screen.lastFrame()!; expect(frame.includes("Bearer JWT")).toBe(conditional); - expect(frame.includes("MCP")).toBe(conditional); + expect(frame.includes("│ MCP")).toBe(conditional); expect( frame .split("\n") @@ -422,6 +422,43 @@ describe("Runtime invoke console", () => { } }); + test("floats options over the console without editing the background payload", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("draft payload"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request options"); + + const optionsFrame = screen.lastFrame()!; + expect(optionsFrame).toContain("Payload · application/json"); + expect(optionsFrame).toContain("draft payload"); + expect(optionsFrame).toContain("idle · Sessions"); + expect(optionsFrame).toContain("╭"); + + await screen.write("ignored"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "idle"); + expect(screen.lastFrame()).toContain("draft payload"); + expect(screen.lastFrame()).not.toContain("ignored"); + }); + + test("uses the full-screen options fallback in a compact terminal", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.resize(60, 24); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request options"); + + expect(screen.lastFrame()).not.toContain("Payload · application/json"); + expect(screen.lastFrame()).not.toContain("idle · Sessions"); + }); + test("manually edited protocol options reach invoke", async () => { const core = new TestCoreClient(); core.runtime diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 1eb8cf631..ffff4939a 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -125,7 +125,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); const { stdin } = useStdin(); - const { rows } = useWindowSize(); + const { columns, rows } = useWindowSize(); const [target, setTarget] = useState({ runtimeId, qualifier }); const [targetPicker, setTargetPicker] = useState(null); const detail = useQuery({ @@ -292,6 +292,22 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const inputRows = Math.min(4, Math.max(1, payload.split("\n").length)); const canPrettyJson = history.some((exchange) => exchange.pretty !== undefined); const contentType = requestOptions.contentType || "application/json"; + const floatingOptions = showOptions && columns >= 72 && rows >= 30; + const optionsPanelWidth = Math.min(76, columns - 4); + const closeOptions = () => { + setShowOptions(false); + setOptionsMode("overview"); + }; + const optionsScreen = ( + + ); useInput( (input, key) => { @@ -412,68 +428,88 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke ] } > - {detail.isPending ? ( - - ) : detail.isError ? ( - Error: {(detail.error as Error).message} - ) : showOptions ? ( - { - setShowOptions(false); - setOptionsMode("overview"); - }} - onModeChange={setOptionsMode} - customJwt={customJwt} - mcp={mcp} - /> - ) : ( - - - - {history.map((exchange, index) => ( - - Request - {exchange.payload} - {exchange.heading ?? "Response"} - {prettyJson && exchange.pretty ? exchange.pretty : exchange.response} - {exchange.state !== "connecting" && exchange.state !== "streaming" ? ( - <> - {exchange.metadata ? {exchange.metadata} : null} - {exchange.note ? {exchange.note} : null} - - {exchange.state} · {exchange.byteCount} bytes + + {detail.isPending ? ( + + ) : detail.isError ? ( + Error: {(detail.error as Error).message} + ) : showOptions && !floatingOptions ? ( + optionsScreen + ) : ( + <> + + + + {history.map((exchange, index) => ( + + Request + {exchange.payload} + {exchange.heading ?? "Response"} + + {prettyJson && exchange.pretty ? exchange.pretty : exchange.response} - - ) : null} + {exchange.state !== "connecting" && exchange.state !== "streaming" ? ( + <> + {exchange.metadata ? {exchange.metadata} : null} + {exchange.note ? {exchange.note} : null} + + {exchange.state} · {exchange.byteCount} bytes + + + ) : null} + + ))} + + + + void send()} + submitDisabled={busy} + focused={!showOptions} + previewLines={4} + /> + + + {busy ? ( + + ) : ( + + idle · Sessions: Runtime {requestOptions.runtimeSessionId ?? "new"} · MCP{" "} + {requestOptions.mcpSessionId ?? "new"} + + )} + + + {floatingOptions ? ( + + + {optionsScreen} - ))} - - - - void send()} - submitDisabled={busy} - previewLines={4} - /> - - - {busy ? ( - - ) : ( - - idle · Sessions: Runtime {requestOptions.runtimeSessionId ?? "new"} · MCP{" "} - {requestOptions.mcpSessionId ?? "new"} - - )} - - - )} + + ) : null} + + )} + ); } From 189c88db92785f14fb4df325d8b94393d5805294 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 16:43:48 +0000 Subject: [PATCH 11/25] feat(runtime): enlarge request options modal --- src/handlers/runtime/invoke/invoke.screen.test.tsx | 4 ++++ src/handlers/runtime/invoke/screen.tsx | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 9132ea015..3fb3f56f3 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -437,6 +437,10 @@ describe("Runtime invoke console", () => { expect(optionsFrame).toContain("draft payload"); expect(optionsFrame).toContain("idle · Sessions"); expect(optionsFrame).toContain("╭"); + const panelLines = optionsFrame.split("\n"); + const panelTop = panelLines.findIndex((line) => line.includes("╭")); + const panelBottom = panelLines.findIndex((line) => line.includes("╰")); + expect(panelBottom - panelTop + 1).toBeGreaterThanOrEqual(26); await screen.write("ignored"); await screen.press("escape"); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index ffff4939a..df8fd7446 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -292,7 +292,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const inputRows = Math.min(4, Math.max(1, payload.split("\n").length)); const canPrettyJson = history.some((exchange) => exchange.pretty !== undefined); const contentType = requestOptions.contentType || "application/json"; - const floatingOptions = showOptions && columns >= 72 && rows >= 30; + const floatingOptions = showOptions && columns >= 72 && rows >= 34; const optionsPanelWidth = Math.min(76, columns - 4); const closeOptions = () => { setShowOptions(false); @@ -496,6 +496,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke > Date: Fri, 31 Jul 2026 18:59:44 +0000 Subject: [PATCH 12/25] feat(runtime): add JSON payload templates --- .../invoke/RequestOptionsScreen.test.tsx | 65 +++++++++++++++++- .../runtime/invoke/RequestOptionsScreen.tsx | 44 ++++++++++++- .../runtime/invoke/invoke.screen.test.tsx | 52 +++++++++++++-- .../runtime/invoke/payloadTemplate.test.ts | 47 +++++++++++++ .../runtime/invoke/payloadTemplate.ts | 66 +++++++++++++++++++ src/handlers/runtime/invoke/screen.tsx | 41 +++++++++++- 6 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 src/handlers/runtime/invoke/payloadTemplate.test.ts create mode 100644 src/handlers/runtime/invoke/payloadTemplate.ts diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx index 5898fd33f..aea0b066a 100644 --- a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx +++ b/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx @@ -65,7 +65,7 @@ describe("Request options", () => { expect(httpFrame).toContain("Request options"); expect(httpFrame).toMatch( - /Payload[\s\S]*Source\s+Inline[\s\S]*Content type\s+application\/json/, + /Payload[\s\S]*Source\s+Inline[\s\S]*Content type\s+application\/json[\s\S]*Payload template\s+Not set/, ); expect(httpFrame).toMatch(/Response[\s\S]*Accept\s+Automatic[\s\S]*Destination\s+Console/); expect(httpFrame).toMatch(/Runtime[\s\S]*Session ID\s+Not set[\s\S]*User ID\s+Not set/); @@ -147,7 +147,7 @@ describe("Request options", () => { test("resets Accept to Automatic", async () => { const screen = render(); - await moveDown(screen, 2); + await moveDown(screen, 3); await press(screen, "return"); expect(screen.lastFrame()).toContain("❯ text/plain"); await press(screen, "up"); @@ -160,7 +160,7 @@ describe("Request options", () => { test("saves multiline headers with Ctrl+D and cancels without leaking values", async () => { const screen = render(); - await moveDown(screen, 6); + await moveDown(screen, 7); await press(screen, "return"); await write(screen, "X-Tenant: retail\nX-Mode: fast"); await write(screen, "\x04"); @@ -173,4 +173,63 @@ describe("Request options", () => { expect(screen.lastFrame()).toMatch(/Application headers\s+2 headers/); expect(screen.lastFrame()).not.toContain("X-Cancelled"); }); + + test("shows and validates multiline templates only for inline JSON payloads", async () => { + const screen = render(); + + await moveDown(screen, 2); + await press(screen, "return"); + await write(screen, '{"prompt":\n "{{input}}"\n}'); + await write(screen, "\x04"); + expect(screen.lastFrame()).toMatch(/Payload template\s+3-line template/); + + await press(screen, "return"); + expect(screen.lastFrame()).toContain('{"prompt":\n "{{input}}"\n}'); + await press(screen, "escape"); + + await press(screen, "up"); + await press(screen, "return"); + await press(screen, "down"); + await press(screen, "return"); + expect(screen.lastFrame()).not.toContain("Payload template"); + + await press(screen, "return"); + await press(screen, "up"); + await press(screen, "return"); + expect(screen.lastFrame()).toMatch(/Payload template\s+3-line template/); + screen.unmount(); + + const invalid = render(); + await moveDown(invalid, 2); + await press(invalid, "return"); + await write(invalid, '{"prompt":"fixed"}'); + await write(invalid, "\x04"); + expect(invalid.lastFrame()).toContain( + 'Payload template must include "{{input}}" in a string value', + ); + expect(invalid.lastFrame()).toContain('{"prompt":"fixed"}'); + invalid.unmount(); + + const text = render( + , + ); + expect(text.lastFrame()).not.toContain("Payload template"); + text.unmount(); + + const file = render( + , + ); + expect(file.lastFrame()).not.toContain("Payload template"); + file.unmount(); + + const vendorJson = render( + , + ); + expect(vendorJson.lastFrame()).toContain("Payload template"); + }); }); diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.tsx index 0e4e2100b..bdee08619 100644 --- a/src/handlers/runtime/invoke/RequestOptionsScreen.tsx +++ b/src/handlers/runtime/invoke/RequestOptionsScreen.tsx @@ -4,12 +4,15 @@ import { FormTextArea } from "../../../components/FormTextArea"; import { darkTheme } from "../../../components/ui/_core.js"; import { Select } from "../../../components/ui/select"; import { TextInput } from "../../../components/ui/text-input"; +import { InputValidationError } from "../../../errors"; +import { renderPayloadTemplate, supportsPayloadTemplate } from "./payloadTemplate"; export type RuntimeInvokeOptions = { payloadSource: "Inline" | "File"; responseDestination: "Console" | "File"; payloadPath?: string; contentType?: string; + payloadTemplate?: string; accept?: string; outputPath?: string; runtimeSessionId?: string; @@ -42,6 +45,7 @@ type Row = { label: string; choices?: Choice[]; multiline?: boolean; + placeholder?: string; secret?: boolean; }; @@ -71,6 +75,17 @@ function optionRows(value: RuntimeInvokeOptions, customJwt: boolean, mcp: boolea { label: "Custom", custom: true }, ], }, + ...(value.payloadSource === "Inline" && supportsPayloadTemplate(value.contentType) + ? [ + { + section: "Payload", + field: "payloadTemplate", + label: "Payload template", + multiline: true, + placeholder: '{"prompt":"{{input}}"}', + } as Row, + ] + : []), { section: "Response", field: "accept", @@ -137,6 +152,11 @@ function optionSummary(row: Row, value: RuntimeInvokeOptions): string { const count = current?.split("\n").filter((line) => line.trim()).length ?? 0; return count === 0 ? "Not set" : `${count} ${count === 1 ? "header" : "headers"}`; } + if (row.field === "payloadTemplate") { + if (!current) return "Not set"; + const lines = current.split("\n").length; + return `${lines}-line template`; + } if (row.field === "accept") return current || "Automatic"; return current || "Not set"; } @@ -171,16 +191,29 @@ export function RequestOptionsScreen({ const [editing, setEditing] = useState(); const [draft, setDraft] = useState(""); const [custom, setCustom] = useState(false); + const [error, setError] = useState(); const selectedIndex = Math.min(selected, rows.length - 1); const row = rows[selectedIndex]!; const closeEditor = () => { setEditing(undefined); setCustom(false); + setError(undefined); onModeChange?.("overview"); }; const save = (next?: string) => { - onChange({ ...value, [row.field]: next }); + const normalized = row.field === "payloadTemplate" ? (next?.trim() ? next : undefined) : next; + if (row.field === "payloadTemplate" && normalized) { + try { + renderPayloadTemplate(normalized, ""); + } catch (cause) { + setError( + cause instanceof InputValidationError ? cause.message : "Payload template is invalid", + ); + return; + } + } + onChange({ ...value, [row.field]: normalized }); closeEditor(); }; @@ -208,6 +241,7 @@ export function RequestOptionsScreen({ if (key.return) { setDraft(value[row.field] ?? ""); setCustom(false); + setError(undefined); setEditing(row.field); onModeChange?.(row.multiline ? "multiline" : row.choices ? "choice" : "text"); } @@ -220,9 +254,12 @@ export function RequestOptionsScreen({ { + setDraft(next); + setError(undefined); + }} /> ) : ( {row.label} @@ -258,6 +295,7 @@ export function RequestOptionsScreen({ /> ) : null} + {error ? {error} : null} ); } diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 3fb3f56f3..4212de732 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -92,7 +92,7 @@ async function editCustom(screen: InvokeScreen, down: number, choice: number, va async function configureRuntimeScopedOptions(screen: InvokeScreen, token: string) { await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request options"); - await editText(screen, 5, RUNTIME_USER_ID); + await editText(screen, 6, RUNTIME_USER_ID); await screen.press("down"); await screen.press("return"); await screen.write(APPLICATION_HEADER); @@ -275,6 +275,46 @@ describe("Runtime invoke console", () => { expect(screen.lastFrame()).toContain("Enter text payload"); }); + test("renders template input into the request and transcript payload", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "application/json", + body: responseBody(Buffer.from('{"ok":true}')), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + await screen.write("\x0f"); + await waitForText(screen.lastFrame, "Request options"); + await moveDown(screen, 2); + await screen.press("return"); + await screen.write('{\n "prompt": "{{input}}",\n "context": "User: {{input}}"\n}'); + await screen.write("\x04"); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "Input · 4-line template"); + expect(screen.lastFrame()).toContain('{"prompt":"{{input}}","context":"User: {{input}}"}'); + expect(screen.lastFrame()).toContain("Enter input"); + + await screen.write('hello "world"'); + await screen.write("\x1b[13;2u"); + await screen.write("next"); + await screen.press("return"); + await waitFor( + () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 1, + ); + + const expected = + '{"prompt":"hello \\"world\\"\\nnext","context":"User: hello \\"world\\"\\nnext"}'; + const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! + .args[0] as RuntimeInvokeRequest; + expect(new TextDecoder().decode(request.payload)).toBe(expected); + await waitForText(screen.lastFrame, expected); + }); + test("accepts the next payload draft while a response is streaming", async () => { const release = Promise.withResolvers(); const requests: RuntimeInvokeRequest[] = []; @@ -483,7 +523,7 @@ describe("Runtime invoke console", () => { await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request options"); await editCustom(screen, 1, 3, "application/vnd.test+json"); - await editCustom(screen, 1, 5, "application/vnd.test-response+json"); + await editCustom(screen, 2, 5, "application/vnd.test-response+json"); await editText(screen, 2, "runtime-session"); await editText(screen, 1, "runtime-user"); await screen.press("down"); @@ -776,7 +816,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request options"); - await editText(screen, 7, "mcp-session"); + await editText(screen, 8, "mcp-session"); await editText(screen, 1, "2025-06-18"); await editCustom(screen, 1, 4, "tasks/run"); await editText(screen, 1, "task-name"); @@ -1033,7 +1073,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request options"); - for (let index = 0; index < 3; index++) await screen.press("down"); + for (let index = 0; index < 4; index++) await screen.press("down"); await screen.press("return"); await screen.press("down"); await screen.press("return"); @@ -1061,7 +1101,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request options"); - for (let index = 0; index < 3; index++) await screen.press("down"); + for (let index = 0; index < 4; index++) await screen.press("down"); await screen.press("return"); await screen.press("down"); await screen.press("return"); @@ -1129,7 +1169,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.write("\x0f"); await waitForText(screen.lastFrame, "Request options"); - await editText(screen, 7, token); + await editText(screen, 8, token); await screen.press("escape"); await screen.write("{}"); await screen.write("\x04"); diff --git a/src/handlers/runtime/invoke/payloadTemplate.test.ts b/src/handlers/runtime/invoke/payloadTemplate.test.ts new file mode 100644 index 000000000..0986f1ee3 --- /dev/null +++ b/src/handlers/runtime/invoke/payloadTemplate.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { InputValidationError } from "../../../errors"; +import { + renderPayloadTemplate, + summarizePayloadTemplate, + supportsPayloadTemplate, +} from "./payloadTemplate"; + +describe("payload templates", () => { + test.each([ + ["application/json", true], + ["APPLICATION/JSON; charset=utf-8", true], + ["application/problem+json", true], + ["application/vnd.example+json; version=1", true], + ["text/plain", false], + ["application/x-ndjson", false], + ["application/octet-stream", false], + ["application/json-seq", false], + ])("recognizes eligible content type %s", (contentType, expected) => { + expect(supportsPayloadTemplate(contentType)).toBe(expected); + }); + + test("renders multiline JSON with embedded and repeated input markers", () => { + const template = `{ + "prompt": "{{input}}", + "messages": ["User request: {{input}}", "{{input}}"], + "unchanged": 3 +}`; + + expect(renderPayloadTemplate(template, 'hello "world"\nnext')).toBe( + '{"prompt":"hello \\"world\\"\\nnext","messages":["User request: hello \\"world\\"\\nnext","hello \\"world\\"\\nnext"],"unchanged":3}', + ); + expect(summarizePayloadTemplate(template)).toBe( + '5-line template · {"prompt":"{{input}}","messages":["User request: {{input}}","{{input}}"],"unchanged":3}', + ); + }); + + test("rejects invalid JSON and templates without an input marker in a value", () => { + expect(() => renderPayloadTemplate('{"prompt":', "hello")).toThrow(InputValidationError); + expect(() => renderPayloadTemplate('{"prompt":"fixed"}', "hello")).toThrow( + 'Payload template must include "{{input}}" in a string value', + ); + expect(() => renderPayloadTemplate('{"{{input}}":"fixed"}', "hello")).toThrow( + 'Payload template must include "{{input}}" in a string value', + ); + }); +}); diff --git a/src/handlers/runtime/invoke/payloadTemplate.ts b/src/handlers/runtime/invoke/payloadTemplate.ts new file mode 100644 index 000000000..19b83ce34 --- /dev/null +++ b/src/handlers/runtime/invoke/payloadTemplate.ts @@ -0,0 +1,66 @@ +import { InputValidationError } from "../../../errors"; + +const INPUT_MARKER = "{{input}}"; + +function mediaType(contentType?: string): string { + return (contentType || "application/json").split(";", 1)[0]!.trim().toLowerCase(); +} + +export function supportsPayloadTemplate(contentType?: string): boolean { + const type = mediaType(contentType); + return type === "application/json" || /^application\/[^/]+\+json$/.test(type); +} + +function replaceInput(value: unknown, input: string): { value: unknown; replacements: number } { + if (typeof value === "string") { + const replacements = value.split(INPUT_MARKER).length - 1; + return { + value: value.replaceAll(INPUT_MARKER, input), + replacements, + }; + } + if (Array.isArray(value)) { + let replacements = 0; + const next = value.map((item) => { + const rendered = replaceInput(item, input); + replacements += rendered.replacements; + return rendered.value; + }); + return { value: next, replacements }; + } + if (value !== null && typeof value === "object") { + let replacements = 0; + const next = Object.fromEntries( + Object.entries(value).map(([key, item]) => { + const rendered = replaceInput(item, input); + replacements += rendered.replacements; + return [key, rendered.value]; + }), + ); + return { value: next, replacements }; + } + return { value, replacements: 0 }; +} + +function parsePayloadTemplate(template: string): unknown { + try { + return JSON.parse(template); + } catch (error) { + throw new InputValidationError("Payload template must be valid JSON", { cause: error }); + } +} + +export function renderPayloadTemplate(template: string, input: string): string { + const rendered = replaceInput(parsePayloadTemplate(template), input); + if (rendered.replacements === 0) { + throw new InputValidationError( + `Payload template must include "${INPUT_MARKER}" in a string value`, + ); + } + return JSON.stringify(rendered.value); +} + +export function summarizePayloadTemplate(template: string): string { + const lines = template.split("\n").length; + return `${lines}-line template · ${JSON.stringify(parsePayloadTemplate(template))}`; +} diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index df8fd7446..bde27cde8 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput, useStdin, useWindowSize } from "ink"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import cliTruncate from "cli-truncate"; import { InputValidationError } from "../../../errors"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -24,6 +25,11 @@ import { type RuntimeInvokeOptions, } from "./RequestOptionsScreen"; import { RuntimePayloadInput } from "./RuntimePayloadInput"; +import { + renderPayloadTemplate, + summarizePayloadTemplate, + supportsPayloadTemplate, +} from "./payloadTemplate"; import { classifyRuntimeResponse, writeRuntimeInvokeFile } from "./response"; const theme = darkTheme; @@ -174,14 +180,32 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke mcpProtocolVersion, mcpMethod, mcpName, + payloadTemplate, ...modeled } = requestOptions; - const requestPayload = payloadSource === "File" ? `file://${payloadPath ?? ""}` : payload; + let requestPayload = payloadSource === "File" ? `file://${payloadPath ?? ""}` : payload; const appendExchange = (response: string, state: ExchangeState) => setHistory((current) => [ ...current, { payload: requestPayload, response, byteCount: 0, state }, ]); + if ( + payloadSource === "Inline" && + payloadTemplate?.trim() && + supportsPayloadTemplate(modeled.contentType) + ) { + try { + requestPayload = renderPayloadTemplate(payloadTemplate, payload); + } catch (error) { + appendExchange( + `Error: ${ + error instanceof InputValidationError ? error.message : "Payload template is invalid" + }`, + "failed", + ); + return; + } + } if (responseDestination === "File" && !outputPath?.trim()) { appendExchange("Response path is required for File destination.", "failed"); return; @@ -292,6 +316,17 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const inputRows = Math.min(4, Math.max(1, payload.split("\n").length)); const canPrettyJson = history.some((exchange) => exchange.pretty !== undefined); const contentType = requestOptions.contentType || "application/json"; + const templateActive = + requestOptions.payloadSource === "Inline" && + supportsPayloadTemplate(requestOptions.contentType) && + Boolean(requestOptions.payloadTemplate?.trim()); + const inputLabel = + templateActive && requestOptions.payloadTemplate + ? cliTruncate( + `Input · ${summarizePayloadTemplate(requestOptions.payloadTemplate)}`, + Math.max(1, columns), + ) + : `Payload · ${contentType}`; const floatingOptions = showOptions && columns >= 72 && rows >= 34; const optionsPanelWidth = Math.min(76, columns - 4); const closeOptions = () => { @@ -463,8 +498,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke void send()} From df19c7b4f7d23559ffd3ccab15963b2f8f1067eb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 19:55:30 +0000 Subject: [PATCH 13/25] fix(runtime): improve request options editing --- src/components/FormTextArea.tsx | 97 ++++++++++++++----- .../invoke/RequestOptionsScreen.test.tsx | 8 ++ .../runtime/invoke/invoke.screen.test.tsx | 15 ++- src/handlers/runtime/invoke/screen.tsx | 56 +++++++---- 4 files changed, 132 insertions(+), 44 deletions(-) diff --git a/src/components/FormTextArea.tsx b/src/components/FormTextArea.tsx index 14a816081..1c9e04938 100644 --- a/src/components/FormTextArea.tsx +++ b/src/components/FormTextArea.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Box, Text, useInput } from "ink"; import { darkTheme } from "./ui/_core.js"; @@ -16,11 +17,10 @@ export interface FormTextAreaProps { focused?: boolean; } -// FormTextArea is a minimal multiline editor: append-only typing/pasting plus -// backspace. Pasted chunks arrive as one input string whose \r become -// newlines, so multi-line paste just works. Enter inserts a newline only once -// there is content — on an empty value it is left to the parent (e.g. to -// continue a wizard step). +function Cursor({ character, focused }: { character: string; focused: boolean }) { + return focused ? {character} : {character}; +} + export function FormTextArea({ name, helpText, @@ -30,28 +30,44 @@ export function FormTextArea({ previewLines = 10, focused = true, }: FormTextAreaProps) { + 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; + if (key.return) { - if (value !== "") onChange(value + "\n"); + if (value !== "") { + onChange(value.slice(0, cursor) + "\n" + value.slice(cursor)); + setRawCursor(cursor + 1); + } return; } if (key.backspace || key.delete) { - onChange(value.slice(0, -1)); + if (cursor === 0) return; + onChange(value.slice(0, cursor - 1) + value.slice(cursor)); + setRawCursor(cursor - 1); return; } if (key.ctrl || key.meta || key.escape) return; if (input !== "") { - onChange(value + input.replace(/\r/g, "\n")); + const next = input.replace(/\r/g, "\n"); + onChange(value.slice(0, cursor) + next + value.slice(cursor)); + setRawCursor(cursor + next.length); } }, { isActive: focused }, ); - const lines = value === "" ? [] : value.split("\n"); - const hidden = Math.max(0, lines.length - previewLines); - const visible = lines.slice(hidden); - return ( {name} {helpText} - {hidden > 0 && … (+{hidden} earlier lines)} - {visible.length === 0 ? ( + {value === "" ? ( - {placeholder} - + + {placeholder.slice(1)} ) : ( - visible.map((line, i) => ( - - {line} - {i === visible.length - 1 ? : null} - - )) + )} ); } + +function TextAreaValue({ + value, + cursor, + previewLines, + focused, +}: { + value: string; + cursor: number; + previewLines: number; + focused: boolean; +}) { + const lines = value.split("\n"); + const beforeCursor = value.slice(0, cursor); + const cursorLine = beforeCursor.split("\n").length - 1; + const lastNewline = beforeCursor.lastIndexOf("\n"); + const cursorColumn = cursor - lastNewline - 1; + const start = Math.max(0, cursorLine - previewLines + 1); + const visible = lines.slice(start, start + previewLines); + + return ( + <> + {start > 0 ? … (+{start} earlier lines) : null} + {visible.map((line, index) => { + const lineIndex = start + index; + if (lineIndex !== cursorLine) return {line}; + + return ( + + {line.slice(0, cursorColumn)} + + {line.slice(cursorColumn + 1)} + + ); + })} + + ); +} diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx index aea0b066a..9c14a4e14 100644 --- a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx +++ b/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx @@ -185,6 +185,14 @@ describe("Request options", () => { await press(screen, "return"); expect(screen.lastFrame()).toContain('{"prompt":\n "{{input}}"\n}'); + await press(screen, "left"); + await press(screen, "left"); + await press(screen, "left"); + await write(screen, "!"); + expect(screen.lastFrame()).toContain('{"prompt":\n "{{input}}!"\n}'); + await write(screen, "\x04"); + await press(screen, "return"); + expect(screen.lastFrame()).toContain('{"prompt":\n "{{input}}!"\n}'); await press(screen, "escape"); await press(screen, "up"); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 4212de732..5cfc4abf1 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -473,16 +473,27 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "Request options"); const optionsFrame = screen.lastFrame()!; - expect(optionsFrame).toContain("Payload · application/json"); - expect(optionsFrame).toContain("draft payload"); expect(optionsFrame).toContain("idle · Sessions"); expect(optionsFrame).toContain("╭"); const panelLines = optionsFrame.split("\n"); const panelTop = panelLines.findIndex((line) => line.includes("╭")); const panelBottom = panelLines.findIndex((line) => line.includes("╰")); + const overviewHints = panelLines.findIndex((line) => line.includes("[enter] edit")); expect(panelBottom - panelTop + 1).toBeGreaterThanOrEqual(26); + expect(overviewHints).toBeGreaterThan(panelTop); + expect(overviewHints).toBeLessThan(panelBottom); + expect(panelLines.at(-1)).not.toContain("[enter] edit"); await screen.write("ignored"); + await moveDown(screen, 2); + await screen.press("return"); + await waitForText(screen.lastFrame, "[ctl+d] save"); + const editorLines = screen.lastFrame()!.split("\n"); + const editorBottom = editorLines.findIndex((line) => line.includes("╰")); + const editorHints = editorLines.findIndex((line) => line.includes("[ctl+d] save")); + expect(editorHints).toBeLessThan(editorBottom); + expect(editorLines.at(-1)).not.toContain("[ctl+d] save"); + await screen.press("escape"); await screen.press("escape"); await waitForText(screen.lastFrame, "idle"); expect(screen.lastFrame()).toContain("draft payload"); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index bde27cde8..82348031c 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -12,6 +12,7 @@ import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker import { RuntimePicker } from "../../../components/RuntimePicker"; import { darkTheme } from "../../../components/ui/_core.js"; import { Divider } from "../../../components/ui/divider"; +import { KeyHint, type KeyHintItem } from "../../../components/ui/key-hint"; import { Spinner } from "../../../components/ui/spinner"; import type { RuntimeInvokeResponse } from "../types"; import { @@ -94,6 +95,28 @@ const metadata = (response: RuntimeInvokeResponse) => .map((entry) => entry.join(" ")) .join(" · "); +function requestOptionsKeyHints(mode: RequestOptionsMode): KeyHintItem[] { + if (mode === "overview") { + return [ + { key: "enter", label: "edit" }, + { key: "↑↓", label: "move" }, + { key: "esc", label: "back" }, + ]; + } + if (mode === "multiline") { + return [ + { key: "ctl+d", label: "save" }, + { key: "enter", label: "newline" }, + { key: "esc", label: "cancel" }, + ]; + } + return [ + { key: "enter", label: mode === "choice" ? "select" : "save" }, + ...(mode === "choice" ? [{ key: "↑↓", label: "move" }] : []), + { key: "esc", label: "cancel" }, + ]; +} + export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); const navigate = useNavigate(); @@ -329,6 +352,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke : `Payload · ${contentType}`; const floatingOptions = showOptions && columns >= 72 && rows >= 34; const optionsPanelWidth = Math.min(76, columns - 4); + const optionsKeyHints = requestOptionsKeyHints(optionsMode); const closeOptions = () => { setShowOptions(false); setOptionsMode("overview"); @@ -422,23 +446,9 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke breadcrumb={["agentcore", "runtime", "invoke", target.runtimeId, target.qualifier]} keyHints={ showOptions - ? optionsMode === "overview" - ? [ - { key: "enter", label: "edit" }, - { key: "↑↓", label: "move" }, - { key: "esc", label: "back" }, - ] - : optionsMode === "multiline" - ? [ - { key: "ctl+d", label: "save" }, - { key: "enter", label: "newline" }, - { key: "esc", label: "cancel" }, - ] - : [ - { key: "enter", label: optionsMode === "choice" ? "select" : "save" }, - ...(optionsMode === "choice" ? [{ key: "↑↓", label: "move" }] : []), - { key: "esc", label: "cancel" }, - ] + ? floatingOptions + ? [] + : optionsKeyHints : busy ? [ { key: "esc", label: "interrupt" }, @@ -531,8 +541,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke > - {optionsScreen} + + {optionsScreen} + + + + + ) : null} From 3bf185b22e4093a999f39f22d88569714eacea64 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 20:07:13 +0000 Subject: [PATCH 14/25] fix(runtime): center request options modal --- .../runtime/invoke/invoke.screen.test.tsx | 6 ++++ src/handlers/runtime/invoke/screen.tsx | 28 ++++++++++++------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 5cfc4abf1..242c299ba 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -478,8 +478,14 @@ describe("Runtime invoke console", () => { const panelLines = optionsFrame.split("\n"); const panelTop = panelLines.findIndex((line) => line.includes("╭")); const panelBottom = panelLines.findIndex((line) => line.includes("╰")); + const headerDivider = panelLines.findIndex((line) => /^─+$/.test(line)); + const payloadDivider = panelLines.findIndex( + (line, index) => index > panelBottom && /^─+$/.test(line), + ); const overviewHints = panelLines.findIndex((line) => line.includes("[enter] edit")); expect(panelBottom - panelTop + 1).toBeGreaterThanOrEqual(26); + expect(panelLines[panelTop]!.indexOf("╭")).toBe(12); + expect(panelTop - headerDivider - 1).toBe(payloadDivider - panelBottom - 1); expect(overviewHints).toBeGreaterThan(panelTop); expect(overviewHints).toBeLessThan(panelBottom); expect(panelLines.at(-1)).not.toContain("[enter] edit"); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 82348031c..10d28ba9c 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -337,6 +337,8 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke 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 optionsRegionHeight = transcriptHeight + 1; const canPrettyJson = history.some((exchange) => exchange.pretty !== undefined); const contentType = requestOptions.contentType || "application/json"; const templateActive = @@ -350,8 +352,17 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke Math.max(1, columns), ) : `Payload · ${contentType}`; - const floatingOptions = showOptions && columns >= 72 && rows >= 34; + const floatingOptions = showOptions && columns >= 72 && transcriptHeight >= 30; const optionsPanelWidth = Math.min(76, columns - 4); + const optionsPanelLeft = Math.floor((columns - optionsPanelWidth) / 2); + // Match the rendered region's parity so Ink can leave identical gaps above and below the panel. + const optionsPanelTargetHeight = Math.max( + 1, + Math.min(optionsRegionHeight - 2, Math.max(28, Math.floor(optionsRegionHeight * 0.85))), + ); + const optionsPanelHeight = + optionsPanelTargetHeight - ((optionsRegionHeight - optionsPanelTargetHeight) % 2); + const optionsPanelTop = Math.max(0, (optionsRegionHeight - optionsPanelHeight) / 2 - 1); const optionsKeyHints = requestOptionsKeyHints(optionsMode); const closeOptions = () => { setShowOptions(false); @@ -483,7 +494,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke ) : ( <> - + {history.map((exchange, index) => ( @@ -532,17 +543,14 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke {floatingOptions ? ( Date: Fri, 31 Jul 2026 20:17:16 +0000 Subject: [PATCH 15/25] fix(runtime): match request options background --- src/handlers/runtime/invoke/screen.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 10d28ba9c..647b49875 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -554,7 +554,6 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke flexDirection="column" borderStyle="round" borderColor={theme.colors.border} - backgroundColor="black" paddingX={1} overflow="hidden" > From 8bfbb7dbbbe3a0066138e44d8aa35cd2af205f3b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 20:35:35 +0000 Subject: [PATCH 16/25] fix(runtime): preserve blank payload editor rows --- .../runtime/invoke/RuntimePayloadInput.tsx | 2 +- .../runtime/invoke/invoke.screen.test.tsx | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx b/src/handlers/runtime/invoke/RuntimePayloadInput.tsx index 08d3779a6..9905bff52 100644 --- a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx +++ b/src/handlers/runtime/invoke/RuntimePayloadInput.tsx @@ -104,7 +104,7 @@ export function RuntimePayloadInput({ return ( {prefix} - {line} + {line || " "} ); } diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 242c299ba..08a15d1b0 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -255,6 +255,42 @@ describe("Runtime invoke console", () => { expect(new TextDecoder().decode(request.payload)).toBe("first\nsecond"); }); + test("keeps blank multiline payload rows inside the editor", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "idle"); + const initialIdleLine = screen + .lastFrame()! + .split("\n") + .findIndex((line) => line.includes("idle · Sessions")); + + for (let index = 0; index < 3; index++) await screen.write("\x1b[13;2u"); + + const expandedLines = screen.lastFrame()!.split("\n"); + const labelLine = expandedLines.findIndex((line) => + line.includes("Payload · application/json"), + ); + const lowerDivider = expandedLines.findIndex( + (line, index) => index > labelLine && /^─+$/.test(line), + ); + expect(expandedLines.findIndex((line) => line.includes("idle · Sessions"))).toBe( + initialIdleLine, + ); + expect(lowerDivider - labelLine).toBe(5); + expect(screen.lastFrame()).not.toContain("…"); + + await screen.write("\x1b[13;2u"); + expect(screen.lastFrame()).toContain("…"); + expect( + screen + .lastFrame()! + .split("\n") + .findIndex((line) => line.includes("idle · Sessions")), + ).toBe(initialIdleLine); + }); + test("labels the payload editor with its active content type", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); From 0bd5189677804ac1990ecf3078fc714be91d65f9 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 21:03:29 +0000 Subject: [PATCH 17/25] refactor(runtime): simplify invoke TUI to JSON console --- README.md | 22 +- bun.lock | 13 - package.json | 1 - src/components/FormTextArea.tsx | 97 +- .../invoke/RequestOptionsScreen.test.tsx | 243 ---- .../runtime/invoke/RequestOptionsScreen.tsx | 332 ----- src/handlers/runtime/invoke/index.tsx | 6 +- .../runtime/invoke/invoke.screen.test.tsx | 1094 ++--------------- src/handlers/runtime/invoke/invoke.test.tsx | 20 + .../runtime/invoke/payloadTemplate.test.ts | 47 - .../runtime/invoke/payloadTemplate.ts | 66 - src/handlers/runtime/invoke/screen.tsx | 497 +++----- 12 files changed, 344 insertions(+), 2094 deletions(-) delete mode 100644 src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx delete mode 100644 src/handlers/runtime/invoke/RequestOptionsScreen.tsx delete mode 100644 src/handlers/runtime/invoke/payloadTemplate.test.ts delete mode 100644 src/handlers/runtime/invoke/payloadTemplate.ts diff --git a/README.md b/README.md index 58f992d1f..bfc7b74ab 100644 --- a/README.md +++ b/README.md @@ -254,16 +254,17 @@ agentcore runtime invoke --id --payload '{"action":"status"}' --json # {"statusCode":200,"contentType":"application/json","bodyEncoding":"utf8","body":"{\"ok\":true}","complete":true} ``` -Without `--payload`, Runtime Invoke opens a persistent console for repeated -requests. Bare invoke opens the Runtime and endpoint pickers; `--id` skips the -Runtime picker, and `--id` plus `--qualifier` opens the console directly. +Without `--payload`, Runtime Invoke opens a persistent JSON console for repeated +requests. The console sends inline `application/json` payloads and renders each +response according to its returned content type. Bare invoke opens the Runtime +and endpoint pickers; `--id` skips the Runtime picker, and `--id` plus +`--qualifier` opens the console directly. `--session-id` resumes that Runtime +session in the console. | Shortcut | Action | | ------------- | -------------------------------------------- | -| `Enter` | Send the request | -| `Shift+Enter` | Insert a newline (`Alt+Enter` also works) | -| `Ctrl+D` | Send the request (alternate shortcut) | -| `Ctrl+O` | Open Request Options | +| `Enter` | Send the JSON request | +| `Shift+Enter` | Insert a newline | | `Ctrl+T` | Change Runtime or endpoint | | `Ctrl+V` | Toggle raw and pretty completed JSON | | `Esc` | Interrupt an active request or navigate back | @@ -274,9 +275,10 @@ accept ARNs, `--version`, `--interactive`, cross-account targets, or custom request paths. All requests use the Runtime `/invocations` route, including MCP Runtimes. -Bare Runtime and Memory branches and leaves require a TTY on stdin and stdout. Supplying -operation flags runs the command headlessly, and `--json` always suppresses TUI -rendering. +Bare Runtime and Memory branches and leaves require a TTY on stdin and stdout. +For Runtime Invoke, supplying a payload or advanced request options runs headlessly; +`--session-id` can instead seed the persistent console. `--json` always +suppresses TUI rendering. ```bash agentcore runtime diff --git a/bun.lock b/bun.lock index a61c220ed..774e24652 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "@tanstack/react-query": "^5.101.2", "cli-truncate": "^6.1.1", "commander": "^15.0.0", - "handlebars": "^4.7.9", "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", "lodash": "^4.18.1", @@ -205,8 +204,6 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], - "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -241,14 +238,10 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], @@ -289,8 +282,6 @@ "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -319,8 +310,6 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -333,8 +322,6 @@ "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], - "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], - "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], diff --git a/package.json b/package.json index c2fa1da53..935251cdc 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "string-width": "^8.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "handlebars": "^4.7.9", "zod": "^4.4.3" } } diff --git a/src/components/FormTextArea.tsx b/src/components/FormTextArea.tsx index 1c9e04938..14a816081 100644 --- a/src/components/FormTextArea.tsx +++ b/src/components/FormTextArea.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { Box, Text, useInput } from "ink"; import { darkTheme } from "./ui/_core.js"; @@ -17,10 +16,11 @@ export interface FormTextAreaProps { focused?: boolean; } -function Cursor({ character, focused }: { character: string; focused: boolean }) { - return focused ? {character} : {character}; -} - +// FormTextArea is a minimal multiline editor: append-only typing/pasting plus +// backspace. Pasted chunks arrive as one input string whose \r become +// newlines, so multi-line paste just works. Enter inserts a newline only once +// there is content — on an empty value it is left to the parent (e.g. to +// continue a wizard step). export function FormTextArea({ name, helpText, @@ -30,44 +30,28 @@ export function FormTextArea({ previewLines = 10, focused = true, }: FormTextAreaProps) { - 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; - if (key.return) { - if (value !== "") { - onChange(value.slice(0, cursor) + "\n" + value.slice(cursor)); - setRawCursor(cursor + 1); - } + if (value !== "") onChange(value + "\n"); return; } if (key.backspace || key.delete) { - if (cursor === 0) return; - onChange(value.slice(0, cursor - 1) + value.slice(cursor)); - setRawCursor(cursor - 1); + onChange(value.slice(0, -1)); return; } if (key.ctrl || key.meta || key.escape) return; if (input !== "") { - const next = input.replace(/\r/g, "\n"); - onChange(value.slice(0, cursor) + next + value.slice(cursor)); - setRawCursor(cursor + next.length); + onChange(value + input.replace(/\r/g, "\n")); } }, { isActive: focused }, ); + const lines = value === "" ? [] : value.split("\n"); + const hidden = Math.max(0, lines.length - previewLines); + const visible = lines.slice(hidden); + return ( {name} {helpText} - {value === "" ? ( + {hidden > 0 && … (+{hidden} earlier lines)} + {visible.length === 0 ? ( - - {placeholder.slice(1)} + {placeholder} + ) : ( - + visible.map((line, i) => ( + + {line} + {i === visible.length - 1 ? : null} + + )) )} ); } - -function TextAreaValue({ - value, - cursor, - previewLines, - focused, -}: { - value: string; - cursor: number; - previewLines: number; - focused: boolean; -}) { - const lines = value.split("\n"); - const beforeCursor = value.slice(0, cursor); - const cursorLine = beforeCursor.split("\n").length - 1; - const lastNewline = beforeCursor.lastIndexOf("\n"); - const cursorColumn = cursor - lastNewline - 1; - const start = Math.max(0, cursorLine - previewLines + 1); - const visible = lines.slice(start, start + previewLines); - - return ( - <> - {start > 0 ? … (+{start} earlier lines) : null} - {visible.map((line, index) => { - const lineIndex = start + index; - if (lineIndex !== cursorLine) return {line}; - - return ( - - {line.slice(0, cursorColumn)} - - {line.slice(cursorColumn + 1)} - - ); - })} - - ); -} diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx deleted file mode 100644 index 9c14a4e14..000000000 --- a/src/handlers/runtime/invoke/RequestOptionsScreen.test.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { useState } from "react"; -import { cleanup, render } from "ink-testing-library"; -import { keys, tick } from "../../../testing"; -import { - RequestOptionsScreen, - type RequestOptionsMode, - type RuntimeInvokeOptions, -} from "./RequestOptionsScreen"; - -const defaults: RuntimeInvokeOptions = { - payloadSource: "Inline", - responseDestination: "Console", - contentType: "application/json", -}; - -type OptionsProps = { - initial?: RuntimeInvokeOptions; - customJwt?: boolean; - mcp?: boolean; - onModeChange?: (mode: RequestOptionsMode) => void; -}; - -function Options({ - initial = defaults, - customJwt = false, - mcp = false, - onModeChange, -}: OptionsProps) { - const [value, setValue] = useState(initial); - return ( - {}} - onModeChange={onModeChange} - customJwt={customJwt} - mcp={mcp} - /> - ); -} - -type RenderedOptions = ReturnType; - -async function write(screen: RenderedOptions, input: string) { - await tick(); - screen.stdin.write(input); - await tick(); -} - -async function press(screen: RenderedOptions, key: keyof typeof keys) { - await write(screen, keys[key]); -} - -async function moveDown(screen: RenderedOptions, count: number) { - for (let index = 0; index < count; index++) await press(screen, "down"); -} - -afterEach(cleanup); - -describe("Request options", () => { - test("groups context-specific options and redacts sensitive values", () => { - const httpScreen = render(); - const httpFrame = httpScreen.lastFrame()!; - - expect(httpFrame).toContain("Request options"); - expect(httpFrame).toMatch( - /Payload[\s\S]*Source\s+Inline[\s\S]*Content type\s+application\/json[\s\S]*Payload template\s+Not set/, - ); - expect(httpFrame).toMatch(/Response[\s\S]*Accept\s+Automatic[\s\S]*Destination\s+Console/); - expect(httpFrame).toMatch(/Runtime[\s\S]*Session ID\s+Not set[\s\S]*User ID\s+Not set/); - expect(httpFrame).toContain("Trace"); - expect(httpFrame).not.toContain("MCP"); - expect(httpFrame).not.toContain("Bearer JWT"); - httpScreen.unmount(); - - const mcpScreen = render( - , - ); - const mcpFrame = mcpScreen.lastFrame()!; - - expect(mcpFrame).toMatch(/Application headers\s+2 headers/); - expect(mcpFrame).toMatch(/Bearer JWT\s+Configured/); - expect(mcpFrame).toMatch(/MCP[\s\S]*Session ID\s+mcp-session[\s\S]*Protocol version\s+Not set/); - expect(mcpFrame).not.toContain("secret-token"); - expect(mcpFrame).not.toContain("X-Tenant"); - expect(mcpFrame).not.toContain("retail"); - }); - - test("highlights saved choices and reveals conditional rows", async () => { - const modes: RequestOptionsMode[] = []; - const screen = render( modes.push(mode)} />); - - await press(screen, "return"); - const frame = screen.lastFrame()!; - expect(frame).not.toContain("Request options"); - expect(frame).toContain("Source"); - expect(frame).toContain("❯ Inline"); - expect(frame).toContain(" File"); - expect(modes).toEqual(["choice"]); - - await press(screen, "down"); - await press(screen, "return"); - expect(screen.lastFrame()).toMatch(/Source\s+File[\s\S]*File path\s+Not set/); - expect(modes).toEqual(["choice", "overview"]); - - await press(screen, "return"); - expect(screen.lastFrame()).toContain("❯ File"); - await press(screen, "escape"); - expect(modes).toEqual(["choice", "overview", "choice", "overview"]); - }); - - test("cancels and saves a custom media type", async () => { - const screen = render(); - - await press(screen, "down"); - await press(screen, "return"); - await moveDown(screen, 3); - await press(screen, "return"); - await write(screen, "application/cancelled"); - await press(screen, "escape"); - expect(screen.lastFrame()).toMatch(/Content type\s+application\/json/); - expect(screen.lastFrame()).not.toContain("application/cancelled"); - - await press(screen, "return"); - await moveDown(screen, 3); - await press(screen, "return"); - await write(screen, "application/vnd.example+json"); - await press(screen, "return"); - expect(screen.lastFrame()).toMatch(/Content type\s+application\/vnd\.example\+json/); - - await press(screen, "return"); - expect(screen.lastFrame()).toContain("❯ Custom"); - await press(screen, "return"); - expect(screen.lastFrame()).toContain("application/vnd.example+json"); - }); - - test("resets Accept to Automatic", async () => { - const screen = render(); - - await moveDown(screen, 3); - await press(screen, "return"); - expect(screen.lastFrame()).toContain("❯ text/plain"); - await press(screen, "up"); - await press(screen, "up"); - await press(screen, "return"); - - expect(screen.lastFrame()).toMatch(/Accept\s+Automatic/); - }); - - test("saves multiline headers with Ctrl+D and cancels without leaking values", async () => { - const screen = render(); - - await moveDown(screen, 7); - await press(screen, "return"); - await write(screen, "X-Tenant: retail\nX-Mode: fast"); - await write(screen, "\x04"); - expect(screen.lastFrame()).toMatch(/Application headers\s+2 headers/); - expect(screen.lastFrame()).not.toContain("retail"); - - await press(screen, "return"); - await write(screen, "\nX-Cancelled: secret"); - await press(screen, "escape"); - expect(screen.lastFrame()).toMatch(/Application headers\s+2 headers/); - expect(screen.lastFrame()).not.toContain("X-Cancelled"); - }); - - test("shows and validates multiline templates only for inline JSON payloads", async () => { - const screen = render(); - - await moveDown(screen, 2); - await press(screen, "return"); - await write(screen, '{"prompt":\n "{{input}}"\n}'); - await write(screen, "\x04"); - expect(screen.lastFrame()).toMatch(/Payload template\s+3-line template/); - - await press(screen, "return"); - expect(screen.lastFrame()).toContain('{"prompt":\n "{{input}}"\n}'); - await press(screen, "left"); - await press(screen, "left"); - await press(screen, "left"); - await write(screen, "!"); - expect(screen.lastFrame()).toContain('{"prompt":\n "{{input}}!"\n}'); - await write(screen, "\x04"); - await press(screen, "return"); - expect(screen.lastFrame()).toContain('{"prompt":\n "{{input}}!"\n}'); - await press(screen, "escape"); - - await press(screen, "up"); - await press(screen, "return"); - await press(screen, "down"); - await press(screen, "return"); - expect(screen.lastFrame()).not.toContain("Payload template"); - - await press(screen, "return"); - await press(screen, "up"); - await press(screen, "return"); - expect(screen.lastFrame()).toMatch(/Payload template\s+3-line template/); - screen.unmount(); - - const invalid = render(); - await moveDown(invalid, 2); - await press(invalid, "return"); - await write(invalid, '{"prompt":"fixed"}'); - await write(invalid, "\x04"); - expect(invalid.lastFrame()).toContain( - 'Payload template must include "{{input}}" in a string value', - ); - expect(invalid.lastFrame()).toContain('{"prompt":"fixed"}'); - invalid.unmount(); - - const text = render( - , - ); - expect(text.lastFrame()).not.toContain("Payload template"); - text.unmount(); - - const file = render( - , - ); - expect(file.lastFrame()).not.toContain("Payload template"); - file.unmount(); - - const vendorJson = render( - , - ); - expect(vendorJson.lastFrame()).toContain("Payload template"); - }); -}); diff --git a/src/handlers/runtime/invoke/RequestOptionsScreen.tsx b/src/handlers/runtime/invoke/RequestOptionsScreen.tsx deleted file mode 100644 index bdee08619..000000000 --- a/src/handlers/runtime/invoke/RequestOptionsScreen.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import { useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { FormTextArea } from "../../../components/FormTextArea"; -import { darkTheme } from "../../../components/ui/_core.js"; -import { Select } from "../../../components/ui/select"; -import { TextInput } from "../../../components/ui/text-input"; -import { InputValidationError } from "../../../errors"; -import { renderPayloadTemplate, supportsPayloadTemplate } from "./payloadTemplate"; - -export type RuntimeInvokeOptions = { - payloadSource: "Inline" | "File"; - responseDestination: "Console" | "File"; - payloadPath?: string; - contentType?: string; - payloadTemplate?: string; - accept?: string; - outputPath?: string; - runtimeSessionId?: string; - runtimeUserId?: string; - headers?: string; - bearerToken?: string; - mcpSessionId?: string; - mcpProtocolVersion?: string; - mcpMethod?: string; - mcpName?: string; - traceId?: string; - traceParent?: string; - traceState?: string; - baggage?: string; -}; - -export type RequestOptionsMode = "overview" | "choice" | "text" | "multiline"; - -type OptionSection = "Payload" | "Response" | "Runtime" | "MCP" | "Trace"; - -type Choice = { - label: string; - value?: string; - custom?: boolean; -}; - -type Row = { - section: OptionSection; - field: keyof RuntimeInvokeOptions; - label: string; - choices?: Choice[]; - multiline?: boolean; - placeholder?: string; - secret?: boolean; -}; - -const theme = darkTheme; - -const choice = (value: string): Choice => ({ label: value, value }); - -function optionRows(value: RuntimeInvokeOptions, customJwt: boolean, mcp: boolean): Row[] { - return [ - { - section: "Payload", - field: "payloadSource", - label: "Source", - choices: [choice("Inline"), choice("File")], - }, - ...(value.payloadSource === "File" - ? [{ section: "Payload", field: "payloadPath", label: "File path" } as Row] - : []), - { - section: "Payload", - field: "contentType", - label: "Content type", - choices: [ - choice("application/json"), - choice("text/plain"), - choice("application/octet-stream"), - { label: "Custom", custom: true }, - ], - }, - ...(value.payloadSource === "Inline" && supportsPayloadTemplate(value.contentType) - ? [ - { - section: "Payload", - field: "payloadTemplate", - label: "Payload template", - multiline: true, - placeholder: '{"prompt":"{{input}}"}', - } as Row, - ] - : []), - { - section: "Response", - field: "accept", - label: "Accept", - choices: [ - { label: "Automatic" }, - choice("application/json"), - choice("text/plain"), - choice("text/event-stream"), - choice("application/octet-stream"), - { label: "Custom", custom: true }, - ], - }, - { - section: "Response", - field: "responseDestination", - label: "Destination", - choices: [choice("Console"), choice("File")], - }, - ...(value.responseDestination === "File" - ? [{ section: "Response", field: "outputPath", label: "File path" } as Row] - : []), - { section: "Runtime", field: "runtimeSessionId", label: "Session ID" }, - { section: "Runtime", field: "runtimeUserId", label: "User ID" }, - { - section: "Runtime", - field: "headers", - label: "Application headers", - multiline: true, - }, - ...(customJwt - ? [{ section: "Runtime", field: "bearerToken", label: "Bearer JWT", secret: true } as Row] - : []), - ...(mcp - ? ([ - { section: "MCP", field: "mcpSessionId", label: "Session ID" }, - { section: "MCP", field: "mcpProtocolVersion", label: "Protocol version" }, - { - section: "MCP", - field: "mcpMethod", - label: "Method", - choices: [ - choice("tools/call"), - choice("tools/list"), - choice("resources/read"), - choice("prompts/get"), - { label: "Custom", custom: true }, - ], - }, - { section: "MCP", field: "mcpName", label: "Name" }, - ] as Row[]) - : []), - { section: "Trace", field: "traceId", label: "Trace ID" }, - { section: "Trace", field: "traceParent", label: "Traceparent" }, - { section: "Trace", field: "traceState", label: "Tracestate" }, - { section: "Trace", field: "baggage", label: "Baggage" }, - ]; -} - -function optionSummary(row: Row, value: RuntimeInvokeOptions): string { - const current = value[row.field]; - if (row.secret) return current ? "Configured" : "Not set"; - if (row.field === "headers") { - const count = current?.split("\n").filter((line) => line.trim()).length ?? 0; - return count === 0 ? "Not set" : `${count} ${count === 1 ? "header" : "headers"}`; - } - if (row.field === "payloadTemplate") { - if (!current) return "Not set"; - const lines = current.split("\n").length; - return `${lines}-line template`; - } - if (row.field === "accept") return current || "Automatic"; - return current || "Not set"; -} - -function selectedChoiceIndex(row: Row, current: string | undefined): number { - const exact = row.choices!.findIndex((item) => !item.custom && item.value === current); - if (exact >= 0) return exact; - if (current === undefined) return 0; - return Math.max( - 0, - row.choices!.findIndex((item) => item.custom), - ); -} - -export function RequestOptionsScreen({ - value, - onChange, - onClose, - onModeChange, - customJwt, - mcp, -}: { - value: RuntimeInvokeOptions; - onChange: (value: RuntimeInvokeOptions) => void; - onClose: () => void; - onModeChange?: (mode: RequestOptionsMode) => void; - customJwt: boolean; - mcp: boolean; -}) { - const rows = optionRows(value, customJwt, mcp); - const [selected, setSelected] = useState(0); - const [editing, setEditing] = useState(); - const [draft, setDraft] = useState(""); - const [custom, setCustom] = useState(false); - const [error, setError] = useState(); - const selectedIndex = Math.min(selected, rows.length - 1); - const row = rows[selectedIndex]!; - - const closeEditor = () => { - setEditing(undefined); - setCustom(false); - setError(undefined); - onModeChange?.("overview"); - }; - const save = (next?: string) => { - const normalized = row.field === "payloadTemplate" ? (next?.trim() ? next : undefined) : next; - if (row.field === "payloadTemplate" && normalized) { - try { - renderPayloadTemplate(normalized, ""); - } catch (cause) { - setError( - cause instanceof InputValidationError ? cause.message : "Payload template is invalid", - ); - return; - } - } - onChange({ ...value, [row.field]: normalized }); - closeEditor(); - }; - - useInput((input, key) => { - if (editing) { - if (key.escape) { - closeEditor(); - return; - } - if (row.multiline && key.ctrl && input === "d") save(draft); - return; - } - if (key.escape) { - onClose(); - return; - } - if (key.upArrow) { - setSelected((current) => Math.max(0, current - 1)); - return; - } - if (key.downArrow) { - setSelected((current) => Math.min(rows.length - 1, current + 1)); - return; - } - if (key.return) { - setDraft(value[row.field] ?? ""); - setCustom(false); - setError(undefined); - setEditing(row.field); - onModeChange?.(row.multiline ? "multiline" : row.choices ? "choice" : "text"); - } - }); - - if (editing) { - return ( - - {row.multiline ? ( - { - setDraft(next); - setError(undefined); - }} - /> - ) : ( - {row.label} - )} - {row.choices && !custom ? ( - - - items={row.choices.map((item, index) => ({ label: item.label, value: index }))} - initialValue={selectedChoiceIndex(row, value[row.field])} - onSelect={(item) => { - const selectedChoice = row.choices![item.value]!; - if (selectedChoice.custom) { - const savedChoice = row.choices!.some( - (choice) => choice.value === value[row.field], - ); - setDraft(savedChoice ? "" : (value[row.field] ?? "")); - setCustom(true); - onModeChange?.("text"); - } else { - save(selectedChoice.value); - } - }} - /> - - ) : !row.multiline ? ( - - - - ) : null} - {error ? {error} : null} - - ); - } - - return ( - - Request options - {rows.map((item, index) => { - const isSelected = index === selectedIndex; - const firstInSection = index === 0 || rows[index - 1]!.section !== item.section; - const summary = optionSummary(item, value); - const empty = summary === "Not set" || summary === "Automatic"; - return ( - - {firstInSection ? ( - - {item.section} - - ) : null} - - - {isSelected ? "❯ " : " "} - - - {item.label} - - {summary} - - - ); - })} - - ); -} diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 90519f202..cb5ff97ed 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -57,7 +57,8 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => } if (flags.payload === undefined) { const requestOption = Object.entries(flags).some( - ([name, value]) => !["id", "qualifier", "payload"].includes(name) && value !== undefined, + ([name, value]) => + !["id", "qualifier", "payload", "session-id"].includes(name) && value !== undefined, ); if (ctx.require(JsonKey) || requestOption) { throw new InputValidationError("required option '--payload ' not specified", { @@ -68,6 +69,9 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => if (flags.qualifier !== undefined) { path += `/${encodeURIComponent(flags.qualifier)}`; } + if (flags["session-id"] !== undefined) { + path += `?${new URLSearchParams({ "session-id": flags["session-id"] })}`; + } try { await renderTuiAt(path, ctx, core, io); } catch (error) { diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 08a15d1b0..bc1377b0c 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -1,7 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; import type { AgentRuntime, AgentRuntimeEndpoint, @@ -21,8 +18,6 @@ const RUNTIME_ID = "runtime-123"; const QUALIFIER = "prod"; const RUNTIME_ARN = `arn:aws:bedrock-agentcore:${REGION}:123456789012:runtime/${RUNTIME_ID}`; const CONSOLE_PATH = `/agentcore/runtime/invoke/${RUNTIME_ID}/${QUALIFIER}`; -const RUNTIME_USER_ID = "preserved-user"; -const APPLICATION_HEADER = "X-Tenant: secret-header"; afterEach(cleanupScreens); @@ -55,50 +50,16 @@ function endpoint(overrides: Partial = {}): AgentRuntimeEn }; } -async function* splitUtf8(text: string, splitAt: number): AsyncIterable { - const bytes = new TextEncoder().encode(text); - yield bytes.slice(0, splitAt); - yield bytes.slice(splitAt); -} - function responseBody(...chunks: Uint8Array[]): AsyncIterable { return (async function* () { yield* chunks; })(); } -type InvokeScreen = ReturnType; - -async function moveDown(screen: InvokeScreen, count: number) { - for (let index = 0; index < count; index++) await screen.press("down"); -} - -async function editText(screen: InvokeScreen, down: number, value: string) { - await moveDown(screen, down); - await screen.press("return"); - await screen.write(value); - await screen.press("return"); -} - -async function editCustom(screen: InvokeScreen, down: number, choice: number, value: string) { - await moveDown(screen, down); - await screen.press("return"); - await moveDown(screen, choice); - await screen.press("return"); - await screen.write(value); - await screen.press("return"); -} - -async function configureRuntimeScopedOptions(screen: InvokeScreen, token: string) { - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await editText(screen, 6, RUNTIME_USER_ID); - await screen.press("down"); - await screen.press("return"); - await screen.write(APPLICATION_HEADER); - await screen.write("\x04"); - await editText(screen, 1, token); - await screen.press("escape"); +function invokeRequests(core: TestCoreClient): RuntimeInvokeRequest[] { + return core.runtime.calls + .filter((call) => call.method === "invokeRuntime") + .map((call) => call.args[0] as RuntimeInvokeRequest); } describe("Runtime invoke routing", () => { @@ -134,11 +95,6 @@ describe("Runtime invoke routing", () => { `agentcore → runtime → invoke → ${runtimeId} → ${qualifier}`, ); await waitForText(screen.lastFrame, "Enter JSON payload"); - expect( - core.runtime.calls.some( - (call) => call.method === "listRuntimeEndpoints" && call.args[0] === runtimeId, - ), - ).toBe(true); }); test("esc from an initial endpoint picker returns to the Runtime picker", async () => { @@ -155,6 +111,23 @@ describe("Runtime invoke routing", () => { expect(screen.lastFrame()).toContain("agentcore → runtime → invoke"); }); + test("keeps a CLI-selected session while choosing an endpoint", async () => { + const sessionId = "cli-selected-session"; + const core = new TestCoreClient(); + core.runtime + .setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }) + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen( + `/agentcore/runtime/invoke/${RUNTIME_ID}?session-id=${encodeURIComponent(sessionId)}`, + { core }, + ); + + await waitForText(screen.lastFrame, QUALIFIER); + await screen.press("return"); + + await waitForText(screen.lastFrame, `Sessions: Runtime ${sessionId} · MCP new`); + }); + test("idle esc from an initial console returns to its endpoint picker", async () => { const core = new TestCoreClient(); core.runtime @@ -168,7 +141,6 @@ describe("Runtime invoke routing", () => { await screen.press("escape"); await waitForText(screen.lastFrame, "back-to-endpoint-picker"); - expect(screen.lastFrame()).toContain(`agentcore → runtime → invoke → ${RUNTIME_ID}`); }); test("unmount cancels the Runtime detail lookup", async () => { @@ -187,8 +159,8 @@ describe("Runtime invoke routing", () => { }); }); -describe("Runtime invoke console", () => { - test("edits an inline payload at the cursor before sending", async () => { +describe("Runtime invoke JSON console", () => { + test("sends inline JSON with the fixed content type and no options UI", async () => { const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) @@ -199,36 +171,39 @@ describe("Runtime invoke console", () => { }); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); - await screen.write("abc"); - await screen.press("left"); - await screen.write("X"); + await waitForText(screen.lastFrame, "JSON payload"); + expect(screen.lastFrame()).not.toContain("[ctl+o] options"); + await screen.write("\x0f"); + expect(screen.lastFrame()).not.toContain("Request options"); + + await screen.write('{"prompt":"hello"}'); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); - await waitForText(screen.lastFrame, "abXc"); - expect(screen.lastFrame()).not.toContain("abcX"); - await screen.write("\x7f"); - await waitFor(() => !(screen.lastFrame() ?? "").includes("abXc")); - await screen.write("Y"); - await waitForText(screen.lastFrame, "abYc"); + expect(invokeRequests(core)[0]).toMatchObject({ + runtimeId: RUNTIME_ID, + qualifier: QUALIFIER, + contentType: "application/json", + }); + expect(new TextDecoder().decode(invokeRequests(core)[0]!.payload)).toBe('{"prompt":"hello"}'); + await waitForText(screen.lastFrame, "complete · 2 bytes"); + }); + + test("rejects invalid JSON locally without clearing the editor or invoking", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + const screen = renderScreen(CONSOLE_PATH, { core }); + await waitForText(screen.lastFrame, "idle"); + await screen.write('{"prompt":'); await screen.press("return"); - await waitFor( - () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 1, - ); - const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(new TextDecoder().decode(request.payload)).toBe("abYc"); - const frame = screen.lastFrame()!; - expect(frame).toContain("[↑↓] scroll"); - expect(frame).toContain("[ctl+c] quit"); - expect(frame).toContain("Payload · application/json"); - expect(frame).not.toContain("❯"); + + await waitForText(screen.lastFrame, "Enter a valid JSON payload"); + expect(screen.lastFrame()).toContain('{"prompt":'); + expect(invokeRequests(core)).toHaveLength(0); }); - test.each([ - ["Shift+Enter", "\x1b[13;2u"], - ["Alt+Enter", "\x1b\r"], - ])("%s inserts a newline without sending", async (_shortcut, input) => { + test("Shift+Enter inserts JSON newlines without sending", async () => { const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) @@ -240,22 +215,22 @@ describe("Runtime invoke console", () => { const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "idle"); - await screen.write("first"); - await screen.write(input); - await screen.write("second"); - await waitForText(screen.lastFrame, "first\nsecond"); - expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); + await screen.write("{"); + await screen.write("\x1b[13;2u"); + await screen.write('"prompt":"hello"'); + await screen.write("\x1b[13;2u"); + await screen.write("}"); + await waitForText(screen.lastFrame, '{\n"prompt":"hello"\n}'); + expect(invokeRequests(core)).toHaveLength(0); await screen.press("return"); - await waitFor( - () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 1, + await waitFor(() => invokeRequests(core).length === 1); + expect(new TextDecoder().decode(invokeRequests(core)[0]!.payload)).toBe( + '{\n"prompt":"hello"\n}', ); - const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(new TextDecoder().decode(request.payload)).toBe("first\nsecond"); }); - test("keeps blank multiline payload rows inside the editor", async () => { + test("keeps blank multiline rows inside the four-line editor", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); const screen = renderScreen(CONSOLE_PATH, { core }); @@ -269,89 +244,21 @@ describe("Runtime invoke console", () => { for (let index = 0; index < 3; index++) await screen.write("\x1b[13;2u"); const expandedLines = screen.lastFrame()!.split("\n"); - const labelLine = expandedLines.findIndex((line) => - line.includes("Payload · application/json"), - ); + const labelLine = expandedLines.findIndex((line) => line.includes("JSON payload")); const lowerDivider = expandedLines.findIndex( (line, index) => index > labelLine && /^─+$/.test(line), ); + expect(lowerDivider - labelLine).toBe(5); expect(expandedLines.findIndex((line) => line.includes("idle · Sessions"))).toBe( initialIdleLine, ); - expect(lowerDivider - labelLine).toBe(5); expect(screen.lastFrame()).not.toContain("…"); await screen.write("\x1b[13;2u"); expect(screen.lastFrame()).toContain("…"); - expect( - screen - .lastFrame()! - .split("\n") - .findIndex((line) => line.includes("idle · Sessions")), - ).toBe(initialIdleLine); }); - test("labels the payload editor with its active content type", async () => { - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "Payload · application/json"); - expect(screen.lastFrame()).toContain("Enter JSON payload"); - - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await screen.press("down"); - await screen.press("return"); - await screen.press("down"); - await screen.press("return"); - await screen.press("escape"); - - await waitForText(screen.lastFrame, "Payload · text/plain"); - expect(screen.lastFrame()).toContain("Enter text payload"); - }); - - test("renders template input into the request and transcript payload", async () => { - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) - .setInvokeResponse({ - statusCode: 200, - contentType: "application/json", - body: responseBody(Buffer.from('{"ok":true}')), - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await moveDown(screen, 2); - await screen.press("return"); - await screen.write('{\n "prompt": "{{input}}",\n "context": "User: {{input}}"\n}'); - await screen.write("\x04"); - await screen.press("escape"); - - await waitForText(screen.lastFrame, "Input · 4-line template"); - expect(screen.lastFrame()).toContain('{"prompt":"{{input}}","context":"User: {{input}}"}'); - expect(screen.lastFrame()).toContain("Enter input"); - - await screen.write('hello "world"'); - await screen.write("\x1b[13;2u"); - await screen.write("next"); - await screen.press("return"); - await waitFor( - () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 1, - ); - - const expected = - '{"prompt":"hello \\"world\\"\\nnext","context":"User: hello \\"world\\"\\nnext"}'; - const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(new TextDecoder().decode(request.payload)).toBe(expected); - await waitForText(screen.lastFrame, expected); - }); - - test("accepts the next payload draft while a response is streaming", async () => { + test("keeps the next JSON draft while the current response streams", async () => { const release = Promise.withResolvers(); const requests: RuntimeInvokeRequest[] = []; const core = new TestCoreClient(); @@ -373,11 +280,10 @@ describe("Runtime invoke console", () => { const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "idle"); - await screen.write("first"); + await screen.write('{"turn":1}'); await screen.press("return"); await waitForText(screen.lastFrame, "partial"); - await screen.write("second"); - await waitForText(screen.lastFrame, "second"); + await screen.write('{"turn":2}'); await screen.press("return"); expect(requests).toHaveLength(1); @@ -385,8 +291,7 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, "idle"); await screen.press("return"); await waitFor(() => requests.length === 2); - - expect(new TextDecoder().decode(requests[1]!.payload)).toBe("second"); + expect(new TextDecoder().decode(requests[1]!.payload)).toBe('{"turn":2}'); }); test("keeps settled response details out of streaming frames", async () => { @@ -426,255 +331,49 @@ describe("Runtime invoke console", () => { expect(screen.lastFrame()).toContain("Runtime returned-runtime"); }); - test("manual scrolling stays detached while a response continues streaming", async () => { - const release = Promise.withResolvers(); - let deliveredTail = false; - const initial = Array.from({ length: 50 }, (_, index) => `line-${index}`).join("\n"); - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) - .setInvokeResponse({ - statusCode: 200, - contentType: "text/plain", - body: (async function* () { - yield Buffer.from(initial); - await release.promise; - deliveredTail = true; - yield Buffer.from("\nline-50"); - })(), - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("{}"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, "line-49"); - for (let index = 0; index < 10; index++) await screen.press("up"); - await waitForText(screen.lastFrame, "line-20"); - const frameCount = screen.frames.length; - - release.resolve(); - await waitFor(() => deliveredTail && screen.frames.length > frameCount); - - expect(screen.lastFrame()).toContain("line-20"); - expect(screen.lastFrame()).not.toContain("line-50"); - }); - - test.each([ - ["HTTP/IAM", {}, false], - [ - "MCP/CUSTOM_JWT", - { - protocolConfiguration: { serverProtocol: "MCP" }, - authorizerConfiguration: { customJWTAuthorizer: {} }, - }, - true, - ], - ])("Ctrl+O shows one-column options for %s", async (_name, overrides, conditional) => { + test("starts from --session-id and adopts returned Runtime and MCP sessions", async () => { + const requests: RuntimeInvokeRequest[] = []; const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN, - protocolConfiguration: { serverProtocol: "HTTP" }, - ...overrides, + protocolConfiguration: { serverProtocol: "MCP" }, } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - const frame = screen.lastFrame()!; - expect(frame.includes("Bearer JWT")).toBe(conditional); - expect(frame.includes("│ MCP")).toBe(conditional); - expect( - frame - .split("\n") - .every((line) => !(line.includes("Content type") && line.includes("Runtime user ID"))), - ).toBe(true); - if (!conditional) { - await screen.press("down"); - await screen.press("return"); - await waitForText(screen.lastFrame, "application/octet-stream"); - expect(screen.lastFrame()).toContain("text/plain"); - } - }); - - test("floats options over the console without editing the background payload", async () => { - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("draft payload"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - - const optionsFrame = screen.lastFrame()!; - expect(optionsFrame).toContain("idle · Sessions"); - expect(optionsFrame).toContain("╭"); - const panelLines = optionsFrame.split("\n"); - const panelTop = panelLines.findIndex((line) => line.includes("╭")); - const panelBottom = panelLines.findIndex((line) => line.includes("╰")); - const headerDivider = panelLines.findIndex((line) => /^─+$/.test(line)); - const payloadDivider = panelLines.findIndex( - (line, index) => index > panelBottom && /^─+$/.test(line), - ); - const overviewHints = panelLines.findIndex((line) => line.includes("[enter] edit")); - expect(panelBottom - panelTop + 1).toBeGreaterThanOrEqual(26); - expect(panelLines[panelTop]!.indexOf("╭")).toBe(12); - expect(panelTop - headerDivider - 1).toBe(payloadDivider - panelBottom - 1); - expect(overviewHints).toBeGreaterThan(panelTop); - expect(overviewHints).toBeLessThan(panelBottom); - expect(panelLines.at(-1)).not.toContain("[enter] edit"); - - await screen.write("ignored"); - await moveDown(screen, 2); - await screen.press("return"); - await waitForText(screen.lastFrame, "[ctl+d] save"); - const editorLines = screen.lastFrame()!.split("\n"); - const editorBottom = editorLines.findIndex((line) => line.includes("╰")); - const editorHints = editorLines.findIndex((line) => line.includes("[ctl+d] save")); - expect(editorHints).toBeLessThan(editorBottom); - expect(editorLines.at(-1)).not.toContain("[ctl+d] save"); - await screen.press("escape"); - await screen.press("escape"); - await waitForText(screen.lastFrame, "idle"); - expect(screen.lastFrame()).toContain("draft payload"); - expect(screen.lastFrame()).not.toContain("ignored"); - }); - - test("uses the full-screen options fallback in a compact terminal", async () => { - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.resize(60, 24); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - - expect(screen.lastFrame()).not.toContain("Payload · application/json"); - expect(screen.lastFrame()).not.toContain("idle · Sessions"); - }); - - test("manually edited protocol options reach invoke", async () => { - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ - agentRuntimeArn: RUNTIME_ARN, - protocolConfiguration: { serverProtocol: "MCP" }, - authorizerConfiguration: { customJWTAuthorizer: {} }, - requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, - } as GetAgentRuntimeResponse) - .setInvokeResponse({ + core.runtime.invokeRuntime = async (request) => { + requests.push(request); + return { statusCode: 200, - contentType: "text/plain", - body: splitUtf8("ok", 1), - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await editCustom(screen, 1, 3, "application/vnd.test+json"); - await editCustom(screen, 2, 5, "application/vnd.test-response+json"); - await editText(screen, 2, "runtime-session"); - await editText(screen, 1, "runtime-user"); - await screen.press("down"); - await screen.press("return"); - await screen.write("X-Tenant: retail\nX-Amzn-Bedrock-AgentCore-Runtime-Custom-Mode: fast"); - await screen.write("\x04"); - await editText(screen, 1, "bearer-token"); - await editText(screen, 1, "mcp-session"); - await editText(screen, 1, "2025-06-18"); - await editCustom(screen, 1, 4, "tasks/run"); - await editText(screen, 1, "task-name"); - await editText(screen, 1, "trace-id"); - await editText(screen, 1, "00-trace-id-span-id-01"); - await editText(screen, 1, "vendor=value"); - await editText(screen, 1, "tenant=retail"); - await screen.press("escape"); - await waitForText(screen.lastFrame, "idle"); - - await screen.write("{}"); - await screen.write("\x04"); - await waitFor( - () => core.runtime.calls.filter((c) => c.method === "invokeRuntime").length === 1, + contentType: "text/event-stream", + runtimeSessionId: "returned-runtime", + mcpSessionId: "returned-mcp", + body: responseBody(Buffer.from("data: done\n\n")), + }; + }; + const initialSession = "cli-selected-session"; + const screen = renderScreen( + `${CONSOLE_PATH}?session-id=${encodeURIComponent(initialSession)}`, + { core }, ); - const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(request).toMatchObject({ - contentType: "application/vnd.test+json", - accept: "application/vnd.test-response+json", - runtimeSessionId: "runtime-session", - runtimeUserId: "runtime-user", - applicationHeaders: [ - ["X-Tenant", "retail"], - ["X-Amzn-Bedrock-AgentCore-Runtime-Custom-Mode", "fast"], - ], - bearerToken: "bearer-token", - mcpSessionId: "mcp-session", - mcpProtocolVersion: "2025-06-18", - mcpMethod: "tasks/run", - mcpName: "task-name", - traceId: "trace-id", - traceParent: "00-trace-id-span-id-01", - traceState: "vendor=value", - baggage: "tenant=retail", - }); - }); - - test("Request options editors save drafts and Esc cancels them", async () => { - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await moveDown(screen, 1); - await screen.press("return"); - await moveDown(screen, 3); - await screen.press("return"); - await screen.write("application/cancelled"); - await screen.press("escape"); - await waitForText(screen.lastFrame, "Request options"); - expect(screen.lastFrame()).toMatch(/Content type\s+application\/json/); - expect(screen.lastFrame()).not.toContain("application/cancelled"); - - await screen.press("return"); - await moveDown(screen, 3); + await waitForText(screen.lastFrame, `Runtime ${initialSession}`); + await screen.write('{"turn":1}'); await screen.press("return"); - await screen.write("application/saved"); + await waitForText(screen.lastFrame, "Sessions: Runtime returned-runtime · MCP returned-mcp"); + await screen.write('{"turn":2}'); await screen.press("return"); - await waitForText(screen.lastFrame, "application/saved"); - - await moveDown(screen, 5); - await screen.press("return"); - await screen.write("X-Test: cancelled"); - await screen.press("escape"); - await waitForText(screen.lastFrame, "Request options"); - expect(screen.lastFrame()).not.toContain("X-Test: cancelled"); + await waitFor(() => requests.length === 2); - await screen.press("return"); - await screen.write("X-Test: saved"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, "1 header"); + expect(requests[0]!.runtimeSessionId).toBe(initialSession); + expect(requests[0]!.mcpSessionId).toBeUndefined(); + expect(requests[1]!.runtimeSessionId).toBe("returned-runtime"); + expect(requests[1]!.mcpSessionId).toBe("returned-mcp"); }); - test("Ctrl+T preserves Runtime-scoped credentials across endpoints and clears sessions", async () => { + test("target switching clears transcript and target-specific sessions", async () => { const nextQualifier = "canary"; - const token = "token-secret"; const core = new TestCoreClient(); core.runtime - .setGetResponse({ - agentRuntimeArn: RUNTIME_ARN, - authorizerConfiguration: { customJWTAuthorizer: {} }, - requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, - } as GetAgentRuntimeResponse) - .setListResponse({ - agentRuntimes: [runtime()], - }) + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setListResponse({ agentRuntimes: [runtime()] }) .setListEndpointsResponse({ runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], }) @@ -682,22 +381,14 @@ describe("Runtime invoke console", () => { statusCode: 200, contentType: "text/plain", runtimeSessionId: "returned-runtime", - mcpSessionId: "returned-mcp", body: responseBody(Buffer.from("old response")), }); const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "idle"); - await configureRuntimeScopedOptions(screen, token); - await screen.write("first"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, "old response"); - await waitForText(screen.lastFrame, "idle"); - - await screen.write("\x14"); - await waitForText(screen.lastFrame, "choose another Runtime"); - await screen.press("escape"); - await waitForText(screen.lastFrame, "old response"); + await screen.write('{"turn":1}'); + await screen.press("return"); + await waitForText(screen.lastFrame, "Sessions: Runtime returned-runtime"); await screen.write("\x14"); await waitForText(screen.lastFrame, "choose another Runtime"); @@ -708,326 +399,36 @@ describe("Runtime invoke console", () => { screen.lastFrame, `agentcore → runtime → invoke → ${RUNTIME_ID} → ${nextQualifier}`, ); - await waitForText(screen.lastFrame, "idle"); - expect(screen.lastFrame()).not.toContain("old response"); - expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); - - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - const endpointOptions = screen.lastFrame()!; - expect(endpointOptions).toMatch(new RegExp(`User ID\\s+${RUNTIME_USER_ID}`)); - expect(endpointOptions).toMatch(/Application headers\s+1 header/); - expect(endpointOptions).toMatch(/Bearer JWT\s+Configured/); - expect(endpointOptions).not.toContain(APPLICATION_HEADER); - expect(endpointOptions).not.toContain(token); - await screen.press("escape"); - - core.runtime.setInvokeResponse({ - statusCode: 200, - contentType: "text/plain", - body: responseBody(Buffer.from("new response")), - }); - await screen.write("second"); - await screen.write("\x04"); - await waitFor( - () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 2, - ); - - const second = core.runtime.calls.filter((call) => call.method === "invokeRuntime")[1]! - .args[0] as RuntimeInvokeRequest; - expect(second).toMatchObject({ - runtimeId: RUNTIME_ID, - qualifier: nextQualifier, - runtimeUserId: RUNTIME_USER_ID, - bearerToken: token, - applicationHeaders: [["X-Tenant", "secret-header"]], - }); - expect(second.runtimeSessionId).toBeUndefined(); - expect(second.mcpSessionId).toBeUndefined(); - }); - - test("Ctrl+T clears Runtime-scoped credentials and sessions for another Runtime", async () => { - const nextRuntimeId = "runtime-next"; - const nextQualifier = "canary"; - const nextArn = RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId); - const token = "token-secret"; - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ - agentRuntimeArn: RUNTIME_ARN, - authorizerConfiguration: { customJWTAuthorizer: {} }, - requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, - } as GetAgentRuntimeResponse) - .setListResponse({ - agentRuntimes: [ - runtime(), - runtime({ - agentRuntimeId: nextRuntimeId, - agentRuntimeName: "next-runtime", - agentRuntimeArn: nextArn, - }), - ], - }) - .setListEndpointsResponse({ - runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], - }) - .setInvokeResponse({ - statusCode: 200, - contentType: "text/plain", - runtimeSessionId: "returned-runtime", - mcpSessionId: "returned-mcp", - body: responseBody(Buffer.from("old response")), - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await configureRuntimeScopedOptions(screen, token); - await screen.write("first"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, "old response"); - await waitForText(screen.lastFrame, "idle"); - - core.runtime.setGetResponse({ - agentRuntimeArn: nextArn, - protocolConfiguration: { serverProtocol: "HTTP" }, - } as GetAgentRuntimeResponse); - await screen.write("\x14"); - await waitForText(screen.lastFrame, "next-runtime"); - await screen.press("down"); - await screen.press("return"); - await waitForText(screen.lastFrame, nextQualifier); - await screen.press("return"); - await waitForText( - screen.lastFrame, - `agentcore → runtime → invoke → ${nextRuntimeId} → ${nextQualifier}`, - ); - await waitForText(screen.lastFrame, "idle"); expect(screen.lastFrame()).not.toContain("old response"); expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - const options = screen.lastFrame()!; - expect(options).toMatch(new RegExp(`User ID\\s+${RUNTIME_USER_ID}`)); - expect(options).not.toContain("secret-header"); - expect(options).not.toContain("*".repeat(token.length)); - await screen.press("escape"); - core.runtime.setInvokeResponse({ statusCode: 200, contentType: "text/plain", body: responseBody(Buffer.from("new response")), }); - await screen.write("second"); - await screen.write("\x04"); - await waitFor( - () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 2, - ); - - const second = core.runtime.calls.filter((call) => call.method === "invokeRuntime")[1]! - .args[0] as RuntimeInvokeRequest; - expect(second).toMatchObject({ - runtimeId: nextRuntimeId, - qualifier: nextQualifier, - runtimeUserId: RUNTIME_USER_ID, - }); - expect(second.bearerToken).toBeUndefined(); - expect(second.applicationHeaders).toBeUndefined(); - expect(second.runtimeSessionId).toBeUndefined(); - expect(second.mcpSessionId).toBeUndefined(); - }); - - test("switching from MCP to HTTP omits hidden MCP options", async () => { - const nextRuntimeId = "runtime-http"; - const nextQualifier = "http"; - const nextArn = RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId); - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ - agentRuntimeArn: RUNTIME_ARN, - protocolConfiguration: { serverProtocol: "MCP" }, - } as GetAgentRuntimeResponse) - .setListResponse({ - agentRuntimes: [ - runtime({ - agentRuntimeId: nextRuntimeId, - agentRuntimeName: "http-runtime", - agentRuntimeArn: nextArn, - }), - ], - }) - .setListEndpointsResponse({ - runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], - }) - .setInvokeResponse({ - statusCode: 200, - contentType: "text/plain", - body: responseBody(Buffer.from("http response")), - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await editText(screen, 8, "mcp-session"); - await editText(screen, 1, "2025-06-18"); - await editCustom(screen, 1, 4, "tasks/run"); - await editText(screen, 1, "task-name"); - await screen.press("escape"); - - core.runtime.setGetResponse({ - agentRuntimeArn: nextArn, - protocolConfiguration: { serverProtocol: "HTTP" }, - } as GetAgentRuntimeResponse); - await screen.write("\x14"); - await waitForText(screen.lastFrame, "http-runtime"); - await screen.press("return"); - await waitForText(screen.lastFrame, nextQualifier); + await screen.write('{"turn":2}'); await screen.press("return"); - await waitForText( - screen.lastFrame, - `agentcore → runtime → invoke → ${nextRuntimeId} → ${nextQualifier}`, - ); - await waitForText(screen.lastFrame, "idle"); - - await screen.write("{}"); - await screen.write("\x04"); - - await waitForText(screen.lastFrame, "http response"); - const request = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[0] as RuntimeInvokeRequest; - expect(request).not.toHaveProperty("mcpSessionId"); - expect(request).not.toHaveProperty("mcpProtocolVersion"); - expect(request).not.toHaveProperty("mcpMethod"); - expect(request).not.toHaveProperty("mcpName"); + await waitFor(() => invokeRequests(core).length === 2); + expect(invokeRequests(core)[1]!.runtimeSessionId).toBeUndefined(); }); - test("preserves raw SSE text and both requests across two sends", async () => { - const firstResponse = 'data: {"first":"€"}\n\nnot-json'; - const secondResponse = '{"second":true}\nraw: ✓'; - const firstGate = Promise.withResolvers(); - const firstBytes = new TextEncoder().encode(firstResponse); - const firstBody = (async function* () { - yield firstBytes.slice(0, 18); - await firstGate.promise; - yield firstBytes.slice(18); - })(); - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ - agentRuntimeArn: RUNTIME_ARN, - protocolConfiguration: { serverProtocol: "MCP" }, - } as GetAgentRuntimeResponse) - .setInvokeResponse({ - statusCode: 202, - contentType: "text/event-stream", - runtimeSessionId: "returned-runtime", - mcpSessionId: "returned-mcp", - traceId: "trace-id", - traceParent: "trace-parent", - traceState: "trace-state", - baggage: "tenant=retail", - body: firstBody, - }) - .queueInvokeBody(firstBody) - .queueInvokeBody(splitUtf8(secondResponse, 23)); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "Enter JSON payload"); - await screen.write("first"); - await screen.write("\x1b[13;2u"); - await screen.write("payload"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, 'data: {"first":"'); - - await screen.write("\x04"); - expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(1); - - firstGate.resolve(); - await waitForText(screen.lastFrame, firstResponse); - await waitForText(screen.lastFrame, "idle"); - - await screen.write("second payload"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, secondResponse); - await waitFor( - () => core.runtime.calls.filter((call) => call.method === "invokeRuntime").length === 2, - ); - - const requests = core.runtime.calls - .filter((call) => call.method === "invokeRuntime") - .map((call) => call.args[0] as RuntimeInvokeRequest); - expect( - requests.map(({ runtimeId, qualifier, payload }) => ({ - runtimeId, - qualifier, - payload: new TextDecoder().decode(payload), - })), - ).toEqual([ - { - runtimeId: RUNTIME_ID, - qualifier: QUALIFIER, - payload: "first\npayload", - }, - { - runtimeId: RUNTIME_ID, - qualifier: QUALIFIER, - payload: "second payload", - }, - ]); - expect(core.runtime.calls.filter((call) => call.method === "getRuntime")).toHaveLength(1); - - const finalFrame = screen.lastFrame()!; - expect(finalFrame).toContain("first\npayload"); - expect(finalFrame).toContain(firstResponse); - expect(finalFrame).toContain( - `Request\nsecond payload\nResponse · 202 · text/event-stream\n${secondResponse}`, - ); - expect( - finalFrame.match(new RegExp(secondResponse.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")), - ).toHaveLength(1); - expect(finalFrame.match(/first\npayload/g)).toHaveLength(1); - expect(finalFrame.match(/data: \{"first":"€"\}/g)).toHaveLength(1); - expect(finalFrame).toContain(`agentcore → runtime → invoke → ${RUNTIME_ID} → ${QUALIFIER}`); - expect(finalFrame).toContain("Sessions: Runtime returned-runtime · MCP returned-mcp"); - expect(finalFrame).toContain("Response · 202 · text/event-stream"); - expect(finalFrame).toContain( - "Runtime returned-runtime · MCP returned-mcp · trace trace-id · traceparent trace-parent", - ); - expect(finalFrame).toContain( - `complete · ${new TextEncoder().encode(secondResponse).byteLength} bytes`, - ); - }); - - test("toggles a completed valid JSON response between raw and pretty text with Ctrl+V", async () => { + test("toggles a completed JSON response between raw and pretty text", async () => { const raw = '{"z":1,"nested":{"ok":true}}'; - const first = '{"z":1,'; - const second = '"nested":{"ok":true}}'; const pretty = JSON.stringify(JSON.parse(raw), null, 2); - const release = Promise.withResolvers(); - const mutable = Buffer.alloc(second.length); const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "application/json", - body: (async function* () { - mutable.set(Buffer.from(first)); - yield mutable.subarray(0, first.length); - await release.promise; - mutable.set(Buffer.from(second)); - yield mutable.subarray(0, second.length); - })(), + body: responseBody(Buffer.from(raw)), }); const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "idle"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, first); - expect(screen.lastFrame()).toContain("streaming…"); - - release.resolve(); + await screen.write("{}"); + await screen.press("return"); await waitForText(screen.lastFrame, raw); await waitForText(screen.lastFrame, "idle"); @@ -1037,300 +438,52 @@ describe("Runtime invoke console", () => { await waitForText(screen.lastFrame, raw); }); - test("keeps invalid completed JSON raw, notes the presentation error, and returns idle", async () => { - const raw = '{"broken":'; - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) - .setInvokeResponse({ - statusCode: 200, - contentType: "application/json", - body: responseBody(Buffer.from(raw)), - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x04"); - - await waitForText(screen.lastFrame, raw); - await waitForText(screen.lastFrame, "Invalid JSON response; showing raw text."); - await waitForText(screen.lastFrame, "idle"); - }); - - test("keeps invalid UTF-8 bytes and shows a presentation error after completion", async () => { - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) - .setInvokeResponse({ - statusCode: 200, - contentType: "text/plain", - body: responseBody(Buffer.from([0x66, 0x80])), - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x04"); - - await waitForText(screen.lastFrame, "f�"); - await waitForText(screen.lastFrame, "Invalid UTF-8 response; showing raw text."); - await waitForText(screen.lastFrame, "idle"); - }); - - test("does not consume a binary Console response", async () => { + test("rejects binary console responses before consuming their bodies", async () => { let iterations = 0; - const source = (async function* () { - iterations++; - yield Buffer.from([0, 255]); - })(); const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setInvokeResponse({ statusCode: 200, contentType: "application/octet-stream", - runtimeSessionId: "refused-runtime", - mcpSessionId: "refused-mcp", - body: source, - }); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x04"); - - await waitForText( - screen.lastFrame, - "Binary or unknown response content requires File destination.", - ); - await waitForText(screen.lastFrame, "idle"); - expect(iterations).toBe(0); - const signal = core.runtime.calls.find((call) => call.method === "invokeRuntime")! - .args[2] as AbortSignal; - expect(signal.aborted).toBe(true); - expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); - }); - - test("writes a binary File response exactly and records its byte count", async () => { - const file = join(tmpdir(), `runtime-binary-response-${process.pid}`); - const bytes = Buffer.from([0, 255, 10, 1]); - const core = new TestCoreClient(); - core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) - .setInvokeResponse({ - statusCode: 200, - contentType: "application/octet-stream", - body: responseBody(bytes.slice(0, 2), bytes.slice(2)), + body: (async function* () { + iterations++; + yield Buffer.from([0, 255]); + })(), }); const screen = renderScreen(CONSOLE_PATH, { core }); - try { - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - for (let index = 0; index < 4; index++) await screen.press("down"); - await screen.press("return"); - await screen.press("down"); - await screen.press("return"); - await screen.press("down"); - await screen.press("return"); - await screen.write(file); - await screen.press("return"); - await screen.press("escape"); - await waitForText(screen.lastFrame, "idle"); - - await screen.write("\x04"); - - await waitForText(screen.lastFrame, `Saved 4 bytes to ${file}`); - expect(Buffer.from(await Bun.file(file).bytes())).toEqual(bytes); - } finally { - await rm(file, { force: true }); - } - }); - - test("requires a File response path before invoking", async () => { - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - for (let index = 0; index < 4; index++) await screen.press("down"); - await screen.press("return"); - await screen.press("down"); - await screen.press("return"); - await screen.press("escape"); - await screen.write("\x04"); - - await waitForText(screen.lastFrame, "Response path is required for File destination."); - expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); - }); - - test("shows unreadable payload files as local request errors", async () => { - const missing = join(tmpdir(), `missing-runtime-screen-payload-${process.pid}`); - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await screen.press("return"); - await screen.press("down"); + await screen.write("{}"); await screen.press("return"); - await editText(screen, 1, missing); - await screen.press("escape"); - await screen.write("\x04"); - - await waitForText(screen.lastFrame, "Error: could not read '--payload' from file"); - await waitForText(screen.lastFrame, "idle"); - expect(screen.lastFrame()).not.toContain("response stream failed"); - expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); - }); - - test("rejects stdin payload sources without consuming TUI input", async () => { - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("-"); - await screen.write("\x04"); await waitForText( screen.lastFrame, - "Error: stdin sources are not available in the interactive console", + "Binary or unknown responses require headless invoke with --output-file.", ); - await waitForText(screen.lastFrame, "idle"); - expect(core.runtime.calls.filter((call) => call.method === "invokeRuntime")).toHaveLength(0); + expect(iterations).toBe(0); }); - test("shows a CUSTOM_JWT HTTP status without exposing request or response secrets", async () => { - const token = "secret-bearer-token"; - const responseBody = "secret response body"; + test("directs CUSTOM_JWT users to the headless bearer-token option", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN, authorizerConfiguration: { customJWTAuthorizer: {} }, } as GetAgentRuntimeResponse); - core.runtime.invokeRuntime = async () => { - throw Object.assign(new Error("HTTP 401"), { - cause: new Error(`Bearer ${token}: ${responseBody}`), - }); - }; - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("\x0f"); - await waitForText(screen.lastFrame, "Request options"); - await editText(screen, 8, token); - await screen.press("escape"); - await screen.write("{}"); - await screen.write("\x04"); - - await waitForText(screen.lastFrame, "HTTP 401"); - const frame = screen.lastFrame()!; - expect(frame).not.toContain("response stream failed"); - expect(frame).not.toContain(token); - expect(frame).not.toContain(responseBody); - }); - - test("shows sanitized Core invocation diagnostics", async () => { - const message = - "Runtime invocation failed (ValidationException, HTTP 400, request ID request-123)"; - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - core.runtime.invokeRuntime = async () => { - throw new Error(message); - }; const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "idle"); await screen.write("{}"); - await screen.write("\x04"); - - await waitForText(screen.lastFrame, message); - expect(screen.lastFrame()).not.toContain("response stream failed"); - }); - - test("adopts returned sessions only after response completion", async () => { - const requests: RuntimeInvokeRequest[] = []; - const core = new TestCoreClient(); - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - core.runtime.invokeRuntime = async (request) => { - requests.push(request); - if (requests.length === 1) { - return { - statusCode: 200, - contentType: "text/plain", - runtimeSessionId: "failed-runtime", - mcpSessionId: "failed-mcp", - body: (async function* () { - yield Buffer.from("partial"); - throw new Error("stream failed"); - })(), - }; - } - return { - statusCode: 200, - contentType: "text/plain", - body: responseBody(Buffer.from("ok")), - }; - }; - const screen = renderScreen(CONSOLE_PATH, { core }); - - await waitForText(screen.lastFrame, "idle"); - await screen.write("first"); - await screen.write("\x04"); - await waitForText(screen.lastFrame, "response stream failed"); - await waitForText(screen.lastFrame, "idle"); - await screen.write("second"); - await screen.write("\x04"); - await waitFor(() => requests.length === 2); - - expect(requests[1]!.runtimeSessionId).toBeUndefined(); - expect(requests[1]!.mcpSessionId).toBeUndefined(); - }); - - test("Esc interrupts while connecting and returns the console to idle", async () => { - const core = new TestCoreClient(); - const connection = Promise.withResolvers(); - let signal: AbortSignal | undefined; - core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - core.runtime.invokeRuntime = async (_request, _options, nextSignal) => { - signal = nextSignal; - nextSignal?.addEventListener( - "abort", - () => - connection.reject( - Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), - ), - { once: true }, - ); - return connection.promise; - }; - const screen = renderScreen(CONSOLE_PATH, { core }); - - try { - await waitForText(screen.lastFrame, "idle"); - await screen.write("{}"); - await screen.write("\x04"); - await waitFor(() => signal !== undefined); - expect(screen.lastFrame()).toContain("connecting…"); - - await screen.press("escape"); + await screen.press("return"); - expect(signal!.aborted).toBe(true); - await waitForText(screen.lastFrame, "interrupted"); - expect(screen.lastFrame()).toContain("idle"); - } finally { - connection.reject(Object.assign(new Error("stop"), { name: "AbortError" })); - } + await waitForText(screen.lastFrame, "CUSTOM_JWT Runtime requires --bearer-token"); + expect(invokeRequests(core)).toHaveLength(0); }); - test("Esc interrupts a response stream and keeps its partial text", async () => { - const core = new TestCoreClient(); + test("Esc interrupts a response stream, preserves partial text, and rejects its sessions", async () => { const stop = Promise.withResolvers(); let signal: AbortSignal | undefined; + const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); core.runtime.invokeRuntime = async (_request, _options, nextSignal) => { signal = nextSignal; @@ -1350,20 +503,17 @@ describe("Runtime invoke console", () => { try { await waitForText(screen.lastFrame, "idle"); await screen.write("{}"); - await screen.write("\x04"); + await screen.press("return"); await waitForText(screen.lastFrame, "data: partial"); - expect(screen.lastFrame()).toContain("streaming…"); await screen.press("escape"); - expect(signal?.aborted).toBe(true); stop.reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); await waitForText(screen.lastFrame, "interrupted · 14 bytes"); expect(screen.lastFrame()).toContain("data: partial"); - expect(screen.lastFrame()).toContain("idle"); expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); } finally { - stop.reject(Object.assign(new Error("stop"), { name: "AbortError" })); + stop.resolve(); } }); }); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index a1a7610a5..31bd4a5a3 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -426,6 +426,22 @@ describe("runtime invoke", () => { try { await runCommand(core, output.io, ["runtime", "invoke", "--id", "runtime/blue one"]); + await runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + "runtime/blue one", + "--session-id", + "session/one two", + ]); + await runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + "runtime/blue one", + "--qualifier", + "prod/green one", + ]); await runCommand(core, output.io, [ "runtime", "invoke", @@ -433,11 +449,15 @@ describe("runtime invoke", () => { "runtime/blue one", "--qualifier", "prod/green one", + "--session-id", + "session/one two", ]); expect(render.mock.calls.map(([path]) => path)).toEqual([ "/agentcore/runtime/invoke/runtime%2Fblue%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one?session-id=session%2Fone+two", "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one?session-id=session%2Fone+two", ]); } finally { render.mockRestore(); diff --git a/src/handlers/runtime/invoke/payloadTemplate.test.ts b/src/handlers/runtime/invoke/payloadTemplate.test.ts deleted file mode 100644 index 0986f1ee3..000000000 --- a/src/handlers/runtime/invoke/payloadTemplate.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { InputValidationError } from "../../../errors"; -import { - renderPayloadTemplate, - summarizePayloadTemplate, - supportsPayloadTemplate, -} from "./payloadTemplate"; - -describe("payload templates", () => { - test.each([ - ["application/json", true], - ["APPLICATION/JSON; charset=utf-8", true], - ["application/problem+json", true], - ["application/vnd.example+json; version=1", true], - ["text/plain", false], - ["application/x-ndjson", false], - ["application/octet-stream", false], - ["application/json-seq", false], - ])("recognizes eligible content type %s", (contentType, expected) => { - expect(supportsPayloadTemplate(contentType)).toBe(expected); - }); - - test("renders multiline JSON with embedded and repeated input markers", () => { - const template = `{ - "prompt": "{{input}}", - "messages": ["User request: {{input}}", "{{input}}"], - "unchanged": 3 -}`; - - expect(renderPayloadTemplate(template, 'hello "world"\nnext')).toBe( - '{"prompt":"hello \\"world\\"\\nnext","messages":["User request: hello \\"world\\"\\nnext","hello \\"world\\"\\nnext"],"unchanged":3}', - ); - expect(summarizePayloadTemplate(template)).toBe( - '5-line template · {"prompt":"{{input}}","messages":["User request: {{input}}","{{input}}"],"unchanged":3}', - ); - }); - - test("rejects invalid JSON and templates without an input marker in a value", () => { - expect(() => renderPayloadTemplate('{"prompt":', "hello")).toThrow(InputValidationError); - expect(() => renderPayloadTemplate('{"prompt":"fixed"}', "hello")).toThrow( - 'Payload template must include "{{input}}" in a string value', - ); - expect(() => renderPayloadTemplate('{"{{input}}":"fixed"}', "hello")).toThrow( - 'Payload template must include "{{input}}" in a string value', - ); - }); -}); diff --git a/src/handlers/runtime/invoke/payloadTemplate.ts b/src/handlers/runtime/invoke/payloadTemplate.ts deleted file mode 100644 index 19b83ce34..000000000 --- a/src/handlers/runtime/invoke/payloadTemplate.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { InputValidationError } from "../../../errors"; - -const INPUT_MARKER = "{{input}}"; - -function mediaType(contentType?: string): string { - return (contentType || "application/json").split(";", 1)[0]!.trim().toLowerCase(); -} - -export function supportsPayloadTemplate(contentType?: string): boolean { - const type = mediaType(contentType); - return type === "application/json" || /^application\/[^/]+\+json$/.test(type); -} - -function replaceInput(value: unknown, input: string): { value: unknown; replacements: number } { - if (typeof value === "string") { - const replacements = value.split(INPUT_MARKER).length - 1; - return { - value: value.replaceAll(INPUT_MARKER, input), - replacements, - }; - } - if (Array.isArray(value)) { - let replacements = 0; - const next = value.map((item) => { - const rendered = replaceInput(item, input); - replacements += rendered.replacements; - return rendered.value; - }); - return { value: next, replacements }; - } - if (value !== null && typeof value === "object") { - let replacements = 0; - const next = Object.fromEntries( - Object.entries(value).map(([key, item]) => { - const rendered = replaceInput(item, input); - replacements += rendered.replacements; - return [key, rendered.value]; - }), - ); - return { value: next, replacements }; - } - return { value, replacements: 0 }; -} - -function parsePayloadTemplate(template: string): unknown { - try { - return JSON.parse(template); - } catch (error) { - throw new InputValidationError("Payload template must be valid JSON", { cause: error }); - } -} - -export function renderPayloadTemplate(template: string, input: string): string { - const rendered = replaceInput(parsePayloadTemplate(template), input); - if (rendered.replacements === 0) { - throw new InputValidationError( - `Payload template must include "${INPUT_MARKER}" in a string value`, - ); - } - return JSON.stringify(rendered.value); -} - -export function summarizePayloadTemplate(template: string): string { - const lines = template.split("\n").length; - return `${lines}-line template · ${JSON.stringify(parsePayloadTemplate(template))}`; -} diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 647b49875..1a4fa46b9 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -1,9 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Box, Text, useInput, useStdin, useWindowSize } from "ink"; +import { Box, Text, useInput, useWindowSize } from "ink"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useNavigate, useParams, useSearchParams } from "react-router"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; -import cliTruncate from "cli-truncate"; import { InputValidationError } from "../../../errors"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -12,26 +11,11 @@ import { RuntimeEndpointPicker } from "../../../components/RuntimeEndpointPicker import { RuntimePicker } from "../../../components/RuntimePicker"; import { darkTheme } from "../../../components/ui/_core.js"; import { Divider } from "../../../components/ui/divider"; -import { KeyHint, type KeyHintItem } from "../../../components/ui/key-hint"; import { Spinner } from "../../../components/ui/spinner"; import type { RuntimeInvokeResponse } from "../types"; -import { - normalizeRuntimeInvokeRequest, - parseRuntimeInvokeHeaders, - resolveRuntimeInvokeSources, -} from "./request"; -import { - RequestOptionsScreen, - type RequestOptionsMode, - type RuntimeInvokeOptions, -} from "./RequestOptionsScreen"; +import { normalizeRuntimeInvokeRequest } from "./request"; import { RuntimePayloadInput } from "./RuntimePayloadInput"; -import { - renderPayloadTemplate, - summarizePayloadTemplate, - supportsPayloadTemplate, -} from "./payloadTemplate"; -import { classifyRuntimeResponse, writeRuntimeInvokeFile } from "./response"; +import { classifyRuntimeResponse } from "./response"; const theme = darkTheme; @@ -53,34 +37,6 @@ interface Exchange { const invokePath = (...parts: string[]) => ["/agentcore/runtime/invoke", ...parts.map(encodeURIComponent)].join("/"); -// Sessions are target-specific; credentials are reusable only within the same Runtime. -function resetOptionsForTargetChange( - options: RuntimeInvokeOptions, - currentRuntimeId: string, - nextRuntimeId: string, -): RuntimeInvokeOptions { - const { - runtimeSessionId: _runtimeSessionId, - mcpSessionId: _mcpSessionId, - ...withoutSessions - } = options; - if (currentRuntimeId === nextRuntimeId) return withoutSessions; - - const { - bearerToken: _bearerToken, - headers: _headers, - ...withoutRuntimeCredentials - } = withoutSessions; - return withoutRuntimeCredentials; -} - -function payloadPlaceholder(contentType?: string): string { - const mediaType = (contentType || "application/json").split(";", 1)[0]!.trim().toLowerCase(); - if (mediaType === "application/json" || mediaType.endsWith("+json")) return "Enter JSON payload"; - if (mediaType.startsWith("text/")) return "Enter text payload"; - return "Enter payload"; -} - const metadata = (response: RuntimeInvokeResponse) => [ ["Runtime", response.runtimeSessionId], @@ -95,31 +51,11 @@ const metadata = (response: RuntimeInvokeResponse) => .map((entry) => entry.join(" ")) .join(" · "); -function requestOptionsKeyHints(mode: RequestOptionsMode): KeyHintItem[] { - if (mode === "overview") { - return [ - { key: "enter", label: "edit" }, - { key: "↑↓", label: "move" }, - { key: "esc", label: "back" }, - ]; - } - if (mode === "multiline") { - return [ - { key: "ctl+d", label: "save" }, - { key: "enter", label: "newline" }, - { key: "esc", label: "cancel" }, - ]; - } - return [ - { key: "enter", label: mode === "choice" ? "select" : "save" }, - ...(mode === "choice" ? [{ key: "↑↓", label: "move" }] : []), - { key: "esc", label: "cancel" }, - ]; -} - export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); + const [search] = useSearchParams(); const navigate = useNavigate(); + const initialSessionId = search.get("session-id") ?? undefined; if (!runtimeId) { return ( @@ -139,40 +75,56 @@ export function RuntimeInvokeScreen(props: ScreenProps) { runtimeId={runtimeId} breadcrumb={["agentcore", "runtime", "invoke", runtimeId]} description="choose an endpoint to invoke" - onSelect={(selected) => navigate(invokePath(runtimeId, selected))} + onSelect={(selected) => { + const path = invokePath(runtimeId, selected); + navigate( + initialSessionId + ? `${path}?${new URLSearchParams({ "session-id": initialSessionId })}` + : path, + ); + }} onEscape={() => navigate(invokePath())} /> ); } - return ; + return ( + + ); } -type RuntimeInvokeConsoleProps = ScreenProps & { runtimeId: string; qualifier: string }; +type RuntimeInvokeConsoleProps = ScreenProps & { + runtimeId: string; + qualifier: string; + initialSessionId?: string; +}; -function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvokeConsoleProps) { +function RuntimeInvokeConsole({ + ctx, + core, + runtimeId, + qualifier, + initialSessionId, +}: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); - const { stdin } = useStdin(); - const { columns, rows } = useWindowSize(); + const { rows } = useWindowSize(); const [target, setTarget] = useState({ runtimeId, qualifier }); const [targetPicker, setTargetPicker] = useState(null); const detail = useQuery({ queryKey: ["runtime", opts.region, target.runtimeId], queryFn: ({ signal }) => core.runtime.getRuntime(target.runtimeId, opts, signal), }); - const customJwt = - detail.data?.authorizerConfiguration !== undefined && - "customJWTAuthorizer" in detail.data.authorizerConfiguration; const mcp = detail.data?.protocolConfiguration?.serverProtocol === "MCP"; const [payload, setPayload] = useState(""); - const [requestOptions, setRequestOptions] = useState({ - payloadSource: "Inline", - responseDestination: "Console", - contentType: "application/json", - }); - const [showOptions, setShowOptions] = useState(false); - const [optionsMode, setOptionsMode] = useState("overview"); + const [inputError, setInputError] = useState(); + const [runtimeSessionId, setRuntimeSessionId] = useState(initialSessionId); + const [mcpSessionId, setMcpSessionId] = useState(); const [history, setHistory] = useState([]); const [prettyJson, setPrettyJson] = useState(false); const abortRef = useRef(null); @@ -190,49 +142,21 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke const send = async () => { if (abortRef.current || !detail.data) return; - stickRef.current = true; + const requestPayload = payload; + try { + JSON.parse(requestPayload); + } catch { + setInputError("Enter a valid JSON payload"); + return; + } - const { - payloadSource, - payloadPath, - responseDestination, - outputPath, - headers, - bearerToken, - mcpSessionId, - mcpProtocolVersion, - mcpMethod, - mcpName, - payloadTemplate, - ...modeled - } = requestOptions; - let requestPayload = payloadSource === "File" ? `file://${payloadPath ?? ""}` : payload; + setInputError(undefined); + stickRef.current = true; const appendExchange = (response: string, state: ExchangeState) => setHistory((current) => [ ...current, { payload: requestPayload, response, byteCount: 0, state }, ]); - if ( - payloadSource === "Inline" && - payloadTemplate?.trim() && - supportsPayloadTemplate(modeled.contentType) - ) { - try { - requestPayload = renderPayloadTemplate(payloadTemplate, payload); - } catch (error) { - appendExchange( - `Error: ${ - error instanceof InputValidationError ? error.message : "Payload template is invalid" - }`, - "failed", - ); - return; - } - } - if (responseDestination === "File" && !outputPath?.trim()) { - appendExchange("Response path is required for File destination.", "failed"); - return; - } setPayload(""); appendExchange("", "connecting"); setPrettyJson(false); @@ -241,24 +165,13 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke let responseStarted = false; try { - if (requestPayload === "-" || (customJwt && bearerToken === "-")) { - throw new InputValidationError( - "stdin sources are not available in the interactive console", - ); - } - const sources = await resolveRuntimeInvokeSources( - { payload: requestPayload, bearerToken: customJwt ? bearerToken : undefined }, - stdin, - controller.signal, - ); const request = normalizeRuntimeInvokeRequest(detail.data, { - ...modeled, runtimeId: target.runtimeId, qualifier: target.qualifier, - payload: sources.payload, - applicationHeaders: parseRuntimeInvokeHeaders(headers?.split("\n").filter(Boolean)), - bearerToken: sources.bearerToken, - ...(mcp && { mcpSessionId, mcpProtocolVersion, mcpMethod, mcpName }), + payload: new TextEncoder().encode(requestPayload), + contentType: "application/json", + runtimeSessionId, + ...(mcp && { mcpSessionId }), }); const response = await core.runtime.invokeRuntime(request, opts, controller.signal); responseStarted = true; @@ -268,56 +181,46 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke state: "streaming", }); let byteCount = 0; - if (responseDestination === "File") { - await writeRuntimeInvokeFile(response, outputPath!, controller.signal, (size) => - updateExchange({ byteCount: (byteCount += size) }), - ); - updateExchange({ response: `Saved ${byteCount} bytes to ${outputPath}` }); - } else { - const kind = classifyRuntimeResponse(response.contentType); - if (kind === "binary") { - controller.abort(); - updateExchange({ - response: "Binary or unknown response content requires File destination.", - state: "failed", - }); - return; - } - const decoder = new TextDecoder(); - const chunks: Uint8Array[] = []; - let responseText = ""; - for await (const chunk of response.body) { - const snapshot = Uint8Array.from(chunk); - chunks.push(snapshot); - byteCount += snapshot.byteLength; - responseText += decoder.decode(snapshot, { stream: true }); - updateExchange({ - response: responseText, - byteCount, - }); - } - responseText += decoder.decode(); - updateExchange({ response: responseText }); - let text: string | undefined; + const kind = classifyRuntimeResponse(response.contentType); + if (kind === "binary") { + controller.abort(); + updateExchange({ + response: "Binary or unknown responses require headless invoke with --output-file.", + state: "failed", + }); + return; + } + const decoder = new TextDecoder(); + const chunks: Uint8Array[] = []; + let responseText = ""; + for await (const chunk of response.body) { + const snapshot = Uint8Array.from(chunk); + chunks.push(snapshot); + byteCount += snapshot.byteLength; + responseText += decoder.decode(snapshot, { stream: true }); + updateExchange({ + response: responseText, + byteCount, + }); + } + responseText += decoder.decode(); + updateExchange({ response: responseText }); + let text: string | undefined; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + } catch { + updateExchange({ note: "Invalid UTF-8 response; showing raw text." }); + } + if (kind === "json" && text !== undefined) { try { - text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + const pretty = JSON.stringify(JSON.parse(text), null, 2); + updateExchange({ pretty }); } catch { - updateExchange({ note: "Invalid UTF-8 response; showing raw text." }); - } - if (kind === "json" && text !== undefined) { - try { - const pretty = JSON.stringify(JSON.parse(text), null, 2); - updateExchange({ pretty }); - } catch { - updateExchange({ note: "Invalid JSON response; showing raw text." }); - } + updateExchange({ note: "Invalid JSON response; showing raw text." }); } } - setRequestOptions((current) => ({ - ...current, - runtimeSessionId: response.runtimeSessionId ?? current.runtimeSessionId, - mcpSessionId: response.mcpSessionId ?? current.mcpSessionId, - })); + if (response.runtimeSessionId) setRuntimeSessionId(response.runtimeSessionId); + if (response.mcpSessionId) setMcpSessionId(response.mcpSessionId); updateExchange({ state: "complete" }); } catch (error) { if (controller.signal.aborted || (error as Error)?.name === "AbortError") { @@ -338,54 +241,13 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke 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 optionsRegionHeight = transcriptHeight + 1; const canPrettyJson = history.some((exchange) => exchange.pretty !== undefined); - const contentType = requestOptions.contentType || "application/json"; - const templateActive = - requestOptions.payloadSource === "Inline" && - supportsPayloadTemplate(requestOptions.contentType) && - Boolean(requestOptions.payloadTemplate?.trim()); - const inputLabel = - templateActive && requestOptions.payloadTemplate - ? cliTruncate( - `Input · ${summarizePayloadTemplate(requestOptions.payloadTemplate)}`, - Math.max(1, columns), - ) - : `Payload · ${contentType}`; - const floatingOptions = showOptions && columns >= 72 && transcriptHeight >= 30; - const optionsPanelWidth = Math.min(76, columns - 4); - const optionsPanelLeft = Math.floor((columns - optionsPanelWidth) / 2); - // Match the rendered region's parity so Ink can leave identical gaps above and below the panel. - const optionsPanelTargetHeight = Math.max( - 1, - Math.min(optionsRegionHeight - 2, Math.max(28, Math.floor(optionsRegionHeight * 0.85))), - ); - const optionsPanelHeight = - optionsPanelTargetHeight - ((optionsRegionHeight - optionsPanelTargetHeight) % 2); - const optionsPanelTop = Math.max(0, (optionsRegionHeight - optionsPanelHeight) / 2 - 1); - const optionsKeyHints = requestOptionsKeyHints(optionsMode); - const closeOptions = () => { - setShowOptions(false); - setOptionsMode("overview"); - }; - const optionsScreen = ( - - ); useInput( (input, key) => { if (key.ctrl) { - if (input === "o" && !abortRef.current) setShowOptions(true); - else if (input === "v" && !abortRef.current) setPrettyJson((current) => !current); + if (input === "v" && !abortRef.current) setPrettyJson((current) => !current); else if (input === "t" && !abortRef.current) setTargetPicker({ stage: "runtime" }); - else if (input === "d") void send(); return; } if (key.escape) { @@ -409,7 +271,7 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke if (next >= bottom) stickRef.current = true; } }, - { isActive: !showOptions && targetPicker === null }, + { isActive: targetPicker === null }, ); if (targetPicker?.stage === "runtime") { @@ -439,11 +301,11 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke onSelect={(selected) => { if (nextRuntimeId !== target.runtimeId || selected !== target.qualifier) { setTarget({ runtimeId: nextRuntimeId, qualifier: selected }); - setRequestOptions((current) => - resetOptionsForTargetChange(current, target.runtimeId, nextRuntimeId), - ); + setRuntimeSessionId(undefined); + setMcpSessionId(undefined); setHistory([]); setPrettyJson(false); + setInputError(undefined); } setTargetPicker(null); }} @@ -456,118 +318,85 @@ function RuntimeInvokeConsole({ ctx, core, runtimeId, qualifier }: RuntimeInvoke - + {detail.isPending ? ( ) : detail.isError ? ( Error: {(detail.error as Error).message} - ) : showOptions && !floatingOptions ? ( - optionsScreen ) : ( - <> - - - - {history.map((exchange, index) => ( - - Request - {exchange.payload} - {exchange.heading ?? "Response"} - - {prettyJson && exchange.pretty ? exchange.pretty : exchange.response} - - {exchange.state !== "connecting" && exchange.state !== "streaming" ? ( - <> - {exchange.metadata ? {exchange.metadata} : null} - {exchange.note ? {exchange.note} : null} - - {exchange.state} · {exchange.byteCount} bytes - - - ) : null} - - ))} - - - - void send()} - submitDisabled={busy} - focused={!showOptions} - previewLines={4} - /> - - - {busy ? ( - - ) : ( - - idle · Sessions: Runtime {requestOptions.runtimeSessionId ?? "new"} · MCP{" "} - {requestOptions.mcpSessionId ?? "new"} - - )} - - - {floatingOptions ? ( - - - - {optionsScreen} - - - - + + + + {history.map((exchange, index) => ( + + Request + {exchange.payload} + {exchange.heading ?? "Response"} + + {prettyJson && exchange.pretty ? exchange.pretty : exchange.response} + + {exchange.state !== "connecting" && exchange.state !== "streaming" ? ( + <> + {exchange.metadata ? {exchange.metadata} : null} + {exchange.note ? {exchange.note} : null} + + {exchange.state} · {exchange.byteCount} bytes + + + ) : null} - - - ) : null} - + ))} + + + + { + setPayload(value); + setInputError(undefined); + }} + onSubmit={() => void send()} + submitDisabled={busy} + previewLines={4} + /> + + + {inputError ? ( + {inputError} + ) : busy ? ( + + ) : ( + + idle · Sessions: Runtime {runtimeSessionId ?? "new"} · MCP {mcpSessionId ?? "new"} + + )} + + )} From 80bcdd5e676fb52592166e4b62ca31e03012dadb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 31 Jul 2026 22:12:39 +0000 Subject: [PATCH 18/25] feat(runtime): preserve invoke context in TUI --- README.md | 6 +- src/handlers/runtime/invoke/index.tsx | 34 +++- .../runtime/invoke/invoke.screen.test.tsx | 156 ++++++++++++++++-- src/handlers/runtime/invoke/invoke.test.tsx | 57 ++++++- src/handlers/runtime/invoke/launchContext.ts | 12 ++ src/handlers/runtime/invoke/request.ts | 19 +++ src/handlers/runtime/invoke/screen.tsx | 49 ++++-- src/testing/renderScreen.tsx | 5 +- 8 files changed, 301 insertions(+), 37 deletions(-) create mode 100644 src/handlers/runtime/invoke/launchContext.ts diff --git a/README.md b/README.md index bfc7b74ab..04584a0d6 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,11 @@ requests. The console sends inline `application/json` payloads and renders each response according to its returned content type. Bare invoke opens the Runtime and endpoint pickers; `--id` skips the Runtime picker, and `--id` plus `--qualifier` opens the console directly. `--session-id` resumes that Runtime -session in the console. +session in the console. `--user-id`, `--header`, and `--bearer-token` seed +request context that persists across sends and endpoint changes within that +Runtime. The console never displays their values, and switching Runtimes clears +them. Interactive bearer tokens may be inline or `file://` sources, but not +stdin. | Shortcut | Action | | ------------- | -------------------------------------------- | diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index cb5ff97ed..45a59bb7e 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -11,9 +11,11 @@ import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, + resolveRuntimeInvokeTuiBearerToken, runtimeIdSchema, } from "./request"; import { writeRuntimeInvokeResponse } from "./response"; +import { RuntimeInvokeLaunchContextKey } from "./launchContext"; export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => createHandler({ @@ -58,7 +60,15 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => if (flags.payload === undefined) { const requestOption = Object.entries(flags).some( ([name, value]) => - !["id", "qualifier", "payload", "session-id"].includes(name) && value !== undefined, + ![ + "id", + "qualifier", + "payload", + "session-id", + "user-id", + "header", + "bearer-token", + ].includes(name) && value !== undefined, ); if (ctx.require(JsonKey) || requestOption) { throw new InputValidationError("required option '--payload ' not specified", { @@ -69,11 +79,25 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => if (flags.qualifier !== undefined) { path += `/${encodeURIComponent(flags.qualifier)}`; } - if (flags["session-id"] !== undefined) { - path += `?${new URLSearchParams({ "session-id": flags["session-id"] })}`; - } + const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); + const bearerToken = await resolveRuntimeInvokeTuiBearerToken( + flags["bearer-token"], + io.stdin, + ); + const launchContext = { + runtimeId: flags.id, + runtimeSessionId: flags["session-id"], + runtimeUserId: flags["user-id"], + applicationHeaders, + bearerToken, + }; try { - await renderTuiAt(path, ctx, core, io); + await renderTuiAt( + path, + ctx.withValue(RuntimeInvokeLaunchContextKey, launchContext), + core, + io, + ); } catch (error) { if (error instanceof InvalidEnvironmentError) { throw new InputValidationError(error.message, { diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index bc1377b0c..fa9e1c588 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -12,6 +12,7 @@ import { waitFor, waitForText, } from "../../../testing"; +import { RuntimeInvokeLaunchContextKey } from "./launchContext"; const REGION = "us-east-1"; const RUNTIME_ID = "runtime-123"; @@ -117,10 +118,14 @@ describe("Runtime invoke routing", () => { core.runtime .setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }) .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); - const screen = renderScreen( - `/agentcore/runtime/invoke/${RUNTIME_ID}?session-id=${encodeURIComponent(sessionId)}`, - { core }, - ); + const screen = renderScreen(`/agentcore/runtime/invoke/${RUNTIME_ID}`, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + runtimeSessionId: sessionId, + }), + }); await waitForText(screen.lastFrame, QUALIFIER); await screen.press("return"); @@ -349,10 +354,14 @@ describe("Runtime invoke JSON console", () => { }; }; const initialSession = "cli-selected-session"; - const screen = renderScreen( - `${CONSOLE_PATH}?session-id=${encodeURIComponent(initialSession)}`, - { core }, - ); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + runtimeSessionId: initialSession, + }), + }); await waitForText(screen.lastFrame, `Runtime ${initialSession}`); await screen.write('{"turn":1}'); @@ -368,11 +377,64 @@ describe("Runtime invoke JSON console", () => { expect(requests[1]!.mcpSessionId).toBe("returned-mcp"); }); + test("persists launch identity, authentication, and headers without exposing values", async () => { + const token = "secret-bearer-token"; + const userId = "user-123"; + const requests: RuntimeInvokeRequest[] = []; + const core = new TestCoreClient(); + core.runtime.setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + authorizerConfiguration: { customJWTAuthorizer: {} }, + requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, + } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async (request) => { + requests.push(request); + return { + statusCode: 200, + contentType: "application/json", + body: responseBody(Buffer.from('{"ok":true}')), + }; + }; + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + runtimeUserId: userId, + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: token, + }), + }); + + await waitForText(screen.lastFrame, "Context user/JWT/1h"); + expect(screen.lastFrame()).not.toContain(userId); + expect(screen.lastFrame()).not.toContain(token); + expect(screen.lastFrame()).not.toContain("retail"); + + await screen.write('{"turn":1}'); + await screen.press("return"); + await waitForText(screen.lastFrame, "idle"); + await screen.write('{"turn":2}'); + await screen.press("return"); + await waitFor(() => requests.length === 2); + + for (const request of requests) { + expect(request).toMatchObject({ + runtimeUserId: userId, + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: token, + }); + } + }); + test("target switching clears transcript and target-specific sessions", async () => { const nextQualifier = "canary"; const core = new TestCoreClient(); core.runtime - .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, + } as GetAgentRuntimeResponse) .setListResponse({ agentRuntimes: [runtime()] }) .setListEndpointsResponse({ runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], @@ -383,7 +445,15 @@ describe("Runtime invoke JSON console", () => { runtimeSessionId: "returned-runtime", body: responseBody(Buffer.from("old response")), }); - const screen = renderScreen(CONSOLE_PATH, { core }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + runtimeUserId: "user-123", + applicationHeaders: [["X-Tenant", "retail"]], + }), + }); await waitForText(screen.lastFrame, "idle"); await screen.write('{"turn":1}'); @@ -411,6 +481,72 @@ describe("Runtime invoke JSON console", () => { await screen.press("return"); await waitFor(() => invokeRequests(core).length === 2); expect(invokeRequests(core)[1]!.runtimeSessionId).toBeUndefined(); + expect(invokeRequests(core)[1]).toMatchObject({ + runtimeUserId: "user-123", + applicationHeaders: [["X-Tenant", "retail"]], + }); + }); + + test("switching Runtimes clears launch identity, authentication, and headers", async () => { + const nextRuntimeId = "runtime-next"; + const nextQualifier = "canary"; + const nextArn = RUNTIME_ARN.replace(RUNTIME_ID, nextRuntimeId); + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ + agentRuntimeArn: RUNTIME_ARN, + authorizerConfiguration: { customJWTAuthorizer: {} }, + requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, + } as GetAgentRuntimeResponse) + .setListResponse({ + agentRuntimes: [ + runtime(), + runtime({ + agentRuntimeId: nextRuntimeId, + agentRuntimeName: "next-runtime", + agentRuntimeArn: nextArn, + }), + ], + }) + .setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], + }) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), + }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + runtimeUserId: "user-123", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: "secret-token", + }), + }); + + await waitForText(screen.lastFrame, "Context user/JWT/1h"); + core.runtime.setGetResponse({ agentRuntimeArn: nextArn } as GetAgentRuntimeResponse); + await screen.write("\x14"); + await waitForText(screen.lastFrame, "next-runtime"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, nextQualifier); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → runtime → invoke → ${nextRuntimeId} → ${nextQualifier}`, + ); + expect(screen.lastFrame()).not.toContain("Context"); + + await screen.write("{}"); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + expect(invokeRequests(core)[0]!.runtimeUserId).toBeUndefined(); + expect(invokeRequests(core)[0]!.applicationHeaders).toBeUndefined(); + expect(invokeRequests(core)[0]!.bearerToken).toBeUndefined(); }); test("toggles a completed JSON response between raw and pretty text", async () => { diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 31bd4a5a3..978502797 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -14,6 +14,7 @@ import { import { ExitCode, runWithExitCode } from "../../../runnable"; import { createRootHandler } from "../../index"; import * as tui from "../../../tui"; +import { RuntimeInvokeLaunchContextKey } from "./launchContext"; const REGION = "us-west-2"; const RUNTIME_ID = "runtime-123"; @@ -156,7 +157,6 @@ describe("runtime invoke", () => { test.each([ ["--content-type", "text/plain"], - ["--header", "X-Test: value"], ["--output-file", "response.bin"], ])("rejects request option %s without a payload before Core calls", async (flagName, value) => { const core = new TestCoreClient(); @@ -455,15 +455,66 @@ describe("runtime invoke", () => { expect(render.mock.calls.map(([path]) => path)).toEqual([ "/agentcore/runtime/invoke/runtime%2Fblue%20one", - "/agentcore/runtime/invoke/runtime%2Fblue%20one?session-id=session%2Fone+two", + "/agentcore/runtime/invoke/runtime%2Fblue%20one", + "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one", - "/agentcore/runtime/invoke/runtime%2Fblue%20one/prod%2Fgreen%20one?session-id=session%2Fone+two", ]); + expect(render.mock.calls[1]![1].value(RuntimeInvokeLaunchContextKey)).toMatchObject({ + runtimeId: "runtime/blue one", + runtimeSessionId: "session/one two", + }); + expect(render.mock.calls[3]![1].value(RuntimeInvokeLaunchContextKey)).toMatchObject({ + runtimeId: "runtime/blue one", + runtimeSessionId: "session/one two", + }); + } finally { + render.mockRestore(); + } + }); + + test("passes launch identity, authentication, and headers to the TUI without a payload", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const render = spyOn(tui, "renderTuiAt").mockResolvedValue(undefined); + + try { + await runCommand(core, output.io, [ + "runtime", + "invoke", + "--id", + RUNTIME_ID, + "--user-id", + "user-123", + "--header", + "X-Tenant: retail", + "--bearer-token", + "secret-token", + ]); + + expect(render).toHaveBeenCalledTimes(1); + expect(render.mock.calls[0]![1].value(RuntimeInvokeLaunchContextKey)).toEqual({ + runtimeId: RUNTIME_ID, + runtimeSessionId: undefined, + runtimeUserId: "user-123", + applicationHeaders: [["X-Tenant", "retail"]], + bearerToken: "secret-token", + }); + expect(core.runtime.calls).toEqual([]); } finally { render.mockRestore(); } }); + test("rejects a stdin bearer token when launching the TUI", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID, "--bearer-token", "-"]), + ).rejects.toThrow("stdin bearer tokens are not available"); + expect(core.runtime.calls).toEqual([]); + }); + test("handler keeps JSON mode without a payload as a usage error", async () => { const core = new TestCoreClient(); const output = captureIO(); diff --git a/src/handlers/runtime/invoke/launchContext.ts b/src/handlers/runtime/invoke/launchContext.ts new file mode 100644 index 000000000..bcee87f31 --- /dev/null +++ b/src/handlers/runtime/invoke/launchContext.ts @@ -0,0 +1,12 @@ +import { contextKey } from "../../../router"; + +export type RuntimeInvokeLaunchContext = { + runtimeId: string; + runtimeSessionId?: string; + runtimeUserId?: string; + applicationHeaders?: [string, string][]; + bearerToken?: string; +}; + +export const RuntimeInvokeLaunchContextKey = + contextKey("runtime.invoke.launch"); diff --git a/src/handlers/runtime/invoke/request.ts b/src/handlers/runtime/invoke/request.ts index 5f53c21f3..4da78c5ac 100644 --- a/src/handlers/runtime/invoke/request.ts +++ b/src/handlers/runtime/invoke/request.ts @@ -56,6 +56,25 @@ export async function resolveRuntimeInvokeSources( } } +export async function resolveRuntimeInvokeTuiBearerToken( + 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 parseRuntimeInvokeHeaders(values: string[] = []): [string, string][] { const seen = new Set(); diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 1a4fa46b9..e06a3affa 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams, useSearchParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import cliTruncate from "cli-truncate"; import { InputValidationError } from "../../../errors"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -16,6 +17,7 @@ import type { RuntimeInvokeResponse } from "../types"; import { normalizeRuntimeInvokeRequest } from "./request"; import { RuntimePayloadInput } from "./RuntimePayloadInput"; import { classifyRuntimeResponse } from "./response"; +import { RuntimeInvokeLaunchContextKey, type RuntimeInvokeLaunchContext } from "./launchContext"; const theme = darkTheme; @@ -53,9 +55,9 @@ const metadata = (response: RuntimeInvokeResponse) => export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); - const [search] = useSearchParams(); const navigate = useNavigate(); - const initialSessionId = search.get("session-id") ?? undefined; + const launchContext = props.ctx.value(RuntimeInvokeLaunchContextKey); + const initialContext = launchContext?.runtimeId === runtimeId ? launchContext : undefined; if (!runtimeId) { return ( @@ -75,14 +77,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { runtimeId={runtimeId} breadcrumb={["agentcore", "runtime", "invoke", runtimeId]} description="choose an endpoint to invoke" - onSelect={(selected) => { - const path = invokePath(runtimeId, selected); - navigate( - initialSessionId - ? `${path}?${new URLSearchParams({ "session-id": initialSessionId })}` - : path, - ); - }} + onSelect={(selected) => navigate(invokePath(runtimeId, selected))} onEscape={() => navigate(invokePath())} /> ); @@ -93,7 +88,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { {...props} runtimeId={runtimeId} qualifier={qualifier} - initialSessionId={initialSessionId} + initialContext={initialContext} /> ); } @@ -101,7 +96,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { type RuntimeInvokeConsoleProps = ScreenProps & { runtimeId: string; qualifier: string; - initialSessionId?: string; + initialContext?: RuntimeInvokeLaunchContext; }; function RuntimeInvokeConsole({ @@ -109,11 +104,11 @@ function RuntimeInvokeConsole({ core, runtimeId, qualifier, - initialSessionId, + initialContext, }: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); - const { rows } = useWindowSize(); + const { columns, rows } = useWindowSize(); const [target, setTarget] = useState({ runtimeId, qualifier }); const [targetPicker, setTargetPicker] = useState(null); const detail = useQuery({ @@ -123,7 +118,8 @@ function RuntimeInvokeConsole({ const mcp = detail.data?.protocolConfiguration?.serverProtocol === "MCP"; const [payload, setPayload] = useState(""); const [inputError, setInputError] = useState(); - const [runtimeSessionId, setRuntimeSessionId] = useState(initialSessionId); + const [requestContext, setRequestContext] = useState(initialContext); + const [runtimeSessionId, setRuntimeSessionId] = useState(initialContext?.runtimeSessionId); const [mcpSessionId, setMcpSessionId] = useState(); const [history, setHistory] = useState([]); const [prettyJson, setPrettyJson] = useState(false); @@ -171,6 +167,9 @@ function RuntimeInvokeConsole({ payload: new TextEncoder().encode(requestPayload), contentType: "application/json", runtimeSessionId, + runtimeUserId: requestContext?.runtimeUserId, + applicationHeaders: requestContext?.applicationHeaders, + bearerToken: requestContext?.bearerToken, ...(mcp && { mcpSessionId }), }); const response = await core.runtime.invokeRuntime(request, opts, controller.signal); @@ -242,6 +241,15 @@ function RuntimeInvokeConsole({ 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?.runtimeUserId ? "user" : undefined, + requestContext?.bearerToken ? "JWT" : undefined, + requestContext?.applicationHeaders?.length + ? `${requestContext.applicationHeaders.length}h` + : undefined, + ] + .filter(Boolean) + .join("/"); useInput( (input, key) => { @@ -300,9 +308,11 @@ function RuntimeInvokeConsole({ description="choose another endpoint" onSelect={(selected) => { if (nextRuntimeId !== target.runtimeId || selected !== target.qualifier) { + const runtimeChanged = nextRuntimeId !== target.runtimeId; setTarget({ runtimeId: nextRuntimeId, qualifier: selected }); setRuntimeSessionId(undefined); setMcpSessionId(undefined); + if (runtimeChanged) setRequestContext(undefined); setHistory([]); setPrettyJson(false); setInputError(undefined); @@ -392,7 +402,12 @@ function RuntimeInvokeConsole({ ) : ( - idle · Sessions: Runtime {runtimeSessionId ?? "new"} · MCP {mcpSessionId ?? "new"} + {cliTruncate( + `idle · Sessions: Runtime ${runtimeSessionId ?? "new"} · MCP ${ + mcpSessionId ?? "new" + }${requestContextSummary ? ` · Context ${requestContextSummary}` : ""}`, + columns, + )} )} diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 9e10ac754..f638bb6cb 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -61,6 +61,8 @@ export interface RenderScreenOptions { core?: TestCoreClient; // ctx overrides the base context (rarely needed). ctx?: Context; + // withContext adds screen-specific launch values to the otherwise real base context. + withContext?: (ctx: Context) => Context; // queryClient overrides the deterministic default when a test needs to // exercise cache behavior. queryClient?: QueryClient; @@ -122,7 +124,8 @@ export function cleanupScreens(): void { // and returns handles to read frames and send input. export function renderScreen(path: string, options: RenderScreenOptions = {}): RenderScreenResult { const core = options.core ?? new TestCoreClient(); - const ctx = options.ctx ?? baseContext(core, options.endpointUrl); + const base = options.ctx ?? baseContext(core, options.endpointUrl); + const ctx = options.withContext?.(base) ?? base; const queryClient = options.queryClient ?? testQueryClient(); const instance = render(<>); From 826ff46b6e7802e408aaceba6e4747fa3dff2476 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 3 Aug 2026 14:48:01 +0000 Subject: [PATCH 19/25] fix(runtime): clarify invoke session status --- .../runtime/invoke/invoke.screen.test.tsx | 56 ++++++++++--------- src/handlers/runtime/invoke/screen.tsx | 4 +- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index fa9e1c588..ab84140fe 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -130,10 +130,11 @@ describe("Runtime invoke routing", () => { await waitForText(screen.lastFrame, QUALIFIER); await screen.press("return"); - await waitForText(screen.lastFrame, `Sessions: Runtime ${sessionId} · MCP new`); + await waitForText(screen.lastFrame, `Ready · Session ID: ${sessionId}`); + expect(screen.lastFrame()).not.toContain("MCP session ID"); }); - test("idle esc from an initial console returns to its endpoint picker", async () => { + test("escape from a ready console returns to its endpoint picker", async () => { const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) @@ -142,7 +143,7 @@ describe("Runtime invoke routing", () => { }); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.press("escape"); await waitForText(screen.lastFrame, "back-to-endpoint-picker"); @@ -199,7 +200,7 @@ describe("Runtime invoke JSON console", () => { core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write('{"prompt":'); await screen.press("return"); @@ -219,7 +220,7 @@ describe("Runtime invoke JSON console", () => { }); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write("{"); await screen.write("\x1b[13;2u"); await screen.write('"prompt":"hello"'); @@ -240,11 +241,11 @@ describe("Runtime invoke JSON console", () => { core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); - const initialIdleLine = screen + await waitForText(screen.lastFrame, "Ready"); + const initialStatusLine = screen .lastFrame()! .split("\n") - .findIndex((line) => line.includes("idle · Sessions")); + .findIndex((line) => line.includes("Ready · Session ID")); for (let index = 0; index < 3; index++) await screen.write("\x1b[13;2u"); @@ -254,8 +255,8 @@ describe("Runtime invoke JSON console", () => { (line, index) => index > labelLine && /^─+$/.test(line), ); expect(lowerDivider - labelLine).toBe(5); - expect(expandedLines.findIndex((line) => line.includes("idle · Sessions"))).toBe( - initialIdleLine, + expect(expandedLines.findIndex((line) => line.includes("Ready · Session ID"))).toBe( + initialStatusLine, ); expect(screen.lastFrame()).not.toContain("…"); @@ -284,7 +285,7 @@ describe("Runtime invoke JSON console", () => { }; const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write('{"turn":1}'); await screen.press("return"); await waitForText(screen.lastFrame, "partial"); @@ -293,7 +294,7 @@ describe("Runtime invoke JSON console", () => { expect(requests).toHaveLength(1); release.resolve(); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.press("return"); await waitFor(() => requests.length === 2); expect(new TextDecoder().decode(requests[1]!.payload)).toBe('{"turn":2}'); @@ -316,7 +317,7 @@ describe("Runtime invoke JSON console", () => { }); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write("{}"); const frameCount = screen.frames.length; await screen.press("return"); @@ -363,10 +364,13 @@ describe("Runtime invoke JSON console", () => { }), }); - await waitForText(screen.lastFrame, `Runtime ${initialSession}`); + await waitForText(screen.lastFrame, `Session ID: ${initialSession}`); await screen.write('{"turn":1}'); await screen.press("return"); - await waitForText(screen.lastFrame, "Sessions: Runtime returned-runtime · MCP returned-mcp"); + await waitForText( + screen.lastFrame, + "Ready · Session ID: returned-runtime · MCP session ID: returned-mcp", + ); await screen.write('{"turn":2}'); await screen.press("return"); await waitFor(() => requests.length === 2); @@ -413,7 +417,7 @@ describe("Runtime invoke JSON console", () => { await screen.write('{"turn":1}'); await screen.press("return"); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write('{"turn":2}'); await screen.press("return"); await waitFor(() => requests.length === 2); @@ -455,10 +459,10 @@ describe("Runtime invoke JSON console", () => { }), }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write('{"turn":1}'); await screen.press("return"); - await waitForText(screen.lastFrame, "Sessions: Runtime returned-runtime"); + await waitForText(screen.lastFrame, "Ready · Session ID: returned-runtime"); await screen.write("\x14"); await waitForText(screen.lastFrame, "choose another Runtime"); @@ -470,7 +474,8 @@ describe("Runtime invoke JSON console", () => { `agentcore → runtime → invoke → ${RUNTIME_ID} → ${nextQualifier}`, ); expect(screen.lastFrame()).not.toContain("old response"); - expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); + expect(screen.lastFrame()).toContain("Ready · Session ID: Not set"); + expect(screen.lastFrame()).not.toContain("MCP session ID"); core.runtime.setInvokeResponse({ statusCode: 200, @@ -562,11 +567,11 @@ describe("Runtime invoke JSON console", () => { }); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write("{}"); await screen.press("return"); await waitForText(screen.lastFrame, raw); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write("\x16"); await waitForText(screen.lastFrame, pretty); @@ -589,7 +594,7 @@ describe("Runtime invoke JSON console", () => { }); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write("{}"); await screen.press("return"); @@ -608,7 +613,7 @@ describe("Runtime invoke JSON console", () => { } as GetAgentRuntimeResponse); const screen = renderScreen(CONSOLE_PATH, { core }); - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write("{}"); await screen.press("return"); @@ -637,7 +642,7 @@ describe("Runtime invoke JSON console", () => { const screen = renderScreen(CONSOLE_PATH, { core }); try { - await waitForText(screen.lastFrame, "idle"); + await waitForText(screen.lastFrame, "Ready"); await screen.write("{}"); await screen.press("return"); await waitForText(screen.lastFrame, "data: partial"); @@ -647,7 +652,8 @@ describe("Runtime invoke JSON console", () => { stop.reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); await waitForText(screen.lastFrame, "interrupted · 14 bytes"); expect(screen.lastFrame()).toContain("data: partial"); - expect(screen.lastFrame()).toContain("Sessions: Runtime new · MCP new"); + expect(screen.lastFrame()).toContain("Ready · Session ID: Not set"); + expect(screen.lastFrame()).not.toContain("MCP session ID"); } finally { stop.resolve(); } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index e06a3affa..23fd57b57 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -403,8 +403,8 @@ function RuntimeInvokeConsole({ ) : ( {cliTruncate( - `idle · Sessions: Runtime ${runtimeSessionId ?? "new"} · MCP ${ - mcpSessionId ?? "new" + `Ready · Session ID: ${runtimeSessionId ?? "Not set"}${ + mcp ? ` · MCP session ID: ${mcpSessionId ?? "Not set"}` : "" }${requestContextSummary ? ` · Context ${requestContextSummary}` : ""}`, columns, )} From cb255738b232aedb0cc30f43077d3791f3ebbfe6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 3 Aug 2026 14:58:46 +0000 Subject: [PATCH 20/25] fix(runtime): use centralized environment error --- src/handlers/runtime/invoke/index.tsx | 8 ++++++-- src/handlers/runtime/invoke/invoke.test.tsx | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 45a59bb7e..fa040fc31 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -1,12 +1,16 @@ import z from "zod"; -import { InputValidationError, RuntimeInvokeInterruptedError } from "../../../errors"; +import { + InputValidationError, + InvalidEnvironmentError, + RuntimeInvokeInterruptedError, +} from "../../../errors"; import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; import { JsonKey } from "../../keys"; import { ExitCode } from "../../../runnable"; -import { InvalidEnvironmentError, renderTuiAt } from "../../../tui"; +import { renderTuiAt } from "../../../tui"; import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 978502797..27091c3f7 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -12,6 +12,7 @@ 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,7 +405,7 @@ describe("runtime invoke", () => { const core = new TestCoreClient(); const output = captureIO(); const render = spyOn(tui, "renderTuiAt").mockRejectedValue( - new tui.InvalidEnvironmentError("interactive mode requires a TTY on stdin and stdout"), + new InvalidEnvironmentError("interactive mode requires a TTY on stdin and stdout"), ); try { From 8bad46ae2ce743cc0c70b1b5d0d6e93704687af5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 3 Aug 2026 15:13:50 +0000 Subject: [PATCH 21/25] test(runtime): cover invoke console edge cases --- .../runtime/invoke/invoke.screen.test.tsx | 122 ++++++++++++++++++ src/handlers/runtime/invoke/invoke.test.tsx | 16 +++ src/handlers/runtime/invoke/request.test.ts | 28 +++- 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index ab84140fe..f07d643bb 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -236,6 +236,33 @@ describe("Runtime invoke JSON console", () => { ); }); + test("edits JSON with cursor movement and backspace", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write('{"a":1}'); + await screen.press("left"); + await screen.press("left"); + await screen.write("2"); + await screen.press("right"); + await screen.write("\x7f"); + await screen.press("return"); + + await waitFor(() => invokeRequests(core).length === 1); + expect(new TextDecoder().decode(invokeRequests(core)[0]!.payload)).toBe('{"a":2}'); + await waitForText(screen.lastFrame, "Ready"); + await screen.write("\x7f"); + expect(screen.lastFrame()).toContain("Enter JSON payload"); + }); + test("keeps blank multiline rows inside the four-line editor", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); @@ -337,6 +364,101 @@ describe("Runtime invoke JSON console", () => { expect(screen.lastFrame()).toContain("Runtime returned-runtime"); }); + test.each([ + [ + "invalid UTF-8 text", + "text/plain", + Buffer.from([0xff]), + "Invalid UTF-8 response; showing raw text.", + ], + [ + "invalid JSON", + "application/json", + Buffer.from("not-json"), + "Invalid JSON response; showing raw text.", + ], + ])("explains %s responses", async (_case, contentType, body, note) => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType, + body: responseBody(body), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, note); + expect(screen.lastFrame()).toContain(`complete · ${body.byteLength} bytes`); + }); + + test("shows failures that occur before a response starts", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); + core.runtime.invokeRuntime = async () => { + throw new Error("connection failed"); + }; + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "connection failed"); + expect(screen.lastFrame()).toContain("failed · 0 bytes"); + }); + + test("shows failures that occur while reading a response", async () => { + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: (async function* () { + yield Buffer.from("partial"); + throw new Error("stream failed"); + })(), + }); + const screen = renderScreen(CONSOLE_PATH, { core }); + + await waitForText(screen.lastFrame, "Ready"); + await screen.write("{}"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "response stream failed"); + expect(screen.lastFrame()).toContain("partial"); + expect(screen.lastFrame()).toContain("failed · 7 bytes"); + }); + + test("scrolls completed response history with the arrow keys", async () => { + const response = Array.from({ length: 12 }, (_, index) => `response-line-${index}`).join("\n"); + const core = new TestCoreClient(); + core.runtime + .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) + .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"); + }); + test("starts from --session-id and adopts returned Runtime and MCP sessions", async () => { const requests: RuntimeInvokeRequest[] = []; const core = new TestCoreClient(); diff --git a/src/handlers/runtime/invoke/invoke.test.tsx b/src/handlers/runtime/invoke/invoke.test.tsx index 27091c3f7..fa4d4859d 100644 --- a/src/handlers/runtime/invoke/invoke.test.tsx +++ b/src/handlers/runtime/invoke/invoke.test.tsx @@ -420,6 +420,22 @@ describe("runtime invoke", () => { } }); + test("preserves unexpected TUI rendering failures", async () => { + const core = new TestCoreClient(); + const output = captureIO(); + const failure = new TypeError("render failed"); + const render = spyOn(tui, "renderTuiAt").mockRejectedValue(failure); + + try { + await expect( + runCommand(core, output.io, ["runtime", "invoke", "--id", RUNTIME_ID]), + ).rejects.toBe(failure); + expect(core.runtime.calls).toEqual([]); + } finally { + render.mockRestore(); + } + }); + test("handler deep-links id-only and qualified invokes with encoded path segments", async () => { const core = new TestCoreClient(); const output = captureIO(); diff --git a/src/handlers/runtime/invoke/request.test.ts b/src/handlers/runtime/invoke/request.test.ts index 995c69882..4b6625a7c 100644 --- a/src/handlers/runtime/invoke/request.test.ts +++ b/src/handlers/runtime/invoke/request.test.ts @@ -1,11 +1,13 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { Readable } from "node:stream"; import type { GetAgentRuntimeResponse } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError, SourceResolutionError } from "../../../errors"; +import { SourceResolver } from "../../../io"; import { normalizeRuntimeInvokeRequest, parseRuntimeInvokeHeaders, resolveRuntimeInvokeSources, + resolveRuntimeInvokeTuiBearerToken, } from "./request"; const REGION = "us-west-2"; @@ -86,6 +88,30 @@ describe("resolveRuntimeInvokeSources", () => { }); }); +test("brands TUI bearer-token file failures as input errors", async () => { + const missing = `file:///tmp/missing-runtime-token-${process.pid}`; + const error = await resolveRuntimeInvokeTuiBearerToken(missing, stdin(new Uint8Array())).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(resolveRuntimeInvokeTuiBearerToken("token", stdin(new Uint8Array()))).rejects.toBe( + failure, + ); + } finally { + resolve.mockRestore(); + } +}); + describe("parseRuntimeInvokeHeaders", () => { test("brands validation failures as input errors", () => { expect(() => parseRuntimeInvokeHeaders(["missing separator"])).toThrow(InputValidationError); From e44bf32a60b927a5dcf2879c97acd01f1a7bd433 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 3 Aug 2026 15:17:36 +0000 Subject: [PATCH 22/25] refactor(runtime): remove obsolete select customization --- src/components/ui/select/Select.tsx | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/components/ui/select/Select.tsx b/src/components/ui/select/Select.tsx index 68403051b..84d5c5c45 100644 --- a/src/components/ui/select/Select.tsx +++ b/src/components/ui/select/Select.tsx @@ -14,8 +14,6 @@ export interface SelectProps { items: SelectItem[]; /** Called when the user presses Enter on an enabled item */ onSelect: (item: SelectItem) => void; - /** Value highlighted when the select mounts */ - initialValue?: T; /** Whether this select captures keyboard input */ focus?: boolean; /** Theme override — defaults to darkTheme */ @@ -70,16 +68,15 @@ function ListDisplay({ items, activeIndex, isFocused, theme }: ListDisplayPro interface FocusedSelectProps { items: SelectItem[]; onSelect: (item: SelectItem) => void; - initialValue?: T; theme: InkUITheme; } -function FocusedSelect({ items, onSelect, initialValue, theme }: FocusedSelectProps) { +function FocusedSelect({ items, onSelect, theme }: FocusedSelectProps) { const { exit } = useApp(); + // Start on the first non-disabled item const firstEnabled = items.findIndex((it) => !it.disabled); - const initialIndex = items.findIndex((it) => !it.disabled && Object.is(it.value, initialValue)); - const [index, setIndex] = useState(Math.max(0, initialIndex >= 0 ? initialIndex : firstEnabled)); + const [index, setIndex] = useState(Math.max(0, firstEnabled)); const move = (dir: 1 | -1) => { setIndex((prev) => { @@ -122,7 +119,6 @@ function FocusedSelect({ items, onSelect, initialValue, theme }: FocusedSelec export function Select({ items, onSelect, - initialValue, focus = true, theme = darkTheme, }: SelectProps) { @@ -130,13 +126,12 @@ export function Select({ const canFocus = focus && isRawModeSupported; if (canFocus) { - return ( - - ); + return ; } - const initialIndex = items.findIndex((it) => !it.disabled && Object.is(it.value, initialValue)); - const firstEnabled = items.findIndex((it) => !it.disabled); - const activeIndex = Math.max(0, initialIndex >= 0 ? initialIndex : firstEnabled); - return ; + const firstEnabled = Math.max( + 0, + items.findIndex((it) => !it.disabled), + ); + return ; } From d3d6305a89566380d1d7390a365f33c46eb54e0a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 3 Aug 2026 15:28:25 +0000 Subject: [PATCH 23/25] refactor(runtime): remove request options residue --- README.md | 6 +- bun.lock | 13 +++ package.json | 1 + .../runtime/invoke/RuntimePayloadInput.tsx | 88 ++++++++----------- src/handlers/runtime/invoke/index.tsx | 4 +- .../runtime/invoke/invoke.screen.test.tsx | 8 +- src/handlers/runtime/invoke/screen.tsx | 3 - 7 files changed, 60 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 04584a0d6..4920e9190 100644 --- a/README.md +++ b/README.md @@ -280,9 +280,9 @@ request paths. All requests use the Runtime `/invocations` route, including MCP Runtimes. Bare Runtime and Memory branches and leaves require a TTY on stdin and stdout. -For Runtime Invoke, supplying a payload or advanced request options runs headlessly; -`--session-id` can instead seed the persistent console. `--json` always -suppresses TUI rendering. +For Runtime Invoke, supplying a payload or headless-only request or output flags +runs headlessly; `--session-id` can instead seed the persistent console. +`--json` always suppresses TUI rendering. ```bash agentcore runtime diff --git a/bun.lock b/bun.lock index 774e24652..a61c220ed 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.101.2", "cli-truncate": "^6.1.1", "commander": "^15.0.0", + "handlebars": "^4.7.9", "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", "lodash": "^4.18.1", @@ -204,6 +205,8 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -238,10 +241,14 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], @@ -282,6 +289,8 @@ "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -310,6 +319,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -322,6 +333,8 @@ "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], diff --git a/package.json b/package.json index 935251cdc..c2fa1da53 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "string-width": "^8.2.2", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "handlebars": "^4.7.9", "zod": "^4.4.3" } } diff --git a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx b/src/handlers/runtime/invoke/RuntimePayloadInput.tsx index 9905bff52..e9c66e251 100644 --- a/src/handlers/runtime/invoke/RuntimePayloadInput.tsx +++ b/src/handlers/runtime/invoke/RuntimePayloadInput.tsx @@ -3,20 +3,17 @@ import { Box, Text, useInput } from "ink"; import { darkTheme } from "../../../components/ui/_core.js"; const theme = darkTheme; +const PREVIEW_LINES = 4; +const PLACEHOLDER = "Enter JSON payload"; interface RuntimePayloadInputProps { value: string; onChange: (value: string) => void; onSubmit: () => void; submitDisabled?: boolean; - focused?: boolean; - label: string; - placeholder: string; - previewLines?: number; } -function Cursor({ character, focused }: { character: string; focused: boolean }) { - if (!focused) return {character}; +function Cursor({ character }: { character: string }) { return ( {character} @@ -29,58 +26,51 @@ export function RuntimePayloadInput({ onChange, onSubmit, submitDisabled = false, - focused = true, - label, - placeholder, - previewLines = 4, }: RuntimePayloadInputProps) { 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(); - } - 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.ctrl || key.meta || key.escape || input === "") return; + 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); - }, - { isActive: focused }, - ); + const next = input.replace(/\r/g, "\n"); + onChange(value.slice(0, cursor) + next + value.slice(cursor)); + setRawCursor(cursor + next.length); + }); if (value === "") { return ( - {label} + JSON payload - - {placeholder.slice(1)} + + {PLACEHOLDER.slice(1)} ); @@ -91,12 +81,12 @@ export function RuntimePayloadInput({ const cursorLine = beforeCursor.split("\n").length - 1; const lastNewline = beforeCursor.lastIndexOf("\n"); const cursorColumn = cursor - lastNewline - 1; - const start = Math.max(0, cursorLine - previewLines + 1); - const visible = lines.slice(start, start + previewLines); + const start = Math.max(0, cursorLine - PREVIEW_LINES + 1); + const visible = lines.slice(start, start + PREVIEW_LINES); return ( - {label} + JSON payload {visible.map((line, index) => { const lineIndex = start + index; const prefix = index === 0 && start > 0 ? "… " : ""; @@ -116,7 +106,7 @@ export function RuntimePayloadInput({ {prefix} {before ? {before} : null} - + {after ? {after} : null} ); diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index fa040fc31..0110273a3 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -62,7 +62,7 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => }); } if (flags.payload === undefined) { - const requestOption = Object.entries(flags).some( + const hasHeadlessOnlyFlag = Object.entries(flags).some( ([name, value]) => ![ "id", @@ -74,7 +74,7 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => "bearer-token", ].includes(name) && value !== undefined, ); - if (ctx.require(JsonKey) || requestOption) { + if (ctx.require(JsonKey) || hasHeadlessOnlyFlag) { throw new InputValidationError("required option '--payload ' not specified", { exitCode: ExitCode.USAGE, }); diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index f07d643bb..556783765 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -166,7 +166,7 @@ describe("Runtime invoke routing", () => { }); describe("Runtime invoke JSON console", () => { - test("sends inline JSON with the fixed content type and no options UI", async () => { + test("sends inline JSON with the fixed content type", async () => { const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) @@ -178,10 +178,6 @@ describe("Runtime invoke JSON console", () => { const screen = renderScreen(CONSOLE_PATH, { core }); await waitForText(screen.lastFrame, "JSON payload"); - expect(screen.lastFrame()).not.toContain("[ctl+o] options"); - await screen.write("\x0f"); - expect(screen.lastFrame()).not.toContain("Request options"); - await screen.write('{"prompt":"hello"}'); await screen.press("return"); await waitFor(() => invokeRequests(core).length === 1); @@ -727,7 +723,7 @@ describe("Runtime invoke JSON console", () => { expect(iterations).toBe(0); }); - test("directs CUSTOM_JWT users to the headless bearer-token option", async () => { + test("reports a missing bearer token for CUSTOM_JWT Runtimes", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse({ agentRuntimeArn: RUNTIME_ARN, diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index 23fd57b57..c6369d8b8 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -383,8 +383,6 @@ function RuntimeInvokeConsole({ { setPayload(value); @@ -392,7 +390,6 @@ function RuntimeInvokeConsole({ }} onSubmit={() => void send()} submitDisabled={busy} - previewLines={4} /> From 620579f3752e11c9155a3e0fb646470541b28626 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 3 Aug 2026 16:07:16 +0000 Subject: [PATCH 24/25] fix(runtime): preserve invoke session boundaries --- .../runtime/invoke/invoke.screen.test.tsx | 45 ++++++++++++++++--- src/handlers/runtime/invoke/screen.tsx | 8 ++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 556783765..606a10b20 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -134,19 +134,42 @@ describe("Runtime invoke routing", () => { expect(screen.lastFrame()).not.toContain("MCP session ID"); }); - test("escape from a ready console returns to its endpoint picker", async () => { + test("escape switches endpoints without restoring the launch session", async () => { + const nextQualifier = "back-endpoint"; const core = new TestCoreClient(); core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse) .setListEndpointsResponse({ - runtimeEndpoints: [endpoint({ name: "back-to-endpoint-picker", id: "back-endpoint" })], + runtimeEndpoints: [endpoint({ name: nextQualifier, id: nextQualifier })], + }) + .setInvokeResponse({ + statusCode: 200, + contentType: "text/plain", + body: responseBody(Buffer.from("ok")), }); - const screen = renderScreen(CONSOLE_PATH, { core }); + const screen = renderScreen(CONSOLE_PATH, { + core, + withContext: (ctx) => + ctx.withValue(RuntimeInvokeLaunchContextKey, { + runtimeId: RUNTIME_ID, + runtimeSessionId: "cli-selected-session", + }), + }); - await waitForText(screen.lastFrame, "Ready"); + await waitForText(screen.lastFrame, "Session ID: cli-selected-session"); await screen.press("escape"); + await waitForText(screen.lastFrame, nextQualifier); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → runtime → invoke → ${RUNTIME_ID} → ${nextQualifier}`, + ); + expect(screen.lastFrame()).toContain("Ready · Session ID: Not set"); - await waitForText(screen.lastFrame, "back-to-endpoint-picker"); + await screen.write("{}"); + await screen.press("return"); + await waitFor(() => invokeRequests(core).length === 1); + expect(invokeRequests(core)[0]!.runtimeSessionId).toBeUndefined(); }); test("unmount cancels the Runtime detail lookup", async () => { @@ -455,7 +478,7 @@ describe("Runtime invoke JSON console", () => { expect(screen.lastFrame()).toContain("response-line-11"); }); - test("starts from --session-id and adopts returned Runtime and MCP sessions", async () => { + test("starts from --session-id and adopts returned Runtime and MCP context", async () => { const requests: RuntimeInvokeRequest[] = []; const core = new TestCoreClient(); core.runtime.setGetResponse({ @@ -469,6 +492,7 @@ describe("Runtime invoke JSON console", () => { contentType: "text/event-stream", runtimeSessionId: "returned-runtime", mcpSessionId: "returned-mcp", + mcpProtocolVersion: "2025-06-18", body: responseBody(Buffer.from("data: done\n\n")), }; }; @@ -495,8 +519,10 @@ describe("Runtime invoke JSON console", () => { expect(requests[0]!.runtimeSessionId).toBe(initialSession); expect(requests[0]!.mcpSessionId).toBeUndefined(); + expect(requests[0]!.mcpProtocolVersion).toBeUndefined(); expect(requests[1]!.runtimeSessionId).toBe("returned-runtime"); expect(requests[1]!.mcpSessionId).toBe("returned-mcp"); + expect(requests[1]!.mcpProtocolVersion).toBe("2025-06-18"); }); test("persists launch identity, authentication, and headers without exposing values", async () => { @@ -555,6 +581,7 @@ describe("Runtime invoke JSON console", () => { core.runtime .setGetResponse({ agentRuntimeArn: RUNTIME_ARN, + protocolConfiguration: { serverProtocol: "MCP" }, requestHeaderConfiguration: { requestHeaderAllowlist: ["X-Tenant"] }, } as GetAgentRuntimeResponse) .setListResponse({ agentRuntimes: [runtime()] }) @@ -565,6 +592,8 @@ describe("Runtime invoke JSON console", () => { 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, { @@ -593,7 +622,7 @@ describe("Runtime invoke JSON console", () => { ); expect(screen.lastFrame()).not.toContain("old response"); expect(screen.lastFrame()).toContain("Ready · Session ID: Not set"); - expect(screen.lastFrame()).not.toContain("MCP session ID"); + expect(screen.lastFrame()).toContain("MCP session ID: Not set"); core.runtime.setInvokeResponse({ statusCode: 200, @@ -604,6 +633,8 @@ describe("Runtime invoke JSON console", () => { await screen.press("return"); await waitFor(() => invokeRequests(core).length === 2); expect(invokeRequests(core)[1]!.runtimeSessionId).toBeUndefined(); + expect(invokeRequests(core)[1]!.mcpSessionId).toBeUndefined(); + expect(invokeRequests(core)[1]!.mcpProtocolVersion).toBeUndefined(); expect(invokeRequests(core)[1]).toMatchObject({ runtimeUserId: "user-123", applicationHeaders: [["X-Tenant", "retail"]], diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index c6369d8b8..a0e20a017 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -107,7 +107,6 @@ function RuntimeInvokeConsole({ initialContext, }: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); const { columns, rows } = useWindowSize(); const [target, setTarget] = useState({ runtimeId, qualifier }); const [targetPicker, setTargetPicker] = useState(null); @@ -121,6 +120,7 @@ function RuntimeInvokeConsole({ const [requestContext, setRequestContext] = useState(initialContext); const [runtimeSessionId, setRuntimeSessionId] = useState(initialContext?.runtimeSessionId); const [mcpSessionId, setMcpSessionId] = useState(); + const [mcpProtocolVersion, setMcpProtocolVersion] = useState(); const [history, setHistory] = useState([]); const [prettyJson, setPrettyJson] = useState(false); const abortRef = useRef(null); @@ -170,7 +170,7 @@ function RuntimeInvokeConsole({ runtimeUserId: requestContext?.runtimeUserId, applicationHeaders: requestContext?.applicationHeaders, bearerToken: requestContext?.bearerToken, - ...(mcp && { mcpSessionId }), + ...(mcp && { mcpSessionId, mcpProtocolVersion }), }); const response = await core.runtime.invokeRuntime(request, opts, controller.signal); responseStarted = true; @@ -220,6 +220,7 @@ function RuntimeInvokeConsole({ } if (response.runtimeSessionId) setRuntimeSessionId(response.runtimeSessionId); if (response.mcpSessionId) setMcpSessionId(response.mcpSessionId); + if (response.mcpProtocolVersion) setMcpProtocolVersion(response.mcpProtocolVersion); updateExchange({ state: "complete" }); } catch (error) { if (controller.signal.aborted || (error as Error)?.name === "AbortError") { @@ -260,7 +261,7 @@ function RuntimeInvokeConsole({ } if (key.escape) { if (abortRef.current) abortRef.current.abort(); - else navigate(invokePath(target.runtimeId)); + else setTargetPicker({ stage: "endpoint", runtimeId: target.runtimeId }); return; } const view = scrollRef.current; @@ -312,6 +313,7 @@ function RuntimeInvokeConsole({ setTarget({ runtimeId: nextRuntimeId, qualifier: selected }); setRuntimeSessionId(undefined); setMcpSessionId(undefined); + setMcpProtocolVersion(undefined); if (runtimeChanged) setRequestContext(undefined); setHistory([]); setPrettyJson(false); From b749c46fc48670f8cd32f415bda59e983e5156bf Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 4 Aug 2026 14:53:42 +0000 Subject: [PATCH 25/25] fix(tui): stabilize invoke console layout --- src/components/Layout.tsx | 7 +-- src/components/ui/key-hint/KeyHint.test.tsx | 35 +++++++++++ src/components/ui/key-hint/KeyHint.tsx | 62 +++++++++++++++---- .../runtime/invoke/invoke.screen.test.tsx | 31 +++++++++- src/handlers/runtime/invoke/screen.tsx | 6 +- 5 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 src/components/ui/key-hint/KeyHint.test.tsx diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index a2cda1b9f..149f88ac9 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -22,12 +22,9 @@ export const Layout: React.FC = ({ breadcrumb, description, keyHint const { columns, rows } = useWindowSize(); return ( - +
- {/* The header and footer each occupy 2 rows (breadcrumb/divider and - divider/key-hints), so the content area gets the remaining rows - 4. - ScrollView children need a concrete height to measure their viewport. */} - + {children}