From 8e1aecbab6f00e890458722001033fe8cc1a5dc3 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Thu, 10 Sep 2026 23:52:06 +0000 Subject: [PATCH 1/2] feat(recommendation): add interactive TUI for get and list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror batch-evaluation: list renders a paginated table picker that navigates into the raw-JSON get view, and a bare `recommendation` invocation opens the list→get TUI. start/delete stay command-line-only. --- src/components/CliOnlyScreen.test.tsx | 6 +- src/components/Root.tsx | 21 ++++++ src/handlers/eval/index.tsx | 3 +- .../eval/recommendation/get/index.tsx | 2 + .../eval/recommendation/get/screen.tsx | 32 +++++++++ src/handlers/eval/recommendation/index.tsx | 10 ++- .../eval/recommendation/list/index.tsx | 2 + .../eval/recommendation/list/screen.tsx | 66 +++++++++++++++++++ src/handlers/eval/recommendation/screen.tsx | 6 ++ 9 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/handlers/eval/recommendation/get/screen.tsx create mode 100644 src/handlers/eval/recommendation/list/screen.tsx create mode 100644 src/handlers/eval/recommendation/screen.tsx diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index b6b7c91eb..9e825ea1e 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -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(); }); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index f4cdefa0a..439056ee7 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -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"; @@ -618,6 +621,24 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/eval/batch-evaluation/get/:batchEvaluationId" element={} /> + } + /> + } + /> + {/* Bare `get` (no id) has nothing to show — send the user to the list. */} + } + /> + {/* get is raw JSON only — no metadata hub, so :id is the JSON view. */} + } + /> } /> .renderJson(await core.eval.getRecommendation(flags["id"], coreOptsFromCtx(ctx))); }, }); + +export { RecommendationGetJsonScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/recommendation/get/screen.tsx b/src/handlers/eval/recommendation/get/screen.tsx new file mode 100644 index 000000000..70e39941a --- /dev/null +++ b/src/handlers/eval/recommendation/get/screen.tsx @@ -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 ( + void query.refetch()} + /> + ); +} diff --git a/src/handlers/eval/recommendation/index.tsx b/src/handlers/eval/recommendation/index.tsx index 917664075..5e129242c 100644 --- a/src/handlers/eval/recommendation/index.tsx +++ b/src/handlers/eval/recommendation/index.tsx @@ -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"; diff --git a/src/handlers/eval/recommendation/list/index.tsx b/src/handlers/eval/recommendation/list/index.tsx index 3f2a86386..1fdc80cd6 100644 --- a/src/handlers/eval/recommendation/list/index.tsx +++ b/src/handlers/eval/recommendation/list/index.tsx @@ -35,3 +35,5 @@ export const createListRecommendationsHandler = (core: Core) => ctx.require(JsonRendererKey).renderJson(response); }, }); + +export { RecommendationListScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/recommendation/list/screen.tsx b/src/handlers/eval/recommendation/list/screen.tsx new file mode 100644 index 000000000..cefc8e16a --- /dev/null +++ b/src/handlers/eval/recommendation/list/screen.tsx @@ -0,0 +1,66 @@ +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` constraint, which the +// SDK's RecommendationSummary interface does not. +interface RecommendationRow extends Record { + recommendationId: string; + name: string; + type: string; + status: string; + updatedAt: string; +} + +const columns = [ + { key: "name", header: "name", flex: true }, + { key: "type", header: "type", width: 22 }, + { key: "status", header: "status", width: 14 }, + { key: "updatedAt", header: "updated UTC", width: 16, render: formatTimestamp }, +] satisfies DataTableColumn[]; + +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 ( + { + 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." + /> + ); +} diff --git a/src/handlers/eval/recommendation/screen.tsx b/src/handlers/eval/recommendation/screen.tsx new file mode 100644 index 000000000..2db19b4b4 --- /dev/null +++ b/src/handlers/eval/recommendation/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function RecommendationScreen(props: ScreenProps) { + return ; +} From f213da3d2846e767c35b3717eef56d9dd3d2f72a Mon Sep 17 00:00:00 2001 From: jariy17 Date: Thu, 10 Sep 2026 23:59:35 +0000 Subject: [PATCH 2/2] test(recommendation): bug-bash the TUI screens; strip verbose type suffix Add screen tests covering the picker, JSON detail, empty and error/retry states. Bug bash surfaced that the type column (width 22) truncated the verbose enum values (SYSTEM_PROMPT_RECOMMENDATION / TOOL_DESCRIPTION_RECOMMENDATION); strip the shared `_RECOMMENDATION` suffix so the value fits and reads cleanly. --- .../eval/recommendation/list/screen.tsx | 10 +- .../recommendation.screen.test.tsx | 166 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/handlers/eval/recommendation/recommendation.screen.test.tsx diff --git a/src/handlers/eval/recommendation/list/screen.tsx b/src/handlers/eval/recommendation/list/screen.tsx index cefc8e16a..ac2b2b736 100644 --- a/src/handlers/eval/recommendation/list/screen.tsx +++ b/src/handlers/eval/recommendation/list/screen.tsx @@ -17,9 +17,17 @@ interface RecommendationRow extends Record { 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: 22 }, + { + 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[]; diff --git a/src/handlers/eval/recommendation/recommendation.screen.test.tsx b/src/handlers/eval/recommendation/recommendation.screen.test.tsx new file mode 100644 index 000000000..c427e57c5 --- /dev/null +++ b/src/handlers/eval/recommendation/recommendation.screen.test.tsx @@ -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 { + 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"); + }); +});