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
6 changes: 3 additions & 3 deletions src/components/CliOnlyScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,17 @@ describe("menus list command-line-only subcommands below a divider", () => {
const r = renderScreen("/agentcore/eval");

await waitForText(r.lastFrame, "command line only");
expect(menuEntries(r.lastFrame()!).cliOnly).toEqual(["ondemand", "recommendation"]);
expect(menuEntries(r.lastFrame()!).cliOnly).toEqual(["ondemand"]);
r.unmount();
});

test("a menu whose every subcommand is command line only", async () => {
const r = renderScreen("/agentcore/eval/recommendation");
const r = renderScreen("/agentcore/eval/ondemand");

await waitForText(r.lastFrame, "command line only");
expect(menuEntries(r.lastFrame()!)).toEqual({
screens: [],
cliOnly: ["start", "get", "list", "delete"],
cliOnly: ["evaluate", "simulate"],
});
r.unmount();
});
Expand Down
21 changes: 21 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ import {
import { BatchEvaluationScreen } from "../handlers/eval/batch-evaluation/screen.tsx";
import { BatchEvaluationListScreen } from "../handlers/eval/batch-evaluation/list/screen.tsx";
import { BatchEvaluationGetJsonScreen } from "../handlers/eval/batch-evaluation/get/screen.tsx";
import { RecommendationScreen } from "../handlers/eval/recommendation/screen.tsx";
import { RecommendationListScreen } from "../handlers/eval/recommendation/list/screen.tsx";
import { RecommendationGetJsonScreen } from "../handlers/eval/recommendation/get/screen.tsx";
import { BatchInsightsScreen } from "../handlers/eval/batch-insights/screen.tsx";
import { BatchInsightsListScreen } from "../handlers/eval/batch-insights/list/screen.tsx";
import { BatchInsightsGetJsonScreen } from "../handlers/eval/batch-insights/get/screen.tsx";
Expand Down Expand Up @@ -618,6 +621,24 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/eval/batch-evaluation/get/:batchEvaluationId"
element={<BatchEvaluationGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/eval/recommendation"
element={<RecommendationScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/eval/recommendation/list"
element={<RecommendationListScreen ctx={ctx} core={core} />}
/>
{/* Bare `get` (no id) has nothing to show — send the user to the list. */}
<Route
path="agentcore/eval/recommendation/get"
element={<Navigate to="/agentcore/eval/recommendation/list" replace />}
/>
{/* get is raw JSON only — no metadata hub, so :id is the JSON view. */}
<Route
path="agentcore/eval/recommendation/get/:recommendationId"
element={<RecommendationGetJsonScreen ctx={ctx} core={core} />}
/>
<Route path="agentcore/eval/ab-test" element={<AbTestScreen ctx={ctx} core={core} />} />
<Route
path="agentcore/eval/ab-test/list"
Expand Down
3 changes: 2 additions & 1 deletion src/handlers/eval/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { createRecommendationHandler } from "./recommendation";

export function createEvalHandler(core: Core, io: AppIO): Router {
// Only the groups with an interactive screen are marked TUI-supported;
// ondemand and recommendation are listed below the command-line-only divider.
// ondemand is listed below the command-line-only divider.
return new Router("eval", "evaluate and optimize AgentCore agents")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
Expand All @@ -29,6 +29,7 @@ export function createEvalHandler(core: Core, io: AppIO): Router {
"batch-insights",
"config-bundle",
"ab-test",
"recommendation",
)
.handler(createEvaluatorHandler(core, io))
.handler(createOnlineEvalHandler(core, io))
Expand Down
2 changes: 2 additions & 0 deletions src/handlers/eval/recommendation/get/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ export const createGetRecommendationHandler = (core: Core) =>
.renderJson(await core.eval.getRecommendation(flags["id"], coreOptsFromCtx(ctx)));
},
});

export { RecommendationGetJsonScreen } from "./screen.tsx";
32 changes: 32 additions & 0 deletions src/handlers/eval/recommendation/get/screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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";

function useRecommendationDetail({ ctx, core }: ScreenProps, id: string | undefined) {
const opts = coreOptsFromCtx(ctx);
return useQuery({
queryKey: ["recommendation", opts.region, id],
queryFn: () => core.eval.getRecommendation(id!, opts),
enabled: id !== undefined,
});
}

// Recommendation get is raw JSON only — no metadata hub, matching batch-evaluation.
// The full response is the value; a curated field subset would just hide data.
export function RecommendationGetJsonScreen(props: ScreenProps) {
const { recommendationId } = useParams();
const query = useRecommendationDetail(props, recommendationId);

return (
<JsonDetail
breadcrumb={["agentcore", "eval", "recommendation", "get", recommendationId ?? ""]}
isPending={query.isPending}
error={query.isError ? (query.error as Error) : null}
data={query.data}
loadingLabel="loading recommendation…"
onRetry={() => void query.refetch()}
/>
);
}
10 changes: 9 additions & 1 deletion src/handlers/eval/recommendation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import type { AppIO } from "../../../io";
import { Router } from "../../../router";
import { renderTui } from "../../../tui";
import { withTuiOnEmptyFlagsAndArgs } from "../../../middleware";
import type { Core } from "../../types";
import { createDeleteRecommendationHandler } from "./delete";
import { createGetRecommendationHandler } from "./get";
import { createListRecommendationsHandler } from "./list";
import { createStartRecommendationHandler } from "./start";

// A bare invocation opens the interactive TUI (list → get), matching
// batch-evaluation; start/delete stay below the command-line-only divider.
export function createRecommendationHandler(core: Core, io: AppIO): Router {
return new Router("recommendation", "manage AgentCore recommendations")
.supportedTuiCommands()
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list")
.handler(createStartRecommendationHandler(core, io))
.handler(createGetRecommendationHandler(core))
.handler(createListRecommendationsHandler(core))
.handler(createDeleteRecommendationHandler(core));
}

export { RecommendationScreen } from "./screen.tsx";
2 changes: 2 additions & 0 deletions src/handlers/eval/recommendation/list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ export const createListRecommendationsHandler = (core: Core) =>
ctx.require(JsonRendererKey).renderJson(response);
},
});

export { RecommendationListScreen } from "./screen.tsx";
74 changes: 74 additions & 0 deletions src/handlers/eval/recommendation/list/screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { RecommendationSummary } from "@aws-sdk/client-bedrock-agentcore";
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";

// RecommendationRow is the flat, display-ready shape the table renders. It also
// satisfies DataTable's `T extends Record<string, unknown>` constraint, which the
// SDK's RecommendationSummary interface does not.
interface RecommendationRow extends Record<string, unknown> {
recommendationId: string;
name: string;
type: string;
status: string;
updatedAt: string;
}

// Recommendation types are the verbose enum values SYSTEM_PROMPT_RECOMMENDATION /
// TOOL_DESCRIPTION_RECOMMENDATION; the shared `_RECOMMENDATION` suffix is noise
// that would otherwise force truncation, so drop it for display.
const columns = [
{ key: "name", header: "name", flex: true },
{
key: "type",
header: "type",
width: 16,
render: (v) => String(v).replace(/_RECOMMENDATION$/, ""),
},
{ key: "status", header: "status", width: 14 },
{ key: "updatedAt", header: "updated UTC", width: 16, render: formatTimestamp },
] satisfies DataTableColumn<RecommendationRow>[];

function toRow(summary: RecommendationSummary): RecommendationRow {
const id = summary.recommendationId ?? "";
return {
recommendationId: id,
name: summary.name ?? id,
type: summary.type ?? "-",
status: summary.status ?? "-",
updatedAt: summary.updatedAt?.toISOString() ?? "-",
};
}

export function RecommendationListScreen({ ctx, core }: ScreenProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
const breadcrumb = ["agentcore", "eval", "recommendation", "list"];

return (
<PaginatedTablePicker
breadcrumb={breadcrumb}
description="list recommendations"
queryKey={["recommendations", opts.region]}
loadPage={async (token, pageSize) => {
const response = await core.eval.listRecommendations(token, pageSize, undefined, opts);
return {
items: response.recommendationSummaries ?? [],
nextToken: response.nextToken,
};
}}
toRow={toRow}
columns={columns}
getValue={(row) => row.recommendationId}
onSelect={(id) => navigate(`/agentcore/eval/recommendation/get/${encodeURIComponent(id)}`)}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
loadingMessage="loading recommendations…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No recommendations found in this Region."
emptyPageMessage="No recommendations on this page."
/>
);
}
166 changes: 166 additions & 0 deletions src/handlers/eval/recommendation/recommendation.screen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, test } from "bun:test";
import type {
GetRecommendationResponse,
RecommendationSummary,
} from "@aws-sdk/client-bedrock-agentcore";
import {
cleanupScreens,
renderScreen,
TestCoreClient,
waitFor,
waitForText,
} from "../../../testing";

afterEach(cleanupScreens);

const evalEndpointUrl = "https://eval.test";

function summary(overrides: Partial<RecommendationSummary> = {}): RecommendationSummary {
return {
recommendationArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:recommendation/rec-1",
recommendationId: "rec-1",
name: "prompt_tuning",
type: "SYSTEM_PROMPT_RECOMMENDATION",
status: "COMPLETED",
createdAt: new Date("2026-07-19T01:02:03.000Z"),
updatedAt: new Date("2026-07-20T12:34:56.000Z"),
...overrides,
};
}

function coreWith(items: RecommendationSummary[]): TestCoreClient {
const core = new TestCoreClient();
core.eval.setListRecommendationsResponse({ recommendationSummaries: items });
return core;
}

describe("recommendation menu", () => {
test("offers get and list", async () => {
const screen = renderScreen("/agentcore/eval/recommendation");
await waitForText(screen.lastFrame, "list recommendations");
const frame = screen.lastFrame()!;
expect(frame).toContain("list");
expect(frame).toContain("get");
});
});

describe("recommendation picker", () => {
test("renders name, type, status, and updated time", async () => {
const core = coreWith([
summary({
name: "staging_rec",
type: "TOOL_DESCRIPTION_RECOMMENDATION",
status: "FAILED",
updatedAt: new Date("2026-07-21T02:03:04.000Z"),
}),
]);
const screen = renderScreen("/agentcore/eval/recommendation/list", { core });

await waitForText(screen.lastFrame, "staging_rec");
const frame = screen.lastFrame()!;
// The `_RECOMMENDATION` suffix is stripped so the verbose enum fits the column.
expect(frame).toContain("TOOL_DESCRIPTION");
expect(frame).not.toContain("TOOL_DESCRIPTION_RECOMMENDATION");
expect(frame).toContain("FAILED");
expect(frame).toContain("2026-07-21 02:03");
});

test("calls listRecommendations with exact Core options and no status filter", async () => {
const core = coreWith([summary()]);
renderScreen("/agentcore/eval/recommendation/list", { core, endpointUrl: evalEndpointUrl });

await waitFor(() => core.eval.calls.some((c) => c.method === "listRecommendations"));
expect(core.eval.calls.filter((c) => c.method === "listRecommendations")).toEqual([
{
method: "listRecommendations",
args: [
undefined,
expect.any(Number),
undefined,
{ region: "us-east-1", endpointUrl: evalEndpointUrl },
],
},
]);
});

test("falls back to id and dashes when summary fields are missing", async () => {
const core = coreWith([
// Only an id — every other display field absent.
{ recommendationId: "rec-bare" } as RecommendationSummary,
]);
const screen = renderScreen("/agentcore/eval/recommendation/list", { core });

await waitForText(screen.lastFrame, "rec-bare");
expect(screen.lastFrame()).toContain("-");
});

test("bare get redirects to the picker", async () => {
const core = coreWith([summary({ recommendationId: "redirected", name: "redirected_rec" })]);
const screen = renderScreen("/agentcore/eval/recommendation/get", { core });

await waitForText(screen.lastFrame, "redirected_rec");
expect(core.eval.calls[0]?.method).toBe("listRecommendations");
});

test("selection opens the matching recommendation JSON", async () => {
const core = coreWith([summary({ recommendationId: "rec-1" })]);
core.eval.setGetRecommendationResponse({
recommendationId: "rec-1",
name: "prompt_tuning",
} as GetRecommendationResponse);
const screen = renderScreen("/agentcore/eval/recommendation/list", { core });

await waitForText(screen.lastFrame, "prompt_tuning");
await screen.press("return");
await waitForText(screen.lastFrame, "agentcore → eval → recommendation → get → rec-1");
await waitFor(() =>
core.eval.calls.some((c) => c.method === "getRecommendation" && c.args[0] === "rec-1"),
);
});

test("shows the empty state", async () => {
const empty = renderScreen("/agentcore/eval/recommendation/list");
await waitForText(empty.lastFrame, "No recommendations found in this Region.");
});
});

describe("recommendation detail (raw JSON)", () => {
test("renders the full response", async () => {
const core = new TestCoreClient();
core.eval.setGetRecommendationResponse({
recommendationId: "rec-1",
name: "prompt_tuning",
status: "COMPLETED",
} as GetRecommendationResponse);
const screen = renderScreen("/agentcore/eval/recommendation/get/rec-1", {
core,
endpointUrl: evalEndpointUrl,
});

await waitForText(screen.lastFrame, "prompt_tuning");
const frame = screen.lastFrame()!;
expect(frame).toContain('"status"');
expect(frame).toContain("COMPLETED");
expect(core.eval.calls.find((c) => c.method === "getRecommendation")).toEqual({
method: "getRecommendation",
args: ["rec-1", { region: "us-east-1", endpointUrl: evalEndpointUrl }],
});
});

test("retries a failed detail query", async () => {
const core = new TestCoreClient();
core.eval.setError(new Error("recommendation unavailable"));
const screen = renderScreen("/agentcore/eval/recommendation/get/rec-1", { core });

await waitForText(screen.lastFrame, "recommendation unavailable");
expect(screen.lastFrame()).toContain("[r] retry");

core.eval.setError(undefined);
core.eval.setGetRecommendationResponse({
recommendationId: "rec-1",
name: "prompt_tuning",
} as GetRecommendationResponse);
await screen.write("r");
await waitForText(screen.lastFrame, "prompt_tuning");
});
});
6 changes: 6 additions & 0 deletions src/handlers/eval/recommendation/screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { RouterScreen } from "../../../components/RouterScreen";
import type { ScreenProps } from "../../types";

export function RecommendationScreen(props: ScreenProps) {
return <RouterScreen {...props} path={["agentcore", "eval", "recommendation"]} />;
}
Loading