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
85 changes: 85 additions & 0 deletions src/components/ConfigBundleBranchPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<ConfigBundleBranchRow>[];

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 (
<PaginatedTablePicker
breadcrumb={breadcrumb}
description={`choose a branch for configuration bundle ${bundleId}`}
queryKey={["configuration-bundle-branches", opts.region, bundleId]}
loadPage={async (token, pageSize) => {
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}.`}
/>
);
}
5 changes: 5 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -686,6 +687,10 @@ function RouteTable({ ctx, core }: ScreenProps) {
/>
<Route
path="agentcore/eval/config-bundle/get/:bundleId"
element={<ConfigBundleBranchScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/eval/config-bundle/get/:bundleId/branch/:branchName"
element={<ConfigBundleGetScreen ctx={ctx} core={core} />}
/>
<Route
Expand Down
21 changes: 21 additions & 0 deletions src/core/configBundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
GetConfigurationBundleCommand,
GetConfigurationBundleVersionCommand,
ListConfigurationBundleVersionsCommand,
UpdateConfigurationBundleCommand,
type BedrockAgentCoreControlClient,
} from "@aws-sdk/client-bedrock-agentcore-control";
Expand Down Expand Up @@ -32,6 +33,26 @@ function subject(respond: (command: unknown) => Promise<unknown>): {
}

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) => {
Expand Down
6 changes: 5 additions & 1 deletion src/core/eval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
type GetOnlineEvaluationConfigResponse,
type ListConfigurationBundlesResponse,
type ListConfigurationBundleVersionsResponse,
type VersionFilter,
type ListDatasetsResponse,
type ListEvaluatorsResponse,
type DataSourceConfig,
Expand Down Expand Up @@ -1245,10 +1246,13 @@ export class EvalClient implements CoreEvalClient {
nextToken: string | undefined,
maxResults: number | undefined,
options: CoreOptions,
filter?: VersionFilter,
): Promise<ListConfigurationBundleVersionsResponse> {
return this.clients
.control(toClientConfig(options))
.send(new ListConfigurationBundleVersionsCommand({ bundleId: id, nextToken, maxResults }));
.send(
new ListConfigurationBundleVersionsCommand({ bundleId: id, nextToken, maxResults, filter }),
);
}

async createDataset(
Expand Down
100 changes: 97 additions & 3 deletions src/handlers/eval/config-bundle/config-bundle.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: [
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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");
Expand Down
23 changes: 23 additions & 0 deletions src/handlers/eval/config-bundle/get/branch-screen.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ConfigBundleBranchPicker
{...props}
bundleId={bundleId}
breadcrumb={["agentcore", "eval", "config-bundle", "get", bundleId]}
onSelect={(branchName) => navigate(branchDetailPath(bundleId, branchName))}
onBack={() => navigate("/agentcore/eval/config-bundle/list")}
/>
);
}
20 changes: 13 additions & 7 deletions src/handlers/eval/config-bundle/get/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -24,14 +29,15 @@ export function ConfigBundleGetScreen({ ctx, core }: ScreenProps) {
"get",
bundleId ?? "",
...(versionId ? [versionId] : []),
...(branchName ? [branchName] : []),
]}
isPending={detail.isPending}
error={detail.isError ? (detail.error as Error) : null}
data={detail.data}
loadingLabel={
versionId
? `Loading version ${versionId} for configuration bundle ${bundleId ?? ""}…`
: `Loading configuration bundle ${bundleId ?? ""}…`
: `Loading branch ${branchName ?? ""} for configuration bundle ${bundleId ?? ""}…`
}
onRetry={() => void detail.refetch()}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/handlers/eval/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
GetOnlineEvaluationConfigResponse,
ListConfigurationBundlesResponse,
ListConfigurationBundleVersionsResponse,
VersionFilter,
ListDatasetsResponse,
ListEvaluatorsResponse,
ListOnlineEvaluationConfigsResponse,
Expand Down Expand Up @@ -504,6 +505,7 @@ export interface CoreEvalClient {
nextToken: string | undefined,
maxResults: number | undefined,
options: CoreOptions,
filter?: VersionFilter,
): Promise<ListConfigurationBundleVersionsResponse>;

// createDataset seeds a new dataset's DRAFT from `source`, which is required.
Expand Down
6 changes: 5 additions & 1 deletion src/testing/TestCoreClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type {
GetDatasetResponse,
ListConfigurationBundlesResponse,
ListConfigurationBundleVersionsResponse,
VersionFilter,
ListDatasetsResponse,
GetEvaluatorResponse,
GetOnlineEvaluationConfigResponse,
Expand Down Expand Up @@ -2333,10 +2334,13 @@ export class TestEvalClient implements CoreEvalClient {
nextToken: string | undefined,
maxResults: number | undefined,
options: CoreOptions,
filter?: VersionFilter,
): Promise<ListConfigurationBundleVersionsResponse> {
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 (
Expand Down
Loading