diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 3770acb4a..a5eeceae2 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -169,8 +169,53 @@ export function archiveResultToast({ /** The short model name for a definition's capabilities — fetched lazily, * per row, the same route (and the same plain fetch-effect, no react-query - * client required) `AgentDetailPanel` below already uses; a load or fetch - * failure degrades to a dash rather than blocking the row. */ + * client required) `AgentDetailPanel` below already uses. A fetch failure + * must not reuse the muted em-dash empty fields use (CL-6848). */ +export type AgentModelCellState = + | { readonly status: "loading" } + | { readonly status: "ready"; readonly data: AgentCapabilities } + | { readonly status: "error"; readonly message: string }; + +/** Settled Model-column content — an unset model reads as "Default"; a + * fetch failure is a distinct error, never the same label. */ +export function agentModelSettledContent( + state: + | { readonly status: "ready"; readonly data: AgentCapabilities } + | { readonly status: "error"; readonly message: string }, +): + | { readonly kind: "model"; readonly label: string } + | { readonly kind: "error"; readonly message: string } { + if (state.status === "error") { + return { kind: "error", message: state.message }; + } + return { kind: "model", label: state.data.model ?? "Default" }; +} + +/** Presentational half of the Model column — exported so tests can assert + * the failure glyph without waiting on the per-row fetch effect. */ +export function AgentModelCellView({ + state, +}: { + readonly state: AgentModelCellState; +}) { + if (state.status === "loading") { + return ; + } + const settled = agentModelSettledContent(state); + if (settled.kind === "error") { + return ( + + {settled.message} + + ); + } + return ( + + {settled.label} + + ); +} + function AgentModelCell({ tenantId, definitionId, @@ -178,11 +223,9 @@ function AgentModelCell({ readonly tenantId: string; readonly definitionId: string; }) { - const [capabilities, setCapabilities] = useState< - | { readonly status: "loading" } - | { readonly status: "ready"; readonly data: AgentCapabilities } - | { readonly status: "error" } - >({ status: "loading" }); + const [capabilities, setCapabilities] = useState({ + status: "loading", + }); useEffect(() => { let cancelled = false; @@ -191,25 +234,39 @@ function AgentModelCell({ .then((data) => { if (!cancelled) setCapabilities({ status: "ready", data }); }) - .catch(() => { - if (!cancelled) setCapabilities({ status: "error" }); + .catch((cause: unknown) => { + if (!cancelled) { + setCapabilities({ + status: "error", + message: describeApiError(cause, "loading this agent's model"), + }); + } }); return () => { cancelled = true; }; }, [tenantId, definitionId]); - if (capabilities.status === "loading") { - return ; - } - if (capabilities.status === "error") { - return —; + return ; +} + +/** Settled Runs · 7d content — a failed top-level-runs fetch is never the + * same as an honest count of zero (CL-6842). */ +export function agentRunsSettledContent( + definitionId: string, + instances: readonly AgentInstance[], + now: number, + instancesError: string | null, +): + | { readonly kind: "count"; readonly value: number } + | { readonly kind: "error"; readonly message: string } { + if (instancesError !== null) { + return { kind: "error", message: instancesError }; } - return ( - - {capabilities.data.model ?? "Default"} - - ); + return { + kind: "count", + value: runsInLast7Days(definitionId, instances, now), + }; } /** A workbench instance running a given agent definition — just enough to @@ -377,6 +434,7 @@ export function AgentsPage({ definitions, workbenches, instances, + instancesError = null, now = Date.now(), selectedId, onSelect, @@ -393,6 +451,9 @@ export function AgentsPage({ readonly DefinitionWorkbenchInstance[] >; readonly instances: readonly AgentInstance[]; + /** When the top-level-runs fetch failed — Status/Runs · 7d must not pretend + * the history is empty (CL-6842). */ + readonly instancesError?: string | null; readonly now?: number; readonly selectedId: string | null; readonly onSelect: (id: string | null) => void; @@ -455,6 +516,11 @@ export function AgentsPage({ /> ) : (
+ {instancesError !== null ? ( +

+ {instancesError} +

+ ) : null} @@ -487,7 +553,16 @@ export function AgentsPage({ {definitions.map((definition) => { const isSelected = selection.isSelected(definition.id); - const status = agentRosterStatus(definition, instances); + const runs = agentRunsSettledContent( + definition.id, + instances, + now, + instancesError, + ); + const status = + runs.kind === "error" + ? null + : agentRosterStatus(definition, instances); return ( - - {status === "running" ? ( - - ) : null} - - {AGENT_ROSTER_STATUS_LABEL[status]} - - + {status === null ? ( + — + ) : ( + + {status === "running" ? ( + + ) : null} + + {AGENT_ROSTER_STATUS_LABEL[status]} + + + )} {tenantId !== null ? ( @@ -581,7 +660,11 @@ export function AgentsPage({ )} - {runsInLast7Days(definition.id, instances, now)} + {runs.kind === "error" ? ( + — + ) : ( + runs.value + )} ); @@ -645,9 +728,9 @@ export function AgentsRoute({ const queryClient = useQueryClient(); const directory = useAgentDirectory(selectedTenantId ?? undefined); const activity = useBenchActivity(selectedTenantId); - // Powers the roster's Status and "Runs · 7d" columns; a failed fetch here - // degrades those two columns to Idle/0 rather than blocking the page — - // the definitions listing above is what makes the page usable at all. + // Powers the roster's Status and "Runs · 7d" columns. A failed fetch must + // not degrade those columns to Idle/0 (CL-6842) — the definitions listing + // still makes the page usable, but Status/Runs admit the load failed. const runsQuery = useQuery({ queryKey: ["agent-top-level-runs", selectedTenantId], queryFn: () => listTopLevelRuns(selectedTenantId as string), @@ -695,6 +778,11 @@ export function AgentsRoute({ definitions={definitions} workbenches={workbenches} instances={runsQuery.data ?? []} + instancesError={ + runsQuery.isError + ? describeApiError(runsQuery.error, "loading run history") + : null + } selectedId={selectedId} onSelect={(id) => navigate( diff --git a/apps/web/test/agents-page.test.tsx b/apps/web/test/agents-page.test.tsx index 3281dc420..d53f83d30 100644 --- a/apps/web/test/agents-page.test.tsx +++ b/apps/web/test/agents-page.test.tsx @@ -9,7 +9,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { AgentsPage, + AgentModelCellView, + agentModelSettledContent, agentRosterStatus, + agentRunsSettledContent, archiveDefinitions, archiveResultToast, runsInLast7Days, @@ -102,6 +105,98 @@ describe("runsInLast7Days", () => { }); }); +describe("agentModelSettledContent (CL-6848)", () => { + test("a fetch failure is an error, never the same label as an unset model", () => { + expect( + agentModelSettledContent({ + status: "error", + message: "Something went wrong loading this agent's model. Try again.", + }), + ).toEqual({ + kind: "error", + message: "Something went wrong loading this agent's model. Try again.", + }); + expect( + agentModelSettledContent({ + status: "ready", + data: { name: "triage-bot" }, + }), + ).toEqual({ kind: "model", label: "Default" }); + }); + + test("a ready model name passes through", () => { + expect( + agentModelSettledContent({ + status: "ready", + data: { name: "triage-bot", model: "claude-sonnet-4" }, + }), + ).toEqual({ kind: "model", label: "claude-sonnet-4" }); + }); +}); + +describe("AgentModelCellView (CL-6848)", () => { + test("a capabilities fetch error is visually distinct from an unset model", () => { + const errorMarkup = renderToStaticMarkup( + , + ); + const unsetMarkup = renderToStaticMarkup( + , + ); + expect(errorMarkup).toContain("text-destructive"); + expect(errorMarkup).toContain('role="alert"'); + expect(errorMarkup).toContain("loading this agent's model"); + expect(errorMarkup).not.toContain("text-muted-foreground"); + expect(unsetMarkup).toContain("Default"); + expect(unsetMarkup).toContain("text-muted-foreground"); + expect(unsetMarkup).not.toContain("text-destructive"); + // The muted em-dash is the absent-value glyph elsewhere on the page — + // a fetch failure must not reuse it. + expect(errorMarkup).not.toContain(">—<"); + }); +}); + +describe("agentRunsSettledContent (CL-6842)", () => { + test("a runs fetch failure is not the same as an honest empty history", () => { + expect( + agentRunsSettledContent("wfd_1", [], NOW, "Couldn't load run history"), + ).toEqual({ + kind: "error", + message: "Couldn't load run history", + }); + expect(agentRunsSettledContent("wfd_1", [], NOW, null)).toEqual({ + kind: "count", + value: 0, + }); + }); + + test("a successful fetch still reports the trailing-week count", () => { + expect( + agentRunsSettledContent( + "wfd_1", + [ + instance({ + definitionId: "wfd_1", + createdAt: "2026-08-19T00:00:00.000Z", + }), + ], + NOW, + null, + ), + ).toEqual({ kind: "count", value: 1 }); + }); +}); + describe("archiveDefinitions", () => { test("one id failing does not roll back or hide the ids that succeeded", async () => { const result = await archiveDefinitions( @@ -393,4 +488,53 @@ describe("AgentsPage", () => { ); expect(markup).not.toContain("Could not load agent skills"); }); + + test("an honest empty run history shows 0, not a load-failure marker (CL-6842)", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Idle"); + expect(markup).toContain(">0<"); + expect(markup).not.toContain("Couldn't load run history"); + }); + + test("a runs fetch failure is distinguishable from zero instances (CL-6842)", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("text-destructive"); + expect(markup).toContain('role="alert"'); + expect(markup).toContain( + "Something went wrong loading run history. Try again.", + ); + // Must not claim an Idle status or a literal zero off fabricated empty + // instances — that is the dishonest path `runsQuery.data ?? []` produced. + expect(markup).not.toContain("Idle"); + expect(markup).not.toContain(">0<"); + }); });