Skip to content
Open
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
88 changes: 88 additions & 0 deletions src/components/BatchEvaluationPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { BatchEvaluationSummary } from "@aws-sdk/client-bedrock-agentcore";
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";

// BatchEvaluationRow is the flat, display-ready shape the table renders. It also
// satisfies DataTable's `T extends Record<string, unknown>` constraint, which the
// SDK's BatchEvaluationSummary interface does not. The list API returns summary
// fields only; per-session results come from GetBatchEvaluation.
interface BatchEvaluationRow extends Record<string, unknown> {
batchEvaluationId: string;
name: string;
status: string;
updatedAt: string;
}

export const batchEvaluationColumns = [
{ key: "name", header: "name", flex: true },
{ key: "status", header: "status", width: 22 },
{
key: "updatedAt",
header: "updated UTC",
width: 16,
render: formatTimestamp,
},
] satisfies DataTableColumn<BatchEvaluationRow>[];

function toRow(summary: BatchEvaluationSummary): BatchEvaluationRow {
const id = summary.batchEvaluationId ?? "";
return {
batchEvaluationId: id,
name: summary.batchEvaluationName ?? id,
status: summary.status ?? "-",
updatedAt: summary.updatedAt?.toISOString() ?? "-",
};
}

export interface BatchEvaluationPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (batchEvaluationId: string) => void;
onEscape?: () => void;
}

/**
* Fetches the caller's batch evaluations and renders them as a navigable table.
* The shared body of every "pick a batch evaluation" screen. Esc returns to the
* parent menu derived from the breadcrumb unless a host supplies its own onEscape.
*/
export function BatchEvaluationPicker({
ctx,
core,
breadcrumb,
description,
onSelect,
onEscape,
}: BatchEvaluationPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")));

return (
<PaginatedTablePicker
breadcrumb={breadcrumb}
description={description}
queryKey={["batch-evaluations", opts.region]}
loadPage={async (token, pageSize) => {
const response = await core.eval.listBatchEvaluations(token, pageSize, opts);
return {
items: response.batchEvaluations ?? [],
nextToken: response.nextToken,
};
}}
toRow={toRow}
columns={batchEvaluationColumns}
getValue={(row) => row.batchEvaluationId}
onSelect={onSelect}
onBack={goBack}
loadingMessage="Loading batch evaluations…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No batch evaluations found in this Region."
emptyPageMessage="No batch evaluations on this page."
/>
);
}
6 changes: 6 additions & 0 deletions src/components/JsonDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export interface JsonDetailProps {
// loadingLabel names what's loading (e.g. "Loading endpoint…").
loadingLabel: string;
onRetry?: () => void;
// warning, when set, renders a persistent advisory above the JSON — the TUI's
// equivalent of the CLI's stderr warning (e.g. a CloudWatch results read that
// failed while the job metadata is still intact).
warning?: string;
}

// JsonDetail is the shared "show me the raw resource" screen body: a scrollable
Expand All @@ -30,6 +34,7 @@ export function JsonDetail({
data,
loadingLabel,
onRetry,
warning,
}: JsonDetailProps) {
const navigate = useNavigate();
const scrollRef = useRef<ScrollViewRef>(null);
Expand Down Expand Up @@ -67,6 +72,7 @@ export function JsonDetail({
<Text color="red">Error: {error.message}</Text>
) : (
<ScrollView ref={scrollRef}>
{warning ? <Text color="yellow">Warning: {warning}</Text> : null}
<CodeBlock
language="json"
showLineNumbers={false}
Expand Down
21 changes: 21 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import {
OnlineEvalGetScreen,
OnlineEvalGetJsonScreen,
} from "../handlers/eval/online-eval/get/screen.tsx";
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 { 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";
Expand Down Expand Up @@ -359,6 +362,24 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/eval/online-eval/get/:configId/json"
element={<OnlineEvalGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/eval/batch-evaluation"
element={<BatchEvaluationScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/eval/batch-evaluation/list"
element={<BatchEvaluationListScreen ctx={ctx} core={core} />}
/>
{/* Bare `get` (no id) has nothing to show — send the user to the list. */}
<Route
path="agentcore/eval/batch-evaluation/get"
element={<Navigate to="/agentcore/eval/batch-evaluation/list" replace />}
/>
{/* get is raw JSON only — no metadata hub, so :id is the JSON view. */}
<Route
path="agentcore/eval/batch-evaluation/get/:batchEvaluationId"
element={<BatchEvaluationGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/memory/event"
element={<MemoryEventScreen ctx={ctx} core={core} />}
Expand Down
200 changes: 200 additions & 0 deletions src/handlers/eval/batch-evaluation/batch-evaluation.screen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { afterEach, describe, expect, test } from "bun:test";
import type {
BatchEvaluationSummary,
GetBatchEvaluationResponse,
} 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<BatchEvaluationSummary> = {}): BatchEvaluationSummary {
return {
batchEvaluationArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluate/be-1",
batchEvaluationId: "be-1",
batchEvaluationName: "nightly_regression",
status: "COMPLETED",
createdAt: new Date("2026-07-19T01:02:03.000Z"),
updatedAt: new Date("2026-07-20T12:34:56.000Z"),
...overrides,
};
}

function getResponse(
overrides: Partial<GetBatchEvaluationResponse> = {},
): GetBatchEvaluationResponse {
return {
batchEvaluationArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluate/be-1",
batchEvaluationId: "be-1",
batchEvaluationName: "nightly_regression",
status: "COMPLETED",
createdAt: new Date("2026-07-19T01:02:03.000Z"),
updatedAt: new Date("2026-07-20T12:34:56.000Z"),
evaluators: [{ evaluatorId: "Builtin.Correctness" }],
outputConfig: {
cloudWatchConfig: {
logGroupName: "/aws/bedrock-agentcore/evaluations/batch-evaluations/results/default",
logStreamName: "run-be-1",
},
},
evaluationResults: {
numberOfSessionsCompleted: 3,
totalNumberOfSessions: 3,
numberOfSessionsFailed: 0,
evaluatorSummaries: [
{
evaluatorId: "Builtin.Correctness",
statistics: { averageScore: 1 },
totalEvaluated: 3,
totalFailed: 0,
},
],
},
...overrides,
};
}

function coreWithBatchEvals(items: BatchEvaluationSummary[]): TestCoreClient {
const core = new TestCoreClient();
core.eval.setBatchEvalListResponse({ batchEvaluations: items });
return core;
}

describe("batch-evaluation menu", () => {
test("offers get and list", async () => {
const screen = renderScreen("/agentcore/eval/batch-evaluation");

await waitForText(screen.lastFrame, "list batch evaluations");
const frame = screen.lastFrame()!;
expect(frame).toContain("list");
expect(frame).toContain("get");
});
});

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

await waitForText(screen.lastFrame, "staging_eval");
const frame = screen.lastFrame()!;
expect(frame).toContain("FAILED");
expect(frame).toContain("2026-07-21 02:03");
});

test("calls listBatchEvaluations with exact Core options", async () => {
const core = coreWithBatchEvals([summary()]);
renderScreen("/agentcore/eval/batch-evaluation/list", { core, endpointUrl: evalEndpointUrl });

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

test("bare get redirects to the picker", async () => {
const core = coreWithBatchEvals([
summary({ batchEvaluationId: "redirected-be", batchEvaluationName: "redirected_eval" }),
]);
const screen = renderScreen("/agentcore/eval/batch-evaluation/get", { core });

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

test("selection opens the matching batch evaluation JSON", async () => {
const core = coreWithBatchEvals([summary({ batchEvaluationId: "be-1" })]);
core.eval.setBatchEvalGetResponse(getResponse({ batchEvaluationId: "be-1" }));
const screen = renderScreen("/agentcore/eval/batch-evaluation/list", { core });

await waitForText(screen.lastFrame, "nightly_regression");
await screen.press("return");
await waitForText(screen.lastFrame, "agentcore → eval → batch-evaluation → get → be-1");
await waitFor(() =>
core.eval.calls.some(
(call) => call.method === "getBatchEvaluation" && call.args[0] === "be-1",
),
);
});

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

describe("batch-evaluation detail (raw JSON)", () => {
test("renders the full response, including merged results", async () => {
const core = new TestCoreClient();
core.eval.setBatchEvalGetResponse(getResponse());
core.eval.setBatchEvalResults([
{ evaluatorId: "Builtin.Correctness", level: "Trace", sessionId: "s1", score: 1 },
]);
const screen = renderScreen("/agentcore/eval/batch-evaluation/get/be-1", {
core,
endpointUrl: evalEndpointUrl,
});

await waitForText(screen.lastFrame, "nightly_regression");
const frame = screen.lastFrame()!;
expect(frame).toContain('"status"');
expect(frame).toContain("COMPLETED");
expect(frame).toContain('"evaluationResults"');
expect(frame).toContain('"results"');
// Screen requests results by default (no --disable-cw-results in the TUI): it
// calls getBatchEvaluation(id, opts) with no includeResults override, so the
// options object defaults to {} (includeResults defaults to true in Core).
expect(core.eval.calls.find((call) => call.method === "getBatchEvaluation")).toEqual({
method: "getBatchEvaluation",
args: ["be-1", { region: "us-east-1", endpointUrl: evalEndpointUrl }, {}],
});
});

test("a CloudWatch results failure still renders the metadata", async () => {
const core = new TestCoreClient();
core.eval.setBatchEvalGetResponse(getResponse());
core.eval.setBatchEvalResultsError(new Error("AccessDenied"));
const screen = renderScreen("/agentcore/eval/batch-evaluation/get/be-1", { core });

await waitForText(screen.lastFrame, "nightly_regression");
const frame = screen.lastFrame()!;
expect(frame).toContain("COMPLETED"); // status intact
expect(frame).not.toContain('"results"'); // results omitted, screen didn't crash
expect(frame).toContain("could not retrieve CloudWatch results"); // warned, not silent
expect(frame).toContain("AccessDenied");
});

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

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

core.eval.setError(undefined);
core.eval.setBatchEvalGetResponse(getResponse());
await screen.write("r");
await waitForText(screen.lastFrame, "nightly_regression");
});
});
2 changes: 2 additions & 0 deletions src/handlers/eval/batch-evaluation/get/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ function warnCloudWatchFailure(
`Job status is unaffected.${link}`,
);
}

export { BatchEvaluationGetJsonScreen } from "./screen.tsx";
Loading
Loading