Skip to content
Merged
172 changes: 166 additions & 6 deletions admin-ui/__specs__/health-tile-llm-cause.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,177 @@
// @jest-environment jsdom
// STORY-353 — A red LLM tile names its cause (SPEC F139.2, the admin-ui half · PLAN T334)
//
// BDD specification — Jest (jsdom). PENDING via it.todo until T334 builds the surface;
// the backend half rides tests/GenWave.Host.Tests/Specs/Story353_LlmCauseTaxonomy.cs.
// BDD specification — Jest (jsdom). The backend half rides
// tests/GenWave.Host.Tests/Specs/Story353_LlmCauseTaxonomy.cs (the /api/llm-calls surface) and
// Story125_LlmStatus.cs (the /api/status llm.dominantCause* fields this file's own tile reads).
//
// StatusTiles is a pure, prop-driven presentational component (`{ status, error, timeZone }` — no
// fetch of its own; DashboardView owns polling via usePoll, Q5/STORY-087) — these specs render it
// directly with a built `status` prop, mirroring safe-scope-tile.spec.tsx's own idiom, rather than
// standing up DashboardView's three-endpoint fetch mock: the tile rides the EXISTING /api/status
// poll (no new poller, the gh-#558 lesson), so there is nothing fetch-shaped left to prove here —
// dashboard-llm-tile.spec.tsx already covers that this data arrives via that one poll.

import { describe, it, expect, jest, afterEach } from "@jest/globals";
import { render, screen, cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/jest-globals";
import { StatusTiles } from "../app/(authed)/dashboard/StatusTiles";
import type { StatusResponse } from "@/lib/broadcast-api";

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

interface LlmOverrides {
enabled?: boolean;
model?: string | null;
lastOutcome?: "ok" | "failed" | null;
dominantCause?: string | null;
dominantCauseCount?: number | null;
dominantCauseModel?: string | null;
}

/** Catalog/SafeScope/Voice are fixed benign values — these specs exercise the LLM tile's own
* dominant-cause line only. */
function makeStatus(llm: LlmOverrides = {}): StatusResponse {
return {
startedAt: "2026-01-01T08:00:00.000Z",
catalog: { ready: 10, enriching: 0, failed: 0, unavailable: 0 },
safeScope: { libraryIds: [1], playable: 5 },
llm: {
enabled: true,
model: "gemma3:12b",
activePersona: null,
lastOutcome: "ok",
lastAttemptAt: "2026-01-01T07:59:00.000Z",
dominantCause: null,
dominantCauseCount: null,
dominantCauseModel: null,
...llm,
},
voice: { engine: "kokoro", degraded: false, reason: null, checkedAt: null },
};
}

import { describe, it } from "@jest/globals";
afterEach(() => {
cleanup();
jest.restoreAllMocks();
});

// ---------------------------------------------------------------------------
// Feature: a red LLM tile names its cause
// ---------------------------------------------------------------------------

describe("Feature: a red LLM tile names its cause", () => {
describe("Scenario: the tile explains a red verdict", () => {
it.todo("names the dominant recent cause when the LLM verdict is red (T334)");
it.todo("names the model alongside the cause (T334)");
it("names the dominant recent cause when the LLM verdict is red (T334)", () => {
render(
<StatusTiles
status={makeStatus({
lastOutcome: "failed",
dominantCause: "timeout",
dominantCauseCount: 6,
dominantCauseModel: "gemma3:12b",
})}
error={false}
/>
);

// SPEC F139.2's own worked example shape ("red: 6 timeouts…"), sentence-cased per house
// copy rule — pluralized since the count is 6, not 1.
expect(screen.getByText(/Red: 6 timeouts/)).toBeInTheDocument();
});

it("names the model alongside the cause (T334)", () => {
render(
<StatusTiles
status={makeStatus({
lastOutcome: "failed",
dominantCause: "timeout",
dominantCauseCount: 6,
dominantCauseModel: "gemma3:12b",
})}
error={false}
/>
);

const line = screen.getByText(/Red: 6 timeouts/);
expect(line).toHaveTextContent("gemma3:12b");
});

it("singularizes the noun for a count of exactly one", () => {
render(
<StatusTiles
status={makeStatus({
lastOutcome: "failed",
dominantCause: "connectionfailure",
dominantCauseCount: 1,
dominantCauseModel: "llama3.1:8b",
})}
error={false}
/>
);

expect(screen.getByText("Red: 1 connection failure in the last 24h, llama3.1:8b")).toBeInTheDocument();
});

// T334 review round 1, advisory c — the same "never drop an unknown kind" discipline
// LlmCallsFeed.tsx's CAUSE_LABELS already follows: a cause value shipped by the api ahead of an
// admin-ui label update still renders, unstyled, as its raw wire token — never `undefined`,
// never a thrown render.
it("renders the raw wire token for a cause this tile has no specific label for", () => {
render(
<StatusTiles
status={makeStatus({
lastOutcome: "failed",
dominantCause: "somenewcause",
dominantCauseCount: 2,
dominantCauseModel: "gemma3:12b",
})}
error={false}
/>
);

expect(screen.getByText("Red: 2 somenewcause in the last 24h, gemma3:12b")).toBeInTheDocument();
});
});

describe("Scenario: quiet states stay quiet", () => {
it.todo("shows no cause line when the LLM verdict is green (T334)");
it("shows no cause line when the LLM verdict is green (T334)", () => {
render(
<StatusTiles
status={makeStatus({
lastOutcome: "ok",
// Deliberately non-null: even if the api ever reported a dominant cause alongside an
// "ok" last attempt, the tile only ever renders the line once the verdict is ALREADY
// red (StatusTiles' own DominantCauseLine is gated on lastOutcome === "failed", not on
// these fields alone) — a green tile must never show a "why" line for a fault that
// didn't cause it to go red.
dominantCause: "timeout",
dominantCauseCount: 3,
dominantCauseModel: "gemma3:12b",
})}
error={false}
/>
);

expect(screen.queryByText(/Red:/)).not.toBeInTheDocument();
});

it("shows no cause line when the LLM is disabled", () => {
render(<StatusTiles status={makeStatus({ enabled: false, lastOutcome: null })} error={false} />);

expect(screen.queryByText(/Red:/)).not.toBeInTheDocument();
});

it("shows no cause line for a failed verdict with nothing yet to explain it", () => {
// The three dominantCause* fields travel together (broadcast-api.ts's own remarks) — a
// failed verdict with none of them set renders the existing failure line alone, never an
// empty or malformed "why" line.
render(<StatusTiles status={makeStatus({ lastOutcome: "failed" })} error={false} />);

expect(screen.getByText(/Last completion failed/)).toBeInTheDocument();
expect(screen.queryByText(/Red:/)).not.toBeInTheDocument();
});
});
});
40 changes: 38 additions & 2 deletions admin-ui/__specs__/llm-calls-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ interface EntryOverrides {
promptChars?: number;
responseChars?: number;
kind?: string;
/** SPEC F139.1, STORY-353, PLAN T334 */
cause?: string;
/** SPEC F139.2, STORY-353, PLAN T334 */
model?: string;
}

function makeEntry(overrides: EntryOverrides = {}) {
Expand All @@ -52,14 +56,20 @@ function makeEntry(overrides: EntryOverrides = {}) {
promptChars: 100,
responseChars: 48,
kind: "copy",
cause: "success",
model: "test-model",
...overrides,
};
}

type MockResult = { kind: "ok"; body: unknown } | { kind: "network-error" };

function ok(body: unknown): MockResult {
return { kind: "ok", body };
/** Wraps `calls` in the SPEC F139.2/PLAN T334 response shape (`{ calls, causeSummary }` —
* `GenWave.Host.Api.LlmCallsResponseDto`) — every call site below still just hands this the flat
* array of entries it always did; `causeSummary` is empty since none of these facts exercise it
* (this page doesn't render it — see lib/llm-calls-api.ts's own remarks). */
function ok(calls: unknown[]): MockResult {
return { kind: "ok", body: { calls, causeSummary: [] } };
}

function networkError(): MockResult {
Expand Down Expand Up @@ -209,6 +219,32 @@ describe("Feature: LLM call inspector", () => {
});
});

// SPEC F139.1, STORY-353, PLAN T334 — Cause is Status's own finer-grained sibling: an operator
// can tell a timeout apart from a connection failure without expanding into the raw prompt/
// response text.
describe("Scenario: rows show why the call resolved the way it did", () => {
it("renders a Timeout cause chip", async () => {
installFetchMock(ok([makeEntry({ status: "failed", cause: "timeout" })]));

render(<LlmCallsView timeZone="UTC" />);
await flush();

expect(screen.getByText("Timeout")).toBeInTheDocument();
});

it("renders the raw wire value for a cause this UI has no specific label for", async () => {
// The "never drop an unknown kind" discipline STATUS_LABELS/KIND_LABELS already follow — a
// taxonomy value shipped by the api ahead of an admin-ui label update still renders,
// unstyled, rather than vanishing (SPEC F139.1's own history: eight values as of T330).
installFetchMock(ok([makeEntry({ cause: "somenewcause" })]));

render(<LlmCallsView timeZone="UTC" />);
await flush();

expect(screen.getByText("somenewcause")).toBeInTheDocument();
});
});

// gh-#429: personas now author the copy this table exists to triage, so each row names who
// authored it without an admin having to expand into the system prompt to find out.
describe("Scenario: rows show which persona authored the call", () => {
Expand Down
40 changes: 36 additions & 4 deletions admin-ui/app/(authed)/booth-log/LlmCallsFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ const MODE_LABELS: Record<string, string> = { normal: "Normal", soft: "Soft", ha
* "never drop an unknown kind" discipline STATUS_LABELS/MODE_LABELS already follow). */
const KIND_LABELS: Record<string, string> = { copy: "Copy", crosstalk: "Crosstalk" };

/** SPEC F139.1, STORY-353, PLAN T334 — WHY this call resolved the way it did
* (GenWave.Tts.LlmCallCause), a finer-grained sibling of STATUS_LABELS above. Keyed on the wire's
* own lowercase, no-separator enum spelling (mirrors STATUS_LABELS/MODE_LABELS/KIND_LABELS' own
* convention); an unstyled cause this UI doesn't specifically label still shows its raw text. */
const CAUSE_LABELS: Record<string, string> = {
success: "Success",
timeout: "Timeout",
overlength: "Over length",
truthgatereject: "Truth-gate reject",
connectionfailure: "Connection failure",
canceledbywindow: "Canceled by window",
emptycompletion: "Empty completion",
malformedresponse: "Malformed response",
};

/** gh-#142: the MODE column is the F69/F70 degradation ladder, which nothing on the page said —
* a native-title flyover per chip explains the rung without spending any screen real estate.
* Wording tracks DegradationMode's own docs. gh-#210: these same three lines also feed the Mode
Expand Down Expand Up @@ -104,6 +119,14 @@ function KindChip({ kind }: { kind: string }): ReactNode {
return <Chip tone="neutral">{KIND_LABELS[kind] ?? kind}</Chip>;
}

/** SPEC F139.1, STORY-353, PLAN T334 — neutral tone, same reasoning as KindChip immediately
* above: Cause is supplementary detail alongside the Status chip's already-colored verdict
* ("failed" is danger; "timeout" just names which kind of failed it was), never a second,
* redundant severity judgment painted onto the same row. */
function CauseChip({ cause }: { cause: string }): ReactNode {
return <Chip tone="neutral">{CAUSE_LABELS[cause] ?? cause}</Chip>;
}

function ModeChip({ mode }: { mode: string }): ReactNode {
return (
<span title={MODE_TITLES[mode]}>
Expand All @@ -124,9 +147,10 @@ function DetailField({ label, value }: { label: string; value: string | null }):
}

/**
* The LLM call inspector's table (PLAN T41, STORY-196, SPEC F73.1-F73.2; persona column gh-#429):
* newest-first rows of time / persona / status chip / mode chip / elapsed / a truncated response
* preview, each expandable to the full system prompt, user prompt, and raw response text —
* The LLM call inspector's table (PLAN T41/T334, STORY-196/353, SPEC F73.1-F73.2, F139.1; persona
* column gh-#429): newest-first rows of time / persona / kind chip / status chip / cause chip /
* mode chip / elapsed / a truncated response preview, each expandable to the full system prompt,
* user prompt, and raw response text —
* admin-only debug detail, never persisted (see GenWave.Tts.LlmCallRing's own remarks). Loading/
* empty/error idioms match the booth log's own BoothLogFeed (skeleton rows, EmptyState, a quiet
* unavailable hint on a poll failure that keeps whatever was already loaded).
Expand Down Expand Up @@ -180,6 +204,11 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Persona</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Kind</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Status</th>
{/* SPEC F139.1, STORY-353, PLAN T334 — Cause is Status's own finer-grained sibling
("failed" -> "timeout"/"connectionfailure"/…), a column of its own rather than
folded into Status so an operator can still scan Status's coarse ok/failed
read without every row's cell text growing. */}
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">Cause</th>
<th scope="col" className="py-2 pr-3 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-accent-2">
{/* gh-#210: the header carries the house `?` flyover (gh-#145 pattern) — the
per-chip native titles above survive for hover users, but touch and anyone
Expand Down Expand Up @@ -225,6 +254,9 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
<td className="py-2 pr-3">
<StatusChip status={entry.status} />
</td>
<td className="py-2 pr-3">
<CauseChip cause={entry.cause} />
</td>
<td className="py-2 pr-3">
<ModeChip mode={entry.mode} />
</td>
Expand All @@ -245,7 +277,7 @@ export function LlmCallsFeed({ entries, error, timeZone }: LlmCallsFeedProps): R
</tr>
{isExpanded && (
<tr className="border-b border-line last:border-b-0">
<td colSpan={8} className="py-3">
<td colSpan={9} className="py-3">
<div className="space-y-3 rounded-[6px] border border-line bg-surface-2 p-3">
<DetailField label="System prompt" value={entry.promptSystem} />
<DetailField label="User prompt" value={entry.promptUser} />
Expand Down
4 changes: 3 additions & 1 deletion admin-ui/app/(authed)/booth-log/useLlmCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { fetchLlmCalls, type LlmCallEntry } from "@/lib/llm-calls-api";
const LLM_CALLS_POLL_INTERVAL_MS = 12000;

export interface UseLlmCallsResult {
/** Newest-first, exactly as the endpoint returns it. `null` until the first poll resolves. */
/** Newest-first, the `calls` half of the endpoint's own `{ calls, causeSummary }` response (SPEC
* F139.2, PLAN T334 — `fetchLlmCalls` unwraps it; see that function's own remarks for why
* `causeSummary` stops there and never reaches this hook). `null` until the first poll resolves. */
entries: LlmCallEntry[] | null;
/** True when the most recent poll failed; `entries` is left untouched (usePoll's contract — a
* caller renders a quiet degrade, never discards what's already loaded). */
Expand Down
Loading
Loading