From 80a2f4d8a8c32271d04e9facf64b14691a3d611c Mon Sep 17 00:00:00 2001 From: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:06:04 -0700 Subject: [PATCH] feat(lens): add preset watch-for checks to investigation setup (#44313) * feat(lens): add preset watch-for checks for common agent failures * feat(lens): add keyboard-driven watch-for picker with lens dot animation * feat(lens): use the watch-for picker in investigation setup * feat(lens): show preset checks by name in the criteria tab * test(lens): cover saving and editing watch-for presets * feat(lens): shorten watch-for summaries and start with three presets on * feat(lens): lay out watch-for presets as toggle tiles with a clear add-your-own button * feat(lens): open a custom check from the watch-for picker * test(lens): cover watch-for tiles and the add-your-own button * fix(lens): draw the selected tile border inside the tile so the dialog edge cannot clip it --- .../LensSetup.integration.test.tsx | 70 ++++++- .../lens/_components/LensSetup.tsx | 60 +++--- .../(dashboard)/lens/_components/LensView.tsx | 16 +- .../lens/_components/WatchPicker.tsx | 177 ++++++++++++++++++ .../(dashboard)/lens/_components/lensData.ts | 93 ++++++++- 5 files changed, 375 insertions(+), 41 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/lens/_components/WatchPicker.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.integration.test.tsx index cdc201d8dd5..6b664c230f9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.integration.test.tsx @@ -5,7 +5,7 @@ import { renderWithProviders } from "@/../tests/test-utils"; import { MonitoringSetup } from "./LensOverview"; import { LensSetup } from "./LensSetup"; import { apiClient } from "@/components/networking"; -import type { Settings } from "./lensData"; +import { initialWatches, watchChecks, type Settings } from "./lensData"; vi.mock("@/components/networking", () => ({ apiClient: { post: vi.fn(), get: vi.fn() } })); @@ -105,12 +105,13 @@ describe("Lens setup", () => { fireEvent.change(screen.getByRole("combobox", { name: "Metadata key 1" }), { target: { value: "swarm" } }); fireEvent.change(screen.getByRole("combobox", { name: "Metadata value 1" }), { target: { value: "research" } }); await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click(screen.getByRole("button", { name: /Add your own/ })); await user.type(screen.getByRole("textbox", { name: "Check 1" }), "Find incomplete reports"); - await user.click(screen.getByRole("button", { name: "Add check" })); + await user.click(screen.getByRole("button", { name: /Add your own/ })); fireEvent.change(screen.getByRole("textbox", { name: "Check 2" }), { target: { value: "Find repeated searches\nInclude retries that add no information" }, }); - await user.click(screen.getByRole("button", { name: "Add check" })); + await user.click(screen.getByRole("button", { name: /Add your own/ })); await user.click(screen.getByRole("button", { name: "Remove check 3" })); await user.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled()); @@ -129,6 +130,7 @@ describe("Lens setup", () => { model: "analysis", monthly_budget: 100, checks: [ + ...watchChecks(initialWatches(undefined)), expect.objectContaining({ instruction: "Find incomplete reports" }), expect.objectContaining({ instruction: "Find repeated searches\nInclude retries that add no information" }), ], @@ -411,3 +413,65 @@ it("saves a discovered agent independently of the application name", async () => expect.objectContaining({ agent_name: "research_agent", service: "shared-service" }), ); }); + +describe("Watch for", () => { + const tile = (name: string) => screen.getByRole("button", { name: new RegExp(`^${name}`) }); + + it("saves exactly the presets the user toggled, by click and by number key", async () => { + const save = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + renderWithProviders( + , + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click(tile("unhappy")); + tile("unsolved").focus(); + await user.keyboard("6"); + await user.keyboard("{ArrowRight}{ArrowRight}"); + expect(tile("unsafe")).toHaveFocus(); + expect(tile("unhappy")).toHaveAttribute("aria-pressed", "false"); + expect(tile("looping")).toHaveAttribute("aria-pressed", "true"); + await user.click(screen.getByRole("button", { name: "Continue" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Run investigation" })); + const saved = (save.mock.calls[0][0] as Settings).checks.map((check) => check.id); + expect(saved).toEqual(["watch_unsolved", "watch_blocked", "watch_looping"]); + }); + + it("keeps an edited investigation's preset choices and custom checks apart", async () => { + const save = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + const initial: Settings = { + ...settings, + checks: [{ id: "watch_invented", instruction: "old wording", enabled: true }, settings.checks[1]], + }; + renderWithProviders( + , + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(tile("invented")).toHaveAttribute("aria-pressed", "true"); + expect(tile("unsolved")).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByRole("textbox", { name: "Check 1" })).toHaveValue("Find incomplete reports"); + await user.click(screen.getByRole("button", { name: "Continue" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled()); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect((save.mock.calls[0][0] as Settings).checks).toEqual([ + ...watchChecks(new Set(["watch_invented"])), + settings.checks[1], + ]); + }); + + it("lets a run start from presets alone and blocks it once nothing is selected", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(await screen.findByRole("button", { name: "Run investigation" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Back" })); + for (const name of ["unsolved", "blocked", "unhappy"]) await user.click(tile(name)); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(screen.getByRole("alert")).toHaveTextContent("pick something to watch for"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.tsx b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.tsx index 32fcb2f7d43..88000eb5192 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/lens/_components/LensSetup.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Plus, X } from "lucide-react"; +import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -16,7 +16,16 @@ import { import { SearchSelect } from "@/components/shared/SearchSelect"; import { DurationInput } from "./DurationInput"; import { ActivityScope, type ActivitySelection } from "./ActivityScope"; -import { analysisModelOptions, normalizeFilters, type AnalysisModelInfo, type Settings } from "./lensData"; +import { WatchPicker } from "./WatchPicker"; +import { + analysisModelOptions, + initialWatches, + isWatch, + normalizeFilters, + watchChecks, + type AnalysisModelInfo, + type Settings, +} from "./lensData"; function validateSample(selection: ActivitySelection) { const hours = selection.lookback_hours ?? 24; @@ -77,7 +86,8 @@ export function LensSetup({ }; const [selection, setSelection] = useState(initialSelection); const [context, setContext] = useState(initial?.context ?? ""); - const [questions, setQuestions] = useState(() => (initial?.checks?.length ? initial.checks : [newCheck()])); + const [watching, setWatching] = useState>(() => initialWatches(initial?.checks)); + const [questions, setQuestions] = useState(() => (initial?.checks ?? []).filter((check) => !isWatch(check))); const [selectedModel, setModel] = useState(initial?.model ?? null); const model = selectedModel ?? defaultModel ?? ""; const [budget, setBudget] = useState(initial?.monthly_budget ?? 100); @@ -102,8 +112,8 @@ export function LensSetup({ if (step >= 2 && manualSelection && !selection.execution_ids?.length) throw new Error("Choose at least one run or turn off individual selection"); validateSample(selection); - if (step >= 1 && !context.trim() && !filledChecks.length) - throw new Error("Describe the expected behavior or what to look out for"); + const nothingToCheck = !context.trim() && !filledChecks.length && !watching.size; + if (step >= 1 && nothingToCheck) throw new Error("Describe the expected behavior or pick something to watch for"); if (filledChecks.some((check) => check.instruction.trim().length < 3)) throw new Error("Use at least three characters for each check"); }; @@ -132,7 +142,10 @@ export function LensSetup({ interval_minutes: interval, concurrency: initial?.concurrency ?? 8, filters: normalizeFilters(selection.filters ?? []), - checks: filledChecks.map((check) => ({ ...check, instruction: check.instruction.trim() })), + checks: [ + ...watchChecks(watching), + ...filledChecks.map((check) => ({ ...check, instruction: check.instruction.trim() })), + ], }; await onSave(settings); } catch (cause) { @@ -172,7 +185,7 @@ export function LensSetup({ }} > {headings[step]} @@ -239,8 +252,13 @@ export function LensSetup({ placeholder="Answer the customer's question using verified sources and explain when information is missing." /> -
- What should we look out for? + setQuestions([...questions, newCheck()])} + /> +
+ Custom checks {questions.map((check, index) => (