From 1018113651decad7fbed5563acbc5612445bf55d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 11 Aug 2026 16:34:52 +0000 Subject: [PATCH 1/2] feat(runtime): add contextual TUI actions --- src/components/Root.tsx | 9 ++- .../runtime/endpoint/endpoint.screen.test.tsx | 76 +++++++++++++++++- src/handlers/runtime/endpoint/get/screen.tsx | 78 +++++++++++++++++-- src/handlers/runtime/get/screen.tsx | 26 +++++-- src/handlers/runtime/invoke/screen.tsx | 22 +++++- src/handlers/runtime/runtime.screen.test.tsx | 50 +++++++++++- 6 files changed, 238 insertions(+), 23 deletions(-) diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 4f5c60a9e..9f928022e 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -23,7 +23,10 @@ import { RuntimeScreen } from "../handlers/runtime/screen.tsx"; import { RuntimeGetJsonScreen, RuntimeGetScreen } from "../handlers/runtime/get/screen.tsx"; import { RuntimeListScreen } from "../handlers/runtime/list/screen.tsx"; import { RuntimeEndpointScreen } from "../handlers/runtime/endpoint/screen.tsx"; -import { RuntimeGetEndpointScreen } from "../handlers/runtime/endpoint/get/screen.tsx"; +import { + RuntimeGetEndpointJsonScreen, + RuntimeGetEndpointScreen, +} from "../handlers/runtime/endpoint/get/screen.tsx"; import { RuntimeListEndpointsScreen } from "../handlers/runtime/endpoint/list/screen.tsx"; import { RuntimeVersionScreen } from "../handlers/runtime/version/screen.tsx"; import { RuntimeGetVersionScreen } from "../handlers/runtime/version/get/screen.tsx"; @@ -283,6 +286,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/runtime/endpoint/get/:runtimeId/:qualifier" element={} /> + } + /> } diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 2cdc167ce..e3ac9ccba 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AgentRuntime, AgentRuntimeEndpoint, + GetAgentRuntimeResponse, GetAgentRuntimeEndpointResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { @@ -65,6 +66,25 @@ function getEndpointResponse( }; } +function getRuntimeResponse(): GetAgentRuntimeResponse { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeName: "checkout", + agentRuntimeVersion: "3", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + roleArn: "arn:aws:iam::123456789012:role/runtime-role", + networkConfiguration: { networkMode: "PUBLIC" }, + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28_800, + }, + status: "READY", + protocolConfiguration: { serverProtocol: "HTTP" }, + }; +} + async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise { await waitFor(() => { const frame = lastFrame() ?? ""; @@ -187,7 +207,7 @@ describe("Runtime endpoint flow", () => { expect(r.lastFrame()).toContain("endpoint access denied"); }); - test("selecting an encoded qualifier opens complete endpoint JSON with exact selectors", async () => { + test("selecting an encoded qualifier opens its summary with exact selectors", async () => { const qualifier = "prod/blue one"; const core = new TestCoreClient(); core.runtime.setListEndpointsResponse({ @@ -205,7 +225,10 @@ describe("Runtime endpoint flow", () => { r.lastFrame, `agentcore → runtime → endpoint → get → runtime-123 → ${qualifier}`, ); - await waitForText(r.lastFrame, '"targetVersion"'); + await waitForText(r.lastFrame, "invoke this Runtime endpoint"); + expect(r.lastFrame()).toMatch(/liveVersion\s+3/); + expect(r.lastFrame()).toMatch(/targetVersion\s+4/); + expect(r.lastFrame()).toContain("show the full JSON definition"); await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeEndpoint")); expect(core.runtime.calls.find((call) => call.method === "getRuntimeEndpoint")).toEqual({ method: "getRuntimeEndpoint", @@ -220,6 +243,53 @@ describe("Runtime endpoint flow", () => { }); }); + test("opens complete endpoint JSON from the detail action and returns to the summary", async () => { + const core = new TestCoreClient(); + core.runtime.setGetEndpointResponse(getEndpointResponse()); + const r = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", { core }); + + await waitForText(r.lastFrame, "invoke this Runtime endpoint"); + await r.press("down"); + await r.press("return"); + await waitForText( + r.lastFrame, + "agentcore → runtime → endpoint → get → runtime-123 → prod → json", + ); + await waitForText(r.lastFrame, '"targetVersion"'); + + await r.press("escape"); + await waitFor(() => { + const frame = r.lastFrame() ?? ""; + return ( + frame.includes("agentcore → runtime → endpoint → get → runtime-123 → prod") && + !frame.includes("→ json") + ); + }); + await waitForText(r.lastFrame, "invoke this Runtime endpoint"); + }); + + test("invokes the selected endpoint directly and Esc returns to its summary", async () => { + const core = new TestCoreClient(); + core.runtime + .setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }) + .setGetEndpointResponse(getEndpointResponse()) + .setGetResponse(getRuntimeResponse()); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "prod"); + await r.press("return"); + await waitForText(r.lastFrame, "invoke this Runtime endpoint"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → invoke → runtime-123 → prod"); + await waitForText(r.lastFrame, "Enter JSON payload"); + + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint → get → runtime-123 → prod"); + await waitForText(r.lastFrame, "invoke this Runtime endpoint"); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); + }); + test("uses a structural parent for the Runtime picker and history below it", async () => { const parentCore = new TestCoreClient(); parentCore.runtime.setListResponse({ @@ -254,7 +324,7 @@ describe("Runtime endpoint flow", () => { await list.press("return"); await waitForText(list.lastFrame, "prod"); await list.press("return"); - await waitForText(list.lastFrame, '"agentRuntimeEndpointArn"'); + await waitForText(list.lastFrame, "invoke this Runtime endpoint"); await list.press("escape"); await waitForText(list.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); }); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index c570650b7..de7557c6f 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -1,23 +1,91 @@ import { useQuery } from "@tanstack/react-query"; -import { useParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; +import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) { +function useRuntimeEndpointDetail( + { ctx, core }: ScreenProps, + runtimeId: string | undefined, + qualifier: string | undefined, +) { const opts = coreOptsFromCtx(ctx); - const { runtimeId, qualifier } = useParams(); - const detail = useQuery({ + return useQuery({ queryKey: ["runtime-endpoint", opts.region, runtimeId, qualifier], queryFn: () => core.runtime.getRuntimeEndpoint(runtimeId!, qualifier!, opts), enabled: runtimeId !== undefined && qualifier !== undefined, }); +} + +function endpointPath(runtimeId: string, qualifier: string, suffix?: string): string { + const path = `/agentcore/runtime/endpoint/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`; + return suffix ? `${path}/${suffix}` : path; +} + +export function RuntimeGetEndpointScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { runtimeId, qualifier } = useParams(); + const detail = useRuntimeEndpointDetail(props, runtimeId, qualifier); + const endpoint = detail.data; return ( - + navigate( + `/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, + { state: { returnOnEscape: true } }, + ), + }, + { + name: "detail", + description: "show the full JSON definition", + onSelect: () => navigate(endpointPath(runtimeId, qualifier, "json")), + }, + ] + : [] + } + loadingLabel={`Loading endpoint ${qualifier ?? ""} for Runtime ${runtimeId ?? ""}…`} + onRetry={() => void detail.refetch()} + /> + ); +} + +export function RuntimeGetEndpointJsonScreen(props: ScreenProps) { + const { runtimeId, qualifier } = useParams(); + const detail = useRuntimeEndpointDetail(props, runtimeId, qualifier); + + return ( + void detail.refetch()} diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index c33528ec1..a144e28ee 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -7,9 +7,15 @@ import { coreOptsFromCtx } from "../../utils"; const ACTIONS = [ { - name: "detail", - description: "show the full JSON definition", - to: (id: string) => `/agentcore/runtime/get/${encodeURIComponent(id)}/json`, + name: "invoke", + description: "invoke this Runtime", + to: (id: string) => `/agentcore/runtime/invoke/${encodeURIComponent(id)}`, + returnsToDetails: true, + }, + { + name: "endpoints", + description: "list this Runtime's endpoints", + to: (id: string) => `/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`, }, { name: "versions", @@ -17,9 +23,9 @@ const ACTIONS = [ to: (id: string) => `/agentcore/runtime/version/list/${encodeURIComponent(id)}`, }, { - name: "endpoints", - description: "list this Runtime's endpoints", - to: (id: string) => `/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`, + name: "detail", + description: "show the full JSON definition", + to: (id: string) => `/agentcore/runtime/get/${encodeURIComponent(id)}/json`, }, ] as const; @@ -56,7 +62,13 @@ export function RuntimeGetScreen(props: ScreenProps) { ? ACTIONS.map((action) => ({ name: action.name, description: action.description, - onSelect: () => navigate(action.to(runtimeId)), + onSelect: () => + navigate(action.to(runtimeId), { + state: + "returnsToDetails" in action && action.returnsToDetails + ? { returnOnEscape: true } + : undefined, + }), })) : [] } diff --git a/src/handlers/runtime/invoke/screen.tsx b/src/handlers/runtime/invoke/screen.tsx index d428ddb4f..eab299d8c 100644 --- a/src/handlers/runtime/invoke/screen.tsx +++ b/src/handlers/runtime/invoke/screen.tsx @@ -3,7 +3,7 @@ import { ServiceException } from "@smithy/core/client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useLocation, useNavigate, useParams } from "react-router"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import cliTruncate from "cli-truncate"; import type { ScreenProps } from "../../types"; @@ -26,6 +26,10 @@ type ExchangeState = "connecting" | "streaming" | "complete" | "interrupted" | " type TargetPickerState = { stage: "runtime" } | { stage: "endpoint"; runtimeId: string }; +type RuntimeInvokeLocationState = { + returnOnEscape?: boolean; +}; + type ErrorDetails = { name: string; message?: string; @@ -90,9 +94,11 @@ function ErrorBlock({ details }: { details: ErrorDetails }) { export function RuntimeInvokeScreen(props: ScreenProps) { const { runtimeId, qualifier } = useParams(); + const location = useLocation(); const navigate = useNavigate(); const launchContext = props.ctx.value(RuntimeInvokeLaunchContextKey); const initialContext = launchContext?.runtimeId === runtimeId ? launchContext : undefined; + const returnOnEscape = (location.state as RuntimeInvokeLocationState | null)?.returnOnEscape; if (!runtimeId) { return ( @@ -112,8 +118,13 @@ export function RuntimeInvokeScreen(props: ScreenProps) { runtimeId={runtimeId} breadcrumb={["agentcore", "runtime", "invoke", runtimeId]} description="choose an endpoint to invoke" - onSelect={(selected) => navigate(invokePath(runtimeId, selected))} - onEscape={() => navigate(invokePath())} + onSelect={(selected) => + navigate(invokePath(runtimeId, selected), { + replace: returnOnEscape === true, + state: returnOnEscape ? { returnOnEscape } : undefined, + }) + } + onEscape={() => (returnOnEscape ? navigate(-1) : navigate(invokePath()))} /> ); } @@ -124,6 +135,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) { runtimeId={runtimeId} qualifier={qualifier} initialContext={initialContext} + returnOnEscape={returnOnEscape} /> ); } @@ -132,6 +144,7 @@ type RuntimeInvokeConsoleProps = ScreenProps & { runtimeId: string; qualifier: string; initialContext?: RuntimeInvokeLaunchContext; + returnOnEscape?: boolean; }; function RuntimeInvokeConsole({ @@ -140,8 +153,10 @@ function RuntimeInvokeConsole({ runtimeId, qualifier, initialContext, + returnOnEscape, }: RuntimeInvokeConsoleProps) { const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); const { columns, rows } = useWindowSize(); const [target, setTarget] = useState({ runtimeId, qualifier }); const [targetPicker, setTargetPicker] = useState(null); @@ -292,6 +307,7 @@ function RuntimeInvokeConsole({ } if (key.escape) { if (abortRef.current) abortRef.current.abort(); + else if (returnOnEscape) navigate(-1); else setTargetPicker({ stage: "endpoint", runtimeId: target.runtimeId }); return; } diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index afaa84786..dff9eb0f5 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -208,16 +208,17 @@ describe("runtime hub", () => { expect(failed.lastFrame()).toMatch(/failureReason\s+Image could not be pulled/); }); - test("renders exactly the read-only detail, versions, and endpoints actions", async () => { + test("renders invoke first with endpoint, version, and detail actions", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse(getRuntimeResponse()); const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); await waitForText(r.lastFrame, "show the full JSON definition"); const frame = r.lastFrame()!; + expect(frame).toMatch(/❯ invoke\s+invoke this Runtime/); expect(frame).toContain("versions"); expect(frame).toContain("endpoints"); - for (const excluded of ["invoke", "exec", "update", "create", "delete"]) { + for (const excluded of ["exec", "update", "create", "delete"]) { expect(frame).not.toContain(excluded); } }); @@ -244,8 +245,8 @@ describe("runtime hub", () => { }); test.each([ - ["versions", 1], - ["endpoints", 2], + ["endpoints", 1], + ["versions", 2], ] as const)( "selecting %s opens its encoded Runtime-scoped route", async (action, downPresses) => { @@ -303,6 +304,7 @@ describe("runtime hub", () => { const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); await waitForText(r.lastFrame, "show the full JSON definition"); + for (let index = 0; index < 3; index += 1) await r.press("down"); await r.press("return"); await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123 → json"); const frame = r.lastFrame()!; @@ -399,6 +401,7 @@ describe("runtime hub", () => { await waitForText(r.lastFrame, "checkout"); await r.press("return"); await waitForText(r.lastFrame, "show the full JSON definition"); + for (let index = 0; index < 3; index += 1) await r.press("down"); await r.press("return"); await waitForText(r.lastFrame, '"agentRuntimeId"'); await r.press("escape"); @@ -409,6 +412,45 @@ describe("runtime hub", () => { await waitForText(r.lastFrame, "show the full JSON definition"); }); + test("invoke keeps the selected Runtime and Esc returns from endpoint selection", async () => { + const core = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + core.runtime.setGetResponse(getRuntimeResponse()); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [ + { + name: "prod", + id: "prod", + liveVersion: "7", + agentRuntimeEndpointArn: "arn:endpoint", + agentRuntimeArn: "arn:runtime", + status: "READY", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + }, + ], + }); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "checkout"); + await r.press("return"); + await waitForText(r.lastFrame, "invoke this Runtime"); + const listCallsBeforeInvoke = core.runtime.calls.filter( + (call) => call.method === "listRuntimes", + ).length; + await r.press("return"); + await waitForText(r.lastFrame, "choose an endpoint to invoke"); + await waitForText(r.lastFrame, "prod"); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( + listCallsBeforeInvoke, + ); + + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); + await waitForText(r.lastFrame, "invoke this Runtime"); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → list"); + }); + test("Esc remains active while the hub is loading", async () => { const hubCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); const hubPending = Promise.withResolvers(); From 3cfa9fe72a6bc2e893c262bc4fafe117840fad52 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 13 Aug 2026 19:09:32 +0000 Subject: [PATCH 2/2] fix(runtime): show endpoint failure reason --- .../runtime/endpoint/endpoint.screen.test.tsx | 26 +++++++++++++++++++ src/handlers/runtime/endpoint/get/screen.tsx | 1 + 2 files changed, 27 insertions(+) diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index e3ac9ccba..5c821472f 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -243,6 +243,32 @@ describe("Runtime endpoint flow", () => { }); }); + test("shows the endpoint failure reason only when the service provides one", async () => { + const healthyCore = new TestCoreClient(); + healthyCore.runtime.setGetEndpointResponse(getEndpointResponse()); + const healthy = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", { + core: healthyCore, + }); + + await waitForText(healthy.lastFrame, "invoke this Runtime endpoint"); + expect(healthy.lastFrame()).not.toContain("failureReason"); + healthy.unmount(); + + const failedCore = new TestCoreClient(); + failedCore.runtime.setGetEndpointResponse( + getEndpointResponse({ + status: "UPDATE_FAILED", + failureReason: "Endpoint failed its health check", + }), + ); + const failed = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", { + core: failedCore, + }); + + await waitForText(failed.lastFrame, "Endpoint failed its health check"); + expect(failed.lastFrame()).toMatch(/failureReason\s+Endpoint failed its health check/); + }); + test("opens complete endpoint JSON from the detail action and returns to the summary", async () => { const core = new TestCoreClient(); core.runtime.setGetEndpointResponse(getEndpointResponse()); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index de7557c6f..5dacae012 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -37,6 +37,7 @@ export function RuntimeGetEndpointScreen(props: ScreenProps) { items={{ qualifier: endpoint?.name ?? qualifier ?? "", status: endpoint?.status ?? "", + ...(endpoint?.failureReason ? { failureReason: endpoint.failureReason } : {}), liveVersion: endpoint?.liveVersion ?? "-", targetVersion: endpoint?.targetVersion ?? "-", description: endpoint?.description ?? "-",