From 2cd85934d0ccb0514b5bed0b919e32338c54135e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 17:53:49 +0000 Subject: [PATCH 01/38] feat: add Runtime TUI route menus --- src/components/Root.tsx | 2 ++ src/handlers/runtime/endpoint/index.tsx | 4 +-- src/handlers/runtime/endpoint/screen.tsx | 6 ++++ src/handlers/runtime/index.tsx | 4 +-- src/handlers/runtime/routes.tsx | 23 +++++++++++++ src/handlers/runtime/runtime.screen.test.tsx | 36 ++++++++++++++++++++ src/handlers/runtime/runtime.test.tsx | 4 +-- src/handlers/runtime/screen.tsx | 6 ++++ src/handlers/runtime/version/index.tsx | 4 +-- src/handlers/runtime/version/screen.tsx | 6 ++++ 10 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 src/handlers/runtime/endpoint/screen.tsx create mode 100644 src/handlers/runtime/routes.tsx create mode 100644 src/handlers/runtime/runtime.screen.test.tsx create mode 100644 src/handlers/runtime/screen.tsx create mode 100644 src/handlers/runtime/version/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ef8af5bf9..bc58095c3 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -19,6 +19,7 @@ import { HarnessDeleteEndpointScreen } from "../handlers/harness/endpoint/delete import { HarnessVersionScreen } from "../handlers/harness/version/screen.tsx"; import { HarnessGetVersionScreen } from "../handlers/harness/version/get/screen.tsx"; import { HarnessListVersionsScreen } from "../handlers/harness/version/list/screen.tsx"; +import { runtimeRoutes } from "../handlers/runtime/routes.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -189,6 +190,7 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/harness/version/list/:harnessId" element={} /> + {runtimeRoutes(ctx, core)} } /> diff --git a/src/handlers/runtime/endpoint/index.tsx b/src/handlers/runtime/endpoint/index.tsx index 1af837ecd..4e35e5a19 100644 --- a/src/handlers/runtime/endpoint/index.tsx +++ b/src/handlers/runtime/endpoint/index.tsx @@ -1,12 +1,12 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO, Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createGetRuntimeEndpointHandler } from "./get"; import { createListRuntimeEndpointsHandler } from "./list"; export function createRuntimeEndpointHandler(core: Core, io: AppIO): Router { return new Router("endpoint", "inspect AgentCore Runtime endpoints") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetRuntimeEndpointHandler(core)) .handler(createListRuntimeEndpointsHandler(core)); } diff --git a/src/handlers/runtime/endpoint/screen.tsx b/src/handlers/runtime/endpoint/screen.tsx new file mode 100644 index 000000000..b2f14ac5f --- /dev/null +++ b/src/handlers/runtime/endpoint/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function RuntimeEndpointScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 0f827148e..8d3c99e32 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -1,14 +1,14 @@ import { Router } from "../../router"; +import { renderTui } from "../../tui"; import type { AppIO, Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; -import { createHelpDefault } from "../help"; import { createListRuntimesHandler } from "./list"; import { createRuntimeVersionHandler } from "./version"; export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) .handler(createRuntimeVersionHandler(core, io)) diff --git a/src/handlers/runtime/routes.tsx b/src/handlers/runtime/routes.tsx new file mode 100644 index 000000000..ac36cc8c9 --- /dev/null +++ b/src/handlers/runtime/routes.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; +import { Route } from "react-router"; +import type { Context } from "../../router"; +import type { Core } from "../types"; +import { RuntimeEndpointScreen } from "./endpoint/screen"; +import { RuntimeScreen } from "./screen"; +import { RuntimeVersionScreen } from "./version/screen"; + +export function runtimeRoutes(ctx: Context, core: Core): ReactNode { + return ( + <> + } /> + } + /> + } + /> + + ); +} diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx new file mode 100644 index 000000000..38c54380b --- /dev/null +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { cleanupScreens, renderScreen, waitForText } from "../../testing"; + +afterEach(cleanupScreens); + +describe("runtime menus", () => { + test("renders the Runtime command menu", async () => { + const r = renderScreen("/agentcore/runtime"); + await waitForText(r.lastFrame, "agentcore → runtime"); + + const frame = r.lastFrame()!; + for (const command of ["get", "list", "version", "endpoint"]) { + expect(frame).toContain(command); + } + }); + + test("renders the Runtime version command menu", async () => { + const r = renderScreen("/agentcore/runtime/version"); + await waitForText(r.lastFrame, "agentcore → runtime → version"); + + const frame = r.lastFrame()!; + for (const command of ["get", "list"]) { + expect(frame).toContain(command); + } + }); + + test("renders the Runtime endpoint command menu", async () => { + const r = renderScreen("/agentcore/runtime/endpoint"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint"); + + const frame = r.lastFrame()!; + for (const command of ["get", "list"]) { + expect(frame).toContain(command); + } + }); +}); diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 501aed620..75691be1b 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -66,9 +66,9 @@ describe("runtime command hierarchy", () => { }); test.each(["runtime", "runtime version", "runtime endpoint"])( - "prints help for bare `%s` without an SDK call", + "prints help for `%s --json` without an SDK call", async (command) => { - const stdout = await run(command.split(" ")); + const stdout = await run([...command.split(" "), "--json"]); expect(stdout).toContain(`Usage: agentcore ${command}`); expect(stdout).toContain("Commands:"); diff --git a/src/handlers/runtime/screen.tsx b/src/handlers/runtime/screen.tsx new file mode 100644 index 000000000..76430b642 --- /dev/null +++ b/src/handlers/runtime/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../components/RouterScreen"; +import type { ScreenProps } from "../types"; + +export function RuntimeScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/runtime/version/index.tsx b/src/handlers/runtime/version/index.tsx index d54543a8c..6f82bd3b6 100644 --- a/src/handlers/runtime/version/index.tsx +++ b/src/handlers/runtime/version/index.tsx @@ -1,12 +1,12 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; import type { AppIO, Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createGetRuntimeVersionHandler } from "./get"; import { createListRuntimeVersionsHandler } from "./list"; export function createRuntimeVersionHandler(core: Core, io: AppIO): Router { return new Router("version", "inspect AgentCore Runtime versions") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetRuntimeVersionHandler(core)) .handler(createListRuntimeVersionsHandler(core)); } diff --git a/src/handlers/runtime/version/screen.tsx b/src/handlers/runtime/version/screen.tsx new file mode 100644 index 000000000..c7dbbdaee --- /dev/null +++ b/src/handlers/runtime/version/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function RuntimeVersionScreen(props: ScreenProps) { + return ; +} From b95036a7cef4e89e668a232ae26a764c81d3d4d2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 18:07:28 +0000 Subject: [PATCH 02/38] test: add controllable Runtime screen client --- src/handlers/runtime/runtime.screen.test.tsx | 152 ++++++++++++++++++- src/testing/TestCoreClient.tsx | 129 +++++++++++++--- src/testing/index.tsx | 7 +- src/testing/renderScreen.tsx | 26 +++- 4 files changed, 281 insertions(+), 33 deletions(-) diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 38c54380b..b1e5058e9 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -1,8 +1,158 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { cleanupScreens, renderScreen, waitForText } from "../../testing"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + TestRuntimeClient, + waitForText, +} from "../../testing"; afterEach(cleanupScreens); +describe("runtime test client", () => { + test("configures list responses and records calls", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ agentRuntimes: [], nextToken: "page-2" }); + + await core.runtime.listRuntimes(undefined, 20, { region: "us-east-1" }); + + expect(core.runtime.calls).toEqual([ + { + method: "listRuntimes", + args: [undefined, 20, { region: "us-east-1" }], + }, + ]); + }); + + test("configures detail responses and records every method call", async () => { + const runtime = new TestRuntimeClient(); + const options = { region: "us-west-2" }; + const getResponse = { + agentRuntimeName: "latest", + } as Awaited>; + const versionResponse = { + agentRuntimeName: "version", + } as Awaited>; + const endpointResponse = { + endpointName: "prod", + } as unknown as Awaited>; + + expect(runtime.setGetResponse(getResponse)).toBe(runtime); + expect(runtime.setGetVersionResponse(versionResponse)).toBe(runtime); + expect(runtime.setGetEndpointResponse(endpointResponse)).toBe(runtime); + + expect(await runtime.getRuntime("runtime-1", options)).toBe(getResponse); + expect(await runtime.getRuntimeVersion("runtime-1", "2", options)).toBe(versionResponse); + expect(await runtime.getRuntimeEndpoint("runtime-1", "prod", options)).toBe(endpointResponse); + + expect(runtime.calls).toEqual([ + { method: "getRuntime", args: ["runtime-1", options] }, + { method: "getRuntimeVersion", args: ["runtime-1", "2", options] }, + { method: "getRuntimeEndpoint", args: ["runtime-1", "prod", options] }, + ]); + }); + + test("serves token-specific, first-page, and empty list responses", async () => { + const runtime = new TestRuntimeClient(); + const options = { region: "us-east-1" }; + const runtimesFirst = { agentRuntimes: [], nextToken: "runtime-page-2" }; + const runtimesSecond = { agentRuntimes: [], nextToken: "runtime-page-3" }; + const versionsFirst = { agentRuntimes: [], nextToken: "version-page-2" }; + const versionsSecond = { agentRuntimes: [], nextToken: "version-page-3" }; + const endpointsFirst = { runtimeEndpoints: [], nextToken: "endpoint-page-2" }; + const endpointsSecond = { runtimeEndpoints: [], nextToken: "endpoint-page-3" }; + + expect(runtime.setListResponse(runtimesFirst)).toBe(runtime); + expect(runtime.setListResponse(runtimesSecond, "runtime-page-2")).toBe(runtime); + expect(runtime.setListVersionsResponse(versionsFirst)).toBe(runtime); + expect(runtime.setListVersionsResponse(versionsSecond, "version-page-2")).toBe(runtime); + expect(runtime.setListEndpointsResponse(endpointsFirst)).toBe(runtime); + expect(runtime.setListEndpointsResponse(endpointsSecond, "endpoint-page-2")).toBe(runtime); + + expect(await runtime.listRuntimes("runtime-page-2", 10, options)).toBe(runtimesSecond); + expect(await runtime.listRuntimes("unknown", 10, options)).toBe(runtimesFirst); + expect(await runtime.listRuntimeVersions("runtime-1", "version-page-2", 11, options)).toBe( + versionsSecond, + ); + expect(await runtime.listRuntimeVersions("runtime-1", "unknown", 11, options)).toBe( + versionsFirst, + ); + expect(await runtime.listRuntimeEndpoints("runtime-1", "endpoint-page-2", 12, options)).toBe( + endpointsSecond, + ); + expect(await runtime.listRuntimeEndpoints("runtime-1", "unknown", 12, options)).toBe( + endpointsFirst, + ); + + const empty = new TestRuntimeClient(); + expect(await empty.listRuntimes(undefined, undefined, options)).toEqual({ + agentRuntimes: [], + }); + expect(await empty.listRuntimeVersions("runtime-1", undefined, undefined, options)).toEqual({ + agentRuntimes: [], + }); + expect(await empty.listRuntimeEndpoints("runtime-1", undefined, undefined, options)).toEqual({ + runtimeEndpoints: [], + }); + + expect(runtime.calls).toEqual([ + { method: "listRuntimes", args: ["runtime-page-2", 10, options] }, + { method: "listRuntimes", args: ["unknown", 10, options] }, + { + method: "listRuntimeVersions", + args: ["runtime-1", "version-page-2", 11, options], + }, + { method: "listRuntimeVersions", args: ["runtime-1", "unknown", 11, options] }, + { + method: "listRuntimeEndpoints", + args: ["runtime-1", "endpoint-page-2", 12, options], + }, + { method: "listRuntimeEndpoints", args: ["runtime-1", "unknown", 12, options] }, + ]); + }); + + test("records calls before throwing configured errors", async () => { + const runtime = new TestRuntimeClient(); + const error = new Error("access denied"); + const options = { region: "us-east-1" }; + const response = { + agentRuntimeName: "recovered", + } as Awaited>; + + runtime.setGetResponse(response); + expect(runtime.setError(error)).toBe(runtime); + await expect(runtime.getRuntime("runtime-1", options)).rejects.toBe(error); + expect(runtime.calls).toEqual([{ method: "getRuntime", args: ["runtime-1", options] }]); + + expect(runtime.setError(undefined)).toBe(runtime); + expect(await runtime.getRuntime("runtime-2", options)).toBe(response); + }); + + test("resizes the rendered terminal and defaults omitted rows to 40", async () => { + const r = renderScreen("/agentcore/runtime"); + await waitForText(r.lastFrame, "agentcore → runtime"); + + expect( + Math.max( + ...r + .lastFrame()! + .split("\n") + .map((line) => line.length), + ), + ).toBe(100); + + await r.resize(60, 12); + const compactLines = r.lastFrame()!.split("\n"); + expect(Math.max(...compactLines.map((line) => line.length))).toBe(60); + expect(compactLines).toHaveLength(12); + + await r.resize(70); + const defaultHeightLines = r.lastFrame()!.split("\n"); + expect(Math.max(...defaultHeightLines.map((line) => line.length))).toBe(70); + expect(defaultHeightLines.length).toBeGreaterThan(12); + }); +}); + describe("runtime menus", () => { test("renders the Runtime command menu", async () => { const r = renderScreen("/agentcore/runtime"); diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 93a2c33aa..531c2df07 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -436,51 +436,130 @@ export class TestHarnessClient implements CoreHarnessClient { } } -class TestRuntimeClient implements CoreRuntimeClient { - async getRuntime(_id: string, _options: CoreOptions): Promise { - return DEFAULT_GET_RUNTIME_RESPONSE; +export class TestRuntimeClient implements CoreRuntimeClient { + readonly calls: RecordedCall[] = []; + + private getResponse: GetAgentRuntimeResponse = DEFAULT_GET_RUNTIME_RESPONSE; + private getVersionResponse: GetAgentRuntimeResponse = DEFAULT_GET_RUNTIME_RESPONSE; + private getEndpointResponse: GetAgentRuntimeEndpointResponse = + DEFAULT_GET_RUNTIME_ENDPOINT_RESPONSE; + private listResponses = new Map(); + private listVersionResponses = new Map(); + private listEndpointResponses = new Map(); + private error?: Error; + + setGetResponse(response: GetAgentRuntimeResponse): this { + this.getResponse = response; + return this; + } + + setGetVersionResponse(response: GetAgentRuntimeResponse): this { + this.getVersionResponse = response; + return this; + } + + setGetEndpointResponse(response: GetAgentRuntimeEndpointResponse): this { + this.getEndpointResponse = response; + return this; + } + + setListResponse(response: ListAgentRuntimesResponse, forNextToken?: string): this { + this.listResponses.set(forNextToken, response); + return this; + } + + setListVersionsResponse(response: ListAgentRuntimeVersionsResponse, forNextToken?: string): this { + this.listVersionResponses.set(forNextToken, response); + return this; + } + + setListEndpointsResponse( + response: ListAgentRuntimeEndpointsResponse, + forNextToken?: string, + ): this { + this.listEndpointResponses.set(forNextToken, response); + return this; + } + + setError(error: Error | undefined): this { + this.error = error; + return this; + } + + async getRuntime(id: string, options: CoreOptions): Promise { + this.calls.push({ method: "getRuntime", args: [id, options] }); + if (this.error) throw this.error; + return this.getResponse; } async getRuntimeVersion( - _id: string, - _version: string, - _options: CoreOptions, + id: string, + version: string, + options: CoreOptions, ): Promise { - return DEFAULT_GET_RUNTIME_RESPONSE; + this.calls.push({ method: "getRuntimeVersion", args: [id, version, options] }); + if (this.error) throw this.error; + return this.getVersionResponse; } async getRuntimeEndpoint( - _id: string, - _qualifier: string, - _options: CoreOptions, + id: string, + qualifier: string, + options: CoreOptions, ): Promise { - return DEFAULT_GET_RUNTIME_ENDPOINT_RESPONSE; + this.calls.push({ method: "getRuntimeEndpoint", args: [id, qualifier, options] }); + if (this.error) throw this.error; + return this.getEndpointResponse; } async listRuntimes( - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { - return DEFAULT_LIST_RUNTIMES_RESPONSE; + this.calls.push({ method: "listRuntimes", args: [nextToken, maxResults, options] }); + if (this.error) throw this.error; + return ( + this.listResponses.get(nextToken) ?? + this.listResponses.get(undefined) ?? + DEFAULT_LIST_RUNTIMES_RESPONSE + ); } async listRuntimeVersions( - _id: string, - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + id: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { - return DEFAULT_LIST_RUNTIME_VERSIONS_RESPONSE; + this.calls.push({ + method: "listRuntimeVersions", + args: [id, nextToken, maxResults, options], + }); + if (this.error) throw this.error; + return ( + this.listVersionResponses.get(nextToken) ?? + this.listVersionResponses.get(undefined) ?? + DEFAULT_LIST_RUNTIME_VERSIONS_RESPONSE + ); } async listRuntimeEndpoints( - _id: string, - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + id: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { - return DEFAULT_LIST_RUNTIME_ENDPOINTS_RESPONSE; + this.calls.push({ + method: "listRuntimeEndpoints", + args: [id, nextToken, maxResults, options], + }); + if (this.error) throw this.error; + return ( + this.listEndpointResponses.get(nextToken) ?? + this.listEndpointResponses.get(undefined) ?? + DEFAULT_LIST_RUNTIME_ENDPOINTS_RESPONSE + ); } } type TestCoreClientOptions = { diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 30ee08187..e3e711ee6 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -2,7 +2,12 @@ export { parse, stringify } from "./serialization"; export { fixtureFactories, isRecording, matchGolden } from "./fixtures"; export { testIO, type TestIO } from "./testIO"; export { tick, waitFor } from "./timing"; -export { TestCoreClient, TestHarnessClient, type RecordedCall } from "./TestCoreClient"; +export { + TestCoreClient, + TestHarnessClient, + TestRuntimeClient, + type RecordedCall, +} from "./TestCoreClient"; export { StreamController } from "./StreamController"; export { renderScreen, diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 18973cce1..c83975705 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -72,10 +72,25 @@ export interface RenderScreenResult { // press sends a named key (e.g. "return", "escape", "down"), then yields a tick // for the same reason as `write`. press: (key: keyof typeof keys) => Promise; + resize: (columns: number, rows?: number) => Promise; rerender: () => void; unmount: () => void; } +interface ResizableStdout { + readonly columns: number; + readonly rows?: number; + emit(event: "resize"): boolean; +} + +function setWindowSize(stdout: ResizableStdout, columns: number, rows: number): void { + Object.defineProperties(stdout, { + columns: { configurable: true, value: columns }, + rows: { configurable: true, value: rows }, + }); + stdout.emit("resize"); +} + // keys maps friendly names to the escape sequences Ink decodes into key events. export const keys = { return: "\r", @@ -103,12 +118,7 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R const instance = render(); - // ink-testing-library's fake stdout reports columns=100 but no rows, so Ink - // falls back to the host terminal's height — making tests clip (and fail) - // differently per environment. Pin a fixed, realistically sized window and - // announce it; useWindowSize listens for "resize" and re-renders. - Object.defineProperty(instance.stdout, "rows", { value: 40 }); - instance.stdout.emit("resize"); + setWindowSize(instance.stdout, 100, 40); return { core, @@ -127,6 +137,10 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R instance.stdin.write(keys[key]); await tick(); }, + resize: async (columns, rows = 40) => { + setWindowSize(instance.stdout, columns, rows); + await tick(); + }, rerender: () => instance.rerender(), unmount: instance.unmount, From 410de77f3d1d4ea573355b368da8900806624b7b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 18:14:26 +0000 Subject: [PATCH 03/38] test: focus Runtime test helper coverage --- src/handlers/runtime/runtime.screen.test.tsx | 140 ++----------------- 1 file changed, 14 insertions(+), 126 deletions(-) diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index b1e5058e9..f2f6b9067 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -1,14 +1,16 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { - cleanupScreens, - renderScreen, - TestCoreClient, - TestRuntimeClient, - waitForText, -} from "../../testing"; +import { cleanupScreens, renderScreen, TestCoreClient, tick, waitForText } from "../../testing"; afterEach(cleanupScreens); +function frameSize(frame: string): { columns: number; rows: number } { + const lines = frame.split("\n"); + return { + columns: Math.max(...lines.map((line) => line.length)), + rows: lines.length, + }; +} + describe("runtime test client", () => { test("configures list responses and records calls", async () => { const core = new TestCoreClient(); @@ -24,132 +26,18 @@ describe("runtime test client", () => { ]); }); - test("configures detail responses and records every method call", async () => { - const runtime = new TestRuntimeClient(); - const options = { region: "us-west-2" }; - const getResponse = { - agentRuntimeName: "latest", - } as Awaited>; - const versionResponse = { - agentRuntimeName: "version", - } as Awaited>; - const endpointResponse = { - endpointName: "prod", - } as unknown as Awaited>; - - expect(runtime.setGetResponse(getResponse)).toBe(runtime); - expect(runtime.setGetVersionResponse(versionResponse)).toBe(runtime); - expect(runtime.setGetEndpointResponse(endpointResponse)).toBe(runtime); - - expect(await runtime.getRuntime("runtime-1", options)).toBe(getResponse); - expect(await runtime.getRuntimeVersion("runtime-1", "2", options)).toBe(versionResponse); - expect(await runtime.getRuntimeEndpoint("runtime-1", "prod", options)).toBe(endpointResponse); - - expect(runtime.calls).toEqual([ - { method: "getRuntime", args: ["runtime-1", options] }, - { method: "getRuntimeVersion", args: ["runtime-1", "2", options] }, - { method: "getRuntimeEndpoint", args: ["runtime-1", "prod", options] }, - ]); - }); - - test("serves token-specific, first-page, and empty list responses", async () => { - const runtime = new TestRuntimeClient(); - const options = { region: "us-east-1" }; - const runtimesFirst = { agentRuntimes: [], nextToken: "runtime-page-2" }; - const runtimesSecond = { agentRuntimes: [], nextToken: "runtime-page-3" }; - const versionsFirst = { agentRuntimes: [], nextToken: "version-page-2" }; - const versionsSecond = { agentRuntimes: [], nextToken: "version-page-3" }; - const endpointsFirst = { runtimeEndpoints: [], nextToken: "endpoint-page-2" }; - const endpointsSecond = { runtimeEndpoints: [], nextToken: "endpoint-page-3" }; - - expect(runtime.setListResponse(runtimesFirst)).toBe(runtime); - expect(runtime.setListResponse(runtimesSecond, "runtime-page-2")).toBe(runtime); - expect(runtime.setListVersionsResponse(versionsFirst)).toBe(runtime); - expect(runtime.setListVersionsResponse(versionsSecond, "version-page-2")).toBe(runtime); - expect(runtime.setListEndpointsResponse(endpointsFirst)).toBe(runtime); - expect(runtime.setListEndpointsResponse(endpointsSecond, "endpoint-page-2")).toBe(runtime); - - expect(await runtime.listRuntimes("runtime-page-2", 10, options)).toBe(runtimesSecond); - expect(await runtime.listRuntimes("unknown", 10, options)).toBe(runtimesFirst); - expect(await runtime.listRuntimeVersions("runtime-1", "version-page-2", 11, options)).toBe( - versionsSecond, - ); - expect(await runtime.listRuntimeVersions("runtime-1", "unknown", 11, options)).toBe( - versionsFirst, - ); - expect(await runtime.listRuntimeEndpoints("runtime-1", "endpoint-page-2", 12, options)).toBe( - endpointsSecond, - ); - expect(await runtime.listRuntimeEndpoints("runtime-1", "unknown", 12, options)).toBe( - endpointsFirst, - ); - - const empty = new TestRuntimeClient(); - expect(await empty.listRuntimes(undefined, undefined, options)).toEqual({ - agentRuntimes: [], - }); - expect(await empty.listRuntimeVersions("runtime-1", undefined, undefined, options)).toEqual({ - agentRuntimes: [], - }); - expect(await empty.listRuntimeEndpoints("runtime-1", undefined, undefined, options)).toEqual({ - runtimeEndpoints: [], - }); - - expect(runtime.calls).toEqual([ - { method: "listRuntimes", args: ["runtime-page-2", 10, options] }, - { method: "listRuntimes", args: ["unknown", 10, options] }, - { - method: "listRuntimeVersions", - args: ["runtime-1", "version-page-2", 11, options], - }, - { method: "listRuntimeVersions", args: ["runtime-1", "unknown", 11, options] }, - { - method: "listRuntimeEndpoints", - args: ["runtime-1", "endpoint-page-2", 12, options], - }, - { method: "listRuntimeEndpoints", args: ["runtime-1", "unknown", 12, options] }, - ]); - }); - - test("records calls before throwing configured errors", async () => { - const runtime = new TestRuntimeClient(); - const error = new Error("access denied"); - const options = { region: "us-east-1" }; - const response = { - agentRuntimeName: "recovered", - } as Awaited>; - - runtime.setGetResponse(response); - expect(runtime.setError(error)).toBe(runtime); - await expect(runtime.getRuntime("runtime-1", options)).rejects.toBe(error); - expect(runtime.calls).toEqual([{ method: "getRuntime", args: ["runtime-1", options] }]); - - expect(runtime.setError(undefined)).toBe(runtime); - expect(await runtime.getRuntime("runtime-2", options)).toBe(response); - }); - - test("resizes the rendered terminal and defaults omitted rows to 40", async () => { + test("uses exact initial, explicit, and default resize dimensions", async () => { const r = renderScreen("/agentcore/runtime"); await waitForText(r.lastFrame, "agentcore → runtime"); + await tick(); - expect( - Math.max( - ...r - .lastFrame()! - .split("\n") - .map((line) => line.length), - ), - ).toBe(100); + expect(frameSize(r.lastFrame()!)).toEqual({ columns: 100, rows: 40 }); await r.resize(60, 12); - const compactLines = r.lastFrame()!.split("\n"); - expect(Math.max(...compactLines.map((line) => line.length))).toBe(60); - expect(compactLines).toHaveLength(12); + expect(frameSize(r.lastFrame()!)).toEqual({ columns: 60, rows: 12 }); await r.resize(70); - const defaultHeightLines = r.lastFrame()!.split("\n"); - expect(Math.max(...defaultHeightLines.map((line) => line.length))).toBe(70); - expect(defaultHeightLines.length).toBeGreaterThan(12); + expect(frameSize(r.lastFrame()!)).toEqual({ columns: 70, rows: 40 }); }); }); From 2575049f74955426098ee1581840ce8ee2fabd24 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 18:42:22 +0000 Subject: [PATCH 04/38] feat: add Runtime TUI picker --- src/components/ui/data-table/DataTable.tsx | 18 +- .../harness/list/list.screen.test.tsx | 25 +- .../runtime/components/RuntimePicker.tsx | 150 ++++++++ src/handlers/runtime/list/screen.tsx | 16 + src/handlers/runtime/routes.tsx | 8 +- src/handlers/runtime/runtime.screen.test.tsx | 341 +++++++++++++++++- src/testing/renderScreen.tsx | 5 +- 7 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 src/handlers/runtime/components/RuntimePicker.tsx create mode 100644 src/handlers/runtime/list/screen.tsx diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 0d6a94521..4621c057b 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -48,6 +48,7 @@ export function DataTable>({ data, pageSize = 10, searchable = true, + searchPlaceholder = "Filter this page", onSelect, onEscape, onPrevPage, @@ -135,8 +136,11 @@ export function DataTable>({ onNextPage(); } } else setCurrentPage((p) => Math.min(totalPages - 1, p + 1)); - } else if (input === "/") setSearchMode(true); - else if (input === "s") { + } else if (input === "/") { + setSelectedRow(0); + setCurrentPage(0); + setSearchMode(true); + } else if (input === "s") { const col = columns.find((c) => c.sortable); if (col) { if (sortColumn === col.key) setSortDirection((d) => (d === "asc" ? "desc" : "asc")); @@ -190,12 +194,14 @@ export function DataTable>({ {searchMode ? ( - / Filter: + / {searchPlaceholder}: {searchQuery} ) : searchQuery ? ( - / Filter: {searchQuery} + + / {searchPlaceholder}: {searchQuery} + ) : null} @@ -240,7 +246,9 @@ export function DataTable>({ {/* Rows */} {pageData.length === 0 ? ( - {emptyMessage} + + {searchQuery ? "No matches on this page" : emptyMessage} + ) : ( pageData.map((row, i) => { diff --git a/src/handlers/harness/list/list.screen.test.tsx b/src/handlers/harness/list/list.screen.test.tsx index bd668e83e..d25029c95 100644 --- a/src/handlers/harness/list/list.screen.test.tsx +++ b/src/handlers/harness/list/list.screen.test.tsx @@ -170,13 +170,36 @@ describe("harness list screen", () => { await waitForText(r.lastFrame, "alpha_one"); await r.write("/"); await r.write("l"); - await waitForText(r.lastFrame, "/ Filter: l"); + await waitForText(r.lastFrame, "/ Filter this page: l"); expect(core.harness.calls.some((c) => c.method === "listHarnesses" && c.args[0] === "t2")).toBe( false, ); r.unmount(); }); + test("filtering resets selection to the first matching row", async () => { + const alpha = harness({ harnessName: "alpha", harnessId: "alpha-1" }); + const core = coreWith([alpha, harness({ harnessName: "beta", harnessId: "beta-2" })]); + core.harness.setGetResponse(getResponse(alpha)); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "beta"); + await r.press("down"); + await r.write("/"); + for (const character of "alpha") await r.write(character); + await waitForText(r.lastFrame, "/ Filter this page: alpha"); + await r.press("return"); + await r.press("return"); + + await waitForText(r.lastFrame, "agentcore → harness → get → alpha-1"); + await waitFor(() => + core.harness.calls.some((call) => call.method === "getHarness" && call.args[0] === "alpha-1"), + ); + expect( + core.harness.calls.some((call) => call.method === "getHarness" && call.args[0] === "alpha-1"), + ).toBe(true); + }); + test("esc returns to the harness menu", async () => { const core = coreWith([harness()]); const r = renderScreen("/agentcore/harness/list", { core }); diff --git a/src/handlers/runtime/components/RuntimePicker.tsx b/src/handlers/runtime/components/RuntimePicker.tsx new file mode 100644 index 000000000..5c292ac53 --- /dev/null +++ b/src/handlers/runtime/components/RuntimePicker.tsx @@ -0,0 +1,150 @@ +import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Text, useInput, useWindowSize } from "ink"; +import { DataTable } from "../../../components/ui/data-table"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { Spinner } from "../../../components/ui/spinner"; +import { Layout } from "../../../components/Layout"; +import { usePagedList } from "../../../components/usePagedList"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +interface RuntimeRow extends Record { + runtimeId: string; + runtimeName: string; + runtimeVersion: string; + status: string; + lastUpdatedAt: string; +} + +function toRow(runtime: AgentRuntime): RuntimeRow { + const runtimeId = runtime.agentRuntimeId ?? ""; + return { + runtimeId, + runtimeName: runtime.agentRuntimeName ?? runtimeId, + runtimeVersion: runtime.agentRuntimeVersion ?? "-", + status: runtime.status ?? "-", + lastUpdatedAt: runtime.lastUpdatedAt?.toISOString() ?? "-", + }; +} + +export interface RuntimePickerProps extends ScreenProps { + breadcrumb: string[]; + description?: string; + onSelect: (runtimeId: string) => void; + onEscape: () => void; +} + +export function RuntimePicker({ + ctx, + core, + breadcrumb, + description, + onSelect, + onEscape, +}: RuntimePickerProps) { + const opts = coreOptsFromCtx(ctx); + const { columns } = useWindowSize(); + const paging = usePagedList(); + const list = useQuery({ + queryKey: ["runtimes", opts.region, paging.pageSize, paging.token], + queryFn: () => core.runtime.listRuntimes(paging.token, paging.pageSize, opts), + placeholderData: keepPreviousData, + }); + + useInput( + (input, key) => { + if (key.escape) { + onEscape(); + return; + } + if (input === "r" && list.isError) void list.refetch(); + }, + { isActive: list.isPending || list.isError }, + ); + + const nextToken = list.data?.nextToken; + const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const pageTransition = list.isFetching && !list.isPending; + const showId = columns >= 130; + const showUpdatedAt = columns >= 90; + const showStatus = columns >= 70; + const versionWidth = 8; + const statusWidth = showStatus ? 16 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const idWidth = showId ? Math.max(30, Math.floor(columns * 0.36)) : 0; + const nameWidth = Math.max( + 12, + columns - 2 - versionWidth - statusWidth - updatedAtWidth - idWidth, + ); + + return ( + + {list.isPending ? ( + + ) : list.isError ? ( + Error: {(list.error as Error).message} + ) : ( + <> + { + if (row.runtimeId) onSelect(row.runtimeId); + }} + onEscape={onEscape} + onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} + onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} + /> + {(paginated || pageTransition) && ( + + {pageTransition + ? `loading page ${paging.pageIndex + 1}…` + : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} + + )} + + )} + + ); +} diff --git a/src/handlers/runtime/list/screen.tsx b/src/handlers/runtime/list/screen.tsx new file mode 100644 index 000000000..6fc1f0409 --- /dev/null +++ b/src/handlers/runtime/list/screen.tsx @@ -0,0 +1,16 @@ +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../../types"; +import { RuntimePicker } from "../components/RuntimePicker"; + +export function RuntimeListScreen(props: ScreenProps) { + const navigate = useNavigate(); + + return ( + navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`)} + onEscape={() => navigate("/agentcore/runtime")} + /> + ); +} diff --git a/src/handlers/runtime/routes.tsx b/src/handlers/runtime/routes.tsx index ac36cc8c9..5bc459943 100644 --- a/src/handlers/runtime/routes.tsx +++ b/src/handlers/runtime/routes.tsx @@ -1,8 +1,9 @@ import type { ReactNode } from "react"; -import { Route } from "react-router"; +import { Navigate, Route } from "react-router"; import type { Context } from "../../router"; import type { Core } from "../types"; import { RuntimeEndpointScreen } from "./endpoint/screen"; +import { RuntimeListScreen } from "./list/screen"; import { RuntimeScreen } from "./screen"; import { RuntimeVersionScreen } from "./version/screen"; @@ -10,6 +11,11 @@ export function runtimeRoutes(ctx: Context, core: Core): ReactNode { return ( <> } /> + } + /> + } /> } diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index f2f6b9067..7b8c4a2e0 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -1,5 +1,17 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { cleanupScreens, renderScreen, TestCoreClient, tick, waitForText } from "../../testing"; +import type { + AgentRuntime, + ListAgentRuntimesResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + tick, + waitFor, + waitForText, +} from "../../testing"; afterEach(cleanupScreens); @@ -11,6 +23,42 @@ function frameSize(frame: string): { columns: number; rows: number } { }; } +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-1", + agentRuntimeId: "runtime-1", + agentRuntimeVersion: "7", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function coreWithRuntimes(runtimes: AgentRuntime[]): TestCoreClient { + const core = new TestCoreClient(); + core.runtime.setListResponse({ agentRuntimes: runtimes }); + return core; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((release) => { + resolve = release; + }); + return { promise, resolve }; +} + +async function waitForRuntimeMenu(lastFrame: () => string | undefined): Promise { + await waitFor(() => + (lastFrame() ?? "").includes("agentcore → runtime → inspect AgentCore Runtimes"), + ); +} + describe("runtime test client", () => { test("configures list responses and records calls", async () => { const core = new TestCoreClient(); @@ -72,3 +120,294 @@ describe("runtime menus", () => { } }); }); + +describe("runtime picker", () => { + test("renders Runtime identity, latest version, status, and update time when wide", async () => { + const core = coreWithRuntimes([ + runtime({ + agentRuntimeId: "runtime-visible-id", + agentRuntimeName: "orders", + agentRuntimeVersion: "42", + status: "CREATE_FAILED", + lastUpdatedAt: new Date("2026-07-19T01:02:03.000Z"), + }), + ]); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await r.resize(140); + await waitForText(r.lastFrame, "orders"); + const frame = r.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("id"); + expect(frame).toContain("latest"); + expect(frame).toContain("status"); + expect(frame).toContain("lastUpdatedAt"); + expect(frame).toContain("runtime-visible-id"); + expect(frame).toContain("42"); + expect(frame).toContain("CREATE_FAILED"); + expect(frame).toContain("2026-07-19T01:02:03.000Z"); + }); + + test("calls listRuntimes with terminal page size and exact Core options", async () => { + const core = coreWithRuntimes([runtime()]); + renderScreen("/agentcore/runtime/list", { core }); + + await waitFor(() => core.runtime.calls.some((call) => call.args[1] === 32)); + expect(core.runtime.calls.find((call) => call.args[1] === 32)).toEqual({ + method: "listRuntimes", + args: [ + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }); + }); + + test("shows loading and lets Esc return to the Runtime menu", async () => { + const core = new TestCoreClient(); + const pending = deferred(); + core.runtime.listRuntimes = async () => pending.promise; + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "Loading Runtimes…"); + await r.press("escape"); + await waitForRuntimeMenu(r.lastFrame); + expect(r.lastFrame()).toContain("list AgentCore Runtimes"); + }); + + test("shows service errors, retries with r, and lets Esc return to the Runtime menu", async () => { + const core = new TestCoreClient(); + core.runtime.setError(new Error("runtime access denied")); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "runtime access denied"); + expect(r.lastFrame()).toContain("[r] retry"); + + core.runtime.setError(undefined); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "recovered" })], + }); + const callsBeforeRetry = core.runtime.calls.filter( + (call) => call.method === "listRuntimes", + ).length; + await r.write("r"); + await waitForText(r.lastFrame, "recovered"); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( + callsBeforeRetry + 1, + ); + + core.runtime.setError(new Error("runtime access denied")); + const errorScreen = renderScreen("/agentcore/runtime/list", { core }); + await waitForText(errorScreen.lastFrame, "runtime access denied"); + await errorScreen.press("escape"); + await waitForRuntimeMenu(errorScreen.lastFrame); + }); + + test("shows the first-page empty state and lets Esc return to the Runtime menu", async () => { + const r = renderScreen("/agentcore/runtime/list"); + + await waitForText(r.lastFrame, "No Runtimes found in this Region."); + await r.press("escape"); + await waitForRuntimeMenu(r.lastFrame); + expect(r.lastFrame()).toContain("list AgentCore Runtimes"); + }); + + test("pages forward and backward using token history", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "page-one" })], + nextToken: "page-2", + }); + core.runtime.setListResponse( + { + agentRuntimes: [runtime({ agentRuntimeName: "page-two" })], + }, + "page-2", + ); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + expect(r.lastFrame()).toContain("page-one"); + + await r.write("l"); + await waitForText(r.lastFrame, "page-two"); + expect(r.lastFrame()).toContain("page 2"); + expect(core.runtime.calls.at(-1)?.args[0]).toBe("page-2"); + + await r.write("h"); + await waitForText(r.lastFrame, "page-one"); + expect(r.lastFrame()).toContain("page 1 · more →"); + expect(core.runtime.calls.at(-1)?.args[0]).toBeUndefined(); + }); + + test("retains rows and ignores page keys while either page direction is loading", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "stable-page-one" })], + nextToken: "page-2", + }); + core.runtime.setListResponse( + { + agentRuntimes: [runtime({ agentRuntimeName: "settled-page-two" })], + }, + "page-2", + ); + const pageTwo = deferred(); + const pageOneAgain = deferred(); + let holdFirstPage = false; + const listRuntimes = core.runtime.listRuntimes.bind(core.runtime); + core.runtime.listRuntimes = async (...args) => { + const result = await listRuntimes(...args); + if (args[0] === "page-2") await pageTwo.promise; + if (args[0] === undefined && holdFirstPage) await pageOneAgain.promise; + return result; + }; + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "loading page 2…"); + expect(r.lastFrame()).toContain("stable-page-one"); + + const callsDuringTransition = core.runtime.calls.filter( + (call) => call.method === "listRuntimes", + ).length; + await r.write("l"); + await r.write("h"); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( + callsDuringTransition, + ); + + pageTwo.resolve(); + await waitForText(r.lastFrame, "settled-page-two"); + + holdFirstPage = true; + await r.write("h"); + await waitForText(r.lastFrame, "loading page 1…"); + expect(r.lastFrame()).toContain("settled-page-two"); + + const callsDuringBackTransition = core.runtime.calls.filter( + (call) => call.method === "listRuntimes", + ).length; + await r.write("l"); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( + callsDuringBackTransition, + ); + + pageOneAgain.resolve(); + await waitForText(r.lastFrame, "stable-page-one"); + }); + + test("disables page keys while a cached page is refetching", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "cached-page-one" })], + nextToken: "page-2", + }); + core.runtime.setListResponse( + { + agentRuntimes: [runtime({ agentRuntimeName: "cached-page-two" })], + }, + "page-2", + ); + const pageOneRefetch = deferred(); + let holdFirstPage = false; + const listRuntimes = core.runtime.listRuntimes.bind(core.runtime); + core.runtime.listRuntimes = async (...args) => { + const result = await listRuntimes(...args); + if (args[0] === undefined && holdFirstPage) await pageOneRefetch.promise; + return result; + }; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const r = renderScreen("/agentcore/runtime/list", { core, queryClient }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "cached-page-two"); + + holdFirstPage = true; + const callsBeforeBack = core.runtime.calls.length; + await r.write("h"); + await waitFor( + () => + core.runtime.calls.length > callsBeforeBack && + core.runtime.calls.at(-1)?.args[0] === undefined, + ); + expect(r.lastFrame()).toContain("loading page 1…"); + expect(r.lastFrame()).toContain("cached-page-one"); + + const callsDuringRefetch = core.runtime.calls.length; + await r.write("l"); + expect(core.runtime.calls).toHaveLength(callsDuringRefetch); + expect(r.lastFrame()).toContain("cached-page-one"); + + pageOneRefetch.resolve(); + await waitForText(r.lastFrame, "page 1 · more →"); + }); + + test("filters only the loaded page and does not treat h or l as page keys", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "orders" })], + nextToken: "page-2", + }); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("/"); + await r.write("l"); + await r.write("h"); + await waitForText(r.lastFrame, "/ Filter this page: lh"); + expect(r.lastFrame()).toContain("No matches on this page"); + expect(core.runtime.calls.some((call) => call.args[0] === "page-2")).toBe(false); + }); + + test("keeps name and latest version when narrow", async () => { + const core = coreWithRuntimes([ + runtime({ + agentRuntimeId: "hidden-runtime-id", + agentRuntimeName: "visible-name", + agentRuntimeVersion: "88", + status: "CREATE_FAILED", + lastUpdatedAt: new Date("2026-07-18T09:08:07.000Z"), + }), + ]); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await r.resize(60); + await waitForText(r.lastFrame, "visible-name"); + const frame = r.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("latest"); + expect(frame).toContain("visible-name"); + expect(frame).toContain("88"); + expect(frame).not.toContain("hidden-runtime-id"); + expect(frame).not.toContain("CREATE_FAILED"); + expect(frame).not.toContain("2026-07-18T09:08:07.000Z"); + }); + + test("Esc returns to the Runtime menu from a successful direct entry", async () => { + const core = coreWithRuntimes([runtime()]); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "checkout"); + await r.press("escape"); + await waitForRuntimeMenu(r.lastFrame); + expect(r.lastFrame()).toContain("list AgentCore Runtimes"); + }); + + test("bare Runtime get redirects to the picker", async () => { + const core = coreWithRuntimes([runtime({ agentRuntimeName: "redirected" })]); + const r = renderScreen("/agentcore/runtime/get", { core }); + + await waitForText(r.lastFrame, "redirected"); + expect(core.runtime.calls[0]?.method).toBe("listRuntimes"); + }); +}); diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index c83975705..b3c82e70a 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -56,6 +56,9 @@ export interface RenderScreenOptions { core?: TestCoreClient; // ctx overrides the base context (rarely needed). ctx?: Context; + // queryClient overrides the deterministic default when a test needs to + // exercise cache behavior. + queryClient?: QueryClient; } export interface RenderScreenResult { @@ -114,7 +117,7 @@ export function cleanupScreens(): void { export function renderScreen(path: string, options: RenderScreenOptions = {}): RenderScreenResult { const core = options.core ?? new TestCoreClient(); const ctx = options.ctx ?? baseContext(core); - const queryClient = testQueryClient(); + const queryClient = options.queryClient ?? testQueryClient(); const instance = render(); From 506fef314ec34eb94a38e80c32de85c6240872df Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 18:48:06 +0000 Subject: [PATCH 05/38] test: stabilize screen resize synchronization --- src/testing/renderScreen.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index b3c82e70a..7e4987213 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -142,7 +142,10 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R }, resize: async (columns, rows = 40) => { setWindowSize(instance.stdout, columns, rows); - await tick(); + await waitFor(() => { + const lines = (instance.lastFrame() ?? "").split("\n"); + return lines.length === rows && Math.max(...lines.map((line) => line.length)) === columns; + }); }, rerender: () => instance.rerender(), From 95bd4c1fe7d2fe478cccc0480071a6fbebc19fca Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 19:00:42 +0000 Subject: [PATCH 06/38] feat: add Runtime TUI hub and detail --- src/components/JsonDetail.tsx | 21 +- src/components/Root.tsx | 9 +- src/handlers/runtime/get/screen.tsx | 145 +++++++++ src/handlers/runtime/routes.tsx | 9 + src/handlers/runtime/runtime.screen.test.tsx | 295 +++++++++++++++++++ src/testing/renderScreen.tsx | 22 +- 6 files changed, 495 insertions(+), 6 deletions(-) create mode 100644 src/handlers/runtime/get/screen.tsx diff --git a/src/components/JsonDetail.tsx b/src/components/JsonDetail.tsx index fbfe98854..d772a3aff 100644 --- a/src/components/JsonDetail.tsx +++ b/src/components/JsonDetail.tsx @@ -16,19 +16,35 @@ export interface JsonDetailProps { data: unknown; // loadingLabel names what's loading (e.g. "Loading endpoint…"). loadingLabel: string; + onEscape?: () => void; + onRetry?: () => void; } // JsonDetail is the shared "show me the raw resource" screen body: a scrollable // pretty-printed JSON block framed by the standard Layout. Every harness // resource (harness, version, endpoint) gets the same detail treatment. Esc pops // back to wherever the user came from; jk/arrows scroll. -export function JsonDetail({ breadcrumb, isPending, error, data, loadingLabel }: JsonDetailProps) { +export function JsonDetail({ + breadcrumb, + isPending, + error, + data, + loadingLabel, + onEscape, + onRetry, +}: JsonDetailProps) { const navigate = useNavigate(); const scrollRef = useRef(null); useInput((input, key) => { if (key.escape) { - navigate(-1); + if (onEscape) onEscape(); + else navigate(-1); + return; + } + if (input === "r" && error && onRetry) { + onRetry(); + return; } if (key.upArrow || input === "k") { scrollRef.current?.scrollBy(-1); @@ -43,6 +59,7 @@ export function JsonDetail({ breadcrumb, isPending, error, data, loadingLabel }: breadcrumb={breadcrumb} keyHints={[ { key: "↑↓/kj", label: "navigate" }, + ...(error && onRetry ? [{ key: "r", label: "retry" }] : []), { key: "esc", label: "back" }, { key: "ctl+c", label: "quit" }, ]} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index bc58095c3..897571637 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { MemoryRouter, Navigate, Route, Routes } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Core } from "../handlers/types.tsx"; @@ -35,11 +35,15 @@ export interface RootProps { // leaves it unset (a stable one is created per mount); tests inject one — e.g. // with retries disabled — to keep behavior deterministic and fast. queryClient?: QueryClient; + + // Tests can add route targets for flows whose destination screens belong to + // a later implementation slice. + additionalRoutes?: ReactNode; } // Root is the top of the Ink React tree, rendered by the `agentcore` default // handler when the CLI is invoked without a subcommand. -export function Root({ path, ctx, core, queryClient }: RootProps) { +export function Root({ path, ctx, core, queryClient, additionalRoutes }: RootProps) { // Create the QueryClient once per mount; a lazy initializer keeps it stable // across re-renders (a fresh client would drop the cache and refetch). An // injected client (tests) takes precedence. @@ -191,6 +195,7 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { element={} /> {runtimeRoutes(ctx, core)} + {additionalRoutes} } /> diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx new file mode 100644 index 000000000..ba3583cd7 --- /dev/null +++ b/src/handlers/runtime/get/screen.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Box, Text, useInput } from "ink"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../components/JsonDetail"; +import { KeyValueTable } from "../../../components/KeyValueTable.js"; +import { Layout } from "../../../components/Layout"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { Divider } from "../../../components/ui/divider/Divider.js"; +import { Spinner } from "../../../components/ui/spinner"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +const ACTIONS = [ + { + name: "detail", + description: "show the full JSON definition", + to: (id: string) => `/agentcore/runtime/get/${encodeURIComponent(id)}/json`, + }, + { + name: "versions", + description: "list immutable Runtime versions", + 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)}`, + }, +] as const; + +export function RuntimeGetScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const { runtimeId } = useParams(); + const [index, setIndex] = useState(0); + const detail = useQuery({ + queryKey: ["runtime", opts.region, runtimeId], + queryFn: () => core.runtime.getRuntime(runtimeId!, opts), + enabled: runtimeId !== undefined, + }); + + useInput((input, key) => { + if (key.escape) { + navigate("/agentcore/runtime/list"); + return; + } + if (input === "r" && detail.isError) { + void detail.refetch(); + return; + } + if (detail.isError || !detail.data) return; + if (key.upArrow || input === "k") { + setIndex((current) => Math.max(0, current - 1)); + return; + } + if (key.downArrow || input === "j") { + setIndex((current) => Math.min(ACTIONS.length - 1, current + 1)); + return; + } + if (key.return && runtimeId) navigate(ACTIONS[index]!.to(runtimeId)); + }); + + const nameWidth = ACTIONS.reduce((width, action) => Math.max(width, action.name.length), 0) + 3; + + return ( + + {detail.isPending ? ( + + ) : detail.isError ? ( + Error: {(detail.error as Error).message} + ) : ( + + + + + + + + + {ACTIONS.map((action, actionIndex) => { + const selected = actionIndex === index; + return ( + + {selected ? "❯ " : " "} + + {action.name.padEnd(nameWidth)} + + {action.description} + + ); + })} + + + )} + + ); +} + +export function RuntimeGetJsonScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const { runtimeId } = useParams(); + const detail = useQuery({ + queryKey: ["runtime", opts.region, runtimeId], + queryFn: () => core.runtime.getRuntime(runtimeId!, opts), + enabled: runtimeId !== undefined, + }); + + return ( + navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId!)}`)} + onRetry={() => void detail.refetch()} + /> + ); +} diff --git a/src/handlers/runtime/routes.tsx b/src/handlers/runtime/routes.tsx index 5bc459943..53f67d69f 100644 --- a/src/handlers/runtime/routes.tsx +++ b/src/handlers/runtime/routes.tsx @@ -3,6 +3,7 @@ import { Navigate, Route } from "react-router"; import type { Context } from "../../router"; import type { Core } from "../types"; import { RuntimeEndpointScreen } from "./endpoint/screen"; +import { RuntimeGetJsonScreen, RuntimeGetScreen } from "./get/screen"; import { RuntimeListScreen } from "./list/screen"; import { RuntimeScreen } from "./screen"; import { RuntimeVersionScreen } from "./version/screen"; @@ -16,6 +17,14 @@ export function runtimeRoutes(ctx: Context, core: Core): ReactNode { element={} /> } /> + } + /> + } + /> } diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 7b8c4a2e0..582524a79 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -1,9 +1,12 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AgentRuntime, + GetAgentRuntimeResponse, ListAgentRuntimesResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { QueryClient } from "@tanstack/react-query"; +import { Text } from "ink"; +import { Route, useParams } from "react-router"; import { cleanupScreens, renderScreen, @@ -36,6 +39,38 @@ function runtime(overrides: Partial = {}): AgentRuntime { }; } +function getRuntimeResponse( + overrides: Partial = {}, +): GetAgentRuntimeResponse { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeName: "checkout", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "7", + 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" }, + status: "READY", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28_800, + }, + description: "Checkout Runtime", + workloadIdentityDetails: { + workloadIdentityArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity/checkout", + }, + agentRuntimeArtifact: { + containerConfiguration: { + containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/checkout:latest", + }, + }, + metadataConfiguration: { requireMMDSV2: true }, + ...overrides, + }; +} + function coreWithRuntimes(runtimes: AgentRuntime[]): TestCoreClient { const core = new TestCoreClient(); core.runtime.setListResponse({ agentRuntimes: runtimes }); @@ -59,6 +94,15 @@ async function waitForRuntimeMenu(lastFrame: () => string | undefined): Promise< ); } +function RuntimeActionTarget({ action }: { action: string }) { + const { runtimeId } = useParams(); + return ( + + {action} target: {runtimeId} + + ); +} + describe("runtime test client", () => { test("configures list responses and records calls", async () => { const core = new TestCoreClient(); @@ -411,3 +455,254 @@ describe("runtime picker", () => { expect(core.runtime.calls[0]?.method).toBe("listRuntimes"); }); }); + +describe("runtime hub", () => { + test("fetches the route ID with exact Core options and renders its summary", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse()); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + + await waitForText(r.lastFrame, "arn:aws:bedrock-agentcore:us-east-1"); + expect(r.lastFrame()).toContain("runtime-123"); + expect(r.lastFrame()).toContain("READY"); + expect(r.lastFrame()).toMatch(/version\s+7/); + expect(r.lastFrame()).toMatch(/network\s+PUBLIC/); + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntime")); + expect(core.runtime.calls.find((call) => call.method === "getRuntime")).toEqual({ + method: "getRuntime", + args: [ + "runtime-123", + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }); + }); + + test("renders exactly the read-only detail, versions, and endpoints 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).toContain("versions"); + expect(frame).toContain("endpoints"); + for (const excluded of ["invoke", "exec", "update", "create", "delete"]) { + expect(frame).not.toContain(excluded); + } + }); + + test("picker selection encodes the ID and opens the matching Runtime hub", async () => { + const runtimeId = "runtime/blue one"; + const core = coreWithRuntimes([ + runtime({ agentRuntimeId: runtimeId, agentRuntimeName: "encoded-runtime" }), + ]); + core.runtime.setGetResponse( + getRuntimeResponse({ + agentRuntimeId: runtimeId, + agentRuntimeName: "encoded-runtime", + }), + ); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "encoded-runtime"); + await r.press("return"); + await waitForText(r.lastFrame, `agentcore → runtime → get → ${runtimeId}`); + await waitFor(() => + core.runtime.calls.some((call) => call.method === "getRuntime" && call.args[0] === runtimeId), + ); + }); + + test.each([ + ["versions", 1, "version"], + ["endpoints", 2, "endpoint"], + ] as const)( + "selecting %s opens its encoded Runtime-scoped route", + async (action, downPresses, routeGroup) => { + const runtimeId = "runtime/blue one"; + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse({ agentRuntimeId: runtimeId })); + const r = renderScreen(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`, { + core, + additionalRoutes: ( + } + /> + ), + }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + for (let index = 0; index < downPresses; index += 1) { + await r.press("down"); + } + await r.press("return"); + + await waitForText(r.lastFrame, `${action} target: ${runtimeId}`); + }, + ); + + test("opens complete Runtime JSON from the detail action and scrolls", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse( + getRuntimeResponse({ + environmentVariables: Object.fromEntries( + Array.from({ length: 30 }, (_, index) => [`VARIABLE_${index}`, `value-${index}`]), + ), + }), + ); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123 → json"); + const frame = r.lastFrame()!; + expect(frame).toContain('"agentRuntimeId"'); + expect(frame).toContain('"networkConfiguration"'); + expect(frame).toContain('"lifecycleConfiguration"'); + expect(frame).not.toContain('"VARIABLE_29"'); + for (let index = 0; index < 20; index += 1) await r.press("down"); + for (let index = 0; index < 20; index += 1) await r.write("j"); + await waitForText(r.lastFrame, '"VARIABLE_29"'); + expect(r.lastFrame()).not.toContain('"agentRuntimeId"'); + await r.press("up"); + await r.write("k"); + expect(r.lastFrame()).toContain('"VARIABLE_28"'); + }); + + test("retries a failed hub query without leaving the route", async () => { + const core = new TestCoreClient(); + core.runtime.setError(new Error("runtime unavailable")); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + + await waitForText(r.lastFrame, "runtime unavailable"); + expect(r.lastFrame()).toContain("agentcore → runtime → get → runtime-123"); + expect(r.lastFrame()).toContain("[r] retry"); + + core.runtime.setError(undefined); + core.runtime.setGetResponse(getRuntimeResponse()); + const callsBeforeRetry = core.runtime.calls.length; + await r.write("r"); + await waitForText(r.lastFrame, "show the full JSON definition"); + expect(core.runtime.calls).toHaveLength(callsBeforeRetry + 1); + }); + + test("does not activate cached hub actions after a background refetch fails", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse()); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { + core, + queryClient, + }); + + await waitForText(r.lastFrame, "show the full JSON definition"); + core.runtime.setError(new Error("background refresh failed")); + await queryClient.invalidateQueries({ + queryKey: ["runtime", "us-east-1", "runtime-123"], + }); + await waitForText(r.lastFrame, "background refresh failed"); + + await r.press("return"); + await tick(); + expect(r.lastFrame()).toContain("agentcore → runtime → get → runtime-123"); + expect(r.lastFrame()).not.toContain("→ json"); + expect(r.lastFrame()).toContain("background refresh failed"); + }); + + test("retries a failed JSON query without leaving the route", async () => { + const core = new TestCoreClient(); + core.runtime.setError(new Error("detail unavailable")); + const r = renderScreen("/agentcore/runtime/get/runtime-123/json", { core }); + + await waitForText(r.lastFrame, "detail unavailable"); + expect(r.lastFrame()).toContain("[r] retry"); + + core.runtime.setError(undefined); + core.runtime.setGetResponse(getRuntimeResponse()); + const callsBeforeRetry = core.runtime.calls.length; + await r.write("r"); + await waitForText(r.lastFrame, '"agentRuntimeId"'); + expect(core.runtime.calls).toHaveLength(callsBeforeRetry + 1); + }); + + test("Esc from the hub returns explicitly to the Runtime picker", 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"); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → list"); + }); + + test("Esc from Runtime JSON returns explicitly to the Runtime hub", async () => { + const core = new TestCoreClient(); + core.runtime.setGetResponse(getRuntimeResponse()); + const r = renderScreen("/agentcore/runtime/get/runtime-123/json", { core }); + + await waitForText(r.lastFrame, '"agentRuntimeId"'); + await r.press("escape"); + await waitFor(() => { + const frame = r.lastFrame() ?? ""; + return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); + }); + await waitForText(r.lastFrame, "show the full JSON definition"); + }); + + test("Esc remains active while hub and JSON routes are loading", async () => { + const hubCore = new TestCoreClient(); + const hubPending = deferred(); + hubCore.runtime.getRuntime = async () => hubPending.promise; + const hub = renderScreen("/agentcore/runtime/get/runtime-123", { core: hubCore }); + + await waitForText(hub.lastFrame, "Loading Runtime…"); + await hub.press("escape"); + await waitForText(hub.lastFrame, "agentcore → runtime → list"); + hub.unmount(); + + const jsonCore = new TestCoreClient(); + const jsonPending = deferred(); + jsonCore.runtime.getRuntime = async () => jsonPending.promise; + const json = renderScreen("/agentcore/runtime/get/runtime-123/json", { + core: jsonCore, + }); + + await waitForText(json.lastFrame, "Loading Runtime…"); + await json.press("escape"); + await waitFor(() => { + const frame = json.lastFrame() ?? ""; + return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); + }); + }); + + test("Esc remains active while hub and JSON routes show errors", async () => { + const hubCore = new TestCoreClient(); + hubCore.runtime.setError(new Error("hub failed")); + const hub = renderScreen("/agentcore/runtime/get/runtime-123", { core: hubCore }); + + await waitForText(hub.lastFrame, "hub failed"); + await hub.press("escape"); + await waitForText(hub.lastFrame, "agentcore → runtime → list"); + hub.unmount(); + + const jsonCore = new TestCoreClient(); + jsonCore.runtime.setError(new Error("json failed")); + const json = renderScreen("/agentcore/runtime/get/runtime-123/json", { + core: jsonCore, + }); + + await waitForText(json.lastFrame, "json failed"); + await json.press("escape"); + await waitFor(() => { + const frame = json.lastFrame() ?? ""; + return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); + }); + }); +}); diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 7e4987213..a7aee5f75 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -59,6 +59,8 @@ export interface RenderScreenOptions { // queryClient overrides the deterministic default when a test needs to // exercise cache behavior. queryClient?: QueryClient; + // additionalRoutes adds test-only destinations before Root's wildcard. + additionalRoutes?: React.ReactNode; } export interface RenderScreenResult { @@ -119,7 +121,15 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R const ctx = options.ctx ?? baseContext(core); const queryClient = options.queryClient ?? testQueryClient(); - const instance = render(); + const instance = render( + , + ); setWindowSize(instance.stdout, 100, 40); @@ -148,7 +158,15 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R }); }, rerender: () => - instance.rerender(), + instance.rerender( + , + ), unmount: instance.unmount, }; } From 1a3bb9436826bc1ca966c162601b9781df65700b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 19:15:19 +0000 Subject: [PATCH 07/38] feat: add Runtime version TUI --- .../components/RuntimeVersionPicker.tsx | 144 +++++++ src/handlers/runtime/routes.tsx | 18 + src/handlers/runtime/runtime.screen.test.tsx | 25 +- src/handlers/runtime/version/get/screen.tsx | 28 ++ src/handlers/runtime/version/list/screen.tsx | 35 ++ .../runtime/version/version.screen.test.tsx | 396 ++++++++++++++++++ 6 files changed, 639 insertions(+), 7 deletions(-) create mode 100644 src/handlers/runtime/components/RuntimeVersionPicker.tsx create mode 100644 src/handlers/runtime/version/get/screen.tsx create mode 100644 src/handlers/runtime/version/list/screen.tsx create mode 100644 src/handlers/runtime/version/version.screen.test.tsx diff --git a/src/handlers/runtime/components/RuntimeVersionPicker.tsx b/src/handlers/runtime/components/RuntimeVersionPicker.tsx new file mode 100644 index 000000000..eaedbcf66 --- /dev/null +++ b/src/handlers/runtime/components/RuntimeVersionPicker.tsx @@ -0,0 +1,144 @@ +import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Text, useInput, useWindowSize } from "ink"; +import { DataTable } from "../../../components/ui/data-table"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { Spinner } from "../../../components/ui/spinner"; +import { Layout } from "../../../components/Layout"; +import { usePagedList } from "../../../components/usePagedList"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +interface RuntimeVersionRow extends Record { + version: string; + status: string; + lastUpdatedAt: string; +} + +function toRow(runtime: AgentRuntime): RuntimeVersionRow { + return { + version: runtime.agentRuntimeVersion ?? "", + status: runtime.status ?? "-", + lastUpdatedAt: runtime.lastUpdatedAt?.toISOString() ?? "-", + }; +} + +export interface RuntimeVersionPickerProps extends ScreenProps { + runtimeId: string; + breadcrumb: string[]; + description?: string; + onSelect: (version: string) => void; + onEscape: () => void; +} + +export function RuntimeVersionPicker({ + ctx, + core, + runtimeId, + breadcrumb, + description, + onSelect, + onEscape, +}: RuntimeVersionPickerProps) { + const opts = coreOptsFromCtx(ctx); + const { columns } = useWindowSize(); + const paging = usePagedList(); + const list = useQuery({ + queryKey: ["runtime-versions", opts.region, runtimeId, paging.pageSize, paging.token], + queryFn: () => core.runtime.listRuntimeVersions(runtimeId, paging.token, paging.pageSize, opts), + placeholderData: keepPreviousData, + }); + + useInput( + (input, key) => { + if (key.escape) { + onEscape(); + return; + } + if (input === "r" && list.isError) void list.refetch(); + }, + { isActive: list.isPending || list.isError }, + ); + + const nextToken = list.data?.nextToken; + const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const pageTransition = list.isFetching && !list.isPending; + const showUpdatedAt = columns >= 90; + const showStatus = columns >= 60; + const statusWidth = showStatus ? 20 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const versionWidth = Math.max(8, columns - 2 - statusWidth - updatedAtWidth); + const rows = (list.data?.agentRuntimes ?? []) + .map(toRow) + .sort((left, right) => Number(right.version) - Number(left.version)); + + return ( + + {list.isPending ? ( + + ) : list.isError ? ( + + Error loading versions for Runtime {runtimeId}: {(list.error as Error).message} + + ) : ( + <> + { + if (row.version) onSelect(row.version); + }} + onEscape={onEscape} + onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} + onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} + /> + {(paginated || pageTransition) && ( + + {pageTransition + ? `loading page ${paging.pageIndex + 1}…` + : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} + + )} + + )} + + ); +} diff --git a/src/handlers/runtime/routes.tsx b/src/handlers/runtime/routes.tsx index 53f67d69f..52ffe1aa8 100644 --- a/src/handlers/runtime/routes.tsx +++ b/src/handlers/runtime/routes.tsx @@ -6,6 +6,8 @@ import { RuntimeEndpointScreen } from "./endpoint/screen"; import { RuntimeGetJsonScreen, RuntimeGetScreen } from "./get/screen"; import { RuntimeListScreen } from "./list/screen"; import { RuntimeScreen } from "./screen"; +import { RuntimeGetVersionScreen } from "./version/get/screen"; +import { RuntimeListVersionsScreen } from "./version/list/screen"; import { RuntimeVersionScreen } from "./version/screen"; export function runtimeRoutes(ctx: Context, core: Core): ReactNode { @@ -29,6 +31,22 @@ export function runtimeRoutes(ctx: Context, core: Core): ReactNode { path="agentcore/runtime/version" element={} /> + } + /> + } + /> + } + /> + } + /> } diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 582524a79..1bcdd192c 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -524,14 +524,20 @@ describe("runtime hub", () => { const runtimeId = "runtime/blue one"; const core = new TestCoreClient(); core.runtime.setGetResponse(getRuntimeResponse({ agentRuntimeId: runtimeId })); + if (action === "versions") { + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeVersion: "7" })], + }); + } const r = renderScreen(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`, { core, - additionalRoutes: ( - } - /> - ), + additionalRoutes: + action === "endpoints" ? ( + } + /> + ) : undefined, }); await waitForText(r.lastFrame, "show the full JSON definition"); @@ -540,7 +546,12 @@ describe("runtime hub", () => { } await r.press("return"); - await waitForText(r.lastFrame, `${action} target: ${runtimeId}`); + await waitForText( + r.lastFrame, + action === "versions" + ? `agentcore → runtime → version → list → ${runtimeId}` + : `${action} target: ${runtimeId}`, + ); }, ); diff --git a/src/handlers/runtime/version/get/screen.tsx b/src/handlers/runtime/version/get/screen.tsx new file mode 100644 index 000000000..ffaded6ae --- /dev/null +++ b/src/handlers/runtime/version/get/screen.tsx @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function RuntimeGetVersionScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const { runtimeId, version } = useParams(); + const detail = useQuery({ + queryKey: ["runtime-version", opts.region, runtimeId, version], + queryFn: () => core.runtime.getRuntimeVersion(runtimeId!, version!, opts), + enabled: runtimeId !== undefined && version !== undefined, + }); + + return ( + navigate(`/agentcore/runtime/version/list/${encodeURIComponent(runtimeId!)}`)} + onRetry={() => void detail.refetch()} + /> + ); +} diff --git a/src/handlers/runtime/version/list/screen.tsx b/src/handlers/runtime/version/list/screen.tsx new file mode 100644 index 000000000..16c940fa0 --- /dev/null +++ b/src/handlers/runtime/version/list/screen.tsx @@ -0,0 +1,35 @@ +import { useNavigate, useParams } from "react-router"; +import type { ScreenProps } from "../../../types"; +import { RuntimePicker } from "../../components/RuntimePicker"; +import { RuntimeVersionPicker } from "../../components/RuntimeVersionPicker"; + +export function RuntimeListVersionsScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { runtimeId } = useParams(); + + if (!runtimeId) { + return ( + navigate(`/agentcore/runtime/version/list/${encodeURIComponent(id)}`)} + onEscape={() => navigate("/agentcore/runtime/version")} + /> + ); + } + + return ( + + navigate( + `/agentcore/runtime/version/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(version)}`, + ) + } + onEscape={() => navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`)} + /> + ); +} diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx new file mode 100644 index 000000000..465603aa4 --- /dev/null +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -0,0 +1,396 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + AgentRuntime, + GetAgentRuntimeResponse, + ListAgentRuntimeVersionsResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "3", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function getVersionResponse( + overrides: Partial = {}, +): GetAgentRuntimeResponse { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeName: "checkout", + agentRuntimeId: "runtime-123", + 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" }, + status: "READY", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28_800, + }, + description: "Checkout Runtime version", + metadataConfiguration: { requireMMDSV2: true }, + ...overrides, + }; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((release) => { + resolve = release; + }); + return { promise, resolve }; +} + +async function waitForRuntimeHub( + lastFrame: () => string | undefined, + runtimeId = "runtime-123", +): Promise { + await waitFor(() => { + const frame = lastFrame() ?? ""; + return frame.includes(`agentcore → runtime → get → ${runtimeId}`) && !frame.includes("→ json"); + }); +} + +describe("Runtime version flow", () => { + test("uses the Runtime picker to scope an unscoped version list", async () => { + const runtimeId = "runtime/blue one"; + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeName: "pick-runtime" })], + }); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeVersion: "9" })], + }); + const r = renderScreen("/agentcore/runtime/version/list", { core }); + + await waitForText(r.lastFrame, "pick-runtime"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → version → list → runtime/blue one"); + await waitForText(r.lastFrame, "9"); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeVersions" && call.args[0] === runtimeId, + ), + ).toBe(true); + }); + + test("skips parent selection when the Runtime ID is already scoped", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "8" })], + }); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "8"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeVersions" && call.args[0] === "runtime-123", + ), + ).toBe(true); + }); + + test("calls listRuntimeVersions with exact scope, page size, and options", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime()], + }); + renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitFor(() => + core.runtime.calls.some( + (call) => call.method === "listRuntimeVersions" && call.args[2] === 32, + ), + ); + expect( + core.runtime.calls.find( + (call) => call.method === "listRuntimeVersions" && call.args[2] === 32, + ), + ).toEqual({ + method: "listRuntimeVersions", + args: [ + "runtime-123", + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }); + }); + + test("renders numeric versions newest first with only version status and update time", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [ + runtime({ + agentRuntimeId: "hidden-id", + agentRuntimeName: "hidden-name", + agentRuntimeVersion: "2", + status: "UPDATE_FAILED", + lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + runtime({ + agentRuntimeId: "hidden-id", + agentRuntimeName: "hidden-name", + agentRuntimeVersion: "10", + status: "READY", + lastUpdatedAt: new Date("2026-07-20T10:00:00.000Z"), + }), + ], + }); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "UPDATE_FAILED"); + const frame = r.lastFrame()!; + expect(frame).toContain("version"); + expect(frame).toContain("status"); + expect(frame).toContain("lastUpdatedAt"); + const lines = frame.split("\n"); + const versionTen = lines.findIndex((line) => line.includes("10") && line.includes("READY")); + const versionTwo = lines.findIndex( + (line) => line.includes("2") && line.includes("UPDATE_FAILED"), + ); + expect(versionTen).toBeGreaterThanOrEqual(0); + expect(versionTwo).toBeGreaterThanOrEqual(0); + expect(versionTen).toBeLessThan(versionTwo); + expect(frame).not.toContain("hidden-id"); + expect(frame).not.toContain("hidden-name"); + }); + + test("keeps version visible without wrapping lower-priority columns when narrow", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [ + runtime({ + agentRuntimeVersion: "123", + status: "UPDATE_FAILED", + lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ], + }); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await r.resize(50); + await waitForText(r.lastFrame, "123"); + const frame = r.lastFrame()!; + expect(frame).toContain("version"); + expect(frame).toContain("123"); + expect(frame).not.toContain("status"); + expect(frame).not.toContain("lastUpdatedAt"); + expect(frame).not.toContain("UPDATE_FAILED"); + expect(frame).not.toContain("2026-07-18T02:00:00.000Z"); + expect(Math.max(...frame.split("\n").map((line) => line.length))).toBe(50); + }); + + test("pages forward and backward using token history", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "3" })], + nextToken: "page-2", + }); + core.runtime.setListVersionsResponse( + { + agentRuntimes: [runtime({ agentRuntimeVersion: "2" })], + }, + "page-2", + ); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page 2"); + expect(core.runtime.calls.at(-1)?.args[1]).toBe("page-2"); + + await r.write("h"); + await waitForText(r.lastFrame, "page 1 · more →"); + expect(core.runtime.calls.at(-1)?.args[1]).toBeUndefined(); + }); + + test("retains rows and ignores page keys during a page transition", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "11" })], + nextToken: "page-2", + }); + core.runtime.setListVersionsResponse( + { + agentRuntimes: [runtime({ agentRuntimeVersion: "10" })], + }, + "page-2", + ); + const nextPage = deferred(); + const listVersions = core.runtime.listRuntimeVersions.bind(core.runtime); + core.runtime.listRuntimeVersions = async (...args) => { + const response = await listVersions(...args); + if (args[1] === "page-2") await nextPage.promise; + return response; + }; + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "loading page 2…"); + expect(r.lastFrame()).toContain("11"); + + const callsDuringTransition = core.runtime.calls.length; + await r.write("l"); + await r.write("h"); + expect(core.runtime.calls).toHaveLength(callsDuringTransition); + + nextPage.resolve(); + await waitForText(r.lastFrame, "10"); + }); + + test("names the selected Runtime in empty and error states", async () => { + const empty = renderScreen("/agentcore/runtime/version/list/runtime-123"); + await waitForText(empty.lastFrame, "No versions found for Runtime runtime-123."); + empty.unmount(); + + const core = new TestCoreClient(); + core.runtime.setError(new Error("version access denied")); + const error = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + await waitForText(error.lastFrame, "Error loading versions for Runtime runtime-123"); + expect(error.lastFrame()).toContain("version access denied"); + expect(error.lastFrame()).toContain("[r] retry"); + }); + + test("retries a failed scoped version list", async () => { + const core = new TestCoreClient(); + core.runtime.setError(new Error("version access denied")); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "version access denied"); + core.runtime.setError(undefined); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "12" })], + }); + const callsBeforeRetry = core.runtime.calls.length; + await r.write("r"); + await waitForText(r.lastFrame, "12"); + expect(core.runtime.calls).toHaveLength(callsBeforeRetry + 1); + }); + + test("Esc remains active in loading, error, and empty states", async () => { + const loadingCore = new TestCoreClient(); + const pending = deferred(); + loadingCore.runtime.listRuntimeVersions = async () => pending.promise; + loadingCore.runtime.setGetResponse(getVersionResponse()); + const loading = renderScreen("/agentcore/runtime/version/list/runtime-123", { + core: loadingCore, + }); + await waitForText(loading.lastFrame, "Loading versions for Runtime runtime-123…"); + await loading.press("escape"); + await waitForRuntimeHub(loading.lastFrame); + loading.unmount(); + + const errorCore = new TestCoreClient(); + errorCore.runtime.setError(new Error("failed")); + const error = renderScreen("/agentcore/runtime/version/list/runtime-123", { core: errorCore }); + await waitForText(error.lastFrame, "Error loading versions"); + errorCore.runtime.setError(undefined); + errorCore.runtime.setGetResponse(getVersionResponse()); + await error.press("escape"); + await waitForRuntimeHub(error.lastFrame); + error.unmount(); + + const emptyCore = new TestCoreClient(); + emptyCore.runtime.setGetResponse(getVersionResponse()); + const empty = renderScreen("/agentcore/runtime/version/list/runtime-123", { core: emptyCore }); + await waitForText(empty.lastFrame, "No versions found"); + await empty.press("escape"); + await waitForRuntimeHub(empty.lastFrame); + }); + + test("selecting a version opens complete JSON with exact selectors", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "9" })], + }); + core.runtime.setGetVersionResponse(getVersionResponse({ agentRuntimeVersion: "9" })); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "9"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → version → get → runtime-123 → 9"); + await waitForText(r.lastFrame, '"networkConfiguration"'); + await waitFor(() => + core.runtime.calls.some( + (call) => + call.method === "getRuntimeVersion" && + call.args[0] === "runtime-123" && + call.args[1] === "9", + ), + ); + }); + + test("uses explicit Esc destinations for parent picker, scoped list, and JSON", async () => { + const parentCore = new TestCoreClient(); + parentCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + const parent = renderScreen("/agentcore/runtime/version/list", { + core: parentCore, + }); + await waitForText(parent.lastFrame, "checkout"); + await parent.press("escape"); + await waitForText( + parent.lastFrame, + "agentcore → runtime → version → inspect AgentCore Runtime versions", + ); + parent.unmount(); + + const listCore = new TestCoreClient(); + listCore.runtime.setListVersionsResponse({ + agentRuntimes: [runtime()], + }); + listCore.runtime.setGetResponse(getVersionResponse()); + const list = renderScreen("/agentcore/runtime/version/list/runtime-123", { core: listCore }); + await waitForText(list.lastFrame, "3"); + await list.press("escape"); + await waitForRuntimeHub(list.lastFrame); + list.unmount(); + + const jsonCore = new TestCoreClient(); + jsonCore.runtime.setGetVersionResponse(getVersionResponse()); + jsonCore.runtime.setListVersionsResponse({ + agentRuntimes: [runtime()], + }); + const json = renderScreen("/agentcore/runtime/version/get/runtime-123/3", { core: jsonCore }); + await waitForText(json.lastFrame, '"agentRuntimeVersion"'); + await json.press("escape"); + await waitForText(json.lastFrame, "agentcore → runtime → version → list → runtime-123"); + }); + + test("bare version get redirects to parent selection", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "redirect-parent" })], + }); + const r = renderScreen("/agentcore/runtime/version/get", { core }); + + await waitForText(r.lastFrame, "redirect-parent"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(true); + }); +}); From 79301bbd6525968d769a393127e20d6810d3b685 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 19:20:57 +0000 Subject: [PATCH 08/38] feat: add Runtime endpoint TUI --- src/components/Root.tsx | 9 +- .../components/RuntimeEndpointPicker.tsx | 150 ++++++ .../runtime/endpoint/endpoint.screen.test.tsx | 437 ++++++++++++++++++ src/handlers/runtime/endpoint/get/screen.tsx | 30 ++ src/handlers/runtime/endpoint/list/screen.tsx | 35 ++ src/handlers/runtime/routes.tsx | 18 + src/handlers/runtime/runtime.screen.test.tsx | 41 +- src/testing/renderScreen.tsx | 22 +- 8 files changed, 693 insertions(+), 49 deletions(-) create mode 100644 src/handlers/runtime/components/RuntimeEndpointPicker.tsx create mode 100644 src/handlers/runtime/endpoint/endpoint.screen.test.tsx create mode 100644 src/handlers/runtime/endpoint/get/screen.tsx create mode 100644 src/handlers/runtime/endpoint/list/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 897571637..bc58095c3 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { MemoryRouter, Navigate, Route, Routes } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Core } from "../handlers/types.tsx"; @@ -35,15 +35,11 @@ export interface RootProps { // leaves it unset (a stable one is created per mount); tests inject one — e.g. // with retries disabled — to keep behavior deterministic and fast. queryClient?: QueryClient; - - // Tests can add route targets for flows whose destination screens belong to - // a later implementation slice. - additionalRoutes?: ReactNode; } // Root is the top of the Ink React tree, rendered by the `agentcore` default // handler when the CLI is invoked without a subcommand. -export function Root({ path, ctx, core, queryClient, additionalRoutes }: RootProps) { +export function Root({ path, ctx, core, queryClient }: RootProps) { // Create the QueryClient once per mount; a lazy initializer keeps it stable // across re-renders (a fresh client would drop the cache and refetch). An // injected client (tests) takes precedence. @@ -195,7 +191,6 @@ export function Root({ path, ctx, core, queryClient, additionalRoutes }: RootPro element={} /> {runtimeRoutes(ctx, core)} - {additionalRoutes} } /> diff --git a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx new file mode 100644 index 000000000..f22b0d6d7 --- /dev/null +++ b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx @@ -0,0 +1,150 @@ +import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Text, useInput, useWindowSize } from "ink"; +import { DataTable } from "../../../components/ui/data-table"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { Spinner } from "../../../components/ui/spinner"; +import { Layout } from "../../../components/Layout"; +import { usePagedList } from "../../../components/usePagedList"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +interface RuntimeEndpointRow extends Record { + qualifier: string; + liveVersion: string; + status: string; + lastUpdatedAt: string; +} + +function toRow(endpoint: AgentRuntimeEndpoint): RuntimeEndpointRow { + return { + qualifier: endpoint.name ?? endpoint.id ?? "", + liveVersion: endpoint.liveVersion ?? "-", + status: endpoint.status ?? "-", + lastUpdatedAt: endpoint.lastUpdatedAt?.toISOString() ?? "-", + }; +} + +export interface RuntimeEndpointPickerProps extends ScreenProps { + runtimeId: string; + breadcrumb: string[]; + description?: string; + onSelect: (qualifier: string) => void; + onEscape: () => void; +} + +export function RuntimeEndpointPicker({ + ctx, + core, + runtimeId, + breadcrumb, + description, + onSelect, + onEscape, +}: RuntimeEndpointPickerProps) { + const opts = coreOptsFromCtx(ctx); + const { columns } = useWindowSize(); + const paging = usePagedList(); + const list = useQuery({ + queryKey: ["runtime-endpoints", opts.region, runtimeId, paging.pageSize, paging.token], + queryFn: () => + core.runtime.listRuntimeEndpoints(runtimeId, paging.token, paging.pageSize, opts), + placeholderData: keepPreviousData, + }); + + useInput( + (input, key) => { + if (key.escape) { + onEscape(); + return; + } + if (input === "r" && list.isError) void list.refetch(); + }, + { isActive: list.isPending || list.isError }, + ); + + const nextToken = list.data?.nextToken; + const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const pageTransition = list.isFetching && !list.isPending; + const showUpdatedAt = columns >= 90; + const showStatus = columns >= 60; + const liveWidth = 8; + const statusWidth = showStatus ? 20 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const qualifierWidth = Math.max(12, columns - 2 - liveWidth - statusWidth - updatedAtWidth); + + return ( + + {list.isPending ? ( + + ) : list.isError ? ( + + Error loading endpoints for Runtime {runtimeId}: {(list.error as Error).message} + + ) : ( + <> + { + if (row.qualifier) onSelect(row.qualifier); + }} + onEscape={onEscape} + onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} + onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} + /> + {(paginated || pageTransition) && ( + + {pageTransition + ? `loading page ${paging.pageIndex + 1}…` + : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} + + )} + + )} + + ); +} diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx new file mode 100644 index 000000000..1c2cfaef6 --- /dev/null +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -0,0 +1,437 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + AgentRuntime, + AgentRuntimeEndpoint, + GetAgentRuntimeEndpointResponse, + GetAgentRuntimeResponse, + ListAgentRuntimeEndpointsResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "3", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function endpoint(overrides: Partial = {}): AgentRuntimeEndpoint { + return { + name: "prod", + liveVersion: "3", + targetVersion: "99", + agentRuntimeEndpointArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + status: "READY", + id: "prod", + description: "Production endpoint", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getEndpointResponse( + overrides: Partial = {}, +): GetAgentRuntimeEndpointResponse { + return { + liveVersion: "3", + targetVersion: "4", + agentRuntimeEndpointArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + description: "Production endpoint", + status: "READY", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + name: "prod", + id: "prod", + ...overrides, + }; +} + +function getRuntimeResponse(): GetAgentRuntimeResponse { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeName: "checkout", + agentRuntimeId: "runtime-123", + 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" }, + status: "READY", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28_800, + }, + }; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((release) => { + resolve = release; + }); + return { promise, resolve }; +} + +async function waitForRuntimeHub(lastFrame: () => string | undefined): Promise { + await waitFor(() => { + const frame = lastFrame() ?? ""; + return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); + }); +} + +describe("Runtime endpoint flow", () => { + test("uses the Runtime picker to scope an unscoped endpoint list", async () => { + const runtimeId = "runtime/blue one"; + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeName: "pick-runtime" })], + }); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + const r = renderScreen("/agentcore/runtime/endpoint/list", { core }); + + await waitForText(r.lastFrame, "pick-runtime"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint → list → runtime/blue one"); + await waitForText(r.lastFrame, "prod"); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeEndpoints" && call.args[0] === runtimeId, + ), + ).toBe(true); + }); + + test("skips parent selection when the Runtime ID is already scoped", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "prod"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeEndpoints" && call.args[0] === "runtime-123", + ), + ).toBe(true); + }); + + test("calls listRuntimeEndpoints with exact scope, page size, and options", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitFor(() => + core.runtime.calls.some( + (call) => call.method === "listRuntimeEndpoints" && call.args[2] === 32, + ), + ); + expect( + core.runtime.calls.find( + (call) => call.method === "listRuntimeEndpoints" && call.args[2] === 32, + ), + ).toEqual({ + method: "listRuntimeEndpoints", + args: [ + "runtime-123", + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }); + }); + + test("renders qualifier, live version, status, and update time without invented columns", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [ + endpoint({ + name: "production", + liveVersion: "7", + targetVersion: "target-hidden", + status: "UPDATE_FAILED", + lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ], + }); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "production"); + const frame = r.lastFrame()!; + expect(frame).toContain("qualifier"); + expect(frame).toContain("live"); + expect(frame).toContain("status"); + expect(frame).toContain("lastUpdatedAt"); + expect(frame).toContain("UPDATE_FAILED"); + expect(frame).toContain("2026-07-18T02:00:00.000Z"); + expect(frame).not.toContain("protocol"); + expect(frame).not.toContain("target"); + expect(frame).not.toContain("target-hidden"); + }); + + test("keeps qualifier and live version when narrow", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [ + endpoint({ + name: "visible-endpoint", + liveVersion: "7", + status: "UPDATE_FAILED", + lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ], + }); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await r.resize(50); + await waitForText(r.lastFrame, "visible-endpoint"); + const frame = r.lastFrame()!; + expect(frame).toContain("qualifier"); + expect(frame).toContain("live"); + expect(frame).toContain("visible-endpoint"); + expect(frame).toContain("7"); + expect(frame).not.toContain("status"); + expect(frame).not.toContain("lastUpdatedAt"); + expect(frame).not.toContain("UPDATE_FAILED"); + }); + + test("shows the Runtime-scoped empty state", async () => { + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123"); + + await waitForText(r.lastFrame, "This Runtime has no endpoints."); + expect(r.lastFrame()).toContain("runtime-123"); + }); + + test("pages forward and backward using token history", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: "page-one" })], + nextToken: "page-2", + }); + core.runtime.setListEndpointsResponse( + { + runtimeEndpoints: [endpoint({ name: "page-two" })], + }, + "page-2", + ); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page-two"); + expect(core.runtime.calls.at(-1)?.args[1]).toBe("page-2"); + + await r.write("h"); + await waitForText(r.lastFrame, "page-one"); + expect(core.runtime.calls.at(-1)?.args[1]).toBeUndefined(); + }); + + test("retains rows and ignores page keys during a page transition", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: "stable-page-one" })], + nextToken: "page-2", + }); + core.runtime.setListEndpointsResponse( + { + runtimeEndpoints: [endpoint({ name: "settled-page-two" })], + }, + "page-2", + ); + const nextPage = deferred(); + const listEndpoints = core.runtime.listRuntimeEndpoints.bind(core.runtime); + core.runtime.listRuntimeEndpoints = async (...args) => { + const response = await listEndpoints(...args); + if (args[1] === "page-2") await nextPage.promise; + return response; + }; + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "loading page 2…"); + expect(r.lastFrame()).toContain("stable-page-one"); + + const callsDuringTransition = core.runtime.calls.length; + await r.write("l"); + await r.write("h"); + expect(core.runtime.calls).toHaveLength(callsDuringTransition); + + nextPage.resolve(); + await waitForText(r.lastFrame, "settled-page-two"); + }); + + test("filters only the loaded page without paging on h or l", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: "production" })], + nextToken: "page-2", + }); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("/"); + await r.write("l"); + await r.write("h"); + await waitForText(r.lastFrame, "/ Filter this page: lh"); + expect(r.lastFrame()).toContain("No matches on this page"); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeEndpoints" && call.args[1] === "page-2", + ), + ).toBe(false); + }); + + test("retries errors and keeps Esc active in loading, error, and empty states", async () => { + const retryCore = new TestCoreClient(); + retryCore.runtime.setError(new Error("endpoint access denied")); + const retry = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: retryCore }); + await waitForText(retry.lastFrame, "endpoint access denied"); + expect(retry.lastFrame()).toContain("Runtime runtime-123"); + expect(retry.lastFrame()).toContain("[r] retry"); + retryCore.runtime.setError(undefined); + retryCore.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: "recovered" })], + }); + const callsBeforeRetry = retryCore.runtime.calls.length; + await retry.write("r"); + await waitForText(retry.lastFrame, "recovered"); + expect(retryCore.runtime.calls).toHaveLength(callsBeforeRetry + 1); + retry.unmount(); + + const loadingCore = new TestCoreClient(); + const pending = deferred(); + loadingCore.runtime.listRuntimeEndpoints = async () => pending.promise; + loadingCore.runtime.setGetResponse(getRuntimeResponse()); + const loading = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { + core: loadingCore, + }); + await waitForText(loading.lastFrame, "Loading endpoints for Runtime runtime-123…"); + await loading.press("escape"); + await waitForRuntimeHub(loading.lastFrame); + loading.unmount(); + + const errorCore = new TestCoreClient(); + errorCore.runtime.setError(new Error("failed")); + const error = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: errorCore }); + await waitForText(error.lastFrame, "failed"); + errorCore.runtime.setError(undefined); + errorCore.runtime.setGetResponse(getRuntimeResponse()); + await error.press("escape"); + await waitForRuntimeHub(error.lastFrame); + error.unmount(); + + const emptyCore = new TestCoreClient(); + emptyCore.runtime.setGetResponse(getRuntimeResponse()); + const empty = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: emptyCore }); + await waitForText(empty.lastFrame, "This Runtime has no endpoints."); + await empty.press("escape"); + await waitForRuntimeHub(empty.lastFrame); + }); + + test("selecting an encoded qualifier opens complete endpoint JSON with exact selectors", async () => { + const qualifier = "prod/blue one"; + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: qualifier, id: qualifier })], + }); + core.runtime.setGetEndpointResponse(getEndpointResponse({ name: qualifier, id: qualifier })); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, qualifier); + await r.press("return"); + await waitForText( + r.lastFrame, + `agentcore → runtime → endpoint → get → runtime-123 → ${qualifier}`, + ); + await waitForText(r.lastFrame, '"targetVersion"'); + await waitFor(() => + core.runtime.calls.some( + (call) => + call.method === "getRuntimeEndpoint" && + call.args[0] === "runtime-123" && + call.args[1] === qualifier, + ), + ); + }); + + test("uses explicit Esc destinations for parent picker, scoped list, and JSON", async () => { + const parentCore = new TestCoreClient(); + parentCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + const parent = renderScreen("/agentcore/runtime/endpoint/list", { + core: parentCore, + }); + await waitForText(parent.lastFrame, "checkout"); + await parent.press("escape"); + await waitForText( + parent.lastFrame, + "agentcore → runtime → endpoint → inspect AgentCore Runtime endpoints", + ); + parent.unmount(); + + const listCore = new TestCoreClient(); + listCore.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + listCore.runtime.setGetResponse(getRuntimeResponse()); + const list = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: listCore }); + await waitForText(list.lastFrame, "prod"); + await list.press("escape"); + await waitForRuntimeHub(list.lastFrame); + list.unmount(); + + const jsonCore = new TestCoreClient(); + jsonCore.runtime.setGetEndpointResponse(getEndpointResponse()); + jsonCore.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + const json = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", { + core: jsonCore, + }); + await waitForText(json.lastFrame, '"agentRuntimeEndpointArn"'); + await json.press("escape"); + await waitForText(json.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); + }); + + test("bare endpoint get redirects to parent selection", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "redirect-parent" })], + }); + const r = renderScreen("/agentcore/runtime/endpoint/get", { core }); + + await waitForText(r.lastFrame, "redirect-parent"); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(true); + }); +}); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx new file mode 100644 index 000000000..ba1022a64 --- /dev/null +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const { runtimeId, qualifier } = useParams(); + const detail = useQuery({ + queryKey: ["runtime-endpoint", opts.region, runtimeId, qualifier], + queryFn: () => core.runtime.getRuntimeEndpoint(runtimeId!, qualifier!, opts), + enabled: runtimeId !== undefined && qualifier !== undefined, + }); + + return ( + + navigate(`/agentcore/runtime/endpoint/list/${encodeURIComponent(runtimeId!)}`) + } + onRetry={() => void detail.refetch()} + /> + ); +} diff --git a/src/handlers/runtime/endpoint/list/screen.tsx b/src/handlers/runtime/endpoint/list/screen.tsx new file mode 100644 index 000000000..f585788fd --- /dev/null +++ b/src/handlers/runtime/endpoint/list/screen.tsx @@ -0,0 +1,35 @@ +import { useNavigate, useParams } from "react-router"; +import type { ScreenProps } from "../../../types"; +import { RuntimeEndpointPicker } from "../../components/RuntimeEndpointPicker"; +import { RuntimePicker } from "../../components/RuntimePicker"; + +export function RuntimeListEndpointsScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { runtimeId } = useParams(); + + if (!runtimeId) { + return ( + navigate(`/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`)} + onEscape={() => navigate("/agentcore/runtime/endpoint")} + /> + ); + } + + return ( + + navigate( + `/agentcore/runtime/endpoint/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, + ) + } + onEscape={() => navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`)} + /> + ); +} diff --git a/src/handlers/runtime/routes.tsx b/src/handlers/runtime/routes.tsx index 52ffe1aa8..622acb905 100644 --- a/src/handlers/runtime/routes.tsx +++ b/src/handlers/runtime/routes.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from "react"; import { Navigate, Route } from "react-router"; import type { Context } from "../../router"; import type { Core } from "../types"; +import { RuntimeGetEndpointScreen } from "./endpoint/get/screen"; +import { RuntimeListEndpointsScreen } from "./endpoint/list/screen"; import { RuntimeEndpointScreen } from "./endpoint/screen"; import { RuntimeGetJsonScreen, RuntimeGetScreen } from "./get/screen"; import { RuntimeListScreen } from "./list/screen"; @@ -51,6 +53,22 @@ export function runtimeRoutes(ctx: Context, core: Core): ReactNode { path="agentcore/runtime/endpoint" element={} /> + } + /> + } + /> + } + /> + } + /> ); } diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 1bcdd192c..a48fcb4c8 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -5,8 +5,6 @@ import type { ListAgentRuntimesResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { QueryClient } from "@tanstack/react-query"; -import { Text } from "ink"; -import { Route, useParams } from "react-router"; import { cleanupScreens, renderScreen, @@ -94,15 +92,6 @@ async function waitForRuntimeMenu(lastFrame: () => string | undefined): Promise< ); } -function RuntimeActionTarget({ action }: { action: string }) { - const { runtimeId } = useParams(); - return ( - - {action} target: {runtimeId} - - ); -} - describe("runtime test client", () => { test("configures list responses and records calls", async () => { const core = new TestCoreClient(); @@ -516,11 +505,11 @@ describe("runtime hub", () => { }); test.each([ - ["versions", 1, "version"], - ["endpoints", 2, "endpoint"], + ["versions", 1], + ["endpoints", 2], ] as const)( "selecting %s opens its encoded Runtime-scoped route", - async (action, downPresses, routeGroup) => { + async (action, downPresses) => { const runtimeId = "runtime/blue one"; const core = new TestCoreClient(); core.runtime.setGetResponse(getRuntimeResponse({ agentRuntimeId: runtimeId })); @@ -528,16 +517,24 @@ describe("runtime hub", () => { core.runtime.setListVersionsResponse({ agentRuntimes: [runtime({ agentRuntimeId: runtimeId, agentRuntimeVersion: "7" })], }); + } else { + 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/get/${encodeURIComponent(runtimeId)}`, { core, - additionalRoutes: - action === "endpoints" ? ( - } - /> - ) : undefined, }); await waitForText(r.lastFrame, "show the full JSON definition"); @@ -550,7 +547,7 @@ describe("runtime hub", () => { r.lastFrame, action === "versions" ? `agentcore → runtime → version → list → ${runtimeId}` - : `${action} target: ${runtimeId}`, + : `agentcore → runtime → endpoint → list → ${runtimeId}`, ); }, ); diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index a7aee5f75..7e4987213 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -59,8 +59,6 @@ export interface RenderScreenOptions { // queryClient overrides the deterministic default when a test needs to // exercise cache behavior. queryClient?: QueryClient; - // additionalRoutes adds test-only destinations before Root's wildcard. - additionalRoutes?: React.ReactNode; } export interface RenderScreenResult { @@ -121,15 +119,7 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R const ctx = options.ctx ?? baseContext(core); const queryClient = options.queryClient ?? testQueryClient(); - const instance = render( - , - ); + const instance = render(); setWindowSize(instance.stdout, 100, 40); @@ -158,15 +148,7 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R }); }, rerender: () => - instance.rerender( - , - ), + instance.rerender(), unmount: instance.unmount, }; } From 801f608a6f37dbadfa06507cbf1e5657960a86ac Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 19:26:43 +0000 Subject: [PATCH 09/38] feat: add explicit Runtime TUI entry --- src/handlers/runtime/index.tsx | 3 + src/handlers/runtime/interactive.test.tsx | 76 ++++++++++++++++++++ src/handlers/runtime/interactive.tsx | 80 +++++++++++++++++++++ src/handlers/runtime/runtime.test.tsx | 88 ++++++++++++++++++++++- src/testing/index.tsx | 2 +- src/testing/testIO.tsx | 10 ++- src/tui/index.tsx | 4 ++ src/tui/tui.test.tsx | 31 ++++++++ 8 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 src/handlers/runtime/interactive.test.tsx create mode 100644 src/handlers/runtime/interactive.tsx diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 8d3c99e32..200d403bc 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -3,11 +3,14 @@ import { renderTui } from "../../tui"; import type { AppIO, Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; +import { RuntimeInteractiveKey, withRuntimeInteractive } from "./interactive"; import { createListRuntimesHandler } from "./list"; import { createRuntimeVersionHandler } from "./version"; export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") + .groupFlags(RuntimeInteractiveKey) + .use(withRuntimeInteractive(core, io)) .default(renderTui(core, io)) .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) diff --git a/src/handlers/runtime/interactive.test.tsx b/src/handlers/runtime/interactive.test.tsx new file mode 100644 index 000000000..c8b0af710 --- /dev/null +++ b/src/handlers/runtime/interactive.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { runtimeTuiPath } from "./interactive"; + +describe("runtimeTuiPath", () => { + test.each([ + ["/agentcore/runtime", {}, "/agentcore/runtime"], + ["/agentcore/runtime/version", {}, "/agentcore/runtime/version"], + ["/agentcore/runtime/endpoint", {}, "/agentcore/runtime/endpoint"], + ["/agentcore/runtime/get", {}, "/agentcore/runtime/list"], + [ + "/agentcore/runtime/get", + { id: "runtime/id with spaces" }, + "/agentcore/runtime/get/runtime%2Fid%20with%20spaces", + ], + ["/agentcore/runtime/list", {}, "/agentcore/runtime/list"], + ["/agentcore/runtime/version/list", {}, "/agentcore/runtime/version/list"], + [ + "/agentcore/runtime/version/list", + { id: "runtime/id with spaces" }, + "/agentcore/runtime/version/list/runtime%2Fid%20with%20spaces", + ], + ["/agentcore/runtime/version/get", {}, "/agentcore/runtime/version/list"], + [ + "/agentcore/runtime/version/get", + { id: "runtime/id with spaces" }, + "/agentcore/runtime/version/list/runtime%2Fid%20with%20spaces", + ], + [ + "/agentcore/runtime/version/get", + { id: "runtime/id with spaces", version: "v/1 + beta" }, + "/agentcore/runtime/version/get/runtime%2Fid%20with%20spaces/v%2F1%20%2B%20beta", + ], + ["/agentcore/runtime/endpoint/list", {}, "/agentcore/runtime/endpoint/list"], + [ + "/agentcore/runtime/endpoint/list", + { id: "runtime/id with spaces" }, + "/agentcore/runtime/endpoint/list/runtime%2Fid%20with%20spaces", + ], + ["/agentcore/runtime/endpoint/get", {}, "/agentcore/runtime/endpoint/list"], + [ + "/agentcore/runtime/endpoint/get", + { id: "runtime/id with spaces" }, + "/agentcore/runtime/endpoint/list/runtime%2Fid%20with%20spaces", + ], + [ + "/agentcore/runtime/endpoint/get", + { id: "runtime/id with spaces", qualifier: "prod/us east" }, + "/agentcore/runtime/endpoint/get/runtime%2Fid%20with%20spaces/prod%2Fus%20east", + ], + ] as const)("maps %s", (commandPath, flags, expected) => { + expect(runtimeTuiPath(commandPath, flags)).toBe(expected); + }); + + test("ignores empty and non-string selectors", () => { + expect(runtimeTuiPath("/agentcore/runtime/get", { id: "" })).toBe("/agentcore/runtime/list"); + expect(runtimeTuiPath("/agentcore/runtime/get", { id: 42 })).toBe("/agentcore/runtime/list"); + }); + + test("rejects a version without a Runtime ID", () => { + expect(() => runtimeTuiPath("/agentcore/runtime/version/get", { version: "1" })).toThrow( + /version.*id/i, + ); + }); + + test("rejects a qualifier without a Runtime ID", () => { + expect(() => runtimeTuiPath("/agentcore/runtime/endpoint/get", { qualifier: "prod" })).toThrow( + /qualifier.*id/i, + ); + }); + + test("rejects unknown command paths", () => { + expect(() => runtimeTuiPath("/agentcore/runtime/unknown", {})).toThrow( + /unknown Runtime command path/, + ); + }); +}); diff --git a/src/handlers/runtime/interactive.tsx b/src/handlers/runtime/interactive.tsx new file mode 100644 index 000000000..ad9b9783f --- /dev/null +++ b/src/handlers/runtime/interactive.tsx @@ -0,0 +1,80 @@ +import z from "zod"; +import { renderTuiAt } from "../../tui"; +import { globalFlag, PathKey, type Middleware } from "../../router"; +import { JsonKey } from "../keys"; +import type { AppIO, Core } from "../types"; + +export const RuntimeInteractiveKey = globalFlag( + "interactive", + "open the Runtime TUI", + z.boolean().default(false), +); + +function selector(flags: Record, name: string): string | undefined { + const value = flags[name]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function runtimeTuiPath(commandPath: string, flags: Record): string { + const id = selector(flags, "id"); + const version = selector(flags, "version"); + const qualifier = selector(flags, "qualifier"); + const encodedId = id === undefined ? undefined : encodeURIComponent(id); + + switch (commandPath) { + case "/agentcore/runtime": + case "/agentcore/runtime/version": + case "/agentcore/runtime/endpoint": + case "/agentcore/runtime/list": + return commandPath; + case "/agentcore/runtime/get": + return encodedId === undefined + ? "/agentcore/runtime/list" + : `/agentcore/runtime/get/${encodedId}`; + case "/agentcore/runtime/version/list": + return encodedId === undefined + ? "/agentcore/runtime/version/list" + : `/agentcore/runtime/version/list/${encodedId}`; + case "/agentcore/runtime/version/get": + if (version !== undefined && encodedId === undefined) { + throw new TypeError("Runtime version requires a Runtime id"); + } + if (encodedId === undefined) return "/agentcore/runtime/version/list"; + if (version === undefined) return `/agentcore/runtime/version/list/${encodedId}`; + return `/agentcore/runtime/version/get/${encodedId}/${encodeURIComponent(version)}`; + case "/agentcore/runtime/endpoint/list": + return encodedId === undefined + ? "/agentcore/runtime/endpoint/list" + : `/agentcore/runtime/endpoint/list/${encodedId}`; + case "/agentcore/runtime/endpoint/get": + if (qualifier !== undefined && encodedId === undefined) { + throw new TypeError("Runtime qualifier requires a Runtime id"); + } + if (encodedId === undefined) return "/agentcore/runtime/endpoint/list"; + if (qualifier === undefined) return `/agentcore/runtime/endpoint/list/${encodedId}`; + return `/agentcore/runtime/endpoint/get/${encodedId}/${encodeURIComponent(qualifier)}`; + default: + throw new TypeError(`unknown Runtime command path: ${commandPath}`); + } +} + +export function withRuntimeInteractive(core: Core, io: AppIO): Middleware { + return (handler) => ({ + name: () => handler.name(), + description: () => handler.description(), + flags: () => handler.flags(), + arguments: () => handler.arguments(), + children: () => handler.children(), + handle: async (ctx, flags, args) => { + if (!ctx.require(RuntimeInteractiveKey)) { + await handler.handle(ctx, flags, args); + return; + } + if (ctx.require(JsonKey)) { + throw new TypeError("--interactive cannot be combined with --json"); + } + + await renderTuiAt(runtimeTuiPath(ctx.require(PathKey), flags), ctx, core, io); + }, + }); +} diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 75691be1b..ae59f05bc 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import { CoreClient } from "../../core"; -import { createSilentLogger, fixtureFactories, matchGolden, testIO } from "../../testing"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestCoreClient, + testIO, +} from "../../testing"; import { createRootHandler } from "../index"; const REGION = "us-west-2"; @@ -35,6 +41,21 @@ async function run(args: string[]): Promise { return io.stdout(); } +function testRuntimeCommand(isTTY = false) { + const core = new TestCoreClient(); + const io = testIO({ isTTY }); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + }); + + return { + core, + io, + route: (args: string[]) => root.route(["node", "agentcore", ...args, "--region", REGION]), + }; +} + describe("runtime command hierarchy", () => { test("registers the Runtime read-only command hierarchy", () => { const root = createRootHandler(createFixtureCore(), { @@ -76,6 +97,71 @@ describe("runtime command hierarchy", () => { ); }); +describe("runtime interactive mode", () => { + test("rejects --interactive on non-TTY streams before calling Runtime Core", async () => { + const { core, route } = testRuntimeCommand(); + + await expect(route(["runtime", "list", "--interactive"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + expect(core.runtime.calls).toEqual([]); + }); + + test("rejects --interactive with --json before rendering or calling Runtime Core", async () => { + const { core, route } = testRuntimeCommand(true); + + await expect(route(["runtime", "list", "--interactive", "--json"])).rejects.toThrow( + /interactive.*json/i, + ); + expect(core.runtime.calls).toEqual([]); + }); + + test.each([ + ["runtime", ["runtime", "--interactive"]], + ["runtime version", ["runtime", "version", "--interactive"]], + ["runtime endpoint", ["runtime", "endpoint", "--interactive"]], + ] as const)("keeps explicit interactive mode on the %s menu", async (_label, args) => { + const { core, route } = testRuntimeCommand(); + + await expect(route([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + expect(core.runtime.calls).toEqual([]); + }); + + test.each([ + ["version", ["runtime", "version", "get", "--version", "1", "--interactive"], /version.*id/i], + [ + "qualifier", + ["runtime", "endpoint", "get", "--qualifier", "prod", "--interactive"], + /qualifier.*id/i, + ], + ] as const)( + "rejects a %s without an ID before rendering or calling Runtime Core", + async (_label, args, message) => { + const { core, route } = testRuntimeCommand(); + + await expect(route([...args])).rejects.toThrow(message); + expect(core.runtime.calls).toEqual([]); + }, + ); + + test("keeps runtime list without own flags headless", async () => { + const { core, io, route } = testRuntimeCommand(); + core.runtime.setListResponse({ agentRuntimes: [], nextToken: "page-2" }); + + await route(["runtime", "list"]); + + expect(JSON.parse(io.stdout())).toEqual({ agentRuntimes: [], nextToken: "page-2" }); + expect(core.runtime.calls).toEqual([ + { + method: "listRuntimes", + args: [undefined, undefined, { region: REGION, endpointUrl: undefined }], + }, + ]); + }); +}); + describe("runtime read-only commands", () => { test("gets a Runtime whose ID exceeds the 48-character Runtime name limit", async () => { expect(FIXTURE_RUNTIME_ID.length).toBeGreaterThan(48); diff --git a/src/testing/index.tsx b/src/testing/index.tsx index e3e711ee6..ad4752ee4 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -1,6 +1,6 @@ export { parse, stringify } from "./serialization"; export { fixtureFactories, isRecording, matchGolden } from "./fixtures"; -export { testIO, type TestIO } from "./testIO"; +export { testIO, type TestIO, type TestIOOptions } from "./testIO"; export { tick, waitFor } from "./timing"; export { TestCoreClient, diff --git a/src/testing/testIO.tsx b/src/testing/testIO.tsx index 8c6da4c88..a07ed36d1 100644 --- a/src/testing/testIO.tsx +++ b/src/testing/testIO.tsx @@ -14,6 +14,10 @@ export interface TestIO { stderr(): string; } +export interface TestIOOptions { + isTTY?: boolean; +} + // collect wraps a PassThrough, accumulating everything written to it as a string. function collect(): { stream: NodeJS.WriteStream; read: () => string } { const stream = new PassThrough(); @@ -29,11 +33,15 @@ function collect(): { stream: NodeJS.WriteStream; read: () => string } { // testIO builds a fresh in-memory TestIO for a single test. stdin is an idle // PassThrough (no input) so screens that read input simply see nothing. -export function testIO(): TestIO { +export function testIO({ isTTY = false }: TestIOOptions = {}): TestIO { const out = collect(); const err = collect(); const stdin = new PassThrough() as unknown as NodeJS.ReadStream; + for (const stream of [stdin, out.stream, err.stream]) { + Object.defineProperty(stream, "isTTY", { configurable: true, value: isTTY }); + } + return { io: { stdin, stdout: out.stream, stderr: err.stream }, stdout: out.read, diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 9c6f91ec2..4978d1f9f 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -41,6 +41,10 @@ export async function renderTuiAt( core: Core, io: AppIO, ): Promise { + if (!io.stdin.isTTY || !io.stdout.isTTY) { + throw new TypeError("interactive mode requires a TTY on stdin and stdout"); + } + // alternateScreen switches the terminal to its alternate buffer so the TUI // takes over the screen and the prior scrollback is restored on exit (like Vim). const { waitUntilExit } = render(, { diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index c018fb748..058c620be 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -40,3 +40,34 @@ describe("--json short-circuits the TUI", () => { expect(out).toContain("get"); }); }); + +describe("TUI stream boundary", () => { + test("test IO defaults every stream to non-TTY and supports explicit TTY streams", () => { + const nonTty = testIO(); + const tty = testIO({ isTTY: true }); + + for (const stream of [nonTty.io.stdin, nonTty.io.stdout, nonTty.io.stderr]) { + expect(stream.isTTY).toBe(false); + expect(Object.getOwnPropertyDescriptor(stream, "isTTY")?.configurable).toBe(true); + } + for (const stream of [tty.io.stdin, tty.io.stdout, tty.io.stderr]) { + expect(stream.isTTY).toBe(true); + expect(Object.getOwnPropertyDescriptor(stream, "isTTY")?.configurable).toBe(true); + } + }); + + test.each([ + ["bare root", []], + ["Harness", ["harness"]], + ] as const)("rejects non-TTY streams at the shared boundary for %s", async (_label, args) => { + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger: createSilentLogger(), + }); + + await expect(root.route(["node", "agentcore", ...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }); +}); From 739efc8fa8771439d495fe068a1da53e5ed8043f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 19:30:14 +0000 Subject: [PATCH 10/38] refactor: keep test IO options local --- src/testing/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testing/index.tsx b/src/testing/index.tsx index ad4752ee4..e3e711ee6 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -1,6 +1,6 @@ export { parse, stringify } from "./serialization"; export { fixtureFactories, isRecording, matchGolden } from "./fixtures"; -export { testIO, type TestIO, type TestIOOptions } from "./testIO"; +export { testIO, type TestIO } from "./testIO"; export { tick, waitFor } from "./timing"; export { TestCoreClient, From 6d34438a468f86dafc210483bea219743e6c20cf Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 19:50:41 +0000 Subject: [PATCH 11/38] test: complete Runtime TUI coverage --- README.md | 22 +- src/components/RouterScreen.test.tsx | 216 +++++++++++++++++- .../runtime/endpoint/endpoint.screen.test.tsx | 52 +++++ src/handlers/runtime/runtime.screen.test.tsx | 49 ++++ .../runtime/version/version.screen.test.tsx | 52 +++++ 5 files changed, 385 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fd94c2170..55668ef75 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ It gives you two ways to work, from the same binary: - **A scriptable CLI** — every operation is a flag-driven subcommand that emits JSON (`--json`), so it can be used by codeing agents and can drop cleanly into scripts, CI, and automation. -- **An interactive TUI** — run a command with no arguments and it opens a - full-screen terminal app for browsing resources, filling in wizards, and - chatting with a live agent. +- **An interactive TUI** — bare branch commands open menus, Harness commands + retain their interactive flows, and Runtime leaves opt in with + `--interactive`. ```bash agentcore # launch the interactive TUI @@ -27,8 +27,9 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Every leaf command runs headless with flags, or opens the matching TUI screen -when invoked bare. +Commands run headless by default. Harness leaves retain their existing bare +interactive behavior; Runtime leaves remain headless unless `--interactive` is +set. ``` agentcore # interactive TUI @@ -117,6 +118,17 @@ agentcore identity api-key-credential-provider update --name my-provider --api-k agentcore identity api-key-credential-provider delete --name my-provider ``` +Runtime interactive entry points require a TTY on stdin and stdout. +`--interactive --json` is invalid. + +```bash +agentcore runtime +agentcore runtime list --interactive +agentcore runtime get --interactive --id +agentcore runtime version list --interactive --id +agentcore runtime endpoint list --interactive --id +``` + --- # Architecture & patterns diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 2de9f30f0..b9b2fd8ea 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -1,8 +1,134 @@ import { test, expect, describe, afterEach } from "bun:test"; -import { renderScreen, waitForText, cleanupScreens, tick } from "../testing"; +import type { + AgentRuntime, + AgentRuntimeEndpoint, + GetAgentRuntimeEndpointResponse, + GetAgentRuntimeResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { createRootHandler } from "../handlers"; +import { DebugKey, EndpointKey, JsonKey, RegionKey } from "../handlers/keys"; +import { CommandKey, compile, type Context, ValueContext } from "../router"; +import { + cleanupScreens, + createSilentLogger, + renderScreen, + TestCoreClient, + testIO, + tick, + waitForText, +} from "../testing"; +import { JsonRendererKey } from "../tui"; afterEach(cleanupScreens); +const runtimeEndpointUrl = "https://runtime.test"; + +function runtime(overrides: Partial = {}): AgentRuntime { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "7", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + ...overrides, + }; +} + +function getRuntimeResponse( + overrides: Partial = {}, +): GetAgentRuntimeResponse { + return { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeName: "checkout", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "7", + 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" }, + status: "READY", + lifecycleConfiguration: { + idleRuntimeSessionTimeout: 900, + maxLifetime: 28_800, + }, + ...overrides, + }; +} + +function endpoint(overrides: Partial = {}): AgentRuntimeEndpoint { + return { + name: "prod", + liveVersion: "7", + agentRuntimeEndpointArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + status: "READY", + id: "prod", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getEndpointResponse( + overrides: Partial = {}, +): GetAgentRuntimeEndpointResponse { + return { + liveVersion: "7", + targetVersion: "8", + agentRuntimeEndpointArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + status: "READY", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + name: "prod", + id: "prod", + ...overrides, + }; +} + +function runtimeCore(): TestCoreClient { + const core = new TestCoreClient(); + core.runtime.setListResponse({ agentRuntimes: [runtime()] }); + core.runtime.setGetResponse(getRuntimeResponse()); + core.runtime.setListVersionsResponse({ agentRuntimes: [runtime()] }); + core.runtime.setGetVersionResponse(getRuntimeResponse()); + core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }); + core.runtime.setGetEndpointResponse(getEndpointResponse()); + return core; +} + +function runtimeContext(core: TestCoreClient): Context { + const rootCommand = compile( + createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }), + ValueContext.EmptyContext(), + ); + + return ValueContext.EmptyContext() + .withValue(CommandKey, rootCommand) + .withValue(RegionKey, "us-east-1") + .withValue(EndpointKey, runtimeEndpointUrl) + .withValue(JsonKey, false) + .withValue(DebugKey, false) + .withValue(JsonRendererKey, { renderJson: () => {} }); +} + +function expectEndpointPropagation(core: TestCoreClient, methods: readonly string[]): void { + for (const method of methods) { + const calls = core.runtime.calls.filter((call) => call.method === method); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.args.at(-1)).toEqual({ + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }); + } + } +} + // RouterScreen is the interactive command menu. These tests mount it through the // real Root at a command path and drive it with key presses, asserting on the // rendered frames — behavior a user would see, not internal state. @@ -148,4 +274,92 @@ describe("navigation", () => { await tick(20); r.unmount(); }); + + test.each([ + [ + "versions", + 1, + "agentcore → runtime → version → list → runtime-123", + "2026-07-20T12:34:56.000Z", + "agentcore → runtime → version → get → runtime-123 → 7", + '"agentRuntimeVersion"', + "listRuntimeVersions", + "getRuntimeVersion", + ], + [ + "endpoints", + 2, + "agentcore → runtime → endpoint → list → runtime-123", + "prod", + "agentcore → runtime → endpoint → get → runtime-123 → prod", + '"agentRuntimeEndpointArn"', + "listRuntimeEndpoints", + "getRuntimeEndpoint", + ], + ] as const)( + "navigates from Root through Runtime %s and escapes each explicit boundary", + async ( + _destination, + hubDownPresses, + scopedListBreadcrumb, + scopedListValue, + jsonBreadcrumb, + jsonField, + listMethod, + getMethod, + ) => { + const core = runtimeCore(); + const r = renderScreen("/agentcore", { + core, + ctx: runtimeContext(core), + }); + + await waitForText(r.lastFrame, "❯ harness"); + await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); + + await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → list"); + await waitForText(r.lastFrame, "checkout"); + + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); + await waitForText(r.lastFrame, "show the full JSON definition"); + + for (let index = 0; index < hubDownPresses; index += 1) { + await r.press("down"); + } + await r.press("return"); + await waitForText(r.lastFrame, scopedListBreadcrumb); + await waitForText(r.lastFrame, scopedListValue); + + await r.press("return"); + await waitForText(r.lastFrame, jsonBreadcrumb); + await waitForText(r.lastFrame, jsonField); + + await r.press("escape"); + await waitForText(r.lastFrame, scopedListBreadcrumb); + await waitForText(r.lastFrame, scopedListValue); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); + await waitForText(r.lastFrame, "show the full JSON definition"); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → list"); + await waitForText(r.lastFrame, "checkout"); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); + await r.press("escape"); + await waitForText(r.lastFrame, "the platform for production AI agents"); + expect(r.lastFrame()).toContain("❯ harness"); + + const callsAtRoot = core.runtime.calls.length; + await r.press("escape"); + expect(r.lastFrame()).toContain("❯ harness"); + expect(core.runtime.calls).toHaveLength(callsAtRoot); + + expectEndpointPropagation(core, ["listRuntimes", "getRuntime", listMethod, getMethod]); + }, + ); }); diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 1c2cfaef6..f5e775706 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -255,6 +255,58 @@ describe("Runtime endpoint flow", () => { expect(core.runtime.calls.at(-1)?.args[1]).toBeUndefined(); }); + test("resizing resets to page one with the terminal-derived page size", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint({ name: "page-one" })], + nextToken: "page-2", + }); + core.runtime.setListEndpointsResponse( + { + runtimeEndpoints: [endpoint({ name: "page-two" })], + }, + "page-2", + ); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page-two"); + const callsBeforeResize = core.runtime.calls.filter( + (call) => call.method === "listRuntimeEndpoints", + ).length; + + await r.resize(100, 20); + await waitFor(() => { + const calls = core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints"); + return ( + calls.length > callsBeforeResize && + calls.at(-1)?.args[1] === undefined && + calls.at(-1)?.args[2] === 12 + ); + }); + await waitForText(r.lastFrame, "page-one"); + expect(r.lastFrame()).toContain("page 1 · more →"); + }); + + test("Ctrl+C exits without another Runtime endpoint list request", async () => { + const core = new TestCoreClient(); + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + }); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "prod"); + const callsBeforeExit = core.runtime.calls.filter( + (call) => call.method === "listRuntimeEndpoints", + ).length; + await r.write(String.fromCharCode(3)); + + expect( + core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints"), + ).toHaveLength(callsBeforeExit); + }); + test("retains rows and ignores page keys during a page transition", async () => { const core = new TestCoreClient(); core.runtime.setListEndpointsResponse({ diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index a48fcb4c8..5d43f1513 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -276,6 +276,55 @@ describe("runtime picker", () => { expect(core.runtime.calls.at(-1)?.args[0]).toBeUndefined(); }); + test("resizing resets to page one with the terminal-derived page size", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime({ agentRuntimeName: "page-one" })], + nextToken: "page-2", + }); + core.runtime.setListResponse( + { + agentRuntimes: [runtime({ agentRuntimeName: "page-two" })], + }, + "page-2", + ); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page-two"); + const callsBeforeResize = core.runtime.calls.filter( + (call) => call.method === "listRuntimes", + ).length; + + await r.resize(100, 20); + await waitFor(() => { + const calls = core.runtime.calls.filter((call) => call.method === "listRuntimes"); + return ( + calls.length > callsBeforeResize && + calls.at(-1)?.args[0] === undefined && + calls.at(-1)?.args[1] === 12 + ); + }); + await waitForText(r.lastFrame, "page-one"); + expect(r.lastFrame()).toContain("page 1 · more →"); + }); + + test("Ctrl+C exits without another Runtime list request", async () => { + const core = coreWithRuntimes([runtime()]); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "checkout"); + const callsBeforeExit = core.runtime.calls.filter( + (call) => call.method === "listRuntimes", + ).length; + await r.write(String.fromCharCode(3)); + + expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( + callsBeforeExit, + ); + }); + test("retains rows and ignores page keys while either page direction is loading", async () => { const core = new TestCoreClient(); core.runtime.setListResponse({ diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 465603aa4..6cef54517 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -228,6 +228,58 @@ describe("Runtime version flow", () => { expect(core.runtime.calls.at(-1)?.args[1]).toBeUndefined(); }); + test("resizing resets to page one with the terminal-derived page size", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "3" })], + nextToken: "page-2", + }); + core.runtime.setListVersionsResponse( + { + agentRuntimes: [runtime({ agentRuntimeVersion: "2" })], + }, + "page-2", + ); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page 2"); + const callsBeforeResize = core.runtime.calls.filter( + (call) => call.method === "listRuntimeVersions", + ).length; + + await r.resize(100, 20); + await waitFor(() => { + const calls = core.runtime.calls.filter((call) => call.method === "listRuntimeVersions"); + return ( + calls.length > callsBeforeResize && + calls.at(-1)?.args[1] === undefined && + calls.at(-1)?.args[2] === 12 + ); + }); + await waitForText(r.lastFrame, "page 1 · more →"); + expect(r.lastFrame()).toContain("3"); + }); + + test("Ctrl+C exits without another Runtime version list request", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime()], + }); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "2026-07-20T12:34:56.000Z"); + const callsBeforeExit = core.runtime.calls.filter( + (call) => call.method === "listRuntimeVersions", + ).length; + await r.write(String.fromCharCode(3)); + + expect(core.runtime.calls.filter((call) => call.method === "listRuntimeVersions")).toHaveLength( + callsBeforeExit, + ); + }); + test("retains rows and ignores page keys during a page transition", async () => { const core = new TestCoreClient(); core.runtime.setListVersionsResponse({ From 76cb02001c6a53216c262686ccc683a778e5fe71 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 19:57:57 +0000 Subject: [PATCH 12/38] test: verify Runtime TUI exit and filtering --- src/components/RouterScreen.test.tsx | 85 ++++++++++++++++++- .../runtime/endpoint/endpoint.screen.test.tsx | 18 ---- src/handlers/runtime/runtime.screen.test.tsx | 15 ---- .../runtime/version/version.screen.test.tsx | 39 +++++---- 4 files changed, 105 insertions(+), 52 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index b9b2fd8ea..957dec4a1 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -15,9 +15,10 @@ import { TestCoreClient, testIO, tick, + waitFor, waitForText, } from "../testing"; -import { JsonRendererKey } from "../tui"; +import { JsonRendererKey, renderTuiAt } from "../tui"; afterEach(cleanupScreens); @@ -129,6 +130,29 @@ function expectEndpointPropagation(core: TestCoreClient, methods: readonly strin } } +interface TtyInput extends NodeJS.ReadStream { + write(chunk: string): boolean; +} + +function ttyTestIO(): { streams: ReturnType; stdin: TtyInput } { + const streams = testIO({ isTTY: true }); + const stdin = streams.io.stdin as TtyInput; + stdin.setRawMode = function () { + return this; + }; + stdin.ref = function () { + return this; + }; + stdin.unref = function () { + return this; + }; + Object.defineProperties(streams.io.stdout, { + columns: { configurable: true, value: 100 }, + rows: { configurable: true, value: 40 }, + }); + return { streams, stdin }; +} + // RouterScreen is the interactive command menu. These tests mount it through the // real Root at a command path and drive it with key presses, asserting on the // rendered frames — behavior a user would see, not internal state. @@ -363,3 +387,62 @@ describe("navigation", () => { }, ); }); + +describe("Runtime TUI exit", () => { + test.each([ + [ + "Runtime list", + "/agentcore/runtime/list", + "listRuntimes", + 0, + (core: TestCoreClient) => + core.runtime.setListResponse({ + agentRuntimes: [runtime()], + nextToken: "page-2", + }), + ], + [ + "version list", + "/agentcore/runtime/version/list/runtime-123", + "listRuntimeVersions", + 1, + (core: TestCoreClient) => + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime()], + nextToken: "page-2", + }), + ], + [ + "endpoint list", + "/agentcore/runtime/endpoint/list/runtime-123", + "listRuntimeEndpoints", + 1, + (core: TestCoreClient) => + core.runtime.setListEndpointsResponse({ + runtimeEndpoints: [endpoint()], + nextToken: "page-2", + }), + ], + ] as const)( + "Ctrl+C exits the production %s and ignores paging input after exit", + async (_label, path, method, tokenIndex, configure) => { + const core = new TestCoreClient(); + configure(core); + const { streams, stdin } = ttyTestIO(); + const renderPromise = renderTuiAt(path, runtimeContext(core), core, streams.io); + const methodCalls = () => core.runtime.calls.filter((call) => call.method === method); + + await waitFor(() => methodCalls().length > 0 && streams.stdout().includes("page 1 · more →")); + await tick(); + const callsBeforeExit = methodCalls().length; + + stdin.write(String.fromCharCode(3)); + await expect(renderPromise).resolves.toBeUndefined(); + + stdin.write("l"); + await tick(50); + expect(methodCalls()).toHaveLength(callsBeforeExit); + expect(methodCalls().some((call) => call.args[tokenIndex] === "page-2")).toBe(false); + }, + ); +}); diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index f5e775706..5e1edfef3 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -289,24 +289,6 @@ describe("Runtime endpoint flow", () => { expect(r.lastFrame()).toContain("page 1 · more →"); }); - test("Ctrl+C exits without another Runtime endpoint list request", async () => { - const core = new TestCoreClient(); - core.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint()], - }); - const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "prod"); - const callsBeforeExit = core.runtime.calls.filter( - (call) => call.method === "listRuntimeEndpoints", - ).length; - await r.write(String.fromCharCode(3)); - - expect( - core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints"), - ).toHaveLength(callsBeforeExit); - }); - test("retains rows and ignores page keys during a page transition", async () => { const core = new TestCoreClient(); core.runtime.setListEndpointsResponse({ diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 5d43f1513..c4ad35e5c 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -310,21 +310,6 @@ describe("runtime picker", () => { expect(r.lastFrame()).toContain("page 1 · more →"); }); - test("Ctrl+C exits without another Runtime list request", async () => { - const core = coreWithRuntimes([runtime()]); - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "checkout"); - const callsBeforeExit = core.runtime.calls.filter( - (call) => call.method === "listRuntimes", - ).length; - await r.write(String.fromCharCode(3)); - - expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( - callsBeforeExit, - ); - }); - test("retains rows and ignores page keys while either page direction is loading", async () => { const core = new TestCoreClient(); core.runtime.setListResponse({ diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 6cef54517..1ede038d4 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -262,24 +262,6 @@ describe("Runtime version flow", () => { expect(r.lastFrame()).toContain("3"); }); - test("Ctrl+C exits without another Runtime version list request", async () => { - const core = new TestCoreClient(); - core.runtime.setListVersionsResponse({ - agentRuntimes: [runtime()], - }); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "2026-07-20T12:34:56.000Z"); - const callsBeforeExit = core.runtime.calls.filter( - (call) => call.method === "listRuntimeVersions", - ).length; - await r.write(String.fromCharCode(3)); - - expect(core.runtime.calls.filter((call) => call.method === "listRuntimeVersions")).toHaveLength( - callsBeforeExit, - ); - }); - test("retains rows and ignores page keys during a page transition", async () => { const core = new TestCoreClient(); core.runtime.setListVersionsResponse({ @@ -315,6 +297,27 @@ describe("Runtime version flow", () => { await waitForText(r.lastFrame, "10"); }); + test("filters only the loaded page without paging on h or l", async () => { + const core = new TestCoreClient(); + core.runtime.setListVersionsResponse({ + agentRuntimes: [runtime({ agentRuntimeVersion: "12" })], + nextToken: "page-2", + }); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("/"); + await r.write("l"); + await r.write("h"); + await waitForText(r.lastFrame, "/ Filter this page: lh"); + expect(r.lastFrame()).toContain("No matches on this page"); + expect( + core.runtime.calls.some( + (call) => call.method === "listRuntimeVersions" && call.args[1] === "page-2", + ), + ).toBe(false); + }); + test("names the selected Runtime in empty and error states", async () => { const empty = renderScreen("/agentcore/runtime/version/list/runtime-123"); await waitForText(empty.lastFrame, "No versions found for Runtime runtime-123."); From 3028bbfc1dce7cdf41d85f575e757a1db34fffb4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 20:22:33 +0000 Subject: [PATCH 13/38] fix: stabilize Runtime pagination resets --- src/components/ui/data-table/DataTable.tsx | 9 +- src/components/usePagedList.tsx | 45 ++++++++-- .../harness/list/list.screen.test.tsx | 21 ++++- .../components/RuntimeEndpointPicker.tsx | 1 + .../runtime/components/RuntimePicker.tsx | 1 + .../components/RuntimeVersionPicker.tsx | 1 + .../runtime/endpoint/endpoint.screen.test.tsx | 35 ++++---- src/handlers/runtime/runtime.screen.test.tsx | 83 ++++++++++++++++--- .../runtime/version/version.screen.test.tsx | 35 ++++---- src/testing/renderScreen.tsx | 4 +- 10 files changed, 176 insertions(+), 59 deletions(-) diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 4621c057b..a0676c2dc 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Text, useInput } from "ink"; import { darkTheme } from "../_core.js"; import type { InkUITheme } from "../_core.js"; @@ -40,6 +40,7 @@ export interface DataTableProps { showFooter?: boolean; emptyMessage?: string; focus?: boolean; + selectionResetKey?: string | number; theme?: InkUITheme; } @@ -63,6 +64,7 @@ export function DataTable>({ showFooter = true, emptyMessage = "No data", focus = true, + selectionResetKey, theme = darkTheme, }: DataTableProps): React.ReactElement { const [selectedRow, setSelectedRow] = useState(0); @@ -72,6 +74,11 @@ export function DataTable>({ const [searchQuery, setSearchQuery] = useState(""); const [searchMode, setSearchMode] = useState(false); + useEffect(() => { + setSelectedRow(0); + setCurrentPage(0); + }, [selectionResetKey]); + // Filter const filtered = data.filter((row) => { if (!searchQuery) return true; diff --git a/src/components/usePagedList.tsx b/src/components/usePagedList.tsx index e5565cf52..fa6ed7a25 100644 --- a/src/components/usePagedList.tsx +++ b/src/components/usePagedList.tsx @@ -20,6 +20,20 @@ export interface PagedList { prev: () => void; } +interface PaginationState { + pageSize: number; + pageIndex: number; + tokens: (string | undefined)[]; +} + +function initialPagination(pageSize: number): PaginationState { + return { + pageSize, + pageIndex: 0, + tokens: [undefined], + }; +} + // usePagedList holds the server-side pagination state shared by the picker // tables: a terminal-height-derived page size and the trail of nextTokens // leading to the current page, so ←/h can walk back through cached pages. @@ -27,26 +41,39 @@ export function usePagedList(): PagedList { const { rows } = useWindowSize(); const pageSize = Math.max(3, rows - CHROME_ROWS); - const [pageIndex, setPageIndex] = useState(0); - // tokens[i] is the nextToken that fetched page i; page 0 has none. - const [tokens, setTokens] = useState<(string | undefined)[]>([undefined]); + const [state, setState] = useState(() => initialPagination(pageSize)); + const pageSizeChanged = state.pageSize !== pageSize; + const pageIndex = pageSizeChanged ? 0 : state.pageIndex; + const token = pageSizeChanged ? undefined : state.tokens[state.pageIndex]; // A resize changes maxResults, which invalidates the token trail (tokens // encode positions relative to the old page size) — restart from page 1. useEffect(() => { - setPageIndex(0); - setTokens([undefined]); + setState((current) => (current.pageSize === pageSize ? current : initialPagination(pageSize))); }, [pageSize]); return { pageSize, pageIndex, - token: tokens[pageIndex], + token, next: (nextToken) => { if (!nextToken) return; - setTokens((trail) => [...trail.slice(0, pageIndex + 1), nextToken]); - setPageIndex((i) => i + 1); + setState((current) => { + const active = current.pageSize === pageSize ? current : initialPagination(pageSize); + return { + pageSize, + pageIndex: active.pageIndex + 1, + tokens: [...active.tokens.slice(0, active.pageIndex + 1), nextToken], + }; + }); }, - prev: () => setPageIndex((i) => Math.max(0, i - 1)), + prev: () => + setState((current) => { + const active = current.pageSize === pageSize ? current : initialPagination(pageSize); + return { + ...active, + pageIndex: Math.max(0, active.pageIndex - 1), + }; + }), }; } diff --git a/src/handlers/harness/list/list.screen.test.tsx b/src/handlers/harness/list/list.screen.test.tsx index d25029c95..7ef10ffb1 100644 --- a/src/handlers/harness/list/list.screen.test.tsx +++ b/src/handlers/harness/list/list.screen.test.tsx @@ -88,13 +88,26 @@ describe("harness list screen", () => { r.unmount(); }); - test("calls listHarnesses with the region from context", async () => { + test("makes one initial list request at the 100x40 page size with context options", async () => { const core = coreWith([harness()]); const r = renderScreen("/agentcore/harness/list", { core }); - await waitFor(() => core.harness.calls.length > 0); - const call = core.harness.calls.find((c) => c.method === "listHarnesses")!; - expect((call.args[2] as { region: string }).region).toBe("us-east-1"); + await waitFor(() => + core.harness.calls.some((call) => call.method === "listHarnesses" && call.args[1] === 32), + ); + expect(core.harness.calls.filter((call) => call.method === "listHarnesses")).toEqual([ + { + method: "listHarnesses", + args: [ + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); r.unmount(); }); diff --git a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx index f22b0d6d7..b17fca4ef 100644 --- a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx +++ b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx @@ -107,6 +107,7 @@ export function RuntimeEndpointPicker({ showFooter={false} showDivider={true} pageSize={paging.pageSize} + selectionResetKey={`${paging.pageSize}:${paging.pageIndex}`} columns={[ { key: "qualifier", diff --git a/src/handlers/runtime/components/RuntimePicker.tsx b/src/handlers/runtime/components/RuntimePicker.tsx index 5c292ac53..f2e60eace 100644 --- a/src/handlers/runtime/components/RuntimePicker.tsx +++ b/src/handlers/runtime/components/RuntimePicker.tsx @@ -110,6 +110,7 @@ export function RuntimePicker({ showFooter={false} showDivider={true} pageSize={paging.pageSize} + selectionResetKey={`${paging.pageSize}:${paging.pageIndex}`} columns={[ { key: "runtimeName", header: "name", width: nameWidth }, ...(showId ? [{ key: "runtimeId" as const, header: "id", width: idWidth }] : []), diff --git a/src/handlers/runtime/components/RuntimeVersionPicker.tsx b/src/handlers/runtime/components/RuntimeVersionPicker.tsx index eaedbcf66..50f95737b 100644 --- a/src/handlers/runtime/components/RuntimeVersionPicker.tsx +++ b/src/handlers/runtime/components/RuntimeVersionPicker.tsx @@ -106,6 +106,7 @@ export function RuntimeVersionPicker({ showFooter={false} showDivider={true} pageSize={paging.pageSize} + selectionResetKey={`${paging.pageSize}:${paging.pageIndex}`} columns={[ { key: "version", header: "version", width: versionWidth }, ...(showStatus diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 5e1edfef3..df296084b 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -152,22 +152,20 @@ describe("Runtime endpoint flow", () => { (call) => call.method === "listRuntimeEndpoints" && call.args[2] === 32, ), ); - expect( - core.runtime.calls.find( - (call) => call.method === "listRuntimeEndpoints" && call.args[2] === 32, - ), - ).toEqual({ - method: "listRuntimeEndpoints", - args: [ - "runtime-123", - undefined, - 32, - { - region: "us-east-1", - endpointUrl: undefined, - }, - ], - }); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints")).toEqual([ + { + method: "listRuntimeEndpoints", + args: [ + "runtime-123", + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); }); test("renders qualifier, live version, status, and update time without invented columns", async () => { @@ -287,6 +285,11 @@ describe("Runtime endpoint flow", () => { }); await waitForText(r.lastFrame, "page-one"); expect(r.lastFrame()).toContain("page 1 · more →"); + const callsAfterResize = core.runtime.calls + .filter((call) => call.method === "listRuntimeEndpoints") + .slice(callsBeforeResize); + expect(callsAfterResize.length).toBeGreaterThan(0); + expect(callsAfterResize.every((call) => call.args[1] === undefined)).toBe(true); }); test("retains rows and ignores page keys during a page transition", async () => { diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index c4ad35e5c..9bdd9e8f6 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -186,17 +186,19 @@ describe("runtime picker", () => { renderScreen("/agentcore/runtime/list", { core }); await waitFor(() => core.runtime.calls.some((call) => call.args[1] === 32)); - expect(core.runtime.calls.find((call) => call.args[1] === 32)).toEqual({ - method: "listRuntimes", - args: [ - undefined, - 32, - { - region: "us-east-1", - endpointUrl: undefined, - }, - ], - }); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toEqual([ + { + method: "listRuntimes", + args: [ + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); }); test("shows loading and lets Esc return to the Runtime menu", async () => { @@ -308,6 +310,65 @@ describe("runtime picker", () => { }); await waitForText(r.lastFrame, "page-one"); expect(r.lastFrame()).toContain("page 1 · more →"); + const callsAfterResize = core.runtime.calls + .filter((call) => call.method === "listRuntimes") + .slice(callsBeforeResize); + expect(callsAfterResize.length).toBeGreaterThan(0); + expect(callsAfterResize.every((call) => call.args[0] === undefined)).toBe(true); + }); + + test("resizing from page two resets selection to the first row on page one", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [ + runtime({ agentRuntimeId: "page-one-first", agentRuntimeName: "page-one-first" }), + runtime({ agentRuntimeId: "page-one-second", agentRuntimeName: "page-one-second" }), + ], + nextToken: "page-2", + }); + core.runtime.setListResponse( + { + agentRuntimes: [ + runtime({ agentRuntimeId: "page-two-first", agentRuntimeName: "page-two-first" }), + runtime({ agentRuntimeId: "page-two-second", agentRuntimeName: "page-two-second" }), + ], + }, + "page-2", + ); + core.runtime.setGetResponse( + getRuntimeResponse({ + agentRuntimeId: "page-one-first", + agentRuntimeName: "page-one-first", + }), + ); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page-two-second"); + await r.press("down"); + await waitForText(r.lastFrame, "❯ page-two-second"); + + await r.resize(100, 20); + await waitForText(r.lastFrame, "page-one-first"); + await r.press("return"); + + await waitForText(r.lastFrame, "agentcore → runtime → get → page-one-first"); + await waitFor(() => + core.runtime.calls.some( + (call) => call.method === "getRuntime" && call.args[0] === "page-one-first", + ), + ); + expect( + core.runtime.calls.some( + (call) => call.method === "getRuntime" && call.args[0] === "page-one-first", + ), + ).toBe(true); + expect( + core.runtime.calls.some( + (call) => call.method === "getRuntime" && call.args[0] === "page-one-second", + ), + ).toBe(false); }); test("retains rows and ignores page keys while either page direction is loading", async () => { diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 1ede038d4..c6398c68c 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -122,22 +122,20 @@ describe("Runtime version flow", () => { (call) => call.method === "listRuntimeVersions" && call.args[2] === 32, ), ); - expect( - core.runtime.calls.find( - (call) => call.method === "listRuntimeVersions" && call.args[2] === 32, - ), - ).toEqual({ - method: "listRuntimeVersions", - args: [ - "runtime-123", - undefined, - 32, - { - region: "us-east-1", - endpointUrl: undefined, - }, - ], - }); + expect(core.runtime.calls.filter((call) => call.method === "listRuntimeVersions")).toEqual([ + { + method: "listRuntimeVersions", + args: [ + "runtime-123", + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); }); test("renders numeric versions newest first with only version status and update time", async () => { @@ -260,6 +258,11 @@ describe("Runtime version flow", () => { }); await waitForText(r.lastFrame, "page 1 · more →"); expect(r.lastFrame()).toContain("3"); + const callsAfterResize = core.runtime.calls + .filter((call) => call.method === "listRuntimeVersions") + .slice(callsBeforeResize); + expect(callsAfterResize.length).toBeGreaterThan(0); + expect(callsAfterResize.every((call) => call.args[1] === undefined)).toBe(true); }); test("retains rows and ignores page keys during a page transition", async () => { diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 7e4987213..dea93d7b9 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -119,9 +119,9 @@ export function renderScreen(path: string, options: RenderScreenOptions = {}): R const ctx = options.ctx ?? baseContext(core); const queryClient = options.queryClient ?? testQueryClient(); - const instance = render(); - + const instance = render(<>); setWindowSize(instance.stdout, 100, 40); + instance.rerender(); return { core, From 27353397aaff5efa338321e9eec60bb88cc1f36b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 21:14:20 +0000 Subject: [PATCH 14/38] fix: keep TUI footer stable in narrow terminals --- bun.lock | 1 + package.json | 3 + src/components/ui/key-hint/KeyHint.test.tsx | 45 ++++++++++++++ src/components/ui/key-hint/KeyHint.tsx | 62 ++++++++++++++++---- src/handlers/runtime/runtime.screen.test.tsx | 49 ++++++++++++++++ 5 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 src/components/ui/key-hint/KeyHint.test.tsx diff --git a/bun.lock b/bun.lock index f001ff39c..257d4b5b6 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "react-router": "^8.1.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "string-width": "^8.2.2", "zod": "^4.4.3", }, "devDependencies": { diff --git a/package.json b/package.json index 9a5e0d80e..866922c14 100644 --- a/package.json +++ b/package.json @@ -58,11 +58,14 @@ "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", "lodash": "^4.18.1", + "pino": "^10.3.1", + "pino-roll": "^4.0.0", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "react-router": "^8.1.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "string-width": "^8.2.2", "zod": "^4.4.3" } } diff --git a/src/components/ui/key-hint/KeyHint.test.tsx b/src/components/ui/key-hint/KeyHint.test.tsx new file mode 100644 index 000000000..ba6ab6dd4 --- /dev/null +++ b/src/components/ui/key-hint/KeyHint.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import React from "react"; +import { cleanup, render } from "ink-testing-library"; +import { KeyHint, type KeyHintItem } from "./KeyHint"; + +afterEach(cleanup); + +function renderAtWidth(keys: KeyHintItem[], columns: number): string { + const instance = render(<>); + Object.defineProperty(instance.stdout, "columns", { + configurable: true, + value: columns, + }); + instance.stdout.emit("resize"); + instance.rerender(); + return instance.lastFrame() ?? ""; +} + +describe("KeyHint", () => { + test("excludes a double-width hint that does not fit", () => { + const frame = renderAtWidth( + [ + { key: "界", label: "go" }, + { key: "enter", label: "select" }, + ], + 22, + ); + + expect(frame).not.toContain("[界] go"); + expect(frame).toContain("[enter] select"); + }); + + test("includes a combining-character hint when its terminal cells fit", () => { + const frame = renderAtWidth( + [ + { key: "e\u0301", label: "go" }, + { key: "enter", label: "select" }, + ], + 22, + ); + + expect(frame).toContain("[e\u0301] go"); + expect(frame).toContain("[enter] select"); + }); +}); diff --git a/src/components/ui/key-hint/KeyHint.tsx b/src/components/ui/key-hint/KeyHint.tsx index 6f0e80086..0c89c427a 100644 --- a/src/components/ui/key-hint/KeyHint.tsx +++ b/src/components/ui/key-hint/KeyHint.tsx @@ -1,8 +1,11 @@ import React from "react"; -import { Text, Box } from "ink"; +import { Text, Box, useWindowSize } from "ink"; +import stringWidth from "string-width"; import { darkTheme } from "../_core.js"; import type { InkUITheme } from "../_core.js"; +const ITEM_GAP = 2; + export interface KeyHintItem { /** Displayed in brackets, e.g. "Enter", "↑↓", "Space" */ key: string; @@ -15,15 +18,48 @@ export interface KeyHintProps { theme?: InkUITheme; } -export const KeyHint: React.FC = ({ keys, theme = darkTheme }) => ( - - {keys.map(({ key, label }) => ( - - - [{key}] - - {label} - - ))} - -); +function priority({ key, label }: KeyHintItem): number { + if (key === "esc" || label === "back") return 0; + if (key === "enter" || label === "select") return 1; + if (key.includes("↑") || key.includes("↓")) return 2; + if (key.includes("←") || key.includes("→") || key === "/" || key === "ctl+c") return 4; + return 3; +} + +function itemWidth({ key, label }: KeyHintItem): number { + return stringWidth(`[${key}] ${label}`); +} + +function fitKeys(keys: KeyHintItem[], columns: number): KeyHintItem[] { + const candidates = keys + .map((item, index) => ({ item, index })) + .sort((a, b) => priority(a.item) - priority(b.item) || a.index - b.index); + const selected = new Set(); + let width = 0; + + for (const candidate of candidates) { + const nextWidth = itemWidth(candidate.item) + (selected.size === 0 ? 0 : ITEM_GAP); + if (width + nextWidth > columns) continue; + selected.add(candidate.index); + width += nextWidth; + } + + return keys.filter((_, index) => selected.has(index)); +} + +export const KeyHint: React.FC = ({ keys, theme = darkTheme }) => { + const { columns } = useWindowSize(); + + return ( + + {fitKeys(keys, columns).map(({ key, label }) => ( + + + [{key}] + + {label} + + ))} + + ); +}; diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 9bdd9e8f6..9834c9b95 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -181,6 +181,55 @@ describe("runtime picker", () => { expect(frame).toContain("2026-07-19T01:02:03.000Z"); }); + test("keeps a full Runtime list and its footer coherent at 50x20", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: Array.from({ length: 12 }, (_, index) => + runtime({ + agentRuntimeId: `narrow-row-${index + 1}`, + agentRuntimeName: `narrow-row-${index + 1}`, + }), + ), + nextToken: "page-2", + }); + const r = renderScreen("/agentcore/runtime/list", { core }); + + await waitForText(r.lastFrame, "narrow-row-12"); + for (const hint of [ + "[↑↓/jk] navigate", + "[←→/hl] page", + "[/] filter", + "[enter] select", + "[esc] back", + "[ctl+c] quit", + ]) { + expect(r.lastFrame()).toContain(hint); + } + await r.resize(50, 20); + await waitForText(r.lastFrame, "page 1 · more →"); + const frame = r.lastFrame()!; + const lines = frame.split("\n"); + const pageLine = lines.find((line) => line.includes("page 1 · more →")); + const footerLine = lines.find((line) => line.includes("[↑↓/jk] navigate")); + + expect(frameSize(frame)).toEqual({ columns: 50, rows: 20 }); + expect(frame).toContain("agentcore → runtime → list"); + expect(pageLine?.trim()).toBe("page 1 · more →"); + expect(pageLine).not.toContain("narrow-row"); + expect(footerLine).toContain("[enter] select"); + expect(footerLine).toContain("[esc] back"); + expect(frame).not.toContain("[←→/hl] page"); + expect(frame).not.toContain("[/] filter"); + expect(frame).not.toContain("[ctl+c] quit"); + expect(footerLine!.indexOf("[↑↓/jk] navigate")).toBeLessThan( + footerLine!.indexOf("[enter] select"), + ); + expect(footerLine!.indexOf("[enter] select")).toBeLessThan(footerLine!.indexOf("[esc] back")); + expect( + lines.filter((line) => line.includes("[") && line.includes("]")).map((line) => line.trim()), + ).toEqual([footerLine!.trim()]); + }); + test("calls listRuntimes with terminal page size and exact Core options", async () => { const core = coreWithRuntimes([runtime()]); renderScreen("/agentcore/runtime/list", { core }); From 5ec22d0c26c3bd59a853b5433bee63c9d5504d1c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 23:04:48 +0000 Subject: [PATCH 15/38] fix: clarify Runtime latest version column --- src/handlers/runtime/components/RuntimePicker.tsx | 4 ++-- src/handlers/runtime/runtime.screen.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/handlers/runtime/components/RuntimePicker.tsx b/src/handlers/runtime/components/RuntimePicker.tsx index f2e60eace..17bf31220 100644 --- a/src/handlers/runtime/components/RuntimePicker.tsx +++ b/src/handlers/runtime/components/RuntimePicker.tsx @@ -69,7 +69,7 @@ export function RuntimePicker({ const showId = columns >= 130; const showUpdatedAt = columns >= 90; const showStatus = columns >= 70; - const versionWidth = 8; + const versionWidth = 15; const statusWidth = showStatus ? 16 : 0; const updatedAtWidth = showUpdatedAt ? 30 : 0; const idWidth = showId ? Math.max(30, Math.floor(columns * 0.36)) : 0; @@ -114,7 +114,7 @@ export function RuntimePicker({ columns={[ { key: "runtimeName", header: "name", width: nameWidth }, ...(showId ? [{ key: "runtimeId" as const, header: "id", width: idWidth }] : []), - { key: "runtimeVersion", header: "latest", width: versionWidth }, + { key: "runtimeVersion", header: "latestVersion", width: versionWidth }, ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 9834c9b95..05607a5e3 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -172,7 +172,7 @@ describe("runtime picker", () => { const frame = r.lastFrame()!; expect(frame).toContain("name"); expect(frame).toContain("id"); - expect(frame).toContain("latest"); + expect(frame).toContain("latestVersion"); expect(frame).toContain("status"); expect(frame).toContain("lastUpdatedAt"); expect(frame).toContain("runtime-visible-id"); @@ -562,7 +562,7 @@ describe("runtime picker", () => { await waitForText(r.lastFrame, "visible-name"); const frame = r.lastFrame()!; expect(frame).toContain("name"); - expect(frame).toContain("latest"); + expect(frame).toContain("latestVersion"); expect(frame).toContain("visible-name"); expect(frame).toContain("88"); expect(frame).not.toContain("hidden-runtime-id"); From 451c27dcc906118c4b5481b1aef6d16853acc3e0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 21 Jul 2026 23:44:41 +0000 Subject: [PATCH 16/38] fix: align Runtime TUI navigation with Harness --- src/components/RouterScreen.test.tsx | 55 +++++++++++ .../components/RuntimeEndpointPicker.tsx | 9 +- .../runtime/components/RuntimePicker.tsx | 9 +- .../components/RuntimeVersionPicker.tsx | 9 +- .../runtime/endpoint/endpoint.screen.test.tsx | 93 +++++++++---------- src/handlers/runtime/endpoint/get/screen.tsx | 6 +- src/handlers/runtime/endpoint/list/screen.tsx | 2 - src/handlers/runtime/get/screen.tsx | 4 +- src/handlers/runtime/list/screen.tsx | 1 - src/handlers/runtime/runtime.screen.test.tsx | 64 ++++++------- src/handlers/runtime/version/get/screen.tsx | 4 +- src/handlers/runtime/version/list/screen.tsx | 2 - .../runtime/version/version.screen.test.tsx | 80 ++++++++++------ 13 files changed, 200 insertions(+), 138 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 957dec4a1..7cc565564 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -386,6 +386,61 @@ describe("navigation", () => { expectEndpointPropagation(core, ["listRuntimes", "getRuntime", listMethod, getMethod]); }, ); + + test.each([ + [ + "versions", + "version", + "2026-07-20T12:34:56.000Z", + "agentcore → runtime → version → get → runtime-123 → 7", + '"agentRuntimeVersion"', + ], + [ + "endpoints", + "endpoint", + "prod", + "agentcore → runtime → endpoint → get → runtime-123 → prod", + '"agentRuntimeEndpointArn"', + ], + ] as const)( + "reverses the direct Runtime %s get flow through its actual history", + async (_destination, resource, scopedListValue, jsonBreadcrumb, jsonField) => { + const core = runtimeCore(); + const r = renderScreen(`/agentcore/runtime/${resource}`, { + core, + ctx: runtimeContext(core), + }); + + await waitForText(r.lastFrame, `agentcore → runtime → ${resource}`); + await r.press("return"); + await waitForText(r.lastFrame, "checkout"); + + await r.press("return"); + await waitForText(r.lastFrame, `agentcore → runtime → ${resource} → list → runtime-123`); + await waitForText(r.lastFrame, scopedListValue); + + await r.press("return"); + await waitForText(r.lastFrame, jsonBreadcrumb); + await waitForText(r.lastFrame, jsonField); + + await r.press("escape"); + await waitForText(r.lastFrame, `agentcore → runtime → ${resource} → list → runtime-123`); + + await tick(50); + await r.press("escape"); + await tick(100); + const parentFrame = r.lastFrame() ?? ""; + expect(parentFrame).toContain(`agentcore → runtime → ${resource} → list`); + expect(parentFrame).not.toContain(`agentcore → runtime → ${resource} → list → runtime-123`); + expect(parentFrame).toContain("checkout"); + + await r.press("escape"); + await waitForText( + r.lastFrame, + `agentcore → runtime → ${resource} → inspect AgentCore Runtime ${resource}s`, + ); + }, + ); }); describe("Runtime TUI exit", () => { diff --git a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx index b17fca4ef..dc08402cb 100644 --- a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx +++ b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx @@ -1,6 +1,7 @@ import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Text, useInput, useWindowSize } from "ink"; +import { useNavigate } from "react-router"; import { DataTable } from "../../../components/ui/data-table"; import { darkTheme } from "../../../components/ui/_core.js"; import { Spinner } from "../../../components/ui/spinner"; @@ -30,7 +31,6 @@ export interface RuntimeEndpointPickerProps extends ScreenProps { breadcrumb: string[]; description?: string; onSelect: (qualifier: string) => void; - onEscape: () => void; } export function RuntimeEndpointPicker({ @@ -40,11 +40,12 @@ export function RuntimeEndpointPicker({ breadcrumb, description, onSelect, - onEscape, }: RuntimeEndpointPickerProps) { const opts = coreOptsFromCtx(ctx); const { columns } = useWindowSize(); + const navigate = useNavigate(); const paging = usePagedList(); + const goBack = () => navigate(-1); const list = useQuery({ queryKey: ["runtime-endpoints", opts.region, runtimeId, paging.pageSize, paging.token], queryFn: () => @@ -55,7 +56,7 @@ export function RuntimeEndpointPicker({ useInput( (input, key) => { if (key.escape) { - onEscape(); + goBack(); return; } if (input === "r" && list.isError) void list.refetch(); @@ -133,7 +134,7 @@ export function RuntimeEndpointPicker({ onSelect={(row) => { if (row.qualifier) onSelect(row.qualifier); }} - onEscape={onEscape} + onEscape={goBack} onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} /> diff --git a/src/handlers/runtime/components/RuntimePicker.tsx b/src/handlers/runtime/components/RuntimePicker.tsx index 17bf31220..22451a229 100644 --- a/src/handlers/runtime/components/RuntimePicker.tsx +++ b/src/handlers/runtime/components/RuntimePicker.tsx @@ -1,6 +1,7 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Text, useInput, useWindowSize } from "ink"; +import { useNavigate } from "react-router"; import { DataTable } from "../../../components/ui/data-table"; import { darkTheme } from "../../../components/ui/_core.js"; import { Spinner } from "../../../components/ui/spinner"; @@ -32,7 +33,6 @@ export interface RuntimePickerProps extends ScreenProps { breadcrumb: string[]; description?: string; onSelect: (runtimeId: string) => void; - onEscape: () => void; } export function RuntimePicker({ @@ -41,11 +41,12 @@ export function RuntimePicker({ breadcrumb, description, onSelect, - onEscape, }: RuntimePickerProps) { const opts = coreOptsFromCtx(ctx); const { columns } = useWindowSize(); + const navigate = useNavigate(); const paging = usePagedList(); + const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); const list = useQuery({ queryKey: ["runtimes", opts.region, paging.pageSize, paging.token], queryFn: () => core.runtime.listRuntimes(paging.token, paging.pageSize, opts), @@ -55,7 +56,7 @@ export function RuntimePicker({ useInput( (input, key) => { if (key.escape) { - onEscape(); + goBack(); return; } if (input === "r" && list.isError) void list.refetch(); @@ -133,7 +134,7 @@ export function RuntimePicker({ onSelect={(row) => { if (row.runtimeId) onSelect(row.runtimeId); }} - onEscape={onEscape} + onEscape={goBack} onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} /> diff --git a/src/handlers/runtime/components/RuntimeVersionPicker.tsx b/src/handlers/runtime/components/RuntimeVersionPicker.tsx index 50f95737b..4a9fe4144 100644 --- a/src/handlers/runtime/components/RuntimeVersionPicker.tsx +++ b/src/handlers/runtime/components/RuntimeVersionPicker.tsx @@ -1,6 +1,7 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Text, useInput, useWindowSize } from "ink"; +import { useNavigate } from "react-router"; import { DataTable } from "../../../components/ui/data-table"; import { darkTheme } from "../../../components/ui/_core.js"; import { Spinner } from "../../../components/ui/spinner"; @@ -28,7 +29,6 @@ export interface RuntimeVersionPickerProps extends ScreenProps { breadcrumb: string[]; description?: string; onSelect: (version: string) => void; - onEscape: () => void; } export function RuntimeVersionPicker({ @@ -38,11 +38,12 @@ export function RuntimeVersionPicker({ breadcrumb, description, onSelect, - onEscape, }: RuntimeVersionPickerProps) { const opts = coreOptsFromCtx(ctx); const { columns } = useWindowSize(); + const navigate = useNavigate(); const paging = usePagedList(); + const goBack = () => navigate(-1); const list = useQuery({ queryKey: ["runtime-versions", opts.region, runtimeId, paging.pageSize, paging.token], queryFn: () => core.runtime.listRuntimeVersions(runtimeId, paging.token, paging.pageSize, opts), @@ -52,7 +53,7 @@ export function RuntimeVersionPicker({ useInput( (input, key) => { if (key.escape) { - onEscape(); + goBack(); return; } if (input === "r" && list.isError) void list.refetch(); @@ -127,7 +128,7 @@ export function RuntimeVersionPicker({ onSelect={(row) => { if (row.version) onSelect(row.version); }} - onEscape={onEscape} + onEscape={goBack} onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} /> diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index df296084b..e4ee7345d 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -3,7 +3,6 @@ import type { AgentRuntime, AgentRuntimeEndpoint, GetAgentRuntimeEndpointResponse, - GetAgentRuntimeResponse, ListAgentRuntimeEndpointsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { @@ -65,24 +64,6 @@ function getEndpointResponse( }; } -function getRuntimeResponse(): GetAgentRuntimeResponse { - return { - agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", - agentRuntimeName: "checkout", - agentRuntimeId: "runtime-123", - 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" }, - status: "READY", - lifecycleConfiguration: { - idleRuntimeSessionTimeout: 900, - maxLifetime: 28_800, - }, - }; -} - function deferred(): { promise: Promise; resolve: (value: T) => void; @@ -94,10 +75,14 @@ function deferred(): { return { promise, resolve }; } -async function waitForRuntimeHub(lastFrame: () => string | undefined): Promise { +async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise { await waitFor(() => { const frame = lastFrame() ?? ""; - return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); + return ( + frame.includes("agentcore → runtime → endpoint → list") && + !frame.includes("agentcore → runtime → endpoint → list → runtime-123") && + frame.includes("checkout") + ); }); } @@ -366,33 +351,46 @@ describe("Runtime endpoint flow", () => { retry.unmount(); const loadingCore = new TestCoreClient(); + loadingCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); const pending = deferred(); loadingCore.runtime.listRuntimeEndpoints = async () => pending.promise; - loadingCore.runtime.setGetResponse(getRuntimeResponse()); - const loading = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { + const loading = renderScreen("/agentcore/runtime/endpoint/list", { core: loadingCore, }); + await waitForText(loading.lastFrame, "checkout"); + await loading.press("return"); await waitForText(loading.lastFrame, "Loading endpoints for Runtime runtime-123…"); await loading.press("escape"); - await waitForRuntimeHub(loading.lastFrame); + await waitForRuntimePicker(loading.lastFrame); loading.unmount(); const errorCore = new TestCoreClient(); - errorCore.runtime.setError(new Error("failed")); - const error = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: errorCore }); + errorCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + errorCore.runtime.listRuntimeEndpoints = async () => { + throw new Error("failed"); + }; + const error = renderScreen("/agentcore/runtime/endpoint/list", { core: errorCore }); + await waitForText(error.lastFrame, "checkout"); + await error.press("return"); await waitForText(error.lastFrame, "failed"); - errorCore.runtime.setError(undefined); - errorCore.runtime.setGetResponse(getRuntimeResponse()); await error.press("escape"); - await waitForRuntimeHub(error.lastFrame); + await waitForRuntimePicker(error.lastFrame); error.unmount(); const emptyCore = new TestCoreClient(); - emptyCore.runtime.setGetResponse(getRuntimeResponse()); - const empty = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: emptyCore }); + emptyCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + const empty = renderScreen("/agentcore/runtime/endpoint/list", { core: emptyCore }); + await waitForText(empty.lastFrame, "checkout"); + await empty.press("return"); await waitForText(empty.lastFrame, "This Runtime has no endpoints."); await empty.press("escape"); - await waitForRuntimeHub(empty.lastFrame); + await waitForRuntimePicker(empty.lastFrame); }); test("selecting an encoded qualifier opens complete endpoint JSON with exact selectors", async () => { @@ -421,7 +419,7 @@ describe("Runtime endpoint flow", () => { ); }); - test("uses explicit Esc destinations for parent picker, scoped list, and JSON", async () => { + test("uses a structural parent for the Runtime picker and history below it", async () => { const parentCore = new TestCoreClient(); parentCore.runtime.setListResponse({ agentRuntimes: [runtime()], @@ -438,27 +436,26 @@ describe("Runtime endpoint flow", () => { parent.unmount(); const listCore = new TestCoreClient(); + listCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); listCore.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()], }); - listCore.runtime.setGetResponse(getRuntimeResponse()); - const list = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: listCore }); + listCore.runtime.setGetEndpointResponse(getEndpointResponse()); + const list = renderScreen("/agentcore/runtime/endpoint/list", { core: listCore }); + await waitForText(list.lastFrame, "checkout"); + await list.press("return"); await waitForText(list.lastFrame, "prod"); await list.press("escape"); - await waitForRuntimeHub(list.lastFrame); - list.unmount(); + await waitForRuntimePicker(list.lastFrame); - const jsonCore = new TestCoreClient(); - jsonCore.runtime.setGetEndpointResponse(getEndpointResponse()); - jsonCore.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint()], - }); - const json = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", { - core: jsonCore, - }); - await waitForText(json.lastFrame, '"agentRuntimeEndpointArn"'); - await json.press("escape"); - await waitForText(json.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); + await list.press("return"); + await waitForText(list.lastFrame, "prod"); + await list.press("return"); + await waitForText(list.lastFrame, '"agentRuntimeEndpointArn"'); + await list.press("escape"); + await waitForText(list.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); }); test("bare endpoint get redirects to parent selection", async () => { diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index ba1022a64..c570650b7 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -1,12 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) { const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); const { runtimeId, qualifier } = useParams(); const detail = useQuery({ queryKey: ["runtime-endpoint", opts.region, runtimeId, qualifier], @@ -21,9 +20,6 @@ export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) { error={detail.isError ? (detail.error as Error) : null} data={detail.data} loadingLabel={`Loading endpoint ${qualifier ?? ""} for Runtime ${runtimeId ?? ""}…`} - onEscape={() => - navigate(`/agentcore/runtime/endpoint/list/${encodeURIComponent(runtimeId!)}`) - } onRetry={() => void detail.refetch()} /> ); diff --git a/src/handlers/runtime/endpoint/list/screen.tsx b/src/handlers/runtime/endpoint/list/screen.tsx index f585788fd..67a3600c4 100644 --- a/src/handlers/runtime/endpoint/list/screen.tsx +++ b/src/handlers/runtime/endpoint/list/screen.tsx @@ -14,7 +14,6 @@ export function RuntimeListEndpointsScreen(props: ScreenProps) { breadcrumb={["agentcore", "runtime", "endpoint", "list"]} description="choose a Runtime to list endpoints for" onSelect={(id) => navigate(`/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`)} - onEscape={() => navigate("/agentcore/runtime/endpoint")} /> ); } @@ -29,7 +28,6 @@ export function RuntimeListEndpointsScreen(props: ScreenProps) { `/agentcore/runtime/endpoint/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`, ) } - onEscape={() => navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`)} /> ); } diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index ba3583cd7..d6767fc73 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -42,7 +42,7 @@ export function RuntimeGetScreen({ ctx, core }: ScreenProps) { useInput((input, key) => { if (key.escape) { - navigate("/agentcore/runtime/list"); + navigate(-1); return; } if (input === "r" && detail.isError) { @@ -123,7 +123,6 @@ export function RuntimeGetScreen({ ctx, core }: ScreenProps) { export function RuntimeGetJsonScreen({ ctx, core }: ScreenProps) { const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); const { runtimeId } = useParams(); const detail = useQuery({ queryKey: ["runtime", opts.region, runtimeId], @@ -138,7 +137,6 @@ export function RuntimeGetJsonScreen({ ctx, core }: ScreenProps) { error={detail.isError ? (detail.error as Error) : null} data={detail.data} loadingLabel="Loading Runtime…" - onEscape={() => navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId!)}`)} onRetry={() => void detail.refetch()} /> ); diff --git a/src/handlers/runtime/list/screen.tsx b/src/handlers/runtime/list/screen.tsx index 6fc1f0409..eee307cbe 100644 --- a/src/handlers/runtime/list/screen.tsx +++ b/src/handlers/runtime/list/screen.tsx @@ -10,7 +10,6 @@ export function RuntimeListScreen(props: ScreenProps) { {...props} breadcrumb={["agentcore", "runtime", "list"]} onSelect={(runtimeId) => navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`)} - onEscape={() => navigate("/agentcore/runtime")} /> ); } diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 05607a5e3..88439750e 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -784,21 +784,27 @@ describe("runtime hub", () => { expect(core.runtime.calls).toHaveLength(callsBeforeRetry + 1); }); - test("Esc from the hub returns explicitly to the Runtime picker", async () => { - const core = new TestCoreClient(); + test("Esc from the hub returns through history to the Runtime picker", async () => { + const core = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); core.runtime.setGetResponse(getRuntimeResponse()); - const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + const r = renderScreen("/agentcore/runtime/list", { core }); + await waitForText(r.lastFrame, "checkout"); + await r.press("return"); await waitForText(r.lastFrame, "show the full JSON definition"); await r.press("escape"); await waitForText(r.lastFrame, "agentcore → runtime → list"); }); - test("Esc from Runtime JSON returns explicitly to the Runtime hub", async () => { - const core = new TestCoreClient(); + test("Esc from Runtime JSON returns through history to the Runtime hub", async () => { + const core = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); core.runtime.setGetResponse(getRuntimeResponse()); - const r = renderScreen("/agentcore/runtime/get/runtime-123/json", { core }); + const r = renderScreen("/agentcore/runtime/list", { core }); + await waitForText(r.lastFrame, "checkout"); + await r.press("return"); + await waitForText(r.lastFrame, "show the full JSON definition"); + await r.press("return"); await waitForText(r.lastFrame, '"agentRuntimeId"'); await r.press("escape"); await waitFor(() => { @@ -808,48 +814,42 @@ describe("runtime hub", () => { await waitForText(r.lastFrame, "show the full JSON definition"); }); - test("Esc remains active while hub and JSON routes are loading", async () => { - const hubCore = new TestCoreClient(); + test("Esc remains active while the hub is loading", async () => { + const hubCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); const hubPending = deferred(); hubCore.runtime.getRuntime = async () => hubPending.promise; - const hub = renderScreen("/agentcore/runtime/get/runtime-123", { core: hubCore }); + const hub = renderScreen("/agentcore/runtime/list", { core: hubCore }); + await waitForText(hub.lastFrame, "checkout"); + await hub.press("return"); await waitForText(hub.lastFrame, "Loading Runtime…"); await hub.press("escape"); await waitForText(hub.lastFrame, "agentcore → runtime → list"); - hub.unmount(); - - const jsonCore = new TestCoreClient(); - const jsonPending = deferred(); - jsonCore.runtime.getRuntime = async () => jsonPending.promise; - const json = renderScreen("/agentcore/runtime/get/runtime-123/json", { - core: jsonCore, - }); - - await waitForText(json.lastFrame, "Loading Runtime…"); - await json.press("escape"); - await waitFor(() => { - const frame = json.lastFrame() ?? ""; - return frame.includes("agentcore → runtime → get → runtime-123") && !frame.includes("→ json"); - }); }); test("Esc remains active while hub and JSON routes show errors", async () => { - const hubCore = new TestCoreClient(); - hubCore.runtime.setError(new Error("hub failed")); - const hub = renderScreen("/agentcore/runtime/get/runtime-123", { core: hubCore }); + const hubCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + hubCore.runtime.getRuntime = async () => { + throw new Error("hub failed"); + }; + const hub = renderScreen("/agentcore/runtime/list", { core: hubCore }); + await waitForText(hub.lastFrame, "checkout"); + await hub.press("return"); await waitForText(hub.lastFrame, "hub failed"); await hub.press("escape"); await waitForText(hub.lastFrame, "agentcore → runtime → list"); hub.unmount(); - const jsonCore = new TestCoreClient(); - jsonCore.runtime.setError(new Error("json failed")); - const json = renderScreen("/agentcore/runtime/get/runtime-123/json", { - core: jsonCore, - }); + const jsonCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); + jsonCore.runtime.setGetResponse(getRuntimeResponse()); + const json = renderScreen("/agentcore/runtime/list", { core: jsonCore }); + await waitForText(json.lastFrame, "checkout"); + await json.press("return"); + await waitForText(json.lastFrame, "show the full JSON definition"); + jsonCore.runtime.setError(new Error("json failed")); + await json.press("return"); await waitForText(json.lastFrame, "json failed"); await json.press("escape"); await waitFor(() => { diff --git a/src/handlers/runtime/version/get/screen.tsx b/src/handlers/runtime/version/get/screen.tsx index ffaded6ae..924c5ba36 100644 --- a/src/handlers/runtime/version/get/screen.tsx +++ b/src/handlers/runtime/version/get/screen.tsx @@ -1,12 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; export function RuntimeGetVersionScreen({ ctx, core }: ScreenProps) { const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); const { runtimeId, version } = useParams(); const detail = useQuery({ queryKey: ["runtime-version", opts.region, runtimeId, version], @@ -21,7 +20,6 @@ export function RuntimeGetVersionScreen({ ctx, core }: ScreenProps) { error={detail.isError ? (detail.error as Error) : null} data={detail.data} loadingLabel={`Loading version ${version ?? ""} for Runtime ${runtimeId ?? ""}…`} - onEscape={() => navigate(`/agentcore/runtime/version/list/${encodeURIComponent(runtimeId!)}`)} onRetry={() => void detail.refetch()} /> ); diff --git a/src/handlers/runtime/version/list/screen.tsx b/src/handlers/runtime/version/list/screen.tsx index 16c940fa0..9201ea842 100644 --- a/src/handlers/runtime/version/list/screen.tsx +++ b/src/handlers/runtime/version/list/screen.tsx @@ -14,7 +14,6 @@ export function RuntimeListVersionsScreen(props: ScreenProps) { breadcrumb={["agentcore", "runtime", "version", "list"]} description="choose a Runtime to list versions for" onSelect={(id) => navigate(`/agentcore/runtime/version/list/${encodeURIComponent(id)}`)} - onEscape={() => navigate("/agentcore/runtime/version")} /> ); } @@ -29,7 +28,6 @@ export function RuntimeListVersionsScreen(props: ScreenProps) { `/agentcore/runtime/version/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(version)}`, ) } - onEscape={() => navigate(`/agentcore/runtime/get/${encodeURIComponent(runtimeId)}`)} /> ); } diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index c6398c68c..867c0de40 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -8,6 +8,7 @@ import { cleanupScreens, renderScreen, TestCoreClient, + tick, waitFor, waitForText, } from "../../../testing"; @@ -61,13 +62,14 @@ function deferred(): { return { promise, resolve }; } -async function waitForRuntimeHub( - lastFrame: () => string | undefined, - runtimeId = "runtime-123", -): Promise { +async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise { await waitFor(() => { const frame = lastFrame() ?? ""; - return frame.includes(`agentcore → runtime → get → ${runtimeId}`) && !frame.includes("→ json"); + return ( + frame.includes("agentcore → runtime → version → list") && + !frame.includes("agentcore → runtime → version → list → runtime-123") && + frame.includes("checkout") + ); }); } @@ -352,33 +354,46 @@ describe("Runtime version flow", () => { test("Esc remains active in loading, error, and empty states", async () => { const loadingCore = new TestCoreClient(); + loadingCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); const pending = deferred(); loadingCore.runtime.listRuntimeVersions = async () => pending.promise; - loadingCore.runtime.setGetResponse(getVersionResponse()); - const loading = renderScreen("/agentcore/runtime/version/list/runtime-123", { + const loading = renderScreen("/agentcore/runtime/version/list", { core: loadingCore, }); + await waitForText(loading.lastFrame, "checkout"); + await loading.press("return"); await waitForText(loading.lastFrame, "Loading versions for Runtime runtime-123…"); await loading.press("escape"); - await waitForRuntimeHub(loading.lastFrame); + await waitForRuntimePicker(loading.lastFrame); loading.unmount(); const errorCore = new TestCoreClient(); - errorCore.runtime.setError(new Error("failed")); - const error = renderScreen("/agentcore/runtime/version/list/runtime-123", { core: errorCore }); + errorCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + errorCore.runtime.listRuntimeVersions = async () => { + throw new Error("failed"); + }; + const error = renderScreen("/agentcore/runtime/version/list", { core: errorCore }); + await waitForText(error.lastFrame, "checkout"); + await error.press("return"); await waitForText(error.lastFrame, "Error loading versions"); - errorCore.runtime.setError(undefined); - errorCore.runtime.setGetResponse(getVersionResponse()); await error.press("escape"); - await waitForRuntimeHub(error.lastFrame); + await waitForRuntimePicker(error.lastFrame); error.unmount(); const emptyCore = new TestCoreClient(); - emptyCore.runtime.setGetResponse(getVersionResponse()); - const empty = renderScreen("/agentcore/runtime/version/list/runtime-123", { core: emptyCore }); + emptyCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); + const empty = renderScreen("/agentcore/runtime/version/list", { core: emptyCore }); + await waitForText(empty.lastFrame, "checkout"); + await empty.press("return"); await waitForText(empty.lastFrame, "No versions found"); await empty.press("escape"); - await waitForRuntimeHub(empty.lastFrame); + await waitForRuntimePicker(empty.lastFrame); }); test("selecting a version opens complete JSON with exact selectors", async () => { @@ -403,7 +418,7 @@ describe("Runtime version flow", () => { ); }); - test("uses explicit Esc destinations for parent picker, scoped list, and JSON", async () => { + test("uses a structural parent for the Runtime picker and history below it", async () => { const parentCore = new TestCoreClient(); parentCore.runtime.setListResponse({ agentRuntimes: [runtime()], @@ -420,25 +435,30 @@ describe("Runtime version flow", () => { parent.unmount(); const listCore = new TestCoreClient(); + listCore.runtime.setListResponse({ + agentRuntimes: [runtime()], + }); listCore.runtime.setListVersionsResponse({ agentRuntimes: [runtime()], }); - listCore.runtime.setGetResponse(getVersionResponse()); - const list = renderScreen("/agentcore/runtime/version/list/runtime-123", { core: listCore }); + listCore.runtime.setGetVersionResponse(getVersionResponse()); + const list = renderScreen("/agentcore/runtime/version/list", { core: listCore }); + await waitForText(list.lastFrame, "checkout"); + await list.press("return"); + await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); await waitForText(list.lastFrame, "3"); + await tick(50); await list.press("escape"); - await waitForRuntimeHub(list.lastFrame); - list.unmount(); + await waitForRuntimePicker(list.lastFrame); - const jsonCore = new TestCoreClient(); - jsonCore.runtime.setGetVersionResponse(getVersionResponse()); - jsonCore.runtime.setListVersionsResponse({ - agentRuntimes: [runtime()], - }); - const json = renderScreen("/agentcore/runtime/version/get/runtime-123/3", { core: jsonCore }); - await waitForText(json.lastFrame, '"agentRuntimeVersion"'); - await json.press("escape"); - await waitForText(json.lastFrame, "agentcore → runtime → version → list → runtime-123"); + await list.press("return"); + await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); + await waitForText(list.lastFrame, "3"); + await tick(50); + await list.press("return"); + await waitForText(list.lastFrame, '"agentRuntimeVersion"'); + await list.press("escape"); + await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); }); test("bare version get redirects to parent selection", async () => { From e81653c440bc5599a380664a09babcc517740c81 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 00:08:12 +0000 Subject: [PATCH 17/38] refactor: simplify Runtime TUI helpers --- src/components/JsonDetail.tsx | 5 +---- .../runtime/endpoint/endpoint.screen.test.tsx | 15 ++----------- src/handlers/runtime/runtime.screen.test.tsx | 21 +++++-------------- .../runtime/version/version.screen.test.tsx | 15 ++----------- 4 files changed, 10 insertions(+), 46 deletions(-) diff --git a/src/components/JsonDetail.tsx b/src/components/JsonDetail.tsx index d772a3aff..84e61582e 100644 --- a/src/components/JsonDetail.tsx +++ b/src/components/JsonDetail.tsx @@ -16,7 +16,6 @@ export interface JsonDetailProps { data: unknown; // loadingLabel names what's loading (e.g. "Loading endpoint…"). loadingLabel: string; - onEscape?: () => void; onRetry?: () => void; } @@ -30,7 +29,6 @@ export function JsonDetail({ error, data, loadingLabel, - onEscape, onRetry, }: JsonDetailProps) { const navigate = useNavigate(); @@ -38,8 +36,7 @@ export function JsonDetail({ useInput((input, key) => { if (key.escape) { - if (onEscape) onEscape(); - else navigate(-1); + navigate(-1); return; } if (input === "r" && error && onRetry) { diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index e4ee7345d..70f5e425c 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -64,17 +64,6 @@ function getEndpointResponse( }; } -function deferred(): { - promise: Promise; - resolve: (value: T) => void; -} { - let resolve!: (value: T) => void; - const promise = new Promise((release) => { - resolve = release; - }); - return { promise, resolve }; -} - async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise { await waitFor(() => { const frame = lastFrame() ?? ""; @@ -289,7 +278,7 @@ describe("Runtime endpoint flow", () => { }, "page-2", ); - const nextPage = deferred(); + const nextPage = Promise.withResolvers(); const listEndpoints = core.runtime.listRuntimeEndpoints.bind(core.runtime); core.runtime.listRuntimeEndpoints = async (...args) => { const response = await listEndpoints(...args); @@ -354,7 +343,7 @@ describe("Runtime endpoint flow", () => { loadingCore.runtime.setListResponse({ agentRuntimes: [runtime()], }); - const pending = deferred(); + const pending = Promise.withResolvers(); loadingCore.runtime.listRuntimeEndpoints = async () => pending.promise; const loading = renderScreen("/agentcore/runtime/endpoint/list", { core: loadingCore, diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 88439750e..d7c94ffe8 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -75,17 +75,6 @@ function coreWithRuntimes(runtimes: AgentRuntime[]): TestCoreClient { return core; } -function deferred(): { - promise: Promise; - resolve: (value: T) => void; -} { - let resolve!: (value: T) => void; - const promise = new Promise((release) => { - resolve = release; - }); - return { promise, resolve }; -} - async function waitForRuntimeMenu(lastFrame: () => string | undefined): Promise { await waitFor(() => (lastFrame() ?? "").includes("agentcore → runtime → inspect AgentCore Runtimes"), @@ -252,7 +241,7 @@ describe("runtime picker", () => { test("shows loading and lets Esc return to the Runtime menu", async () => { const core = new TestCoreClient(); - const pending = deferred(); + const pending = Promise.withResolvers(); core.runtime.listRuntimes = async () => pending.promise; const r = renderScreen("/agentcore/runtime/list", { core }); @@ -432,8 +421,8 @@ describe("runtime picker", () => { }, "page-2", ); - const pageTwo = deferred(); - const pageOneAgain = deferred(); + const pageTwo = Promise.withResolvers(); + const pageOneAgain = Promise.withResolvers(); let holdFirstPage = false; const listRuntimes = core.runtime.listRuntimes.bind(core.runtime); core.runtime.listRuntimes = async (...args) => { @@ -490,7 +479,7 @@ describe("runtime picker", () => { }, "page-2", ); - const pageOneRefetch = deferred(); + const pageOneRefetch = Promise.withResolvers(); let holdFirstPage = false; const listRuntimes = core.runtime.listRuntimes.bind(core.runtime); core.runtime.listRuntimes = async (...args) => { @@ -816,7 +805,7 @@ describe("runtime hub", () => { test("Esc remains active while the hub is loading", async () => { const hubCore = coreWithRuntimes([runtime({ agentRuntimeId: "runtime-123" })]); - const hubPending = deferred(); + const hubPending = Promise.withResolvers(); hubCore.runtime.getRuntime = async () => hubPending.promise; const hub = renderScreen("/agentcore/runtime/list", { core: hubCore }); diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 867c0de40..1909b2090 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -51,17 +51,6 @@ function getVersionResponse( }; } -function deferred(): { - promise: Promise; - resolve: (value: T) => void; -} { - let resolve!: (value: T) => void; - const promise = new Promise((release) => { - resolve = release; - }); - return { promise, resolve }; -} - async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise { await waitFor(() => { const frame = lastFrame() ?? ""; @@ -279,7 +268,7 @@ describe("Runtime version flow", () => { }, "page-2", ); - const nextPage = deferred(); + const nextPage = Promise.withResolvers(); const listVersions = core.runtime.listRuntimeVersions.bind(core.runtime); core.runtime.listRuntimeVersions = async (...args) => { const response = await listVersions(...args); @@ -357,7 +346,7 @@ describe("Runtime version flow", () => { loadingCore.runtime.setListResponse({ agentRuntimes: [runtime()], }); - const pending = deferred(); + const pending = Promise.withResolvers(); loadingCore.runtime.listRuntimeVersions = async () => pending.promise; const loading = renderScreen("/agentcore/runtime/version/list", { core: loadingCore, From dbbe4556cca59a193372d232c235af5c272b97bf Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 01:13:02 +0000 Subject: [PATCH 18/38] fix: align Runtime TUI entry and detail output --- README.md | 23 +++--- .../runtime/components/withoutSdkMetadata.ts | 7 ++ .../runtime/endpoint/endpoint.screen.test.tsx | 7 +- src/handlers/runtime/endpoint/get/screen.tsx | 3 +- src/handlers/runtime/get/screen.tsx | 5 +- src/handlers/runtime/index.tsx | 5 +- src/handlers/runtime/interactive.test.tsx | 76 ------------------ src/handlers/runtime/interactive.tsx | 80 ------------------- src/handlers/runtime/runtime.screen.test.tsx | 34 +++++++- src/handlers/runtime/runtime.test.tsx | 64 +++++---------- src/handlers/runtime/version/get/screen.tsx | 3 +- .../runtime/version/version.screen.test.tsx | 7 +- 12 files changed, 89 insertions(+), 225 deletions(-) create mode 100644 src/handlers/runtime/components/withoutSdkMetadata.ts delete mode 100644 src/handlers/runtime/interactive.test.tsx delete mode 100644 src/handlers/runtime/interactive.tsx diff --git a/README.md b/README.md index 55668ef75..b2a3be481 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,8 @@ It gives you two ways to work, from the same binary: - **A scriptable CLI** — every operation is a flag-driven subcommand that emits JSON (`--json`), so it can be used by codeing agents and can drop cleanly into scripts, CI, and automation. -- **An interactive TUI** — bare branch commands open menus, Harness commands - retain their interactive flows, and Runtime leaves opt in with - `--interactive`. +- **An interactive TUI** — bare Harness and Runtime branches and leaves open + their corresponding menus and selection flows. ```bash agentcore # launch the interactive TUI @@ -27,9 +26,8 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Commands run headless by default. Harness leaves retain their existing bare -interactive behavior; Runtime leaves remain headless unless `--interactive` is -set. +Commands with operation flags run headlessly. Bare Harness and Runtime branches +and leaves open their interactive flows. ``` agentcore # interactive TUI @@ -118,15 +116,16 @@ agentcore identity api-key-credential-provider update --name my-provider --api-k agentcore identity api-key-credential-provider delete --name my-provider ``` -Runtime interactive entry points require a TTY on stdin and stdout. -`--interactive --json` is invalid. +Bare Runtime branches and leaves require a TTY on stdin and stdout. Supplying +operation flags runs the command headlessly, and `--json` always suppresses TUI +rendering. ```bash agentcore runtime -agentcore runtime list --interactive -agentcore runtime get --interactive --id -agentcore runtime version list --interactive --id -agentcore runtime endpoint list --interactive --id +agentcore runtime list +agentcore runtime get +agentcore runtime version list +agentcore runtime endpoint list ``` --- diff --git a/src/handlers/runtime/components/withoutSdkMetadata.ts b/src/handlers/runtime/components/withoutSdkMetadata.ts new file mode 100644 index 000000000..c44b385fe --- /dev/null +++ b/src/handlers/runtime/components/withoutSdkMetadata.ts @@ -0,0 +1,7 @@ +export function withoutSdkMetadata(data: unknown): unknown { + if (data === null || typeof data !== "object" || !("$metadata" in data)) return data; + + const normalized = { ...(data as Record) }; + delete normalized.$metadata; + return normalized; +} diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 70f5e425c..83d4197a9 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -388,7 +388,10 @@ describe("Runtime endpoint flow", () => { core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint({ name: qualifier, id: qualifier })], }); - core.runtime.setGetEndpointResponse(getEndpointResponse({ name: qualifier, id: qualifier })); + core.runtime.setGetEndpointResponse({ + $metadata: { requestId: "endpoint-request-metadata" }, + ...getEndpointResponse({ name: qualifier, id: qualifier }), + } as GetAgentRuntimeEndpointResponse); const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); await waitForText(r.lastFrame, qualifier); @@ -398,6 +401,8 @@ describe("Runtime endpoint flow", () => { `agentcore → runtime → endpoint → get → runtime-123 → ${qualifier}`, ); await waitForText(r.lastFrame, '"targetVersion"'); + expect(r.lastFrame()).not.toContain("$metadata"); + expect(r.lastFrame()).not.toContain("endpoint-request-metadata"); await waitFor(() => core.runtime.calls.some( (call) => diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index c570650b7..e16b3887e 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; +import { withoutSdkMetadata } from "../../components/withoutSdkMetadata"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -18,7 +19,7 @@ export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) { breadcrumb={["agentcore", "runtime", "endpoint", "get", runtimeId ?? "", qualifier ?? ""]} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} - data={detail.data} + data={withoutSdkMetadata(detail.data)} loadingLabel={`Loading endpoint ${qualifier ?? ""} for Runtime ${runtimeId ?? ""}…`} onRetry={() => void detail.refetch()} /> diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index d6767fc73..c0965369f 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -10,6 +10,7 @@ import { Divider } from "../../../components/ui/divider/Divider.js"; import { Spinner } from "../../../components/ui/spinner"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; +import { withoutSdkMetadata } from "../components/withoutSdkMetadata"; const ACTIONS = [ { @@ -89,7 +90,9 @@ export function RuntimeGetScreen({ ctx, core }: ScreenProps) { items={{ id: detail.data.agentRuntimeId ?? "", status: detail.data.status ?? "", + ...(detail.data.failureReason ? { failureReason: detail.data.failureReason } : {}), version: detail.data.agentRuntimeVersion ?? "", + protocol: detail.data.protocolConfiguration?.serverProtocol ?? "-", network: detail.data.networkConfiguration?.networkMode ?? "-", arn: detail.data.agentRuntimeArn ?? "", }} @@ -135,7 +138,7 @@ export function RuntimeGetJsonScreen({ ctx, core }: ScreenProps) { breadcrumb={["agentcore", "runtime", "get", runtimeId ?? "", "json"]} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} - data={detail.data} + data={withoutSdkMetadata(detail.data)} loadingLabel="Loading Runtime…" onRetry={() => void detail.refetch()} /> diff --git a/src/handlers/runtime/index.tsx b/src/handlers/runtime/index.tsx index 200d403bc..dfe2b73bb 100644 --- a/src/handlers/runtime/index.tsx +++ b/src/handlers/runtime/index.tsx @@ -1,16 +1,15 @@ +import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; import { renderTui } from "../../tui"; import type { AppIO, Core } from "../types"; import { createRuntimeEndpointHandler } from "./endpoint"; import { createGetRuntimeHandler } from "./get"; -import { RuntimeInteractiveKey, withRuntimeInteractive } from "./interactive"; import { createListRuntimesHandler } from "./list"; import { createRuntimeVersionHandler } from "./version"; export function createRuntimeHandler(core: Core, io: AppIO): Router { return new Router("runtime", "inspect AgentCore Runtimes") - .groupFlags(RuntimeInteractiveKey) - .use(withRuntimeInteractive(core, io)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) .handler(createGetRuntimeHandler(core)) .handler(createListRuntimesHandler(core)) diff --git a/src/handlers/runtime/interactive.test.tsx b/src/handlers/runtime/interactive.test.tsx deleted file mode 100644 index c8b0af710..000000000 --- a/src/handlers/runtime/interactive.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { runtimeTuiPath } from "./interactive"; - -describe("runtimeTuiPath", () => { - test.each([ - ["/agentcore/runtime", {}, "/agentcore/runtime"], - ["/agentcore/runtime/version", {}, "/agentcore/runtime/version"], - ["/agentcore/runtime/endpoint", {}, "/agentcore/runtime/endpoint"], - ["/agentcore/runtime/get", {}, "/agentcore/runtime/list"], - [ - "/agentcore/runtime/get", - { id: "runtime/id with spaces" }, - "/agentcore/runtime/get/runtime%2Fid%20with%20spaces", - ], - ["/agentcore/runtime/list", {}, "/agentcore/runtime/list"], - ["/agentcore/runtime/version/list", {}, "/agentcore/runtime/version/list"], - [ - "/agentcore/runtime/version/list", - { id: "runtime/id with spaces" }, - "/agentcore/runtime/version/list/runtime%2Fid%20with%20spaces", - ], - ["/agentcore/runtime/version/get", {}, "/agentcore/runtime/version/list"], - [ - "/agentcore/runtime/version/get", - { id: "runtime/id with spaces" }, - "/agentcore/runtime/version/list/runtime%2Fid%20with%20spaces", - ], - [ - "/agentcore/runtime/version/get", - { id: "runtime/id with spaces", version: "v/1 + beta" }, - "/agentcore/runtime/version/get/runtime%2Fid%20with%20spaces/v%2F1%20%2B%20beta", - ], - ["/agentcore/runtime/endpoint/list", {}, "/agentcore/runtime/endpoint/list"], - [ - "/agentcore/runtime/endpoint/list", - { id: "runtime/id with spaces" }, - "/agentcore/runtime/endpoint/list/runtime%2Fid%20with%20spaces", - ], - ["/agentcore/runtime/endpoint/get", {}, "/agentcore/runtime/endpoint/list"], - [ - "/agentcore/runtime/endpoint/get", - { id: "runtime/id with spaces" }, - "/agentcore/runtime/endpoint/list/runtime%2Fid%20with%20spaces", - ], - [ - "/agentcore/runtime/endpoint/get", - { id: "runtime/id with spaces", qualifier: "prod/us east" }, - "/agentcore/runtime/endpoint/get/runtime%2Fid%20with%20spaces/prod%2Fus%20east", - ], - ] as const)("maps %s", (commandPath, flags, expected) => { - expect(runtimeTuiPath(commandPath, flags)).toBe(expected); - }); - - test("ignores empty and non-string selectors", () => { - expect(runtimeTuiPath("/agentcore/runtime/get", { id: "" })).toBe("/agentcore/runtime/list"); - expect(runtimeTuiPath("/agentcore/runtime/get", { id: 42 })).toBe("/agentcore/runtime/list"); - }); - - test("rejects a version without a Runtime ID", () => { - expect(() => runtimeTuiPath("/agentcore/runtime/version/get", { version: "1" })).toThrow( - /version.*id/i, - ); - }); - - test("rejects a qualifier without a Runtime ID", () => { - expect(() => runtimeTuiPath("/agentcore/runtime/endpoint/get", { qualifier: "prod" })).toThrow( - /qualifier.*id/i, - ); - }); - - test("rejects unknown command paths", () => { - expect(() => runtimeTuiPath("/agentcore/runtime/unknown", {})).toThrow( - /unknown Runtime command path/, - ); - }); -}); diff --git a/src/handlers/runtime/interactive.tsx b/src/handlers/runtime/interactive.tsx deleted file mode 100644 index ad9b9783f..000000000 --- a/src/handlers/runtime/interactive.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import z from "zod"; -import { renderTuiAt } from "../../tui"; -import { globalFlag, PathKey, type Middleware } from "../../router"; -import { JsonKey } from "../keys"; -import type { AppIO, Core } from "../types"; - -export const RuntimeInteractiveKey = globalFlag( - "interactive", - "open the Runtime TUI", - z.boolean().default(false), -); - -function selector(flags: Record, name: string): string | undefined { - const value = flags[name]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -export function runtimeTuiPath(commandPath: string, flags: Record): string { - const id = selector(flags, "id"); - const version = selector(flags, "version"); - const qualifier = selector(flags, "qualifier"); - const encodedId = id === undefined ? undefined : encodeURIComponent(id); - - switch (commandPath) { - case "/agentcore/runtime": - case "/agentcore/runtime/version": - case "/agentcore/runtime/endpoint": - case "/agentcore/runtime/list": - return commandPath; - case "/agentcore/runtime/get": - return encodedId === undefined - ? "/agentcore/runtime/list" - : `/agentcore/runtime/get/${encodedId}`; - case "/agentcore/runtime/version/list": - return encodedId === undefined - ? "/agentcore/runtime/version/list" - : `/agentcore/runtime/version/list/${encodedId}`; - case "/agentcore/runtime/version/get": - if (version !== undefined && encodedId === undefined) { - throw new TypeError("Runtime version requires a Runtime id"); - } - if (encodedId === undefined) return "/agentcore/runtime/version/list"; - if (version === undefined) return `/agentcore/runtime/version/list/${encodedId}`; - return `/agentcore/runtime/version/get/${encodedId}/${encodeURIComponent(version)}`; - case "/agentcore/runtime/endpoint/list": - return encodedId === undefined - ? "/agentcore/runtime/endpoint/list" - : `/agentcore/runtime/endpoint/list/${encodedId}`; - case "/agentcore/runtime/endpoint/get": - if (qualifier !== undefined && encodedId === undefined) { - throw new TypeError("Runtime qualifier requires a Runtime id"); - } - if (encodedId === undefined) return "/agentcore/runtime/endpoint/list"; - if (qualifier === undefined) return `/agentcore/runtime/endpoint/list/${encodedId}`; - return `/agentcore/runtime/endpoint/get/${encodedId}/${encodeURIComponent(qualifier)}`; - default: - throw new TypeError(`unknown Runtime command path: ${commandPath}`); - } -} - -export function withRuntimeInteractive(core: Core, io: AppIO): Middleware { - return (handler) => ({ - name: () => handler.name(), - description: () => handler.description(), - flags: () => handler.flags(), - arguments: () => handler.arguments(), - children: () => handler.children(), - handle: async (ctx, flags, args) => { - if (!ctx.require(RuntimeInteractiveKey)) { - await handler.handle(ctx, flags, args); - return; - } - if (ctx.require(JsonKey)) { - throw new TypeError("--interactive cannot be combined with --json"); - } - - await renderTuiAt(runtimeTuiPath(ctx.require(PathKey), flags), ctx, core, io); - }, - }); -} diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index d7c94ffe8..f587f27b2 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -50,6 +50,7 @@ function getRuntimeResponse( roleArn: "arn:aws:iam::123456789012:role/runtime-role", networkConfiguration: { networkMode: "PUBLIC" }, status: "READY", + protocolConfiguration: { serverProtocol: "HTTP" }, lifecycleConfiguration: { idleRuntimeSessionTimeout: 900, maxLifetime: 28_800, @@ -588,7 +589,9 @@ describe("runtime hub", () => { expect(r.lastFrame()).toContain("runtime-123"); expect(r.lastFrame()).toContain("READY"); expect(r.lastFrame()).toMatch(/version\s+7/); + expect(r.lastFrame()).toMatch(/protocol\s+HTTP/); expect(r.lastFrame()).toMatch(/network\s+PUBLIC/); + expect(r.lastFrame()).not.toContain("failureReason"); await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntime")); expect(core.runtime.calls.find((call) => call.method === "getRuntime")).toEqual({ method: "getRuntime", @@ -602,6 +605,28 @@ describe("runtime hub", () => { }); }); + test("shows the Runtime failure reason only when the service provides one", async () => { + const healthyCore = new TestCoreClient(); + healthyCore.runtime.setGetResponse(getRuntimeResponse()); + const healthy = renderScreen("/agentcore/runtime/get/runtime-123", { core: healthyCore }); + + await waitForText(healthy.lastFrame, "show the full JSON definition"); + expect(healthy.lastFrame()).not.toContain("failureReason"); + healthy.unmount(); + + const failedCore = new TestCoreClient(); + failedCore.runtime.setGetResponse( + getRuntimeResponse({ + status: "CREATE_FAILED", + failureReason: "Image could not be pulled", + }), + ); + const failed = renderScreen("/agentcore/runtime/get/runtime-123", { core: failedCore }); + + await waitForText(failed.lastFrame, "Image could not be pulled"); + expect(failed.lastFrame()).toMatch(/failureReason\s+Image could not be pulled/); + }); + test("renders exactly the read-only detail, versions, and endpoints actions", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse(getRuntimeResponse()); @@ -687,13 +712,14 @@ describe("runtime hub", () => { test("opens complete Runtime JSON from the detail action and scrolls", async () => { const core = new TestCoreClient(); - core.runtime.setGetResponse( - getRuntimeResponse({ + core.runtime.setGetResponse({ + $metadata: { requestId: "runtime-request-metadata" }, + ...getRuntimeResponse({ environmentVariables: Object.fromEntries( Array.from({ length: 30 }, (_, index) => [`VARIABLE_${index}`, `value-${index}`]), ), }), - ); + } as GetAgentRuntimeResponse); const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); await waitForText(r.lastFrame, "show the full JSON definition"); @@ -703,6 +729,8 @@ describe("runtime hub", () => { expect(frame).toContain('"agentRuntimeId"'); expect(frame).toContain('"networkConfiguration"'); expect(frame).toContain('"lifecycleConfiguration"'); + expect(frame).not.toContain("$metadata"); + expect(frame).not.toContain("runtime-request-metadata"); expect(frame).not.toContain('"VARIABLE_29"'); for (let index = 0; index < 20; index += 1) await r.press("down"); for (let index = 0; index < 20; index += 1) await r.write("j"); diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index ae59f05bc..01e769e8f 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -64,6 +64,7 @@ describe("runtime command hierarchy", () => { }); const runtime = root.children().find((child) => child.name() === "runtime"); + expect(runtime?.flags().map((flag) => flag.name)).not.toContain("interactive"); expect(runtime?.children().map((child) => child.name())).toEqual([ "get", "list", @@ -97,30 +98,15 @@ describe("runtime command hierarchy", () => { ); }); -describe("runtime interactive mode", () => { - test("rejects --interactive on non-TTY streams before calling Runtime Core", async () => { - const { core, route } = testRuntimeCommand(); - - await expect(route(["runtime", "list", "--interactive"])).rejects.toThrow( - "interactive mode requires a TTY on stdin and stdout", - ); - expect(core.runtime.calls).toEqual([]); - }); - - test("rejects --interactive with --json before rendering or calling Runtime Core", async () => { - const { core, route } = testRuntimeCommand(true); - - await expect(route(["runtime", "list", "--interactive", "--json"])).rejects.toThrow( - /interactive.*json/i, - ); - expect(core.runtime.calls).toEqual([]); - }); - +describe("runtime TUI dispatch", () => { test.each([ - ["runtime", ["runtime", "--interactive"]], - ["runtime version", ["runtime", "version", "--interactive"]], - ["runtime endpoint", ["runtime", "endpoint", "--interactive"]], - ] as const)("keeps explicit interactive mode on the %s menu", async (_label, args) => { + ["get", ["runtime", "get"]], + ["list", ["runtime", "list"]], + ["version get", ["runtime", "version", "get"]], + ["version list", ["runtime", "version", "list"]], + ["endpoint get", ["runtime", "endpoint", "get"]], + ["endpoint list", ["runtime", "endpoint", "list"]], + ] as const)("opens the TUI for a bare Runtime %s leaf", async (_label, args) => { const { core, route } = testRuntimeCommand(); await expect(route([...args])).rejects.toThrow( @@ -129,34 +115,17 @@ describe("runtime interactive mode", () => { expect(core.runtime.calls).toEqual([]); }); - test.each([ - ["version", ["runtime", "version", "get", "--version", "1", "--interactive"], /version.*id/i], - [ - "qualifier", - ["runtime", "endpoint", "get", "--qualifier", "prod", "--interactive"], - /qualifier.*id/i, - ], - ] as const)( - "rejects a %s without an ID before rendering or calling Runtime Core", - async (_label, args, message) => { - const { core, route } = testRuntimeCommand(); - - await expect(route([...args])).rejects.toThrow(message); - expect(core.runtime.calls).toEqual([]); - }, - ); - - test("keeps runtime list without own flags headless", async () => { + test("keeps Runtime leaves with operation flags headless", async () => { const { core, io, route } = testRuntimeCommand(); core.runtime.setListResponse({ agentRuntimes: [], nextToken: "page-2" }); - await route(["runtime", "list"]); + await route(["runtime", "list", "--max-results", "1"]); expect(JSON.parse(io.stdout())).toEqual({ agentRuntimes: [], nextToken: "page-2" }); expect(core.runtime.calls).toEqual([ { method: "listRuntimes", - args: [undefined, undefined, { region: REGION, endpointUrl: undefined }], + args: [undefined, 1, { region: REGION, endpointUrl: undefined }], }, ]); }); @@ -304,9 +273,12 @@ describe("runtime read-only commands", () => { /--qualifier/, ], ["runtime endpoint list", ["runtime", "endpoint", "list"], /--id/], - ] as const)("rejects a missing required selector for `%s`", async (_label, args, message) => { - expect(run([...args])).rejects.toThrow(message); - }); + ] as const)( + "rejects a missing required selector for headless `%s`", + async (_label, args, message) => { + expect(run([...args, "--json"])).rejects.toThrow(message); + }, + ); test.each([ [ diff --git a/src/handlers/runtime/version/get/screen.tsx b/src/handlers/runtime/version/get/screen.tsx index 924c5ba36..8174b1b1e 100644 --- a/src/handlers/runtime/version/get/screen.tsx +++ b/src/handlers/runtime/version/get/screen.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; +import { withoutSdkMetadata } from "../../components/withoutSdkMetadata"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -18,7 +19,7 @@ export function RuntimeGetVersionScreen({ ctx, core }: ScreenProps) { breadcrumb={["agentcore", "runtime", "version", "get", runtimeId ?? "", version ?? ""]} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} - data={detail.data} + data={withoutSdkMetadata(detail.data)} loadingLabel={`Loading version ${version ?? ""} for Runtime ${runtimeId ?? ""}…`} onRetry={() => void detail.refetch()} /> diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 1909b2090..923150c5a 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -390,13 +390,18 @@ describe("Runtime version flow", () => { core.runtime.setListVersionsResponse({ agentRuntimes: [runtime({ agentRuntimeVersion: "9" })], }); - core.runtime.setGetVersionResponse(getVersionResponse({ agentRuntimeVersion: "9" })); + core.runtime.setGetVersionResponse({ + $metadata: { requestId: "version-request-metadata" }, + ...getVersionResponse({ agentRuntimeVersion: "9" }), + } as GetAgentRuntimeResponse); const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); await waitForText(r.lastFrame, "9"); await r.press("return"); await waitForText(r.lastFrame, "agentcore → runtime → version → get → runtime-123 → 9"); await waitForText(r.lastFrame, '"networkConfiguration"'); + expect(r.lastFrame()).not.toContain("$metadata"); + expect(r.lastFrame()).not.toContain("version-request-metadata"); await waitFor(() => core.runtime.calls.some( (call) => From a25dd750e1d0b2d3d51c30f371a48b29e7f22df4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 01:14:56 +0000 Subject: [PATCH 19/38] test: simplify Runtime TUI integration coverage --- src/components/RouterScreen.test.tsx | 274 ++++++------------- src/handlers/runtime/runtime.screen.test.tsx | 30 -- 2 files changed, 79 insertions(+), 225 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 7cc565564..27c079bd7 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -95,8 +95,6 @@ function runtimeCore(): TestCoreClient { const core = new TestCoreClient(); core.runtime.setListResponse({ agentRuntimes: [runtime()] }); core.runtime.setGetResponse(getRuntimeResponse()); - core.runtime.setListVersionsResponse({ agentRuntimes: [runtime()] }); - core.runtime.setGetVersionResponse(getRuntimeResponse()); core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }); core.runtime.setGetEndpointResponse(getEndpointResponse()); return core; @@ -299,205 +297,91 @@ describe("navigation", () => { r.unmount(); }); - test.each([ - [ - "versions", - 1, - "agentcore → runtime → version → list → runtime-123", - "2026-07-20T12:34:56.000Z", - "agentcore → runtime → version → get → runtime-123 → 7", - '"agentRuntimeVersion"', - "listRuntimeVersions", - "getRuntimeVersion", - ], - [ - "endpoints", - 2, - "agentcore → runtime → endpoint → list → runtime-123", - "prod", - "agentcore → runtime → endpoint → get → runtime-123 → prod", - '"agentRuntimeEndpointArn"', - "listRuntimeEndpoints", - "getRuntimeEndpoint", - ], - ] as const)( - "navigates from Root through Runtime %s and escapes each explicit boundary", - async ( - _destination, - hubDownPresses, - scopedListBreadcrumb, - scopedListValue, - jsonBreadcrumb, - jsonField, - listMethod, - getMethod, - ) => { - const core = runtimeCore(); - const r = renderScreen("/agentcore", { - core, - ctx: runtimeContext(core), - }); + test("navigates from Root through Runtime endpoints and escapes each boundary", async () => { + const core = runtimeCore(); + const r = renderScreen("/agentcore", { + core, + ctx: runtimeContext(core), + }); - await waitForText(r.lastFrame, "❯ harness"); - await r.press("down"); - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); - - await r.press("down"); - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → list"); - await waitForText(r.lastFrame, "checkout"); - - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); - await waitForText(r.lastFrame, "show the full JSON definition"); - - for (let index = 0; index < hubDownPresses; index += 1) { - await r.press("down"); - } - await r.press("return"); - await waitForText(r.lastFrame, scopedListBreadcrumb); - await waitForText(r.lastFrame, scopedListValue); - - await r.press("return"); - await waitForText(r.lastFrame, jsonBreadcrumb); - await waitForText(r.lastFrame, jsonField); - - await r.press("escape"); - await waitForText(r.lastFrame, scopedListBreadcrumb); - await waitForText(r.lastFrame, scopedListValue); - await r.press("escape"); - await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); - await waitForText(r.lastFrame, "show the full JSON definition"); - await r.press("escape"); - await waitForText(r.lastFrame, "agentcore → runtime → list"); - await waitForText(r.lastFrame, "checkout"); - await r.press("escape"); - await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); - await r.press("escape"); - await waitForText(r.lastFrame, "the platform for production AI agents"); - expect(r.lastFrame()).toContain("❯ harness"); - - const callsAtRoot = core.runtime.calls.length; - await r.press("escape"); - expect(r.lastFrame()).toContain("❯ harness"); - expect(core.runtime.calls).toHaveLength(callsAtRoot); - - expectEndpointPropagation(core, ["listRuntimes", "getRuntime", listMethod, getMethod]); - }, - ); + await waitForText(r.lastFrame, "❯ harness"); + await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); - test.each([ - [ - "versions", - "version", - "2026-07-20T12:34:56.000Z", - "agentcore → runtime → version → get → runtime-123 → 7", - '"agentRuntimeVersion"', - ], - [ - "endpoints", - "endpoint", - "prod", - "agentcore → runtime → endpoint → get → runtime-123 → prod", - '"agentRuntimeEndpointArn"', - ], - ] as const)( - "reverses the direct Runtime %s get flow through its actual history", - async (_destination, resource, scopedListValue, jsonBreadcrumb, jsonField) => { - const core = runtimeCore(); - const r = renderScreen(`/agentcore/runtime/${resource}`, { - core, - ctx: runtimeContext(core), - }); + await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → list"); + await waitForText(r.lastFrame, "checkout"); + + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); + await waitForText(r.lastFrame, "show the full JSON definition"); + + await r.press("down"); + await r.press("down"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); + await waitForText(r.lastFrame, "prod"); + + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → runtime → endpoint → get → runtime-123 → prod"); + await waitForText(r.lastFrame, '"agentRuntimeEndpointArn"'); - await waitForText(r.lastFrame, `agentcore → runtime → ${resource}`); - await r.press("return"); - await waitForText(r.lastFrame, "checkout"); - - await r.press("return"); - await waitForText(r.lastFrame, `agentcore → runtime → ${resource} → list → runtime-123`); - await waitForText(r.lastFrame, scopedListValue); - - await r.press("return"); - await waitForText(r.lastFrame, jsonBreadcrumb); - await waitForText(r.lastFrame, jsonField); - - await r.press("escape"); - await waitForText(r.lastFrame, `agentcore → runtime → ${resource} → list → runtime-123`); - - await tick(50); - await r.press("escape"); - await tick(100); - const parentFrame = r.lastFrame() ?? ""; - expect(parentFrame).toContain(`agentcore → runtime → ${resource} → list`); - expect(parentFrame).not.toContain(`agentcore → runtime → ${resource} → list → runtime-123`); - expect(parentFrame).toContain("checkout"); - - await r.press("escape"); - await waitForText( - r.lastFrame, - `agentcore → runtime → ${resource} → inspect AgentCore Runtime ${resource}s`, + await r.press("escape"); + await waitFor(() => { + const frame = r.lastFrame() ?? ""; + return ( + frame.includes("agentcore → runtime → endpoint → list → runtime-123") && + !frame.includes("agentcore → runtime → endpoint → get") ); - }, - ); + }); + await tick(50); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); + await tick(50); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → list"); + await tick(50); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); + await r.press("escape"); + await waitForText(r.lastFrame, "the platform for production AI agents"); + + expectEndpointPropagation(core, [ + "listRuntimes", + "getRuntime", + "listRuntimeEndpoints", + "getRuntimeEndpoint", + ]); + }); }); describe("Runtime TUI exit", () => { - test.each([ - [ - "Runtime list", + test("Ctrl+C exits a production Runtime list and ignores input after exit", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [runtime()], + nextToken: "page-2", + }); + const { streams, stdin } = ttyTestIO(); + const renderPromise = renderTuiAt( "/agentcore/runtime/list", - "listRuntimes", - 0, - (core: TestCoreClient) => - core.runtime.setListResponse({ - agentRuntimes: [runtime()], - nextToken: "page-2", - }), - ], - [ - "version list", - "/agentcore/runtime/version/list/runtime-123", - "listRuntimeVersions", - 1, - (core: TestCoreClient) => - core.runtime.setListVersionsResponse({ - agentRuntimes: [runtime()], - nextToken: "page-2", - }), - ], - [ - "endpoint list", - "/agentcore/runtime/endpoint/list/runtime-123", - "listRuntimeEndpoints", - 1, - (core: TestCoreClient) => - core.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint()], - nextToken: "page-2", - }), - ], - ] as const)( - "Ctrl+C exits the production %s and ignores paging input after exit", - async (_label, path, method, tokenIndex, configure) => { - const core = new TestCoreClient(); - configure(core); - const { streams, stdin } = ttyTestIO(); - const renderPromise = renderTuiAt(path, runtimeContext(core), core, streams.io); - const methodCalls = () => core.runtime.calls.filter((call) => call.method === method); - - await waitFor(() => methodCalls().length > 0 && streams.stdout().includes("page 1 · more →")); - await tick(); - const callsBeforeExit = methodCalls().length; - - stdin.write(String.fromCharCode(3)); - await expect(renderPromise).resolves.toBeUndefined(); - - stdin.write("l"); - await tick(50); - expect(methodCalls()).toHaveLength(callsBeforeExit); - expect(methodCalls().some((call) => call.args[tokenIndex] === "page-2")).toBe(false); - }, - ); + runtimeContext(core), + core, + streams.io, + ); + const listCalls = () => core.runtime.calls.filter((call) => call.method === "listRuntimes"); + + await waitFor(() => listCalls().length > 0 && streams.stdout().includes("page 1 · more →")); + const callsBeforeExit = listCalls().length; + + stdin.write(String.fromCharCode(3)); + await expect(renderPromise).resolves.toBeUndefined(); + + stdin.write("l"); + await tick(50); + expect(listCalls()).toHaveLength(callsBeforeExit); + expect(listCalls().some((call) => call.args[0] === "page-2")).toBe(false); + }); }); diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index f587f27b2..a6885d0a3 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -82,36 +82,6 @@ async function waitForRuntimeMenu(lastFrame: () => string | undefined): Promise< ); } -describe("runtime test client", () => { - test("configures list responses and records calls", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ agentRuntimes: [], nextToken: "page-2" }); - - await core.runtime.listRuntimes(undefined, 20, { region: "us-east-1" }); - - expect(core.runtime.calls).toEqual([ - { - method: "listRuntimes", - args: [undefined, 20, { region: "us-east-1" }], - }, - ]); - }); - - test("uses exact initial, explicit, and default resize dimensions", async () => { - const r = renderScreen("/agentcore/runtime"); - await waitForText(r.lastFrame, "agentcore → runtime"); - await tick(); - - expect(frameSize(r.lastFrame()!)).toEqual({ columns: 100, rows: 40 }); - - await r.resize(60, 12); - expect(frameSize(r.lastFrame()!)).toEqual({ columns: 60, rows: 12 }); - - await r.resize(70); - expect(frameSize(r.lastFrame()!)).toEqual({ columns: 70, rows: 40 }); - }); -}); - describe("runtime menus", () => { test("renders the Runtime command menu", async () => { const r = renderScreen("/agentcore/runtime"); From 6b7e4a25e02ddb5330ac69b750583b04038b471d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 01:36:02 +0000 Subject: [PATCH 20/38] refactor: share token-paged table picker --- src/components/EndpointPicker.tsx | 114 +++--- src/components/HarnessPicker.tsx | 105 ++---- src/components/TokenPagedTablePicker.test.tsx | 307 ++++++++++++++++ src/components/TokenPagedTablePicker.tsx | 132 +++++++ src/components/VersionPicker.tsx | 108 ++---- .../endpoint/list/list.screen.test.tsx | 107 +++--- .../harness/list/list.screen.test.tsx | 128 +------ .../harness/version/list/list.screen.test.tsx | 138 ++++--- .../components/RuntimeEndpointPicker.tsx | 162 +++------ .../runtime/components/RuntimePicker.tsx | 153 +++----- .../components/RuntimeVersionPicker.tsx | 145 +++----- .../runtime/endpoint/endpoint.screen.test.tsx | 182 +--------- src/handlers/runtime/runtime.screen.test.tsx | 343 +----------------- .../runtime/version/version.screen.test.tsx | 173 +-------- 14 files changed, 889 insertions(+), 1408 deletions(-) create mode 100644 src/components/TokenPagedTablePicker.test.tsx create mode 100644 src/components/TokenPagedTablePicker.tsx diff --git a/src/components/EndpointPicker.tsx b/src/components/EndpointPicker.tsx index 9231093ef..366586a60 100644 --- a/src/components/EndpointPicker.tsx +++ b/src/components/EndpointPicker.tsx @@ -1,14 +1,8 @@ -import { Text, useWindowSize } from "ink"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; -import { DataTable } from "./ui/data-table"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; -import { usePagedList } from "./usePagedList"; -import { Spinner } from "./ui/spinner"; -import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; +import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; // EndpointRow is the flat, display-ready shape the table renders. interface EndpointRow extends Record { @@ -57,73 +51,53 @@ export function EndpointPicker({ onEscape, }: EndpointPickerProps) { const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); const navigate = useNavigate(); - const paging = usePagedList(); - - const list = useQuery({ - queryKey: ["harness-endpoints", opts.region, harnessId, paging.pageSize, paging.token], - queryFn: () => - core.harness.listHarnessEndpoints(harnessId, paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - const nextToken = list.data?.nextToken; - // Pagination surfaces only once a response reports more pages (nextToken). - const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const goBack = onEscape ?? (() => navigate(-1)); return ( - - {list.isPending ? ( - - ) : list.isError ? ( - Error: {(list.error as Error).message} - ) : !paginated && (list.data.endpoints ?? []).length === 0 ? ( - This harness has no endpoints yet. - ) : ( - <> - { - if (row.endpointName) onSelect(row.endpointName); - }} - onEscape={onEscape ?? (() => navigate(-1))} - onPrevPage={paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={nextToken ? () => paging.next(nextToken) : undefined} - /> - {paginated && ( - - page {paging.pageIndex + 1} - {nextToken ? " · more →" : ""} - - )} - - )} - + queryKey={["harness-endpoints", opts.region, harnessId]} + loadPage={async (token, pageSize) => { + const response = await core.harness.listHarnessEndpoints(harnessId, token, pageSize, opts); + return { + items: response.endpoints ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={(terminalColumns) => { + const liveWidth = 8; + const showTarget = terminalColumns >= 60; + const showStatus = terminalColumns >= 70; + const showUpdatedAt = terminalColumns >= 90; + const targetWidth = showTarget ? 8 : 0; + const statusWidth = showStatus ? 20 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const nameWidth = Math.max( + 12, + terminalColumns - 2 - liveWidth - targetWidth - statusWidth - updatedAtWidth, + ); + return [ + { key: "endpointName", header: "name", width: nameWidth }, + { key: "liveVersion", header: "live", width: liveWidth }, + ...(showTarget + ? [{ key: "targetVersion" as const, header: "target", width: targetWidth }] + : []), + ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), + ...(showUpdatedAt + ? [{ key: "updatedAt" as const, header: "updatedAt", width: updatedAtWidth }] + : []), + ]; + }} + getValue={(row) => row.endpointName} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading endpoints…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This harness has no endpoints yet." + emptyPageMessage={`No endpoints on this page for harness ${harnessId}.`} + /> ); } diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index f5042b8e7..f16bf4032 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -1,14 +1,8 @@ -import { Text, useWindowSize } from "ink"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import type { HarnessSummary } from "@aws-sdk/client-bedrock-agentcore-control"; -import { DataTable } from "./ui/data-table"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; -import { usePagedList } from "./usePagedList"; -import { Spinner } from "./ui/spinner"; -import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; +import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; // HarnessRow is the flat, display-ready shape the table renders. It also satisfies // DataTable's `T extends Record` constraint, which the SDK's @@ -56,69 +50,48 @@ export function HarnessPicker({ onSelect, }: HarnessPickerProps) { const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); const navigate = useNavigate(); - const paging = usePagedList(); - - const list = useQuery({ - queryKey: ["harnesses", opts.region, paging.pageSize, paging.token], - queryFn: () => core.harness.listHarnesses(paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - const nextToken = list.data?.nextToken; - // Pagination surfaces only once a response reports more pages (nextToken). - const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); return ( - - {list.isPending ? ( - - ) : list.isError ? ( - Error: {(list.error as Error).message} - ) : ( - <> - { - if (row.harnessId) onSelect(row.harnessId); - }} - onEscape={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))} - onPrevPage={paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={nextToken ? () => paging.next(nextToken) : undefined} - /> - {paginated && ( - - page {paging.pageIndex + 1} - {nextToken ? " · more →" : ""} - - )} - - )} - + queryKey={["harnesses", opts.region]} + loadPage={async (token, pageSize) => { + const response = await core.harness.listHarnesses(token, pageSize, opts); + return { + items: response.harnesses ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={(terminalColumns) => { + const versionWidth = 10; + const showStatus = terminalColumns >= 70; + const showUpdatedAt = terminalColumns >= 90; + const statusWidth = showStatus ? 20 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const nameWidth = Math.max( + 12, + terminalColumns - 2 - versionWidth - statusWidth - updatedAtWidth, + ); + return [ + { key: "harnessName", header: "name", width: nameWidth }, + { key: "harnessVersion", header: "version", width: versionWidth }, + ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), + ...(showUpdatedAt + ? [{ key: "updatedAt" as const, header: "updatedAt", width: updatedAtWidth }] + : []), + ]; + }} + getValue={(row) => row.harnessId} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading harnesses…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No harnesses found." + emptyPageMessage="No harnesses on this page." + /> ); } diff --git a/src/components/TokenPagedTablePicker.test.tsx b/src/components/TokenPagedTablePicker.test.tsx new file mode 100644 index 000000000..0c216b697 --- /dev/null +++ b/src/components/TokenPagedTablePicker.test.tsx @@ -0,0 +1,307 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + HarnessSummary, + ListHarnessesResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { QueryClient } from "@tanstack/react-query"; +import { cleanupScreens, renderScreen, TestCoreClient, waitFor, waitForText } from "../testing"; + +afterEach(cleanupScreens); + +function harness(overrides: Partial = {}): HarnessSummary { + return { + harnessId: "MyHarness-abc123", + harnessName: "MyHarness", + arn: "arn:aws:bedrock-agentcore:us-east-1:123:harness/MyHarness-abc123", + createdAt: new Date("2026-04-22T21:53:06.235Z"), + updatedAt: new Date("2026-04-22T21:53:27.062Z"), + harnessVersion: "1", + status: "READY", + ...overrides, + }; +} + +function coreWith(harnesses: HarnessSummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.harness.setListResponse({ harnesses }); + return core; +} + +function getResponse(summary: HarnessSummary) { + return { harness: summary } as Parameters[0]; +} + +function frameSize(frame: string): { columns: number; rows: number } { + const lines = frame.split("\n"); + return { + columns: Math.max(...lines.map((line) => line.length)), + rows: lines.length, + }; +} + +describe("token-paged table picker contract", () => { + test("retries a failed query", async () => { + const core = new TestCoreClient(); + core.harness.setError(new Error("access denied")); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "access denied"); + expect(r.lastFrame()).toContain("[r] retry"); + + core.harness.setError(undefined); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "recovered" })], + }); + await r.write("r"); + await waitForText(r.lastFrame, "recovered"); + }); + + test("returns to the previous page after a later-page query fails", async () => { + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "page-one" })], + nextToken: "t2", + }); + const listHarnesses = core.harness.listHarnesses.bind(core.harness); + core.harness.listHarnesses = async (...args) => { + if (args[0] === "t2") throw new Error("page unavailable"); + return listHarnesses(...args); + }; + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page unavailable"); + expect(r.lastFrame()).toContain("[←/h] previous page"); + + await r.write("h"); + await waitForText(r.lastFrame, "page-one"); + expect(core.harness.calls.at(-1)?.args[0]).toBeUndefined(); + }); + + test("keeps Escape active while loading", async () => { + const core = new TestCoreClient(); + const pending = Promise.withResolvers(); + core.harness.listHarnesses = async () => pending.promise; + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "Loading harnesses"); + await r.press("escape"); + await waitForText(r.lastFrame, "manage agentcore harnesses"); + }); + + test("keeps Escape active after a query fails", async () => { + const core = new TestCoreClient(); + core.harness.setError(new Error("access denied")); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "access denied"); + await r.press("escape"); + await waitForText(r.lastFrame, "manage agentcore harnesses"); + }); + + test("distinguishes first-page and later-page empty states", async () => { + const firstPage = renderScreen("/agentcore/harness/list"); + await waitForText(firstPage.lastFrame, "No harnesses found."); + expect(firstPage.lastFrame()).not.toContain("page 1"); + await firstPage.press("escape"); + await waitForText(firstPage.lastFrame, "manage agentcore harnesses"); + firstPage.unmount(); + + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "page-one" })], + nextToken: "t2", + }); + core.harness.setListResponse({ harnesses: [] }, "t2"); + const laterPage = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(laterPage.lastFrame, "page 1 · more →"); + await laterPage.write("l"); + await waitForText(laterPage.lastFrame, "No harnesses on this page."); + expect(laterPage.lastFrame()).toContain("page 2"); + expect(laterPage.lastFrame()).not.toContain("No harnesses found."); + }); + + test("pages forward and backward using token history", async () => { + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "page-one" })], + nextToken: "t2", + }); + core.harness.setListResponse({ harnesses: [harness({ harnessName: "page-two" })] }, "t2"); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + expect(r.lastFrame()).toContain("[←→/hl] page"); + await r.write("l"); + await waitForText(r.lastFrame, "page-two"); + expect(core.harness.calls.at(-1)).toEqual({ + method: "listHarnesses", + args: ["t2", 32, { region: "us-east-1", endpointUrl: undefined }], + }); + + await r.write("h"); + await waitForText(r.lastFrame, "page-one"); + expect(r.lastFrame()).toContain("page 1"); + await r.press("escape"); + await waitForText(r.lastFrame, "manage agentcore harnesses"); + }); + + test("keeps pagination status and key hints coherent at 50x20", async () => { + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: Array.from({ length: 12 }, (_, index) => + harness({ + harnessId: `narrow-row-${index + 1}`, + harnessName: `narrow-row-${index + 1}`, + }), + ), + nextToken: "t2", + }); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "narrow-row-12"); + await r.resize(50, 20); + await waitForText(r.lastFrame, "page 1 · more →"); + const frame = r.lastFrame()!; + const lines = frame.split("\n"); + const pageLine = lines.find((line) => line.includes("page 1 · more →")); + const footerLine = lines.find((line) => line.includes("[↑↓/jk] navigate")); + + expect(frameSize(frame)).toEqual({ columns: 50, rows: 20 }); + expect(pageLine?.trim()).toBe("page 1 · more →"); + expect(pageLine).not.toContain("narrow-row"); + expect(footerLine).toContain("[enter] select"); + expect(footerLine).toContain("[esc] back"); + expect(frame).not.toContain("[←→/hl] page"); + expect(frame).not.toContain("[/] filter"); + expect(frame).not.toContain("[ctl+c] quit"); + expect(footerLine!.indexOf("[↑↓/jk] navigate")).toBeLessThan( + footerLine!.indexOf("[enter] select"), + ); + expect(footerLine!.indexOf("[enter] select")).toBeLessThan(footerLine!.indexOf("[esc] back")); + expect( + lines.filter((line) => line.includes("[") && line.includes("]")).map((line) => line.trim()), + ).toEqual([footerLine!.trim()]); + }); + + test("retains rows and disables selection and paging during a transition", async () => { + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [harness({ harnessName: "stable-page-one", harnessId: "stable-1" })], + nextToken: "t2", + }); + core.harness.setListResponse( + { harnesses: [harness({ harnessName: "settled-page-two", harnessId: "settled-2" })] }, + "t2", + ); + const nextPage = Promise.withResolvers(); + const previousPage = Promise.withResolvers(); + let holdFirstPage = false; + const listHarnesses = core.harness.listHarnesses.bind(core.harness); + core.harness.listHarnesses = async (...args) => { + const response = await listHarnesses(...args); + if (args[0] === "t2") await nextPage.promise; + if (args[0] === undefined && holdFirstPage) await previousPage.promise; + return response; + }; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const r = renderScreen("/agentcore/harness/list", { core, queryClient }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "loading page 2…"); + expect(r.lastFrame()).toContain("stable-page-one"); + + const callsDuringTransition = core.harness.calls.length; + await r.press("return"); + await r.write("l"); + await r.write("h"); + expect(core.harness.calls).toHaveLength(callsDuringTransition); + expect(core.harness.calls.some((call) => call.method === "getHarness")).toBe(false); + + nextPage.resolve(); + await waitForText(r.lastFrame, "settled-page-two"); + + holdFirstPage = true; + await r.write("h"); + await waitForText(r.lastFrame, "loading page 1…"); + expect(r.lastFrame()).toContain("stable-page-one"); + + const callsDuringRefetch = core.harness.calls.length; + await r.press("return"); + await r.write("l"); + expect(core.harness.calls).toHaveLength(callsDuringRefetch); + expect(core.harness.calls.some((call) => call.method === "getHarness")).toBe(false); + + await r.press("escape"); + await waitForText(r.lastFrame, "manage agentcore harnesses"); + previousPage.resolve(); + }); + + test("resizing resets pagination and selection", async () => { + const first = harness({ harnessName: "page-one-first", harnessId: "page-one-first" }); + const core = new TestCoreClient(); + core.harness.setListResponse({ + harnesses: [first, harness({ harnessName: "page-one-second", harnessId: "page-one-second" })], + nextToken: "t2", + }); + core.harness.setListResponse( + { + harnesses: [ + harness({ harnessName: "page-two-first", harnessId: "page-two-first" }), + harness({ harnessName: "page-two-second", harnessId: "page-two-second" }), + ], + }, + "t2", + ); + core.harness.setGetResponse(getResponse(first)); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "page 1 · more →"); + await r.write("l"); + await waitForText(r.lastFrame, "page-two-second"); + await r.press("down"); + await waitForText(r.lastFrame, "❯ page-two-second"); + + await r.resize(100, 20); + await waitForText(r.lastFrame, "page-one-first"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → harness → get → page-one-first"); + expect( + core.harness.calls.some( + (call) => call.method === "getHarness" && call.args[0] === "page-one-second", + ), + ).toBe(false); + }); + + test("filter input does not page and resets selection to its first match", async () => { + const alpha = harness({ harnessName: "alpha", harnessId: "alpha-1" }); + const beta = harness({ harnessName: "beta", harnessId: "beta-2" }); + const core = coreWith([alpha, beta]); + core.harness.setListResponse({ harnesses: [alpha, beta], nextToken: "t2" }); + core.harness.setGetResponse(getResponse(alpha)); + const r = renderScreen("/agentcore/harness/list", { core }); + + await waitForText(r.lastFrame, "beta"); + await r.press("down"); + await r.write("/"); + for (const character of "alpha") await r.write(character); + await waitForText(r.lastFrame, "/ Filter this page: alpha"); + expect( + core.harness.calls.some((call) => call.method === "listHarnesses" && call.args[0] === "t2"), + ).toBe(false); + + await r.press("return"); + await r.press("return"); + await waitFor(() => + core.harness.calls.some((call) => call.method === "getHarness" && call.args[0] === "alpha-1"), + ); + expect(r.lastFrame()).toContain("agentcore → harness → get → alpha-1"); + }); +}); diff --git a/src/components/TokenPagedTablePicker.tsx b/src/components/TokenPagedTablePicker.tsx new file mode 100644 index 000000000..9e35cfee3 --- /dev/null +++ b/src/components/TokenPagedTablePicker.tsx @@ -0,0 +1,132 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Text, useInput, useWindowSize } from "ink"; +import { Layout } from "./Layout"; +import { usePagedList } from "./usePagedList"; +import { darkTheme } from "./ui/_core.js"; +import { DataTable, type DataTableColumn } from "./ui/data-table"; +import { Spinner } from "./ui/spinner"; + +export interface TokenPage { + items: TItem[]; + nextToken?: string; +} + +export interface TokenPagedTablePickerProps> { + breadcrumb: string[]; + description?: string; + queryKey: readonly unknown[]; + loadPage: (token: string | undefined, pageSize: number) => Promise>; + toRow: (item: TItem) => TRow; + columns: (terminalColumns: number) => DataTableColumn[]; + sortRows?: (rows: TRow[]) => TRow[]; + getValue: (row: TRow) => string | undefined; + onSelect: (value: string) => void; + onBack: () => void; + loadingMessage: string; + errorMessage: (error: Error) => string; + emptyMessage: string; + emptyPageMessage: string; +} + +export function TokenPagedTablePicker>({ + breadcrumb, + description, + queryKey, + loadPage, + toRow, + columns, + sortRows, + getValue, + onSelect, + onBack, + loadingMessage, + errorMessage, + emptyMessage, + emptyPageMessage, +}: TokenPagedTablePickerProps) { + const { columns: terminalColumns } = useWindowSize(); + const paging = usePagedList(); + const list = useQuery({ + queryKey: [...queryKey, paging.pageSize, paging.token], + queryFn: () => loadPage(paging.token, paging.pageSize), + placeholderData: keepPreviousData, + }); + const nextToken = list.data?.nextToken; + const paginated = paging.pageIndex > 0 || nextToken !== undefined; + const pageTransition = list.isFetching && !list.isPending; + const mappedRows = (list.data?.items ?? []).map(toRow); + const rows = sortRows ? sortRows(mappedRows) : mappedRows; + + useInput( + (input, key) => { + if (key.escape) { + onBack(); + return; + } + if (list.isError && paging.pageIndex > 0 && (key.leftArrow || input === "h")) { + paging.prev(); + return; + } + if (input === "r" && list.isError) void list.refetch(); + }, + { isActive: list.isPending || list.isError || pageTransition }, + ); + + return ( + 0 ? [{ key: "←/h", label: "previous page" }] : []), + ...(list.isError ? [{ key: "r", label: "retry" }] : []), + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ]} + > + {list.isPending ? ( + + ) : list.isError ? ( + {errorMessage(list.error as Error)} + ) : ( + <> + { + const value = getValue(row); + if (value) onSelect(value); + }} + onEscape={onBack} + onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} + onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} + /> + {(paginated || pageTransition) && ( + + {pageTransition + ? `loading page ${paging.pageIndex + 1}…` + : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} + + )} + + )} + + ); +} diff --git a/src/components/VersionPicker.tsx b/src/components/VersionPicker.tsx index 1d38f5839..b03b79800 100644 --- a/src/components/VersionPicker.tsx +++ b/src/components/VersionPicker.tsx @@ -1,14 +1,8 @@ -import { Text, useWindowSize } from "ink"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import type { HarnessVersionSummary } from "@aws-sdk/client-bedrock-agentcore-control"; -import { DataTable } from "./ui/data-table"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; -import { usePagedList } from "./usePagedList"; -import { Spinner } from "./ui/spinner"; -import { Layout } from "./Layout"; -import { darkTheme } from "./ui/_core.js"; +import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; // VersionRow is the flat, display-ready shape the table renders. interface VersionRow extends Record { @@ -49,74 +43,46 @@ export function VersionPicker({ onSelect, }: VersionPickerProps) { const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); const navigate = useNavigate(); - const paging = usePagedList(); - - const list = useQuery({ - queryKey: ["harness-versions", opts.region, harnessId, paging.pageSize, paging.token], - queryFn: () => core.harness.listHarnessVersions(harnessId, paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - const nextToken = list.data?.nextToken; - // Pagination surfaces only once a response reports more pages (nextToken). - const paginated = paging.pageIndex > 0 || nextToken !== undefined; - - // Newest first within the page: versions are numeric strings incremented by - // UpdateHarness (the service already pages newest-first). - const rows = (list.data?.harnessVersions ?? []) - .map(toRow) - .sort((a, b) => Number(b.harnessVersion) - Number(a.harnessVersion)); + const goBack = () => navigate(-1); return ( - - {list.isPending ? ( - - ) : list.isError ? ( - Error: {(list.error as Error).message} - ) : ( - <> - { - if (row.harnessVersion) onSelect(row.harnessVersion); - }} - onEscape={() => navigate(-1)} - onPrevPage={paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={nextToken ? () => paging.next(nextToken) : undefined} - /> - {paginated && ( - - page {paging.pageIndex + 1} - {nextToken ? " · more →" : ""} - - )} - - )} - + queryKey={["harness-versions", opts.region, harnessId]} + loadPage={async (token, pageSize) => { + const response = await core.harness.listHarnessVersions(harnessId, token, pageSize, opts); + return { + items: response.harnessVersions ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={(terminalColumns) => { + const showStatus = terminalColumns >= 60; + const showCreatedAt = terminalColumns >= 90; + const statusWidth = showStatus ? 20 : 0; + const createdAtWidth = showCreatedAt ? 30 : 0; + const versionWidth = Math.max(8, terminalColumns - 2 - statusWidth - createdAtWidth); + return [ + { key: "harnessVersion", header: "version", width: versionWidth }, + ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), + ...(showCreatedAt + ? [{ key: "createdAt" as const, header: "createdAt", width: createdAtWidth }] + : []), + ]; + }} + sortRows={(rows) => + [...rows].sort((left, right) => Number(right.harnessVersion) - Number(left.harnessVersion)) + } + getValue={(row) => row.harnessVersion} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading versions…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No versions found." + emptyPageMessage={`No versions on this page for harness ${harnessId}.`} + /> ); } diff --git a/src/handlers/harness/endpoint/list/list.screen.test.tsx b/src/handlers/harness/endpoint/list/list.screen.test.tsx index 8cf3f7676..3decd4ebf 100644 --- a/src/handlers/harness/endpoint/list/list.screen.test.tsx +++ b/src/handlers/harness/endpoint/list/list.screen.test.tsx @@ -1,6 +1,12 @@ import { test, expect, describe, afterEach } from "bun:test"; import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; -import { renderScreen, waitForText, cleanupScreens, TestCoreClient } from "../../../../testing"; +import { + renderScreen, + waitForText, + waitFor, + cleanupScreens, + TestCoreClient, +} from "../../../../testing"; afterEach(cleanupScreens); @@ -57,54 +63,73 @@ describe("harness endpoint list screen", () => { r.unmount(); }); - test("renders each endpoint as a row with its versions and status", async () => { - const core = coreWithEndpoints([ - endpoint(), - endpoint({ endpointName: "staging", targetVersion: "2", status: "UPDATING" }), - ]); + test("makes one exact scoped endpoint list call", async () => { + const core = coreWithEndpoints([endpoint()]); const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "prod"); - const frame = r.lastFrame()!; - expect(frame).toContain("staging"); - expect(frame).toContain("READY"); - expect(frame).toContain("UPDATING"); + await waitFor(() => core.harness.calls.some((call) => call.method === "listHarnessEndpoints")); + expect(core.harness.calls.filter((call) => call.method === "listHarnessEndpoints")).toEqual([ + { + method: "listHarnessEndpoints", + args: [ + "MyHarness-abc123", + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); r.unmount(); }); - test("says so when the harness has no endpoints", async () => { - const core = coreWithEndpoints([]); + test("shows name and live columns when narrow and all columns when wide", async () => { + const core = coreWithEndpoints([ + endpoint({ + endpointName: "visible-endpoint", + liveVersion: "7", + targetVersion: "88", + status: "UPDATE_FAILED", + updatedAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ]); const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "no endpoints"); + await r.resize(50); + await waitForText(r.lastFrame, "visible-endpoint"); + const narrow = r.lastFrame()!; + expect(narrow).toContain("name"); + expect(narrow).toContain("live"); + expect(narrow).toContain("visible-endpoint"); + expect(narrow).toContain("7"); + expect(narrow).not.toContain("target"); + expect(narrow).not.toContain("status"); + expect(narrow).not.toContain("updatedAt"); + expect(narrow).not.toContain("88"); + expect(narrow).not.toContain("UPDATE_FAILED"); + expect(narrow).not.toContain("2026-07-18T02:00:00.000Z"); + + await r.resize(120); + await waitForText(r.lastFrame, "updatedAt"); + const wide = r.lastFrame()!; + expect(wide).toContain("name"); + expect(wide).toContain("live"); + expect(wide).toContain("target"); + expect(wide).toContain("status"); + expect(wide).toContain("updatedAt"); + expect(wide).toMatch(/visible-endpoint\s+7\s+88\s+UPDATE_FAILED/); + expect(wide).toContain("2026-07-18T02:00:00.000Z"); r.unmount(); }); - test("pages forward and back when the response has a nextToken", async () => { + test("uses harness-specific first-page wording", async () => { const core = coreWithEndpoints([]); - core.harness.setListEndpointsResponse({ - endpoints: [endpoint({ endpointName: "alpha_ep" }), endpoint({ endpointName: "beta_ep" })], - nextToken: "ep2", - }); - core.harness.setListEndpointsResponse( - { endpoints: [endpoint({ endpointName: "gamma_ep" })] }, - "ep2", - ); const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "alpha_ep"); - expect(r.lastFrame()).toContain("page 1 · more →"); - - await r.write("l"); - await waitForText(r.lastFrame, "gamma_ep"); - expect(r.lastFrame()).toContain("page 2"); - const paged = core.harness.calls.filter((c) => c.method === "listHarnessEndpoints"); - expect(paged.at(-1)!.args[1]).toBe("ep2"); - expect(paged.at(-1)!.args[2] as number).toBeGreaterThan(0); - - await r.write("h"); - await waitForText(r.lastFrame, "alpha_ep"); - expect(r.lastFrame()).toContain("page 1"); + await waitForText(r.lastFrame, "This harness has no endpoints yet."); + expect(r.lastFrame()).not.toContain("No endpoints on this page"); r.unmount(); }); @@ -122,14 +147,4 @@ describe("harness endpoint list screen", () => { expect(call.args[1]).toBe("prod"); r.unmount(); }); - - test("shows the error message when the list call fails", async () => { - const core = new TestCoreClient(); - core.harness.setError(new Error("access denied")); - const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - - await waitForText(r.lastFrame, "Error:"); - expect(r.lastFrame()).toContain("access denied"); - r.unmount(); - }); }); diff --git a/src/handlers/harness/list/list.screen.test.tsx b/src/handlers/harness/list/list.screen.test.tsx index 7ef10ffb1..b36068ead 100644 --- a/src/handlers/harness/list/list.screen.test.tsx +++ b/src/handlers/harness/list/list.screen.test.tsx @@ -52,40 +52,19 @@ describe("harness list screen", () => { const frame = r.lastFrame()!; expect(frame).toContain("beta"); expect(frame).toContain("READY"); - // Column headers are present. expect(frame).toContain("name"); + expect(frame).toContain("version"); expect(frame).toContain("status"); - r.unmount(); - }); - - test("shows a loading indicator before data arrives", async () => { - // A list response that never resolves keeps the screen pending. - const core = new TestCoreClient(); - let release: (() => void) | undefined; - const pending = new Promise((resolve) => { - release = resolve; - }); - const original = core.harness.listHarnesses.bind(core.harness); - core.harness.listHarnesses = async (...args) => { - await pending; - return original(...args); - }; - - const r = renderScreen("/agentcore/harness/list", { core }); - await waitForText(r.lastFrame, "Loading harnesses"); - expect(r.lastFrame()).toContain("Loading harnesses"); - release?.(); - r.unmount(); - }); + expect(frame).toContain("updatedAt"); - test("shows the error message when the list call fails", async () => { - const core = new TestCoreClient(); - core.harness.setError(new Error("access denied")); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "Error:"); - expect(r.lastFrame()).toContain("access denied"); - r.unmount(); + await r.resize(50, 20); + await waitForText(r.lastFrame, "alpha"); + const narrow = r.lastFrame()!; + expect(narrow).toContain("name"); + expect(narrow).toContain("version"); + expect(narrow).not.toContain("status"); + expect(narrow).not.toContain("updatedAt"); + expect(narrow).not.toContain("READY"); }); test("makes one initial list request at the 100x40 page size with context options", async () => { @@ -126,93 +105,6 @@ describe("harness list screen", () => { r.unmount(); }); - test("pages forward and back when the response has a nextToken", async () => { - const core = new TestCoreClient(); - core.harness.setListResponse({ - harnesses: [ - harness({ harnessName: "alpha_one", harnessId: "a1" }), - harness({ harnessName: "alpha_two", harnessId: "a2" }), - ], - nextToken: "t2", - }); - core.harness.setListResponse( - { harnesses: [harness({ harnessName: "beta_one", harnessId: "b1" })] }, - "t2", - ); - const r = renderScreen("/agentcore/harness/list", { core }); - - // Page 1 advertises pagination: key hint plus the status line. - await waitForText(r.lastFrame, "alpha_one"); - expect(r.lastFrame()).toContain("←→/hl"); - expect(r.lastFrame()).toContain("page 1 · more →"); - - // l → page 2, fetched with the nextToken and a terminal-derived maxResults. - await r.write("l"); - await waitForText(r.lastFrame, "beta_one"); - expect(r.lastFrame()).toContain("page 2"); - expect(r.lastFrame()).not.toContain("more →"); - const paged = core.harness.calls.filter((c) => c.method === "listHarnesses"); - expect(paged.at(-1)!.args[0]).toBe("t2"); - expect(paged.at(-1)!.args[1] as number).toBeGreaterThan(0); - - // ← → back to page 1. - await r.press("left"); - await waitForText(r.lastFrame, "alpha_one"); - expect(r.lastFrame()).toContain("page 1"); - r.unmount(); - }); - - test("no pagination hint when the listing fits one page", async () => { - const core = coreWith([harness({ harnessName: "only_one" })]); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "only_one"); - expect(r.lastFrame()).not.toContain("←→/hl"); - expect(r.lastFrame()).not.toContain("page 1"); - r.unmount(); - }); - - test("typing l into the filter does not turn the page", async () => { - const core = new TestCoreClient(); - core.harness.setListResponse({ - harnesses: [harness({ harnessName: "alpha_one", harnessId: "a1" })], - nextToken: "t2", - }); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "alpha_one"); - await r.write("/"); - await r.write("l"); - await waitForText(r.lastFrame, "/ Filter this page: l"); - expect(core.harness.calls.some((c) => c.method === "listHarnesses" && c.args[0] === "t2")).toBe( - false, - ); - r.unmount(); - }); - - test("filtering resets selection to the first matching row", async () => { - const alpha = harness({ harnessName: "alpha", harnessId: "alpha-1" }); - const core = coreWith([alpha, harness({ harnessName: "beta", harnessId: "beta-2" })]); - core.harness.setGetResponse(getResponse(alpha)); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "beta"); - await r.press("down"); - await r.write("/"); - for (const character of "alpha") await r.write(character); - await waitForText(r.lastFrame, "/ Filter this page: alpha"); - await r.press("return"); - await r.press("return"); - - await waitForText(r.lastFrame, "agentcore → harness → get → alpha-1"); - await waitFor(() => - core.harness.calls.some((call) => call.method === "getHarness" && call.args[0] === "alpha-1"), - ); - expect( - core.harness.calls.some((call) => call.method === "getHarness" && call.args[0] === "alpha-1"), - ).toBe(true); - }); - test("esc returns to the harness menu", async () => { const core = coreWith([harness()]); const r = renderScreen("/agentcore/harness/list", { core }); diff --git a/src/handlers/harness/version/list/list.screen.test.tsx b/src/handlers/harness/version/list/list.screen.test.tsx index 8cd0d38e3..af9f5b90a 100644 --- a/src/handlers/harness/version/list/list.screen.test.tsx +++ b/src/handlers/harness/version/list/list.screen.test.tsx @@ -47,66 +47,122 @@ function coreWithVersions(versions: HarnessVersionSummary[]): TestCoreClient { describe("harness version list screen", () => { test("without a harness id, picking a harness lists its versions", async () => { - const core = coreWithVersions([version()]); + const core = coreWithVersions([version({ harnessVersion: "42" })]); const r = renderScreen("/agentcore/harness/version/list", { core }); await waitForText(r.lastFrame, "MyHarness"); expect(r.lastFrame()).toContain("choose a harness to list versions for"); await r.press("return"); - await waitFor(() => core.harness.calls.some((c) => c.method === "listHarnessVersions")); - const call = core.harness.calls.find((c) => c.method === "listHarnessVersions")!; - expect(call.args[0]).toBe("MyHarness-abc123"); + await waitForText(r.lastFrame, "42"); + expect(r.lastFrame()).toContain("version → list → MyHarness-abc123"); r.unmount(); }); - test("renders versions newest first", async () => { - const core = coreWithVersions([version(), version({ harnessVersion: "2" })]); + test("makes one exact scoped version list call", async () => { + const core = coreWithVersions([version()]); const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "READY"); - const frame = r.lastFrame()!; - // Both rows render, "2" above "1". - expect(frame.indexOf(" 2 ")).toBeGreaterThan(-1); - expect(frame.indexOf("Version")).toBeLessThan(frame.indexOf("READY")); + await waitFor(() => core.harness.calls.some((call) => call.method === "listHarnessVersions")); + expect(core.harness.calls.filter((call) => call.method === "listHarnessVersions")).toEqual([ + { + method: "listHarnessVersions", + args: [ + "MyHarness-abc123", + undefined, + 32, + { + region: "us-east-1", + endpointUrl: undefined, + }, + ], + }, + ]); r.unmount(); }); - test("pages forward and back when the response has a nextToken", async () => { - const core = coreWithVersions([]); - core.harness.setListVersionsResponse({ - harnessVersions: [version({ harnessVersion: "42" }), version({ harnessVersion: "41" })], - nextToken: "v2", - }); - core.harness.setListVersionsResponse( - { harnessVersions: [version({ harnessVersion: "40" })] }, - "v2", - ); + test("sorts numeric versions newest first", async () => { + const core = coreWithVersions([ + version({ harnessVersion: "2", status: "UPDATE_FAILED" }), + version({ harnessVersion: "10", status: "READY" }), + ]); const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - await waitForText(r.lastFrame, "42"); - expect(r.lastFrame()).toContain("page 1 · more →"); + await waitForText(r.lastFrame, "UPDATE_FAILED"); + const frame = r.lastFrame()!; + const lines = frame.split("\n"); + const versionTen = lines.findIndex((line) => line.includes("10") && line.includes("READY")); + const versionTwo = lines.findIndex( + (line) => line.includes("2") && line.includes("UPDATE_FAILED"), + ); + expect(versionTen).toBeGreaterThanOrEqual(0); + expect(versionTwo).toBeGreaterThanOrEqual(0); + expect(versionTen).toBeLessThan(versionTwo); + r.unmount(); + }); - await r.write("l"); - await waitForText(r.lastFrame, "40"); - expect(r.lastFrame()).toContain("page 2"); - const paged = core.harness.calls.filter((c) => c.method === "listHarnessVersions"); - expect(paged.at(-1)!.args[1]).toBe("v2"); - expect(paged.at(-1)!.args[2] as number).toBeGreaterThan(0); + test("shows version-only columns when narrow and all columns when wide", async () => { + const core = coreWithVersions([ + version({ + harnessVersion: "123", + status: "UPDATE_FAILED", + createdAt: new Date("2026-07-18T02:00:00.000Z"), + }), + ]); + const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - await r.write("h"); - await waitForText(r.lastFrame, "42"); - expect(r.lastFrame()).toContain("page 1"); + await r.resize(50); + await waitForText(r.lastFrame, "123"); + const narrow = r.lastFrame()!; + expect(narrow).toContain("version"); + expect(narrow).toContain("123"); + expect(narrow).not.toContain("status"); + expect(narrow).not.toContain("createdAt"); + expect(narrow).not.toContain("UPDATE_FAILED"); + expect(narrow).not.toContain("2026-07-18T02:00:00.000Z"); + + await r.resize(120); + await waitForText(r.lastFrame, "createdAt"); + const wide = r.lastFrame()!; + expect(wide).toContain("version"); + expect(wide).toContain("status"); + expect(wide).toContain("createdAt"); + expect(wide).toContain("123"); + expect(wide).toContain("UPDATE_FAILED"); + expect(wide).toContain("2026-07-18T02:00:00.000Z"); r.unmount(); }); + test("uses harness-version wording for empty pages", async () => { + const firstPage = renderScreen("/agentcore/harness/version/list/MyHarness-abc123"); + await waitForText(firstPage.lastFrame, "No versions found."); + firstPage.unmount(); + + const core = coreWithVersions([version({ harnessVersion: "42" })]); + core.harness.setListVersionsResponse({ + harnessVersions: [version({ harnessVersion: "42" })], + nextToken: "v2", + }); + core.harness.setListVersionsResponse({ harnessVersions: [] }, "v2"); + const laterPage = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); + + await waitForText(laterPage.lastFrame, "page 1 · more →"); + await laterPage.write("l"); + await waitForText( + laterPage.lastFrame, + "No versions on this page for harness MyHarness-abc123.", + ); + expect(laterPage.lastFrame()).not.toContain("No versions found."); + laterPage.unmount(); + }); + test("enter on a row opens the version's JSON detail", async () => { - const core = coreWithVersions([version({ harnessVersion: "2" }), version()]); + const core = coreWithVersions([version({ harnessVersion: "42" })]); core.harness.setGetVersionResponse({ harness: { harnessId: "MyHarness-abc123", harnessName: "MyHarness", - harnessVersion: "2", + harnessVersion: "42", status: "READY", }, } as Awaited>); @@ -115,20 +171,10 @@ describe("harness version list screen", () => { await waitForText(r.lastFrame, "READY"); await r.press("return"); await waitForText(r.lastFrame, '"harnessVersion"'); - expect(r.lastFrame()).toContain("version → get → MyHarness-abc123 → 2"); + expect(r.lastFrame()).toContain("version → get → MyHarness-abc123 → 42"); const call = core.harness.calls.find((c) => c.method === "getHarnessVersion")!; expect(call.args[0]).toBe("MyHarness-abc123"); - expect(call.args[1]).toBe("2"); - r.unmount(); - }); - - test("shows the error message when the list call fails", async () => { - const core = new TestCoreClient(); - core.harness.setError(new Error("access denied")); - const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - - await waitForText(r.lastFrame, "Error:"); - expect(r.lastFrame()).toContain("access denied"); + expect(call.args[1]).toBe("42"); r.unmount(); }); }); diff --git a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx index dc08402cb..5f21b07d5 100644 --- a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx +++ b/src/handlers/runtime/components/RuntimeEndpointPicker.tsx @@ -1,18 +1,13 @@ import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Text, useInput, useWindowSize } from "ink"; import { useNavigate } from "react-router"; -import { DataTable } from "../../../components/ui/data-table"; -import { darkTheme } from "../../../components/ui/_core.js"; -import { Spinner } from "../../../components/ui/spinner"; -import { Layout } from "../../../components/Layout"; -import { usePagedList } from "../../../components/usePagedList"; +import { TokenPagedTablePicker } from "../../../components/TokenPagedTablePicker"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; interface RuntimeEndpointRow extends Record { qualifier: string; liveVersion: string; + targetVersion: string; status: string; lastUpdatedAt: string; } @@ -21,6 +16,7 @@ function toRow(endpoint: AgentRuntimeEndpoint): RuntimeEndpointRow { return { qualifier: endpoint.name ?? endpoint.id ?? "", liveVersion: endpoint.liveVersion ?? "-", + targetVersion: endpoint.targetVersion ?? "-", status: endpoint.status ?? "-", lastUpdatedAt: endpoint.lastUpdatedAt?.toISOString() ?? "-", }; @@ -42,111 +38,63 @@ export function RuntimeEndpointPicker({ onSelect, }: RuntimeEndpointPickerProps) { const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); const navigate = useNavigate(); - const paging = usePagedList(); const goBack = () => navigate(-1); - const list = useQuery({ - queryKey: ["runtime-endpoints", opts.region, runtimeId, paging.pageSize, paging.token], - queryFn: () => - core.runtime.listRuntimeEndpoints(runtimeId, paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - useInput( - (input, key) => { - if (key.escape) { - goBack(); - return; - } - if (input === "r" && list.isError) void list.refetch(); - }, - { isActive: list.isPending || list.isError }, - ); - - const nextToken = list.data?.nextToken; - const paginated = paging.pageIndex > 0 || nextToken !== undefined; - const pageTransition = list.isFetching && !list.isPending; - const showUpdatedAt = columns >= 90; - const showStatus = columns >= 60; - const liveWidth = 8; - const statusWidth = showStatus ? 20 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const qualifierWidth = Math.max(12, columns - 2 - liveWidth - statusWidth - updatedAtWidth); return ( - - {list.isPending ? ( - - ) : list.isError ? ( - - Error loading endpoints for Runtime {runtimeId}: {(list.error as Error).message} - - ) : ( - <> - { - if (row.qualifier) onSelect(row.qualifier); - }} - onEscape={goBack} - onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} - /> - {(paginated || pageTransition) && ( - - {pageTransition - ? `loading page ${paging.pageIndex + 1}…` - : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} - - )} - - )} - + queryKey={["runtime-endpoints", opts.region, runtimeId]} + loadPage={async (token, pageSize) => { + const response = await core.runtime.listRuntimeEndpoints(runtimeId, token, pageSize, opts); + return { + items: response.runtimeEndpoints ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={(terminalColumns) => { + const showUpdatedAt = terminalColumns >= 90; + const showStatus = terminalColumns >= 70; + const showTarget = terminalColumns >= 60; + const liveWidth = 8; + const targetWidth = showTarget ? 8 : 0; + const statusWidth = showStatus ? 20 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const qualifierWidth = Math.max( + 12, + terminalColumns - 2 - liveWidth - targetWidth - statusWidth - updatedAtWidth, + ); + return [ + { + key: "qualifier", + header: "qualifier", + width: qualifierWidth, + }, + { key: "liveVersion", header: "live", width: liveWidth }, + ...(showTarget + ? [{ key: "targetVersion" as const, header: "target", width: targetWidth }] + : []), + ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), + ...(showUpdatedAt + ? [ + { + key: "lastUpdatedAt" as const, + header: "lastUpdatedAt", + width: updatedAtWidth, + }, + ] + : []), + ]; + }} + getValue={(row) => row.qualifier} + onSelect={onSelect} + onBack={goBack} + loadingMessage={`Loading endpoints for Runtime ${runtimeId}…`} + errorMessage={(error) => `Error loading endpoints for Runtime ${runtimeId}: ${error.message}`} + emptyMessage="This Runtime has no endpoints." + emptyPageMessage={`No endpoints on this page for Runtime ${runtimeId}.`} + /> ); } diff --git a/src/handlers/runtime/components/RuntimePicker.tsx b/src/handlers/runtime/components/RuntimePicker.tsx index 22451a229..aa4fbe945 100644 --- a/src/handlers/runtime/components/RuntimePicker.tsx +++ b/src/handlers/runtime/components/RuntimePicker.tsx @@ -1,12 +1,6 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Text, useInput, useWindowSize } from "ink"; import { useNavigate } from "react-router"; -import { DataTable } from "../../../components/ui/data-table"; -import { darkTheme } from "../../../components/ui/_core.js"; -import { Spinner } from "../../../components/ui/spinner"; -import { Layout } from "../../../components/Layout"; -import { usePagedList } from "../../../components/usePagedList"; +import { TokenPagedTablePicker } from "../../../components/TokenPagedTablePicker"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -43,110 +37,57 @@ export function RuntimePicker({ onSelect, }: RuntimePickerProps) { const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); const navigate = useNavigate(); - const paging = usePagedList(); const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); - const list = useQuery({ - queryKey: ["runtimes", opts.region, paging.pageSize, paging.token], - queryFn: () => core.runtime.listRuntimes(paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - useInput( - (input, key) => { - if (key.escape) { - goBack(); - return; - } - if (input === "r" && list.isError) void list.refetch(); - }, - { isActive: list.isPending || list.isError }, - ); - - const nextToken = list.data?.nextToken; - const paginated = paging.pageIndex > 0 || nextToken !== undefined; - const pageTransition = list.isFetching && !list.isPending; - const showId = columns >= 130; - const showUpdatedAt = columns >= 90; - const showStatus = columns >= 70; - const versionWidth = 15; - const statusWidth = showStatus ? 16 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const idWidth = showId ? Math.max(30, Math.floor(columns * 0.36)) : 0; - const nameWidth = Math.max( - 12, - columns - 2 - versionWidth - statusWidth - updatedAtWidth - idWidth, - ); return ( - - {list.isPending ? ( - - ) : list.isError ? ( - Error: {(list.error as Error).message} - ) : ( - <> - { - if (row.runtimeId) onSelect(row.runtimeId); - }} - onEscape={goBack} - onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} - /> - {(paginated || pageTransition) && ( - - {pageTransition - ? `loading page ${paging.pageIndex + 1}…` - : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} - - )} - - )} - + queryKey={["runtimes", opts.region]} + loadPage={async (token, pageSize) => { + const response = await core.runtime.listRuntimes(token, pageSize, opts); + return { + items: response.agentRuntimes ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={(terminalColumns) => { + const showId = terminalColumns >= 130; + const showUpdatedAt = terminalColumns >= 90; + const showStatus = terminalColumns >= 70; + const versionWidth = 15; + const statusWidth = showStatus ? 16 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const idWidth = showId ? Math.max(30, Math.floor(terminalColumns * 0.36)) : 0; + const nameWidth = Math.max( + 12, + terminalColumns - 2 - versionWidth - statusWidth - updatedAtWidth - idWidth, + ); + return [ + { key: "runtimeName", header: "name", width: nameWidth }, + ...(showId ? [{ key: "runtimeId" as const, header: "id", width: idWidth }] : []), + { key: "runtimeVersion", header: "latestVersion", width: versionWidth }, + ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), + ...(showUpdatedAt + ? [ + { + key: "lastUpdatedAt" as const, + header: "lastUpdatedAt", + width: updatedAtWidth, + }, + ] + : []), + ]; + }} + getValue={(row) => row.runtimeId} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading Runtimes…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No Runtimes found in this Region." + emptyPageMessage="No Runtimes on this page." + /> ); } diff --git a/src/handlers/runtime/components/RuntimeVersionPicker.tsx b/src/handlers/runtime/components/RuntimeVersionPicker.tsx index 4a9fe4144..c904556ea 100644 --- a/src/handlers/runtime/components/RuntimeVersionPicker.tsx +++ b/src/handlers/runtime/components/RuntimeVersionPicker.tsx @@ -1,12 +1,6 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Text, useInput, useWindowSize } from "ink"; import { useNavigate } from "react-router"; -import { DataTable } from "../../../components/ui/data-table"; -import { darkTheme } from "../../../components/ui/_core.js"; -import { Spinner } from "../../../components/ui/spinner"; -import { Layout } from "../../../components/Layout"; -import { usePagedList } from "../../../components/usePagedList"; +import { TokenPagedTablePicker } from "../../../components/TokenPagedTablePicker"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; @@ -40,107 +34,52 @@ export function RuntimeVersionPicker({ onSelect, }: RuntimeVersionPickerProps) { const opts = coreOptsFromCtx(ctx); - const { columns } = useWindowSize(); const navigate = useNavigate(); - const paging = usePagedList(); const goBack = () => navigate(-1); - const list = useQuery({ - queryKey: ["runtime-versions", opts.region, runtimeId, paging.pageSize, paging.token], - queryFn: () => core.runtime.listRuntimeVersions(runtimeId, paging.token, paging.pageSize, opts), - placeholderData: keepPreviousData, - }); - - useInput( - (input, key) => { - if (key.escape) { - goBack(); - return; - } - if (input === "r" && list.isError) void list.refetch(); - }, - { isActive: list.isPending || list.isError }, - ); - - const nextToken = list.data?.nextToken; - const paginated = paging.pageIndex > 0 || nextToken !== undefined; - const pageTransition = list.isFetching && !list.isPending; - const showUpdatedAt = columns >= 90; - const showStatus = columns >= 60; - const statusWidth = showStatus ? 20 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const versionWidth = Math.max(8, columns - 2 - statusWidth - updatedAtWidth); - const rows = (list.data?.agentRuntimes ?? []) - .map(toRow) - .sort((left, right) => Number(right.version) - Number(left.version)); return ( - - {list.isPending ? ( - - ) : list.isError ? ( - - Error loading versions for Runtime {runtimeId}: {(list.error as Error).message} - - ) : ( - <> - { - if (row.version) onSelect(row.version); - }} - onEscape={goBack} - onPrevPage={!pageTransition && paging.pageIndex > 0 ? paging.prev : undefined} - onNextPage={!pageTransition && nextToken ? () => paging.next(nextToken) : undefined} - /> - {(paginated || pageTransition) && ( - - {pageTransition - ? `loading page ${paging.pageIndex + 1}…` - : `page ${paging.pageIndex + 1}${nextToken ? " · more →" : ""}`} - - )} - - )} - + queryKey={["runtime-versions", opts.region, runtimeId]} + loadPage={async (token, pageSize) => { + const response = await core.runtime.listRuntimeVersions(runtimeId, token, pageSize, opts); + return { + items: response.agentRuntimes ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={(terminalColumns) => { + const showUpdatedAt = terminalColumns >= 90; + const showStatus = terminalColumns >= 60; + const statusWidth = showStatus ? 20 : 0; + const updatedAtWidth = showUpdatedAt ? 30 : 0; + const versionWidth = Math.max(8, terminalColumns - 2 - statusWidth - updatedAtWidth); + return [ + { key: "version", header: "version", width: versionWidth }, + ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), + ...(showUpdatedAt + ? [ + { + key: "lastUpdatedAt" as const, + header: "lastUpdatedAt", + width: updatedAtWidth, + }, + ] + : []), + ]; + }} + sortRows={(rows) => + [...rows].sort((left, right) => Number(right.version) - Number(left.version)) + } + getValue={(row) => row.version} + onSelect={onSelect} + onBack={goBack} + loadingMessage={`Loading versions for Runtime ${runtimeId}…`} + errorMessage={(error) => `Error loading versions for Runtime ${runtimeId}: ${error.message}`} + emptyMessage={`No versions found for Runtime ${runtimeId}.`} + emptyPageMessage={`No versions on this page for Runtime ${runtimeId}.`} + /> ); } diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 83d4197a9..478d5e6d8 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -3,7 +3,6 @@ import type { AgentRuntime, AgentRuntimeEndpoint, GetAgentRuntimeEndpointResponse, - ListAgentRuntimeEndpointsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { cleanupScreens, @@ -142,14 +141,14 @@ describe("Runtime endpoint flow", () => { ]); }); - test("renders qualifier, live version, status, and update time without invented columns", async () => { + test("renders qualifier, live and target versions, status, and update time when wide", async () => { const core = new TestCoreClient(); core.runtime.setListEndpointsResponse({ runtimeEndpoints: [ endpoint({ name: "production", liveVersion: "7", - targetVersion: "target-hidden", + targetVersion: "8", status: "UPDATE_FAILED", lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), }), @@ -161,13 +160,14 @@ describe("Runtime endpoint flow", () => { const frame = r.lastFrame()!; expect(frame).toContain("qualifier"); expect(frame).toContain("live"); + expect(frame).toContain("target"); expect(frame).toContain("status"); expect(frame).toContain("lastUpdatedAt"); + expect(frame).toMatch(/target\s+status/); + expect(frame).toMatch(/production\s+7\s+8\s+UPDATE_FAILED/); expect(frame).toContain("UPDATE_FAILED"); expect(frame).toContain("2026-07-18T02:00:00.000Z"); expect(frame).not.toContain("protocol"); - expect(frame).not.toContain("target"); - expect(frame).not.toContain("target-hidden"); }); test("keeps qualifier and live version when narrow", async () => { @@ -191,6 +191,7 @@ describe("Runtime endpoint flow", () => { expect(frame).toContain("live"); expect(frame).toContain("visible-endpoint"); expect(frame).toContain("7"); + expect(frame).not.toContain("target"); expect(frame).not.toContain("status"); expect(frame).not.toContain("lastUpdatedAt"); expect(frame).not.toContain("UPDATE_FAILED"); @@ -203,183 +204,28 @@ describe("Runtime endpoint flow", () => { expect(r.lastFrame()).toContain("runtime-123"); }); - test("pages forward and backward using token history", async () => { - const core = new TestCoreClient(); - core.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint({ name: "page-one" })], - nextToken: "page-2", - }); - core.runtime.setListEndpointsResponse( - { - runtimeEndpoints: [endpoint({ name: "page-two" })], - }, - "page-2", - ); - const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "page-two"); - expect(core.runtime.calls.at(-1)?.args[1]).toBe("page-2"); - - await r.write("h"); - await waitForText(r.lastFrame, "page-one"); - expect(core.runtime.calls.at(-1)?.args[1]).toBeUndefined(); - }); - - test("resizing resets to page one with the terminal-derived page size", async () => { + test("describes an empty later page without claiming the Runtime has no endpoints", async () => { const core = new TestCoreClient(); core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint({ name: "page-one" })], nextToken: "page-2", }); - core.runtime.setListEndpointsResponse( - { - runtimeEndpoints: [endpoint({ name: "page-two" })], - }, - "page-2", - ); + core.runtime.setListEndpointsResponse({ runtimeEndpoints: [] }, "page-2"); const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); await waitForText(r.lastFrame, "page 1 · more →"); await r.write("l"); - await waitForText(r.lastFrame, "page-two"); - const callsBeforeResize = core.runtime.calls.filter( - (call) => call.method === "listRuntimeEndpoints", - ).length; - - await r.resize(100, 20); - await waitFor(() => { - const calls = core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints"); - return ( - calls.length > callsBeforeResize && - calls.at(-1)?.args[1] === undefined && - calls.at(-1)?.args[2] === 12 - ); - }); - await waitForText(r.lastFrame, "page-one"); - expect(r.lastFrame()).toContain("page 1 · more →"); - const callsAfterResize = core.runtime.calls - .filter((call) => call.method === "listRuntimeEndpoints") - .slice(callsBeforeResize); - expect(callsAfterResize.length).toBeGreaterThan(0); - expect(callsAfterResize.every((call) => call.args[1] === undefined)).toBe(true); + await waitForText(r.lastFrame, "No endpoints on this page for Runtime runtime-123."); + expect(r.lastFrame()).not.toContain("This Runtime has no endpoints."); }); - test("retains rows and ignores page keys during a page transition", async () => { + test("names the selected Runtime in the error state", async () => { const core = new TestCoreClient(); - core.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint({ name: "stable-page-one" })], - nextToken: "page-2", - }); - core.runtime.setListEndpointsResponse( - { - runtimeEndpoints: [endpoint({ name: "settled-page-two" })], - }, - "page-2", - ); - const nextPage = Promise.withResolvers(); - const listEndpoints = core.runtime.listRuntimeEndpoints.bind(core.runtime); - core.runtime.listRuntimeEndpoints = async (...args) => { - const response = await listEndpoints(...args); - if (args[1] === "page-2") await nextPage.promise; - return response; - }; + core.runtime.setError(new Error("endpoint access denied")); const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "loading page 2…"); - expect(r.lastFrame()).toContain("stable-page-one"); - - const callsDuringTransition = core.runtime.calls.length; - await r.write("l"); - await r.write("h"); - expect(core.runtime.calls).toHaveLength(callsDuringTransition); - - nextPage.resolve(); - await waitForText(r.lastFrame, "settled-page-two"); - }); - - test("filters only the loaded page without paging on h or l", async () => { - const core = new TestCoreClient(); - core.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint({ name: "production" })], - nextToken: "page-2", - }); - const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("/"); - await r.write("l"); - await r.write("h"); - await waitForText(r.lastFrame, "/ Filter this page: lh"); - expect(r.lastFrame()).toContain("No matches on this page"); - expect( - core.runtime.calls.some( - (call) => call.method === "listRuntimeEndpoints" && call.args[1] === "page-2", - ), - ).toBe(false); - }); - - test("retries errors and keeps Esc active in loading, error, and empty states", async () => { - const retryCore = new TestCoreClient(); - retryCore.runtime.setError(new Error("endpoint access denied")); - const retry = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core: retryCore }); - await waitForText(retry.lastFrame, "endpoint access denied"); - expect(retry.lastFrame()).toContain("Runtime runtime-123"); - expect(retry.lastFrame()).toContain("[r] retry"); - retryCore.runtime.setError(undefined); - retryCore.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint({ name: "recovered" })], - }); - const callsBeforeRetry = retryCore.runtime.calls.length; - await retry.write("r"); - await waitForText(retry.lastFrame, "recovered"); - expect(retryCore.runtime.calls).toHaveLength(callsBeforeRetry + 1); - retry.unmount(); - - const loadingCore = new TestCoreClient(); - loadingCore.runtime.setListResponse({ - agentRuntimes: [runtime()], - }); - const pending = Promise.withResolvers(); - loadingCore.runtime.listRuntimeEndpoints = async () => pending.promise; - const loading = renderScreen("/agentcore/runtime/endpoint/list", { - core: loadingCore, - }); - await waitForText(loading.lastFrame, "checkout"); - await loading.press("return"); - await waitForText(loading.lastFrame, "Loading endpoints for Runtime runtime-123…"); - await loading.press("escape"); - await waitForRuntimePicker(loading.lastFrame); - loading.unmount(); - - const errorCore = new TestCoreClient(); - errorCore.runtime.setListResponse({ - agentRuntimes: [runtime()], - }); - errorCore.runtime.listRuntimeEndpoints = async () => { - throw new Error("failed"); - }; - const error = renderScreen("/agentcore/runtime/endpoint/list", { core: errorCore }); - await waitForText(error.lastFrame, "checkout"); - await error.press("return"); - await waitForText(error.lastFrame, "failed"); - await error.press("escape"); - await waitForRuntimePicker(error.lastFrame); - error.unmount(); - - const emptyCore = new TestCoreClient(); - emptyCore.runtime.setListResponse({ - agentRuntimes: [runtime()], - }); - const empty = renderScreen("/agentcore/runtime/endpoint/list", { core: emptyCore }); - await waitForText(empty.lastFrame, "checkout"); - await empty.press("return"); - await waitForText(empty.lastFrame, "This Runtime has no endpoints."); - await empty.press("escape"); - await waitForRuntimePicker(empty.lastFrame); + await waitForText(r.lastFrame, "Error loading endpoints for Runtime runtime-123"); + expect(r.lastFrame()).toContain("endpoint access denied"); }); test("selecting an encoded qualifier opens complete endpoint JSON with exact selectors", async () => { diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index a6885d0a3..93274bf8c 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AgentRuntime, GetAgentRuntimeResponse, - ListAgentRuntimesResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { QueryClient } from "@tanstack/react-query"; import { @@ -16,14 +15,6 @@ import { afterEach(cleanupScreens); -function frameSize(frame: string): { columns: number; rows: number } { - const lines = frame.split("\n"); - return { - columns: Math.max(...lines.map((line) => line.length)), - rows: lines.length, - }; -} - function runtime(overrides: Partial = {}): AgentRuntime { return { agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-1", @@ -141,55 +132,6 @@ describe("runtime picker", () => { expect(frame).toContain("2026-07-19T01:02:03.000Z"); }); - test("keeps a full Runtime list and its footer coherent at 50x20", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ - agentRuntimes: Array.from({ length: 12 }, (_, index) => - runtime({ - agentRuntimeId: `narrow-row-${index + 1}`, - agentRuntimeName: `narrow-row-${index + 1}`, - }), - ), - nextToken: "page-2", - }); - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "narrow-row-12"); - for (const hint of [ - "[↑↓/jk] navigate", - "[←→/hl] page", - "[/] filter", - "[enter] select", - "[esc] back", - "[ctl+c] quit", - ]) { - expect(r.lastFrame()).toContain(hint); - } - await r.resize(50, 20); - await waitForText(r.lastFrame, "page 1 · more →"); - const frame = r.lastFrame()!; - const lines = frame.split("\n"); - const pageLine = lines.find((line) => line.includes("page 1 · more →")); - const footerLine = lines.find((line) => line.includes("[↑↓/jk] navigate")); - - expect(frameSize(frame)).toEqual({ columns: 50, rows: 20 }); - expect(frame).toContain("agentcore → runtime → list"); - expect(pageLine?.trim()).toBe("page 1 · more →"); - expect(pageLine).not.toContain("narrow-row"); - expect(footerLine).toContain("[enter] select"); - expect(footerLine).toContain("[esc] back"); - expect(frame).not.toContain("[←→/hl] page"); - expect(frame).not.toContain("[/] filter"); - expect(frame).not.toContain("[ctl+c] quit"); - expect(footerLine!.indexOf("[↑↓/jk] navigate")).toBeLessThan( - footerLine!.indexOf("[enter] select"), - ); - expect(footerLine!.indexOf("[enter] select")).toBeLessThan(footerLine!.indexOf("[esc] back")); - expect( - lines.filter((line) => line.includes("[") && line.includes("]")).map((line) => line.trim()), - ).toEqual([footerLine!.trim()]); - }); - test("calls listRuntimes with terminal page size and exact Core options", async () => { const core = coreWithRuntimes([runtime()]); renderScreen("/agentcore/runtime/list", { core }); @@ -210,300 +152,25 @@ describe("runtime picker", () => { ]); }); - test("shows loading and lets Esc return to the Runtime menu", async () => { - const core = new TestCoreClient(); - const pending = Promise.withResolvers(); - core.runtime.listRuntimes = async () => pending.promise; - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "Loading Runtimes…"); - await r.press("escape"); - await waitForRuntimeMenu(r.lastFrame); - expect(r.lastFrame()).toContain("list AgentCore Runtimes"); - }); - - test("shows service errors, retries with r, and lets Esc return to the Runtime menu", async () => { - const core = new TestCoreClient(); - core.runtime.setError(new Error("runtime access denied")); - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "runtime access denied"); - expect(r.lastFrame()).toContain("[r] retry"); - - core.runtime.setError(undefined); - core.runtime.setListResponse({ - agentRuntimes: [runtime({ agentRuntimeName: "recovered" })], - }); - const callsBeforeRetry = core.runtime.calls.filter( - (call) => call.method === "listRuntimes", - ).length; - await r.write("r"); - await waitForText(r.lastFrame, "recovered"); - expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( - callsBeforeRetry + 1, - ); - - core.runtime.setError(new Error("runtime access denied")); - const errorScreen = renderScreen("/agentcore/runtime/list", { core }); - await waitForText(errorScreen.lastFrame, "runtime access denied"); - await errorScreen.press("escape"); - await waitForRuntimeMenu(errorScreen.lastFrame); - }); - - test("shows the first-page empty state and lets Esc return to the Runtime menu", async () => { + test("shows the first-page empty state", async () => { const r = renderScreen("/agentcore/runtime/list"); await waitForText(r.lastFrame, "No Runtimes found in this Region."); - await r.press("escape"); - await waitForRuntimeMenu(r.lastFrame); - expect(r.lastFrame()).toContain("list AgentCore Runtimes"); }); - test("pages forward and backward using token history", async () => { + test("describes an empty later page without claiming the Region has no Runtimes", async () => { const core = new TestCoreClient(); core.runtime.setListResponse({ agentRuntimes: [runtime({ agentRuntimeName: "page-one" })], nextToken: "page-2", }); - core.runtime.setListResponse( - { - agentRuntimes: [runtime({ agentRuntimeName: "page-two" })], - }, - "page-2", - ); - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - expect(r.lastFrame()).toContain("page-one"); - - await r.write("l"); - await waitForText(r.lastFrame, "page-two"); - expect(r.lastFrame()).toContain("page 2"); - expect(core.runtime.calls.at(-1)?.args[0]).toBe("page-2"); - - await r.write("h"); - await waitForText(r.lastFrame, "page-one"); - expect(r.lastFrame()).toContain("page 1 · more →"); - expect(core.runtime.calls.at(-1)?.args[0]).toBeUndefined(); - }); - - test("resizing resets to page one with the terminal-derived page size", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ - agentRuntimes: [runtime({ agentRuntimeName: "page-one" })], - nextToken: "page-2", - }); - core.runtime.setListResponse( - { - agentRuntimes: [runtime({ agentRuntimeName: "page-two" })], - }, - "page-2", - ); - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "page-two"); - const callsBeforeResize = core.runtime.calls.filter( - (call) => call.method === "listRuntimes", - ).length; - - await r.resize(100, 20); - await waitFor(() => { - const calls = core.runtime.calls.filter((call) => call.method === "listRuntimes"); - return ( - calls.length > callsBeforeResize && - calls.at(-1)?.args[0] === undefined && - calls.at(-1)?.args[1] === 12 - ); - }); - await waitForText(r.lastFrame, "page-one"); - expect(r.lastFrame()).toContain("page 1 · more →"); - const callsAfterResize = core.runtime.calls - .filter((call) => call.method === "listRuntimes") - .slice(callsBeforeResize); - expect(callsAfterResize.length).toBeGreaterThan(0); - expect(callsAfterResize.every((call) => call.args[0] === undefined)).toBe(true); - }); - - test("resizing from page two resets selection to the first row on page one", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ - agentRuntimes: [ - runtime({ agentRuntimeId: "page-one-first", agentRuntimeName: "page-one-first" }), - runtime({ agentRuntimeId: "page-one-second", agentRuntimeName: "page-one-second" }), - ], - nextToken: "page-2", - }); - core.runtime.setListResponse( - { - agentRuntimes: [ - runtime({ agentRuntimeId: "page-two-first", agentRuntimeName: "page-two-first" }), - runtime({ agentRuntimeId: "page-two-second", agentRuntimeName: "page-two-second" }), - ], - }, - "page-2", - ); - core.runtime.setGetResponse( - getRuntimeResponse({ - agentRuntimeId: "page-one-first", - agentRuntimeName: "page-one-first", - }), - ); - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "page-two-second"); - await r.press("down"); - await waitForText(r.lastFrame, "❯ page-two-second"); - - await r.resize(100, 20); - await waitForText(r.lastFrame, "page-one-first"); - await r.press("return"); - - await waitForText(r.lastFrame, "agentcore → runtime → get → page-one-first"); - await waitFor(() => - core.runtime.calls.some( - (call) => call.method === "getRuntime" && call.args[0] === "page-one-first", - ), - ); - expect( - core.runtime.calls.some( - (call) => call.method === "getRuntime" && call.args[0] === "page-one-first", - ), - ).toBe(true); - expect( - core.runtime.calls.some( - (call) => call.method === "getRuntime" && call.args[0] === "page-one-second", - ), - ).toBe(false); - }); - - test("retains rows and ignores page keys while either page direction is loading", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ - agentRuntimes: [runtime({ agentRuntimeName: "stable-page-one" })], - nextToken: "page-2", - }); - core.runtime.setListResponse( - { - agentRuntimes: [runtime({ agentRuntimeName: "settled-page-two" })], - }, - "page-2", - ); - const pageTwo = Promise.withResolvers(); - const pageOneAgain = Promise.withResolvers(); - let holdFirstPage = false; - const listRuntimes = core.runtime.listRuntimes.bind(core.runtime); - core.runtime.listRuntimes = async (...args) => { - const result = await listRuntimes(...args); - if (args[0] === "page-2") await pageTwo.promise; - if (args[0] === undefined && holdFirstPage) await pageOneAgain.promise; - return result; - }; - const r = renderScreen("/agentcore/runtime/list", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "loading page 2…"); - expect(r.lastFrame()).toContain("stable-page-one"); - - const callsDuringTransition = core.runtime.calls.filter( - (call) => call.method === "listRuntimes", - ).length; - await r.write("l"); - await r.write("h"); - expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( - callsDuringTransition, - ); - - pageTwo.resolve(); - await waitForText(r.lastFrame, "settled-page-two"); - - holdFirstPage = true; - await r.write("h"); - await waitForText(r.lastFrame, "loading page 1…"); - expect(r.lastFrame()).toContain("settled-page-two"); - - const callsDuringBackTransition = core.runtime.calls.filter( - (call) => call.method === "listRuntimes", - ).length; - await r.write("l"); - expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toHaveLength( - callsDuringBackTransition, - ); - - pageOneAgain.resolve(); - await waitForText(r.lastFrame, "stable-page-one"); - }); - - test("disables page keys while a cached page is refetching", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ - agentRuntimes: [runtime({ agentRuntimeName: "cached-page-one" })], - nextToken: "page-2", - }); - core.runtime.setListResponse( - { - agentRuntimes: [runtime({ agentRuntimeName: "cached-page-two" })], - }, - "page-2", - ); - const pageOneRefetch = Promise.withResolvers(); - let holdFirstPage = false; - const listRuntimes = core.runtime.listRuntimes.bind(core.runtime); - core.runtime.listRuntimes = async (...args) => { - const result = await listRuntimes(...args); - if (args[0] === undefined && holdFirstPage) await pageOneRefetch.promise; - return result; - }; - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false, gcTime: Infinity, staleTime: 0 }, - }, - }); - const r = renderScreen("/agentcore/runtime/list", { core, queryClient }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "cached-page-two"); - - holdFirstPage = true; - const callsBeforeBack = core.runtime.calls.length; - await r.write("h"); - await waitFor( - () => - core.runtime.calls.length > callsBeforeBack && - core.runtime.calls.at(-1)?.args[0] === undefined, - ); - expect(r.lastFrame()).toContain("loading page 1…"); - expect(r.lastFrame()).toContain("cached-page-one"); - - const callsDuringRefetch = core.runtime.calls.length; - await r.write("l"); - expect(core.runtime.calls).toHaveLength(callsDuringRefetch); - expect(r.lastFrame()).toContain("cached-page-one"); - - pageOneRefetch.resolve(); - await waitForText(r.lastFrame, "page 1 · more →"); - }); - - test("filters only the loaded page and does not treat h or l as page keys", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ - agentRuntimes: [runtime({ agentRuntimeName: "orders" })], - nextToken: "page-2", - }); + core.runtime.setListResponse({ agentRuntimes: [] }, "page-2"); const r = renderScreen("/agentcore/runtime/list", { core }); await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("/"); await r.write("l"); - await r.write("h"); - await waitForText(r.lastFrame, "/ Filter this page: lh"); - expect(r.lastFrame()).toContain("No matches on this page"); - expect(core.runtime.calls.some((call) => call.args[0] === "page-2")).toBe(false); + await waitForText(r.lastFrame, "No Runtimes on this page."); + expect(r.lastFrame()).not.toContain("No Runtimes found in this Region."); }); test("keeps name and latest version when narrow", async () => { diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 923150c5a..82ef00ec4 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AgentRuntime, GetAgentRuntimeResponse, - ListAgentRuntimeVersionsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { cleanupScreens, @@ -193,123 +192,19 @@ describe("Runtime version flow", () => { expect(Math.max(...frame.split("\n").map((line) => line.length))).toBe(50); }); - test("pages forward and backward using token history", async () => { + test("describes an empty later page without claiming the Runtime has no versions", async () => { const core = new TestCoreClient(); core.runtime.setListVersionsResponse({ agentRuntimes: [runtime({ agentRuntimeVersion: "3" })], nextToken: "page-2", }); - core.runtime.setListVersionsResponse( - { - agentRuntimes: [runtime({ agentRuntimeVersion: "2" })], - }, - "page-2", - ); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "page 2"); - expect(core.runtime.calls.at(-1)?.args[1]).toBe("page-2"); - - await r.write("h"); - await waitForText(r.lastFrame, "page 1 · more →"); - expect(core.runtime.calls.at(-1)?.args[1]).toBeUndefined(); - }); - - test("resizing resets to page one with the terminal-derived page size", async () => { - const core = new TestCoreClient(); - core.runtime.setListVersionsResponse({ - agentRuntimes: [runtime({ agentRuntimeVersion: "3" })], - nextToken: "page-2", - }); - core.runtime.setListVersionsResponse( - { - agentRuntimes: [runtime({ agentRuntimeVersion: "2" })], - }, - "page-2", - ); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("l"); - await waitForText(r.lastFrame, "page 2"); - const callsBeforeResize = core.runtime.calls.filter( - (call) => call.method === "listRuntimeVersions", - ).length; - - await r.resize(100, 20); - await waitFor(() => { - const calls = core.runtime.calls.filter((call) => call.method === "listRuntimeVersions"); - return ( - calls.length > callsBeforeResize && - calls.at(-1)?.args[1] === undefined && - calls.at(-1)?.args[2] === 12 - ); - }); - await waitForText(r.lastFrame, "page 1 · more →"); - expect(r.lastFrame()).toContain("3"); - const callsAfterResize = core.runtime.calls - .filter((call) => call.method === "listRuntimeVersions") - .slice(callsBeforeResize); - expect(callsAfterResize.length).toBeGreaterThan(0); - expect(callsAfterResize.every((call) => call.args[1] === undefined)).toBe(true); - }); - - test("retains rows and ignores page keys during a page transition", async () => { - const core = new TestCoreClient(); - core.runtime.setListVersionsResponse({ - agentRuntimes: [runtime({ agentRuntimeVersion: "11" })], - nextToken: "page-2", - }); - core.runtime.setListVersionsResponse( - { - agentRuntimes: [runtime({ agentRuntimeVersion: "10" })], - }, - "page-2", - ); - const nextPage = Promise.withResolvers(); - const listVersions = core.runtime.listRuntimeVersions.bind(core.runtime); - core.runtime.listRuntimeVersions = async (...args) => { - const response = await listVersions(...args); - if (args[1] === "page-2") await nextPage.promise; - return response; - }; + core.runtime.setListVersionsResponse({ agentRuntimes: [] }, "page-2"); const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); await waitForText(r.lastFrame, "page 1 · more →"); await r.write("l"); - await waitForText(r.lastFrame, "loading page 2…"); - expect(r.lastFrame()).toContain("11"); - - const callsDuringTransition = core.runtime.calls.length; - await r.write("l"); - await r.write("h"); - expect(core.runtime.calls).toHaveLength(callsDuringTransition); - - nextPage.resolve(); - await waitForText(r.lastFrame, "10"); - }); - - test("filters only the loaded page without paging on h or l", async () => { - const core = new TestCoreClient(); - core.runtime.setListVersionsResponse({ - agentRuntimes: [runtime({ agentRuntimeVersion: "12" })], - nextToken: "page-2", - }); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "page 1 · more →"); - await r.write("/"); - await r.write("l"); - await r.write("h"); - await waitForText(r.lastFrame, "/ Filter this page: lh"); - expect(r.lastFrame()).toContain("No matches on this page"); - expect( - core.runtime.calls.some( - (call) => call.method === "listRuntimeVersions" && call.args[1] === "page-2", - ), - ).toBe(false); + await waitForText(r.lastFrame, "No versions on this page for Runtime runtime-123."); + expect(r.lastFrame()).not.toContain("No versions found for Runtime runtime-123."); }); test("names the selected Runtime in empty and error states", async () => { @@ -325,66 +220,6 @@ describe("Runtime version flow", () => { expect(error.lastFrame()).toContain("[r] retry"); }); - test("retries a failed scoped version list", async () => { - const core = new TestCoreClient(); - core.runtime.setError(new Error("version access denied")); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "version access denied"); - core.runtime.setError(undefined); - core.runtime.setListVersionsResponse({ - agentRuntimes: [runtime({ agentRuntimeVersion: "12" })], - }); - const callsBeforeRetry = core.runtime.calls.length; - await r.write("r"); - await waitForText(r.lastFrame, "12"); - expect(core.runtime.calls).toHaveLength(callsBeforeRetry + 1); - }); - - test("Esc remains active in loading, error, and empty states", async () => { - const loadingCore = new TestCoreClient(); - loadingCore.runtime.setListResponse({ - agentRuntimes: [runtime()], - }); - const pending = Promise.withResolvers(); - loadingCore.runtime.listRuntimeVersions = async () => pending.promise; - const loading = renderScreen("/agentcore/runtime/version/list", { - core: loadingCore, - }); - await waitForText(loading.lastFrame, "checkout"); - await loading.press("return"); - await waitForText(loading.lastFrame, "Loading versions for Runtime runtime-123…"); - await loading.press("escape"); - await waitForRuntimePicker(loading.lastFrame); - loading.unmount(); - - const errorCore = new TestCoreClient(); - errorCore.runtime.setListResponse({ - agentRuntimes: [runtime()], - }); - errorCore.runtime.listRuntimeVersions = async () => { - throw new Error("failed"); - }; - const error = renderScreen("/agentcore/runtime/version/list", { core: errorCore }); - await waitForText(error.lastFrame, "checkout"); - await error.press("return"); - await waitForText(error.lastFrame, "Error loading versions"); - await error.press("escape"); - await waitForRuntimePicker(error.lastFrame); - error.unmount(); - - const emptyCore = new TestCoreClient(); - emptyCore.runtime.setListResponse({ - agentRuntimes: [runtime()], - }); - const empty = renderScreen("/agentcore/runtime/version/list", { core: emptyCore }); - await waitForText(empty.lastFrame, "checkout"); - await empty.press("return"); - await waitForText(empty.lastFrame, "No versions found"); - await empty.press("escape"); - await waitForRuntimePicker(empty.lastFrame); - }); - test("selecting a version opens complete JSON with exact selectors", async () => { const core = new TestCoreClient(); core.runtime.setListVersionsResponse({ From b98fdac8f5a8e454f4ce991c18453a506e9d6479 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 02:39:18 +0000 Subject: [PATCH 21/38] fix: strip SDK metadata from Runtime output --- src/handlers/runtime/endpoint/get/index.tsx | 5 ++++- src/handlers/runtime/endpoint/get/screen.tsx | 2 +- src/handlers/runtime/endpoint/list/index.tsx | 13 ++++++++----- src/handlers/runtime/get/index.tsx | 5 ++++- src/handlers/runtime/get/screen.tsx | 2 +- src/handlers/runtime/list/index.tsx | 11 +++++++---- src/handlers/runtime/runtime.test.tsx | 16 +++++++++++++++- src/handlers/runtime/version/get/index.tsx | 5 ++++- src/handlers/runtime/version/get/screen.tsx | 2 +- src/handlers/runtime/version/list/index.tsx | 13 ++++++++----- .../{components => }/withoutSdkMetadata.ts | 0 11 files changed, 53 insertions(+), 21 deletions(-) rename src/handlers/runtime/{components => }/withoutSdkMetadata.ts (100%) diff --git a/src/handlers/runtime/endpoint/get/index.tsx b/src/handlers/runtime/endpoint/get/index.tsx index f4b342920..662c2da33 100644 --- a/src/handlers/runtime/endpoint/get/index.tsx +++ b/src/handlers/runtime/endpoint/get/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; +import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createGetRuntimeEndpointHandler = (core: Core) => createHandler({ @@ -23,7 +24,9 @@ export const createGetRuntimeEndpointHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - await core.runtime.getRuntimeEndpoint(flags.id, flags.qualifier, coreOptsFromCtx(ctx)), + withoutSdkMetadata( + await core.runtime.getRuntimeEndpoint(flags.id, flags.qualifier, coreOptsFromCtx(ctx)), + ), ); }, }); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index e16b3887e..bf136a35e 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; -import { withoutSdkMetadata } from "../../components/withoutSdkMetadata"; +import { withoutSdkMetadata } from "../../withoutSdkMetadata"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; diff --git a/src/handlers/runtime/endpoint/list/index.tsx b/src/handlers/runtime/endpoint/list/index.tsx index b6f492dc6..bdeb993b7 100644 --- a/src/handlers/runtime/endpoint/list/index.tsx +++ b/src/handlers/runtime/endpoint/list/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; +import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createListRuntimeEndpointsHandler = (core: Core) => createHandler({ @@ -21,11 +22,13 @@ export const createListRuntimeEndpointsHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - await core.runtime.listRuntimeEndpoints( - flags.id, - flags["next-token"], - flags["max-results"], - coreOptsFromCtx(ctx), + withoutSdkMetadata( + await core.runtime.listRuntimeEndpoints( + flags.id, + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), ), ); }, diff --git a/src/handlers/runtime/get/index.tsx b/src/handlers/runtime/get/index.tsx index b62f3e88d..e3d1b0755 100644 --- a/src/handlers/runtime/get/index.tsx +++ b/src/handlers/runtime/get/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; +import { withoutSdkMetadata } from "../withoutSdkMetadata"; export const createGetRuntimeHandler = (core: Core) => createHandler({ @@ -16,6 +17,8 @@ export const createGetRuntimeHandler = (core: Core) => ctx .require(JsonRendererKey) - .renderJson(await core.runtime.getRuntime(flags.id, coreOptsFromCtx(ctx))); + .renderJson( + withoutSdkMetadata(await core.runtime.getRuntime(flags.id, coreOptsFromCtx(ctx))), + ); }, }); diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index c0965369f..9a7e384cc 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -10,7 +10,7 @@ import { Divider } from "../../../components/ui/divider/Divider.js"; import { Spinner } from "../../../components/ui/spinner"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { withoutSdkMetadata } from "../components/withoutSdkMetadata"; +import { withoutSdkMetadata } from "../withoutSdkMetadata"; const ACTIONS = [ { diff --git a/src/handlers/runtime/list/index.tsx b/src/handlers/runtime/list/index.tsx index 08728b2e3..9b940533a 100644 --- a/src/handlers/runtime/list/index.tsx +++ b/src/handlers/runtime/list/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; +import { withoutSdkMetadata } from "../withoutSdkMetadata"; export const createListRuntimesHandler = (core: Core) => createHandler({ @@ -16,10 +17,12 @@ export const createListRuntimesHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - await core.runtime.listRuntimes( - flags["next-token"], - flags["max-results"], - coreOptsFromCtx(ctx), + withoutSdkMetadata( + await core.runtime.listRuntimes( + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), ), ); }, diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 01e769e8f..508fab2f5 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; +import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; import { CoreClient } from "../../core"; +import type { CreateControlClient } from "../../core/types"; import { createSilentLogger, fixtureFactories, @@ -22,8 +24,20 @@ const MISSING_RUNTIME_ID = "missing_runtime-0000000000"; function createFixtureCore(): CoreClient { const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + const createControlClientWithMetadata: CreateControlClient = (config) => { + const client = createControlClient(config); + const send = client.send.bind(client) as (command: unknown) => Promise>; + + return { + send: async (command: unknown) => ({ + ...(await send(command)), + $metadata: { requestId: "fixture-request-id" }, + }), + } as unknown as BedrockAgentCoreControlClient; + }; + return new CoreClient({ - createControlClient, + createControlClient: createControlClientWithMetadata, createDataClient, createIamClient, logger: createSilentLogger(), diff --git a/src/handlers/runtime/version/get/index.tsx b/src/handlers/runtime/version/get/index.tsx index 3d82c53b3..053b7e07f 100644 --- a/src/handlers/runtime/version/get/index.tsx +++ b/src/handlers/runtime/version/get/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; +import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createGetRuntimeVersionHandler = (core: Core) => createHandler({ @@ -23,7 +24,9 @@ export const createGetRuntimeVersionHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - await core.runtime.getRuntimeVersion(flags.id, flags.version, coreOptsFromCtx(ctx)), + withoutSdkMetadata( + await core.runtime.getRuntimeVersion(flags.id, flags.version, coreOptsFromCtx(ctx)), + ), ); }, }); diff --git a/src/handlers/runtime/version/get/screen.tsx b/src/handlers/runtime/version/get/screen.tsx index 8174b1b1e..a543b7dd6 100644 --- a/src/handlers/runtime/version/get/screen.tsx +++ b/src/handlers/runtime/version/get/screen.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; -import { withoutSdkMetadata } from "../../components/withoutSdkMetadata"; +import { withoutSdkMetadata } from "../../withoutSdkMetadata"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; diff --git a/src/handlers/runtime/version/list/index.tsx b/src/handlers/runtime/version/list/index.tsx index b2c20b599..a3f495a59 100644 --- a/src/handlers/runtime/version/list/index.tsx +++ b/src/handlers/runtime/version/list/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; +import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createListRuntimeVersionsHandler = (core: Core) => createHandler({ @@ -21,11 +22,13 @@ export const createListRuntimeVersionsHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - await core.runtime.listRuntimeVersions( - flags.id, - flags["next-token"], - flags["max-results"], - coreOptsFromCtx(ctx), + withoutSdkMetadata( + await core.runtime.listRuntimeVersions( + flags.id, + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), ), ); }, diff --git a/src/handlers/runtime/components/withoutSdkMetadata.ts b/src/handlers/runtime/withoutSdkMetadata.ts similarity index 100% rename from src/handlers/runtime/components/withoutSdkMetadata.ts rename to src/handlers/runtime/withoutSdkMetadata.ts From 9f4bfa0cc1b934b19d358b9ca8394c3a5085bede Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 03:12:27 +0000 Subject: [PATCH 22/38] fix: support pasted table filters --- src/components/TokenPagedTablePicker.test.tsx | 4 ++-- src/components/ui/data-table/DataTable.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/TokenPagedTablePicker.test.tsx b/src/components/TokenPagedTablePicker.test.tsx index 0c216b697..45bc6d047 100644 --- a/src/components/TokenPagedTablePicker.test.tsx +++ b/src/components/TokenPagedTablePicker.test.tsx @@ -280,7 +280,7 @@ describe("token-paged table picker contract", () => { ).toBe(false); }); - test("filter input does not page and resets selection to its first match", async () => { + test("pasted filter input does not page and resets selection to its first match", async () => { const alpha = harness({ harnessName: "alpha", harnessId: "alpha-1" }); const beta = harness({ harnessName: "beta", harnessId: "beta-2" }); const core = coreWith([alpha, beta]); @@ -291,7 +291,7 @@ describe("token-paged table picker contract", () => { await waitForText(r.lastFrame, "beta"); await r.press("down"); await r.write("/"); - for (const character of "alpha") await r.write(character); + await r.write("alpha"); await waitForText(r.lastFrame, "/ Filter this page: alpha"); expect( core.harness.calls.some((call) => call.method === "listHarnesses" && call.args[0] === "t2"), diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index a0676c2dc..1cf4b8591 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -116,7 +116,9 @@ export function DataTable>({ setSearchQuery((q) => q.slice(0, -1)); return; } - if (input && input.length === 1 && !key.ctrl) setSearchQuery((q) => q + input); + if (input && !key.ctrl && !key.meta && !/\p{Cc}/u.test(input)) { + setSearchQuery((q) => q + input); + } return; } From 07bee920382a7854a0929e0c070aca46ce44d879 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 03:17:39 +0000 Subject: [PATCH 23/38] test: stabilize Runtime TUI exit readiness --- src/components/RouterScreen.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 27c079bd7..1474b694d 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -373,7 +373,10 @@ describe("Runtime TUI exit", () => { ); const listCalls = () => core.runtime.calls.filter((call) => call.method === "listRuntimes"); - await waitFor(() => listCalls().length > 0 && streams.stdout().includes("page 1 · more →")); + await waitFor( + () => listCalls().length > 0 && streams.stdout().includes("page 1 · more →"), + 5_000, + ); const callsBeforeExit = listCalls().length; stdin.write(String.fromCharCode(3)); From 35118b6bd97ffe8445e9695c86f6150f15c67259 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 03:21:49 +0000 Subject: [PATCH 24/38] test: decouple Runtime exit readiness from rendering --- src/components/RouterScreen.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 1474b694d..2283ca6cb 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -373,10 +373,8 @@ describe("Runtime TUI exit", () => { ); const listCalls = () => core.runtime.calls.filter((call) => call.method === "listRuntimes"); - await waitFor( - () => listCalls().length > 0 && streams.stdout().includes("page 1 · more →"), - 5_000, - ); + await waitFor(() => listCalls().length > 0); + await tick(); const callsBeforeExit = listCalls().length; stdin.write(String.fromCharCode(3)); From e44e5e49334bb6dad2db1bf16ca504a6a836c821 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 03:25:59 +0000 Subject: [PATCH 25/38] refactor: align Runtime picker component placement --- .../runtime => }/components/RuntimeEndpointPicker.tsx | 6 +++--- src/{handlers/runtime => }/components/RuntimePicker.tsx | 6 +++--- .../runtime => }/components/RuntimeVersionPicker.tsx | 6 +++--- src/handlers/runtime/endpoint/list/screen.tsx | 4 ++-- src/handlers/runtime/list/screen.tsx | 2 +- src/handlers/runtime/version/list/screen.tsx | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) rename src/{handlers/runtime => }/components/RuntimeEndpointPicker.tsx (94%) rename src/{handlers/runtime => }/components/RuntimePicker.tsx (94%) rename src/{handlers/runtime => }/components/RuntimeVersionPicker.tsx (93%) diff --git a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx similarity index 94% rename from src/handlers/runtime/components/RuntimeEndpointPicker.tsx rename to src/components/RuntimeEndpointPicker.tsx index 5f21b07d5..524d436d8 100644 --- a/src/handlers/runtime/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -1,8 +1,8 @@ import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; -import { TokenPagedTablePicker } from "../../../components/TokenPagedTablePicker"; -import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; interface RuntimeEndpointRow extends Record { qualifier: string; diff --git a/src/handlers/runtime/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx similarity index 94% rename from src/handlers/runtime/components/RuntimePicker.tsx rename to src/components/RuntimePicker.tsx index aa4fbe945..b349c6bc5 100644 --- a/src/handlers/runtime/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -1,8 +1,8 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; -import { TokenPagedTablePicker } from "../../../components/TokenPagedTablePicker"; -import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; interface RuntimeRow extends Record { runtimeId: string; diff --git a/src/handlers/runtime/components/RuntimeVersionPicker.tsx b/src/components/RuntimeVersionPicker.tsx similarity index 93% rename from src/handlers/runtime/components/RuntimeVersionPicker.tsx rename to src/components/RuntimeVersionPicker.tsx index c904556ea..02588fb3b 100644 --- a/src/handlers/runtime/components/RuntimeVersionPicker.tsx +++ b/src/components/RuntimeVersionPicker.tsx @@ -1,8 +1,8 @@ import type { AgentRuntime } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; -import { TokenPagedTablePicker } from "../../../components/TokenPagedTablePicker"; -import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; interface RuntimeVersionRow extends Record { version: string; diff --git a/src/handlers/runtime/endpoint/list/screen.tsx b/src/handlers/runtime/endpoint/list/screen.tsx index 67a3600c4..bdfa79865 100644 --- a/src/handlers/runtime/endpoint/list/screen.tsx +++ b/src/handlers/runtime/endpoint/list/screen.tsx @@ -1,7 +1,7 @@ import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../../types"; -import { RuntimeEndpointPicker } from "../../components/RuntimeEndpointPicker"; -import { RuntimePicker } from "../../components/RuntimePicker"; +import { RuntimeEndpointPicker } from "../../../../components/RuntimeEndpointPicker"; +import { RuntimePicker } from "../../../../components/RuntimePicker"; export function RuntimeListEndpointsScreen(props: ScreenProps) { const navigate = useNavigate(); diff --git a/src/handlers/runtime/list/screen.tsx b/src/handlers/runtime/list/screen.tsx index eee307cbe..73233523b 100644 --- a/src/handlers/runtime/list/screen.tsx +++ b/src/handlers/runtime/list/screen.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router"; import type { ScreenProps } from "../../types"; -import { RuntimePicker } from "../components/RuntimePicker"; +import { RuntimePicker } from "../../../components/RuntimePicker"; export function RuntimeListScreen(props: ScreenProps) { const navigate = useNavigate(); diff --git a/src/handlers/runtime/version/list/screen.tsx b/src/handlers/runtime/version/list/screen.tsx index 9201ea842..bf9484063 100644 --- a/src/handlers/runtime/version/list/screen.tsx +++ b/src/handlers/runtime/version/list/screen.tsx @@ -1,7 +1,7 @@ import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../../types"; -import { RuntimePicker } from "../../components/RuntimePicker"; -import { RuntimeVersionPicker } from "../../components/RuntimeVersionPicker"; +import { RuntimePicker } from "../../../../components/RuntimePicker"; +import { RuntimeVersionPicker } from "../../../../components/RuntimeVersionPicker"; export function RuntimeListVersionsScreen(props: ScreenProps) { const navigate = useNavigate(); From 06c39f0ef6f4d377bc1173cafdfd3e4bdcbaaf54 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 03:30:01 +0000 Subject: [PATCH 26/38] test: replace fixed TUI delays with state waits --- src/components/RouterScreen.test.tsx | 5 +---- src/handlers/runtime/version/version.screen.test.tsx | 5 ++--- src/testing/renderScreen.tsx | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 2283ca6cb..06fa365e9 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -336,13 +336,10 @@ describe("navigation", () => { !frame.includes("agentcore → runtime → endpoint → get") ); }); - await tick(50); await r.press("escape"); await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); - await tick(50); await r.press("escape"); await waitForText(r.lastFrame, "agentcore → runtime → list"); - await tick(50); await r.press("escape"); await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); await r.press("escape"); @@ -381,7 +378,7 @@ describe("Runtime TUI exit", () => { await expect(renderPromise).resolves.toBeUndefined(); stdin.write("l"); - await tick(50); + await tick(); expect(listCalls()).toHaveLength(callsBeforeExit); expect(listCalls().some((call) => call.args[0] === "page-2")).toBe(false); }); diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 82ef00ec4..546d452f7 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -7,7 +7,6 @@ import { cleanupScreens, renderScreen, TestCoreClient, - tick, waitFor, waitForText, } from "../../../testing"; @@ -276,14 +275,14 @@ describe("Runtime version flow", () => { await list.press("return"); await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); await waitForText(list.lastFrame, "3"); - await tick(50); await list.press("escape"); await waitForRuntimePicker(list.lastFrame); + await waitForText(list.lastFrame, "[enter] select"); await list.press("return"); await waitForText(list.lastFrame, "agentcore → runtime → version → list → runtime-123"); await waitForText(list.lastFrame, "3"); - await tick(50); + await waitForText(list.lastFrame, "[enter] select"); await list.press("return"); await waitForText(list.lastFrame, '"agentRuntimeVersion"'); await list.press("escape"); diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index dea93d7b9..3d276479d 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -97,7 +97,7 @@ function setWindowSize(stdout: ResizableStdout, columns: number, rows: number): // keys maps friendly names to the escape sequences Ink decodes into key events. export const keys = { return: "\r", - escape: "", + escape: "\u001B[27u", up: "", down: "", left: "", From 97f175c8f29d17f204c7e63f7b6e944f84d54c3c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 04:37:19 +0000 Subject: [PATCH 27/38] refactor: align Runtime route registration with Harness --- src/components/Root.tsx | 68 +++++++++++++++++++++++++++++- src/handlers/runtime/routes.tsx | 74 --------------------------------- 2 files changed, 66 insertions(+), 76 deletions(-) delete mode 100644 src/handlers/runtime/routes.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index bc58095c3..5d59130e0 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -19,7 +19,15 @@ import { HarnessDeleteEndpointScreen } from "../handlers/harness/endpoint/delete import { HarnessVersionScreen } from "../handlers/harness/version/screen.tsx"; import { HarnessGetVersionScreen } from "../handlers/harness/version/get/screen.tsx"; import { HarnessListVersionsScreen } from "../handlers/harness/version/list/screen.tsx"; -import { runtimeRoutes } from "../handlers/runtime/routes.tsx"; +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 { 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"; +import { RuntimeListVersionsScreen } from "../handlers/runtime/version/list/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -190,7 +198,63 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/harness/version/list/:harnessId" element={} /> - {runtimeRoutes(ctx, core)} + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> diff --git a/src/handlers/runtime/routes.tsx b/src/handlers/runtime/routes.tsx deleted file mode 100644 index 622acb905..000000000 --- a/src/handlers/runtime/routes.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { ReactNode } from "react"; -import { Navigate, Route } from "react-router"; -import type { Context } from "../../router"; -import type { Core } from "../types"; -import { RuntimeGetEndpointScreen } from "./endpoint/get/screen"; -import { RuntimeListEndpointsScreen } from "./endpoint/list/screen"; -import { RuntimeEndpointScreen } from "./endpoint/screen"; -import { RuntimeGetJsonScreen, RuntimeGetScreen } from "./get/screen"; -import { RuntimeListScreen } from "./list/screen"; -import { RuntimeScreen } from "./screen"; -import { RuntimeGetVersionScreen } from "./version/get/screen"; -import { RuntimeListVersionsScreen } from "./version/list/screen"; -import { RuntimeVersionScreen } from "./version/screen"; - -export function runtimeRoutes(ctx: Context, core: Core): ReactNode { - return ( - <> - } /> - } - /> - } /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - ); -} From eaf3a2f7f8e2c8a026eb36d18030fb68c47bcd0f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 04:37:52 +0000 Subject: [PATCH 28/38] refactor: clarify Harness picker names --- .../{EndpointPicker.tsx => HarnessEndpointPicker.tsx} | 8 ++++---- .../{VersionPicker.tsx => HarnessVersionPicker.tsx} | 8 ++++---- src/handlers/harness/endpoint/delete/screen.tsx | 4 ++-- src/handlers/harness/endpoint/list/screen.tsx | 4 ++-- src/handlers/harness/endpoint/update/screen.tsx | 4 ++-- src/handlers/harness/invoke/screen.tsx | 4 ++-- src/handlers/harness/version/list/screen.tsx | 4 ++-- 7 files changed, 18 insertions(+), 18 deletions(-) rename src/components/{EndpointPicker.tsx => HarnessEndpointPicker.tsx} (94%) rename src/components/{VersionPicker.tsx => HarnessVersionPicker.tsx} (92%) diff --git a/src/components/EndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx similarity index 94% rename from src/components/EndpointPicker.tsx rename to src/components/HarnessEndpointPicker.tsx index 366586a60..f06a5e74c 100644 --- a/src/components/EndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -23,7 +23,7 @@ function toRow(e: HarnessEndpoint): EndpointRow { }; } -export interface EndpointPickerProps extends ScreenProps { +export interface HarnessEndpointPickerProps extends ScreenProps { // harnessId scopes the listing to one harness's endpoints. harnessId: string; // breadcrumb labels the screen the picker is serving. @@ -38,10 +38,10 @@ export interface EndpointPickerProps extends ScreenProps { onEscape?: () => void; } -// EndpointPicker fetches a harness's endpoints and renders them as a navigable +// HarnessEndpointPicker fetches a harness's endpoints and renders them as a navigable // table — the endpoint counterpart of HarnessPicker, shared by every "pick an // endpoint" screen (list, update, delete). Esc pops back. -export function EndpointPicker({ +export function HarnessEndpointPicker({ ctx, core, harnessId, @@ -49,7 +49,7 @@ export function EndpointPicker({ description, onSelect, onEscape, -}: EndpointPickerProps) { +}: HarnessEndpointPickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); const goBack = onEscape ?? (() => navigate(-1)); diff --git a/src/components/VersionPicker.tsx b/src/components/HarnessVersionPicker.tsx similarity index 92% rename from src/components/VersionPicker.tsx rename to src/components/HarnessVersionPicker.tsx index b03b79800..cd8dba0ec 100644 --- a/src/components/VersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -21,7 +21,7 @@ function toRow(v: HarnessVersionSummary): VersionRow { }; } -export interface VersionPickerProps extends ScreenProps { +export interface HarnessVersionPickerProps extends ScreenProps { // harnessId scopes the listing to one harness's versions. harnessId: string; // breadcrumb labels the screen the picker is serving. @@ -32,16 +32,16 @@ export interface VersionPickerProps extends ScreenProps { onSelect: (version: string) => void; } -// VersionPicker fetches a harness's versions and renders them as a navigable +// HarnessVersionPicker fetches a harness's versions and renders them as a navigable // table, newest first. Esc pops back. -export function VersionPicker({ +export function HarnessVersionPicker({ ctx, core, harnessId, breadcrumb, description, onSelect, -}: VersionPickerProps) { +}: HarnessVersionPickerProps) { const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); const goBack = () => navigate(-1); diff --git a/src/handlers/harness/endpoint/delete/screen.tsx b/src/handlers/harness/endpoint/delete/screen.tsx index 9a2e9ecee..122ccf987 100644 --- a/src/handlers/harness/endpoint/delete/screen.tsx +++ b/src/handlers/harness/endpoint/delete/screen.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; import { HarnessPicker } from "../../../../components/HarnessPicker"; -import { EndpointPicker } from "../../../../components/EndpointPicker"; +import { HarnessEndpointPicker } from "../../../../components/HarnessEndpointPicker"; import { ConfirmAction } from "../../../../components/ConfirmAction"; import { useFinishFlow } from "../../../../components/useFinishFlow"; @@ -26,7 +26,7 @@ export function HarnessDeleteEndpointScreen(props: ScreenProps) { } if (!endpointName) { return ( - Date: Wed, 22 Jul 2026 04:51:57 +0000 Subject: [PATCH 29/38] refactor: keep table filter copy generic --- src/components/TokenPagedTablePicker.test.tsx | 5 ++++ src/components/TokenPagedTablePicker.tsx | 2 ++ .../ui/data-table/DataTable.test.tsx | 24 +++++++++++++++++++ src/components/ui/data-table/DataTable.tsx | 8 +++---- 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 src/components/ui/data-table/DataTable.test.tsx diff --git a/src/components/TokenPagedTablePicker.test.tsx b/src/components/TokenPagedTablePicker.test.tsx index 45bc6d047..9ca99e6d1 100644 --- a/src/components/TokenPagedTablePicker.test.tsx +++ b/src/components/TokenPagedTablePicker.test.tsx @@ -291,6 +291,11 @@ describe("token-paged table picker contract", () => { await waitForText(r.lastFrame, "beta"); await r.press("down"); await r.write("/"); + await r.write("missing"); + await waitForText(r.lastFrame, "/ Filter this page: missing"); + expect(r.lastFrame()).toContain("No matches on this page"); + await r.press("escape"); + await r.write("/"); await r.write("alpha"); await waitForText(r.lastFrame, "/ Filter this page: alpha"); expect( diff --git a/src/components/TokenPagedTablePicker.tsx b/src/components/TokenPagedTablePicker.tsx index 9e35cfee3..99bf5c478 100644 --- a/src/components/TokenPagedTablePicker.tsx +++ b/src/components/TokenPagedTablePicker.tsx @@ -106,6 +106,8 @@ export function TokenPagedTablePicker { + const table = render( + , + ); + + await tick(); + table.stdin.write("/"); + await tick(); + table.stdin.write("missing"); + await waitFor(() => (table.lastFrame() ?? "").includes("missing")); + + const frame = table.lastFrame()!; + expect(frame).toContain("/ Filter: missing"); + expect(frame).toContain("No matches"); + expect(frame).not.toContain("No matches on this page"); +}); diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 1cf4b8591..17f92a6e3 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -18,6 +18,7 @@ export interface DataTableProps { pageSize?: number; searchable?: boolean; searchPlaceholder?: string; + noMatchesMessage?: string; onSelect?: (row: T, index: number) => void; /** Called when Escape is pressed while not in search mode (e.g. to go back). */ onEscape?: () => void; @@ -49,7 +50,8 @@ export function DataTable>({ data, pageSize = 10, searchable = true, - searchPlaceholder = "Filter this page", + searchPlaceholder = "Filter", + noMatchesMessage = "No matches", onSelect, onEscape, onPrevPage, @@ -255,9 +257,7 @@ export function DataTable>({ {/* Rows */} {pageData.length === 0 ? ( - - {searchQuery ? "No matches on this page" : emptyMessage} - + {searchQuery ? noMatchesMessage : emptyMessage} ) : ( pageData.map((row, i) => { From 0b3654167d881b709c6af292e1c2a16cf7a9175b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 04:56:11 +0000 Subject: [PATCH 30/38] test: remove redundant DataTable copy test --- .../ui/data-table/DataTable.test.tsx | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 src/components/ui/data-table/DataTable.test.tsx diff --git a/src/components/ui/data-table/DataTable.test.tsx b/src/components/ui/data-table/DataTable.test.tsx deleted file mode 100644 index 9f1570ea8..000000000 --- a/src/components/ui/data-table/DataTable.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; -import React from "react"; -import { cleanup, render } from "ink-testing-library"; -import { tick, waitFor } from "../../../testing"; -import { DataTable } from "./DataTable"; - -afterEach(cleanup); - -test("uses pagination-agnostic filter copy by default", async () => { - const table = render( - , - ); - - await tick(); - table.stdin.write("/"); - await tick(); - table.stdin.write("missing"); - await waitFor(() => (table.lastFrame() ?? "").includes("missing")); - - const frame = table.lastFrame()!; - expect(frame).toContain("/ Filter: missing"); - expect(frame).toContain("No matches"); - expect(frame).not.toContain("No matches on this page"); -}); From 4fed8f0c2f000482e732961a18b56007892a592f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 04:56:53 +0000 Subject: [PATCH 31/38] refactor: separate table reset responsibilities --- src/components/TokenPagedTablePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TokenPagedTablePicker.tsx b/src/components/TokenPagedTablePicker.tsx index 99bf5c478..b8e9cd3b3 100644 --- a/src/components/TokenPagedTablePicker.tsx +++ b/src/components/TokenPagedTablePicker.tsx @@ -105,7 +105,7 @@ export function TokenPagedTablePicker Date: Wed, 22 Jul 2026 05:05:19 +0000 Subject: [PATCH 32/38] refactor: simplify key hint width calculation --- bun.lock | 1 - package.json | 1 - src/components/ui/key-hint/KeyHint.test.tsx | 45 --------------------- src/components/ui/key-hint/KeyHint.tsx | 3 +- 4 files changed, 1 insertion(+), 49 deletions(-) delete mode 100644 src/components/ui/key-hint/KeyHint.test.tsx diff --git a/bun.lock b/bun.lock index 257d4b5b6..f001ff39c 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,6 @@ "react-router": "^8.1.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "string-width": "^8.2.2", "zod": "^4.4.3", }, "devDependencies": { diff --git a/package.json b/package.json index 866922c14..0b273d2b4 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "react-router": "^8.1.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", - "string-width": "^8.2.2", "zod": "^4.4.3" } } diff --git a/src/components/ui/key-hint/KeyHint.test.tsx b/src/components/ui/key-hint/KeyHint.test.tsx deleted file mode 100644 index ba6ab6dd4..000000000 --- a/src/components/ui/key-hint/KeyHint.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import React from "react"; -import { cleanup, render } from "ink-testing-library"; -import { KeyHint, type KeyHintItem } from "./KeyHint"; - -afterEach(cleanup); - -function renderAtWidth(keys: KeyHintItem[], columns: number): string { - const instance = render(<>); - Object.defineProperty(instance.stdout, "columns", { - configurable: true, - value: columns, - }); - instance.stdout.emit("resize"); - instance.rerender(); - return instance.lastFrame() ?? ""; -} - -describe("KeyHint", () => { - test("excludes a double-width hint that does not fit", () => { - const frame = renderAtWidth( - [ - { key: "界", label: "go" }, - { key: "enter", label: "select" }, - ], - 22, - ); - - expect(frame).not.toContain("[界] go"); - expect(frame).toContain("[enter] select"); - }); - - test("includes a combining-character hint when its terminal cells fit", () => { - const frame = renderAtWidth( - [ - { key: "e\u0301", label: "go" }, - { key: "enter", label: "select" }, - ], - 22, - ); - - expect(frame).toContain("[e\u0301] go"); - expect(frame).toContain("[enter] select"); - }); -}); diff --git a/src/components/ui/key-hint/KeyHint.tsx b/src/components/ui/key-hint/KeyHint.tsx index 0c89c427a..58b81b1a6 100644 --- a/src/components/ui/key-hint/KeyHint.tsx +++ b/src/components/ui/key-hint/KeyHint.tsx @@ -1,6 +1,5 @@ import React from "react"; import { Text, Box, useWindowSize } from "ink"; -import stringWidth from "string-width"; import { darkTheme } from "../_core.js"; import type { InkUITheme } from "../_core.js"; @@ -27,7 +26,7 @@ function priority({ key, label }: KeyHintItem): number { } function itemWidth({ key, label }: KeyHintItem): number { - return stringWidth(`[${key}] ${label}`); + return `[${key}] ${label}`.length; } function fitKeys(keys: KeyHintItem[], columns: number): KeyHintItem[] { From 0b11c1f0877528a8ec69e6b7daf63fc7edc594b6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Jul 2026 05:22:13 +0000 Subject: [PATCH 33/38] test: consolidate runtime TUI coverage --- src/components/RouterScreen.test.tsx | 11 ----- src/components/TokenPagedTablePicker.test.tsx | 37 ++++++++++++--- .../endpoint/list/list.screen.test.tsx | 2 +- .../harness/list/list.screen.test.tsx | 43 ++--------------- .../harness/version/list/list.screen.test.tsx | 2 +- .../runtime/endpoint/endpoint.screen.test.tsx | 27 ++--------- src/handlers/runtime/runtime.screen.test.tsx | 46 ++----------------- src/handlers/runtime/runtime.test.tsx | 26 ++--------- .../runtime/version/version.screen.test.tsx | 27 ++--------- src/tui/tui.test.tsx | 44 ++++++++---------- 10 files changed, 69 insertions(+), 196 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 06fa365e9..c549f3aea 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -286,17 +286,6 @@ describe("navigation", () => { r.unmount(); }); - test("ctrl+c is handled (quit) without crashing", async () => { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "❯ harness"); - - // ctrl+c is 0x03; the menu's handler calls exit(), which unmounts the app. - // Driving the branch must not throw; after exit the renderer stops updating. - await r.write(String.fromCharCode(3)); - await tick(20); - r.unmount(); - }); - test("navigates from Root through Runtime endpoints and escapes each boundary", async () => { const core = runtimeCore(); const r = renderScreen("/agentcore", { diff --git a/src/components/TokenPagedTablePicker.test.tsx b/src/components/TokenPagedTablePicker.test.tsx index 9ca99e6d1..144278a43 100644 --- a/src/components/TokenPagedTablePicker.test.tsx +++ b/src/components/TokenPagedTablePicker.test.tsx @@ -125,27 +125,52 @@ describe("token-paged table picker contract", () => { test("pages forward and backward using token history", async () => { const core = new TestCoreClient(); + const first = harness({ harnessName: "page-one-first", harnessId: "page-one-first" }); core.harness.setListResponse({ - harnesses: [harness({ harnessName: "page-one" })], + harnesses: [first, harness({ harnessName: "page-one-second", harnessId: "page-one-second" })], nextToken: "t2", }); - core.harness.setListResponse({ harnesses: [harness({ harnessName: "page-two" })] }, "t2"); + core.harness.setListResponse( + { + harnesses: [ + harness({ harnessName: "page-two-first", harnessId: "page-two-first" }), + harness({ harnessName: "page-two-second", harnessId: "page-two-second" }), + ], + }, + "t2", + ); + core.harness.setGetResponse(getResponse(first)); const r = renderScreen("/agentcore/harness/list", { core }); await waitForText(r.lastFrame, "page 1 · more →"); expect(r.lastFrame()).toContain("[←→/hl] page"); + expect(core.harness.calls.filter((call) => call.method === "listHarnesses")).toEqual([ + { + method: "listHarnesses", + args: [undefined, 32, { region: "us-east-1", endpointUrl: undefined }], + }, + ]); + await r.press("down"); + await waitForText(r.lastFrame, "❯ page-one-second"); await r.write("l"); - await waitForText(r.lastFrame, "page-two"); + await waitForText(r.lastFrame, "❯ page-two-first"); expect(core.harness.calls.at(-1)).toEqual({ method: "listHarnesses", args: ["t2", 32, { region: "us-east-1", endpointUrl: undefined }], }); + await r.press("down"); + await waitForText(r.lastFrame, "❯ page-two-second"); await r.write("h"); - await waitForText(r.lastFrame, "page-one"); + await waitForText(r.lastFrame, "❯ page-one-first"); expect(r.lastFrame()).toContain("page 1"); - await r.press("escape"); - await waitForText(r.lastFrame, "manage agentcore harnesses"); + await r.press("return"); + await waitForText(r.lastFrame, "agentcore → harness → get → page-one-first"); + expect( + core.harness.calls.some( + (call) => call.method === "getHarness" && call.args[0] === "page-one-second", + ), + ).toBe(false); }); test("keeps pagination status and key hints coherent at 50x20", async () => { diff --git a/src/handlers/harness/endpoint/list/list.screen.test.tsx b/src/handlers/harness/endpoint/list/list.screen.test.tsx index 3decd4ebf..6f0661b73 100644 --- a/src/handlers/harness/endpoint/list/list.screen.test.tsx +++ b/src/handlers/harness/endpoint/list/list.screen.test.tsx @@ -74,7 +74,7 @@ describe("harness endpoint list screen", () => { args: [ "MyHarness-abc123", undefined, - 32, + expect.any(Number), { region: "us-east-1", endpointUrl: undefined, diff --git a/src/handlers/harness/list/list.screen.test.tsx b/src/handlers/harness/list/list.screen.test.tsx index b36068ead..d34f51c51 100644 --- a/src/handlers/harness/list/list.screen.test.tsx +++ b/src/handlers/harness/list/list.screen.test.tsx @@ -33,13 +33,6 @@ function coreWith(harnesses: HarnessSummary[]): TestCoreClient { return core; } -// A HarnessSummary is enough for the detail screen's render (it stringifies -// whatever `harness` field it gets), so reuse it as the get response, widening -// to the response's `harness` type. -function getResponse(summary: HarnessSummary) { - return { harness: summary } as Parameters[0]; -} - describe("harness list screen", () => { test("renders each harness as a row once loaded", async () => { const core = coreWith([ @@ -67,19 +60,17 @@ describe("harness list screen", () => { expect(narrow).not.toContain("READY"); }); - test("makes one initial list request at the 100x40 page size with context options", async () => { + test("makes one initial list request with context options", async () => { const core = coreWith([harness()]); const r = renderScreen("/agentcore/harness/list", { core }); - await waitFor(() => - core.harness.calls.some((call) => call.method === "listHarnesses" && call.args[1] === 32), - ); + await waitFor(() => core.harness.calls.some((call) => call.method === "listHarnesses")); expect(core.harness.calls.filter((call) => call.method === "listHarnesses")).toEqual([ { method: "listHarnesses", args: [ undefined, - 32, + expect.any(Number), { region: "us-east-1", endpointUrl: undefined, @@ -89,32 +80,4 @@ describe("harness list screen", () => { ]); r.unmount(); }); - - test("enter on a row navigates to that harness's detail screen", async () => { - const core = coreWith([harness({ harnessName: "pickme", harnessId: "pickme-9" })]); - // The get screen refetches the single harness; give it a response too. - core.harness.setGetResponse( - getResponse(harness({ harnessName: "pickme", harnessId: "pickme-9" })), - ); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "pickme"); - await r.press("return"); - // Detail screen breadcrumb includes the harness id. - await waitForText(r.lastFrame, "pickme-9"); - r.unmount(); - }); - - test("esc returns to the harness menu", async () => { - const core = coreWith([harness()]); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "MyHarness"); - await r.press("escape"); - // Back at the harness command menu — it shows the harness subcommands (e.g. - // the "get a harness" option), which the list table never does. - await waitForText(r.lastFrame, "get a harness"); - expect(r.lastFrame()).toContain("manage agentcore harnesses"); - r.unmount(); - }); }); diff --git a/src/handlers/harness/version/list/list.screen.test.tsx b/src/handlers/harness/version/list/list.screen.test.tsx index af9f5b90a..a4afe7051 100644 --- a/src/handlers/harness/version/list/list.screen.test.tsx +++ b/src/handlers/harness/version/list/list.screen.test.tsx @@ -70,7 +70,7 @@ describe("harness version list screen", () => { args: [ "MyHarness-abc123", undefined, - 32, + expect.any(Number), { region: "us-east-1", endpointUrl: undefined, diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 478d5e6d8..3f5ff9741 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -97,41 +97,21 @@ describe("Runtime endpoint flow", () => { ).toBe(true); }); - test("skips parent selection when the Runtime ID is already scoped", async () => { - const core = new TestCoreClient(); - core.runtime.setListEndpointsResponse({ - runtimeEndpoints: [endpoint()], - }); - const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "prod"); - expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); - expect( - core.runtime.calls.some( - (call) => call.method === "listRuntimeEndpoints" && call.args[0] === "runtime-123", - ), - ).toBe(true); - }); - - test("calls listRuntimeEndpoints with exact scope, page size, and options", async () => { + test("calls listRuntimeEndpoints once with exact scope and options", async () => { const core = new TestCoreClient(); core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()], }); renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); - await waitFor(() => - core.runtime.calls.some( - (call) => call.method === "listRuntimeEndpoints" && call.args[2] === 32, - ), - ); + await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimeEndpoints")); expect(core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints")).toEqual([ { method: "listRuntimeEndpoints", args: [ "runtime-123", undefined, - 32, + expect.any(Number), { region: "us-east-1", endpointUrl: undefined, @@ -139,6 +119,7 @@ describe("Runtime endpoint flow", () => { ], }, ]); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); }); test("renders qualifier, live and target versions, status, and update time when wide", async () => { diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 93274bf8c..6ecfb4750 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -67,44 +67,6 @@ function coreWithRuntimes(runtimes: AgentRuntime[]): TestCoreClient { return core; } -async function waitForRuntimeMenu(lastFrame: () => string | undefined): Promise { - await waitFor(() => - (lastFrame() ?? "").includes("agentcore → runtime → inspect AgentCore Runtimes"), - ); -} - -describe("runtime menus", () => { - test("renders the Runtime command menu", async () => { - const r = renderScreen("/agentcore/runtime"); - await waitForText(r.lastFrame, "agentcore → runtime"); - - const frame = r.lastFrame()!; - for (const command of ["get", "list", "version", "endpoint"]) { - expect(frame).toContain(command); - } - }); - - test("renders the Runtime version command menu", async () => { - const r = renderScreen("/agentcore/runtime/version"); - await waitForText(r.lastFrame, "agentcore → runtime → version"); - - const frame = r.lastFrame()!; - for (const command of ["get", "list"]) { - expect(frame).toContain(command); - } - }); - - test("renders the Runtime endpoint command menu", async () => { - const r = renderScreen("/agentcore/runtime/endpoint"); - await waitForText(r.lastFrame, "agentcore → runtime → endpoint"); - - const frame = r.lastFrame()!; - for (const command of ["get", "list"]) { - expect(frame).toContain(command); - } - }); -}); - describe("runtime picker", () => { test("renders Runtime identity, latest version, status, and update time when wide", async () => { const core = coreWithRuntimes([ @@ -132,17 +94,17 @@ describe("runtime picker", () => { expect(frame).toContain("2026-07-19T01:02:03.000Z"); }); - test("calls listRuntimes with terminal page size and exact Core options", async () => { + test("calls listRuntimes once with exact Core options", async () => { const core = coreWithRuntimes([runtime()]); renderScreen("/agentcore/runtime/list", { core }); - await waitFor(() => core.runtime.calls.some((call) => call.args[1] === 32)); + await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimes")); expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toEqual([ { method: "listRuntimes", args: [ undefined, - 32, + expect.any(Number), { region: "us-east-1", endpointUrl: undefined, @@ -203,7 +165,7 @@ describe("runtime picker", () => { await waitForText(r.lastFrame, "checkout"); await r.press("escape"); - await waitForRuntimeMenu(r.lastFrame); + await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); expect(r.lastFrame()).toContain("list AgentCore Runtimes"); }); diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 508fab2f5..295f901ac 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -55,9 +55,9 @@ async function run(args: string[]): Promise { return io.stdout(); } -function testRuntimeCommand(isTTY = false) { +function testRuntimeCommand() { const core = new TestCoreClient(); - const io = testIO({ isTTY }); + const io = testIO(); const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), @@ -65,7 +65,6 @@ function testRuntimeCommand(isTTY = false) { return { core, - io, route: (args: string[]) => root.route(["node", "agentcore", ...args, "--region", REGION]), }; } @@ -115,10 +114,6 @@ describe("runtime command hierarchy", () => { describe("runtime TUI dispatch", () => { test.each([ ["get", ["runtime", "get"]], - ["list", ["runtime", "list"]], - ["version get", ["runtime", "version", "get"]], - ["version list", ["runtime", "version", "list"]], - ["endpoint get", ["runtime", "endpoint", "get"]], ["endpoint list", ["runtime", "endpoint", "list"]], ] as const)("opens the TUI for a bare Runtime %s leaf", async (_label, args) => { const { core, route } = testRuntimeCommand(); @@ -128,21 +123,6 @@ describe("runtime TUI dispatch", () => { ); expect(core.runtime.calls).toEqual([]); }); - - test("keeps Runtime leaves with operation flags headless", async () => { - const { core, io, route } = testRuntimeCommand(); - core.runtime.setListResponse({ agentRuntimes: [], nextToken: "page-2" }); - - await route(["runtime", "list", "--max-results", "1"]); - - expect(JSON.parse(io.stdout())).toEqual({ agentRuntimes: [], nextToken: "page-2" }); - expect(core.runtime.calls).toEqual([ - { - method: "listRuntimes", - args: [undefined, 1, { region: REGION, endpointUrl: undefined }], - }, - ]); - }); }); describe("runtime read-only commands", () => { @@ -290,7 +270,7 @@ describe("runtime read-only commands", () => { ] as const)( "rejects a missing required selector for headless `%s`", async (_label, args, message) => { - expect(run([...args, "--json"])).rejects.toThrow(message); + await expect(run([...args, "--json"])).rejects.toThrow(message); }, ); diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 546d452f7..49f8637bf 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -83,41 +83,21 @@ describe("Runtime version flow", () => { ).toBe(true); }); - test("skips parent selection when the Runtime ID is already scoped", async () => { - const core = new TestCoreClient(); - core.runtime.setListVersionsResponse({ - agentRuntimes: [runtime({ agentRuntimeVersion: "8" })], - }); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - - await waitForText(r.lastFrame, "8"); - expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); - expect( - core.runtime.calls.some( - (call) => call.method === "listRuntimeVersions" && call.args[0] === "runtime-123", - ), - ).toBe(true); - }); - - test("calls listRuntimeVersions with exact scope, page size, and options", async () => { + test("calls listRuntimeVersions once with exact scope and options", async () => { const core = new TestCoreClient(); core.runtime.setListVersionsResponse({ agentRuntimes: [runtime()], }); renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - await waitFor(() => - core.runtime.calls.some( - (call) => call.method === "listRuntimeVersions" && call.args[2] === 32, - ), - ); + await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimeVersions")); expect(core.runtime.calls.filter((call) => call.method === "listRuntimeVersions")).toEqual([ { method: "listRuntimeVersions", args: [ "runtime-123", undefined, - 32, + expect.any(Number), { region: "us-east-1", endpointUrl: undefined, @@ -125,6 +105,7 @@ describe("Runtime version flow", () => { ], }, ]); + expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); }); test("renders numeric versions newest first with only version status and update time", async () => { diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 058c620be..b84e5ec07 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -42,32 +42,24 @@ describe("--json short-circuits the TUI", () => { }); describe("TUI stream boundary", () => { - test("test IO defaults every stream to non-TTY and supports explicit TTY streams", () => { - const nonTty = testIO(); - const tty = testIO({ isTTY: true }); - - for (const stream of [nonTty.io.stdin, nonTty.io.stdout, nonTty.io.stderr]) { - expect(stream.isTTY).toBe(false); - expect(Object.getOwnPropertyDescriptor(stream, "isTTY")?.configurable).toBe(true); - } - for (const stream of [tty.io.stdin, tty.io.stdout, tty.io.stderr]) { - expect(stream.isTTY).toBe(true); - expect(Object.getOwnPropertyDescriptor(stream, "isTTY")?.configurable).toBe(true); - } - }); - test.each([ - ["bare root", []], - ["Harness", ["harness"]], - ] as const)("rejects non-TTY streams at the shared boundary for %s", async (_label, args) => { - const io = testIO(); - const root = createRootHandler(new TestCoreClient(), { - io: io.io, - logger: createSilentLogger(), - }); + ["stdin and stdout", false, false], + ["stdin", false, true], + ["stdout", true, false], + ] as const)( + "rejects interactive mode when %s is non-TTY", + async (_label, stdinIsTTY, stdoutIsTTY) => { + const io = testIO({ isTTY: true }); + Object.defineProperty(io.io.stdin, "isTTY", { configurable: true, value: stdinIsTTY }); + Object.defineProperty(io.io.stdout, "isTTY", { configurable: true, value: stdoutIsTTY }); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + logger: createSilentLogger(), + }); - await expect(root.route(["node", "agentcore", ...args])).rejects.toThrow( - "interactive mode requires a TTY on stdin and stdout", - ); - }); + await expect(root.route(["node", "agentcore"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }, + ); }); From 88834c4bd94bbb0addf2a654dcbaca7deabf0682 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 03:31:38 +0000 Subject: [PATCH 34/38] refactor: simplify paged picker implementation --- src/components/HarnessEndpointPicker.tsx | 31 +-- src/components/HarnessPicker.tsx | 25 +- src/components/HarnessVersionPicker.tsx | 19 +- src/components/RouterScreen.test.tsx | 236 +----------------- src/components/RuntimeEndpointPicker.tsx | 41 +-- src/components/RuntimePicker.tsx | 35 +-- src/components/RuntimeVersionPicker.tsx | 25 +- src/components/TokenPagedTablePicker.test.tsx | 51 +--- src/components/TokenPagedTablePicker.tsx | 9 +- src/components/ui/data-table/DataTable.tsx | 11 +- src/components/ui/key-hint/KeyHint.tsx | 61 +---- .../endpoint/list/list.screen.test.tsx | 33 +-- .../harness/list/list.screen.test.tsx | 11 +- .../harness/version/list/list.screen.test.tsx | 29 +-- .../runtime/endpoint/endpoint.screen.test.tsx | 63 ++--- src/handlers/runtime/runtime.screen.test.tsx | 40 +-- .../runtime/version/version.screen.test.tsx | 59 ++--- src/testing/renderScreen.tsx | 8 +- src/tui/tui.test.tsx | 62 ++++- 19 files changed, 201 insertions(+), 648 deletions(-) diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx index f06a5e74c..dc07cc215 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -67,30 +67,13 @@ export function HarnessEndpointPicker({ }; }} toRow={toRow} - columns={(terminalColumns) => { - const liveWidth = 8; - const showTarget = terminalColumns >= 60; - const showStatus = terminalColumns >= 70; - const showUpdatedAt = terminalColumns >= 90; - const targetWidth = showTarget ? 8 : 0; - const statusWidth = showStatus ? 20 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const nameWidth = Math.max( - 12, - terminalColumns - 2 - liveWidth - targetWidth - statusWidth - updatedAtWidth, - ); - return [ - { key: "endpointName", header: "name", width: nameWidth }, - { key: "liveVersion", header: "live", width: liveWidth }, - ...(showTarget - ? [{ key: "targetVersion" as const, header: "target", width: targetWidth }] - : []), - ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), - ...(showUpdatedAt - ? [{ key: "updatedAt" as const, header: "updatedAt", width: updatedAtWidth }] - : []), - ]; - }} + columns={[ + { key: "endpointName", header: "name" }, + { key: "liveVersion", header: "live" }, + { key: "targetVersion", header: "target" }, + { key: "status", header: "status" }, + { key: "updatedAt", header: "updatedAt" }, + ]} getValue={(row) => row.endpointName} onSelect={onSelect} onBack={goBack} diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index f16bf4032..b51063df5 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -66,25 +66,12 @@ export function HarnessPicker({ }; }} toRow={toRow} - columns={(terminalColumns) => { - const versionWidth = 10; - const showStatus = terminalColumns >= 70; - const showUpdatedAt = terminalColumns >= 90; - const statusWidth = showStatus ? 20 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const nameWidth = Math.max( - 12, - terminalColumns - 2 - versionWidth - statusWidth - updatedAtWidth, - ); - return [ - { key: "harnessName", header: "name", width: nameWidth }, - { key: "harnessVersion", header: "version", width: versionWidth }, - ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), - ...(showUpdatedAt - ? [{ key: "updatedAt" as const, header: "updatedAt", width: updatedAtWidth }] - : []), - ]; - }} + columns={[ + { key: "harnessName", header: "name" }, + { key: "harnessVersion", header: "version" }, + { key: "status", header: "status" }, + { key: "updatedAt", header: "updatedAt" }, + ]} getValue={(row) => row.harnessId} onSelect={onSelect} onBack={goBack} diff --git a/src/components/HarnessVersionPicker.tsx b/src/components/HarnessVersionPicker.tsx index cd8dba0ec..18efb9f3f 100644 --- a/src/components/HarnessVersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -59,20 +59,11 @@ export function HarnessVersionPicker({ }; }} toRow={toRow} - columns={(terminalColumns) => { - const showStatus = terminalColumns >= 60; - const showCreatedAt = terminalColumns >= 90; - const statusWidth = showStatus ? 20 : 0; - const createdAtWidth = showCreatedAt ? 30 : 0; - const versionWidth = Math.max(8, terminalColumns - 2 - statusWidth - createdAtWidth); - return [ - { key: "harnessVersion", header: "version", width: versionWidth }, - ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), - ...(showCreatedAt - ? [{ key: "createdAt" as const, header: "createdAt", width: createdAtWidth }] - : []), - ]; - }} + columns={[ + { key: "harnessVersion", header: "version" }, + { key: "status", header: "status" }, + { key: "createdAt", header: "createdAt" }, + ]} sortRows={(rows) => [...rows].sort((left, right) => Number(right.harnessVersion) - Number(left.harnessVersion)) } diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index c549f3aea..0fe9c235c 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -1,156 +1,8 @@ import { test, expect, describe, afterEach } from "bun:test"; -import type { - AgentRuntime, - AgentRuntimeEndpoint, - GetAgentRuntimeEndpointResponse, - GetAgentRuntimeResponse, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { createRootHandler } from "../handlers"; -import { DebugKey, EndpointKey, JsonKey, RegionKey } from "../handlers/keys"; -import { CommandKey, compile, type Context, ValueContext } from "../router"; -import { - cleanupScreens, - createSilentLogger, - renderScreen, - TestCoreClient, - testIO, - tick, - waitFor, - waitForText, -} from "../testing"; -import { JsonRendererKey, renderTuiAt } from "../tui"; +import { cleanupScreens, renderScreen, tick, waitForText } from "../testing"; afterEach(cleanupScreens); -const runtimeEndpointUrl = "https://runtime.test"; - -function runtime(overrides: Partial = {}): AgentRuntime { - return { - agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", - agentRuntimeId: "runtime-123", - agentRuntimeVersion: "7", - agentRuntimeName: "checkout", - description: "Checkout Runtime", - lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), - status: "READY", - ...overrides, - }; -} - -function getRuntimeResponse( - overrides: Partial = {}, -): GetAgentRuntimeResponse { - return { - agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", - agentRuntimeName: "checkout", - agentRuntimeId: "runtime-123", - agentRuntimeVersion: "7", - 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" }, - status: "READY", - lifecycleConfiguration: { - idleRuntimeSessionTimeout: 900, - maxLifetime: 28_800, - }, - ...overrides, - }; -} - -function endpoint(overrides: Partial = {}): AgentRuntimeEndpoint { - return { - name: "prod", - liveVersion: "7", - agentRuntimeEndpointArn: - "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", - agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", - status: "READY", - id: "prod", - createdAt: new Date("2026-07-19T01:02:03.000Z"), - lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), - ...overrides, - }; -} - -function getEndpointResponse( - overrides: Partial = {}, -): GetAgentRuntimeEndpointResponse { - return { - liveVersion: "7", - targetVersion: "8", - agentRuntimeEndpointArn: - "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123/endpoint/prod", - agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", - status: "READY", - createdAt: new Date("2026-07-19T01:02:03.000Z"), - lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), - name: "prod", - id: "prod", - ...overrides, - }; -} - -function runtimeCore(): TestCoreClient { - const core = new TestCoreClient(); - core.runtime.setListResponse({ agentRuntimes: [runtime()] }); - core.runtime.setGetResponse(getRuntimeResponse()); - core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] }); - core.runtime.setGetEndpointResponse(getEndpointResponse()); - return core; -} - -function runtimeContext(core: TestCoreClient): Context { - const rootCommand = compile( - createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }), - ValueContext.EmptyContext(), - ); - - return ValueContext.EmptyContext() - .withValue(CommandKey, rootCommand) - .withValue(RegionKey, "us-east-1") - .withValue(EndpointKey, runtimeEndpointUrl) - .withValue(JsonKey, false) - .withValue(DebugKey, false) - .withValue(JsonRendererKey, { renderJson: () => {} }); -} - -function expectEndpointPropagation(core: TestCoreClient, methods: readonly string[]): void { - for (const method of methods) { - const calls = core.runtime.calls.filter((call) => call.method === method); - expect(calls.length).toBeGreaterThan(0); - for (const call of calls) { - expect(call.args.at(-1)).toEqual({ - region: "us-east-1", - endpointUrl: runtimeEndpointUrl, - }); - } - } -} - -interface TtyInput extends NodeJS.ReadStream { - write(chunk: string): boolean; -} - -function ttyTestIO(): { streams: ReturnType; stdin: TtyInput } { - const streams = testIO({ isTTY: true }); - const stdin = streams.io.stdin as TtyInput; - stdin.setRawMode = function () { - return this; - }; - stdin.ref = function () { - return this; - }; - stdin.unref = function () { - return this; - }; - Object.defineProperties(streams.io.stdout, { - columns: { configurable: true, value: 100 }, - rows: { configurable: true, value: 40 }, - }); - return { streams, stdin }; -} - // RouterScreen is the interactive command menu. These tests mount it through the // real Root at a command path and drive it with key presses, asserting on the // rendered frames — behavior a user would see, not internal state. @@ -285,90 +137,4 @@ describe("navigation", () => { expect(r.lastFrame()).toContain("❯ harness"); r.unmount(); }); - - test("navigates from Root through Runtime endpoints and escapes each boundary", async () => { - const core = runtimeCore(); - const r = renderScreen("/agentcore", { - core, - ctx: runtimeContext(core), - }); - - await waitForText(r.lastFrame, "❯ harness"); - await r.press("down"); - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); - - await r.press("down"); - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → list"); - await waitForText(r.lastFrame, "checkout"); - - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); - await waitForText(r.lastFrame, "show the full JSON definition"); - - await r.press("down"); - await r.press("down"); - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → endpoint → list → runtime-123"); - await waitForText(r.lastFrame, "prod"); - - await r.press("return"); - await waitForText(r.lastFrame, "agentcore → runtime → endpoint → get → runtime-123 → prod"); - await waitForText(r.lastFrame, '"agentRuntimeEndpointArn"'); - - await r.press("escape"); - await waitFor(() => { - const frame = r.lastFrame() ?? ""; - return ( - frame.includes("agentcore → runtime → endpoint → list → runtime-123") && - !frame.includes("agentcore → runtime → endpoint → get") - ); - }); - await r.press("escape"); - await waitForText(r.lastFrame, "agentcore → runtime → get → runtime-123"); - await r.press("escape"); - await waitForText(r.lastFrame, "agentcore → runtime → list"); - await r.press("escape"); - await waitForText(r.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); - await r.press("escape"); - await waitForText(r.lastFrame, "the platform for production AI agents"); - - expectEndpointPropagation(core, [ - "listRuntimes", - "getRuntime", - "listRuntimeEndpoints", - "getRuntimeEndpoint", - ]); - }); -}); - -describe("Runtime TUI exit", () => { - test("Ctrl+C exits a production Runtime list and ignores input after exit", async () => { - const core = new TestCoreClient(); - core.runtime.setListResponse({ - agentRuntimes: [runtime()], - nextToken: "page-2", - }); - const { streams, stdin } = ttyTestIO(); - const renderPromise = renderTuiAt( - "/agentcore/runtime/list", - runtimeContext(core), - core, - streams.io, - ); - const listCalls = () => core.runtime.calls.filter((call) => call.method === "listRuntimes"); - - await waitFor(() => listCalls().length > 0); - await tick(); - const callsBeforeExit = listCalls().length; - - stdin.write(String.fromCharCode(3)); - await expect(renderPromise).resolves.toBeUndefined(); - - stdin.write("l"); - await tick(); - expect(listCalls()).toHaveLength(callsBeforeExit); - expect(listCalls().some((call) => call.args[0] === "page-2")).toBe(false); - }); }); diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index 524d436d8..0a06bbd68 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -54,40 +54,13 @@ export function RuntimeEndpointPicker({ }; }} toRow={toRow} - columns={(terminalColumns) => { - const showUpdatedAt = terminalColumns >= 90; - const showStatus = terminalColumns >= 70; - const showTarget = terminalColumns >= 60; - const liveWidth = 8; - const targetWidth = showTarget ? 8 : 0; - const statusWidth = showStatus ? 20 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const qualifierWidth = Math.max( - 12, - terminalColumns - 2 - liveWidth - targetWidth - statusWidth - updatedAtWidth, - ); - return [ - { - key: "qualifier", - header: "qualifier", - width: qualifierWidth, - }, - { key: "liveVersion", header: "live", width: liveWidth }, - ...(showTarget - ? [{ key: "targetVersion" as const, header: "target", width: targetWidth }] - : []), - ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), - ...(showUpdatedAt - ? [ - { - key: "lastUpdatedAt" as const, - header: "lastUpdatedAt", - width: updatedAtWidth, - }, - ] - : []), - ]; - }} + columns={[ + { key: "qualifier", header: "qualifier" }, + { key: "liveVersion", header: "live" }, + { key: "targetVersion", header: "target" }, + { key: "status", header: "status" }, + { key: "lastUpdatedAt", header: "lastUpdatedAt" }, + ]} getValue={(row) => row.qualifier} onSelect={onSelect} onBack={goBack} diff --git a/src/components/RuntimePicker.tsx b/src/components/RuntimePicker.tsx index b349c6bc5..a0ad25d9c 100644 --- a/src/components/RuntimePicker.tsx +++ b/src/components/RuntimePicker.tsx @@ -53,34 +53,13 @@ export function RuntimePicker({ }; }} toRow={toRow} - columns={(terminalColumns) => { - const showId = terminalColumns >= 130; - const showUpdatedAt = terminalColumns >= 90; - const showStatus = terminalColumns >= 70; - const versionWidth = 15; - const statusWidth = showStatus ? 16 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const idWidth = showId ? Math.max(30, Math.floor(terminalColumns * 0.36)) : 0; - const nameWidth = Math.max( - 12, - terminalColumns - 2 - versionWidth - statusWidth - updatedAtWidth - idWidth, - ); - return [ - { key: "runtimeName", header: "name", width: nameWidth }, - ...(showId ? [{ key: "runtimeId" as const, header: "id", width: idWidth }] : []), - { key: "runtimeVersion", header: "latestVersion", width: versionWidth }, - ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), - ...(showUpdatedAt - ? [ - { - key: "lastUpdatedAt" as const, - header: "lastUpdatedAt", - width: updatedAtWidth, - }, - ] - : []), - ]; - }} + columns={[ + { key: "runtimeName", header: "name" }, + { key: "runtimeId", header: "id" }, + { key: "runtimeVersion", header: "latestVersion" }, + { key: "status", header: "status" }, + { key: "lastUpdatedAt", header: "lastUpdatedAt" }, + ]} getValue={(row) => row.runtimeId} onSelect={onSelect} onBack={goBack} diff --git a/src/components/RuntimeVersionPicker.tsx b/src/components/RuntimeVersionPicker.tsx index 02588fb3b..c40a844b9 100644 --- a/src/components/RuntimeVersionPicker.tsx +++ b/src/components/RuntimeVersionPicker.tsx @@ -50,26 +50,11 @@ export function RuntimeVersionPicker({ }; }} toRow={toRow} - columns={(terminalColumns) => { - const showUpdatedAt = terminalColumns >= 90; - const showStatus = terminalColumns >= 60; - const statusWidth = showStatus ? 20 : 0; - const updatedAtWidth = showUpdatedAt ? 30 : 0; - const versionWidth = Math.max(8, terminalColumns - 2 - statusWidth - updatedAtWidth); - return [ - { key: "version", header: "version", width: versionWidth }, - ...(showStatus ? [{ key: "status" as const, header: "status", width: statusWidth }] : []), - ...(showUpdatedAt - ? [ - { - key: "lastUpdatedAt" as const, - header: "lastUpdatedAt", - width: updatedAtWidth, - }, - ] - : []), - ]; - }} + columns={[ + { key: "version", header: "version" }, + { key: "status", header: "status" }, + { key: "lastUpdatedAt", header: "lastUpdatedAt" }, + ]} sortRows={(rows) => [...rows].sort((left, right) => Number(right.version) - Number(left.version)) } diff --git a/src/components/TokenPagedTablePicker.test.tsx b/src/components/TokenPagedTablePicker.test.tsx index 144278a43..29851dd3b 100644 --- a/src/components/TokenPagedTablePicker.test.tsx +++ b/src/components/TokenPagedTablePicker.test.tsx @@ -31,14 +31,6 @@ function getResponse(summary: HarnessSummary) { return { harness: summary } as Parameters[0]; } -function frameSize(frame: string): { columns: number; rows: number } { - const lines = frame.split("\n"); - return { - columns: Math.max(...lines.map((line) => line.length)), - rows: lines.length, - }; -} - describe("token-paged table picker contract", () => { test("retries a failed query", async () => { const core = new TestCoreClient(); @@ -173,44 +165,6 @@ describe("token-paged table picker contract", () => { ).toBe(false); }); - test("keeps pagination status and key hints coherent at 50x20", async () => { - const core = new TestCoreClient(); - core.harness.setListResponse({ - harnesses: Array.from({ length: 12 }, (_, index) => - harness({ - harnessId: `narrow-row-${index + 1}`, - harnessName: `narrow-row-${index + 1}`, - }), - ), - nextToken: "t2", - }); - const r = renderScreen("/agentcore/harness/list", { core }); - - await waitForText(r.lastFrame, "narrow-row-12"); - await r.resize(50, 20); - await waitForText(r.lastFrame, "page 1 · more →"); - const frame = r.lastFrame()!; - const lines = frame.split("\n"); - const pageLine = lines.find((line) => line.includes("page 1 · more →")); - const footerLine = lines.find((line) => line.includes("[↑↓/jk] navigate")); - - expect(frameSize(frame)).toEqual({ columns: 50, rows: 20 }); - expect(pageLine?.trim()).toBe("page 1 · more →"); - expect(pageLine).not.toContain("narrow-row"); - expect(footerLine).toContain("[enter] select"); - expect(footerLine).toContain("[esc] back"); - expect(frame).not.toContain("[←→/hl] page"); - expect(frame).not.toContain("[/] filter"); - expect(frame).not.toContain("[ctl+c] quit"); - expect(footerLine!.indexOf("[↑↓/jk] navigate")).toBeLessThan( - footerLine!.indexOf("[enter] select"), - ); - expect(footerLine!.indexOf("[enter] select")).toBeLessThan(footerLine!.indexOf("[esc] back")); - expect( - lines.filter((line) => line.includes("[") && line.includes("]")).map((line) => line.trim()), - ).toEqual([footerLine!.trim()]); - }); - test("retains rows and disables selection and paging during a transition", async () => { const core = new TestCoreClient(); core.harness.setListResponse({ @@ -317,12 +271,11 @@ describe("token-paged table picker contract", () => { await r.press("down"); await r.write("/"); await r.write("missing"); - await waitForText(r.lastFrame, "/ Filter this page: missing"); - expect(r.lastFrame()).toContain("No matches on this page"); + await waitForText(r.lastFrame, "/ Filter: missing"); await r.press("escape"); await r.write("/"); await r.write("alpha"); - await waitForText(r.lastFrame, "/ Filter this page: alpha"); + await waitForText(r.lastFrame, "/ Filter: alpha"); expect( core.harness.calls.some((call) => call.method === "listHarnesses" && call.args[0] === "t2"), ).toBe(false); diff --git a/src/components/TokenPagedTablePicker.tsx b/src/components/TokenPagedTablePicker.tsx index b8e9cd3b3..b5c188ce5 100644 --- a/src/components/TokenPagedTablePicker.tsx +++ b/src/components/TokenPagedTablePicker.tsx @@ -1,5 +1,5 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Text, useInput, useWindowSize } from "ink"; +import { Text, useInput } from "ink"; import { Layout } from "./Layout"; import { usePagedList } from "./usePagedList"; import { darkTheme } from "./ui/_core.js"; @@ -17,7 +17,7 @@ export interface TokenPagedTablePickerProps Promise>; toRow: (item: TItem) => TRow; - columns: (terminalColumns: number) => DataTableColumn[]; + columns: DataTableColumn[]; sortRows?: (rows: TRow[]) => TRow[]; getValue: (row: TRow) => string | undefined; onSelect: (value: string) => void; @@ -44,7 +44,6 @@ export function TokenPagedTablePicker) { - const { columns: terminalColumns } = useWindowSize(); const paging = usePagedList(); const list = useQuery({ queryKey: [...queryKey, paging.pageSize, paging.token], @@ -106,10 +105,8 @@ export function TokenPagedTablePicker { diff --git a/src/components/ui/data-table/DataTable.tsx b/src/components/ui/data-table/DataTable.tsx index 17f92a6e3..9e23cf13f 100644 --- a/src/components/ui/data-table/DataTable.tsx +++ b/src/components/ui/data-table/DataTable.tsx @@ -18,7 +18,6 @@ export interface DataTableProps { pageSize?: number; searchable?: boolean; searchPlaceholder?: string; - noMatchesMessage?: string; onSelect?: (row: T, index: number) => void; /** Called when Escape is pressed while not in search mode (e.g. to go back). */ onEscape?: () => void; @@ -50,8 +49,6 @@ export function DataTable>({ data, pageSize = 10, searchable = true, - searchPlaceholder = "Filter", - noMatchesMessage = "No matches", onSelect, onEscape, onPrevPage, @@ -205,14 +202,12 @@ export function DataTable>({ {searchMode ? ( - / {searchPlaceholder}: + / Filter: {searchQuery} ) : searchQuery ? ( - - / {searchPlaceholder}: {searchQuery} - + / Filter: {searchQuery} ) : null} @@ -257,7 +252,7 @@ export function DataTable>({ {/* Rows */} {pageData.length === 0 ? ( - {searchQuery ? noMatchesMessage : emptyMessage} + {emptyMessage} ) : ( pageData.map((row, i) => { diff --git a/src/components/ui/key-hint/KeyHint.tsx b/src/components/ui/key-hint/KeyHint.tsx index 58b81b1a6..6f0e80086 100644 --- a/src/components/ui/key-hint/KeyHint.tsx +++ b/src/components/ui/key-hint/KeyHint.tsx @@ -1,10 +1,8 @@ import React from "react"; -import { Text, Box, useWindowSize } from "ink"; +import { Text, Box } from "ink"; import { darkTheme } from "../_core.js"; import type { InkUITheme } from "../_core.js"; -const ITEM_GAP = 2; - export interface KeyHintItem { /** Displayed in brackets, e.g. "Enter", "↑↓", "Space" */ key: string; @@ -17,48 +15,15 @@ export interface KeyHintProps { theme?: InkUITheme; } -function priority({ key, label }: KeyHintItem): number { - if (key === "esc" || label === "back") return 0; - if (key === "enter" || label === "select") return 1; - if (key.includes("↑") || key.includes("↓")) return 2; - if (key.includes("←") || key.includes("→") || key === "/" || key === "ctl+c") return 4; - return 3; -} - -function itemWidth({ key, label }: KeyHintItem): number { - return `[${key}] ${label}`.length; -} - -function fitKeys(keys: KeyHintItem[], columns: number): KeyHintItem[] { - const candidates = keys - .map((item, index) => ({ item, index })) - .sort((a, b) => priority(a.item) - priority(b.item) || a.index - b.index); - const selected = new Set(); - let width = 0; - - for (const candidate of candidates) { - const nextWidth = itemWidth(candidate.item) + (selected.size === 0 ? 0 : ITEM_GAP); - if (width + nextWidth > columns) continue; - selected.add(candidate.index); - width += nextWidth; - } - - return keys.filter((_, index) => selected.has(index)); -} - -export const KeyHint: React.FC = ({ keys, theme = darkTheme }) => { - const { columns } = useWindowSize(); - - return ( - - {fitKeys(keys, columns).map(({ key, label }) => ( - - - [{key}] - - {label} - - ))} - - ); -}; +export const KeyHint: React.FC = ({ keys, theme = darkTheme }) => ( + + {keys.map(({ key, label }) => ( + + + [{key}] + + {label} + + ))} + +); diff --git a/src/handlers/harness/endpoint/list/list.screen.test.tsx b/src/handlers/harness/endpoint/list/list.screen.test.tsx index 6f0661b73..6080d6b46 100644 --- a/src/handlers/harness/endpoint/list/list.screen.test.tsx +++ b/src/handlers/harness/endpoint/list/list.screen.test.tsx @@ -85,7 +85,7 @@ describe("harness endpoint list screen", () => { r.unmount(); }); - test("shows name and live columns when narrow and all columns when wide", async () => { + test("renders endpoint columns and values", async () => { const core = coreWithEndpoints([ endpoint({ endpointName: "visible-endpoint", @@ -97,30 +97,15 @@ describe("harness endpoint list screen", () => { ]); const r = renderScreen("/agentcore/harness/endpoint/list/MyHarness-abc123", { core }); - await r.resize(50); await waitForText(r.lastFrame, "visible-endpoint"); - const narrow = r.lastFrame()!; - expect(narrow).toContain("name"); - expect(narrow).toContain("live"); - expect(narrow).toContain("visible-endpoint"); - expect(narrow).toContain("7"); - expect(narrow).not.toContain("target"); - expect(narrow).not.toContain("status"); - expect(narrow).not.toContain("updatedAt"); - expect(narrow).not.toContain("88"); - expect(narrow).not.toContain("UPDATE_FAILED"); - expect(narrow).not.toContain("2026-07-18T02:00:00.000Z"); - - await r.resize(120); - await waitForText(r.lastFrame, "updatedAt"); - const wide = r.lastFrame()!; - expect(wide).toContain("name"); - expect(wide).toContain("live"); - expect(wide).toContain("target"); - expect(wide).toContain("status"); - expect(wide).toContain("updatedAt"); - expect(wide).toMatch(/visible-endpoint\s+7\s+88\s+UPDATE_FAILED/); - expect(wide).toContain("2026-07-18T02:00:00.000Z"); + const frame = r.lastFrame()!; + expect(frame).toContain("name"); + expect(frame).toContain("live"); + expect(frame).toContain("target"); + expect(frame).toContain("status"); + expect(frame).toContain("updatedAt"); + expect(frame).toMatch(/visible-endpoint\s+7\s+88\s+UPDATE_FAILED/); + expect(frame).toContain("2026-07-18T02:00:00.000Z"); r.unmount(); }); diff --git a/src/handlers/harness/list/list.screen.test.tsx b/src/handlers/harness/list/list.screen.test.tsx index d34f51c51..42f4da28f 100644 --- a/src/handlers/harness/list/list.screen.test.tsx +++ b/src/handlers/harness/list/list.screen.test.tsx @@ -34,7 +34,7 @@ function coreWith(harnesses: HarnessSummary[]): TestCoreClient { } describe("harness list screen", () => { - test("renders each harness as a row once loaded", async () => { + test("renders each harness with its columns", async () => { const core = coreWith([ harness({ harnessName: "alpha", harnessId: "alpha-1" }), harness({ harnessName: "beta", harnessId: "beta-2" }), @@ -49,15 +49,6 @@ describe("harness list screen", () => { expect(frame).toContain("version"); expect(frame).toContain("status"); expect(frame).toContain("updatedAt"); - - await r.resize(50, 20); - await waitForText(r.lastFrame, "alpha"); - const narrow = r.lastFrame()!; - expect(narrow).toContain("name"); - expect(narrow).toContain("version"); - expect(narrow).not.toContain("status"); - expect(narrow).not.toContain("updatedAt"); - expect(narrow).not.toContain("READY"); }); test("makes one initial list request with context options", async () => { diff --git a/src/handlers/harness/version/list/list.screen.test.tsx b/src/handlers/harness/version/list/list.screen.test.tsx index a4afe7051..67f8e5b8b 100644 --- a/src/handlers/harness/version/list/list.screen.test.tsx +++ b/src/handlers/harness/version/list/list.screen.test.tsx @@ -101,7 +101,7 @@ describe("harness version list screen", () => { r.unmount(); }); - test("shows version-only columns when narrow and all columns when wide", async () => { + test("renders version columns and values", async () => { const core = coreWithVersions([ version({ harnessVersion: "123", @@ -111,25 +111,14 @@ describe("harness version list screen", () => { ]); const r = renderScreen("/agentcore/harness/version/list/MyHarness-abc123", { core }); - await r.resize(50); - await waitForText(r.lastFrame, "123"); - const narrow = r.lastFrame()!; - expect(narrow).toContain("version"); - expect(narrow).toContain("123"); - expect(narrow).not.toContain("status"); - expect(narrow).not.toContain("createdAt"); - expect(narrow).not.toContain("UPDATE_FAILED"); - expect(narrow).not.toContain("2026-07-18T02:00:00.000Z"); - - await r.resize(120); - await waitForText(r.lastFrame, "createdAt"); - const wide = r.lastFrame()!; - expect(wide).toContain("version"); - expect(wide).toContain("status"); - expect(wide).toContain("createdAt"); - expect(wide).toContain("123"); - expect(wide).toContain("UPDATE_FAILED"); - expect(wide).toContain("2026-07-18T02:00:00.000Z"); + await waitForText(r.lastFrame, "UPDATE_FAILED"); + const frame = r.lastFrame()!; + expect(frame).toContain("version"); + expect(frame).toContain("status"); + expect(frame).toContain("createdAt"); + expect(frame).toContain("123"); + expect(frame).toContain("UPDATE_FAILED"); + expect(frame).toContain("2026-07-18T02:00:00.000Z"); r.unmount(); }); diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 3f5ff9741..9f840fd53 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -14,6 +14,8 @@ import { afterEach(cleanupScreens); +const runtimeEndpointUrl = "https://runtime.test"; + function runtime(overrides: Partial = {}): AgentRuntime { return { agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", @@ -102,7 +104,10 @@ describe("Runtime endpoint flow", () => { core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint()], }); - renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimeEndpoints")); expect(core.runtime.calls.filter((call) => call.method === "listRuntimeEndpoints")).toEqual([ @@ -114,7 +119,7 @@ describe("Runtime endpoint flow", () => { expect.any(Number), { region: "us-east-1", - endpointUrl: undefined, + endpointUrl: runtimeEndpointUrl, }, ], }, @@ -122,7 +127,7 @@ describe("Runtime endpoint flow", () => { expect(core.runtime.calls.some((call) => call.method === "listRuntimes")).toBe(false); }); - test("renders qualifier, live and target versions, status, and update time when wide", async () => { + test("renders qualifier, live and target versions, status, and update time", async () => { const core = new TestCoreClient(); core.runtime.setListEndpointsResponse({ runtimeEndpoints: [ @@ -151,33 +156,6 @@ describe("Runtime endpoint flow", () => { expect(frame).not.toContain("protocol"); }); - test("keeps qualifier and live version when narrow", async () => { - const core = new TestCoreClient(); - core.runtime.setListEndpointsResponse({ - runtimeEndpoints: [ - endpoint({ - name: "visible-endpoint", - liveVersion: "7", - status: "UPDATE_FAILED", - lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), - }), - ], - }); - const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); - - await r.resize(50); - await waitForText(r.lastFrame, "visible-endpoint"); - const frame = r.lastFrame()!; - expect(frame).toContain("qualifier"); - expect(frame).toContain("live"); - expect(frame).toContain("visible-endpoint"); - expect(frame).toContain("7"); - expect(frame).not.toContain("target"); - expect(frame).not.toContain("status"); - expect(frame).not.toContain("lastUpdatedAt"); - expect(frame).not.toContain("UPDATE_FAILED"); - }); - test("shows the Runtime-scoped empty state", async () => { const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123"); @@ -219,7 +197,10 @@ describe("Runtime endpoint flow", () => { $metadata: { requestId: "endpoint-request-metadata" }, ...getEndpointResponse({ name: qualifier, id: qualifier }), } as GetAgentRuntimeEndpointResponse); - const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core }); + const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); await waitForText(r.lastFrame, qualifier); await r.press("return"); @@ -230,14 +211,18 @@ describe("Runtime endpoint flow", () => { await waitForText(r.lastFrame, '"targetVersion"'); expect(r.lastFrame()).not.toContain("$metadata"); expect(r.lastFrame()).not.toContain("endpoint-request-metadata"); - await waitFor(() => - core.runtime.calls.some( - (call) => - call.method === "getRuntimeEndpoint" && - call.args[0] === "runtime-123" && - call.args[1] === qualifier, - ), - ); + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeEndpoint")); + expect(core.runtime.calls.find((call) => call.method === "getRuntimeEndpoint")).toEqual({ + method: "getRuntimeEndpoint", + args: [ + "runtime-123", + qualifier, + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }); }); test("uses a structural parent for the Runtime picker and history below it", async () => { diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index 6ecfb4750..d28791945 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -15,6 +15,8 @@ import { afterEach(cleanupScreens); +const runtimeEndpointUrl = "https://runtime.test"; + function runtime(overrides: Partial = {}): AgentRuntime { return { agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-1", @@ -68,7 +70,7 @@ function coreWithRuntimes(runtimes: AgentRuntime[]): TestCoreClient { } describe("runtime picker", () => { - test("renders Runtime identity, latest version, status, and update time when wide", async () => { + test("renders Runtime identity, latest version, status, and update time", async () => { const core = coreWithRuntimes([ runtime({ agentRuntimeId: "runtime-visible-id", @@ -80,7 +82,6 @@ describe("runtime picker", () => { ]); const r = renderScreen("/agentcore/runtime/list", { core }); - await r.resize(140); await waitForText(r.lastFrame, "orders"); const frame = r.lastFrame()!; expect(frame).toContain("name"); @@ -96,7 +97,7 @@ describe("runtime picker", () => { test("calls listRuntimes once with exact Core options", async () => { const core = coreWithRuntimes([runtime()]); - renderScreen("/agentcore/runtime/list", { core }); + renderScreen("/agentcore/runtime/list", { core, endpointUrl: runtimeEndpointUrl }); await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimes")); expect(core.runtime.calls.filter((call) => call.method === "listRuntimes")).toEqual([ @@ -107,7 +108,7 @@ describe("runtime picker", () => { expect.any(Number), { region: "us-east-1", - endpointUrl: undefined, + endpointUrl: runtimeEndpointUrl, }, ], }, @@ -135,30 +136,6 @@ describe("runtime picker", () => { expect(r.lastFrame()).not.toContain("No Runtimes found in this Region."); }); - test("keeps name and latest version when narrow", async () => { - const core = coreWithRuntimes([ - runtime({ - agentRuntimeId: "hidden-runtime-id", - agentRuntimeName: "visible-name", - agentRuntimeVersion: "88", - status: "CREATE_FAILED", - lastUpdatedAt: new Date("2026-07-18T09:08:07.000Z"), - }), - ]); - const r = renderScreen("/agentcore/runtime/list", { core }); - - await r.resize(60); - await waitForText(r.lastFrame, "visible-name"); - const frame = r.lastFrame()!; - expect(frame).toContain("name"); - expect(frame).toContain("latestVersion"); - expect(frame).toContain("visible-name"); - expect(frame).toContain("88"); - expect(frame).not.toContain("hidden-runtime-id"); - expect(frame).not.toContain("CREATE_FAILED"); - expect(frame).not.toContain("2026-07-18T09:08:07.000Z"); - }); - test("Esc returns to the Runtime menu from a successful direct entry", async () => { const core = coreWithRuntimes([runtime()]); const r = renderScreen("/agentcore/runtime/list", { core }); @@ -182,7 +159,10 @@ describe("runtime hub", () => { test("fetches the route ID with exact Core options and renders its summary", async () => { const core = new TestCoreClient(); core.runtime.setGetResponse(getRuntimeResponse()); - const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); + const r = renderScreen("/agentcore/runtime/get/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); await waitForText(r.lastFrame, "arn:aws:bedrock-agentcore:us-east-1"); expect(r.lastFrame()).toContain("runtime-123"); @@ -198,7 +178,7 @@ describe("runtime hub", () => { "runtime-123", { region: "us-east-1", - endpointUrl: undefined, + endpointUrl: runtimeEndpointUrl, }, ], }); diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index 49f8637bf..a4754b944 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -13,6 +13,8 @@ import { afterEach(cleanupScreens); +const runtimeEndpointUrl = "https://runtime.test"; + function runtime(overrides: Partial = {}): AgentRuntime { return { agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", @@ -88,7 +90,10 @@ describe("Runtime version flow", () => { core.runtime.setListVersionsResponse({ agentRuntimes: [runtime()], }); - renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + renderScreen("/agentcore/runtime/version/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); await waitFor(() => core.runtime.calls.some((call) => call.method === "listRuntimeVersions")); expect(core.runtime.calls.filter((call) => call.method === "listRuntimeVersions")).toEqual([ @@ -100,7 +105,7 @@ describe("Runtime version flow", () => { expect.any(Number), { region: "us-east-1", - endpointUrl: undefined, + endpointUrl: runtimeEndpointUrl, }, ], }, @@ -147,31 +152,6 @@ describe("Runtime version flow", () => { expect(frame).not.toContain("hidden-name"); }); - test("keeps version visible without wrapping lower-priority columns when narrow", async () => { - const core = new TestCoreClient(); - core.runtime.setListVersionsResponse({ - agentRuntimes: [ - runtime({ - agentRuntimeVersion: "123", - status: "UPDATE_FAILED", - lastUpdatedAt: new Date("2026-07-18T02:00:00.000Z"), - }), - ], - }); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); - - await r.resize(50); - await waitForText(r.lastFrame, "123"); - const frame = r.lastFrame()!; - expect(frame).toContain("version"); - expect(frame).toContain("123"); - expect(frame).not.toContain("status"); - expect(frame).not.toContain("lastUpdatedAt"); - expect(frame).not.toContain("UPDATE_FAILED"); - expect(frame).not.toContain("2026-07-18T02:00:00.000Z"); - expect(Math.max(...frame.split("\n").map((line) => line.length))).toBe(50); - }); - test("describes an empty later page without claiming the Runtime has no versions", async () => { const core = new TestCoreClient(); core.runtime.setListVersionsResponse({ @@ -209,7 +189,10 @@ describe("Runtime version flow", () => { $metadata: { requestId: "version-request-metadata" }, ...getVersionResponse({ agentRuntimeVersion: "9" }), } as GetAgentRuntimeResponse); - const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core }); + const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { + core, + endpointUrl: runtimeEndpointUrl, + }); await waitForText(r.lastFrame, "9"); await r.press("return"); @@ -217,14 +200,18 @@ describe("Runtime version flow", () => { await waitForText(r.lastFrame, '"networkConfiguration"'); expect(r.lastFrame()).not.toContain("$metadata"); expect(r.lastFrame()).not.toContain("version-request-metadata"); - await waitFor(() => - core.runtime.calls.some( - (call) => - call.method === "getRuntimeVersion" && - call.args[0] === "runtime-123" && - call.args[1] === "9", - ), - ); + await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeVersion")); + expect(core.runtime.calls.find((call) => call.method === "getRuntimeVersion")).toEqual({ + method: "getRuntimeVersion", + args: [ + "runtime-123", + "9", + { + region: "us-east-1", + endpointUrl: runtimeEndpointUrl, + }, + ], + }); }); test("uses a structural parent for the Runtime picker and history below it", async () => { diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 3d276479d..4ed219ade 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render, cleanup } from "ink-testing-library"; import { QueryClient } from "@tanstack/react-query"; import { ValueContext, compile, CommandKey, type Context } from "../router"; -import { RegionKey, JsonKey, DebugKey } from "../handlers/keys"; +import { RegionKey, JsonKey, DebugKey, EndpointKey } from "../handlers/keys"; import { JsonRendererKey } from "../tui"; import { createRootHandler } from "../handlers"; import { Root } from "../components/Root"; @@ -27,7 +27,7 @@ import { createSilentLogger } from "./logging"; // RouterScreen walks it to resolve each menu's subcommands), the global flags // (region/json/debug), and a no-op JsonRenderer. Compiling the real handler tree // keeps the command menus faithful to the production command structure. -function baseContext(core: TestCoreClient): Context { +function baseContext(core: TestCoreClient, endpointUrl?: string): Context { const rootCommand = compile( createRootHandler(core, { io: testIO().io, logger: createSilentLogger() }), ValueContext.EmptyContext(), @@ -36,6 +36,7 @@ function baseContext(core: TestCoreClient): Context { return ValueContext.EmptyContext() .withValue(CommandKey, rootCommand) .withValue(RegionKey, "us-east-1") + .withValue(EndpointKey, endpointUrl) .withValue(JsonKey, false) .withValue(DebugKey, false) .withValue(JsonRendererKey, { renderJson: () => {} }); @@ -59,6 +60,7 @@ export interface RenderScreenOptions { // queryClient overrides the deterministic default when a test needs to // exercise cache behavior. queryClient?: QueryClient; + endpointUrl?: string; } export interface RenderScreenResult { @@ -116,7 +118,7 @@ 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); + const ctx = options.ctx ?? baseContext(core, options.endpointUrl); const queryClient = options.queryClient ?? testQueryClient(); const instance = render(<>); diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index b84e5ec07..9fb274b11 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,7 +1,30 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "../handlers"; import { renderJson } from "./index"; -import { createSilentLogger, TestCoreClient, testIO } from "../testing"; +import { createSilentLogger, TestCoreClient, testIO, tick, waitFor } from "../testing"; + +interface TtyInput extends NodeJS.ReadStream { + write(chunk: string): boolean; +} + +function ttyTestIO(): { streams: ReturnType; stdin: TtyInput } { + const streams = testIO({ isTTY: true }); + const stdin = streams.io.stdin as TtyInput; + stdin.setRawMode = function () { + return this; + }; + stdin.ref = function () { + return this; + }; + stdin.unref = function () { + return this; + }; + Object.defineProperties(streams.io.stdout, { + columns: { configurable: true, value: 100 }, + rows: { configurable: true, value: 40 }, + }); + return { streams, stdin }; +} describe("renderJson", () => { test("pretty-prints a value as indented JSON to the given writer", () => { @@ -62,4 +85,41 @@ describe("TUI stream boundary", () => { ); }, ); + + test("Ctrl+C exits a Runtime list and ignores input after exit", async () => { + const core = new TestCoreClient(); + core.runtime.setListResponse({ + agentRuntimes: [ + { + agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123", + agentRuntimeId: "runtime-123", + agentRuntimeVersion: "7", + agentRuntimeName: "checkout", + description: "Checkout Runtime", + lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"), + status: "READY", + }, + ], + nextToken: "page-2", + }); + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + io: streams.io, + logger: createSilentLogger(), + }); + const routePromise = root.route(["node", "agentcore", "runtime", "list"]); + const listCalls = () => core.runtime.calls.filter((call) => call.method === "listRuntimes"); + + await waitFor(() => listCalls().length > 0); + await tick(); + const callsBeforeExit = listCalls().length; + + stdin.write(String.fromCharCode(3)); + await expect(routePromise).resolves.toBeUndefined(); + + stdin.write("l"); + await tick(); + expect(listCalls()).toHaveLength(callsBeforeExit); + expect(listCalls().some((call) => call.args[0] === "page-2")).toBe(false); + }); }); From 8c9ec77ae080492c7dcad1e675ba1447446c6a2c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 04:42:36 +0000 Subject: [PATCH 35/38] fix: preserve Winston dependencies after rebase --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index 0b273d2b4..9a5e0d80e 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,6 @@ "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", "lodash": "^4.18.1", - "pino": "^10.3.1", - "pino-roll": "^4.0.0", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "react-router": "^8.1.0", From 0b4608210a397f11fc4fa676aa24f033c1e9228f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 15:20:34 +0000 Subject: [PATCH 36/38] refactor: defer SDK metadata filtering --- .../runtime/endpoint/endpoint.screen.test.tsx | 7 +------ src/handlers/runtime/endpoint/get/index.tsx | 5 +---- src/handlers/runtime/endpoint/get/screen.tsx | 3 +-- src/handlers/runtime/endpoint/list/index.tsx | 13 +++++-------- src/handlers/runtime/get/index.tsx | 5 +---- src/handlers/runtime/get/screen.tsx | 3 +-- src/handlers/runtime/list/index.tsx | 11 ++++------- src/handlers/runtime/runtime.screen.test.tsx | 9 +++------ src/handlers/runtime/runtime.test.tsx | 15 +-------------- src/handlers/runtime/version/get/index.tsx | 5 +---- src/handlers/runtime/version/get/screen.tsx | 3 +-- src/handlers/runtime/version/list/index.tsx | 13 +++++-------- .../runtime/version/version.screen.test.tsx | 7 +------ src/handlers/runtime/withoutSdkMetadata.ts | 7 ------- 14 files changed, 26 insertions(+), 80 deletions(-) delete mode 100644 src/handlers/runtime/withoutSdkMetadata.ts diff --git a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx index 9f840fd53..aba6958a7 100644 --- a/src/handlers/runtime/endpoint/endpoint.screen.test.tsx +++ b/src/handlers/runtime/endpoint/endpoint.screen.test.tsx @@ -193,10 +193,7 @@ describe("Runtime endpoint flow", () => { core.runtime.setListEndpointsResponse({ runtimeEndpoints: [endpoint({ name: qualifier, id: qualifier })], }); - core.runtime.setGetEndpointResponse({ - $metadata: { requestId: "endpoint-request-metadata" }, - ...getEndpointResponse({ name: qualifier, id: qualifier }), - } as GetAgentRuntimeEndpointResponse); + core.runtime.setGetEndpointResponse(getEndpointResponse({ name: qualifier, id: qualifier })); const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core, endpointUrl: runtimeEndpointUrl, @@ -209,8 +206,6 @@ describe("Runtime endpoint flow", () => { `agentcore → runtime → endpoint → get → runtime-123 → ${qualifier}`, ); await waitForText(r.lastFrame, '"targetVersion"'); - expect(r.lastFrame()).not.toContain("$metadata"); - expect(r.lastFrame()).not.toContain("endpoint-request-metadata"); await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeEndpoint")); expect(core.runtime.calls.find((call) => call.method === "getRuntimeEndpoint")).toEqual({ method: "getRuntimeEndpoint", diff --git a/src/handlers/runtime/endpoint/get/index.tsx b/src/handlers/runtime/endpoint/get/index.tsx index 662c2da33..f4b342920 100644 --- a/src/handlers/runtime/endpoint/get/index.tsx +++ b/src/handlers/runtime/endpoint/get/index.tsx @@ -3,7 +3,6 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createGetRuntimeEndpointHandler = (core: Core) => createHandler({ @@ -24,9 +23,7 @@ export const createGetRuntimeEndpointHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - withoutSdkMetadata( - await core.runtime.getRuntimeEndpoint(flags.id, flags.qualifier, coreOptsFromCtx(ctx)), - ), + await core.runtime.getRuntimeEndpoint(flags.id, flags.qualifier, coreOptsFromCtx(ctx)), ); }, }); diff --git a/src/handlers/runtime/endpoint/get/screen.tsx b/src/handlers/runtime/endpoint/get/screen.tsx index bf136a35e..c570650b7 100644 --- a/src/handlers/runtime/endpoint/get/screen.tsx +++ b/src/handlers/runtime/endpoint/get/screen.tsx @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; -import { withoutSdkMetadata } from "../../withoutSdkMetadata"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -19,7 +18,7 @@ export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) { breadcrumb={["agentcore", "runtime", "endpoint", "get", runtimeId ?? "", qualifier ?? ""]} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} - data={withoutSdkMetadata(detail.data)} + data={detail.data} loadingLabel={`Loading endpoint ${qualifier ?? ""} for Runtime ${runtimeId ?? ""}…`} onRetry={() => void detail.refetch()} /> diff --git a/src/handlers/runtime/endpoint/list/index.tsx b/src/handlers/runtime/endpoint/list/index.tsx index bdeb993b7..b6f492dc6 100644 --- a/src/handlers/runtime/endpoint/list/index.tsx +++ b/src/handlers/runtime/endpoint/list/index.tsx @@ -3,7 +3,6 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createListRuntimeEndpointsHandler = (core: Core) => createHandler({ @@ -22,13 +21,11 @@ export const createListRuntimeEndpointsHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - withoutSdkMetadata( - await core.runtime.listRuntimeEndpoints( - flags.id, - flags["next-token"], - flags["max-results"], - coreOptsFromCtx(ctx), - ), + await core.runtime.listRuntimeEndpoints( + flags.id, + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), ), ); }, diff --git a/src/handlers/runtime/get/index.tsx b/src/handlers/runtime/get/index.tsx index e3d1b0755..b62f3e88d 100644 --- a/src/handlers/runtime/get/index.tsx +++ b/src/handlers/runtime/get/index.tsx @@ -3,7 +3,6 @@ import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { withoutSdkMetadata } from "../withoutSdkMetadata"; export const createGetRuntimeHandler = (core: Core) => createHandler({ @@ -17,8 +16,6 @@ export const createGetRuntimeHandler = (core: Core) => ctx .require(JsonRendererKey) - .renderJson( - withoutSdkMetadata(await core.runtime.getRuntime(flags.id, coreOptsFromCtx(ctx))), - ); + .renderJson(await core.runtime.getRuntime(flags.id, coreOptsFromCtx(ctx))); }, }); diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index 9a7e384cc..892ee9340 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -10,7 +10,6 @@ import { Divider } from "../../../components/ui/divider/Divider.js"; import { Spinner } from "../../../components/ui/spinner"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { withoutSdkMetadata } from "../withoutSdkMetadata"; const ACTIONS = [ { @@ -138,7 +137,7 @@ export function RuntimeGetJsonScreen({ ctx, core }: ScreenProps) { breadcrumb={["agentcore", "runtime", "get", runtimeId ?? "", "json"]} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} - data={withoutSdkMetadata(detail.data)} + data={detail.data} loadingLabel="Loading Runtime…" onRetry={() => void detail.refetch()} /> diff --git a/src/handlers/runtime/list/index.tsx b/src/handlers/runtime/list/index.tsx index 9b940533a..08728b2e3 100644 --- a/src/handlers/runtime/list/index.tsx +++ b/src/handlers/runtime/list/index.tsx @@ -3,7 +3,6 @@ import { createHandler, flag } from "../../../router"; import { JsonRendererKey } from "../../../tui"; import type { Core } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -import { withoutSdkMetadata } from "../withoutSdkMetadata"; export const createListRuntimesHandler = (core: Core) => createHandler({ @@ -17,12 +16,10 @@ export const createListRuntimesHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - withoutSdkMetadata( - await core.runtime.listRuntimes( - flags["next-token"], - flags["max-results"], - coreOptsFromCtx(ctx), - ), + await core.runtime.listRuntimes( + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), ), ); }, diff --git a/src/handlers/runtime/runtime.screen.test.tsx b/src/handlers/runtime/runtime.screen.test.tsx index d28791945..473e46729 100644 --- a/src/handlers/runtime/runtime.screen.test.tsx +++ b/src/handlers/runtime/runtime.screen.test.tsx @@ -291,14 +291,13 @@ describe("runtime hub", () => { test("opens complete Runtime JSON from the detail action and scrolls", async () => { const core = new TestCoreClient(); - core.runtime.setGetResponse({ - $metadata: { requestId: "runtime-request-metadata" }, - ...getRuntimeResponse({ + core.runtime.setGetResponse( + getRuntimeResponse({ environmentVariables: Object.fromEntries( Array.from({ length: 30 }, (_, index) => [`VARIABLE_${index}`, `value-${index}`]), ), }), - } as GetAgentRuntimeResponse); + ); const r = renderScreen("/agentcore/runtime/get/runtime-123", { core }); await waitForText(r.lastFrame, "show the full JSON definition"); @@ -308,8 +307,6 @@ describe("runtime hub", () => { expect(frame).toContain('"agentRuntimeId"'); expect(frame).toContain('"networkConfiguration"'); expect(frame).toContain('"lifecycleConfiguration"'); - expect(frame).not.toContain("$metadata"); - expect(frame).not.toContain("runtime-request-metadata"); expect(frame).not.toContain('"VARIABLE_29"'); for (let index = 0; index < 20; index += 1) await r.press("down"); for (let index = 0; index < 20; index += 1) await r.write("j"); diff --git a/src/handlers/runtime/runtime.test.tsx b/src/handlers/runtime/runtime.test.tsx index 295f901ac..987848006 100644 --- a/src/handlers/runtime/runtime.test.tsx +++ b/src/handlers/runtime/runtime.test.tsx @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; -import type { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control"; import { CoreClient } from "../../core"; -import type { CreateControlClient } from "../../core/types"; import { createSilentLogger, fixtureFactories, @@ -24,20 +22,9 @@ const MISSING_RUNTIME_ID = "missing_runtime-0000000000"; function createFixtureCore(): CoreClient { const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); - const createControlClientWithMetadata: CreateControlClient = (config) => { - const client = createControlClient(config); - const send = client.send.bind(client) as (command: unknown) => Promise>; - - return { - send: async (command: unknown) => ({ - ...(await send(command)), - $metadata: { requestId: "fixture-request-id" }, - }), - } as unknown as BedrockAgentCoreControlClient; - }; return new CoreClient({ - createControlClient: createControlClientWithMetadata, + createControlClient, createDataClient, createIamClient, logger: createSilentLogger(), diff --git a/src/handlers/runtime/version/get/index.tsx b/src/handlers/runtime/version/get/index.tsx index 053b7e07f..3d82c53b3 100644 --- a/src/handlers/runtime/version/get/index.tsx +++ b/src/handlers/runtime/version/get/index.tsx @@ -3,7 +3,6 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createGetRuntimeVersionHandler = (core: Core) => createHandler({ @@ -24,9 +23,7 @@ export const createGetRuntimeVersionHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - withoutSdkMetadata( - await core.runtime.getRuntimeVersion(flags.id, flags.version, coreOptsFromCtx(ctx)), - ), + await core.runtime.getRuntimeVersion(flags.id, flags.version, coreOptsFromCtx(ctx)), ); }, }); diff --git a/src/handlers/runtime/version/get/screen.tsx b/src/handlers/runtime/version/get/screen.tsx index a543b7dd6..924c5ba36 100644 --- a/src/handlers/runtime/version/get/screen.tsx +++ b/src/handlers/runtime/version/get/screen.tsx @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; -import { withoutSdkMetadata } from "../../withoutSdkMetadata"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; @@ -19,7 +18,7 @@ export function RuntimeGetVersionScreen({ ctx, core }: ScreenProps) { breadcrumb={["agentcore", "runtime", "version", "get", runtimeId ?? "", version ?? ""]} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} - data={withoutSdkMetadata(detail.data)} + data={detail.data} loadingLabel={`Loading version ${version ?? ""} for Runtime ${runtimeId ?? ""}…`} onRetry={() => void detail.refetch()} /> diff --git a/src/handlers/runtime/version/list/index.tsx b/src/handlers/runtime/version/list/index.tsx index a3f495a59..b2c20b599 100644 --- a/src/handlers/runtime/version/list/index.tsx +++ b/src/handlers/runtime/version/list/index.tsx @@ -3,7 +3,6 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { withoutSdkMetadata } from "../../withoutSdkMetadata"; export const createListRuntimeVersionsHandler = (core: Core) => createHandler({ @@ -22,13 +21,11 @@ export const createListRuntimeVersionsHandler = (core: Core) => ctx .require(JsonRendererKey) .renderJson( - withoutSdkMetadata( - await core.runtime.listRuntimeVersions( - flags.id, - flags["next-token"], - flags["max-results"], - coreOptsFromCtx(ctx), - ), + await core.runtime.listRuntimeVersions( + flags.id, + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), ), ); }, diff --git a/src/handlers/runtime/version/version.screen.test.tsx b/src/handlers/runtime/version/version.screen.test.tsx index a4754b944..c0e2ebcb0 100644 --- a/src/handlers/runtime/version/version.screen.test.tsx +++ b/src/handlers/runtime/version/version.screen.test.tsx @@ -185,10 +185,7 @@ describe("Runtime version flow", () => { core.runtime.setListVersionsResponse({ agentRuntimes: [runtime({ agentRuntimeVersion: "9" })], }); - core.runtime.setGetVersionResponse({ - $metadata: { requestId: "version-request-metadata" }, - ...getVersionResponse({ agentRuntimeVersion: "9" }), - } as GetAgentRuntimeResponse); + core.runtime.setGetVersionResponse(getVersionResponse({ agentRuntimeVersion: "9" })); const r = renderScreen("/agentcore/runtime/version/list/runtime-123", { core, endpointUrl: runtimeEndpointUrl, @@ -198,8 +195,6 @@ describe("Runtime version flow", () => { await r.press("return"); await waitForText(r.lastFrame, "agentcore → runtime → version → get → runtime-123 → 9"); await waitForText(r.lastFrame, '"networkConfiguration"'); - expect(r.lastFrame()).not.toContain("$metadata"); - expect(r.lastFrame()).not.toContain("version-request-metadata"); await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeVersion")); expect(core.runtime.calls.find((call) => call.method === "getRuntimeVersion")).toEqual({ method: "getRuntimeVersion", diff --git a/src/handlers/runtime/withoutSdkMetadata.ts b/src/handlers/runtime/withoutSdkMetadata.ts deleted file mode 100644 index c44b385fe..000000000 --- a/src/handlers/runtime/withoutSdkMetadata.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function withoutSdkMetadata(data: unknown): unknown { - if (data === null || typeof data !== "object" || !("$metadata" in data)) return data; - - const normalized = { ...(data as Record) }; - delete normalized.$metadata; - return normalized; -} From 432a3971f1aeb3eda8dbfb4ed8cd979b09d95d70 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 19:01:00 +0000 Subject: [PATCH 37/38] docs: use TSDoc for Harness pickers --- src/components/HarnessEndpointPicker.tsx | 9 ++++++--- src/components/HarnessPicker.tsx | 12 +++++++----- src/components/HarnessVersionPicker.tsx | 7 +++++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx index dc07cc215..bde1014fe 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -38,9 +38,12 @@ export interface HarnessEndpointPickerProps extends ScreenProps { onEscape?: () => void; } -// HarnessEndpointPicker fetches a harness's endpoints and renders them as a navigable -// table — the endpoint counterpart of HarnessPicker, shared by every "pick an -// endpoint" screen (list, update, delete). Esc pops back. +/** + * Fetches a harness's endpoints and renders them as a navigable table. + * + * This is the endpoint counterpart of HarnessPicker, shared by every "pick an + * endpoint" screen (list, update, delete). Esc pops back. + */ export function HarnessEndpointPicker({ ctx, core, diff --git a/src/components/HarnessPicker.tsx b/src/components/HarnessPicker.tsx index b51063df5..9ddabd5b1 100644 --- a/src/components/HarnessPicker.tsx +++ b/src/components/HarnessPicker.tsx @@ -37,11 +37,13 @@ export interface HarnessPickerProps extends ScreenProps { onSelect: (harnessId: string) => void; } -// HarnessPicker fetches the caller's harnesses and renders them as a navigable -// table. It is the shared body of every "pick a harness" screen (list, invoke); -// hosts differ only in breadcrumb, subtitle, and what selection does. Esc -// returns to the parent menu, derived from the breadcrumb (e.g. the endpoint -// menu for [..., "endpoint", "list"]). +/** + * Fetches the caller's harnesses and renders them as a navigable table. + * + * This is the shared body of every "pick a harness" screen (list, invoke); + * hosts differ only in breadcrumb, subtitle, and what selection does. Esc + * returns to the parent menu derived from the breadcrumb. + */ export function HarnessPicker({ ctx, core, diff --git a/src/components/HarnessVersionPicker.tsx b/src/components/HarnessVersionPicker.tsx index 18efb9f3f..b3d242794 100644 --- a/src/components/HarnessVersionPicker.tsx +++ b/src/components/HarnessVersionPicker.tsx @@ -32,8 +32,11 @@ export interface HarnessVersionPickerProps extends ScreenProps { onSelect: (version: string) => void; } -// HarnessVersionPicker fetches a harness's versions and renders them as a navigable -// table, newest first. Esc pops back. +/** + * Fetches a harness's versions and renders them as a navigable table, newest first. + * + * Esc pops back. + */ export function HarnessVersionPicker({ ctx, core, From 4ffe40a3d4d03c570209b188db8867ba29663066 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 23 Jul 2026 19:03:19 +0000 Subject: [PATCH 38/38] refactor: rename paginated table picker --- src/components/HarnessEndpointPicker.tsx | 4 ++-- src/components/HarnessPicker.tsx | 4 ++-- src/components/HarnessVersionPicker.tsx | 4 ++-- ...edTablePicker.test.tsx => PaginatedTablePicker.test.tsx} | 2 +- .../{TokenPagedTablePicker.tsx => PaginatedTablePicker.tsx} | 6 +++--- src/components/RuntimeEndpointPicker.tsx | 4 ++-- src/components/RuntimePicker.tsx | 4 ++-- src/components/RuntimeVersionPicker.tsx | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) rename src/components/{TokenPagedTablePicker.test.tsx => PaginatedTablePicker.test.tsx} (99%) rename src/components/{TokenPagedTablePicker.tsx => PaginatedTablePicker.tsx} (94%) diff --git a/src/components/HarnessEndpointPicker.tsx b/src/components/HarnessEndpointPicker.tsx index bde1014fe..4ccd78d0d 100644 --- a/src/components/HarnessEndpointPicker.tsx +++ b/src/components/HarnessEndpointPicker.tsx @@ -2,7 +2,7 @@ import { useNavigate } from "react-router"; import type { HarnessEndpoint } from "@aws-sdk/client-bedrock-agentcore-control"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; -import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; // EndpointRow is the flat, display-ready shape the table renders. interface EndpointRow extends Record { @@ -58,7 +58,7 @@ export function HarnessEndpointPicker({ const goBack = onEscape ?? (() => navigate(-1)); return ( - ` constraint, which the SDK's @@ -56,7 +56,7 @@ export function HarnessPicker({ const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); return ( - { @@ -50,7 +50,7 @@ export function HarnessVersionPicker({ const goBack = () => navigate(-1); return ( - [0]; } -describe("token-paged table picker contract", () => { +describe("paginated table picker contract", () => { test("retries a failed query", async () => { const core = new TestCoreClient(); core.harness.setError(new Error("access denied")); diff --git a/src/components/TokenPagedTablePicker.tsx b/src/components/PaginatedTablePicker.tsx similarity index 94% rename from src/components/TokenPagedTablePicker.tsx rename to src/components/PaginatedTablePicker.tsx index b5c188ce5..3149c65db 100644 --- a/src/components/TokenPagedTablePicker.tsx +++ b/src/components/PaginatedTablePicker.tsx @@ -11,7 +11,7 @@ export interface TokenPage { nextToken?: string; } -export interface TokenPagedTablePickerProps> { +export interface PaginatedTablePickerProps> { breadcrumb: string[]; description?: string; queryKey: readonly unknown[]; @@ -28,7 +28,7 @@ export interface TokenPagedTablePickerProps>({ +export function PaginatedTablePicker>({ breadcrumb, description, queryKey, @@ -43,7 +43,7 @@ export function TokenPagedTablePicker) { +}: PaginatedTablePickerProps) { const paging = usePagedList(); const list = useQuery({ queryKey: [...queryKey, paging.pageSize, paging.token], diff --git a/src/components/RuntimeEndpointPicker.tsx b/src/components/RuntimeEndpointPicker.tsx index 0a06bbd68..509d5ca57 100644 --- a/src/components/RuntimeEndpointPicker.tsx +++ b/src/components/RuntimeEndpointPicker.tsx @@ -2,7 +2,7 @@ import type { AgentRuntimeEndpoint } from "@aws-sdk/client-bedrock-agentcore-con import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; -import { TokenPagedTablePicker } from "./TokenPagedTablePicker"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; interface RuntimeEndpointRow extends Record { qualifier: string; @@ -42,7 +42,7 @@ export function RuntimeEndpointPicker({ const goBack = () => navigate(-1); return ( - { runtimeId: string; @@ -41,7 +41,7 @@ export function RuntimePicker({ const goBack = () => navigate("/" + breadcrumb.slice(0, -1).join("/")); return ( - { version: string; @@ -38,7 +38,7 @@ export function RuntimeVersionPicker({ const goBack = () => navigate(-1); return ( -