Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import { RuntimeScreen } from "../handlers/runtime/screen.tsx";
import { RuntimeGetJsonScreen, RuntimeGetScreen } from "../handlers/runtime/get/screen.tsx";
import { RuntimeListScreen } from "../handlers/runtime/list/screen.tsx";
import { RuntimeEndpointScreen } from "../handlers/runtime/endpoint/screen.tsx";
import { RuntimeGetEndpointScreen } from "../handlers/runtime/endpoint/get/screen.tsx";
import {
RuntimeGetEndpointJsonScreen,
RuntimeGetEndpointScreen,
} from "../handlers/runtime/endpoint/get/screen.tsx";
import { RuntimeListEndpointsScreen } from "../handlers/runtime/endpoint/list/screen.tsx";
import { RuntimeVersionScreen } from "../handlers/runtime/version/screen.tsx";
import { RuntimeGetVersionScreen } from "../handlers/runtime/version/get/screen.tsx";
Expand Down Expand Up @@ -283,6 +286,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/runtime/endpoint/get/:runtimeId/:qualifier"
element={<RuntimeGetEndpointScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/runtime/endpoint/get/:runtimeId/:qualifier/json"
element={<RuntimeGetEndpointJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/runtime/endpoint/list"
element={<RuntimeListEndpointsScreen ctx={ctx} core={core} />}
Expand Down
102 changes: 99 additions & 3 deletions src/handlers/runtime/endpoint/endpoint.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import type {
AgentRuntime,
AgentRuntimeEndpoint,
GetAgentRuntimeResponse,
GetAgentRuntimeEndpointResponse,
} from "@aws-sdk/client-bedrock-agentcore-control";
import {
Expand Down Expand Up @@ -65,6 +66,25 @@ function getEndpointResponse(
};
}

function getRuntimeResponse(): GetAgentRuntimeResponse {
return {
agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-123",
agentRuntimeId: "runtime-123",
agentRuntimeName: "checkout",
agentRuntimeVersion: "3",
createdAt: new Date("2026-07-19T01:02:03.000Z"),
lastUpdatedAt: new Date("2026-07-20T12:34:56.000Z"),
roleArn: "arn:aws:iam::123456789012:role/runtime-role",
networkConfiguration: { networkMode: "PUBLIC" },
lifecycleConfiguration: {
idleRuntimeSessionTimeout: 900,
maxLifetime: 28_800,
},
status: "READY",
protocolConfiguration: { serverProtocol: "HTTP" },
};
}

async function waitForRuntimePicker(lastFrame: () => string | undefined): Promise<void> {
await waitFor(() => {
const frame = lastFrame() ?? "";
Expand Down Expand Up @@ -187,7 +207,7 @@ describe("Runtime endpoint flow", () => {
expect(r.lastFrame()).toContain("endpoint access denied");
});

test("selecting an encoded qualifier opens complete endpoint JSON with exact selectors", async () => {
test("selecting an encoded qualifier opens its summary with exact selectors", async () => {
const qualifier = "prod/blue one";
const core = new TestCoreClient();
core.runtime.setListEndpointsResponse({
Expand All @@ -205,7 +225,10 @@ describe("Runtime endpoint flow", () => {
r.lastFrame,
`agentcore → runtime → endpoint → get → runtime-123 → ${qualifier}`,
);
await waitForText(r.lastFrame, '"targetVersion"');
await waitForText(r.lastFrame, "invoke this Runtime endpoint");
expect(r.lastFrame()).toMatch(/liveVersion\s+3/);
expect(r.lastFrame()).toMatch(/targetVersion\s+4/);
expect(r.lastFrame()).toContain("show the full JSON definition");
await waitFor(() => core.runtime.calls.some((call) => call.method === "getRuntimeEndpoint"));
expect(core.runtime.calls.find((call) => call.method === "getRuntimeEndpoint")).toEqual({
method: "getRuntimeEndpoint",
Expand All @@ -220,6 +243,79 @@ describe("Runtime endpoint flow", () => {
});
});

test("shows the endpoint failure reason only when the service provides one", async () => {
const healthyCore = new TestCoreClient();
healthyCore.runtime.setGetEndpointResponse(getEndpointResponse());
const healthy = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", {
core: healthyCore,
});

await waitForText(healthy.lastFrame, "invoke this Runtime endpoint");
expect(healthy.lastFrame()).not.toContain("failureReason");
healthy.unmount();

const failedCore = new TestCoreClient();
failedCore.runtime.setGetEndpointResponse(
getEndpointResponse({
status: "UPDATE_FAILED",
failureReason: "Endpoint failed its health check",
}),
);
const failed = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", {
core: failedCore,
});

await waitForText(failed.lastFrame, "Endpoint failed its health check");
expect(failed.lastFrame()).toMatch(/failureReason\s+Endpoint failed its health check/);
});

test("opens complete endpoint JSON from the detail action and returns to the summary", async () => {
const core = new TestCoreClient();
core.runtime.setGetEndpointResponse(getEndpointResponse());
const r = renderScreen("/agentcore/runtime/endpoint/get/runtime-123/prod", { core });

await waitForText(r.lastFrame, "invoke this Runtime endpoint");
await r.press("down");
await r.press("return");
await waitForText(
r.lastFrame,
"agentcore → runtime → endpoint → get → runtime-123 → prod → json",
);
await waitForText(r.lastFrame, '"targetVersion"');

await r.press("escape");
await waitFor(() => {
const frame = r.lastFrame() ?? "";
return (
frame.includes("agentcore → runtime → endpoint → get → runtime-123 → prod") &&
!frame.includes("→ json")
);
});
await waitForText(r.lastFrame, "invoke this Runtime endpoint");
});

test("invokes the selected endpoint directly and Esc returns to its summary", async () => {
const core = new TestCoreClient();
core.runtime
.setListEndpointsResponse({ runtimeEndpoints: [endpoint()] })
.setGetEndpointResponse(getEndpointResponse())
.setGetResponse(getRuntimeResponse());
const r = renderScreen("/agentcore/runtime/endpoint/list/runtime-123", { core });

await waitForText(r.lastFrame, "prod");
await r.press("return");
await waitForText(r.lastFrame, "invoke this Runtime endpoint");
await r.press("return");
await waitForText(r.lastFrame, "agentcore → runtime → invoke → runtime-123 → prod");
await waitForText(r.lastFrame, "Enter JSON payload");

await r.press("escape");
await waitForText(r.lastFrame, "agentcore → runtime → endpoint → get → runtime-123 → prod");
await waitForText(r.lastFrame, "invoke this Runtime endpoint");
await r.press("escape");
await waitForText(r.lastFrame, "agentcore → runtime → endpoint → list → runtime-123");
});

test("uses a structural parent for the Runtime picker and history below it", async () => {
const parentCore = new TestCoreClient();
parentCore.runtime.setListResponse({
Expand Down Expand Up @@ -254,7 +350,7 @@ describe("Runtime endpoint flow", () => {
await list.press("return");
await waitForText(list.lastFrame, "prod");
await list.press("return");
await waitForText(list.lastFrame, '"agentRuntimeEndpointArn"');
await waitForText(list.lastFrame, "invoke this Runtime endpoint");
await list.press("escape");
await waitForText(list.lastFrame, "agentcore → runtime → endpoint → list → runtime-123");
});
Expand Down
79 changes: 74 additions & 5 deletions src/handlers/runtime/endpoint/get/screen.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,92 @@
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
import { useNavigate, useParams } from "react-router";
import { JsonDetail } from "../../../../components/JsonDetail";
import { ResourceDetailScreen } from "../../../../components/ResourceDetailScreen";
import type { ScreenProps } from "../../../types";
import { coreOptsFromCtx } from "../../../utils";

export function RuntimeGetEndpointScreen({ ctx, core }: ScreenProps) {
function useRuntimeEndpointDetail(
{ ctx, core }: ScreenProps,
runtimeId: string | undefined,
qualifier: string | undefined,
) {
const opts = coreOptsFromCtx(ctx);
const { runtimeId, qualifier } = useParams();
const detail = useQuery({
return useQuery({
queryKey: ["runtime-endpoint", opts.region, runtimeId, qualifier],
queryFn: () => core.runtime.getRuntimeEndpoint(runtimeId!, qualifier!, opts),
enabled: runtimeId !== undefined && qualifier !== undefined,
});
}

function endpointPath(runtimeId: string, qualifier: string, suffix?: string): string {
const path = `/agentcore/runtime/endpoint/get/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`;
return suffix ? `${path}/${suffix}` : path;
}

export function RuntimeGetEndpointScreen(props: ScreenProps) {
const navigate = useNavigate();
const { runtimeId, qualifier } = useParams();
const detail = useRuntimeEndpointDetail(props, runtimeId, qualifier);
const endpoint = detail.data;

return (
<JsonDetail
<ResourceDetailScreen
breadcrumb={["agentcore", "runtime", "endpoint", "get", runtimeId ?? "", qualifier ?? ""]}
isPending={detail.isPending}
error={detail.isError ? (detail.error as Error) : null}
items={{
qualifier: endpoint?.name ?? qualifier ?? "",
status: endpoint?.status ?? "",
...(endpoint?.failureReason ? { failureReason: endpoint.failureReason } : {}),
liveVersion: endpoint?.liveVersion ?? "-",
targetVersion: endpoint?.targetVersion ?? "-",
description: endpoint?.description ?? "-",
updatedAt: endpoint?.lastUpdatedAt?.toISOString() ?? "-",
arn: endpoint?.agentRuntimeEndpointArn ?? "",
}}
actions={
runtimeId && qualifier && endpoint
? [
{
name: "invoke",
description: "invoke this Runtime endpoint",
onSelect: () =>
navigate(
`/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}/${encodeURIComponent(qualifier)}`,
{ state: { returnOnEscape: true } },
),
},
{
name: "detail",
description: "show the full JSON definition",
onSelect: () => navigate(endpointPath(runtimeId, qualifier, "json")),
},
]
: []
}
loadingLabel={`Loading endpoint ${qualifier ?? ""} for Runtime ${runtimeId ?? ""}…`}
onRetry={() => void detail.refetch()}
/>
);
}

export function RuntimeGetEndpointJsonScreen(props: ScreenProps) {
const { runtimeId, qualifier } = useParams();
const detail = useRuntimeEndpointDetail(props, runtimeId, qualifier);

return (
<JsonDetail
breadcrumb={[
"agentcore",
"runtime",
"endpoint",
"get",
runtimeId ?? "",
qualifier ?? "",
"json",
]}
isPending={detail.isPending}
error={detail.isError ? (detail.error as Error) : null}
data={detail.data}
loadingLabel={`Loading endpoint ${qualifier ?? ""} for Runtime ${runtimeId ?? ""}…`}
onRetry={() => void detail.refetch()}
Expand Down
26 changes: 19 additions & 7 deletions src/handlers/runtime/get/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,25 @@ import { coreOptsFromCtx } from "../../utils";

const ACTIONS = [
{
name: "detail",
description: "show the full JSON definition",
to: (id: string) => `/agentcore/runtime/get/${encodeURIComponent(id)}/json`,
name: "invoke",
description: "invoke this Runtime",
to: (id: string) => `/agentcore/runtime/invoke/${encodeURIComponent(id)}`,
returnsToDetails: true,
},
{
name: "endpoints",
description: "list this Runtime's endpoints",
to: (id: string) => `/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`,
},
{
name: "versions",
description: "list immutable Runtime versions",
to: (id: string) => `/agentcore/runtime/version/list/${encodeURIComponent(id)}`,
},
{
name: "endpoints",
description: "list this Runtime's endpoints",
to: (id: string) => `/agentcore/runtime/endpoint/list/${encodeURIComponent(id)}`,
name: "detail",
description: "show the full JSON definition",
to: (id: string) => `/agentcore/runtime/get/${encodeURIComponent(id)}/json`,
},
] as const;

Expand Down Expand Up @@ -56,7 +62,13 @@ export function RuntimeGetScreen(props: ScreenProps) {
? ACTIONS.map((action) => ({
name: action.name,
description: action.description,
onSelect: () => navigate(action.to(runtimeId)),
onSelect: () =>
navigate(action.to(runtimeId), {
state:
"returnsToDetails" in action && action.returnsToDetails
? { returnOnEscape: true }
: undefined,
}),
}))
: []
}
Expand Down
22 changes: 19 additions & 3 deletions src/handlers/runtime/invoke/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ServiceException } from "@smithy/core/client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Text, useInput, useWindowSize } from "ink";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import { useLocation, useNavigate, useParams } from "react-router";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import cliTruncate from "cli-truncate";
import type { ScreenProps } from "../../types";
Expand All @@ -26,6 +26,10 @@ type ExchangeState = "connecting" | "streaming" | "complete" | "interrupted" | "

type TargetPickerState = { stage: "runtime" } | { stage: "endpoint"; runtimeId: string };

type RuntimeInvokeLocationState = {
returnOnEscape?: boolean;
};

type ErrorDetails = {
name: string;
message?: string;
Expand Down Expand Up @@ -90,9 +94,11 @@ function ErrorBlock({ details }: { details: ErrorDetails }) {

export function RuntimeInvokeScreen(props: ScreenProps) {
const { runtimeId, qualifier } = useParams();
const location = useLocation();
const navigate = useNavigate();
const launchContext = props.ctx.value(RuntimeInvokeLaunchContextKey);
const initialContext = launchContext?.runtimeId === runtimeId ? launchContext : undefined;
const returnOnEscape = (location.state as RuntimeInvokeLocationState | null)?.returnOnEscape;

if (!runtimeId) {
return (
Expand All @@ -112,8 +118,13 @@ export function RuntimeInvokeScreen(props: ScreenProps) {
runtimeId={runtimeId}
breadcrumb={["agentcore", "runtime", "invoke", runtimeId]}
description="choose an endpoint to invoke"
onSelect={(selected) => navigate(invokePath(runtimeId, selected))}
onEscape={() => navigate(invokePath())}
onSelect={(selected) =>
navigate(invokePath(runtimeId, selected), {
replace: returnOnEscape === true,
state: returnOnEscape ? { returnOnEscape } : undefined,
})
}
onEscape={() => (returnOnEscape ? navigate(-1) : navigate(invokePath()))}
/>
);
}
Expand All @@ -124,6 +135,7 @@ export function RuntimeInvokeScreen(props: ScreenProps) {
runtimeId={runtimeId}
qualifier={qualifier}
initialContext={initialContext}
returnOnEscape={returnOnEscape}
/>
);
}
Expand All @@ -132,6 +144,7 @@ type RuntimeInvokeConsoleProps = ScreenProps & {
runtimeId: string;
qualifier: string;
initialContext?: RuntimeInvokeLaunchContext;
returnOnEscape?: boolean;
};

function RuntimeInvokeConsole({
Expand All @@ -140,8 +153,10 @@ function RuntimeInvokeConsole({
runtimeId,
qualifier,
initialContext,
returnOnEscape,
}: RuntimeInvokeConsoleProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
const { columns, rows } = useWindowSize();
const [target, setTarget] = useState({ runtimeId, qualifier });
const [targetPicker, setTargetPicker] = useState<TargetPickerState | null>(null);
Expand Down Expand Up @@ -292,6 +307,7 @@ function RuntimeInvokeConsole({
}
if (key.escape) {
if (abortRef.current) abortRef.current.abort();
else if (returnOnEscape) navigate(-1);
else setTargetPicker({ stage: "endpoint", runtimeId: target.runtimeId });
return;
}
Expand Down
Loading
Loading