diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index f784bd99..49804b38 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -113,7 +113,7 @@ test('Ctrl+K opens the command palette; ? lists the shortcuts', async ({ page }) }); // One
, one h1, the banner and the primary nav on every screen, not just Agents. -for (const route of ['#/traces', '#/traces/e2e-trace?obs=e2e-obs', '#/monitoring', '#/evals/scores', '#/settings', '#/playground']) { +for (const route of ['#/traces', '#/traces?status=error', '#/traces/e2e-trace?obs=e2e-obs', '#/monitoring', '#/evals/scores', '#/settings', '#/playground']) { test(`${route} keeps the landmarks and exactly one main`, async ({ page }) => { await page.goto(`/?token=e2e-studio-token${route}`); await expect(page.locator('.screen-main')).toBeVisible(); diff --git a/apps/agent-forge/src/observe/ObserveView.tsx b/apps/agent-forge/src/observe/ObserveView.tsx deleted file mode 100644 index c0cfb624..00000000 --- a/apps/agent-forge/src/observe/ObserveView.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import './observe.css'; -import { useState } from 'react'; -import { tabIds, tabListKeyDown } from '../components/tabs'; -import { useDaemonHealth } from './useObserve'; -import { ObserveDaemonDown } from './chrome'; -import type { TraceListFilter } from './observeClient'; -import { TracesPanel } from './TracesPanel'; -import { TraceDetailView } from './TraceDetailView'; -import { ScoresPanel } from './ScoresPanel'; - -const TABS = [ - { id: 'traces', label: 'Traces' }, - { id: 'scores', label: 'Scores' }, -] as const; -export type ObserveTab = (typeof TABS)[number]['id']; -const TAB_IDS: ObserveTab[] = TABS.map((t) => t.id); - -/** - * The observability surface - Traces and Scores - served by the observe - * daemon's read API through the same-origin `/observe` proxy. Cross-panel - * drill-down (score row -> its trace) is just shared state here. Monitoring - * (fleet + charts) is its own screen, `screens/monitoring`. - * - * Given `tab`, the route owns the panel: the tab strip is hidden (the left - * nav is the only switcher) and drill-downs ask `onTabChange` to navigate. - */ -export function ObserveView({ - tab: routeTab, - onTabChange, -}: { tab?: ObserveTab; onTabChange?: (tab: ObserveTab) => void } = {}) { - const health = useDaemonHealth(); - const [ownTab, setOwnTab] = useState('traces'); - const routed = routeTab !== undefined; - const tab = routeTab ?? ownTab; - const setTab = (next: ObserveTab) => (routed ? onTabChange?.(next) : setOwnTab(next)); - const [tracesFilter, setTracesFilter] = useState({}); - const [traceId, setTraceId] = useState(undefined); - - const drillToTrace = (id: string) => { - setTraceId(id); - setTab('traces'); - }; - - let body: React.ReactNode; - if (health.down) body = ; - else if (tab === 'traces' && traceId !== undefined) - body = setTraceId(undefined)} />; - else if (tab === 'traces') - body = ( - { - setTracesFilter(filter); - setTraceId(undefined); - }} - onOpenTrace={setTraceId} - /> - ); - else body = ; - - if (routed) - return ( -
-
{body}
-
- ); - - const ids = tabIds('observe', tab); - return ( -
-
- {TABS.map((t) => { - const selected = tab === t.id; - const tids = tabIds('observe', t.id); - return ( - - ); - })} -
-
- {body} -
-
- ); -} diff --git a/apps/agent-forge/src/observe/ScoresPanel.tsx b/apps/agent-forge/src/observe/ScoresPanel.tsx deleted file mode 100644 index 2d8ed95d..00000000 --- a/apps/agent-forge/src/observe/ScoresPanel.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useAgents, useScores } from './useObserve'; -import type { ObserveScoreRow } from './types'; -import { fmtDateTime, fmtInt, fmtPercent } from './format'; -import { AgentSelect, ObserveDaemonDown, ObserveError } from './chrome'; -import { ObserveDaemonDownError } from './observeClient'; - -const LIMIT = 200; -const DAY_MS = 86_400_000; -/** A numeric score at/above this counts as a pass for the per-eval trend (eval scores land 0..1). */ -const PASS_AT = 0.5; - -interface EvalTrend { - name: string; - count: number; - mean: number | null; - passRate: number | null; - /** Daily pass fractions, oldest first - the trend strip. */ - days: { start: number; passRate: number; count: number }[]; -} - -/** Group score rows by eval `name`: count, mean, overall pass rate and the per-day pass-rate strip. */ -function evalTrends(scores: ObserveScoreRow[]): EvalTrend[] { - const byName = new Map(); - for (const score of scores) { - const list = byName.get(score.name) ?? []; - list.push(score); - byName.set(score.name, list); - } - const trends: EvalTrend[] = []; - for (const [name, rows] of byName) { - const scored = rows.filter((row) => row.value !== null); - const mean = scored.length ? scored.reduce((sum, row) => sum + row.value!, 0) / scored.length : null; - const passRate = scored.length ? scored.filter((row) => row.value! >= PASS_AT).length / scored.length : null; - const byDay = new Map(); - for (const row of scored) { - const day = row.created_at - (row.created_at % DAY_MS); - const bucket = byDay.get(day) ?? { pass: 0, count: 0 }; - bucket.count += 1; - if (row.value! >= PASS_AT) bucket.pass += 1; - byDay.set(day, bucket); - } - const days = [...byDay.entries()] - .sort(([a], [b]) => a - b) - .map(([start, bucket]) => ({ start, passRate: bucket.pass / bucket.count, count: bucket.count })); - trends.push({ name, count: rows.length, mean, passRate, days }); - } - return trends.sort((a, b) => b.count - a.count); -} - -function TrendStrip({ trend }: { trend: EvalTrend }) { - return ( -
-
- {trend.name} - - {fmtInt(trend.count)} scores · mean {trend.mean === null ? '-' : trend.mean.toFixed(2)} · pass{' '} - {trend.passRate === null ? '-' : fmtPercent(trend.passRate)} - -
-
- {trend.days.map((day) => ( -
= PASS_AT ? ' pass' : ' fail'}`} - title={`${new Date(day.start).toLocaleDateString()} - ${fmtPercent(day.passRate)} pass (${day.count})`} - style={{ height: `${Math.max(15, day.passRate * 100)}%` }} - /> - ))} -
-
- ); -} - -/** - * `GET /api/v1/scores` - eval/judge/human score rows with the daemon's - * `name`/`agent_id`/`trace_id` filters, plus a per-eval-name pass-rate - * trend computed over the returned window. A score id links back to its - * trace (ObserveView opens {@link TraceDetailView}). - */ -export function ScoresPanel({ onOpenTrace }: { onOpenTrace: (traceId: string) => void }) { - const [name, setName] = useState(''); - const [agentId, setAgentId] = useState(''); - const agents = useAgents(); - const { data, error, loading } = useScores({ name: name || undefined, agent_id: agentId || undefined, limit: LIMIT }); - - const trends = useMemo(() => evalTrends(data ?? []), [data]); - const names = useMemo(() => [...new Set((data ?? []).map((s) => s.name))].sort(), [data]); - - if (error instanceof ObserveDaemonDownError) return ; - - return ( -
-
- - -
- - {loading && data === undefined ? ( -
Loading scores...
- ) : (data?.length ?? 0) === 0 ? ( -
-

No scores yet - eval results land here once a run's evals emit score events.

-
- ) : ( - <> - {trends.length > 0 && ( -
- {trends.map((trend) => ( - - ))} -
- )} -
- - - - - - - - - - - - - {data!.map((score) => ( - - - - - - - - - ))} - -
WhenEvalValueSourceTraceComment
{fmtDateTime(score.created_at)}{score.name} - {score.value === null ? '-' : score.value} - {score.source} - - {score.comment ?? ''}
-
- - )} -
- ); -} diff --git a/apps/agent-forge/src/observe/TraceDetailView.tsx b/apps/agent-forge/src/observe/TraceDetailView.tsx deleted file mode 100644 index b0105ee0..00000000 --- a/apps/agent-forge/src/observe/TraceDetailView.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useMemo, useState } from 'react'; -import { JsonView } from '../ui/JsonView'; -import { useTraceDetail } from './useObserve'; -import { flattenObservationTree, nestObservations, traceBounds, type ObservationNode } from './tree'; -import type { ObserveObservationRow, ObserveScoreRow } from './types'; -import { fmtCost, fmtDateTime, fmtDuration, fmtInt } from './format'; -import { ObserveError, ObserveDaemonDown } from './chrome'; -import { StatusPill } from '../ui/StatusPill'; -import { ObserveDaemonDownError } from './observeClient'; - -const KIND_COLOR: Record = { - span: 'var(--info)', - generation: 'var(--accent)', - tool: 'var(--warning)', - event: 'var(--text-faint)', -}; - -function classNames(...names: (string | false)[]): string { - return names.filter(Boolean).join(' '); -} - -function obsDuration(row: ObserveObservationRow): number | undefined { - return row.end === null ? undefined : row.end - row.start; -} - -function ObsRow({ - node, - minStart, - totalMs, - selected, - onSelect, -}: { - node: ObservationNode; - minStart: number; - totalMs: number; - selected: boolean; - onSelect: () => void; -}) { - const { row } = node; - const startPct = ((row.start - minStart) / totalMs) * 100; - const duration = obsDuration(row); - const widthPct = Math.max(0.5, ((duration ?? 0) / totalMs) * 100); - const failed = row.status === 'error'; - return ( -
e.key === 'Enter' && onSelect()} - title={row.name ?? row.obs_id} - > - - - {row.kind} - - - - {duration === undefined ? 'running' : `${Math.round(duration)}ms`} -
- ); -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-
{label}
-
{children}
-
- ); -} - -/** The right-hand inspector for the selected observation: io, usage/cost (flags kept honest), attrs. */ -function ObservationInspector({ row }: { row: ObserveObservationRow }) { - return ( -
-
- - {row.kind} - - {row.name ?? row.obs_id} - -
-
- {row.model && {row.model}} - {fmtDateTime(row.start)} - {row.end === null ? 'still running' : fmtDuration(row.end - row.start)} - {(row.usage_input_tokens !== null || row.usage_output_tokens !== null) && ( - - {fmtInt(row.usage_input_tokens)} in / {fmtInt(row.usage_output_tokens)} out - {row.usage_cached_tokens ? ` (${fmtInt(row.usage_cached_tokens)} cached)` : ''} - {row.usage_reasoning_tokens ? ` (${fmtInt(row.usage_reasoning_tokens)} reasoning)` : ''} - {row.usage_estimated ? ' · estimated' : ''} - - )} - {row.cost_usd !== null && ( - - {fmtCost(row.cost_usd)} - {row.cost_partial ? ' (partial)' : ''} - - )} - {row.input !== null && ( - -
{row.input}
-
- )} - {row.output !== null && ( - -
{row.output}
- {row.truncated &&
Truncated by the reporter at ~64KB.
} -
- )} - {row.attrs && ( - - - - )} -
-
- ); -} - -function ScoresBlock({ scores }: { scores: ObserveScoreRow[] }) { - if (scores.length === 0) return null; - return ( -
-
Scores
- - - - - - - - - - - {scores.map((score) => ( - - - - - - - ))} - -
NameValueSourceComment
{score.name}{score.value === null ? '-' : score.value}{score.source}{score.comment ?? ''}
-
- ); -} - -/** - * `GET /api/v1/traces/:id` - flat observation rows nested client-side by - * `parent_obs_id` ({@link nestObservations}), drawn as the same latency - * waterfall the live Trace tab uses, plus a per-observation inspector and - * the trace's score rows. - */ -export function TraceDetailView({ traceId, onBack }: { traceId: string; onBack: () => void }) { - const { data, error, loading } = useTraceDetail(traceId); - const [selectedObsId, setSelectedObsId] = useState(undefined); - - const flat = useMemo(() => { - if (!data) return []; - return flattenObservationTree(nestObservations(data.observations)); - }, [data]); - const bounds = useMemo(() => traceBounds(data?.observations ?? []), [data]); - const totalMs = Math.max(1, bounds.maxEnd - bounds.minStart); - const selected = flat.find((node) => node.row.obs_id === selectedObsId); - - if (loading && data === undefined) return
Loading trace...
; - if (error) return error instanceof ObserveDaemonDownError ? : ; - if (!data) { - return ( -
-
- -
-
-

No trace '{traceId}'.

-

Eval traces can be missing if the run's reporter never flushed.

-
-
- ); - } - - return ( -
-
- - - {data.trace_id.slice(0, 16)} - - - {flat.length} observations · {data.scores.length} scores - -
-
-
- {flat.map((node) => ( - setSelectedObsId(node.row.obs_id === selectedObsId ? undefined : node.row.obs_id)} - /> - ))} - -
- {selected && } -
-
- ); -} diff --git a/apps/agent-forge/src/observe/TracesPanel.tsx b/apps/agent-forge/src/observe/TracesPanel.tsx deleted file mode 100644 index bf045e72..00000000 --- a/apps/agent-forge/src/observe/TracesPanel.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { TraceListFilter } from './observeClient'; -import { useAgents, useTraces } from './useObserve'; -import type { ObserveTraceRow } from './types'; -import { fmtCost, fmtDateTime, fmtDuration, fmtInt } from './format'; -import { AgentSelect, ObserveError } from './chrome'; -import { StatusPill } from '../ui/StatusPill'; - -const PAGE_SIZE = 50; -const STATUS_OPTIONS = ['', 'ok', 'error', 'interrupted'] as const; - -function FilterBar({ - filter, - onChange, -}: { - filter: TraceListFilter; - onChange: (filter: TraceListFilter) => void; -}) { - const agents = useAgents(); - const projects = [...new Set((agents.data ?? []).map((a) => a.project).filter((p): p is string => p !== null))].sort(); - const set = (patch: Partial) => onChange({ ...filter, ...patch }); - - return ( -
- - - - {agents.error && } -
- ); -} - -function TraceRow({ trace, onOpen }: { trace: ObserveTraceRow; onOpen: () => void }) { - const duration = trace.end - trace.start; - return ( - e.key === 'Enter' && onOpen()}> - - {trace.name ?? {trace.trace_id.slice(0, 12)}} - - {fmtDateTime(trace.start)} - {fmtDuration(duration)} - - - - {fmtInt(trace.observations)} - - {fmtInt(trace.input_tokens + trace.output_tokens)} - - {fmtCost(trace.cost_usd)} - - ); -} - -/** - * `GET /api/v1/traces` - the paginated, filterable trace list (time-ordered, - * newest first). Clicking a row hands the trace id to ObserveView, which - * swaps in {@link TraceDetailView}. - */ -export function TracesPanel({ - filter, - onFilterChange, - onOpenTrace, -}: { - filter: TraceListFilter; - onFilterChange: (filter: TraceListFilter) => void; - onOpenTrace: (traceId: string) => void; -}) { - const [offset, setOffset] = useState(0); - // Any filter edit restarts pagination at the first page. - useEffect(() => setOffset(0), [JSON.stringify(filter)]); - const query = useTraces({ ...filter, limit: PAGE_SIZE, offset }); - const { data, error, loading } = query; - const total = data?.total ?? 0; - const page = Math.floor(offset / PAGE_SIZE) + 1; - const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); - - return ( -
- - - {loading && data === undefined ? ( -
Loading traces...
- ) : (data?.traces.length ?? 0) === 0 ? ( -
-

No traces match this filter. Traces appear once an agent with LOUSHO_OBSERVE=1 finishes a run.

-
- ) : ( - <> -
- - - - - - - - - - - - - - {data!.traces.map((trace) => ( - onOpenTrace(trace.trace_id)} /> - ))} - -
NameStartedDurationStatusSpansTokensCost
-
-
- - - Page {page} of {pages} · {fmtInt(total)} traces - - -
- - )} -
- ); -} diff --git a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx b/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx deleted file mode 100644 index 3bd5dd2e..00000000 --- a/apps/agent-forge/src/observe/__tests__/ObserveView.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { ObserveView } from '../ObserveView'; -import { TraceDetailView } from '../TraceDetailView'; - -(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -async function flush() { - await act(async () => { - await Promise.resolve(); - }); -} - -describe('ObserveView', () => { - let root: Root | undefined; - let container: HTMLDivElement | undefined; - - afterEach(() => { - act(() => root?.unmount()); - container?.remove(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('shows the `lousho observe start` hint when the daemon is unreachable', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new TypeError('fetch failed'); - }) - ); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root!.render(); - }); - await flush(); - expect(container.textContent).toContain('lousho observe start'); - expect(container.querySelector('[role="status"]')).not.toBeNull(); - }); - - it('names the proxy target in the down hint when the daemon is on another port', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - if (String(input).endsWith('/__target')) { - return new Response(JSON.stringify({ url: 'http://127.0.0.1:7795' }), { status: 200 }); - } - throw new TypeError('fetch failed'); - }) - ); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root!.render(); - }); - await flush(); - await flush(); - expect(container.textContent).toContain('LOUSHO_OBSERVE=http://127.0.0.1:7795'); - }); - - it('renders the trace list once the daemon answers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith('/health')) { - return new Response(JSON.stringify({ service: 'lousho-observe', ok: true }), { status: 200 }); - } - if (url.includes('/api/v1/agents')) { - return new Response(JSON.stringify({ agents: [] }), { status: 200 }); - } - return new Response(JSON.stringify({ traces: [], total: 0, limit: 50, offset: 0 }), { status: 200 }); - }) - ); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root!.render(); - }); - await flush(); - await flush(); - expect(container.textContent).not.toContain('the observe daemon is not reachable'); - expect(container.textContent).toContain('No traces match'); - }); - - it('a missing trace keeps a Traces button that goes back, plus the flush hint', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(JSON.stringify({ error: "No trace 'gone'." }), { status: 404 })) - ); - const onBack = vi.fn(); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root!.render(); - }); - await flush(); - await flush(); - expect(container.textContent).toContain("No trace 'gone'"); - expect(container.textContent).toContain('reporter never flushed'); - const back = Array.from(container.querySelectorAll('button')).find((b) => /Traces/.test(b.textContent ?? '')); - expect(back).toBeDefined(); - act(() => back!.click()); - expect(onBack).toHaveBeenCalledTimes(1); - }); - - function stubDaemon() { - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith('/health')) { - return new Response(JSON.stringify({ service: 'lousho-observe', ok: true }), { status: 200 }); - } - if (url.includes('/api/v1/agents')) { - return new Response(JSON.stringify({ agents: [] }), { status: 200 }); - } - if (url.includes('/api/v1/insights')) { - return new Response(JSON.stringify({ window: { since: 0, until: 1 }, agents: [], series: [] }), { status: 200 }); - } - return new Response(JSON.stringify({ traces: [], total: 0, limit: 50, offset: 0 }), { status: 200 }); - }) - ); - } - - async function renderView(node: React.ReactElement) { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root!.render(node); - }); - await flush(); - await flush(); - } - - it('hides its own tab strip when the route picks the panel', async () => { - stubDaemon(); - await renderView( {}} />); - expect(container!.querySelector('[role="tablist"]')).toBeNull(); - expect(container!.textContent).toContain('No traces match'); - }); -}); diff --git a/apps/agent-forge/src/observe/__tests__/chrome.test.tsx b/apps/agent-forge/src/observe/__tests__/chrome.test.tsx new file mode 100644 index 00000000..35b850b1 --- /dev/null +++ b/apps/agent-forge/src/observe/__tests__/chrome.test.tsx @@ -0,0 +1,48 @@ +import { act } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ObserveDaemonDown } from '../chrome'; +import { render } from '../../ui/__tests__/render'; + +async function flush() { + for (let i = 0; i < 3; i++) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('ObserveDaemonDown', () => { + it('shows the `lousho observe start` hint as a status region', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('fetch failed'); + }) + ); + const container = render(); + await flush(); + expect(container.textContent).toContain('lousho observe start'); + expect(container.textContent).toContain('LOUSHO_OBSERVE=1'); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + }); + + it('names the proxy target when the daemon is on another port', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/__target')) { + return new Response(JSON.stringify({ url: 'http://127.0.0.1:7795' }), { status: 200 }); + } + throw new TypeError('fetch failed'); + }) + ); + const container = render(); + await flush(); + expect(container.textContent).toContain('LOUSHO_OBSERVE=http://127.0.0.1:7795'); + }); +}); diff --git a/apps/agent-forge/src/observe/__tests__/observeClient.test.ts b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts index 7c9c9908..3278ff4c 100644 --- a/apps/agent-forge/src/observe/__tests__/observeClient.test.ts +++ b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts @@ -17,6 +17,16 @@ describe('ObserveClient', () => { expect(fetchFn).toHaveBeenCalledWith('/observe/api/v1/traces?agent_id=a1&status=error&limit=25&offset=50', expect.anything()); }); + it('passes the O12 window, search, model and sort params through', async () => { + const fetchFn = vi.fn(async () => jsonResponse(200, { traces: [], total: 0, limit: 50, offset: 0 })); + const client = clientWith(fetchFn as typeof fetch); + await client.listTraces({ from: 1000, to: 2000, q: 'refund', min_duration_ms: 500, model: 'gpt-4o', sort: 'duration', order: 'asc' }); + expect(fetchFn).toHaveBeenCalledWith( + '/observe/api/v1/traces?from=1000&to=2000&q=refund&min_duration_ms=500&model=gpt-4o&sort=duration&order=asc', + expect.anything() + ); + }); + it('maps a proxy 502 and a network failure to ObserveDaemonDownError', async () => { const via502 = clientWith((async () => jsonResponse(502, { error: 'unreachable' })) as typeof fetch); await expect(via502.listTraces()).rejects.toBeInstanceOf(ObserveDaemonDownError); diff --git a/apps/agent-forge/src/observe/format.ts b/apps/agent-forge/src/observe/format.ts index 6760e30c..676add23 100644 --- a/apps/agent-forge/src/observe/format.ts +++ b/apps/agent-forge/src/observe/format.ts @@ -30,6 +30,14 @@ export function fmtDateTime(epochMs: number | null | undefined): string { return `${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${d.toTimeString().slice(0, 5)}`; } +/** Local `15:29:30`, prefixed with `Oct 24` when the day is not `now`'s day. */ +export function fmtStarted(epochMs: number, now: number = Date.now()): string { + const d = new Date(epochMs); + const time = d.toTimeString().slice(0, 8); + if (d.toDateString() === new Date(now).toDateString()) return time; + return `${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} ${time}`; +} + /** `3s ago` / `4m ago` / `2h ago` / `1d ago` for the fleet heartbeat column. */ export function fmtAge(ageMs: number | null | undefined): string { if (ageMs === null || ageMs === undefined) return 'never'; diff --git a/apps/agent-forge/src/observe/observe.css b/apps/agent-forge/src/observe/observe.css index 8d276459..96d74255 100644 --- a/apps/agent-forge/src/observe/observe.css +++ b/apps/agent-forge/src/observe/observe.css @@ -1,144 +1,8 @@ /* - * Observe view (Traces / Scores) - styles for the - * panels reading the observe daemon. Reuses ui/base.css's primitives (.btn, - * .select, .status-pill, .trace-row/.trace-bar waterfall, .agent-card) and - * only adds what the panels themselves need. + * Shared observe chrome (observe/chrome.tsx): the daemon-down empty state and + * inline API errors, used by the Traces, Trace, Monitoring and Scores screens. */ -.observe { - display: flex; - flex-direction: column; - min-height: 0; - min-width: 0; - background: var(--bg); -} - -/* The same tab chrome as the bottom drawer. */ -.observe-tabs { - display: flex; - align-items: center; - border-bottom: 1px solid var(--border); - background: var(--surface); - flex: none; - padding: 0 10px; -} -.observe-tab { - padding: 9px 14px; - font-size: 12px; - font-weight: 600; - color: var(--text-muted); - cursor: pointer; - border-bottom: 2px solid transparent; - background: none; - border-left: none; - border-right: none; - border-top: none; -} -.observe-tab.active { - color: var(--text); - border-bottom-color: var(--accent); -} -.observe-tab:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; -} - -.observe-body { - flex: 1; - min-height: 0; - overflow-y: auto; -} -.observe-panel { - padding: 12px 16px; - max-width: 1200px; -} - -.observe-filter-bar { - display: flex; - align-items: flex-end; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 12px; -} -.observe-filter-bar label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 11px; - font-weight: 600; - color: var(--text-muted); -} -.observe-filter-bar .select { - width: auto; - min-width: 150px; -} - -.observe-table-wrap { - border: 1px solid var(--border); - border-radius: 9px; - overflow: hidden; - background: var(--surface); -} -.observe-table { - width: 100%; - border-collapse: collapse; - font-size: 12.5px; -} -.observe-table th { - text-align: left; - color: var(--text-faint); - font-weight: 600; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.4px; - padding: 7px 12px; - border-bottom: 1px solid var(--border); - background: var(--surface-2); -} -.observe-table td { - color: var(--text); - padding: 6px 12px; - border-bottom: 1px solid var(--border); - vertical-align: middle; -} -.observe-table tbody tr:last-child td { - border-bottom: none; -} -.observe-table.clickable tbody tr { - cursor: pointer; -} -.observe-table tbody tr:hover { - background: var(--surface-2); -} -.observe-num { - text-align: right; - font-family: var(--font-mono); - font-size: 11.5px; -} -.observe-cell-name { - font-weight: 600; - max-width: 260px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.observe-cell-muted { - color: var(--text-muted); - max-width: 320px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.observe-id { - font-family: var(--font-mono); - font-size: 11px; - color: var(--text-muted); -} -.observe-meta { - color: var(--text-faint); - font-size: 11.5px; -} - .observe-empty { max-width: 560px; margin: 48px auto; @@ -163,10 +27,6 @@ border-radius: 5px; color: var(--text); } -.observe-loading { - padding: 24px; - color: var(--text-faint); -} .observe-error { margin: 8px 0; padding: 8px 12px; @@ -176,157 +36,4 @@ color: var(--danger); font-size: 12px; } -.observe-pager { - display: flex; - align-items: center; - gap: 12px; - margin-top: 10px; - color: var(--text-muted); - font-size: 12px; -} -.observe-pager span { - flex: 1; - text-align: center; -} -.observe-panel-head { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 16px; - border-bottom: 1px solid var(--border); - background: var(--surface); - flex: none; -} -/* ---------- Trace detail: observation waterfall + inspector ---------- */ -.obs-detail { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} -.obs-detail-body { - flex: 1; - min-height: 0; - display: flex; -} -.obs-waterfall { - flex: 1; - min-width: 0; - overflow-y: auto; - padding: 8px; -} -.obs-row .trace-name { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; -} -.obs-kind-dot { - width: 8px; - height: 8px; - border-radius: 3px; - flex: none; -} -.obs-inspector { - width: 340px; - flex: none; - border-left: 1px solid var(--border); - background: var(--surface); - overflow-y: auto; - padding: 12px; -} -.obs-inspector-head { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - margin-bottom: 10px; -} -.obs-inspector-head b { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} -.obs-kind { - font-family: var(--font-mono); - font-size: 11px; - font-weight: 700; - text-transform: uppercase; -} -.obs-field { - margin-bottom: 12px; -} -.obs-field-label { - font-size: 10.5px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-faint); - margin-bottom: 4px; -} -.obs-field-value { - font-size: 12px; - color: var(--text); -} -.obs-io { - margin: 0; - padding: 8px; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: 7px; - font-family: var(--font-mono); - font-size: 11px; - white-space: pre-wrap; - word-break: break-word; - max-height: 220px; - overflow-y: auto; -} -.obs-scores { - margin-top: 14px; -} - -/* Low scores in the Scores table. */ -.insight-bad { - color: var(--danger); -} - -/* ---------- Scores ---------- */ -.score-trends { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 10px; - margin-bottom: 14px; -} -.score-trend { - border: 1px solid var(--border); - border-radius: 9px; - background: var(--surface); - padding: 10px 12px; -} -.score-trend-head { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 8px; - margin-bottom: 8px; - font-size: 12.5px; -} -.score-trend-strip { - display: flex; - align-items: flex-end; - gap: 2px; - height: 36px; -} -.score-trend-day { - flex: 1; - min-width: 4px; - border-radius: 2px 2px 0 0; -} -.score-trend-day.pass { - background: var(--success); -} -.score-trend-day.fail { - background: var(--danger); -} diff --git a/apps/agent-forge/src/observe/observeClient.ts b/apps/agent-forge/src/observe/observeClient.ts index 3172f10b..760b7a88 100644 --- a/apps/agent-forge/src/observe/observeClient.ts +++ b/apps/agent-forge/src/observe/observeClient.ts @@ -60,7 +60,18 @@ export interface ObserveClientOptions { fetchFn?: typeof fetch; } -export interface TraceListFilter { +/** The O12 trace-list params: a half-open `[from, to)` window (ms epoch), search over the root's name/input/output, and server-side order (daemon default `start` + `desc`). */ +interface TraceListQuery { + from?: number; + to?: number; + q?: string; + min_duration_ms?: number; + model?: string; + sort?: TraceSort; + order?: 'asc' | 'desc'; +} + +export interface TraceListFilter extends TraceListQuery { agent_id?: string; project?: string; status?: string; @@ -71,6 +82,9 @@ export interface TraceListFilter { offset?: number; } +/** The trace list's server-side sort keys. */ +export type TraceSort = 'start' | 'duration' | 'tokens' | 'cost'; + export interface ScoreListFilter { trace_id?: string; /** Exact score name (`` is the eval-level row of each case). */ diff --git a/apps/agent-forge/src/observe/types.ts b/apps/agent-forge/src/observe/types.ts index aafdf988..82048c9d 100644 --- a/apps/agent-forge/src/observe/types.ts +++ b/apps/agent-forge/src/observe/types.ts @@ -29,6 +29,16 @@ export interface ObserveTraceRow { input_tokens: number; output_tokens: number; cost_usd: number; + /** The agent's registered name, or null. */ + agent_name: string | null; + /** The first generation's model (by start), or null. */ + model: string | null; + /** The first failed observation's error text, or null. */ + error_message: string | null; + /** The root observation's input, first 200 chars. */ + input_preview: string | null; + /** The root observation's output, first 200 chars. */ + output_preview: string | null; } export interface ObserveTraceList { diff --git a/apps/agent-forge/src/observe/useObserve.ts b/apps/agent-forge/src/observe/useObserve.ts index 89c9111d..d5a4e7b2 100644 --- a/apps/agent-forge/src/observe/useObserve.ts +++ b/apps/agent-forge/src/observe/useObserve.ts @@ -105,10 +105,6 @@ export function useInsights(filter: InsightsFilter, refreshKey?: unknown): Obser return useObserveQuery(() => observeClient.insights(filter), [JSON.stringify(filter), refreshKey]); } -export function useScores(filter: ScoreListFilter, refreshKey?: unknown): ObserveQuery { - return useObserveQuery(() => observeClient.listScores(filter), [JSON.stringify(filter), refreshKey]); -} - /** * One eval's score rows (`eval=` plus `filter`); no request while * `evalName` is empty. `data` is only ever rows of the current `evalName`: diff --git a/apps/agent-forge/src/screens/routes.tsx b/apps/agent-forge/src/screens/routes.tsx index 02dde3a4..fc9589ce 100644 --- a/apps/agent-forge/src/screens/routes.tsx +++ b/apps/agent-forge/src/screens/routes.tsx @@ -3,7 +3,7 @@ import { matchRoute, navigate } from '../router/router'; import { useRoute } from '../router/useRoute'; import { Topbar } from '../components/Topbar'; import { SettingsPanel } from '../components/SettingsPanel'; -import { ObserveView, type ObserveTab } from '../observe/ObserveView'; +import { TracesScreen } from './traces/TracesScreen'; import { AgentsScreen } from './agents/AgentsScreen'; import { TraceScreen } from './traces/TraceScreen'; import { MonitoringScreen } from './monitoring/MonitoringScreen'; @@ -65,13 +65,6 @@ export function useScreen(): ScreenId { return screen ?? 'agents'; } -/** Until its Phase 3 screen lands, Traces is the existing observe panel (Scores has its own screen now). */ -const OBSERVE_TAB: Partial> = { traces: 'traces' }; -const TAB_PATH: Record = { - traces: '/traces', - scores: '/evals/scores', -}; - const PLACEHOLDERS: Partial> = { threads: { title: 'Threads', blurb: 'Multi-turn conversations grouped by session, with every run in each thread.' }, evals: { title: 'Evaluations', blurb: 'Datasets, evaluators and annotation queues for scoring runs.' }, @@ -102,15 +95,13 @@ export function Screens() { const screen = useLeaveAgentsGuard(useScreen()); if (screen === 'agents') return ; - const observeTab = OBSERVE_TAB[screen]; const placeholder = PLACEHOLDERS[screen]; let content: ReactNode; - // Same element type at the same position for all three, so the drill-down filter survives a route change. - if (screen === 'trace') content = ; + if (screen === 'traces') content = ; + else if (screen === 'trace') content = ; else if (screen === 'monitoring') content = ; else if (screen === 'scores') content = ; else if (screen === 'playground') content = ; - else if (observeTab) content = navigate(TAB_PATH[tab])} />; else if (placeholder) content = ; else content = ( diff --git a/apps/agent-forge/src/screens/traces/TraceScreen.tsx b/apps/agent-forge/src/screens/traces/TraceScreen.tsx index 5f4e8490..9393ad8d 100644 --- a/apps/agent-forge/src/screens/traces/TraceScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TraceScreen.tsx @@ -7,6 +7,7 @@ import { useAgents, useDaemonDown, useDaemonHealth, useTraceDetail } from '../.. import { navigate } from '../../router/router'; import { useQueryState } from '../../router/useRoute'; import { EmptyState } from '../../ui/EmptyState'; +import { tracesListPath } from './tracesUrl'; import { TraceViewer, type TraceViewMode } from '../../ui/run/TraceViewer'; import { TraceFeedback } from './TraceFeedback'; import { TraceHeader } from './TraceHeader'; @@ -41,7 +42,7 @@ function MissingTrace({ traceId }: { traceId: string }) { title="Trace not found" hint={`No trace '${traceId}'. Eval traces can be missing if the run's reporter never flushed.`} action={ - } diff --git a/apps/agent-forge/src/screens/traces/TracesScreen.tsx b/apps/agent-forge/src/screens/traces/TracesScreen.tsx new file mode 100644 index 00000000..9fb44603 --- /dev/null +++ b/apps/agent-forge/src/screens/traces/TracesScreen.tsx @@ -0,0 +1,192 @@ +/** + * `#/traces` - the trace list: search, facets, time range, server-side sort and + * paging over `GET /api/v1/traces`. A row opens `#/traces/:traceId` (TraceScreen). + * + * All list state lives in the URL query (see `tracesUrl.ts` for the keys). + * Filter, sort and page edits replace the current history entry, so Back + * leaves the list instead of walking through filter edits; opening a trace + * pushes an entry, so Back returns to the list. Both routes sit behind the + * daemon-down state, which keeps the URL and swaps the screen back in by + * itself once the health probe sees the daemon. + */ +import './traces.css'; +import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import { navigate } from '../../router/router'; +import { useRoute } from '../../router/useRoute'; +import { useAgents, useDaemonHealth, useTraces } from '../../observe/useObserve'; +import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; +import { ObserveDaemonDownError, type TraceSort } from '../../observe/observeClient'; +import type { ObserveAgentRow, ObserveTraceRow } from '../../observe/types'; +import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format'; +import { DataTable, type Column, type TableSort } from '../../ui/DataTable'; +import { FilterBar, type FacetDef } from '../../ui/FilterBar'; +import { formatTimeRange, toWindow, type TimeRange } from '../../ui/TimeRangePicker'; +import { EmptyState } from '../../ui/EmptyState'; +import { StatusPill } from '../../ui/StatusPill'; +import { + PAGE_SIZE, + formatTracesQuery, + parseTracesQuery, + rememberListQuery, + toTraceFilter, + type TracesUrlState, +} from './tracesUrl'; + +const STATUSES = ['ok', 'error', 'running', 'interrupted']; +const SORTABLE: readonly string[] = ['duration', 'tokens', 'cost']; + +/** A preview cell: one line, ellipsised by CSS, the full text in `title`. */ +function Clip({ text, className = 'traces-clip' }: { text: string | null; className?: string }) { + if (!text) return null; + return ( + + {text} + + ); +} + +function columns(now: number): Column[] { + return [ + { key: 'status', header: 'Status', width: 110, render: (t) => }, + { + key: 'name', + header: 'Name', + width: 160, + render: (t) => , + }, + { key: 'agent', header: 'Agent', width: 130, hideBelow: 720, render: (t) => }, + { key: 'input', header: 'Input', width: 200, hideBelow: 900, render: (t) => }, + { key: 'output', header: 'Output', width: 200, hideBelow: 1100, render: (t) => }, + { + key: 'error', + header: 'Error', + width: 200, + render: (t) => , + }, + { key: 'model', header: 'Model', width: 130, hideBelow: 1000, render: (t) => }, + { key: 'started', header: 'Started', width: 130, render: (t) => fmtStarted(t.start, now) }, + { key: 'duration', header: 'Duration', align: 'right', sortable: true, width: 96, render: (t) => fmtDuration(t.end - t.start) }, + { + key: 'tokens', + header: 'Tokens', + align: 'right', + sortable: true, + width: 90, + render: (t) => ( + + {fmtInt(t.input_tokens + t.output_tokens)} + + ), + }, + { key: 'cost', header: 'Cost', align: 'right', sortable: true, width: 84, render: (t) => fmtCost(t.cost_usd) }, + ]; +} + +/** Single-value facet over FilterBar's multi-select: the newest pick wins, clearing it drops the filter. */ +function facet(key: string, label: string, options: string[], value: string | undefined, set: (v: string | undefined) => void): FacetDef { + const all = value && !options.includes(value) ? [value, ...options] : options; + return { + key, + label, + options: all.map((o) => ({ value: o, label: o })), + value: value ? [value] : [], + onChange: (next) => set(next.find((v) => v !== value) ?? (value && next.includes(value) ? value : undefined)), + }; +} + +const uniqueSorted = (values: (string | null | undefined)[]): string[] => + [...new Set(values.filter((v): v is string => Boolean(v)))].sort(); + +function agentFacet(agents: ObserveAgentRow[], value: string | undefined, set: (v: string | undefined) => void): FacetDef { + const def = facet('agent', 'Agent', agents.map((a) => a.agent_id), value, set); + const names = new Map(agents.map((a) => [a.agent_id, a.name ?? a.agent_id.slice(0, 8)])); + return { ...def, options: def.options.map((o) => ({ ...o, label: names.get(o.value) ?? o.value })) }; +} + +/** + * The picker's value. The URL only holds absolute bounds; a preset picked here + * is remembered alongside the bounds it produced, so the picker keeps showing + * "7d" until the window changes (a reload then shows it as Custom). + */ +function rangeValue(state: TracesUrlState, picked: MutableRefObject<{ range: TimeRange; key: string } | undefined>): TimeRange { + if (!state.window) return { preset: '24h' }; + const key = formatTimeRange(state.window); + return picked.current?.key === key ? picked.current.range : state.window; +} + +function TracesList() { + const route = useRoute(); + const queryString = route.query.toString(); + const state = useMemo(() => parseTracesQuery(route.query), [queryString]); + // One "now" per URL, so the rolling default window doesn't refetch every render. + const now = useMemo(() => Date.now(), [queryString]); + const filter = useMemo(() => toTraceFilter(state, now), [state, now]); + const traces = useTraces(filter); + const agents = useAgents(); + const picked = useRef<{ range: TimeRange; key: string } | undefined>(undefined); + + useEffect(() => rememberListQuery(formatTracesQuery(state)), [state]); + + // Every filter edit restarts at the first page; replace keeps Back off the filter edits. + const update = (patch: Partial) => + navigate(`/traces${formatTracesQuery({ ...state, offset: 0, ...patch })}`, { replace: true }); + const setText = (key: 'agent' | 'status' | 'model' | 'project' | 'q') => (v: string | undefined) => update({ [key]: v || undefined }); + + const onRange = (r: TimeRange) => { + if ('preset' in r && r.preset === '24h') { + picked.current = undefined; + update({ window: undefined }); + return; + } + const window = toWindow(r, Date.now()); + picked.current = { range: r, key: formatTimeRange(window) }; + update({ window }); + }; + const onSort = (next: TableSort | undefined) => + update({ sort: next && SORTABLE.includes(next.key) ? { key: next.key as TraceSort, order: next.order } : undefined }); + + const rows = traces.data?.traces ?? []; + const agentRows = agents.data ?? []; + const facets = [ + agentFacet(agentRows, state.agent, setText('agent')), + facet('status', 'Status', STATUSES, state.status, setText('status')), + facet('model', 'Model', uniqueSorted(rows.map((t) => t.model)), state.model, setText('model')), + facet('project', 'Project', uniqueSorted(agentRows.map((a) => a.project)), state.project, setText('project')), + ]; + if (traces.error instanceof ObserveDaemonDownError) return ; + + return ( +
+

Traces

+ setText('q')(q.trim()), placeholder: 'Search name, input or output' }} + facets={facets} + range={{ value: rangeValue(state, picked), onChange: onRange }} + /> + + t.trace_id} + sort={state.sort && state.sort.key !== 'start' ? state.sort : undefined} + onSortChange={onSort} + onRowOpen={(t) => navigate(`/traces/${encodeURIComponent(t.trace_id)}`)} + page={{ offset: state.offset, limit: PAGE_SIZE, total: traces.data?.total ?? 0, onChange: (offset) => update({ offset }) }} + loading={traces.loading} + empty={ + + } + /> +
+ ); +} + +/** `#/traces`, behind the daemon-down state. */ +export function TracesScreen() { + const health = useDaemonHealth(); + return health.down ? : ; +} diff --git a/apps/agent-forge/src/screens/traces/__tests__/TracesScreen.test.tsx b/apps/agent-forge/src/screens/traces/__tests__/TracesScreen.test.tsx new file mode 100644 index 00000000..0ac429e8 --- /dev/null +++ b/apps/agent-forge/src/screens/traces/__tests__/TracesScreen.test.tsx @@ -0,0 +1,151 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TracesScreen } from '../TracesScreen'; +import { render } from '../../../ui/__tests__/render'; +import type { ObserveTraceRow } from '../../../observe/types'; + +const NOW = new Date(2026, 9, 25, 15, 30, 0).getTime(); + +const TRACE_OK: ObserveTraceRow = { + trace_id: 'trace-ok', + name: 'answer', + agent_id: 'agent-1', + agent_name: 'support-bot', + session_id: null, + start: NOW - 60_000, + end: NOW - 58_800, + status: 'ok', + observations: 3, + generations: 1, + tools: 1, + errors: 0, + input_tokens: 120, + output_tokens: 30, + cost_usd: 0.0021, + model: 'gpt-4o-mini', + error_message: null, + input_preview: 'Where is my order?', + output_preview: 'It ships tomorrow.', +}; + +const TRACE_ERR: ObserveTraceRow = { + ...TRACE_OK, + trace_id: 'trace-err', + name: 'refund', + start: NOW - 30_000, + end: NOW - 25_000, + status: 'error', + errors: 1, + error_message: 'Tool refund_lookup timed out', + output_preview: null, +}; + +let fetchFn: ReturnType>>; + +/** Daemon stub: healthy, one agent, and the two traces above for any list query. */ +function stubDaemon() { + fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + if (url.endsWith('/health')) return json({ service: 'lousho-observe', ok: true }); + if (url.includes('/api/v1/agents')) return json({ agents: [{ agent_id: 'agent-1', name: 'support-bot', project: 'shop' }] }); + if (url.includes('/api/v1/traces/')) return json({ trace_id: 'x', observations: [], scores: [] }); + return json({ traces: [TRACE_ERR, TRACE_OK], total: 2, limit: 50, offset: 0 }); + }); + vi.stubGlobal('fetch', fetchFn); +} + +/** The list requests the screen sent, as parsed query strings. */ +const listRequests = () => + fetchFn.mock.calls + .map(([input]) => new URL(String(input), 'http://x')) + .filter((u) => u.pathname.endsWith('/api/v1/traces')) + .map((u) => u.searchParams); + +async function flush() { + for (let i = 0; i < 4; i++) { + await act(async () => { + // hashchange is dispatched as a task, so wait one macrotask, not just microtasks. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +async function mountAt(hash: string) { + window.history.replaceState(null, '', `/${hash}`); + const container = render(); + await flush(); + return container; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + stubDaemon(); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); +}); + +describe('TracesScreen', () => { + it('reproduces status and time range filters from the URL on load', async () => { + const from = NOW - 3_600_000; + const to = NOW - 600_000; + const container = await mountAt(`#/traces?status=error&from=${from}&to=${to}`); + const last = listRequests().at(-1)!; + expect(last.get('status')).toBe('error'); + expect(last.get('from')).toBe(String(from)); + expect(last.get('to')).toBe(String(to)); + expect(last.get('limit')).toBe('50'); + // The status facet shows one selection and the picker shows the absolute window as Custom. + const statusBtn = [...container.querySelectorAll('.facet-btn')].find((b) => b.textContent?.startsWith('Status')); + expect(statusBtn?.querySelector('.facet-count')?.textContent).toBe('1'); + const custom = [...container.querySelectorAll('.seg-btn')].find((b) => b.textContent === 'Custom'); + expect(custom?.getAttribute('aria-pressed')).toBe('true'); + }); + + it('shows the error_message of a failed run in its row, in the danger style', async () => { + const container = await mountAt('#/traces'); + const row = [...container.querySelectorAll('tbody tr')].find((tr) => tr.textContent?.includes('refund'))!; + const error = row.querySelector('.traces-error'); + expect(error?.textContent).toBe('Tool refund_lookup timed out'); + expect(error?.getAttribute('title')).toBe('Tool refund_lookup timed out'); + // The ok row has no error text. + const ok = [...container.querySelectorAll('tbody tr')].find((tr) => tr.textContent?.includes('answer'))!; + expect(ok.querySelector('.traces-error')).toBeNull(); + // Started has seconds; previews keep the full text in a title. + expect(row.textContent).toContain('15:29:30'); + expect(ok.querySelector('[title="Where is my order?"]')).not.toBeNull(); + }); + + it('sorting by duration requests sort=duration&order=desc and writes it to the URL', async () => { + const container = await mountAt('#/traces'); + // A bare #/traces stays bare, and asks for the rolling 24h window. + expect(window.location.hash).toBe('#/traces'); + const first = listRequests().at(-1)!; + expect(first.get('from')).toBe(String(NOW - 86_400_000)); + expect(first.get('sort')).toBeNull(); + + const button = [...container.querySelectorAll('th button.dt-sort')].find((b) => b.textContent?.startsWith('Duration'))!; + act(() => (button as HTMLButtonElement).click()); + await flush(); + const last = listRequests().at(-1)!; + expect(last.get('sort')).toBe('duration'); + expect(last.get('order')).toBe('desc'); + expect(window.location.hash).toBe('#/traces?sort=duration&order=desc'); + }); + + it('Enter on a focused row opens #/traces/:id', async () => { + const container = await mountAt('#/traces?status=error'); + const row = container.querySelector('tbody tr[data-row-index="0"]')!; + row.focus(); + act(() => { + row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + await flush(); + expect(window.location.hash).toBe('#/traces/trace-err'); + }); +}); diff --git a/apps/agent-forge/src/screens/traces/__tests__/tracesUrl.test.ts b/apps/agent-forge/src/screens/traces/__tests__/tracesUrl.test.ts new file mode 100644 index 00000000..f9404f41 --- /dev/null +++ b/apps/agent-forge/src/screens/traces/__tests__/tracesUrl.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTracesQuery, parseTracesQuery, rememberListQuery, toTraceFilter, tracesListPath } from '../tracesUrl'; + +const parse = (qs: string) => parseTracesQuery(new URLSearchParams(qs)); + +describe('tracesUrl', () => { + it('round-trips every key in a stable order', () => { + const qs = '?agent=a1&status=error&model=m&project=p&q=refund&from=10&to=20&sort=cost&order=asc&offset=50'; + expect(formatTracesQuery(parse(qs.slice(1)))).toBe(qs); + }); + + it('leaves the defaults out, so a default state is a bare #/traces', () => { + expect(formatTracesQuery({ offset: 0 })).toBe(''); + expect(formatTracesQuery({ offset: 0, sort: { key: 'start', order: 'desc' } })).toBe(''); + }); + + it('drops malformed values instead of sending them to the daemon', () => { + const state = parse('from=20&to=10&sort=nope&offset=-5&status='); + expect(state).toEqual({ offset: 0, window: undefined, sort: undefined }); + expect(parse('sort=tokens&order=sideways').sort).toEqual({ key: 'tokens', order: 'desc' }); + }); + + it('resolves the default rolling 24h window against now', () => { + const filter = toTraceFilter({ offset: 0 }, 100_000_000); + expect(filter).toMatchObject({ from: 100_000_000 - 86_400_000, to: 100_000_000, limit: 50, offset: 0 }); + expect(toTraceFilter(parse('from=1&to=2&agent=x'), 5)).toMatchObject({ from: 1, to: 2, agent_id: 'x' }); + }); + + it("remembers the list's last query for the trace screen's way back", () => { + rememberListQuery('?status=error'); + expect(tracesListPath()).toBe('/traces?status=error'); + rememberListQuery(''); + expect(tracesListPath()).toBe('/traces'); + }); +}); diff --git a/apps/agent-forge/src/screens/traces/traces.css b/apps/agent-forge/src/screens/traces/traces.css new file mode 100644 index 00000000..29478e6f --- /dev/null +++ b/apps/agent-forge/src/screens/traces/traces.css @@ -0,0 +1,22 @@ +/* Traces list (#/traces). Tokens only. */ +.traces-screen { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 12px 16px; +} +/* Preview cells: one line, ellipsis; the full text is in the title attribute. */ +.traces-clip { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} +.traces-error { + color: var(--danger); +} +.traces-detail { + min-height: 0; +} diff --git a/apps/agent-forge/src/screens/traces/tracesUrl.ts b/apps/agent-forge/src/screens/traces/tracesUrl.ts new file mode 100644 index 00000000..32c0f580 --- /dev/null +++ b/apps/agent-forge/src/screens/traces/tracesUrl.ts @@ -0,0 +1,103 @@ +/** + * The Traces list's filter state, held entirely in the `#/traces?...` query. + * + * Keys: `agent` (agent_id), `status`, `model`, `project`, `q`, `from` + `to` + * (an absolute half-open window in ms epoch), `sort` (duration|tokens|cost|start) + * + `order` (asc|desc), and `offset` (paging). The time range has ONE URL + * representation, `from`/`to`: the default rolling 24h window writes nothing, + * and any other preset is snapshotted to absolute bounds when picked, so a + * reload shows exactly the same rows. Defaults (24h, start desc, offset 0) are + * never written, so a bare `#/traces` stays bare. + */ +import type { TraceListFilter, TraceSort } from '../../observe/observeClient'; + +/** Rows per page, requested from the daemon (`limit`). */ +export const PAGE_SIZE = 50; +/** The default rolling window when the URL has no `from`/`to`. */ +const DEFAULT_WINDOW_MS = 86_400_000; + +const SORTS: readonly TraceSort[] = ['start', 'duration', 'tokens', 'cost']; +/** Text filters, in the order they are written to the URL. */ +const TEXT_KEYS = ['agent', 'status', 'model', 'project', 'q'] as const; +type TextKey = (typeof TEXT_KEYS)[number]; + +/** The parsed `#/traces` query; absent fields are the defaults. */ +export type TracesUrlState = Partial> & { + window?: { from: number; to: number }; + sort?: { key: TraceSort; order: 'asc' | 'desc' }; + offset: number; +}; + +const toInt = (s: string | null): number | undefined => (s !== null && /^\d+$/.test(s) ? Number(s) : undefined); + +function parseWindow(query: URLSearchParams): TracesUrlState['window'] { + const from = toInt(query.get('from')); + const to = toInt(query.get('to')); + return from !== undefined && to !== undefined && from < to ? { from, to } : undefined; +} + +function parseSort(query: URLSearchParams): TracesUrlState['sort'] { + const key = query.get('sort') as TraceSort | null; + if (key === null || !SORTS.includes(key)) return undefined; + return { key, order: query.get('order') === 'asc' ? 'asc' : 'desc' }; +} + +/** Read the list state from a `#/traces` query; unknown or malformed values fall back to the defaults. */ +export function parseTracesQuery(query: URLSearchParams): TracesUrlState { + const state: TracesUrlState = { offset: toInt(query.get('offset')) ?? 0 }; + for (const key of TEXT_KEYS) { + const value = query.get(key); + if (value) state[key] = value; + } + state.window = parseWindow(query); + state.sort = parseSort(query); + return state; +} + +/** The query string (`''` or `?a=b...`) for a list state, leaving every default out. */ +export function formatTracesQuery(state: TracesUrlState): string { + const params = new URLSearchParams(); + for (const key of TEXT_KEYS) if (state[key]) params.set(key, state[key]!); + if (state.window) { + params.set('from', String(state.window.from)); + params.set('to', String(state.window.to)); + } + const { sort } = state; + if (sort && !(sort.key === 'start' && sort.order === 'desc')) { + params.set('sort', sort.key); + params.set('order', sort.order); + } + if (state.offset > 0) params.set('offset', String(state.offset)); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +/** The daemon filter for a list state; `now` resolves the default rolling window. */ +export function toTraceFilter(state: TracesUrlState, now: number): TraceListFilter { + const window = state.window ?? { from: now - DEFAULT_WINDOW_MS, to: now }; + return { + agent_id: state.agent, + status: state.status, + model: state.model, + project: state.project, + q: state.q, + from: window.from, + to: window.to, + sort: state.sort?.key, + order: state.sort?.order, + limit: PAGE_SIZE, + offset: state.offset, + }; +} + +let lastListQuery = ''; + +/** Remember the list's query string so the trace detail's Back returns to the same filters. */ +export function rememberListQuery(query: string): void { + lastListQuery = query; +} + +/** `#/traces` plus the last list query seen this session (bare when none). */ +export function tracesListPath(): string { + return `/traces${lastListQuery}`; +} diff --git a/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx b/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx index 74f2fc64..a23c8421 100644 --- a/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx +++ b/apps/agent-forge/src/shell/__tests__/DaemonStatus.test.tsx @@ -2,7 +2,8 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DaemonStatus } from '../DaemonStatus'; -import { ObserveView } from '../../observe/ObserveView'; +import { TracesScreen } from '../../screens/traces/TracesScreen'; +import { TraceScreen } from '../../screens/traces/TraceScreen'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -17,6 +18,7 @@ function stubProxy() { if (!daemon.up) return new Response('', { status: 502 }); if (url.endsWith('/health')) return new Response(JSON.stringify({ service: 'lousho-observe', ok: true }), { status: 200 }); if (url.includes('/api/v1/agents')) return new Response(JSON.stringify({ agents: [] }), { status: 200 }); + if (url.includes('/api/v1/traces/')) return new Response(JSON.stringify({ error: 'not found' }), { status: 404 }); return new Response(JSON.stringify({ traces: [], total: 0, limit: 50, offset: 0 }), { status: 200 }); }); vi.stubGlobal('fetch', fetchMock); @@ -104,24 +106,29 @@ describe('DaemonStatus', () => { }); describe('observe screens with the daemon down', () => { - it('keeps the deep-linked URL, shows the down state, and re-fetches the list in place on recovery', async () => { - window.location.hash = '#/traces/abc'; + // The list stays quiet while down (it waits for the health probe); the trace screen re-polls its detail by design. + it.each([ + { hash: '#/traces?status=error', node: () => , recovered: 'No traces match', quiet: true }, + { hash: '#/traces/abc', node: () => , recovered: 'Trace not found', quiet: false }, + ])('keeps the deep-linked URL $hash, shows the down state, and re-fetches in place on recovery', async ({ hash, node, recovered, quiet }) => { + window.location.hash = hash; const { daemon, listCalls } = stubProxy(); - await render( {}} />); + await render(node()); await flush(); expect(container.textContent).toContain('the observe daemon is not reachable'); - // The panels may try once while the first probe is pending; after that, polls must not wake them. + // The screen may try once while the first probe is pending; after that, polls must not wake the list. const whileDown = listCalls(); await poll(); await poll(); expect(container.textContent).toContain('the observe daemon is not reachable'); - expect(listCalls()).toBe(whileDown); - expect(window.location.hash).toBe('#/traces/abc'); + if (quiet) expect(listCalls()).toBe(whileDown); + expect(window.location.hash).toBe(hash); daemon.up = true; + const beforeUp = listCalls(); await poll(); - expect(listCalls()).toBeGreaterThan(whileDown); - expect(container.textContent).toContain('No traces match'); - expect(window.location.hash).toBe('#/traces/abc'); + expect(listCalls()).toBeGreaterThan(beforeUp); + expect(container.textContent).toContain(recovered); + expect(window.location.hash).toBe(hash); }); });