From b8b8c657797d933c8bd2d28eda8d49ba71421b66 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 6 Aug 2026 10:57:35 -0400 Subject: [PATCH 1/2] feat: add read-only datasets TUI --- src/components/DatasetPicker.tsx | 91 ++++++++ src/components/Root.tsx | 20 ++ .../eval/dataset/dataset.screen.test.tsx | 195 ++++++++++++++++++ src/handlers/eval/dataset/dataset.test.tsx | 11 + src/handlers/eval/dataset/get/screen.tsx | 74 +++++++ src/handlers/eval/dataset/index.tsx | 8 +- src/handlers/eval/dataset/list/screen.tsx | 17 ++ src/handlers/eval/dataset/screen.tsx | 8 + 8 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 src/components/DatasetPicker.tsx create mode 100644 src/handlers/eval/dataset/dataset.screen.test.tsx create mode 100644 src/handlers/eval/dataset/get/screen.tsx create mode 100644 src/handlers/eval/dataset/list/screen.tsx create mode 100644 src/handlers/eval/dataset/screen.tsx diff --git a/src/components/DatasetPicker.tsx b/src/components/DatasetPicker.tsx new file mode 100644 index 000000000..9daa0f71a --- /dev/null +++ b/src/components/DatasetPicker.tsx @@ -0,0 +1,91 @@ +import type { DatasetSummary } 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 DatasetRow extends Record { + datasetId: string; + datasetName: string; + status: string; + schemaType: string; + exampleCount: string; + updatedAt: string; +} + +export const datasetColumns = [ + { key: "datasetName", header: "name", flex: true }, + { key: "status", header: "status", width: 14 }, + { key: "schemaType", header: "schema", width: 12 }, + { key: "exampleCount", header: "examples", width: 8 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function displaySchemaType(schemaType: DatasetSummary["schemaType"]): string { + if (schemaType === "AGENTCORE_EVALUATION_PREDEFINED_V1") return "predefined"; + if (schemaType === "AGENTCORE_EVALUATION_SIMULATED_V1") return "simulated"; + return schemaType ?? "-"; +} + +function toRow(dataset: DatasetSummary): DatasetRow { + const id = dataset.datasetId ?? ""; + return { + datasetId: id, + datasetName: dataset.datasetName ?? id, + status: dataset.status ?? "-", + schemaType: displaySchemaType(dataset.schemaType), + exampleCount: dataset.exampleCount?.toString() ?? "-", + updatedAt: dataset.updatedAt?.toISOString() ?? "-", + }; +} + +export interface DatasetPickerProps extends ScreenProps { + breadcrumb: string[]; + description?: string; + onSelect: (datasetId: string) => void; + onEscape?: () => void; +} + +export function DatasetPicker({ + ctx, + core, + breadcrumb, + description, + onSelect, + onEscape, +}: DatasetPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); + + return ( + { + const response = await core.eval.listDatasets(token, pageSize, opts); + return { + items: response.datasets ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={datasetColumns} + getValue={(row) => row.datasetId} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading datasets…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No datasets found in this Region." + emptyPageMessage="No datasets on this page." + /> + ); +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 3e35fe4fd..3047c5adc 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -48,6 +48,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 { DatasetScreen } from "../handlers/eval/dataset/screen.tsx"; +import { DatasetListScreen } from "../handlers/eval/dataset/list/screen.tsx"; +import { DatasetGetScreen, DatasetGetJsonScreen } from "../handlers/eval/dataset/get/screen.tsx"; import { MemoryEventScreen } from "../handlers/memory/event/screen.tsx"; import { MemoryEventGetScreen } from "../handlers/memory/event/get/screen.tsx"; import { MemoryEventListScreen } from "../handlers/memory/event/list/screen.tsx"; @@ -458,6 +461,23 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/eval/online-eval/get/:configId/json" element={} /> + } /> + } + /> + } + /> + } + /> + } + /> } diff --git a/src/handlers/eval/dataset/dataset.screen.test.tsx b/src/handlers/eval/dataset/dataset.screen.test.tsx new file mode 100644 index 000000000..1dc6e7862 --- /dev/null +++ b/src/handlers/eval/dataset/dataset.screen.test.tsx @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { DatasetSummary, GetDatasetResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +const evalEndpointUrl = "https://eval.test"; + +function datasetSummary(overrides: Partial = {}): DatasetSummary { + return { + datasetArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:dataset/dataset-1", + datasetId: "dataset-1", + datasetName: "orders-regression", + status: "ACTIVE", + draftStatus: "MODIFIED", + schemaType: "AGENTCORE_EVALUATION_PREDEFINED_V1", + exampleCount: 2, + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T12:34:56.000Z"), + ...overrides, + }; +} + +function getDatasetResponse(overrides: Partial = {}): GetDatasetResponse { + return { + datasetArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:dataset/dataset-1", + datasetId: "dataset-1", + datasetVersion: "DRAFT", + datasetName: "orders-regression", + description: "Regression tests for the order-support agent", + status: "ACTIVE", + draftStatus: "MODIFIED", + schemaType: "AGENTCORE_EVALUATION_PREDEFINED_V1", + exampleCount: 2, + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T12:34:56.000Z"), + tags: { team: "agentcore" }, + ...overrides, + }; +} + +function coreWithDatasets(datasets: DatasetSummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.eval.setListDatasetsResponse({ datasets }); + return core; +} + +describe("dataset menu", () => { + test("offers only the read-only commands", async () => { + const screen = renderScreen("/agentcore/eval/dataset"); + + await waitForText(screen.lastFrame, "get a dataset's metadata"); + const frame = screen.lastFrame()!; + expect(frame).toContain("list"); + expect(frame).not.toContain("create"); + expect(frame).not.toContain("update"); + expect(frame).not.toContain("publish"); + expect(frame).not.toContain("delete"); + }); +}); + +describe("dataset picker", () => { + test("renders name, status, schema, example count, and update time", async () => { + const core = coreWithDatasets([ + datasetSummary({ + datasetName: "staging-regression", + status: "UPDATE_FAILED", + schemaType: "AGENTCORE_EVALUATION_SIMULATED_V1", + exampleCount: 17, + updatedAt: new Date("2026-08-03T02:03:04.000Z"), + }), + ]); + const screen = renderScreen("/agentcore/eval/dataset/list", { core }); + + await waitForText(screen.lastFrame, "staging-regression"); + const frame = screen.lastFrame()!; + expect(frame).toContain("UPDATE_FAILED"); + expect(frame).toContain("simulated"); + expect(frame).toMatch(/17\s+2026-08-03 02:03/); + }); + + test("calls listDatasets with exact Core options", async () => { + const core = coreWithDatasets([datasetSummary()]); + renderScreen("/agentcore/eval/dataset/list", { core, endpointUrl: evalEndpointUrl }); + + await waitFor(() => core.eval.calls.some((call) => call.method === "listDatasets")); + expect(core.eval.calls.filter((call) => call.method === "listDatasets")).toEqual([ + { + method: "listDatasets", + args: [ + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: evalEndpointUrl }, + ], + }, + ]); + }); + + test("bare dataset get redirects to the picker", async () => { + const core = coreWithDatasets([ + datasetSummary({ datasetId: "redirected-dataset", datasetName: "redirected-dataset" }), + ]); + const screen = renderScreen("/agentcore/eval/dataset/get", { core }); + + await waitForText(screen.lastFrame, "redirected-dataset"); + expect(core.eval.calls[0]?.method).toBe("listDatasets"); + }); + + test("selection opens the matching dataset detail", async () => { + const core = coreWithDatasets([datasetSummary({ datasetId: "dataset-1" })]); + core.eval.setGetDatasetResponse(getDatasetResponse({ datasetId: "dataset-1" })); + const screen = renderScreen("/agentcore/eval/dataset/list", { core }); + + await waitForText(screen.lastFrame, "orders-regression"); + await screen.press("return"); + await waitForText(screen.lastFrame, "agentcore → eval → dataset → get → dataset-1"); + await waitFor(() => + core.eval.calls.some((call) => call.method === "getDataset" && call.args[0] === "dataset-1"), + ); + }); + + test("shows the empty state", async () => { + const screen = renderScreen("/agentcore/eval/dataset/list"); + await waitForText(screen.lastFrame, "No datasets found in this Region."); + }); +}); + +describe("dataset detail", () => { + test("renders DRAFT metadata without downloading examples", async () => { + const core = new TestCoreClient(); + core.eval.setGetDatasetResponse(getDatasetResponse()); + const screen = renderScreen("/agentcore/eval/dataset/get/dataset-1", { + core, + endpointUrl: evalEndpointUrl, + }); + + await waitForText(screen.lastFrame, "show the full JSON metadata"); + const frame = screen.lastFrame()!; + expect(frame).toContain("orders-regression"); + expect(frame).toMatch(/version\s+DRAFT/); + expect(frame).toMatch(/draftStatus\s+MODIFIED/); + expect(frame).toMatch(/examples\s+2/); + expect(core.eval.calls).toEqual([ + { + method: "getDataset", + args: ["dataset-1", undefined, { region: "us-east-1", endpointUrl: evalEndpointUrl }], + }, + ]); + }); + + test("shows failure details when present", async () => { + const core = new TestCoreClient(); + core.eval.setGetDatasetResponse( + getDatasetResponse({ + status: "UPDATE_FAILED", + failureReason: "The source could not be read", + }), + ); + const screen = renderScreen("/agentcore/eval/dataset/get/dataset-1", { core }); + + await waitForText(screen.lastFrame, "The source could not be read"); + expect(screen.lastFrame()).toContain("UPDATE_FAILED"); + }); + + test("opens the complete dataset JSON", async () => { + const core = new TestCoreClient(); + core.eval.setGetDatasetResponse(getDatasetResponse()); + const screen = renderScreen("/agentcore/eval/dataset/get/dataset-1", { core }); + + await waitForText(screen.lastFrame, "show the full JSON metadata"); + await screen.press("return"); + await waitForText(screen.lastFrame, "agentcore → eval → dataset → get → dataset-1 → json"); + expect(screen.lastFrame()).toContain('"team"'); + }); + + test("retries a failed detail query", async () => { + const core = new TestCoreClient(); + core.eval.setError(new Error("dataset unavailable")); + const screen = renderScreen("/agentcore/eval/dataset/get/dataset-1", { core }); + + await waitForText(screen.lastFrame, "dataset unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + + core.eval.setError(undefined); + core.eval.setGetDatasetResponse(getDatasetResponse()); + await screen.write("r"); + await waitForText(screen.lastFrame, "show the full JSON metadata"); + }); +}); diff --git a/src/handlers/eval/dataset/dataset.test.tsx b/src/handlers/eval/dataset/dataset.test.tsx index 2ae2afd2f..d89d3893a 100644 --- a/src/handlers/eval/dataset/dataset.test.tsx +++ b/src/handlers/eval/dataset/dataset.test.tsx @@ -93,6 +93,17 @@ describe("eval dataset command hierarchy", () => { expect(stdout()).toContain("Usage: agentcore eval dataset"); expect(core.eval.calls).toHaveLength(0); }); + + test.each([["get"], ["list"]] as const)( + "opens the TUI for a bare `eval dataset %s` leaf", + async (command) => { + const { route } = testDatasetCommand(); + + await expect(route(["eval", "dataset", command])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }, + ); }); describe("dataset create", () => { diff --git a/src/handlers/eval/dataset/get/screen.tsx b/src/handlers/eval/dataset/get/screen.tsx new file mode 100644 index 000000000..31ded97bd --- /dev/null +++ b/src/handlers/eval/dataset/get/screen.tsx @@ -0,0 +1,74 @@ +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 useDatasetDetail({ ctx, core }: ScreenProps, datasetId: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["dataset", opts.region, datasetId], + queryFn: () => core.eval.getDataset(datasetId!, undefined, opts), + enabled: datasetId !== undefined, + }); +} + +export function DatasetGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { datasetId } = useParams(); + const detail = useDatasetDetail(props, datasetId); + const dataset = detail.data; + + return ( + + navigate(`/agentcore/eval/dataset/get/${encodeURIComponent(datasetId)}/json`), + }, + ] + : [] + } + loadingLabel="Loading dataset…" + onRetry={() => void detail.refetch()} + selectLabel="open detail" + /> + ); +} + +export function DatasetGetJsonScreen(props: ScreenProps) { + const { datasetId } = useParams(); + const detail = useDatasetDetail(props, datasetId); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/eval/dataset/index.tsx b/src/handlers/eval/dataset/index.tsx index d9b9e84f3..327e88710 100644 --- a/src/handlers/eval/dataset/index.tsx +++ b/src/handlers/eval/dataset/index.tsx @@ -1,7 +1,8 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import { withTuiOnEmptyFlagsAndArgs } from "../../../middleware"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; -import { createHelpDefault } from "../../help"; import { createCreateDatasetHandler } from "./create"; import { createGetDatasetHandler } from "./get"; import { createListDatasetsHandler } from "./list"; @@ -11,7 +12,8 @@ import { createUpdateDatasetHandler } from "./update"; export function createDatasetHandler(core: Core, io: AppIO): Router { return new Router("dataset", "manage AgentCore evaluation datasets") - .default(createHelpDefault(io)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) .handler(createCreateDatasetHandler(core, io)) .handler(createGetDatasetHandler(core)) .handler(createListDatasetsHandler(core)) @@ -19,3 +21,5 @@ export function createDatasetHandler(core: Core, io: AppIO): Router { .handler(createUpdateDatasetHandler(core, io)) .handler(createPublishDatasetHandler(core)); } + +export { DatasetScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/dataset/list/screen.tsx b/src/handlers/eval/dataset/list/screen.tsx new file mode 100644 index 000000000..64c159972 --- /dev/null +++ b/src/handlers/eval/dataset/list/screen.tsx @@ -0,0 +1,17 @@ +import { useNavigate } from "react-router"; +import { DatasetPicker } from "../../../../components/DatasetPicker"; +import type { ScreenProps } from "../../../types"; + +export function DatasetListScreen(props: ScreenProps) { + const navigate = useNavigate(); + + return ( + + navigate(`/agentcore/eval/dataset/get/${encodeURIComponent(datasetId)}`) + } + /> + ); +} diff --git a/src/handlers/eval/dataset/screen.tsx b/src/handlers/eval/dataset/screen.tsx new file mode 100644 index 000000000..882322580 --- /dev/null +++ b/src/handlers/eval/dataset/screen.tsx @@ -0,0 +1,8 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +const OMIT = ["create", "update", "publish", "delete"]; + +export function DatasetScreen(props: ScreenProps) { + return ; +} From 84034b5ca4b91df527a2d6722426554ab9ff21ff Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 13 Aug 2026 13:23:19 -0400 Subject: [PATCH 2/2] chore: replace OMIT parameter with supportedTUICommands handler --- src/handlers/eval/dataset/dataset.test.tsx | 8 ++++++++ src/handlers/eval/dataset/index.tsx | 1 + src/handlers/eval/dataset/screen.tsx | 4 +--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/handlers/eval/dataset/dataset.test.tsx b/src/handlers/eval/dataset/dataset.test.tsx index d89d3893a..bfb3b5242 100644 --- a/src/handlers/eval/dataset/dataset.test.tsx +++ b/src/handlers/eval/dataset/dataset.test.tsx @@ -104,6 +104,14 @@ describe("eval dataset command hierarchy", () => { ); }, ); + + test("runs normal validation for a bare CLI-only dataset command", async () => { + const { route } = testDatasetCommand(); + + await expect(route(["eval", "dataset", "update"])).rejects.toThrow( + "required option '--id ' not specified", + ); + }); }); describe("dataset create", () => { diff --git a/src/handlers/eval/dataset/index.tsx b/src/handlers/eval/dataset/index.tsx index 327e88710..033ecb40e 100644 --- a/src/handlers/eval/dataset/index.tsx +++ b/src/handlers/eval/dataset/index.tsx @@ -14,6 +14,7 @@ export function createDatasetHandler(core: Core, io: AppIO): Router { return new Router("dataset", "manage AgentCore evaluation datasets") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) + .supportedTuiCommands("get", "list") .handler(createCreateDatasetHandler(core, io)) .handler(createGetDatasetHandler(core)) .handler(createListDatasetsHandler(core)) diff --git a/src/handlers/eval/dataset/screen.tsx b/src/handlers/eval/dataset/screen.tsx index 882322580..b4cff525b 100644 --- a/src/handlers/eval/dataset/screen.tsx +++ b/src/handlers/eval/dataset/screen.tsx @@ -1,8 +1,6 @@ import { RouterScreen } from "../../../components/RouterScreen"; import type { ScreenProps } from "../../types"; -const OMIT = ["create", "update", "publish", "delete"]; - export function DatasetScreen(props: ScreenProps) { - return ; + return ; }