From 02b26080ad7748887c5058a299465926b06110d2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 7 Aug 2026 20:59:59 +0000 Subject: [PATCH 01/10] feat: add read-only Gateway TUI --- src/components/Root.tsx | 41 +++ src/handlers/gateway/connector/get/screen.tsx | 40 +++ .../gateway/connector/list/screen.tsx | 42 +++ src/handlers/gateway/gateway.screen.test.tsx | 280 ++++++++++++++++++ src/handlers/gateway/gateway.test.tsx | 22 +- src/handlers/gateway/get/screen.tsx | 86 ++++++ src/handlers/gateway/index.tsx | 4 +- src/handlers/gateway/rule/get/screen.tsx | 26 ++ src/handlers/gateway/rule/list/screen.tsx | 68 +++++ src/handlers/gateway/screen.tsx | 71 +++++ src/handlers/gateway/target/get/screen.tsx | 26 ++ src/handlers/gateway/target/list/screen.tsx | 71 +++++ 12 files changed, 774 insertions(+), 3 deletions(-) create mode 100644 src/handlers/gateway/connector/get/screen.tsx create mode 100644 src/handlers/gateway/connector/list/screen.tsx create mode 100644 src/handlers/gateway/gateway.screen.test.tsx create mode 100644 src/handlers/gateway/get/screen.tsx create mode 100644 src/handlers/gateway/rule/get/screen.tsx create mode 100644 src/handlers/gateway/rule/list/screen.tsx create mode 100644 src/handlers/gateway/screen.tsx create mode 100644 src/handlers/gateway/target/get/screen.tsx create mode 100644 src/handlers/gateway/target/list/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 4f5c60a9e..ff9050fc0 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -64,6 +64,14 @@ import { Oauth2CredentialProviderGetScreen, Oauth2CredentialProviderGetJsonScreen, } from "../handlers/identity/oauth2-credential-provider/get/screen.tsx"; +import { GatewayBrowseScreen } from "../handlers/gateway/screen.tsx"; +import { GatewayDetailScreen, GatewayJsonScreen } from "../handlers/gateway/get/screen.tsx"; +import { GatewayTargetListScreen } from "../handlers/gateway/target/list/screen.tsx"; +import { GatewayTargetJsonScreen } from "../handlers/gateway/target/get/screen.tsx"; +import { GatewayConnectorListScreen } from "../handlers/gateway/connector/list/screen.tsx"; +import { GatewayConnectorJsonScreen } from "../handlers/gateway/connector/get/screen.tsx"; +import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; +import { GatewayRuleJsonScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -320,6 +328,39 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/runtime/invoke/:runtimeId/:qualifier" element={} /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> { + const target = await props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts); + if (!isConnectorTarget(target.targetConfiguration)) { + throw new InputValidationError(`Gateway Target "${targetId}" is not connector-backed`); + } + return target; + }, + enabled: gatewayId !== undefined && targetId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/connector/list/screen.tsx b/src/handlers/gateway/connector/list/screen.tsx new file mode 100644 index 000000000..2968c2215 --- /dev/null +++ b/src/handlers/gateway/connector/list/screen.tsx @@ -0,0 +1,42 @@ +import { TargetType } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import { targetColumns, targetRow } from "../../target/list/screen"; + +export function GatewayConnectorListScreen({ ctx, core }: ScreenProps) { + const { gatewayId } = useParams(); + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const encodedGatewayId = encodeURIComponent(gatewayId ?? ""); + + return ( + { + const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); + return { + items: (response.items ?? []).filter( + (target) => target.targetType === TargetType.CONNECTOR, + ), + nextToken: response.nextToken, + }; + }} + toRow={targetRow} + columns={targetColumns} + getValue={(row) => row.targetId} + onSelect={(targetId) => + navigate( + `/agentcore/gateway/browse/${encodedGatewayId}/connectors/${encodeURIComponent(targetId)}`, + ) + } + onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} + loadingMessage="Loading Gateway Connectors…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This Gateway has no Connectors." + emptyPageMessage="No Connectors on this page." + /> + ); +} diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx new file mode 100644 index 000000000..9144c4346 --- /dev/null +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + TargetType, + type GatewayRuleDetail, + type GatewaySummary, + type GetGatewayResponse, + type GetGatewayRuleResponse, + type GetGatewayTargetResponse, + type ListGatewaysResponse, + type TargetSummary, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../testing"; + +afterEach(cleanupScreens); + +const ENDPOINT = "https://agentcore.example.test"; +const GATEWAY_ID = "gateway/blue one"; +const TARGET_ID = "target/blue one"; +const CONNECTOR_ID = "connector/blue one"; +const RULE_ID = "rule/blue one"; + +function gateway(overrides: Partial = {}): GatewaySummary { + return { + gatewayId: GATEWAY_ID, + name: "checkout-gateway", + status: "READY", + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + authorizerType: "AWS_IAM", + protocolType: "MCP", + ...overrides, + }; +} + +function gatewayDetail(overrides: Partial = {}): GetGatewayResponse { + return { + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/checkout", + gatewayId: GATEWAY_ID, + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + status: "READY", + name: "checkout-gateway", + authorizerType: "AWS_IAM", + gatewayUrl: "https://checkout.gateway.example.test", + roleArn: "arn:aws:iam::123456789012:role/gateway", + protocolType: "MCP", + ...overrides, + } as GetGatewayResponse; +} + +function target(targetId: string, name: string, targetType: TargetType): TargetSummary { + return { + targetId, + name, + targetType, + status: "READY", + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + }; +} + +function targetDetail(targetId: string, connector = false): GetGatewayTargetResponse { + return { + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/checkout", + targetId, + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + status: "READY", + name: connector ? "search-connector" : "orders-target", + targetConfiguration: connector + ? { mcp: { connector: { source: { connectorId: "web-search" } } } } + : { + http: { + passthrough: { + endpoint: "https://orders.example.test", + protocolType: "CUSTOM", + }, + }, + }, + credentialProviderConfigurations: [], + } as GetGatewayTargetResponse; +} + +function rule(): GatewayRuleDetail { + return { + ruleId: RULE_ID, + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/checkout", + priority: 10, + actions: [{ routeToTarget: { staticRoute: { targetName: "orders-target" } } }], + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + status: "ACTIVE", + description: "Route orders", + }; +} + +function ruleDetail(): GetGatewayRuleResponse { + return rule() as GetGatewayRuleResponse; +} + +function coreWithGateways(items: GatewaySummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.gateway.setListResponse({ items }); + return core; +} + +describe("Gateway browse", () => { + test("renders Gateway identity and calls list with exact Core options", async () => { + const core = coreWithGateways([gateway()]); + const screen = renderScreen("/agentcore/gateway", { core, endpointUrl: ENDPOINT }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + const frame = screen.lastFrame()!; + expect(frame).toContain("status"); + expect(frame).toContain("protocol"); + expect(frame).toContain("authorizer"); + expect(frame).toContain("2026-08-02 03:04"); + expect(frame).toContain("[/] filter"); + expect(core.gateway.calls).toEqual([ + { + method: "listGateways", + args: [undefined, expect.any(Number), { region: "us-east-1", endpointUrl: ENDPOINT }], + }, + ]); + }); + + test("shows loading, empty, and retryable error states", async () => { + const loadingCore = new TestCoreClient(); + const pending = Promise.withResolvers(); + loadingCore.gateway.listGateways = async () => pending.promise; + const loading = renderScreen("/agentcore/gateway", { core: loadingCore }); + + await waitForText(loading.lastFrame, "Loading Gateways"); + await loading.press("escape"); + await waitForText(loading.lastFrame, "the platform for production AI agents"); + loading.unmount(); + + const empty = renderScreen("/agentcore/gateway"); + await waitForText(empty.lastFrame, "No Gateways found in this Region."); + empty.unmount(); + + const errorCore = new TestCoreClient(); + errorCore.gateway.setError(new Error("gateway unavailable")); + const error = renderScreen("/agentcore/gateway", { core: errorCore }); + + await waitForText(error.lastFrame, "gateway unavailable"); + expect(error.lastFrame()).toContain("[r] retry"); + errorCore.gateway.setError(undefined); + errorCore.gateway.setListResponse({ items: [gateway({ name: "recovered-gateway" })] }); + await error.write("r"); + await waitForText(error.lastFrame, "recovered-gateway"); + }); + + test("selects a Gateway and renders only read-only hub actions", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen("/agentcore/gateway", { core, endpointUrl: ENDPOINT }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + expect(screen.lastFrame()).toContain(`agentcore → gateway → ${GATEWAY_ID}`); + const frame = screen.lastFrame()!; + for (const action of ["detail", "targets", "connectors", "rules"]) { + expect(frame).toContain(action); + } + for (const excluded of ["create", "update", "delete", "invoke"]) { + expect(frame).not.toMatch(new RegExp(`\\b${excluded}\\b`)); + } + expect(core.gateway.calls.find((call) => call.method === "getGateway")).toEqual({ + method: "getGateway", + args: [GATEWAY_ID, { region: "us-east-1", endpointUrl: ENDPOINT }], + }); + }); + + test("opens complete Gateway JSON from the detail action", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen(`/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}`, { + core, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"gatewayId"'); + expect(screen.lastFrame()).toContain('"roleArn"'); + await screen.press("escape"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + }); +}); + +describe("Gateway nested browse", () => { + test("browses Targets and opens the selected Target JSON", async () => { + const core = new TestCoreClient(); + core.gateway + .setListTargetsResponse({ + items: [target(TARGET_ID, "orders-target", TargetType.PASSTHROUGH)], + }) + .setGetTargetResponse(targetDetail(TARGET_ID)); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/targets`, + { core, endpointUrl: ENDPOINT }, + ); + + await waitForText(screen.lastFrame, "orders-target"); + expect(core.gateway.calls[0]).toEqual({ + method: "listGatewayTargets", + args: [ + GATEWAY_ID, + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: ENDPOINT }, + ], + }); + await screen.press("return"); + await waitForText(screen.lastFrame, '"targetId"'); + expect(core.gateway.calls.at(-1)).toEqual({ + method: "getGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: "us-east-1", endpointUrl: ENDPOINT }], + }); + }); + + test("filters Connectors and opens the selected Connector JSON", async () => { + const core = new TestCoreClient(); + core.gateway + .setListTargetsResponse({ + items: [ + target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH), + target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR), + ], + }) + .setGetTargetResponse(targetDetail(CONNECTOR_ID, true)); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/connectors`, + { core }, + ); + + await waitForText(screen.lastFrame, "search-connector"); + expect(screen.lastFrame()).not.toContain("ordinary-target"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"targetConfiguration"'); + expect(screen.lastFrame()).toContain('"web-search"'); + }); + + test("browses Rules and opens the selected Rule JSON", async () => { + const core = new TestCoreClient(); + core.gateway.setListRulesResponse({ gatewayRules: [rule()] }).setGetRuleResponse(ruleDetail()); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/rules`, + { core }, + ); + + await waitForText(screen.lastFrame, "Route orders"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"ruleId"'); + expect(core.gateway.calls.at(-1)).toEqual({ + method: "getGatewayRule", + args: [GATEWAY_ID, RULE_ID, { region: "us-east-1", endpointUrl: undefined }], + }); + }); + + test("keeps nested lists empty and retryable without exposing write actions", async () => { + const core = new TestCoreClient(); + core.gateway.setError(new Error("targets unavailable")); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/targets`, + { core }, + ); + + await waitForText(screen.lastFrame, "targets unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + for (const excluded of ["create", "update", "delete"]) { + expect(screen.lastFrame()).not.toMatch(new RegExp(`\\b${excluded}\\b`)); + } + core.gateway.setError(undefined); + core.gateway.setListTargetsResponse({ items: [] }); + await screen.write("r"); + await waitForText(screen.lastFrame, "This Gateway has no Targets."); + }); +}); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 08845e29b..54f88f5b3 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -84,7 +84,27 @@ describe("gateway command hierarchy", () => { expect(rule?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); }); - test.each(["gateway", "gateway target", "gateway connector", "gateway rule"])( + test("opens only the Gateway root in the TUI and keeps read leaves headless", async () => { + await expect(run(["gateway"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + await expect(run(["gateway", "get"])).rejects.toThrow(/--id/); + + const core = new TestCoreClient(); + core.gateway.setListResponse({ items: [] }); + await run(["gateway", "list"], core); + expect(core.gateway.calls).toEqual([ + { + method: "listGateways", + args: [undefined, undefined, { region: REGION }], + }, + ]); + + const target = await run(["gateway", "target"]); + expect(target.stdout).toContain("Usage: agentcore gateway target"); + }); + + test.each(["gateway target", "gateway connector", "gateway rule"])( "prints help for bare `%s` without a Core call", async (command) => { const { core, stdout } = await run(command.split(" ")); diff --git a/src/handlers/gateway/get/screen.tsx b/src/handlers/gateway/get/screen.tsx new file mode 100644 index 000000000..9fd391870 --- /dev/null +++ b/src/handlers/gateway/get/screen.tsx @@ -0,0 +1,86 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../components/JsonDetail"; +import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +function gatewayPath(gatewayId: string): string { + return `/agentcore/gateway/browse/${encodeURIComponent(gatewayId)}`; +} + +function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["gateway", opts.region, gatewayId], + queryFn: () => core.gateway.getGateway(gatewayId!, opts), + enabled: gatewayId !== undefined, + }); +} + +export function GatewayDetailScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { gatewayId } = useParams(); + const detail = useGatewayDetail(props, gatewayId); + + return ( + navigate(`${gatewayPath(gatewayId)}/json`), + }, + { + name: "targets", + description: "browse every Target", + onSelect: () => navigate(`${gatewayPath(gatewayId)}/targets`), + }, + { + name: "connectors", + description: "browse connector-backed Targets", + onSelect: () => navigate(`${gatewayPath(gatewayId)}/connectors`), + }, + { + name: "rules", + description: "browse routing Rules", + onSelect: () => navigate(`${gatewayPath(gatewayId)}/rules`), + }, + ] + : [] + } + loadingLabel="Loading Gateway…" + onRetry={() => void detail.refetch()} + selectLabel="open" + /> + ); +} + +export function GatewayJsonScreen(props: ScreenProps) { + const { gatewayId } = useParams(); + const detail = useGatewayDetail(props, gatewayId); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index e17669880..8fead00c1 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -1,6 +1,6 @@ +import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import { Router } from "../../router"; -import { createHelpDefault } from "../help"; import type { Core } from "../types"; import { createGatewayConnectorHandler } from "./connector"; import { createCreateGatewayHandler } from "./create"; @@ -11,7 +11,7 @@ import { createGatewayTargetHandler } from "./target"; export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createCreateGatewayHandler(core, io)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) diff --git a/src/handlers/gateway/rule/get/screen.tsx b/src/handlers/gateway/rule/get/screen.tsx new file mode 100644 index 000000000..668c8cc45 --- /dev/null +++ b/src/handlers/gateway/rule/get/screen.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function GatewayRuleJsonScreen(props: ScreenProps) { + const { gatewayId, ruleId } = useParams(); + const opts = coreOptsFromCtx(props.ctx); + const detail = useQuery({ + queryKey: ["gateway-rule", opts.region, gatewayId, ruleId], + queryFn: () => props.core.gateway.getGatewayRule(gatewayId!, ruleId!, opts), + enabled: gatewayId !== undefined && ruleId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/rule/list/screen.tsx b/src/handlers/gateway/rule/list/screen.tsx new file mode 100644 index 000000000..de1cf67eb --- /dev/null +++ b/src/handlers/gateway/rule/list/screen.tsx @@ -0,0 +1,68 @@ +import type { GatewayRuleDetail } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +interface RuleRow extends Record { + ruleId: string; + priority: string; + status: string; + description: string; +} + +const ruleColumns = [ + { key: "priority", header: "priority", width: 10 }, + { key: "status", header: "status", width: 13 }, + { key: "description", header: "description", flex: true }, + { + key: "ruleId", + header: "id suffix", + width: 10, + render: (value: unknown) => String(value ?? "").slice(-8), + }, +] satisfies DataTableColumn[]; + +function toRow(rule: GatewayRuleDetail): RuleRow { + return { + ruleId: rule.ruleId ?? "", + priority: rule.priority?.toString() ?? "-", + status: rule.status ?? "-", + description: rule.description ?? "-", + }; +} + +export function GatewayRuleListScreen({ ctx, core }: ScreenProps) { + const { gatewayId } = useParams(); + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const encodedGatewayId = encodeURIComponent(gatewayId ?? ""); + + return ( + { + const response = await core.gateway.listGatewayRules(gatewayId!, token, pageSize, opts); + return { + items: response.gatewayRules ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={ruleColumns} + getValue={(row) => row.ruleId} + onSelect={(ruleId) => + navigate( + `/agentcore/gateway/browse/${encodedGatewayId}/rules/${encodeURIComponent(ruleId)}`, + ) + } + onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} + loadingMessage="Loading Gateway Rules…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This Gateway has no Rules." + emptyPageMessage="No Rules on this page." + /> + ); +} diff --git a/src/handlers/gateway/screen.tsx b/src/handlers/gateway/screen.tsx new file mode 100644 index 000000000..13be7a720 --- /dev/null +++ b/src/handlers/gateway/screen.tsx @@ -0,0 +1,71 @@ +import type { GatewaySummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import { formatTimestamp } from "../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../components/ui/data-table"; +import type { ScreenProps } from "../types"; +import { coreOptsFromCtx } from "../utils"; + +interface GatewayRow extends Record { + gatewayId: string; + name: string; + status: string; + protocol: string; + authorizer: string; + updatedAt: string; +} + +export const gatewayColumns = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", width: 16 }, + { key: "protocol", header: "protocol", width: 12 }, + { key: "authorizer", header: "authorizer", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(gateway: GatewaySummary): GatewayRow { + return { + gatewayId: gateway.gatewayId ?? "", + name: gateway.name ?? gateway.gatewayId ?? "", + status: gateway.status ?? "-", + protocol: gateway.protocolType ?? "unrestricted", + authorizer: gateway.authorizerType ?? "-", + updatedAt: gateway.updatedAt?.toISOString() ?? "-", + }; +} + +export function GatewayBrowseScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.gateway.listGateways(token, pageSize, opts); + return { + items: response.items ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={gatewayColumns} + getValue={(row) => row.gatewayId} + onSelect={(gatewayId) => + navigate(`/agentcore/gateway/browse/${encodeURIComponent(gatewayId)}`) + } + onBack={() => navigate("/agentcore")} + loadingMessage="Loading Gateways…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No Gateways found in this Region." + emptyPageMessage="No Gateways on this page." + /> + ); +} diff --git a/src/handlers/gateway/target/get/screen.tsx b/src/handlers/gateway/target/get/screen.tsx new file mode 100644 index 000000000..c071e4e45 --- /dev/null +++ b/src/handlers/gateway/target/get/screen.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function GatewayTargetJsonScreen(props: ScreenProps) { + const { gatewayId, targetId } = useParams(); + const opts = coreOptsFromCtx(props.ctx); + const detail = useQuery({ + queryKey: ["gateway-target", opts.region, gatewayId, targetId], + queryFn: () => props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts), + enabled: gatewayId !== undefined && targetId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/target/list/screen.tsx b/src/handlers/gateway/target/list/screen.tsx new file mode 100644 index 000000000..eed8e00d6 --- /dev/null +++ b/src/handlers/gateway/target/list/screen.tsx @@ -0,0 +1,71 @@ +import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +interface TargetRow extends Record { + targetId: string; + name: string; + type: string; + status: string; + updatedAt: string; +} + +export const targetColumns = [ + { key: "name", header: "name", flex: true }, + { key: "type", header: "type", width: 18 }, + { key: "status", header: "status", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +export function targetRow(target: TargetSummary): TargetRow { + return { + targetId: target.targetId ?? "", + name: target.name ?? target.targetId ?? "", + type: target.targetType ?? "-", + status: target.status ?? "-", + updatedAt: target.updatedAt?.toISOString() ?? "-", + }; +} + +export function GatewayTargetListScreen({ ctx, core }: ScreenProps) { + const { gatewayId } = useParams(); + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const encodedGatewayId = encodeURIComponent(gatewayId ?? ""); + + return ( + { + const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); + return { + items: response.items ?? [], + nextToken: response.nextToken, + }; + }} + toRow={targetRow} + columns={targetColumns} + getValue={(row) => row.targetId} + onSelect={(targetId) => + navigate( + `/agentcore/gateway/browse/${encodedGatewayId}/targets/${encodeURIComponent(targetId)}`, + ) + } + onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} + loadingMessage="Loading Gateway Targets…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This Gateway has no Targets." + emptyPageMessage="No Targets on this page." + /> + ); +} From 5c057422acf2ef635377ff735cdb0bd8dd1b23f8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 7 Aug 2026 21:15:54 +0000 Subject: [PATCH 02/10] test: cover non-Connector Gateway route --- src/handlers/gateway/gateway.screen.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index 9144c4346..4abbfd019 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -242,6 +242,18 @@ describe("Gateway nested browse", () => { expect(screen.lastFrame()).toContain('"web-search"'); }); + test("rejects a non-Connector Target opened through the Connector route", async () => { + const core = new TestCoreClient(); + core.gateway.setGetTargetResponse(targetDetail(TARGET_ID)); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/connectors/${encodeURIComponent(TARGET_ID)}`, + { core }, + ); + + await waitForText(screen.lastFrame, `Gateway Target "${TARGET_ID}" is not connector-backed`); + expect(screen.lastFrame()).toContain("[r] retry"); + }); + test("browses Rules and opens the selected Rule JSON", async () => { const core = new TestCoreClient(); core.gateway.setListRulesResponse({ gatewayRules: [rule()] }).setGetRuleResponse(ruleDetail()); From 7be36070fbb1f5cf780e71c1e409730f14870986 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 10 Aug 2026 17:09:50 +0000 Subject: [PATCH 03/10] fix(gateway): align read-only TUI with command routes --- README.md | 35 ++- src/components/GatewayPicker.tsx | 81 ++++++ src/components/Root.tsx | 86 ++++-- src/handlers/gateway/connector/get/screen.tsx | 14 +- src/handlers/gateway/connector/index.tsx | 4 +- .../gateway/connector/list/screen.tsx | 62 ++++- src/handlers/gateway/connector/screen.tsx | 6 + src/handlers/gateway/gateway.screen.test.tsx | 257 ++++++++++++++++-- src/handlers/gateway/gateway.test.tsx | 56 ++-- src/handlers/gateway/get/screen.tsx | 26 +- src/handlers/gateway/index.tsx | 2 + src/handlers/gateway/list/screen.tsx | 15 + src/handlers/gateway/rule/get/screen.tsx | 4 +- src/handlers/gateway/rule/index.tsx | 4 +- src/handlers/gateway/rule/list/screen.tsx | 26 +- src/handlers/gateway/rule/screen.tsx | 6 + src/handlers/gateway/screen.tsx | 71 +---- src/handlers/gateway/target/get/screen.tsx | 4 +- src/handlers/gateway/target/index.tsx | 4 +- src/handlers/gateway/target/list/screen.tsx | 26 +- src/handlers/gateway/target/screen.tsx | 6 + 21 files changed, 580 insertions(+), 215 deletions(-) create mode 100644 src/components/GatewayPicker.tsx create mode 100644 src/handlers/gateway/connector/screen.tsx create mode 100644 src/handlers/gateway/list/screen.tsx create mode 100644 src/handlers/gateway/rule/screen.tsx create mode 100644 src/handlers/gateway/target/screen.tsx diff --git a/README.md b/README.md index 6314094eb..ac53f4729 100644 --- a/README.md +++ b/README.md @@ -9,8 +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 Harness, Runtime, Memory, and Identity branches - and leaves open their corresponding menus and selection flows. +- **An interactive TUI** — bare Harness, Runtime, Memory, Identity, and Gateway + branches and leaves open their corresponding menus and selection flows. ```bash agentcore # launch the interactive TUI @@ -26,8 +26,8 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Commands with operation flags run headlessly. Bare Harness, Runtime, Memory, and -Identity branches and leaves open their interactive flows. +Commands with operation flags run headlessly. Bare Harness, Runtime, Memory, +Identity, and Gateway branches and leaves open their interactive flows. ``` agentcore # interactive TUI @@ -86,6 +86,9 @@ agentcore # interactive TUI │ ├── target │ │ ├── get # get a Target under a Gateway │ │ └── list # list Targets under a Gateway +│ ├── connector +│ │ ├── get # get a connector-backed Target +│ │ └── list # list connector-backed Targets │ └── rule │ ├── get # get a Rule under a Gateway │ └── list # list Rules under a Gateway @@ -158,6 +161,8 @@ agentcore gateway get --id agentcore gateway list --max-results 20 agentcore gateway target get --gateway-id --target-id agentcore gateway target list --gateway-id --max-results 20 +agentcore gateway connector get --gateway-id --id +agentcore gateway connector list --gateway-id --max-results 20 agentcore gateway rule get --gateway-id --rule-id agentcore gateway rule list --gateway-id --max-results 20 @@ -340,6 +345,23 @@ agentcore identity oauth2-credential-provider list agentcore identity oauth2-credential-provider get ``` +The Gateway TUI is read-only: bare Gateway, Target, Connector, and Rule +branches and their `get`/`list` leaves open command menus and scoped selection +flows. Connector is presented as a separate resource experience while using +Gateway Target operations internally. + +```bash +agentcore gateway +agentcore gateway list +agentcore gateway get +agentcore gateway target list +agentcore gateway target get +agentcore gateway connector list +agentcore gateway connector get +agentcore gateway rule list +agentcore gateway rule get +``` + --- # Architecture & patterns @@ -749,9 +771,8 @@ A Husky pre-commit hook runs Prettier (via lint-staged) on staged files automati - **Cover more AgentCore resources.** The harness surface (CRUD, versions, endpoints, invoke, exec) is fully implemented in both the CLI and the TUI; - the same patterns extend naturally to gateways, the remaining read-only - Memory data-plane operations, browser profiles, and the other AgentCore - resources. + the same patterns extend naturally to the remaining read-only Memory + data-plane operations, browser profiles, and the other AgentCore resources. - **Implement `config`.** The `config` command is currently a stub — it should read/write real global settings (telemetry, log level, ...) through an injected config accessor. diff --git a/src/components/GatewayPicker.tsx b/src/components/GatewayPicker.tsx new file mode 100644 index 000000000..312055667 --- /dev/null +++ b/src/components/GatewayPicker.tsx @@ -0,0 +1,81 @@ +import type { GatewaySummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import type { DataTableColumn } from "./ui/data-table"; + +interface GatewayRow extends Record { + gatewayId: string; + name: string; + status: string; + protocol: string; + authorizer: string; + updatedAt: string; +} + +export const gatewayColumns = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", width: 16 }, + { key: "protocol", header: "protocol", width: 12 }, + { key: "authorizer", header: "authorizer", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(gateway: GatewaySummary): GatewayRow { + return { + gatewayId: gateway.gatewayId ?? "", + name: gateway.name ?? gateway.gatewayId ?? "", + status: gateway.status ?? "-", + protocol: gateway.protocolType ?? "unrestricted", + authorizer: gateway.authorizerType ?? "-", + updatedAt: gateway.updatedAt?.toISOString() ?? "-", + }; +} + +export interface GatewayPickerProps extends ScreenProps { + breadcrumb: string[]; + description?: string; + onSelect: (gatewayId: string) => void; +} + +export function GatewayPicker({ + ctx, + core, + breadcrumb, + description, + onSelect, +}: GatewayPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.gateway.listGateways(token, pageSize, opts); + return { + items: response.items ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={gatewayColumns} + getValue={(row) => row.gatewayId} + onSelect={onSelect} + onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))} + loadingMessage="Loading Gateways…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No Gateways found in this Region." + emptyPageMessage="No Gateways on this page." + /> + ); +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ff9050fc0..db4057b4a 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -64,14 +64,18 @@ import { Oauth2CredentialProviderGetScreen, Oauth2CredentialProviderGetJsonScreen, } from "../handlers/identity/oauth2-credential-provider/get/screen.tsx"; -import { GatewayBrowseScreen } from "../handlers/gateway/screen.tsx"; -import { GatewayDetailScreen, GatewayJsonScreen } from "../handlers/gateway/get/screen.tsx"; +import { GatewayScreen } from "../handlers/gateway/screen.tsx"; +import { GatewayGetJsonScreen, GatewayGetScreen } from "../handlers/gateway/get/screen.tsx"; +import { GatewayListScreen } from "../handlers/gateway/list/screen.tsx"; +import { GatewayTargetScreen } from "../handlers/gateway/target/screen.tsx"; import { GatewayTargetListScreen } from "../handlers/gateway/target/list/screen.tsx"; -import { GatewayTargetJsonScreen } from "../handlers/gateway/target/get/screen.tsx"; +import { GatewayTargetGetScreen } from "../handlers/gateway/target/get/screen.tsx"; +import { GatewayConnectorScreen } from "../handlers/gateway/connector/screen.tsx"; import { GatewayConnectorListScreen } from "../handlers/gateway/connector/list/screen.tsx"; -import { GatewayConnectorJsonScreen } from "../handlers/gateway/connector/get/screen.tsx"; +import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/screen.tsx"; +import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx"; import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; -import { GatewayRuleJsonScreen } from "../handlers/gateway/rule/get/screen.tsx"; +import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -328,38 +332,82 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/runtime/invoke/:runtimeId/:qualifier" element={} /> - } /> + } /> } + path="agentcore/gateway/get" + element={} /> } + path="agentcore/gateway/list" + element={} /> } + /> + } + /> + } + /> + } + /> + } + /> + } /> } + path="agentcore/gateway/target/get/:gatewayId/:targetId" + element={} + /> + } /> } + /> + } + /> + } /> } + path="agentcore/gateway/connector/get/:gatewayId/:targetId" + element={} + /> + } + /> + } + /> + } /> } /> } + path="agentcore/gateway/rule/get/:gatewayId/:ruleId" + element={} /> } /> { const target = await props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts); - if (!isConnectorTarget(target.targetConfiguration)) { + if (!GatewayConnectorTarget.is(target.targetConfiguration)) { throw new InputValidationError(`Gateway Target "${targetId}" is not connector-backed`); } return target; @@ -29,7 +23,7 @@ export function GatewayConnectorJsonScreen(props: ScreenProps) { return ( { + targetId: string; + name: string; + status: string; + updatedAt: string; +} + +const connectorColumns = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toConnectorRow(target: TargetSummary): ConnectorRow { + return { + targetId: target.targetId ?? "", + name: target.name ?? target.targetId ?? "", + status: target.status ?? "-", + updatedAt: target.updatedAt?.toISOString() ?? "-", + }; +} export function GatewayConnectorListScreen({ ctx, core }: ScreenProps) { const { gatewayId } = useParams(); const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); - const encodedGatewayId = encodeURIComponent(gatewayId ?? ""); + + if (!gatewayId) { + return ( + navigate(`/agentcore/gateway/connector/list/${encodeURIComponent(id)}`)} + /> + ); + } return ( { const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); @@ -24,17 +64,19 @@ export function GatewayConnectorListScreen({ ctx, core }: ScreenProps) { nextToken: response.nextToken, }; }} - toRow={targetRow} - columns={targetColumns} + toRow={toConnectorRow} + columns={connectorColumns} getValue={(row) => row.targetId} onSelect={(targetId) => navigate( - `/agentcore/gateway/browse/${encodedGatewayId}/connectors/${encodeURIComponent(targetId)}`, + `/agentcore/gateway/connector/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(targetId)}`, ) } - onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} - loadingMessage="Loading Gateway Connectors…" - errorMessage={(error) => `Error: ${error.message}`} + onBack={() => navigate(-1)} + loadingMessage={`Loading Connectors for Gateway ${gatewayId}…`} + errorMessage={(error) => + `Error loading Connectors for Gateway ${gatewayId}: ${error.message}` + } emptyMessage="This Gateway has no Connectors." emptyPageMessage="No Connectors on this page." /> diff --git a/src/handlers/gateway/connector/screen.tsx b/src/handlers/gateway/connector/screen.tsx new file mode 100644 index 000000000..9d87c9f92 --- /dev/null +++ b/src/handlers/gateway/connector/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function GatewayConnectorScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index 4abbfd019..8ff001fc8 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -104,10 +104,21 @@ function coreWithGateways(items: GatewaySummary[]): TestCoreClient { return core; } -describe("Gateway browse", () => { +describe("Gateway menu and list", () => { + test("renders the Gateway command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway"); + + await waitForText(screen.lastFrame, "inspect AgentCore Gateways"); + const frame = screen.lastFrame()!; + for (const command of ["get", "list", "target", "connector", "rule"]) { + expect(frame).toContain(command); + } + expect(screen.core.gateway.calls).toEqual([]); + }); + test("renders Gateway identity and calls list with exact Core options", async () => { const core = coreWithGateways([gateway()]); - const screen = renderScreen("/agentcore/gateway", { core, endpointUrl: ENDPOINT }); + const screen = renderScreen("/agentcore/gateway/list", { core, endpointUrl: ENDPOINT }); await waitForText(screen.lastFrame, "checkout-gateway"); const frame = screen.lastFrame()!; @@ -128,20 +139,20 @@ describe("Gateway browse", () => { const loadingCore = new TestCoreClient(); const pending = Promise.withResolvers(); loadingCore.gateway.listGateways = async () => pending.promise; - const loading = renderScreen("/agentcore/gateway", { core: loadingCore }); + const loading = renderScreen("/agentcore/gateway/list", { core: loadingCore }); await waitForText(loading.lastFrame, "Loading Gateways"); await loading.press("escape"); - await waitForText(loading.lastFrame, "the platform for production AI agents"); + await waitForText(loading.lastFrame, "inspect AgentCore Gateways"); loading.unmount(); - const empty = renderScreen("/agentcore/gateway"); + const empty = renderScreen("/agentcore/gateway/list"); await waitForText(empty.lastFrame, "No Gateways found in this Region."); empty.unmount(); const errorCore = new TestCoreClient(); errorCore.gateway.setError(new Error("gateway unavailable")); - const error = renderScreen("/agentcore/gateway", { core: errorCore }); + const error = renderScreen("/agentcore/gateway/list", { core: errorCore }); await waitForText(error.lastFrame, "gateway unavailable"); expect(error.lastFrame()).toContain("[r] retry"); @@ -154,12 +165,12 @@ describe("Gateway browse", () => { test("selects a Gateway and renders only read-only hub actions", async () => { const core = coreWithGateways([gateway()]); core.gateway.setGetResponse(gatewayDetail()); - const screen = renderScreen("/agentcore/gateway", { core, endpointUrl: ENDPOINT }); + const screen = renderScreen("/agentcore/gateway/list", { core, endpointUrl: ENDPOINT }); await waitForText(screen.lastFrame, "checkout-gateway"); await screen.press("return"); await waitForText(screen.lastFrame, "show the full JSON definition"); - expect(screen.lastFrame()).toContain(`agentcore → gateway → ${GATEWAY_ID}`); + expect(screen.lastFrame()).toContain(`agentcore → gateway → get → ${GATEWAY_ID}`); const frame = screen.lastFrame()!; for (const action of ["detail", "targets", "connectors", "rules"]) { expect(frame).toContain(action); @@ -176,7 +187,7 @@ describe("Gateway browse", () => { test("opens complete Gateway JSON from the detail action", async () => { const core = new TestCoreClient(); core.gateway.setGetResponse(gatewayDetail()); - const screen = renderScreen(`/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}`, { + const screen = renderScreen(`/agentcore/gateway/get/${encodeURIComponent(GATEWAY_ID)}`, { core, }); @@ -187,10 +198,49 @@ describe("Gateway browse", () => { await screen.press("escape"); await waitForText(screen.lastFrame, "show the full JSON definition"); }); + + test("bare Gateway get redirects to the Gateway picker", async () => { + const core = coreWithGateways([gateway({ name: "redirected-gateway" })]); + const screen = renderScreen("/agentcore/gateway/get", { core }); + + await waitForText(screen.lastFrame, "redirected-gateway"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); + }); }); -describe("Gateway nested browse", () => { - test("browses Targets and opens the selected Target JSON", async () => { +describe("Gateway Target flow", () => { + test("renders the Target command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway/target"); + + await waitForText(screen.lastFrame, "inspect targets for an AgentCore Gateway"); + expect(screen.lastFrame()).toContain("get"); + expect(screen.lastFrame()).toContain("list"); + expect(screen.core.gateway.calls).toEqual([]); + }); + + test("selects a Gateway before listing Targets", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setListTargetsResponse({ + items: [target(TARGET_ID, "orders-target", TargetType.PASSTHROUGH)], + }); + const screen = renderScreen("/agentcore/gateway/target/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → gateway → target → list → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, "orders-target"); + expect(core.gateway.calls.find((call) => call.method === "listGatewayTargets")).toEqual({ + method: "listGatewayTargets", + args: [ + GATEWAY_ID, + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: undefined }, + ], + }); + }); + + test("opens the selected Target JSON with exact selectors", async () => { const core = new TestCoreClient(); core.gateway .setListTargetsResponse({ @@ -198,8 +248,11 @@ describe("Gateway nested browse", () => { }) .setGetTargetResponse(targetDetail(TARGET_ID)); const screen = renderScreen( - `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/targets`, - { core, endpointUrl: ENDPOINT }, + `/agentcore/gateway/target/list/${encodeURIComponent(GATEWAY_ID)}`, + { + core, + endpointUrl: ENDPOINT, + }, ); await waitForText(screen.lastFrame, "orders-target"); @@ -213,6 +266,10 @@ describe("Gateway nested browse", () => { ], }); await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → gateway → target → get → ${GATEWAY_ID} → ${TARGET_ID}`, + ); await waitForText(screen.lastFrame, '"targetId"'); expect(core.gateway.calls.at(-1)).toEqual({ method: "getGatewayTarget", @@ -220,6 +277,68 @@ describe("Gateway nested browse", () => { }); }); + test("bare Target get redirects to Gateway selection", async () => { + const core = coreWithGateways([gateway({ name: "target-parent" })]); + const screen = renderScreen("/agentcore/gateway/target/get", { core }); + + await waitForText(screen.lastFrame, "target-parent"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); + }); + + test("unwinds Target detail through the scoped list and Gateway picker", async () => { + const core = coreWithGateways([gateway()]); + core.gateway + .setListTargetsResponse({ + items: [target(TARGET_ID, "orders-target", TargetType.PASSTHROUGH)], + }) + .setGetTargetResponse(targetDetail(TARGET_ID)); + const screen = renderScreen("/agentcore/gateway/target/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, "orders-target"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"targetId"'); + + await screen.press("escape"); + await waitForText(screen.lastFrame, `agentcore → gateway → target → list → ${GATEWAY_ID}`); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a Gateway to list Targets for"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "inspect targets for an AgentCore Gateway"); + }); +}); + +describe("Gateway Connector flow", () => { + test("renders the separate Connector command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway/connector"); + + await waitForText(screen.lastFrame, "inspect connectors configured for an AgentCore Gateway"); + expect(screen.lastFrame()).toContain("get"); + expect(screen.lastFrame()).toContain("list"); + expect(screen.core.gateway.calls).toEqual([]); + }); + + test("selects a Gateway before showing the dedicated Connector list", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setListTargetsResponse({ + items: [ + target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH), + target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR), + ], + }); + const screen = renderScreen("/agentcore/gateway/connector/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → gateway → connector → list → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, "search-connector"); + const frame = screen.lastFrame()!; + expect(frame).not.toContain("ordinary-target"); + expect(frame).toContain("updated UTC"); + expect(frame).not.toMatch(/\btype\b/); + }); + test("filters Connectors and opens the selected Connector JSON", async () => { const core = new TestCoreClient(); core.gateway @@ -231,22 +350,30 @@ describe("Gateway nested browse", () => { }) .setGetTargetResponse(targetDetail(CONNECTOR_ID, true)); const screen = renderScreen( - `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/connectors`, + `/agentcore/gateway/connector/list/${encodeURIComponent(GATEWAY_ID)}`, { core }, ); await waitForText(screen.lastFrame, "search-connector"); expect(screen.lastFrame()).not.toContain("ordinary-target"); await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → gateway → connector → get → ${GATEWAY_ID} → ${CONNECTOR_ID}`, + ); await waitForText(screen.lastFrame, '"targetConfiguration"'); expect(screen.lastFrame()).toContain('"web-search"'); + expect(core.gateway.calls.at(-1)).toEqual({ + method: "getGatewayTarget", + args: [GATEWAY_ID, CONNECTOR_ID, { region: "us-east-1", endpointUrl: undefined }], + }); }); test("rejects a non-Connector Target opened through the Connector route", async () => { const core = new TestCoreClient(); core.gateway.setGetTargetResponse(targetDetail(TARGET_ID)); const screen = renderScreen( - `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/connectors/${encodeURIComponent(TARGET_ID)}`, + `/agentcore/gateway/connector/get/${encodeURIComponent(GATEWAY_ID)}/${encodeURIComponent(TARGET_ID)}`, { core }, ); @@ -254,16 +381,82 @@ describe("Gateway nested browse", () => { expect(screen.lastFrame()).toContain("[r] retry"); }); - test("browses Rules and opens the selected Rule JSON", async () => { + test("preserves Target pagination when a filtered Connector page is empty", async () => { const core = new TestCoreClient(); - core.gateway.setListRulesResponse({ gatewayRules: [rule()] }).setGetRuleResponse(ruleDetail()); + core.gateway + .setListTargetsResponse({ + items: [target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH)], + nextToken: "page-2", + }) + .setListTargetsResponse( + { + items: [target(CONNECTOR_ID, "page-two-connector", TargetType.CONNECTOR)], + }, + "page-2", + ); const screen = renderScreen( - `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/rules`, + `/agentcore/gateway/connector/list/${encodeURIComponent(GATEWAY_ID)}`, { core }, ); + await waitForText(screen.lastFrame, "No Connectors on this page."); + expect(screen.lastFrame()).toContain("page 1 · more →"); + await screen.write("l"); + await waitForText(screen.lastFrame, "page-two-connector"); + }); + + test("bare Connector get redirects to Gateway selection", async () => { + const core = coreWithGateways([gateway({ name: "connector-parent" })]); + const screen = renderScreen("/agentcore/gateway/connector/get", { core }); + + await waitForText(screen.lastFrame, "connector-parent"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); + }); +}); + +describe("Gateway Rule flow", () => { + test("renders the Rule command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway/rule"); + + await waitForText(screen.lastFrame, "inspect rules for an AgentCore Gateway"); + expect(screen.lastFrame()).toContain("get"); + expect(screen.lastFrame()).toContain("list"); + expect(screen.core.gateway.calls).toEqual([]); + }); + + test("selects a Gateway before listing Rules", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setListRulesResponse({ gatewayRules: [rule()] }); + const screen = renderScreen("/agentcore/gateway/rule/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → gateway → rule → list → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, "Route orders"); + expect(core.gateway.calls.find((call) => call.method === "listGatewayRules")).toEqual({ + method: "listGatewayRules", + args: [ + GATEWAY_ID, + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: undefined }, + ], + }); + }); + + test("opens the selected Rule JSON with exact selectors", async () => { + const core = new TestCoreClient(); + core.gateway.setListRulesResponse({ gatewayRules: [rule()] }).setGetRuleResponse(ruleDetail()); + const screen = renderScreen(`/agentcore/gateway/rule/list/${encodeURIComponent(GATEWAY_ID)}`, { + core, + }); + await waitForText(screen.lastFrame, "Route orders"); await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → gateway → rule → get → ${GATEWAY_ID} → ${RULE_ID}`, + ); await waitForText(screen.lastFrame, '"ruleId"'); expect(core.gateway.calls.at(-1)).toEqual({ method: "getGatewayRule", @@ -271,22 +464,26 @@ describe("Gateway nested browse", () => { }); }); - test("keeps nested lists empty and retryable without exposing write actions", async () => { + test("keeps scoped lists empty and retryable", async () => { const core = new TestCoreClient(); - core.gateway.setError(new Error("targets unavailable")); - const screen = renderScreen( - `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/targets`, - { core }, - ); + core.gateway.setError(new Error("rules unavailable")); + const screen = renderScreen(`/agentcore/gateway/rule/list/${encodeURIComponent(GATEWAY_ID)}`, { + core, + }); - await waitForText(screen.lastFrame, "targets unavailable"); + await waitForText(screen.lastFrame, "rules unavailable"); expect(screen.lastFrame()).toContain("[r] retry"); - for (const excluded of ["create", "update", "delete"]) { - expect(screen.lastFrame()).not.toMatch(new RegExp(`\\b${excluded}\\b`)); - } core.gateway.setError(undefined); - core.gateway.setListTargetsResponse({ items: [] }); + core.gateway.setListRulesResponse({ gatewayRules: [] }); await screen.write("r"); - await waitForText(screen.lastFrame, "This Gateway has no Targets."); + await waitForText(screen.lastFrame, "This Gateway has no Rules."); + }); + + test("bare Rule get redirects to Gateway selection", async () => { + const core = coreWithGateways([gateway({ name: "rule-parent" })]); + const screen = renderScreen("/agentcore/gateway/rule/get", { core }); + + await waitForText(screen.lastFrame, "rule-parent"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); }); }); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 54f88f5b3..ee463fd96 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -84,36 +84,24 @@ describe("gateway command hierarchy", () => { expect(rule?.children().map((child) => child.name())).toEqual(["create", "get", "list"]); }); - test("opens only the Gateway root in the TUI and keeps read leaves headless", async () => { - await expect(run(["gateway"])).rejects.toThrow( + test.each([ + ["Gateway", ["gateway"]], + ["Gateway get", ["gateway", "get"]], + ["Gateway list", ["gateway", "list"]], + ["Target", ["gateway", "target"]], + ["Target get", ["gateway", "target", "get"]], + ["Target list", ["gateway", "target", "list"]], + ["Connector", ["gateway", "connector"]], + ["Connector get", ["gateway", "connector", "get"]], + ["Connector list", ["gateway", "connector", "list"]], + ["Rule", ["gateway", "rule"]], + ["Rule get", ["gateway", "rule", "get"]], + ["Rule list", ["gateway", "rule", "list"]], + ] as const)("opens the TUI for a bare %s command", async (_label, args) => { + await expect(run([...args])).rejects.toThrow( "interactive mode requires a TTY on stdin and stdout", ); - await expect(run(["gateway", "get"])).rejects.toThrow(/--id/); - - const core = new TestCoreClient(); - core.gateway.setListResponse({ items: [] }); - await run(["gateway", "list"], core); - expect(core.gateway.calls).toEqual([ - { - method: "listGateways", - args: [undefined, undefined, { region: REGION }], - }, - ]); - - const target = await run(["gateway", "target"]); - expect(target.stdout).toContain("Usage: agentcore gateway target"); }); - - test.each(["gateway target", "gateway connector", "gateway rule"])( - "prints help for bare `%s` without a Core call", - async (command) => { - const { core, stdout } = await run(command.split(" ")); - - expect(stdout).toContain(`Usage: agentcore ${command}`); - expect(stdout).toContain("Commands:"); - expect(core.gateway.calls).toEqual([]); - }, - ); }); describe("gateway reads", () => { @@ -361,16 +349,16 @@ describe("gateway reads", () => { describe("gateway validation and errors", () => { test.each([ - ["Gateway get", ["gateway", "get"], /--id/], - ["Target get parent", ["gateway", "target", "get"], /--gateway-id/], + ["Gateway get", ["gateway", "get", "--id", ""], /--id/], + ["Target get parent", ["gateway", "target", "get", "--target-id", TARGET_ID], /--gateway-id/], ["Target get child", ["gateway", "target", "get", "--gateway-id", GATEWAY_ID], /--target-id/], - ["Target list", ["gateway", "target", "list"], /--gateway-id/], - ["Connector get parent", ["gateway", "connector", "get"], /--gateway-id/], + ["Target list", ["gateway", "target", "list", "--max-results", "1"], /--gateway-id/], + ["Connector get parent", ["gateway", "connector", "get", "--id", TARGET_ID], /--gateway-id/], ["Connector get child", ["gateway", "connector", "get", "--gateway-id", GATEWAY_ID], /--id/], - ["Connector list", ["gateway", "connector", "list"], /--gateway-id/], - ["Rule get parent", ["gateway", "rule", "get"], /--gateway-id/], + ["Connector list", ["gateway", "connector", "list", "--max-results", "1"], /--gateway-id/], + ["Rule get parent", ["gateway", "rule", "get", "--rule-id", RULE_ID], /--gateway-id/], ["Rule get child", ["gateway", "rule", "get", "--gateway-id", GATEWAY_ID], /--rule-id/], - ["Rule list", ["gateway", "rule", "list"], /--gateway-id/], + ["Rule list", ["gateway", "rule", "list", "--max-results", "1"], /--gateway-id/], ] as const)( "rejects a missing selector for %s before calling Core", async (_name, args, error) => { diff --git a/src/handlers/gateway/get/screen.tsx b/src/handlers/gateway/get/screen.tsx index 9fd391870..1a456f462 100644 --- a/src/handlers/gateway/get/screen.tsx +++ b/src/handlers/gateway/get/screen.tsx @@ -5,10 +5,6 @@ import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; import { coreOptsFromCtx } from "../../utils"; -function gatewayPath(gatewayId: string): string { - return `/agentcore/gateway/browse/${encodeURIComponent(gatewayId)}`; -} - function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefined) { const opts = coreOptsFromCtx(ctx); return useQuery({ @@ -18,14 +14,14 @@ function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefi }); } -export function GatewayDetailScreen(props: ScreenProps) { +export function GatewayGetScreen(props: ScreenProps) { const navigate = useNavigate(); const { gatewayId } = useParams(); const detail = useGatewayDetail(props, gatewayId); return ( navigate(`${gatewayPath(gatewayId)}/json`), + onSelect: () => + navigate(`/agentcore/gateway/get/${encodeURIComponent(gatewayId)}/json`), }, { name: "targets", description: "browse every Target", - onSelect: () => navigate(`${gatewayPath(gatewayId)}/targets`), + onSelect: () => + navigate(`/agentcore/gateway/target/list/${encodeURIComponent(gatewayId)}`), }, { name: "connectors", - description: "browse connector-backed Targets", - onSelect: () => navigate(`${gatewayPath(gatewayId)}/connectors`), + description: "browse configured Connectors", + onSelect: () => + navigate(`/agentcore/gateway/connector/list/${encodeURIComponent(gatewayId)}`), }, { name: "rules", description: "browse routing Rules", - onSelect: () => navigate(`${gatewayPath(gatewayId)}/rules`), + onSelect: () => + navigate(`/agentcore/gateway/rule/list/${encodeURIComponent(gatewayId)}`), }, ] : [] @@ -69,13 +69,13 @@ export function GatewayDetailScreen(props: ScreenProps) { ); } -export function GatewayJsonScreen(props: ScreenProps) { +export function GatewayGetJsonScreen(props: ScreenProps) { const { gatewayId } = useParams(); const detail = useGatewayDetail(props, gatewayId); return ( navigate(`/agentcore/gateway/get/${encodeURIComponent(gatewayId)}`)} + /> + ); +} diff --git a/src/handlers/gateway/rule/get/screen.tsx b/src/handlers/gateway/rule/get/screen.tsx index 668c8cc45..27ce6fb24 100644 --- a/src/handlers/gateway/rule/get/screen.tsx +++ b/src/handlers/gateway/rule/get/screen.tsx @@ -4,7 +4,7 @@ import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -export function GatewayRuleJsonScreen(props: ScreenProps) { +export function GatewayRuleGetScreen(props: ScreenProps) { const { gatewayId, ruleId } = useParams(); const opts = coreOptsFromCtx(props.ctx); const detail = useQuery({ @@ -15,7 +15,7 @@ export function GatewayRuleJsonScreen(props: ScreenProps) { return ( navigate(`/agentcore/gateway/rule/list/${encodeURIComponent(id)}`)} + /> + ); + } return ( { const response = await core.gateway.listGatewayRules(gatewayId!, token, pageSize, opts); @@ -55,14 +67,14 @@ export function GatewayRuleListScreen({ ctx, core }: ScreenProps) { getValue={(row) => row.ruleId} onSelect={(ruleId) => navigate( - `/agentcore/gateway/browse/${encodedGatewayId}/rules/${encodeURIComponent(ruleId)}`, + `/agentcore/gateway/rule/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(ruleId)}`, ) } - onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} - loadingMessage="Loading Gateway Rules…" - errorMessage={(error) => `Error: ${error.message}`} + onBack={() => navigate(-1)} + loadingMessage={`Loading Rules for Gateway ${gatewayId}…`} + errorMessage={(error) => `Error loading Rules for Gateway ${gatewayId}: ${error.message}`} emptyMessage="This Gateway has no Rules." - emptyPageMessage="No Rules on this page." + emptyPageMessage={`No Rules on this page for Gateway ${gatewayId}.`} /> ); } diff --git a/src/handlers/gateway/rule/screen.tsx b/src/handlers/gateway/rule/screen.tsx new file mode 100644 index 000000000..9055ab168 --- /dev/null +++ b/src/handlers/gateway/rule/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function GatewayRuleScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/gateway/screen.tsx b/src/handlers/gateway/screen.tsx index 13be7a720..74b3c65f0 100644 --- a/src/handlers/gateway/screen.tsx +++ b/src/handlers/gateway/screen.tsx @@ -1,71 +1,6 @@ -import type { GatewaySummary } from "@aws-sdk/client-bedrock-agentcore-control"; -import { useNavigate } from "react-router"; -import { formatTimestamp } from "../../components/formatTimestamp"; -import { PaginatedTablePicker } from "../../components/PaginatedTablePicker"; -import type { DataTableColumn } from "../../components/ui/data-table"; +import { RouterScreen } from "../../components/RouterScreen"; import type { ScreenProps } from "../types"; -import { coreOptsFromCtx } from "../utils"; -interface GatewayRow extends Record { - gatewayId: string; - name: string; - status: string; - protocol: string; - authorizer: string; - updatedAt: string; -} - -export const gatewayColumns = [ - { key: "name", header: "name", flex: true }, - { key: "status", header: "status", width: 16 }, - { key: "protocol", header: "protocol", width: 12 }, - { key: "authorizer", header: "authorizer", width: 18 }, - { - key: "updatedAt", - header: "updated UTC", - width: 16, - render: formatTimestamp, - }, -] satisfies DataTableColumn[]; - -function toRow(gateway: GatewaySummary): GatewayRow { - return { - gatewayId: gateway.gatewayId ?? "", - name: gateway.name ?? gateway.gatewayId ?? "", - status: gateway.status ?? "-", - protocol: gateway.protocolType ?? "unrestricted", - authorizer: gateway.authorizerType ?? "-", - updatedAt: gateway.updatedAt?.toISOString() ?? "-", - }; -} - -export function GatewayBrowseScreen({ ctx, core }: ScreenProps) { - const opts = coreOptsFromCtx(ctx); - const navigate = useNavigate(); - - return ( - { - const response = await core.gateway.listGateways(token, pageSize, opts); - return { - items: response.items ?? [], - nextToken: response.nextToken, - }; - }} - toRow={toRow} - columns={gatewayColumns} - getValue={(row) => row.gatewayId} - onSelect={(gatewayId) => - navigate(`/agentcore/gateway/browse/${encodeURIComponent(gatewayId)}`) - } - onBack={() => navigate("/agentcore")} - loadingMessage="Loading Gateways…" - errorMessage={(error) => `Error: ${error.message}`} - emptyMessage="No Gateways found in this Region." - emptyPageMessage="No Gateways on this page." - /> - ); +export function GatewayScreen(props: ScreenProps) { + return ; } diff --git a/src/handlers/gateway/target/get/screen.tsx b/src/handlers/gateway/target/get/screen.tsx index c071e4e45..51b4fc764 100644 --- a/src/handlers/gateway/target/get/screen.tsx +++ b/src/handlers/gateway/target/get/screen.tsx @@ -4,7 +4,7 @@ import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -export function GatewayTargetJsonScreen(props: ScreenProps) { +export function GatewayTargetGetScreen(props: ScreenProps) { const { gatewayId, targetId } = useParams(); const opts = coreOptsFromCtx(props.ctx); const detail = useQuery({ @@ -15,7 +15,7 @@ export function GatewayTargetJsonScreen(props: ScreenProps) { return ( navigate(`/agentcore/gateway/target/list/${encodeURIComponent(id)}`)} + /> + ); + } return ( { const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); @@ -58,14 +70,14 @@ export function GatewayTargetListScreen({ ctx, core }: ScreenProps) { getValue={(row) => row.targetId} onSelect={(targetId) => navigate( - `/agentcore/gateway/browse/${encodedGatewayId}/targets/${encodeURIComponent(targetId)}`, + `/agentcore/gateway/target/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(targetId)}`, ) } - onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} - loadingMessage="Loading Gateway Targets…" - errorMessage={(error) => `Error: ${error.message}`} + onBack={() => navigate(-1)} + loadingMessage={`Loading Targets for Gateway ${gatewayId}…`} + errorMessage={(error) => `Error loading Targets for Gateway ${gatewayId}: ${error.message}`} emptyMessage="This Gateway has no Targets." - emptyPageMessage="No Targets on this page." + emptyPageMessage={`No Targets on this page for Gateway ${gatewayId}.`} /> ); } diff --git a/src/handlers/gateway/target/screen.tsx b/src/handlers/gateway/target/screen.tsx new file mode 100644 index 000000000..b923eafe1 --- /dev/null +++ b/src/handlers/gateway/target/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function GatewayTargetScreen(props: ScreenProps) { + return ; +} From dd5bc0bb9f269989568929ce3a883db8d981e053 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 10 Aug 2026 18:20:23 +0000 Subject: [PATCH 04/10] refactor(gateway): extract scoped resource pickers --- src/components/GatewayConnectorPicker.tsx | 81 +++++++++++++++++++ src/components/GatewayRulePicker.tsx | 77 ++++++++++++++++++ src/components/GatewayTargetPicker.tsx | 80 ++++++++++++++++++ .../ui/data-table/columnWidths.test.ts | 8 ++ .../gateway/connector/list/screen.tsx | 63 ++------------- src/handlers/gateway/rule/list/screen.tsx | 59 ++------------ src/handlers/gateway/target/list/screen.tsx | 62 ++------------ 7 files changed, 264 insertions(+), 166 deletions(-) create mode 100644 src/components/GatewayConnectorPicker.tsx create mode 100644 src/components/GatewayRulePicker.tsx create mode 100644 src/components/GatewayTargetPicker.tsx diff --git a/src/components/GatewayConnectorPicker.tsx b/src/components/GatewayConnectorPicker.tsx new file mode 100644 index 000000000..9689f2a34 --- /dev/null +++ b/src/components/GatewayConnectorPicker.tsx @@ -0,0 +1,81 @@ +import { TargetType, type TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import type { DataTableColumn } from "./ui/data-table"; + +interface GatewayConnectorRow extends Record { + targetId: string; + name: string; + status: string; + updatedAt: string; +} + +export const gatewayConnectorColumns = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(target: TargetSummary): GatewayConnectorRow { + return { + targetId: target.targetId ?? "", + name: target.name ?? target.targetId ?? "", + status: target.status ?? "-", + updatedAt: target.updatedAt?.toISOString() ?? "-", + }; +} + +export interface GatewayConnectorPickerProps extends ScreenProps { + gatewayId: string; + breadcrumb: string[]; + description?: string; + onSelect: (targetId: string) => void; +} + +export function GatewayConnectorPicker({ + ctx, + core, + gatewayId, + breadcrumb, + description, + onSelect, +}: GatewayConnectorPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.gateway.listGatewayTargets(gatewayId, token, pageSize, opts); + return { + items: (response.items ?? []).filter( + (target) => target.targetType === TargetType.CONNECTOR, + ), + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={gatewayConnectorColumns} + getValue={(row) => row.targetId} + onSelect={onSelect} + onBack={() => navigate(-1)} + loadingMessage={`Loading Connectors for Gateway ${gatewayId}…`} + errorMessage={(error) => + `Error loading Connectors for Gateway ${gatewayId}: ${error.message}` + } + emptyMessage="This Gateway has no Connectors." + emptyPageMessage="No Connectors on this page." + /> + ); +} diff --git a/src/components/GatewayRulePicker.tsx b/src/components/GatewayRulePicker.tsx new file mode 100644 index 000000000..aeefa3594 --- /dev/null +++ b/src/components/GatewayRulePicker.tsx @@ -0,0 +1,77 @@ +import type { GatewayRuleDetail } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import type { DataTableColumn } from "./ui/data-table"; + +interface GatewayRuleRow extends Record { + ruleId: string; + priority: string; + status: string; + description: string; +} + +export const gatewayRuleColumns = [ + { key: "priority", header: "priority", width: 10 }, + { key: "status", header: "status", width: 13 }, + { key: "description", header: "description", flex: true }, + { + key: "ruleId", + header: "id suffix", + width: 10, + render: (value: unknown) => String(value ?? "").slice(-8), + }, +] satisfies DataTableColumn[]; + +function toRow(rule: GatewayRuleDetail): GatewayRuleRow { + return { + ruleId: rule.ruleId ?? "", + priority: rule.priority?.toString() ?? "-", + status: rule.status ?? "-", + description: rule.description ?? "-", + }; +} + +export interface GatewayRulePickerProps extends ScreenProps { + gatewayId: string; + breadcrumb: string[]; + description?: string; + onSelect: (ruleId: string) => void; +} + +export function GatewayRulePicker({ + ctx, + core, + gatewayId, + breadcrumb, + description, + onSelect, +}: GatewayRulePickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.gateway.listGatewayRules(gatewayId, token, pageSize, opts); + return { + items: response.gatewayRules ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={gatewayRuleColumns} + getValue={(row) => row.ruleId} + onSelect={onSelect} + onBack={() => navigate(-1)} + loadingMessage={`Loading Rules for Gateway ${gatewayId}…`} + errorMessage={(error) => `Error loading Rules for Gateway ${gatewayId}: ${error.message}`} + emptyMessage="This Gateway has no Rules." + emptyPageMessage={`No Rules on this page for Gateway ${gatewayId}.`} + /> + ); +} diff --git a/src/components/GatewayTargetPicker.tsx b/src/components/GatewayTargetPicker.tsx new file mode 100644 index 000000000..3508bb4b6 --- /dev/null +++ b/src/components/GatewayTargetPicker.tsx @@ -0,0 +1,80 @@ +import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import type { DataTableColumn } from "./ui/data-table"; + +interface GatewayTargetRow extends Record { + targetId: string; + name: string; + type: string; + status: string; + updatedAt: string; +} + +export const gatewayTargetColumns = [ + { key: "name", header: "name", flex: true }, + { key: "type", header: "type", width: 18 }, + { key: "status", header: "status", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(target: TargetSummary): GatewayTargetRow { + return { + targetId: target.targetId ?? "", + name: target.name ?? target.targetId ?? "", + type: target.targetType ?? "-", + status: target.status ?? "-", + updatedAt: target.updatedAt?.toISOString() ?? "-", + }; +} + +export interface GatewayTargetPickerProps extends ScreenProps { + gatewayId: string; + breadcrumb: string[]; + description?: string; + onSelect: (targetId: string) => void; +} + +export function GatewayTargetPicker({ + ctx, + core, + gatewayId, + breadcrumb, + description, + onSelect, +}: GatewayTargetPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.gateway.listGatewayTargets(gatewayId, token, pageSize, opts); + return { + items: response.items ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={gatewayTargetColumns} + getValue={(row) => row.targetId} + onSelect={onSelect} + onBack={() => navigate(-1)} + loadingMessage={`Loading Targets for Gateway ${gatewayId}…`} + errorMessage={(error) => `Error loading Targets for Gateway ${gatewayId}: ${error.message}`} + emptyMessage="This Gateway has no Targets." + emptyPageMessage={`No Targets on this page for Gateway ${gatewayId}.`} + /> + ); +} diff --git a/src/components/ui/data-table/columnWidths.test.ts b/src/components/ui/data-table/columnWidths.test.ts index 19d52445b..0d5064c9e 100644 --- a/src/components/ui/data-table/columnWidths.test.ts +++ b/src/components/ui/data-table/columnWidths.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; import stringWidth from "string-width"; +import { gatewayConnectorColumns } from "../../GatewayConnectorPicker"; +import { gatewayColumns } from "../../GatewayPicker"; +import { gatewayRuleColumns } from "../../GatewayRulePicker"; +import { gatewayTargetColumns } from "../../GatewayTargetPicker"; import { harnessEndpointColumns } from "../../HarnessEndpointPicker"; import { harnessColumns } from "../../HarnessPicker"; import { harnessVersionColumns } from "../../HarnessVersionPicker"; @@ -16,6 +20,10 @@ import { memoryColumns } from "../../MemoryPicker"; const widths = [40, 60, 80, 100, 120, 160, 200]; const flexConfigs = [ + { name: "Gateway", columns: gatewayColumns, flexIndex: 0 }, + { name: "Gateway Target", columns: gatewayTargetColumns, flexIndex: 0 }, + { name: "Gateway Connector", columns: gatewayConnectorColumns, flexIndex: 0 }, + { name: "Gateway Rule", columns: gatewayRuleColumns, flexIndex: 2 }, { name: "Runtime", columns: runtimeColumns, flexIndex: 0 }, { name: "Harness", columns: harnessColumns, flexIndex: 0 }, { name: "Harness endpoint", columns: harnessEndpointColumns, flexIndex: 0 }, diff --git a/src/handlers/gateway/connector/list/screen.tsx b/src/handlers/gateway/connector/list/screen.tsx index e1a44c761..3c61c9598 100644 --- a/src/handlers/gateway/connector/list/screen.tsx +++ b/src/handlers/gateway/connector/list/screen.tsx @@ -1,49 +1,16 @@ -import { TargetType, type TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate, useParams } from "react-router"; -import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { GatewayConnectorPicker } from "../../../../components/GatewayConnectorPicker"; import { GatewayPicker } from "../../../../components/GatewayPicker"; -import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; -import type { DataTableColumn } from "../../../../components/ui/data-table"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -interface ConnectorRow extends Record { - targetId: string; - name: string; - status: string; - updatedAt: string; -} - -const connectorColumns = [ - { key: "name", header: "name", flex: true }, - { key: "status", header: "status", width: 18 }, - { - key: "updatedAt", - header: "updated UTC", - width: 16, - render: formatTimestamp, - }, -] satisfies DataTableColumn[]; - -function toConnectorRow(target: TargetSummary): ConnectorRow { - return { - targetId: target.targetId ?? "", - name: target.name ?? target.targetId ?? "", - status: target.status ?? "-", - updatedAt: target.updatedAt?.toISOString() ?? "-", - }; -} - -export function GatewayConnectorListScreen({ ctx, core }: ScreenProps) { +export function GatewayConnectorListScreen(props: ScreenProps) { const { gatewayId } = useParams(); - const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); if (!gatewayId) { return ( navigate(`/agentcore/gateway/connector/list/${encodeURIComponent(id)}`)} @@ -52,33 +19,15 @@ export function GatewayConnectorListScreen({ ctx, core }: ScreenProps) { } return ( - { - const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); - return { - items: (response.items ?? []).filter( - (target) => target.targetType === TargetType.CONNECTOR, - ), - nextToken: response.nextToken, - }; - }} - toRow={toConnectorRow} - columns={connectorColumns} - getValue={(row) => row.targetId} onSelect={(targetId) => navigate( `/agentcore/gateway/connector/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(targetId)}`, ) } - onBack={() => navigate(-1)} - loadingMessage={`Loading Connectors for Gateway ${gatewayId}…`} - errorMessage={(error) => - `Error loading Connectors for Gateway ${gatewayId}: ${error.message}` - } - emptyMessage="This Gateway has no Connectors." - emptyPageMessage="No Connectors on this page." /> ); } diff --git a/src/handlers/gateway/rule/list/screen.tsx b/src/handlers/gateway/rule/list/screen.tsx index 54e287e58..787317271 100644 --- a/src/handlers/gateway/rule/list/screen.tsx +++ b/src/handlers/gateway/rule/list/screen.tsx @@ -1,49 +1,16 @@ -import type { GatewayRuleDetail } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate, useParams } from "react-router"; import { GatewayPicker } from "../../../../components/GatewayPicker"; -import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; -import type { DataTableColumn } from "../../../../components/ui/data-table"; +import { GatewayRulePicker } from "../../../../components/GatewayRulePicker"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -interface RuleRow extends Record { - ruleId: string; - priority: string; - status: string; - description: string; -} - -const ruleColumns = [ - { key: "priority", header: "priority", width: 10 }, - { key: "status", header: "status", width: 13 }, - { key: "description", header: "description", flex: true }, - { - key: "ruleId", - header: "id suffix", - width: 10, - render: (value: unknown) => String(value ?? "").slice(-8), - }, -] satisfies DataTableColumn[]; - -function toRow(rule: GatewayRuleDetail): RuleRow { - return { - ruleId: rule.ruleId ?? "", - priority: rule.priority?.toString() ?? "-", - status: rule.status ?? "-", - description: rule.description ?? "-", - }; -} - -export function GatewayRuleListScreen({ ctx, core }: ScreenProps) { +export function GatewayRuleListScreen(props: ScreenProps) { const { gatewayId } = useParams(); - const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); if (!gatewayId) { return ( navigate(`/agentcore/gateway/rule/list/${encodeURIComponent(id)}`)} @@ -52,29 +19,15 @@ export function GatewayRuleListScreen({ ctx, core }: ScreenProps) { } return ( - { - const response = await core.gateway.listGatewayRules(gatewayId!, token, pageSize, opts); - return { - items: response.gatewayRules ?? [], - nextToken: response.nextToken, - }; - }} - toRow={toRow} - columns={ruleColumns} - getValue={(row) => row.ruleId} onSelect={(ruleId) => navigate( `/agentcore/gateway/rule/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(ruleId)}`, ) } - onBack={() => navigate(-1)} - loadingMessage={`Loading Rules for Gateway ${gatewayId}…`} - errorMessage={(error) => `Error loading Rules for Gateway ${gatewayId}: ${error.message}`} - emptyMessage="This Gateway has no Rules." - emptyPageMessage={`No Rules on this page for Gateway ${gatewayId}.`} /> ); } diff --git a/src/handlers/gateway/target/list/screen.tsx b/src/handlers/gateway/target/list/screen.tsx index 1c0289ebf..94f833447 100644 --- a/src/handlers/gateway/target/list/screen.tsx +++ b/src/handlers/gateway/target/list/screen.tsx @@ -1,52 +1,16 @@ -import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate, useParams } from "react-router"; -import { formatTimestamp } from "../../../../components/formatTimestamp"; import { GatewayPicker } from "../../../../components/GatewayPicker"; -import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; -import type { DataTableColumn } from "../../../../components/ui/data-table"; +import { GatewayTargetPicker } from "../../../../components/GatewayTargetPicker"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; -interface TargetRow extends Record { - targetId: string; - name: string; - type: string; - status: string; - updatedAt: string; -} - -export const targetColumns = [ - { key: "name", header: "name", flex: true }, - { key: "type", header: "type", width: 18 }, - { key: "status", header: "status", width: 18 }, - { - key: "updatedAt", - header: "updated UTC", - width: 16, - render: formatTimestamp, - }, -] satisfies DataTableColumn[]; - -export function targetRow(target: TargetSummary): TargetRow { - return { - targetId: target.targetId ?? "", - name: target.name ?? target.targetId ?? "", - type: target.targetType ?? "-", - status: target.status ?? "-", - updatedAt: target.updatedAt?.toISOString() ?? "-", - }; -} - -export function GatewayTargetListScreen({ ctx, core }: ScreenProps) { +export function GatewayTargetListScreen(props: ScreenProps) { const { gatewayId } = useParams(); - const opts = coreOptsFromCtx(ctx); const navigate = useNavigate(); if (!gatewayId) { return ( navigate(`/agentcore/gateway/target/list/${encodeURIComponent(id)}`)} @@ -55,29 +19,15 @@ export function GatewayTargetListScreen({ ctx, core }: ScreenProps) { } return ( - { - const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); - return { - items: response.items ?? [], - nextToken: response.nextToken, - }; - }} - toRow={targetRow} - columns={targetColumns} - getValue={(row) => row.targetId} onSelect={(targetId) => navigate( `/agentcore/gateway/target/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(targetId)}`, ) } - onBack={() => navigate(-1)} - loadingMessage={`Loading Targets for Gateway ${gatewayId}…`} - errorMessage={(error) => `Error loading Targets for Gateway ${gatewayId}: ${error.message}`} - emptyMessage="This Gateway has no Targets." - emptyPageMessage={`No Targets on this page for Gateway ${gatewayId}.`} /> ); } From c27f00fc83ef5d47999565d8e6ca9ff69460f2b4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 10 Aug 2026 19:20:51 +0000 Subject: [PATCH 05/10] refactor(gateway): centralize connector reads in core --- src/components/GatewayConnectorPicker.tsx | 8 +- src/core/gateway.test.ts | 83 +++++++++++++++++++ src/core/gateway.tsx | 35 ++++++++ src/handlers/gateway/connector/get/index.tsx | 8 +- src/handlers/gateway/connector/get/screen.tsx | 10 +-- src/handlers/gateway/connector/list/index.tsx | 8 +- src/handlers/gateway/gateway.fixture.test.tsx | 6 ++ src/handlers/gateway/gateway.screen.test.tsx | 30 +++---- src/handlers/gateway/gateway.test.tsx | 29 +++---- src/handlers/gateway/types.tsx | 11 +++ src/testing/TestCoreClient.tsx | 50 +++++++++-- 11 files changed, 211 insertions(+), 67 deletions(-) create mode 100644 src/core/gateway.test.ts diff --git a/src/components/GatewayConnectorPicker.tsx b/src/components/GatewayConnectorPicker.tsx index 9689f2a34..4cfe16d0d 100644 --- a/src/components/GatewayConnectorPicker.tsx +++ b/src/components/GatewayConnectorPicker.tsx @@ -1,4 +1,4 @@ -import { TargetType, type TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; import { useNavigate } from "react-router"; import type { ScreenProps } from "../handlers/types"; import { coreOptsFromCtx } from "../handlers/utils"; @@ -57,11 +57,9 @@ export function GatewayConnectorPicker({ description={description} queryKey={["gateway-connectors", opts.region, gatewayId]} loadPage={async (token, pageSize) => { - const response = await core.gateway.listGatewayTargets(gatewayId, token, pageSize, opts); + const response = await core.gateway.listGatewayConnectors(gatewayId, token, pageSize, opts); return { - items: (response.items ?? []).filter( - (target) => target.targetType === TargetType.CONNECTOR, - ), + items: response.items ?? [], nextToken: response.nextToken, }; }} diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts new file mode 100644 index 000000000..513d99635 --- /dev/null +++ b/src/core/gateway.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + GetGatewayTargetCommand, + ListGatewayTargetsCommand, + TargetType, + type GetGatewayTargetResponse, + type TargetSummary, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { AwsClients } from "./types"; +import { GatewayClient } from "./gateway"; + +const options = { region: "us-west-2", endpointUrl: "https://agentcore.example.test" }; + +function gatewayClient( + send: (command: GetGatewayTargetCommand | ListGatewayTargetsCommand) => Promise, +): GatewayClient { + return new GatewayClient({ + control: () => ({ send: mock(send) }) as never, + } as unknown as AwsClients); +} + +describe("GatewayClient Connector facade", () => { + test("lists only Connector Targets and preserves the service token", async () => { + const connector = { + targetId: "connector-1", + targetType: TargetType.CONNECTOR, + } as TargetSummary; + const ordinary = { + targetId: "target-1", + targetType: TargetType.MCP_SERVER, + } as TargetSummary; + const client = gatewayClient(async (command) => { + expect(command).toBeInstanceOf(ListGatewayTargetsCommand); + expect(command.input).toEqual({ + gatewayIdentifier: "gateway-1", + nextToken: "page-2", + maxResults: 10, + }); + return { items: [connector, ordinary], nextToken: "page-3" }; + }); + + await expect(client.listGatewayConnectors("gateway-1", "page-2", 10, options)).resolves.toEqual( + { + items: [connector], + nextToken: "page-3", + }, + ); + }); + + test("gets a Connector-backed Target", async () => { + const connector = { + targetId: "connector-1", + targetConfiguration: { + mcp: { connector: { source: { connectorId: "web-search" } } }, + }, + } as GetGatewayTargetResponse; + const client = gatewayClient(async (command) => { + expect(command).toBeInstanceOf(GetGatewayTargetCommand); + expect(command.input).toEqual({ + gatewayIdentifier: "gateway-1", + targetId: "connector-1", + }); + return connector; + }); + + await expect(client.getGatewayConnector("gateway-1", "connector-1", options)).resolves.toEqual( + connector, + ); + }); + + test("rejects a Target that is not Connector-backed", async () => { + const client = gatewayClient(async () => ({ + targetId: "target-1", + targetConfiguration: { + mcp: { mcpServer: { endpoint: "https://example.test/mcp" } }, + }, + })); + + await expect(client.getGatewayConnector("gateway-1", "target-1", options)).rejects.toThrow( + 'Gateway Target "target-1" is not connector-backed', + ); + }); +}); diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 7f5f4a3d0..377d03750 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -8,6 +8,7 @@ import { ListGatewayRulesCommand, ListGatewaysCommand, ListGatewayTargetsCommand, + TargetType, type CreateGatewayResponse, type CreateGatewayRuleResponse, type CreateGatewayTargetResponse, @@ -17,7 +18,9 @@ import { type ListGatewayRulesResponse, type ListGatewaysResponse, type ListGatewayTargetsResponse, + type TargetConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../errors"; import type { CoreGatewayClient, CreateGatewayInput, @@ -98,6 +101,31 @@ export class GatewayClient implements CoreGatewayClient { .send(new CreateGatewayTargetCommand(input)); } + async getGatewayConnector( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + const target = await this.getGatewayTarget(gatewayId, targetId, options); + if (!GatewayClient.isConnectorTarget(target.targetConfiguration)) { + throw new InputValidationError(`Gateway Target "${targetId}" is not connector-backed`); + } + return target; + } + + async listGatewayConnectors( + gatewayId: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + const response = await this.listGatewayTargets(gatewayId, nextToken, maxResults, options); + return { + ...response, + items: response.items?.filter((target) => target.targetType === TargetType.CONNECTOR), + }; + } + async getGatewayRule( gatewayId: string, ruleId: string, @@ -132,4 +160,11 @@ export class GatewayClient implements CoreGatewayClient { ): Promise { return this.clients.control(toClientConfig(options)).send(new CreateGatewayRuleCommand(input)); } + + private static isConnectorTarget(configuration: TargetConfiguration | undefined): boolean { + return ( + configuration?.mcp?.connector !== undefined || + configuration?.inference?.connector !== undefined + ); + } } diff --git a/src/handlers/gateway/connector/get/index.tsx b/src/handlers/gateway/connector/get/index.tsx index 111e9265c..8825e067f 100644 --- a/src/handlers/gateway/connector/get/index.tsx +++ b/src/handlers/gateway/connector/get/index.tsx @@ -1,10 +1,9 @@ import z from "zod"; -import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; +import { InputValidationError } from "../../../../errors"; export const createGetGatewayConnectorHandler = (core: Core) => createHandler({ @@ -22,14 +21,11 @@ export const createGetGatewayConnectorHandler = (core: Core) => throw new InputValidationError("required option '--id ' not specified"); } - const target = await core.gateway.getGatewayTarget( + const target = await core.gateway.getGatewayConnector( flags["gateway-id"], flags.id, coreOptsFromCtx(ctx), ); - if (!GatewayConnectorTarget.is(target.targetConfiguration)) { - throw new InputValidationError(`Gateway Target "${flags.id}" is not connector-backed`); - } ctx.require(JsonRendererKey).renderJson(target); }, diff --git a/src/handlers/gateway/connector/get/screen.tsx b/src/handlers/gateway/connector/get/screen.tsx index 7d83d752f..09acbe560 100644 --- a/src/handlers/gateway/connector/get/screen.tsx +++ b/src/handlers/gateway/connector/get/screen.tsx @@ -1,23 +1,15 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; -import { InputValidationError } from "../../../../errors"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; export function GatewayConnectorGetScreen(props: ScreenProps) { const { gatewayId, targetId } = useParams(); const opts = coreOptsFromCtx(props.ctx); const detail = useQuery({ queryKey: ["gateway-connector", opts.region, gatewayId, targetId], - queryFn: async () => { - const target = await props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts); - if (!GatewayConnectorTarget.is(target.targetConfiguration)) { - throw new InputValidationError(`Gateway Target "${targetId}" is not connector-backed`); - } - return target; - }, + queryFn: () => props.core.gateway.getGatewayConnector(gatewayId!, targetId!, opts), enabled: gatewayId !== undefined && targetId !== undefined, }); diff --git a/src/handlers/gateway/connector/list/index.tsx b/src/handlers/gateway/connector/list/index.tsx index 6448b6122..93da91567 100644 --- a/src/handlers/gateway/connector/list/index.tsx +++ b/src/handlers/gateway/connector/list/index.tsx @@ -1,4 +1,3 @@ -import { TargetType } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; @@ -20,16 +19,13 @@ export const createListGatewayConnectorsHandler = (core: Core) => throw new InputValidationError("required option '--gateway-id ' not specified"); } - const response = await core.gateway.listGatewayTargets( + const response = await core.gateway.listGatewayConnectors( flags["gateway-id"], flags["next-token"], flags["max-results"], coreOptsFromCtx(ctx), ); - ctx.require(JsonRendererKey).renderJson({ - ...response, - items: response.items?.filter((target) => target.targetType === TargetType.CONNECTOR), - }); + ctx.require(JsonRendererKey).renderJson(response); }, }); diff --git a/src/handlers/gateway/gateway.fixture.test.tsx b/src/handlers/gateway/gateway.fixture.test.tsx index 95dd197c8..17753ecbb 100644 --- a/src/handlers/gateway/gateway.fixture.test.tsx +++ b/src/handlers/gateway/gateway.fixture.test.tsx @@ -126,6 +126,12 @@ describe("Gateway fixture-backed reads", () => { expect(JSON.parse(stdout).targetId).toBe(CONNECTOR_ID); }); + test("rejects a non-Connector Target through Connector get", async () => { + await expect( + run(["gateway", "connector", "get", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID]), + ).rejects.toThrow(`Gateway Target "${TARGET_ID}" is not connector-backed`); + }); + test("lists Gateway Connectors", async () => { const stdout = await run( ["gateway", "connector", "list", "--gateway-id", CONNECTOR_GATEWAY_ID], diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index 8ff001fc8..ac7225044 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -321,11 +321,8 @@ describe("Gateway Connector flow", () => { test("selects a Gateway before showing the dedicated Connector list", async () => { const core = coreWithGateways([gateway()]); - core.gateway.setListTargetsResponse({ - items: [ - target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH), - target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR), - ], + core.gateway.setListConnectorsResponse({ + items: [target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR)], }); const screen = renderScreen("/agentcore/gateway/connector/list", { core }); @@ -339,16 +336,13 @@ describe("Gateway Connector flow", () => { expect(frame).not.toMatch(/\btype\b/); }); - test("filters Connectors and opens the selected Connector JSON", async () => { + test("lists Connectors and opens the selected Connector JSON", async () => { const core = new TestCoreClient(); core.gateway - .setListTargetsResponse({ - items: [ - target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH), - target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR), - ], + .setListConnectorsResponse({ + items: [target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR)], }) - .setGetTargetResponse(targetDetail(CONNECTOR_ID, true)); + .setGetConnectorResponse(targetDetail(CONNECTOR_ID, true)); const screen = renderScreen( `/agentcore/gateway/connector/list/${encodeURIComponent(GATEWAY_ID)}`, { core }, @@ -364,14 +358,14 @@ describe("Gateway Connector flow", () => { await waitForText(screen.lastFrame, '"targetConfiguration"'); expect(screen.lastFrame()).toContain('"web-search"'); expect(core.gateway.calls.at(-1)).toEqual({ - method: "getGatewayTarget", + method: "getGatewayConnector", args: [GATEWAY_ID, CONNECTOR_ID, { region: "us-east-1", endpointUrl: undefined }], }); }); test("rejects a non-Connector Target opened through the Connector route", async () => { const core = new TestCoreClient(); - core.gateway.setGetTargetResponse(targetDetail(TARGET_ID)); + core.gateway.setError(new Error(`Gateway Target "${TARGET_ID}" is not connector-backed`)); const screen = renderScreen( `/agentcore/gateway/connector/get/${encodeURIComponent(GATEWAY_ID)}/${encodeURIComponent(TARGET_ID)}`, { core }, @@ -381,14 +375,14 @@ describe("Gateway Connector flow", () => { expect(screen.lastFrame()).toContain("[r] retry"); }); - test("preserves Target pagination when a filtered Connector page is empty", async () => { + test("preserves Connector pagination when a page is empty", async () => { const core = new TestCoreClient(); core.gateway - .setListTargetsResponse({ - items: [target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH)], + .setListConnectorsResponse({ + items: [], nextToken: "page-2", }) - .setListTargetsResponse( + .setListConnectorsResponse( { items: [target(CONNECTOR_ID, "page-two-connector", TargetType.CONNECTOR)], }, diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index ee463fd96..d8270ef98 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -228,7 +228,7 @@ describe("gateway reads", () => { ["inference", { inference: { connector: { source: { connectorId: "openai" } } } }], ] as const)("gets a configured %s Connector", async (_kind, targetConfiguration) => { const core = new TestCoreClient(); - core.gateway.setGetTargetResponse({ ...targetResponse, targetConfiguration }); + core.gateway.setGetConnectorResponse({ ...targetResponse, targetConfiguration }); const result = await run( ["gateway", "connector", "get", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], @@ -237,7 +237,7 @@ describe("gateway reads", () => { expect(result.core.gateway.calls).toEqual([ { - method: "getGatewayTarget", + method: "getGatewayConnector", args: [GATEWAY_ID, TARGET_ID, { region: REGION }], }, ]); @@ -246,42 +246,35 @@ describe("gateway reads", () => { test("lists only configured Connectors and preserves the service token", async () => { const response: ListGatewayTargetsResponse = { - items: [ - { targetId: TARGET_ID, targetType: TargetType.CONNECTOR } as TargetSummary, - { targetId: "target-2", targetType: TargetType.MCP_SERVER } as TargetSummary, - ], + items: [{ targetId: TARGET_ID, targetType: TargetType.CONNECTOR } as TargetSummary], nextToken: "target-page-2", }; const core = new TestCoreClient(); - core.gateway.setListTargetsResponse(response); + core.gateway.setListConnectorsResponse(response); const result = await run( ["gateway", "connector", "list", "--gateway-id", GATEWAY_ID, "--max-results", "2"], core, ); - expect(JSON.parse(result.stdout)).toEqual({ - items: [response.items![0]], - nextToken: response.nextToken, - }); + expect(JSON.parse(result.stdout)).toEqual(response); expect(result.core.gateway.calls).toEqual([ { - method: "listGatewayTargets", + method: "listGatewayConnectors", args: [GATEWAY_ID, undefined, 2, { region: REGION }], }, ]); }); - test("rejects a non-Connector Target from connector get", async () => { + test("propagates Core validation from connector get", async () => { + const error = new Error(`Gateway Target "${TARGET_ID}" is not connector-backed`); + error.name = "InputValidationError"; const core = new TestCoreClient(); - core.gateway.setGetTargetResponse({ - ...targetResponse, - targetConfiguration: { mcp: { mcpServer: { endpoint: "https://example.test/mcp" } } }, - }); + core.gateway.setError(error); await expect( run(["gateway", "connector", "get", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], core), - ).rejects.toThrow(`Gateway Target "${TARGET_ID}" is not connector-backed`); + ).rejects.toBe(error); }); test("gets a Gateway Rule with qualified selectors", async () => { diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index f1c090847..c414d1c3b 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -47,6 +47,17 @@ export interface CoreGatewayClient { input: CreateGatewayTargetInput, options: CoreOptions, ): Promise; + getGatewayConnector( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise; + listGatewayConnectors( + gatewayId: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise; getGatewayRule( gatewayId: string, ruleId: string, diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 3e323ffaf..c606f4cd2 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -830,6 +830,8 @@ export class TestGatewayClient implements CoreGatewayClient { private listResponses = new Map(); private getTargetResponse: GetGatewayTargetResponse = DEFAULT_GET_GATEWAY_TARGET_RESPONSE; private listTargetResponses = new Map(); + private getConnectorResponse: GetGatewayTargetResponse = DEFAULT_GET_GATEWAY_TARGET_RESPONSE; + private listConnectorResponses = new Map(); private getRuleResponse: GetGatewayRuleResponse = DEFAULT_GET_GATEWAY_RULE_RESPONSE; private listRuleResponses = new Map(); private error?: Error; @@ -854,6 +856,16 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setGetConnectorResponse(response: GetGatewayTargetResponse): this { + this.getConnectorResponse = response; + return this; + } + + setListConnectorsResponse(response: ListGatewayTargetsResponse, forNextToken?: string): this { + this.listConnectorResponses.set(forNextToken, response); + return this; + } + setGetRuleResponse(response: GetGatewayRuleResponse): this { this.getRuleResponse = response; return this; @@ -935,13 +947,32 @@ export class TestGatewayClient implements CoreGatewayClient { ); } - async createGatewayRule( - input: CreateGatewayRuleInput, + async getGatewayConnector( + gatewayId: string, + targetId: string, options: CoreOptions, - ): Promise { - this.calls.push({ method: "createGatewayRule", args: [input, options] }); + ): Promise { + this.calls.push({ method: "getGatewayConnector", args: [gatewayId, targetId, options] }); if (this.error) throw this.error; - return DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; + return this.getConnectorResponse; + } + + async listGatewayConnectors( + gatewayId: string, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + this.calls.push({ + method: "listGatewayConnectors", + args: [gatewayId, nextToken, maxResults, options], + }); + if (this.error) throw this.error; + return ( + this.listConnectorResponses.get(nextToken) ?? + this.listConnectorResponses.get(undefined) ?? + DEFAULT_LIST_GATEWAY_TARGETS_RESPONSE + ); } async getGatewayRule( @@ -971,6 +1002,15 @@ export class TestGatewayClient implements CoreGatewayClient { DEFAULT_LIST_GATEWAY_RULES_RESPONSE ); } + + async createGatewayRule( + input: CreateGatewayRuleInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createGatewayRule", args: [input, options] }); + if (this.error) throw this.error; + return DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; + } } type TestCoreClientOptions = { From 956f4887f3104e64c246f32594170afaef99d1d3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 10 Aug 2026 20:25:45 +0000 Subject: [PATCH 06/10] refactor(gateway): align TUI menus and tests --- src/handlers/gateway/connector/screen.tsx | 4 +- src/handlers/gateway/gateway.screen.test.tsx | 19 +- src/handlers/gateway/gateway.test.tsx | 287 +------------------ src/handlers/gateway/rule/screen.tsx | 4 +- src/handlers/gateway/screen.tsx | 4 +- src/handlers/gateway/target/screen.tsx | 4 +- src/testing/TestCoreClient.tsx | 18 +- 7 files changed, 35 insertions(+), 305 deletions(-) diff --git a/src/handlers/gateway/connector/screen.tsx b/src/handlers/gateway/connector/screen.tsx index 9d87c9f92..e77e5ff4e 100644 --- a/src/handlers/gateway/connector/screen.tsx +++ b/src/handlers/gateway/connector/screen.tsx @@ -1,6 +1,8 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; +const OMIT = ["create"]; + export function GatewayConnectorScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index ac7225044..cda1a9c3a 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -113,6 +113,7 @@ describe("Gateway menu and list", () => { for (const command of ["get", "list", "target", "connector", "rule"]) { expect(frame).toContain(command); } + expect(frame).not.toMatch(/\bcreate\b/); expect(screen.core.gateway.calls).toEqual([]); }); @@ -213,8 +214,10 @@ describe("Gateway Target flow", () => { const screen = renderScreen("/agentcore/gateway/target"); await waitForText(screen.lastFrame, "inspect targets for an AgentCore Gateway"); - expect(screen.lastFrame()).toContain("get"); - expect(screen.lastFrame()).toContain("list"); + const frame = screen.lastFrame()!; + expect(frame).toContain("get"); + expect(frame).toContain("list"); + expect(frame).not.toMatch(/\bcreate\b/); expect(screen.core.gateway.calls).toEqual([]); }); @@ -314,8 +317,10 @@ describe("Gateway Connector flow", () => { const screen = renderScreen("/agentcore/gateway/connector"); await waitForText(screen.lastFrame, "inspect connectors configured for an AgentCore Gateway"); - expect(screen.lastFrame()).toContain("get"); - expect(screen.lastFrame()).toContain("list"); + const frame = screen.lastFrame()!; + expect(frame).toContain("get"); + expect(frame).toContain("list"); + expect(frame).not.toMatch(/\bcreate\b/); expect(screen.core.gateway.calls).toEqual([]); }); @@ -413,8 +418,10 @@ describe("Gateway Rule flow", () => { const screen = renderScreen("/agentcore/gateway/rule"); await waitForText(screen.lastFrame, "inspect rules for an AgentCore Gateway"); - expect(screen.lastFrame()).toContain("get"); - expect(screen.lastFrame()).toContain("list"); + const frame = screen.lastFrame()!; + expect(frame).toContain("get"); + expect(frame).toContain("list"); + expect(frame).not.toMatch(/\bcreate\b/); expect(screen.core.gateway.calls).toEqual([]); }); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index d8270ef98..5ce7889ca 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -1,16 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { - TargetType, - type GatewayRuleDetail, - type GatewaySummary, - type GetGatewayResponse, - type GetGatewayRuleResponse, - type GetGatewayTargetResponse, - type ListGatewayRulesResponse, - type ListGatewaysResponse, - type ListGatewayTargetsResponse, - type TargetSummary, -} from "@aws-sdk/client-bedrock-agentcore-control"; import { createSilentLogger, TestCoreClient, @@ -20,29 +8,10 @@ import { import { createRootHandler } from "../index"; const REGION = "us-west-2"; -const ENDPOINT = "https://agentcore.example.test"; const GATEWAY_ID = "gateway-1"; const TARGET_ID = "target-1"; const RULE_ID = "rule-1"; -const gatewayResponse = { - gatewayId: GATEWAY_ID, - name: "fixture-gateway", - status: "READY", -} as GetGatewayResponse; -const targetResponse = { - gatewayArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:gateway/${GATEWAY_ID}`, - targetId: TARGET_ID, - name: "fixture-target", - status: "READY", -} as GetGatewayTargetResponse; -const ruleResponse = { - ruleId: RULE_ID, - gatewayArn: `arn:aws:bedrock-agentcore:${REGION}:123456789012:gateway/${GATEWAY_ID}`, - priority: 1, - status: "ACTIVE", -} as GetGatewayRuleResponse; - async function run( args: string[], core = new TestCoreClient(), @@ -104,243 +73,7 @@ describe("gateway command hierarchy", () => { }); }); -describe("gateway reads", () => { - test("gets a Gateway and renders the response unchanged", async () => { - const core = new TestCoreClient(); - core.gateway.setGetResponse(gatewayResponse); - - const result = await run(["gateway", "get", "--id", GATEWAY_ID], core); - - expect(result.core.gateway.calls).toEqual([ - { - method: "getGateway", - args: [GATEWAY_ID, { region: REGION }], - }, - ]); - expect(JSON.parse(result.stdout)).toEqual(gatewayResponse); - }); - - test("paginates Gateways with the returned token", async () => { - const first: ListGatewaysResponse = { - items: [{ gatewayId: GATEWAY_ID } as GatewaySummary], - nextToken: "gateway-page-2", - }; - const second: ListGatewaysResponse = { - items: [{ gatewayId: "gateway-2" } as GatewaySummary], - }; - const core = new TestCoreClient(); - core.gateway.setListResponse(first).setListResponse(second, first.nextToken); - - const firstResult = await run(["gateway", "list", "--max-results", "1"], core); - const secondResult = await run( - ["gateway", "list", "--max-results", "1", "--next-token", first.nextToken!], - core, - ); - - expect(JSON.parse(firstResult.stdout)).toEqual(first); - expect(JSON.parse(secondResult.stdout)).toEqual(second); - expect(core.gateway.calls).toEqual([ - { - method: "listGateways", - args: [undefined, 1, { region: REGION }], - }, - { - method: "listGateways", - args: [first.nextToken, 1, { region: REGION }], - }, - ]); - }); - - test("gets a Gateway Target with qualified selectors and endpoint options", async () => { - const core = new TestCoreClient(); - core.gateway.setGetTargetResponse(targetResponse); - - const result = await run( - [ - "gateway", - "target", - "get", - "--gateway-id", - GATEWAY_ID, - "--target-id", - TARGET_ID, - "--endpoint-url", - ENDPOINT, - ], - core, - ); - - expect(result.core.gateway.calls).toEqual([ - { - method: "getGatewayTarget", - args: [GATEWAY_ID, TARGET_ID, { region: REGION, endpointUrl: ENDPOINT }], - }, - ]); - expect(JSON.parse(result.stdout)).toEqual(targetResponse); - }); - - test("paginates Gateway Targets with the parent ID and returned token", async () => { - const first: ListGatewayTargetsResponse = { - items: [{ targetId: TARGET_ID } as TargetSummary], - nextToken: "target-page-2", - }; - const second: ListGatewayTargetsResponse = { - items: [{ targetId: "target-2" } as TargetSummary], - }; - const core = new TestCoreClient(); - core.gateway.setListTargetsResponse(first).setListTargetsResponse(second, first.nextToken); - - const firstResult = await run( - ["gateway", "target", "list", "--gateway-id", GATEWAY_ID, "--max-results", "1"], - core, - ); - const secondResult = await run( - [ - "gateway", - "target", - "list", - "--gateway-id", - GATEWAY_ID, - "--max-results", - "1", - "--next-token", - first.nextToken!, - ], - core, - ); - - expect(JSON.parse(firstResult.stdout)).toEqual(first); - expect(JSON.parse(secondResult.stdout)).toEqual(second); - expect(core.gateway.calls).toEqual([ - { - method: "listGatewayTargets", - args: [GATEWAY_ID, undefined, 1, { region: REGION }], - }, - { - method: "listGatewayTargets", - args: [GATEWAY_ID, first.nextToken, 1, { region: REGION }], - }, - ]); - }); - - test.each([ - ["MCP", { mcp: { connector: { source: { connectorId: "web-search" } } } }], - ["inference", { inference: { connector: { source: { connectorId: "openai" } } } }], - ] as const)("gets a configured %s Connector", async (_kind, targetConfiguration) => { - const core = new TestCoreClient(); - core.gateway.setGetConnectorResponse({ ...targetResponse, targetConfiguration }); - - const result = await run( - ["gateway", "connector", "get", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], - core, - ); - - expect(result.core.gateway.calls).toEqual([ - { - method: "getGatewayConnector", - args: [GATEWAY_ID, TARGET_ID, { region: REGION }], - }, - ]); - expect(JSON.parse(result.stdout)).toEqual({ ...targetResponse, targetConfiguration }); - }); - - test("lists only configured Connectors and preserves the service token", async () => { - const response: ListGatewayTargetsResponse = { - items: [{ targetId: TARGET_ID, targetType: TargetType.CONNECTOR } as TargetSummary], - nextToken: "target-page-2", - }; - const core = new TestCoreClient(); - core.gateway.setListConnectorsResponse(response); - - const result = await run( - ["gateway", "connector", "list", "--gateway-id", GATEWAY_ID, "--max-results", "2"], - core, - ); - - expect(JSON.parse(result.stdout)).toEqual(response); - expect(result.core.gateway.calls).toEqual([ - { - method: "listGatewayConnectors", - args: [GATEWAY_ID, undefined, 2, { region: REGION }], - }, - ]); - }); - - test("propagates Core validation from connector get", async () => { - const error = new Error(`Gateway Target "${TARGET_ID}" is not connector-backed`); - error.name = "InputValidationError"; - const core = new TestCoreClient(); - core.gateway.setError(error); - - await expect( - run(["gateway", "connector", "get", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], core), - ).rejects.toBe(error); - }); - - test("gets a Gateway Rule with qualified selectors", async () => { - const core = new TestCoreClient(); - core.gateway.setGetRuleResponse(ruleResponse); - - const result = await run( - ["gateway", "rule", "get", "--gateway-id", GATEWAY_ID, "--rule-id", RULE_ID], - core, - ); - - expect(result.core.gateway.calls).toEqual([ - { - method: "getGatewayRule", - args: [GATEWAY_ID, RULE_ID, { region: REGION }], - }, - ]); - expect(JSON.parse(result.stdout)).toEqual(ruleResponse); - }); - - test("paginates Gateway Rules with the parent ID and returned token", async () => { - const first: ListGatewayRulesResponse = { - gatewayRules: [{ ruleId: RULE_ID } as GatewayRuleDetail], - nextToken: "rule-page-2", - }; - const second: ListGatewayRulesResponse = { - gatewayRules: [{ ruleId: "rule-2" } as GatewayRuleDetail], - }; - const core = new TestCoreClient(); - core.gateway.setListRulesResponse(first).setListRulesResponse(second, first.nextToken); - - const firstResult = await run( - ["gateway", "rule", "list", "--gateway-id", GATEWAY_ID, "--max-results", "1"], - core, - ); - const secondResult = await run( - [ - "gateway", - "rule", - "list", - "--gateway-id", - GATEWAY_ID, - "--max-results", - "1", - "--next-token", - first.nextToken!, - ], - core, - ); - - expect(JSON.parse(firstResult.stdout)).toEqual(first); - expect(JSON.parse(secondResult.stdout)).toEqual(second); - expect(core.gateway.calls).toEqual([ - { - method: "listGatewayRules", - args: [GATEWAY_ID, undefined, 1, { region: REGION }], - }, - { - method: "listGatewayRules", - args: [GATEWAY_ID, first.nextToken, 1, { region: REGION }], - }, - ]); - }); -}); - -describe("gateway validation and errors", () => { +describe("gateway validation", () => { test.each([ ["Gateway get", ["gateway", "get", "--id", ""], /--id/], ["Target get parent", ["gateway", "target", "get", "--target-id", TARGET_ID], /--gateway-id/], @@ -370,22 +103,4 @@ describe("gateway validation and errors", () => { ); expect(core.gateway.calls).toEqual([]); }); - - test.each([ - ["Gateway", ["gateway", "get", "--id", "missing-gateway"]], - [ - "Target", - ["gateway", "target", "get", "--gateway-id", GATEWAY_ID, "--target-id", "missing-target"], - ], - ["Rule", ["gateway", "rule", "get", "--gateway-id", GATEWAY_ID, "--rule-id", "missing-rule"]], - ] as const)("propagates ResourceNotFoundException from %s get", async (_name, args) => { - const error = new Error("resource not found"); - error.name = "ResourceNotFoundException"; - const core = new TestCoreClient(); - core.gateway.setError(error); - - await expect(run([...args], core)).rejects.toMatchObject({ - name: "ResourceNotFoundException", - }); - }); }); diff --git a/src/handlers/gateway/rule/screen.tsx b/src/handlers/gateway/rule/screen.tsx index 9055ab168..ef0c48ec3 100644 --- a/src/handlers/gateway/rule/screen.tsx +++ b/src/handlers/gateway/rule/screen.tsx @@ -1,6 +1,8 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; +const OMIT = ["create"]; + export function GatewayRuleScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/handlers/gateway/screen.tsx b/src/handlers/gateway/screen.tsx index 74b3c65f0..4219b545e 100644 --- a/src/handlers/gateway/screen.tsx +++ b/src/handlers/gateway/screen.tsx @@ -1,6 +1,8 @@ import { RouterScreen } from "../../components/RouterScreen"; import type { ScreenProps } from "../types"; +const OMIT = ["create"]; + export function GatewayScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/handlers/gateway/target/screen.tsx b/src/handlers/gateway/target/screen.tsx index b923eafe1..aa4035111 100644 --- a/src/handlers/gateway/target/screen.tsx +++ b/src/handlers/gateway/target/screen.tsx @@ -1,6 +1,8 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; +const OMIT = ["create"]; + export function GatewayTargetScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index c606f4cd2..bff8ae546 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -975,6 +975,15 @@ export class TestGatewayClient implements CoreGatewayClient { ); } + async createGatewayRule( + input: CreateGatewayRuleInput, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "createGatewayRule", args: [input, options] }); + if (this.error) throw this.error; + return DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; + } + async getGatewayRule( gatewayId: string, ruleId: string, @@ -1002,15 +1011,6 @@ export class TestGatewayClient implements CoreGatewayClient { DEFAULT_LIST_GATEWAY_RULES_RESPONSE ); } - - async createGatewayRule( - input: CreateGatewayRuleInput, - options: CoreOptions, - ): Promise { - this.calls.push({ method: "createGatewayRule", args: [input, options] }); - if (this.error) throw this.error; - return DEFAULT_CREATE_GATEWAY_RULE_RESPONSE; - } } type TestCoreClientOptions = { From 827fdfa3c9f8ebe21379bed000ef0daeabda59a9 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 10 Aug 2026 21:54:51 +0000 Subject: [PATCH 07/10] refactor(gateway): use shared TUI support --- src/handlers/gateway/connector/index.tsx | 1 + src/handlers/gateway/connector/screen.tsx | 4 +--- src/handlers/gateway/gateway.test.tsx | 9 +++++++++ src/handlers/gateway/index.tsx | 1 + src/handlers/gateway/rule/index.tsx | 1 + src/handlers/gateway/rule/screen.tsx | 4 +--- src/handlers/gateway/screen.tsx | 4 +--- src/handlers/gateway/target/index.tsx | 1 + src/handlers/gateway/target/screen.tsx | 4 +--- 9 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/handlers/gateway/connector/index.tsx b/src/handlers/gateway/connector/index.tsx index a40dad806..6a12f39ae 100644 --- a/src/handlers/gateway/connector/index.tsx +++ b/src/handlers/gateway/connector/index.tsx @@ -9,6 +9,7 @@ import { createListGatewayConnectorsHandler } from "./list"; export function createGatewayConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "inspect connectors configured for an AgentCore Gateway") .default(renderTui(core, io)) + .supportedTuiCommands("get", "list") .handler(createCreateGatewayConnectorHandler(core, io)) .handler(createGetGatewayConnectorHandler(core)) .handler(createListGatewayConnectorsHandler(core)); diff --git a/src/handlers/gateway/connector/screen.tsx b/src/handlers/gateway/connector/screen.tsx index e77e5ff4e..9d87c9f92 100644 --- a/src/handlers/gateway/connector/screen.tsx +++ b/src/handlers/gateway/connector/screen.tsx @@ -1,8 +1,6 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; -const OMIT = ["create"]; - export function GatewayConnectorScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 5ce7889ca..1887ec919 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -71,6 +71,15 @@ describe("gateway command hierarchy", () => { "interactive mode requires a TTY on stdin and stdout", ); }); + + test.each([ + ["Gateway create", ["gateway", "create"], /--name/], + ["Target create", ["gateway", "target", "create"], /--gateway-id/], + ["Connector create", ["gateway", "connector", "create"], /--gateway-id/], + ["Rule create", ["gateway", "rule", "create"], /--gateway-id/], + ] as const)("runs normal validation for bare CLI-only %s", async (_label, args, error) => { + await expect(run([...args])).rejects.toThrow(error); + }); }); describe("gateway validation", () => { diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index d0d47d521..dbbf51c3b 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -14,6 +14,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) + .supportedTuiCommands("get", "list", "target", "connector", "rule") .handler(createCreateGatewayHandler(core, io)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) diff --git a/src/handlers/gateway/rule/index.tsx b/src/handlers/gateway/rule/index.tsx index b14c863aa..e8ee9ad68 100644 --- a/src/handlers/gateway/rule/index.tsx +++ b/src/handlers/gateway/rule/index.tsx @@ -9,6 +9,7 @@ import { createListGatewayRulesHandler } from "./list"; export function createGatewayRuleHandler(core: Core, io: AppIO): Router { return new Router("rule", "inspect rules for an AgentCore Gateway") .default(renderTui(core, io)) + .supportedTuiCommands("get", "list") .handler(createCreateGatewayRuleHandler(core, io)) .handler(createGetGatewayRuleHandler(core)) .handler(createListGatewayRulesHandler(core)); diff --git a/src/handlers/gateway/rule/screen.tsx b/src/handlers/gateway/rule/screen.tsx index ef0c48ec3..9055ab168 100644 --- a/src/handlers/gateway/rule/screen.tsx +++ b/src/handlers/gateway/rule/screen.tsx @@ -1,8 +1,6 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; -const OMIT = ["create"]; - export function GatewayRuleScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/handlers/gateway/screen.tsx b/src/handlers/gateway/screen.tsx index 4219b545e..74b3c65f0 100644 --- a/src/handlers/gateway/screen.tsx +++ b/src/handlers/gateway/screen.tsx @@ -1,8 +1,6 @@ import { RouterScreen } from "../../components/RouterScreen"; import type { ScreenProps } from "../types"; -const OMIT = ["create"]; - export function GatewayScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/handlers/gateway/target/index.tsx b/src/handlers/gateway/target/index.tsx index 3e9030a90..8f21577e1 100644 --- a/src/handlers/gateway/target/index.tsx +++ b/src/handlers/gateway/target/index.tsx @@ -9,6 +9,7 @@ import { createListGatewayTargetsHandler } from "./list"; export function createGatewayTargetHandler(core: Core, io: AppIO): Router { return new Router("target", "inspect targets for an AgentCore Gateway") .default(renderTui(core, io)) + .supportedTuiCommands("get", "list") .handler(createCreateGatewayTargetHandler(core, io)) .handler(createGetGatewayTargetHandler(core)) .handler(createListGatewayTargetsHandler(core)); diff --git a/src/handlers/gateway/target/screen.tsx b/src/handlers/gateway/target/screen.tsx index aa4035111..b923eafe1 100644 --- a/src/handlers/gateway/target/screen.tsx +++ b/src/handlers/gateway/target/screen.tsx @@ -1,8 +1,6 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; -const OMIT = ["create"]; - export function GatewayTargetScreen(props: ScreenProps) { - return ; + return ; } From 81e2b4adc15c0c69650aa07174ecdc2cc992f0db Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 10 Aug 2026 22:04:44 +0000 Subject: [PATCH 08/10] fix(gateway): fill connector pages --- src/core/gateway.test.ts | 116 +++++++++++++++++-- src/core/gateway.tsx | 21 +++- src/handlers/gateway/gateway.screen.test.tsx | 21 +--- 3 files changed, 123 insertions(+), 35 deletions(-) diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 513d99635..88cb82e08 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -11,6 +11,14 @@ import { GatewayClient } from "./gateway"; const options = { region: "us-west-2", endpointUrl: "https://agentcore.example.test" }; +function connector(targetId: string): TargetSummary { + return { targetId, targetType: TargetType.CONNECTOR } as TargetSummary; +} + +function ordinary(targetId: string): TargetSummary { + return { targetId, targetType: TargetType.MCP_SERVER } as TargetSummary; +} + function gatewayClient( send: (command: GetGatewayTargetCommand | ListGatewayTargetsCommand) => Promise, ): GatewayClient { @@ -20,15 +28,8 @@ function gatewayClient( } describe("GatewayClient Connector facade", () => { - test("lists only Connector Targets and preserves the service token", async () => { - const connector = { - targetId: "connector-1", - targetType: TargetType.CONNECTOR, - } as TargetSummary; - const ordinary = { - targetId: "target-1", - targetType: TargetType.MCP_SERVER, - } as TargetSummary; + test("filters Connector Targets from the final service page", async () => { + const connectorTarget = connector("connector-1"); const client = gatewayClient(async (command) => { expect(command).toBeInstanceOf(ListGatewayTargetsCommand); expect(command.input).toEqual({ @@ -36,15 +37,106 @@ describe("GatewayClient Connector facade", () => { nextToken: "page-2", maxResults: 10, }); - return { items: [connector, ordinary], nextToken: "page-3" }; + return { items: [connectorTarget, ordinary("target-1")] }; }); await expect(client.listGatewayConnectors("gateway-1", "page-2", 10, options)).resolves.toEqual( { - items: [connector], - nextToken: "page-3", + items: [connectorTarget], + }, + ); + }); + + test("fills a Connector page without reading past the returned token", async () => { + const requests: unknown[] = []; + const connectors = [ + connector("connector-1"), + connector("connector-2"), + connector("connector-3"), + ]; + const client = gatewayClient(async (command) => { + if (!(command instanceof ListGatewayTargetsCommand)) { + throw new Error("expected ListGatewayTargetsCommand"); + } + requests.push(command.input); + switch (command.input.nextToken) { + case undefined: + return { + items: [ordinary("target-1"), connectors[0], ordinary("target-2")], + nextToken: "page-2", + }; + case "page-2": + return { + items: [ordinary("target-3"), connectors[1]], + nextToken: "page-3", + }; + case "page-3": + return { items: [connectors[2]], nextToken: "page-4" }; + default: + throw new Error(`unexpected token ${command.input.nextToken}`); + } + }); + + await expect(client.listGatewayConnectors("gateway-1", undefined, 3, options)).resolves.toEqual( + { + items: connectors, + nextToken: "page-4", }, ); + expect(requests).toEqual([ + { gatewayIdentifier: "gateway-1", nextToken: undefined, maxResults: 3 }, + { gatewayIdentifier: "gateway-1", nextToken: "page-2", maxResults: 2 }, + { gatewayIdentifier: "gateway-1", nextToken: "page-3", maxResults: 1 }, + ]); + }); + + test("returns a partial Connector page when Targets are exhausted", async () => { + const connectorTarget = connector("connector-1"); + const requests: unknown[] = []; + const client = gatewayClient(async (command) => { + if (!(command instanceof ListGatewayTargetsCommand)) { + throw new Error("expected ListGatewayTargetsCommand"); + } + requests.push(command.input); + return command.input.nextToken === undefined + ? { items: [ordinary("target-1"), connectorTarget], nextToken: "page-2" } + : { items: [ordinary("target-2")] }; + }); + + await expect(client.listGatewayConnectors("gateway-1", undefined, 3, options)).resolves.toEqual( + { + items: [connectorTarget], + }, + ); + expect(requests).toEqual([ + { gatewayIdentifier: "gateway-1", nextToken: undefined, maxResults: 3 }, + { gatewayIdentifier: "gateway-1", nextToken: "page-2", maxResults: 2 }, + ]); + }); + + test("uses service-default pages when maxResults is omitted", async () => { + const connectorTarget = connector("connector-1"); + const requests: unknown[] = []; + const client = gatewayClient(async (command) => { + if (!(command instanceof ListGatewayTargetsCommand)) { + throw new Error("expected ListGatewayTargetsCommand"); + } + requests.push(command.input); + return command.input.nextToken === undefined + ? { items: [ordinary("target-1")], nextToken: "page-2" } + : { items: [connectorTarget, ordinary("target-2")], nextToken: "page-3" }; + }); + + await expect( + client.listGatewayConnectors("gateway-1", undefined, undefined, options), + ).resolves.toEqual({ + items: [connectorTarget], + nextToken: "page-3", + }); + expect(requests).toEqual([ + { gatewayIdentifier: "gateway-1", nextToken: undefined, maxResults: undefined }, + { gatewayIdentifier: "gateway-1", nextToken: "page-2", maxResults: undefined }, + ]); }); test("gets a Connector-backed Target", async () => { diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 377d03750..d79157af2 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -19,6 +19,7 @@ import { type ListGatewaysResponse, type ListGatewayTargetsResponse, type TargetConfiguration, + type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError } from "../errors"; import type { @@ -119,11 +120,21 @@ export class GatewayClient implements CoreGatewayClient { maxResults: number | undefined, options: CoreOptions, ): Promise { - const response = await this.listGatewayTargets(gatewayId, nextToken, maxResults, options); - return { - ...response, - items: response.items?.filter((target) => target.targetType === TargetType.CONNECTOR), - }; + const items: TargetSummary[] = []; + let token = nextToken; + + while (true) { + const remaining = maxResults === undefined ? undefined : maxResults - items.length; + const response = await this.listGatewayTargets(gatewayId, token, remaining, options); + items.push( + ...(response.items ?? []).filter((target) => target.targetType === TargetType.CONNECTOR), + ); + const full = maxResults === undefined ? items.length > 0 : items.length >= maxResults; + if (full || response.nextToken === undefined) { + return { ...response, items }; + } + token = response.nextToken; + } } async getGatewayRule( diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index cda1a9c3a..e997c0ec3 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -380,28 +380,13 @@ describe("Gateway Connector flow", () => { expect(screen.lastFrame()).toContain("[r] retry"); }); - test("preserves Connector pagination when a page is empty", async () => { - const core = new TestCoreClient(); - core.gateway - .setListConnectorsResponse({ - items: [], - nextToken: "page-2", - }) - .setListConnectorsResponse( - { - items: [target(CONNECTOR_ID, "page-two-connector", TargetType.CONNECTOR)], - }, - "page-2", - ); + test("shows the Gateway-level empty state when no Connectors exist", async () => { const screen = renderScreen( `/agentcore/gateway/connector/list/${encodeURIComponent(GATEWAY_ID)}`, - { core }, ); - await waitForText(screen.lastFrame, "No Connectors on this page."); - expect(screen.lastFrame()).toContain("page 1 · more →"); - await screen.write("l"); - await waitForText(screen.lastFrame, "page-two-connector"); + await waitForText(screen.lastFrame, "This Gateway has no Connectors."); + expect(screen.lastFrame()).not.toContain("more →"); }); test("bare Connector get redirects to Gateway selection", async () => { From 24f49f82df3f0b0a3c173f876481dc61b1d17d78 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 11 Aug 2026 16:59:07 +0000 Subject: [PATCH 09/10] fix(gateway): bound connector pagination --- src/core/gateway.test.ts | 62 ++++++++++++++++--- src/core/gateway.tsx | 35 ++++++++--- ...atewayTargetsCommand.e04764abb647e975.json | 20 ++++++ 3 files changed, 99 insertions(+), 18 deletions(-) create mode 100644 src/handlers/gateway/__fixtures__/ListGatewayTargetsCommand.e04764abb647e975.json diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 88cb82e08..2322c0c96 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -6,6 +6,7 @@ import { type GetGatewayTargetResponse, type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; +import { ResultTruncationError } from "../errors"; import type { AwsClients } from "./types"; import { GatewayClient } from "./gateway"; @@ -47,7 +48,7 @@ describe("GatewayClient Connector facade", () => { ); }); - test("fills a Connector page without reading past the returned token", async () => { + test("fills a Connector page and returns a token known to lead to another Connector", async () => { const requests: unknown[] = []; const connectors = [ connector("connector-1"), @@ -72,6 +73,8 @@ describe("GatewayClient Connector facade", () => { }; case "page-3": return { items: [connectors[2]], nextToken: "page-4" }; + case "page-4": + return { items: [ordinary("target-4"), connector("connector-4")], nextToken: "page-5" }; default: throw new Error(`unexpected token ${command.input.nextToken}`); } @@ -87,6 +90,32 @@ describe("GatewayClient Connector facade", () => { { gatewayIdentifier: "gateway-1", nextToken: undefined, maxResults: 3 }, { gatewayIdentifier: "gateway-1", nextToken: "page-2", maxResults: 2 }, { gatewayIdentifier: "gateway-1", nextToken: "page-3", maxResults: 1 }, + { gatewayIdentifier: "gateway-1", nextToken: "page-4", maxResults: 100 }, + ]); + }); + + test("omits nextToken when lookahead finds no more Connectors", async () => { + const connectorTarget = connector("connector-1"); + const requests: unknown[] = []; + const client = gatewayClient(async (command) => { + if (!(command instanceof ListGatewayTargetsCommand)) { + throw new Error("expected ListGatewayTargetsCommand"); + } + requests.push(command.input); + return command.input.nextToken === undefined + ? { items: [connectorTarget], nextToken: "page-2" } + : { items: [ordinary("target-2")] }; + }); + + await expect(client.listGatewayConnectors("gateway-1", undefined, 1, options)).resolves.toEqual( + { + items: [connectorTarget], + nextToken: undefined, + }, + ); + expect(requests).toEqual([ + { gatewayIdentifier: "gateway-1", nextToken: undefined, maxResults: 1 }, + { gatewayIdentifier: "gateway-1", nextToken: "page-2", maxResults: 100 }, ]); }); @@ -114,8 +143,8 @@ describe("GatewayClient Connector facade", () => { ]); }); - test("uses service-default pages when maxResults is omitted", async () => { - const connectorTarget = connector("connector-1"); + test("fills the default Connector page when maxResults is omitted", async () => { + const connectors = [connector("connector-1"), connector("connector-2")]; const requests: unknown[] = []; const client = gatewayClient(async (command) => { if (!(command instanceof ListGatewayTargetsCommand)) { @@ -123,22 +152,37 @@ describe("GatewayClient Connector facade", () => { } requests.push(command.input); return command.input.nextToken === undefined - ? { items: [ordinary("target-1")], nextToken: "page-2" } - : { items: [connectorTarget, ordinary("target-2")], nextToken: "page-3" }; + ? { items: [connectors[0], ordinary("target-1")], nextToken: "page-2" } + : { items: [connectors[1], ordinary("target-2")] }; }); await expect( client.listGatewayConnectors("gateway-1", undefined, undefined, options), ).resolves.toEqual({ - items: [connectorTarget], - nextToken: "page-3", + items: connectors, }); expect(requests).toEqual([ - { gatewayIdentifier: "gateway-1", nextToken: undefined, maxResults: undefined }, - { gatewayIdentifier: "gateway-1", nextToken: "page-2", maxResults: undefined }, + { gatewayIdentifier: "gateway-1", nextToken: undefined, maxResults: 100 }, + { gatewayIdentifier: "gateway-1", nextToken: "page-2", maxResults: 99 }, ]); }); + test("throws when Connector discovery exceeds the Target page cap", async () => { + let calls = 0; + const client = gatewayClient(async (command) => { + if (!(command instanceof ListGatewayTargetsCommand)) { + throw new Error("expected ListGatewayTargetsCommand"); + } + calls += 1; + return { items: [], nextToken: `page-${calls}` }; + }); + + await expect(client.listGatewayConnectors("gateway-1", undefined, 1, options)).rejects.toThrow( + ResultTruncationError, + ); + expect(calls).toBe(101); + }); + test("gets a Connector-backed Target", async () => { const connector = { targetId: "connector-1", diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index d79157af2..b1d7764c5 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -21,7 +21,7 @@ import { type TargetConfiguration, type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { InputValidationError } from "../errors"; +import { InputValidationError, ResultTruncationError } from "../errors"; import type { CoreGatewayClient, CreateGatewayInput, @@ -31,6 +31,9 @@ import type { import type { AwsClients, CoreOptions } from "./types"; import { toClientConfig } from "./utils"; +const DEFAULT_CONNECTOR_PAGE_SIZE = 100; +const MAX_CONNECTOR_TARGET_PAGES = 101; + export class GatewayClient implements CoreGatewayClient { constructor(private readonly clients: AwsClients) {} @@ -120,21 +123,35 @@ export class GatewayClient implements CoreGatewayClient { maxResults: number | undefined, options: CoreOptions, ): Promise { + const pageSize = maxResults ?? DEFAULT_CONNECTOR_PAGE_SIZE; const items: TargetSummary[] = []; let token = nextToken; + let filling = true; - while (true) { - const remaining = maxResults === undefined ? undefined : maxResults - items.length; - const response = await this.listGatewayTargets(gatewayId, token, remaining, options); - items.push( - ...(response.items ?? []).filter((target) => target.targetType === TargetType.CONNECTOR), + for (let page = 0; page < MAX_CONNECTOR_TARGET_PAGES; page++) { + const requestToken = token; + const requestSize = filling ? pageSize - items.length : DEFAULT_CONNECTOR_PAGE_SIZE; + const response = await this.listGatewayTargets(gatewayId, token, requestSize, options); + const connectors = (response.items ?? []).filter( + (target) => target.targetType === TargetType.CONNECTOR, ); - const full = maxResults === undefined ? items.length > 0 : items.length >= maxResults; - if (full || response.nextToken === undefined) { - return { ...response, items }; + + if (filling) { + items.push(...connectors); + filling = items.length < pageSize; + } else if (connectors.length > 0) { + return { ...response, items, nextToken: requestToken }; + } + + if (response.nextToken === undefined) { + return { ...response, items, nextToken: undefined }; } token = response.nextToken; } + + throw new ResultTruncationError( + `Gateway Connector discovery exceeded ${MAX_CONNECTOR_TARGET_PAGES} Target pages; results are incomplete`, + ); } async getGatewayRule( diff --git a/src/handlers/gateway/__fixtures__/ListGatewayTargetsCommand.e04764abb647e975.json b/src/handlers/gateway/__fixtures__/ListGatewayTargetsCommand.e04764abb647e975.json new file mode 100644 index 000000000..cfc4c7480 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/ListGatewayTargetsCommand.e04764abb647e975.json @@ -0,0 +1,20 @@ +{ + "items": [ + { + "targetId": "Z3FQ0H8JCK", + "name": "agentcore-cli-gateway-read-connector-openai", + "status": "READY", + "createdAt": { + "$date": "2026-08-05T20:20:45.351Z" + }, + "updatedAt": { + "$date": "2026-08-05T20:20:51.210Z" + }, + "description": "AgentCore CLI persistent READY Gateway Connector Target fixture", + "lastSynchronizedAt": { + "$date": "2026-08-05T20:20:51.000Z" + }, + "targetType": "CONNECTOR" + } + ] +} \ No newline at end of file From 9d530a5e78061ab795a1d6b10c0c0375943bc3b7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 11 Aug 2026 18:42:05 +0000 Subject: [PATCH 10/10] test(gateway): assert TUI command support --- src/handlers/gateway/gateway.test.tsx | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 1887ec919..e025c526a 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -5,6 +5,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; +import { compile, isTuiCommandSupported, ValueContext } from "../../router"; import { createRootHandler } from "../index"; const REGION = "us-west-2"; @@ -27,6 +28,24 @@ async function run( return { core, stdout: io.stdout() }; } +function supportsTui(path: readonly string[]): boolean { + let command = compile( + createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), + ValueContext.EmptyContext(), + ); + + for (const name of path) { + const child = command.commands.find((candidate) => candidate.name() === name); + if (!child) throw new Error(`missing command ${path.join(" ")}`); + command = child; + } + return isTuiCommandSupported(command); +} + describe("gateway command hierarchy", () => { test("registers the Gateway command hierarchy", () => { const root = createRootHandler(new TestCoreClient(), { @@ -66,10 +85,8 @@ describe("gateway command hierarchy", () => { ["Rule", ["gateway", "rule"]], ["Rule get", ["gateway", "rule", "get"]], ["Rule list", ["gateway", "rule", "list"]], - ] as const)("opens the TUI for a bare %s command", async (_label, args) => { - await expect(run([...args])).rejects.toThrow( - "interactive mode requires a TTY on stdin and stdout", - ); + ] as const)("marks bare %s as TUI-supported", (_label, args) => { + expect(supportsTui(args)).toBe(true); }); test.each([ @@ -77,7 +94,8 @@ describe("gateway command hierarchy", () => { ["Target create", ["gateway", "target", "create"], /--gateway-id/], ["Connector create", ["gateway", "connector", "create"], /--gateway-id/], ["Rule create", ["gateway", "rule", "create"], /--gateway-id/], - ] as const)("runs normal validation for bare CLI-only %s", async (_label, args, error) => { + ] as const)("keeps bare CLI-only %s out of the TUI", async (_label, args, error) => { + expect(supportsTui(args)).toBe(false); await expect(run([...args])).rejects.toThrow(error); }); });