diff --git a/src/components/ConfigBundleBranchPicker.tsx b/src/components/ConfigBundleBranchPicker.tsx new file mode 100644 index 0000000000..83ba8b9147 --- /dev/null +++ b/src/components/ConfigBundleBranchPicker.tsx @@ -0,0 +1,85 @@ +import type { ConfigurationBundleVersionSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +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 ConfigBundleBranchRow extends Record { + branchName: string; + versionId: string; + commitMessage: string; + versionCreatedAt: string; +} + +const columns = [ + { key: "branchName", header: "branch", flex: true }, + { key: "versionId", header: "latest version", width: 20, minWidth: 10 }, + { key: "commitMessage", header: "message", width: 28, minWidth: 12 }, + { + key: "versionCreatedAt", + header: "updated UTC", + width: 16, + minWidth: 11, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(version: ConfigurationBundleVersionSummary): ConfigBundleBranchRow { + return { + branchName: version.lineageMetadata?.branchName ?? "", + versionId: version.versionId ?? "", + commitMessage: version.lineageMetadata?.commitMessage ?? "-", + versionCreatedAt: version.versionCreatedAt?.toISOString() ?? "-", + }; +} + +export interface ConfigBundleBranchPickerProps extends ScreenProps { + bundleId: string; + breadcrumb: string[]; + onSelect: (branchName: string) => void; + onBack: () => void; +} + +export function ConfigBundleBranchPicker({ + ctx, + core, + bundleId, + breadcrumb, + onSelect, + onBack, +}: ConfigBundleBranchPickerProps) { + const opts = coreOptsFromCtx(ctx); + + return ( + { + const response = await core.eval.listConfigurationBundleVersions( + bundleId, + token, + pageSize, + opts, + { latestPerBranch: true }, + ); + return { + items: response.versions ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={columns} + getValue={(row) => row.branchName} + onSelect={onSelect} + onBack={onBack} + loadingMessage={`loading branches for configuration bundle ${bundleId}…`} + errorMessage={(error) => + `Error loading branches for configuration bundle ${bundleId}: ${error.message}` + } + emptyMessage={`No branches found for configuration bundle ${bundleId}.`} + emptyPageMessage={`No branches on this page for configuration bundle ${bundleId}.`} + /> + ); +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index a8d55c98e4..eed954010f 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -71,6 +71,7 @@ import { DatasetGetScreen, DatasetGetJsonScreen } from "../handlers/eval/dataset import { ConfigBundleScreen } from "../handlers/eval/config-bundle/screen.tsx"; import { ConfigBundleListScreen } from "../handlers/eval/config-bundle/list/screen.tsx"; import { ConfigBundleGetScreen } from "../handlers/eval/config-bundle/get/screen.tsx"; +import { ConfigBundleBranchScreen } from "../handlers/eval/config-bundle/get/branch-screen.tsx"; import { ConfigBundleVersionScreen } from "../handlers/eval/config-bundle/version/screen.tsx"; import { ConfigBundleVersionListScreen } from "../handlers/eval/config-bundle/version/list/screen.tsx"; import { AbTestScreen } from "../handlers/eval/ab-test/screen.tsx"; @@ -686,6 +687,10 @@ function RouteTable({ ctx, core }: ScreenProps) { /> } + /> + } /> Promise): { } describe("EvalClient configuration bundles", () => { + test("lists only the latest version per branch when requested", async () => { + const sent: unknown[] = []; + const { client } = subject(async (command) => { + sent.push(command); + return { versions: [] }; + }); + + await client.listConfigurationBundleVersions("b-1", undefined, 10, OPTIONS, { + latestPerBranch: true, + }); + + expect(sent[0]).toBeInstanceOf(ListConfigurationBundleVersionsCommand); + expect((sent[0] as ListConfigurationBundleVersionsCommand).input).toEqual({ + bundleId: "b-1", + nextToken: undefined, + maxResults: 10, + filter: { latestPerBranch: true }, + }); + }); + test("get passes an explicit branch or selects the immutable-version operation", async () => { const sent: unknown[] = []; const { client } = subject(async (command) => { diff --git a/src/core/eval.tsx b/src/core/eval.tsx index 49304b0740..a82229096b 100644 --- a/src/core/eval.tsx +++ b/src/core/eval.tsx @@ -49,6 +49,7 @@ import { type GetOnlineEvaluationConfigResponse, type ListConfigurationBundlesResponse, type ListConfigurationBundleVersionsResponse, + type VersionFilter, type ListDatasetsResponse, type ListEvaluatorsResponse, type DataSourceConfig, @@ -1245,10 +1246,13 @@ export class EvalClient implements CoreEvalClient { nextToken: string | undefined, maxResults: number | undefined, options: CoreOptions, + filter?: VersionFilter, ): Promise { return this.clients .control(toClientConfig(options)) - .send(new ListConfigurationBundleVersionsCommand({ bundleId: id, nextToken, maxResults })); + .send( + new ListConfigurationBundleVersionsCommand({ bundleId: id, nextToken, maxResults, filter }), + ); } async createDataset( diff --git a/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx b/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx index cb253ab860..bd510fa2e4 100644 --- a/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx +++ b/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx @@ -175,8 +175,11 @@ describe("configuration bundle picker", () => { expect(core.eval.calls[0]?.method).toBe("listConfigurationBundles"); }); - test("selection opens the latest mainline bundle JSON", async () => { + test("mainline-only bundle requires selecting a branch", async () => { const core = coreWithBundles([bundleSummary()]); + core.eval.setListConfigurationBundleVersionsResponse({ + versions: [bundleVersionSummary()], + }); core.eval.setGetConfigurationBundleResponse(latestBundleResponse()); const screen = renderScreen("/agentcore/eval/config-bundle/list", { core, @@ -185,8 +188,27 @@ describe("configuration bundle picker", () => { await waitForText(screen.lastFrame, "orders-prompt"); await screen.press("return"); - await waitForText(screen.lastFrame, "agentcore → eval → config-bundle → get → bundle-1"); + await waitForText(screen.lastFrame, "latest version"); + expect(screen.lastFrame()).toContain("mainline"); + expect(core.eval.calls.some((call) => call.method === "getConfigurationBundle")).toBe(false); + await screen.press("return"); + await waitForText( + screen.lastFrame, + "agentcore → eval → config-bundle → get → bundle-1 → mainline", + ); await waitForText(screen.lastFrame, '"system_prompt"'); + expect( + core.eval.calls.find((call) => call.method === "listConfigurationBundleVersions"), + ).toEqual({ + method: "listConfigurationBundleVersions", + args: [ + "bundle-1", + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: evalEndpointUrl }, + { latestPerBranch: true }, + ], + }); expect(core.eval.calls.find((call) => call.method === "getConfigurationBundle")).toEqual({ method: "getConfigurationBundle", args: [ @@ -196,6 +218,76 @@ describe("configuration bundle picker", () => { { region: "us-east-1", endpointUrl: evalEndpointUrl }, ], }); + await screen.press("escape"); + await waitForText(screen.lastFrame, "latest version"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "orders-prompt"); + expect(screen.lastFrame()).toContain("list configuration bundles"); + }); + + test("selection opens a branch picker when the bundle has multiple branches", async () => { + const core = coreWithBundles([bundleSummary()]); + core.eval.setListConfigurationBundleVersionsResponse({ + versions: [ + bundleVersionSummary(), + bundleVersionSummary({ + versionId: "foo-version", + lineageMetadata: { branchName: "foo/blue", commitMessage: "Experiment" }, + }), + ], + }); + core.eval.setGetConfigurationBundleResponse( + latestBundleResponse({ lineageMetadata: { branchName: "foo/blue" } }), + ); + const screen = renderScreen("/agentcore/eval/config-bundle/list", { core }); + + await waitForText(screen.lastFrame, "orders-prompt"); + await screen.press("return"); + await waitForText(screen.lastFrame, "foo/blue"); + expect(core.eval.calls.some((call) => call.method === "getConfigurationBundle")).toBe(false); + await screen.press("down"); + await screen.press("return"); + await waitForText( + screen.lastFrame, + "agentcore → eval → config-bundle → get → bundle-1 → foo/blue", + ); + await waitFor(() => core.eval.calls.some((call) => call.method === "getConfigurationBundle")); + expect(core.eval.calls.find((call) => call.method === "getConfigurationBundle")).toEqual({ + method: "getConfigurationBundle", + args: ["bundle-1", undefined, "foo/blue", { region: "us-east-1" }], + }); + await screen.press("escape"); + await waitForText(screen.lastFrame, "latest version"); + expect(screen.lastFrame()).toContain("foo/blue"); + }); + + test("selection opens a branch picker when the bundle has a single non-mainline branch", async () => { + const core = coreWithBundles([bundleSummary()]); + core.eval.setListConfigurationBundleVersionsResponse({ + versions: [ + bundleVersionSummary({ + lineageMetadata: { branchName: "foo", commitMessage: "Only branch" }, + }), + ], + }); + core.eval.setGetConfigurationBundleResponse( + latestBundleResponse({ lineageMetadata: { branchName: "foo" } }), + ); + const screen = renderScreen("/agentcore/eval/config-bundle/get/bundle-1", { core }); + + await waitForText(screen.lastFrame, "foo"); + expect(core.eval.calls.some((call) => call.method === "getConfigurationBundle")).toBe(false); + expect(screen.lastFrame()).toContain("latest version"); + await screen.press("return"); + await waitFor(() => core.eval.calls.some((call) => call.method === "getConfigurationBundle")); + expect(core.eval.calls.find((call) => call.method === "getConfigurationBundle")?.args[2]).toBe( + "foo", + ); + }); + + test("shows an empty state when a bundle has no branches", async () => { + const screen = renderScreen("/agentcore/eval/config-bundle/get/bundle-1"); + await waitForText(screen.lastFrame, "No branches found for configuration bundle bundle-1."); }); test("shows the empty state", async () => { @@ -351,7 +443,9 @@ describe("configuration bundle detail", () => { test("retries a failed query", async () => { const core = new TestCoreClient(); core.eval.setError(new Error("configuration bundle unavailable")); - const screen = renderScreen("/agentcore/eval/config-bundle/get/bundle-1", { core }); + const screen = renderScreen("/agentcore/eval/config-bundle/get/bundle-1/branch/mainline", { + core, + }); await waitForText(screen.lastFrame, "configuration bundle unavailable"); expect(screen.lastFrame()).toContain("[r] retry"); diff --git a/src/handlers/eval/config-bundle/get/branch-screen.tsx b/src/handlers/eval/config-bundle/get/branch-screen.tsx new file mode 100644 index 0000000000..31b8939143 --- /dev/null +++ b/src/handlers/eval/config-bundle/get/branch-screen.tsx @@ -0,0 +1,23 @@ +import { useNavigate, useParams } from "react-router"; +import { ConfigBundleBranchPicker } from "../../../../components/ConfigBundleBranchPicker"; +import type { ScreenProps } from "../../../types"; + +function branchDetailPath(bundleId: string, branchName: string): string { + return `/agentcore/eval/config-bundle/get/${encodeURIComponent(bundleId)}/branch/${encodeURIComponent(branchName)}`; +} + +export function ConfigBundleBranchScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { bundleId } = useParams(); + if (!bundleId) return null; + + return ( + navigate(branchDetailPath(bundleId, branchName))} + onBack={() => navigate("/agentcore/eval/config-bundle/list")} + /> + ); +} diff --git a/src/handlers/eval/config-bundle/get/screen.tsx b/src/handlers/eval/config-bundle/get/screen.tsx index 007ebe09fa..c32b8697cf 100644 --- a/src/handlers/eval/config-bundle/get/screen.tsx +++ b/src/handlers/eval/config-bundle/get/screen.tsx @@ -4,15 +4,20 @@ import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; -const DEFAULT_BRANCH = "mainline"; - export function ConfigBundleGetScreen({ ctx, core }: ScreenProps) { const opts = coreOptsFromCtx(ctx); - const { bundleId, versionId } = useParams(); + const { bundleId, versionId, branchName } = useParams(); const detail = useQuery({ - queryKey: ["configuration-bundle", opts.region, bundleId, versionId ?? DEFAULT_BRANCH], - queryFn: () => core.eval.getConfigurationBundle(bundleId!, versionId, DEFAULT_BRANCH, opts), - enabled: bundleId !== undefined, + queryKey: [ + "configuration-bundle", + opts.region, + bundleId, + versionId === undefined ? "branch" : "version", + versionId ?? branchName, + ], + queryFn: () => + core.eval.getConfigurationBundle(bundleId!, versionId, branchName ?? "mainline", opts), + enabled: bundleId !== undefined && (versionId !== undefined || branchName !== undefined), }); return ( @@ -24,6 +29,7 @@ export function ConfigBundleGetScreen({ ctx, core }: ScreenProps) { "get", bundleId ?? "", ...(versionId ? [versionId] : []), + ...(branchName ? [branchName] : []), ]} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} @@ -31,7 +37,7 @@ export function ConfigBundleGetScreen({ ctx, core }: ScreenProps) { loadingLabel={ versionId ? `Loading version ${versionId} for configuration bundle ${bundleId ?? ""}…` - : `Loading configuration bundle ${bundleId ?? ""}…` + : `Loading branch ${branchName ?? ""} for configuration bundle ${bundleId ?? ""}…` } onRetry={() => void detail.refetch()} /> diff --git a/src/handlers/eval/types.tsx b/src/handlers/eval/types.tsx index 92470edaad..2d136f3680 100644 --- a/src/handlers/eval/types.tsx +++ b/src/handlers/eval/types.tsx @@ -18,6 +18,7 @@ import type { GetOnlineEvaluationConfigResponse, ListConfigurationBundlesResponse, ListConfigurationBundleVersionsResponse, + VersionFilter, ListDatasetsResponse, ListEvaluatorsResponse, ListOnlineEvaluationConfigsResponse, @@ -504,6 +505,7 @@ export interface CoreEvalClient { nextToken: string | undefined, maxResults: number | undefined, options: CoreOptions, + filter?: VersionFilter, ): Promise; // createDataset seeds a new dataset's DRAFT from `source`, which is required. diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 0ee7409f4a..3000b8483d 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -59,6 +59,7 @@ import type { GetDatasetResponse, ListConfigurationBundlesResponse, ListConfigurationBundleVersionsResponse, + VersionFilter, ListDatasetsResponse, GetEvaluatorResponse, GetOnlineEvaluationConfigResponse, @@ -2333,10 +2334,13 @@ export class TestEvalClient implements CoreEvalClient { nextToken: string | undefined, maxResults: number | undefined, options: CoreOptions, + filter?: VersionFilter, ): Promise { this.calls.push({ method: "listConfigurationBundleVersions", - args: [id, nextToken, maxResults, options], + args: filter + ? [id, nextToken, maxResults, options, filter] + : [id, nextToken, maxResults, options], }); if (this.error) throw this.error; return (