diff --git a/src/components/BatchEvaluationPicker.tsx b/src/components/BatchEvaluationPicker.tsx index ad6e45fa4..b19b20200 100644 --- a/src/components/BatchEvaluationPicker.tsx +++ b/src/components/BatchEvaluationPicker.tsx @@ -41,6 +41,11 @@ function toRow(summary: BatchEvaluationSummary): BatchEvaluationRow { export interface BatchEvaluationPickerProps extends ScreenProps { breadcrumb: string[]; description?: string; + queryKeyPrefix?: string; + include?: (summary: BatchEvaluationSummary) => boolean; + loadingMessage?: string; + emptyMessage?: string; + emptyPageMessage?: string; onSelect: (batchEvaluationId: string) => void; onEscape?: () => void; } @@ -55,6 +60,11 @@ export function BatchEvaluationPicker({ core, breadcrumb, description, + queryKeyPrefix = "batch-evaluations", + include, + loadingMessage = "Loading batch evaluations…", + emptyMessage = "No batch evaluations found in this Region.", + emptyPageMessage = "No batch evaluations on this page.", onSelect, onEscape, }: BatchEvaluationPickerProps) { @@ -66,11 +76,11 @@ export function BatchEvaluationPicker({ { const response = await core.eval.listBatchEvaluations(token, pageSize, opts); return { - items: response.batchEvaluations ?? [], + items: (response.batchEvaluations ?? []).filter((summary) => include?.(summary) ?? true), nextToken: response.nextToken, }; }} @@ -79,10 +89,10 @@ export function BatchEvaluationPicker({ getValue={(row) => row.batchEvaluationId} onSelect={onSelect} onBack={goBack} - loadingMessage="Loading batch evaluations…" + loadingMessage={loadingMessage} errorMessage={(error) => `Error: ${error.message}`} - emptyMessage="No batch evaluations found in this Region." - emptyPageMessage="No batch evaluations on this page." + emptyMessage={emptyMessage} + emptyPageMessage={emptyPageMessage} /> ); } diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 560096e06..2b876a958 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -51,6 +51,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 { 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"; 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"; @@ -549,6 +552,22 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/eval/batch-evaluation/get/:batchEvaluationId" element={} /> + } + /> + } + /> + } + /> + } + /> } diff --git a/src/handlers/eval/batch-insights/batch-insights.screen.test.tsx b/src/handlers/eval/batch-insights/batch-insights.screen.test.tsx new file mode 100644 index 000000000..3e8998b0a --- /dev/null +++ b/src/handlers/eval/batch-insights/batch-insights.screen.test.tsx @@ -0,0 +1,149 @@ +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); + +function summary(overrides: Partial = {}): BatchEvaluationSummary { + return { + batchEvaluationArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluate/insights-1", + batchEvaluationId: "insights-1", + batchEvaluationName: "failure_analysis", + status: "COMPLETED", + createdAt: new Date("2026-08-20T01:02:03.000Z"), + updatedAt: new Date("2026-08-21T12:34:56.000Z"), + insights: [{ insightId: "Builtin.Insight.FailureAnalysis" }], + ...overrides, + }; +} + +function detail(overrides: Partial = {}): GetBatchEvaluationResponse { + return { + batchEvaluationArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluate/insights-1", + batchEvaluationId: "insights-1", + batchEvaluationName: "failure_analysis", + status: "COMPLETED", + createdAt: new Date("2026-08-20T01:02:03.000Z"), + updatedAt: new Date("2026-08-21T12:34:56.000Z"), + insights: [{ insightId: "Builtin.Insight.FailureAnalysis" }], + failureAnalysisResult: { + failures: [], + }, + ...overrides, + } as GetBatchEvaluationResponse; +} + +function coreWithBatchEvaluations(items: BatchEvaluationSummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.eval.setBatchEvalListResponse({ batchEvaluations: items }); + return core; +} + +describe("batch-insights menu", () => { + test("offers only read-only commands", async () => { + const screen = renderScreen("/agentcore/eval/batch-insights"); + + await waitForText(screen.lastFrame, "list batch insights runs"); + const frame = screen.lastFrame()!; + expect(frame).toContain("list"); + expect(frame).toContain("get"); + expect(frame).not.toContain("start an asynchronous batch insights run"); + }); +}); + +describe("batch-insights picker", () => { + test("filters evaluator-only jobs from the shared service page", async () => { + const core = coreWithBatchEvaluations([ + summary(), + summary({ + batchEvaluationId: "evaluation-1", + batchEvaluationName: "quality_evaluation", + insights: undefined, + evaluators: [{ evaluatorId: "Builtin.Correctness" }], + }), + ]); + const screen = renderScreen("/agentcore/eval/batch-insights/list", { core }); + + await waitForText(screen.lastFrame, "failure_analysis"); + const frame = screen.lastFrame()!; + expect(frame).not.toContain("quality_evaluation"); + expect(core.eval.calls[0]?.method).toBe("listBatchEvaluations"); + }); + + test("bare get redirects to the filtered picker", async () => { + const core = coreWithBatchEvaluations([summary()]); + const screen = renderScreen("/agentcore/eval/batch-insights/get", { core }); + + await waitForText(screen.lastFrame, "failure_analysis"); + expect(core.eval.calls[0]?.method).toBe("listBatchEvaluations"); + }); + + test("selection opens the matching insights JSON", async () => { + const core = coreWithBatchEvaluations([summary()]); + core.eval.setBatchEvalGetResponse(detail()); + const screen = renderScreen("/agentcore/eval/batch-insights/list", { core }); + + await waitForText(screen.lastFrame, "failure_analysis"); + await screen.press("return"); + await waitForText(screen.lastFrame, "agentcore → eval → batch-insights → get → insights-1"); + await waitFor(() => + core.eval.calls.some( + (call) => call.method === "getBatchEvaluation" && call.args[0] === "insights-1", + ), + ); + }); + + test("shows the Insights-specific empty state", async () => { + const core = coreWithBatchEvaluations([ + summary({ + insights: undefined, + evaluators: [{ evaluatorId: "Builtin.Correctness" }], + }), + ]); + const screen = renderScreen("/agentcore/eval/batch-insights/list", { core }); + + await waitForText(screen.lastFrame, "No batch insights found in this Region."); + }); +}); + +describe("batch-insights detail", () => { + test("renders service-side reports without requesting CloudWatch results", async () => { + const core = new TestCoreClient(); + core.eval.setBatchEvalGetResponse(detail()); + const screen = renderScreen("/agentcore/eval/batch-insights/get/insights-1", { core }); + + await waitForText(screen.lastFrame, "failure_analysis"); + const frame = screen.lastFrame()!; + expect(frame).toContain('"failureAnalysisResult"'); + expect(core.eval.calls.find((call) => call.method === "getBatchEvaluation")).toEqual({ + method: "getBatchEvaluation", + args: ["insights-1", { region: "us-east-1" }, { includeResults: false }], + }); + }); + + test("rejects direct navigation to an evaluator-only job", async () => { + const core = new TestCoreClient(); + core.eval.setBatchEvalGetResponse( + detail({ + batchEvaluationId: "evaluation-1", + insights: undefined, + evaluators: [{ evaluatorId: "Builtin.Correctness" }], + failureAnalysisResult: undefined, + }), + ); + const screen = renderScreen("/agentcore/eval/batch-insights/get/evaluation-1", { core }); + + await waitForText(screen.lastFrame, "is not a batch insights run"); + }); +}); diff --git a/src/handlers/eval/batch-insights/get/index.tsx b/src/handlers/eval/batch-insights/get/index.tsx index 065c43222..348ee3c76 100644 --- a/src/handlers/eval/batch-insights/get/index.tsx +++ b/src/handlers/eval/batch-insights/get/index.tsx @@ -4,6 +4,7 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; +import { InsightsJob } from "../insightsJob"; export const createGetBatchInsightsHandler = (core: Core) => createHandler({ @@ -18,10 +19,10 @@ export const createGetBatchInsightsHandler = (core: Core) => const { detail } = await core.eval.getBatchEvaluation(id, opts, { includeResults: false, }); - if (!detail.insights?.length) { - throw new InputValidationError(`batch evaluation "${id}" is not a batch insights run`); - } + InsightsJob.assert(detail, id); ctx.require(JsonRendererKey).renderJson(detail); }, }); + +export { BatchInsightsGetJsonScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/batch-insights/get/screen.tsx b/src/handlers/eval/batch-insights/get/screen.tsx new file mode 100644 index 000000000..45f42f669 --- /dev/null +++ b/src/handlers/eval/batch-insights/get/screen.tsx @@ -0,0 +1,37 @@ +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"; +import { InsightsJob } from "../insightsJob"; + +function useBatchInsightsDetail({ ctx, core }: ScreenProps, id: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["batch-insights", opts.region, id], + queryFn: async () => { + const { detail } = await core.eval.getBatchEvaluation(id!, opts, { + includeResults: false, + }); + InsightsJob.assert(detail, id!); + return detail; + }, + enabled: id !== undefined, + }); +} + +export function BatchInsightsGetJsonScreen(props: ScreenProps) { + const { batchEvaluationId } = useParams(); + const query = useBatchInsightsDetail(props, batchEvaluationId); + + return ( + void query.refetch()} + /> + ); +} diff --git a/src/handlers/eval/batch-insights/index.tsx b/src/handlers/eval/batch-insights/index.tsx index ddcada174..967bbe9a8 100644 --- a/src/handlers/eval/batch-insights/index.tsx +++ b/src/handlers/eval/batch-insights/index.tsx @@ -1,6 +1,7 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; -import { createHelpDefault } from "../../help"; +import { withTuiOnEmptyFlagsAndArgs } from "../../../middleware"; +import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createGetBatchInsightsHandler } from "./get"; import { createListBatchInsightsHandler } from "./list"; @@ -8,8 +9,12 @@ import { createRunBatchInsightsHandler } from "./run"; export function createBatchInsightsHandler(core: Core, io: AppIO): Router { return new Router("batch-insights", "run and inspect batch insights") - .default(createHelpDefault(io)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) + .supportedTuiCommands("get", "list") .handler(createRunBatchInsightsHandler(core, io)) .handler(createGetBatchInsightsHandler(core)) .handler(createListBatchInsightsHandler(core)); } + +export { BatchInsightsScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/batch-insights/insightsJob.ts b/src/handlers/eval/batch-insights/insightsJob.ts new file mode 100644 index 000000000..2b4559755 --- /dev/null +++ b/src/handlers/eval/batch-insights/insightsJob.ts @@ -0,0 +1,13 @@ +import { InputValidationError } from "../../../errors"; + +export class InsightsJob { + static is(job: { insights?: unknown[] }): boolean { + return Boolean(job.insights?.length); + } + + static assert(job: { insights?: unknown[] }, id: string): void { + if (!InsightsJob.is(job)) { + throw new InputValidationError(`batch evaluation "${id}" is not a batch insights run`); + } + } +} diff --git a/src/handlers/eval/batch-insights/list/index.tsx b/src/handlers/eval/batch-insights/list/index.tsx index 7b37e9030..6490e8f28 100644 --- a/src/handlers/eval/batch-insights/list/index.tsx +++ b/src/handlers/eval/batch-insights/list/index.tsx @@ -3,6 +3,7 @@ import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; import { coreOptsFromCtx } from "../../../utils"; +import { InsightsJob } from "../insightsJob"; export const createListBatchInsightsHandler = (core: Core) => createHandler({ @@ -22,9 +23,9 @@ export const createListBatchInsightsHandler = (core: Core) => ctx.require(JsonRendererKey).renderJson({ ...response, - batchEvaluations: (response.batchEvaluations ?? []).filter( - (evaluation) => evaluation.insights?.length, - ), + batchEvaluations: (response.batchEvaluations ?? []).filter(InsightsJob.is), }); }, }); + +export { BatchInsightsListScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/batch-insights/list/screen.tsx b/src/handlers/eval/batch-insights/list/screen.tsx new file mode 100644 index 000000000..50ac2065d --- /dev/null +++ b/src/handlers/eval/batch-insights/list/screen.tsx @@ -0,0 +1,23 @@ +import { useNavigate } from "react-router"; +import { BatchEvaluationPicker } from "../../../../components/BatchEvaluationPicker"; +import type { ScreenProps } from "../../../types"; +import { InsightsJob } from "../insightsJob"; + +export function BatchInsightsListScreen(props: ScreenProps) { + const navigate = useNavigate(); + + return ( + + navigate(`/agentcore/eval/batch-insights/get/${encodeURIComponent(batchEvaluationId)}`) + } + /> + ); +} diff --git a/src/handlers/eval/batch-insights/screen.tsx b/src/handlers/eval/batch-insights/screen.tsx new file mode 100644 index 000000000..dcc3f4118 --- /dev/null +++ b/src/handlers/eval/batch-insights/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function BatchInsightsScreen(props: ScreenProps) { + return ; +}