diff --git a/apps/agent-forge/src/ui/run/RunInspector.tsx b/apps/agent-forge/src/ui/run/RunInspector.tsx new file mode 100644 index 000000000..2aa1cc6ad --- /dev/null +++ b/apps/agent-forge/src/ui/run/RunInspector.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from 'react'; + +import { fmtCost, fmtDuration, fmtInt } from '../../observe/format'; +import type { ObserveObservationRow, ObserveScoreRow } from '../../observe/types'; +import { JsonView } from '../JsonView'; +import { MessageView } from '../MessageView'; +import { StatusPill } from '../StatusPill'; +import { displayName, displayStatus, elapsedMs, isRunning, playgroundHref } from './rows'; +import './run.css'; + +/** + * Props for {@link RunInspector}. `scores` may be the whole trace's scores: + * the inspector keeps the ones for this observation itself (and, for a root + * observation, the trace-level scores with `obs_id === null`). `now` is the + * clock a running observation's duration counts to (defaults to `Date.now()`). + */ +export interface RunInspectorProps { + observation: ObserveObservationRow; + scores: ObserveScoreRow[]; + now?: number; +} + +/** `attrs.error` as a string, else `attrs.error.message`, else a generic line. */ +function errorText(attrs: Record | null): string { + const error = attrs?.error; + if (typeof error === 'string' && error !== '') return error; + if (error !== null && typeof error === 'object') { + const message = (error as { message?: unknown }).message; + if (typeof message === 'string' && message !== '') return message; + } + return 'This step failed.'; +} + +function scoresFor(row: ObserveObservationRow, scores: ObserveScoreRow[]): ObserveScoreRow[] { + const isRoot = row.parent_obs_id === null; + return scores.filter( + (s) => s.obs_id === row.obs_id || (isRoot && s.obs_id === null && s.trace_id === row.trace_id), + ); +} + +function Section({ id, title, children }: { id: string; title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Feedback({ scores }: { scores: ObserveScoreRow[] }) { + if (scores.length === 0) return

No feedback on this step yet.

; + return ( + + ); +} + +function Usage({ row }: { row: ObserveObservationRow }) { + const markers = [row.cost_partial && 'partial', row.usage_estimated && 'estimated'].filter(Boolean); + const facts: [string, ReactNode][] = [ + ['Input tokens', fmtInt(row.usage_input_tokens)], + ['Output tokens', fmtInt(row.usage_output_tokens)], + ['Cached tokens', fmtInt(row.usage_cached_tokens)], + ['Reasoning tokens', fmtInt(row.usage_reasoning_tokens)], + [ + 'Cost', + <> + {fmtCost(row.cost_usd)} + {markers.length > 0 && ({markers.join(', ')})} + , + ], + ['Model', row.model ? {row.model} : '-'], + ]; + return ( +
+ {facts.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +/** + * The selected observation in detail. Sections, in order: Error (only for a + * failed step: a red `role="alert"` banner) → Input → Output (both + * `MessageView`) → Feedback (scores) → Tokens/Cost/Model → Attributes + * (`JsonView` of `attrs`, omitted when null). A truncated row says so. + */ +export function RunInspector({ observation: row, scores, now }: RunInspectorProps) { + const ms = elapsedMs(row, now ?? Date.now()); + return ( +
+
+ {row.kind} +

{displayName(row)}

+ + + {fmtDuration(ms)} + {isRunning(row) ? ' running' : ''} + + {row.kind === 'generation' && ( + + Open in Playground + + )} +
+ {row.status === 'error' && ( +
+ Error {errorText(row.attrs)} +
+ )} + {row.truncated && ( +

Truncated: this step's input or output was cut at ~64 KB by the reporter.

+ )} + {/* MessageView brings its own labelled header, so no Section title here. */} +
+ +
+
+ +
+
+ +
+
+ +
+ {row.attrs !== null && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/agent-forge/src/ui/run/RunTree.tsx b/apps/agent-forge/src/ui/run/RunTree.tsx new file mode 100644 index 000000000..af38f1a6f --- /dev/null +++ b/apps/agent-forge/src/ui/run/RunTree.tsx @@ -0,0 +1,140 @@ +import type { ReactNode } from 'react'; + +import { VirtualList } from '../../components/debug/VirtualList'; +import { fmtDuration, fmtInt } from '../../observe/format'; +import type { ObservationNode } from '../../observe/tree'; +import type { ObserveObservationRow } from '../../observe/types'; +import { StatusPill } from '../StatusPill'; +import { + VIRTUALISE_ABOVE, + VIRTUAL_ROW_HEIGHT, + cx, + displayName, + displayStatus, + elapsedMs, + isRunning, + playgroundHref, + totalTokens, + useFlatNodes, +} from './rows'; +import './run.css'; + +/** + * Props for {@link RunTree}. `observations` are a trace's flat rows as the + * daemon returns them (nested here by `parent_obs_id`); `now` is the clock an + * open observation's running duration counts to (defaults to `Date.now()`). + */ +export interface RunTreeProps { + observations: ObserveObservationRow[]; + selectedObsId?: string | null; + onSelect: (obsId: string) => void; + now?: number; +} + +const INDENT_PX = 16; + +/** One small 16px glyph per observation kind; anything unknown gets the generic dot. */ +const KIND_ICON: Record = { + span: , + generation: , + tool: , + event: , +}; +const GENERIC_ICON = ; + +function KindIcon({ kind }: { kind: string }) { + const known = Object.hasOwn(KIND_ICON, kind); + return ( + + + {kind} + + ); +} + +function Duration({ row, now }: { row: ObserveObservationRow; now: number }) { + const ms = elapsedMs(row, now); + if (isRunning(row)) return {fmtDuration(ms)} running; + return {fmtDuration(ms)}; +} + +interface TreeRowProps { + node: ObservationNode; + selected: boolean; + virtual: boolean; + now: number; + onSelect: (obsId: string) => void; +} + +function TreeRow({ node, selected, virtual, now, onSelect }: TreeRowProps) { + const { row } = node; + const tokens = totalTokens(row); + const status = displayStatus(row); + const name = displayName(row); + return ( +
+ + {row.kind === 'generation' && ( + + Open in Playground + + )} +
+ ); +} + +/** + * The trace as an indented tree, one row per observation in depth-first + * order: kind icon, full name (wraps; virtualised rows keep a `title` + * tooltip), status when not ok, model chip, total tokens and duration. + * Generation rows carry an "Open in Playground" link. Above 500 observations + * the rows render through `VirtualList` at a fixed height. + */ +export function RunTree({ observations, selectedObsId, onSelect, now }: RunTreeProps) { + const nodes = useFlatNodes(observations); + const clock = now ?? Date.now(); + const virtual = nodes.length > VIRTUALISE_ABOVE; + const renderRow = (node: ObservationNode) => ( + + ); + if (virtual) { + return ( +
+ +
+ ); + } + return ( +
+ {nodes.map(renderRow)} +
+ ); +} diff --git a/apps/agent-forge/src/ui/run/TraceViewer.tsx b/apps/agent-forge/src/ui/run/TraceViewer.tsx new file mode 100644 index 000000000..ad0df3a32 --- /dev/null +++ b/apps/agent-forge/src/ui/run/TraceViewer.tsx @@ -0,0 +1,77 @@ +import { useMemo } from 'react'; + +import { nestObservations } from '../../observe/tree'; +import type { ObserveObservationRow, ObserveScoreRow } from '../../observe/types'; +import { EmptyState } from '../EmptyState'; +import { RunInspector } from './RunInspector'; +import { RunTree } from './RunTree'; +import { Waterfall } from './Waterfall'; +import './run.css'; + +/** The two ways {@link TraceViewer} lays out a trace's steps. */ +export type TraceViewMode = 'tree' | 'waterfall'; + +/** + * Props for {@link TraceViewer}. The caller owns both the selection and the + * mode; pass `onModeChange` to get a Tree/Waterfall toggle. `now` is the clock + * open observations run to (defaults to `Date.now()`). + */ +export interface TraceViewerProps { + observations: ObserveObservationRow[]; + scores: ObserveScoreRow[]; + selectedObsId?: string | null; + onSelect: (obsId: string) => void; + mode: TraceViewMode; + onModeChange?: (mode: TraceViewMode) => void; + now?: number; +} + +const MODES: { mode: TraceViewMode; label: string }[] = [ + { mode: 'tree', label: 'Tree' }, + { mode: 'waterfall', label: 'Waterfall' }, +]; + +/** The selected row, else the earliest-started error, else the first root. */ +function pickInspected(rows: ObserveObservationRow[], selectedObsId: string | null | undefined): ObserveObservationRow | undefined { + if (selectedObsId != null) { + const selected = rows.find((row) => row.obs_id === selectedObsId); + if (selected) return selected; + } + let firstError: ObserveObservationRow | undefined; + for (const row of rows) { + if (row.status === 'error' && (firstError === undefined || row.start < firstError.start)) firstError = row; + } + return firstError ?? nestObservations(rows)[0]?.row; +} + +/** + * The one trace viewer: a `RunTree` or `Waterfall` (per `mode`) beside a + * `RunInspector`. With no valid selection the inspector shows the first + * failed step (so a failed trace opens on its Error banner), else the root. + */ +export function TraceViewer({ observations, scores, selectedObsId, onSelect, mode, onModeChange, now }: TraceViewerProps) { + const inspected = useMemo(() => pickInspected(observations, selectedObsId), [observations, selectedObsId]); + if (inspected === undefined) { + return ; + } + const listProps = { observations, selectedObsId: inspected.obs_id, onSelect, now }; + return ( +
+
+ {onModeChange && ( +
+ {MODES.map((m) => ( + + ))} +
+ )} + {mode === 'waterfall' ? : } +
+
+ +
+
+ ); +} diff --git a/apps/agent-forge/src/ui/run/Waterfall.tsx b/apps/agent-forge/src/ui/run/Waterfall.tsx new file mode 100644 index 000000000..67ee98a6c --- /dev/null +++ b/apps/agent-forge/src/ui/run/Waterfall.tsx @@ -0,0 +1,127 @@ +import { VirtualList } from '../../components/debug/VirtualList'; +import { fmtDuration } from '../../observe/format'; +import { traceBounds, type ObservationNode } from '../../observe/tree'; +import type { ObserveObservationRow } from '../../observe/types'; +import { VIRTUALISE_ABOVE, VIRTUAL_ROW_HEIGHT, cx, displayName, elapsedMs, isRunning, useFlatNodes } from './rows'; +import './run.css'; + +/** + * Props for {@link Waterfall}. Same rows and selection contract as `RunTree`; + * `now` is where an open observation's bar ends (defaults to `Date.now()`). + */ +export interface WaterfallProps { + observations: ObserveObservationRow[]; + selectedObsId?: string | null; + onSelect: (obsId: string) => void; + now?: number; +} + +const TARGET_TICKS = 5; + +/** A 1/2/5 x 10^n step that cuts `totalMs` into roughly {@link TARGET_TICKS} pieces. */ +function tickStep(totalMs: number): number { + const raw = totalMs / TARGET_TICKS; + const magnitude = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= raw); + return Math.max(1, step ?? 10 * magnitude); +} + +/** Tick label relative to the trace start: `0ms`, `250ms`, `1.5s`, `12s`. */ +function tickLabel(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${Number((ms / 1000).toFixed(2))}s`; +} + +function TimeAxis({ totalMs }: { totalMs: number }) { + const step = tickStep(totalMs); + const ticks: number[] = []; + for (let t = 0; t <= totalMs; t += step) ticks.push(t); + return ( + + ); +} + +interface BarRowProps { + node: ObservationNode; + minStart: number; + totalMs: number; + now: number; + selected: boolean; + virtual: boolean; + onSelect: (obsId: string) => void; +} + +function BarRow({ node, minStart, totalMs, now, selected, virtual, onSelect }: BarRowProps) { + const { row } = node; + const ms = elapsedMs(row, now); + const left = ((row.start - minStart) / totalMs) * 100; + const width = Math.max(0.5, (ms / totalMs) * 100); + const failed = row.status === 'error'; + const running = isRunning(row); + const label = `${fmtDuration(ms)}${running ? ' running' : ''}`; + return ( + + ); +} + +/** + * The trace on a time axis: tick labels relative to the trace start, then one + * bar per observation (depth-first order) offset and sized by its start/end + * within the trace bounds; an open observation's bar runs to `now`, an error + * bar is red. Above 500 observations the rows render through `VirtualList`. + */ +export function Waterfall({ observations, selectedObsId, onSelect, now }: WaterfallProps) { + const nodes = useFlatNodes(observations); + const clock = now ?? Date.now(); + const { minStart, maxEnd } = traceBounds(observations, clock); + const totalMs = maxEnd - minStart; + const virtual = nodes.length > VIRTUALISE_ABOVE; + const renderRow = (node: ObservationNode) => ( + + ); + return ( +
+ + {virtual ? ( + + ) : ( + nodes.map(renderRow) + )} +
+ ); +} diff --git a/apps/agent-forge/src/ui/run/__tests__/run.test.tsx b/apps/agent-forge/src/ui/run/__tests__/run.test.tsx new file mode 100644 index 000000000..dc4ef2c6d --- /dev/null +++ b/apps/agent-forge/src/ui/run/__tests__/run.test.tsx @@ -0,0 +1,310 @@ +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { render } from '../../__tests__/render'; + +import type { ObserveObservationRow, ObserveScoreRow } from '../../../observe/types'; +import { RunInspector } from '../RunInspector'; +import { RunTree } from '../RunTree'; +import { TraceViewer } from '../TraceViewer'; +import { Waterfall } from '../Waterfall'; + +const T0 = 1_700_000_000_000; + +function obs(over: Partial & { obs_id: string }): ObserveObservationRow { + return { + trace_id: 'tr1', + session_id: null, + agent_id: null, + parent_obs_id: null, + kind: 'span', + name: over.obs_id, + start: T0, + end: T0 + 100, + status: 'ok', + input: null, + output: null, + model: null, + usage_input_tokens: null, + usage_output_tokens: null, + usage_cached_tokens: null, + usage_reasoning_tokens: null, + cost_usd: null, + cost_partial: null, + usage_estimated: null, + truncated: false, + attrs: null, + ...over, + }; +} + +function score(over: Partial & { score_id: string }): ObserveScoreRow { + return { trace_id: 'tr1', obs_id: null, name: 'quality', value: 1, source: 'human', comment: null, agent_id: null, created_at: T0, ...over }; +} + +const LONG_NAME = 'a-really-long-step-name-that-must-never-be-clipped-by-the-tree-row-'.repeat(3); + +const TRACE: ObserveObservationRow[] = [ + obs({ obs_id: 'root', kind: 'span', name: 'agent run', start: T0, end: T0 + 2000 }), + obs({ + obs_id: 'gen/1', + parent_obs_id: 'root', + kind: 'generation', + name: LONG_NAME, + start: T0 + 100, + end: T0 + 900, + model: 'gpt-4o-mini', + usage_input_tokens: 1200, + usage_output_tokens: 34, + }), + obs({ obs_id: 'tool1', parent_obs_id: 'gen/1', kind: 'tool', name: 'get_weather', start: T0 + 950, end: T0 + 1500 }), + obs({ obs_id: 'ev1', parent_obs_id: 'root', kind: 'event', name: 'checkpoint', start: T0 + 1600, end: T0 + 1600 }), + obs({ obs_id: 'odd', parent_obs_id: 'root', kind: 'mystery', name: 'custom', start: T0 + 1700, end: T0 + 1800 }), +]; + +const row = (c: HTMLElement, id: string) => c.querySelector(`[data-obs-id="${id}"]`) as HTMLElement; + +describe('RunTree', () => { + it('indents rows by depth in depth-first order', () => { + const c = render( {}} now={T0 + 5000} />); + const rows = [...c.querySelectorAll('.run-tree-row')]; + expect(rows.map((r) => r.dataset.obsId)).toEqual(['root', 'gen/1', 'tool1', 'ev1', 'odd']); + expect(rows.map((r) => r.dataset.depth)).toEqual(['0', '1', '2', '1', '1']); + const pad = (id: string) => parseInt((row(c, id).querySelector('.run-row-main') as HTMLElement).style.paddingLeft, 10); + expect(pad('gen/1')).toBeGreaterThan(pad('root')); + expect(pad('tool1')).toBeGreaterThan(pad('gen/1')); + }); + + it('shows the full name with a title tooltip, kind icon, duration, tokens and model chip', () => { + const c = render( {}} />); + const gen = row(c, 'gen/1'); + const name = gen.querySelector('.run-name') as HTMLElement; + expect(name.textContent).toBe(LONG_NAME); + expect(name.title).toBe(LONG_NAME); + expect(gen.querySelector('.run-kind-generation svg')).not.toBeNull(); + expect(gen.querySelector('.run-dur')?.textContent).toBe('800ms'); + expect(gen.querySelector('.run-tokens')?.textContent).toBe('1,234 tok'); + expect(gen.querySelector('.run-model-chip')?.textContent).toBe('gpt-4o-mini'); + expect(row(c, 'tool1').querySelector('.run-kind-tool')).not.toBeNull(); + expect(row(c, 'ev1').querySelector('.run-kind-event')).not.toBeNull(); + expect(row(c, 'odd').querySelector('.run-kind-other svg')).not.toBeNull(); + expect(row(c, 'root').querySelector('.run-tokens')).toBeNull(); + expect(row(c, 'root').querySelector('.run-model-chip')).toBeNull(); + }); + + it('links generations (only) to the Playground', () => { + const c = render( {}} />); + const links = [...c.querySelectorAll('.run-tree .run-playground-link')]; + expect(links).toHaveLength(1); + expect(links[0]!.getAttribute('href')).toBe('#/playground?fromObs=gen%2F1'); + expect(links[0]!.textContent).toBe('Open in Playground'); + }); + + it('marks an error node red and an open node as running', () => { + const rows = [ + obs({ obs_id: 'root', end: null, status: null }), + obs({ obs_id: 'bad', parent_obs_id: 'root', status: 'error', start: T0 + 10, end: T0 + 20 }), + ]; + const c = render( {}} now={T0 + 1500} />); + expect(row(c, 'bad').classList.contains('run-row-error')).toBe(true); + expect(row(c, 'bad').querySelector('.status-pill.pill-error')).not.toBeNull(); + expect(row(c, 'root').classList.contains('run-row-error')).toBe(false); + expect(row(c, 'root').querySelector('.run-dur-live')?.textContent).toBe('1.5s running'); + expect(row(c, 'root').querySelector('.status-pill.pill-running')).not.toBeNull(); + }); + + it('calls onSelect on click and keyboard activation via buttons', () => { + const onSelect = vi.fn(); + const c = render(); + const main = row(c, 'gen/1').querySelector('button.run-row-main') as HTMLButtonElement; + act(() => main.click()); + expect(onSelect).toHaveBeenCalledWith('gen/1'); + expect(row(c, 'tool1').classList.contains('selected')).toBe(true); + expect(row(c, 'tool1').querySelector('[aria-current="true"]')).not.toBeNull(); + }); + + it('virtualises above 500 observations and renders plain rows at 500', () => { + const many = (n: number) => Array.from({ length: n }, (_, i) => obs({ obs_id: `o${i}`, start: T0 + i, end: T0 + i + 1 })); + let c = render( {}} />); + expect(c.querySelector('.run-virtual')).toBeNull(); + expect(c.querySelectorAll('.run-tree-row')).toHaveLength(500); + c = render( {}} />); + expect(c.querySelector('.run-virtual')).not.toBeNull(); + const rendered = c.querySelectorAll('.run-tree-row').length; + expect(rendered).toBeGreaterThan(0); + expect(rendered).toBeLessThan(100); + // Virtualised rows keep the full name in a tooltip. + expect((c.querySelector('.run-row-virtual .run-name') as HTMLElement).title).toBe('o0'); + }); +}); + +describe('Waterfall', () => { + it('draws a time axis relative to the trace start', () => { + const c = render( {}} />); + const ticks = [...c.querySelectorAll('.run-axis-tick')].map((t) => t.textContent); + expect(ticks[0]).toBe('0ms'); + expect(ticks).toContain('500ms'); + expect(ticks).toContain('1s'); + expect(ticks.at(-1)).toBe('2s'); + }); + + it('offsets and sizes one bar per row by start/end', () => { + const c = render( {}} />); + expect(c.querySelectorAll('.run-wf-bar')).toHaveLength(TRACE.length); + const bar = row(c, 'gen/1').querySelector('.run-wf-bar') as HTMLElement; + expect(bar.style.left).toBe('5%'); + expect(bar.style.width).toBe('40%'); + expect(bar.classList.contains('run-wf-bar-generation')).toBe(true); + expect((row(c, 'gen/1').querySelector('.run-name') as HTMLElement).title).toBe(LONG_NAME); + }); + + it('extends an open bar to now and paints an error bar red', () => { + const rows = [ + obs({ obs_id: 'root', start: T0, end: null, status: null }), + obs({ obs_id: 'bad', parent_obs_id: 'root', status: 'error', start: T0, end: T0 + 500 }), + ]; + const c = render( {}} now={T0 + 1000} />); + const open = row(c, 'root').querySelector('.run-wf-bar') as HTMLElement; + expect(open.style.width).toBe('100%'); + expect(open.classList.contains('running')).toBe(true); + expect(row(c, 'bad').querySelector('.run-wf-bar.error')).not.toBeNull(); + expect(row(c, 'bad').classList.contains('run-row-error')).toBe(true); + }); + + it('selects a row on click', () => { + const onSelect = vi.fn(); + const c = render(); + act(() => row(c, 'tool1').click()); + expect(onSelect).toHaveBeenCalledWith('tool1'); + expect(row(c, 'tool1').tagName).toBe('BUTTON'); + }); + + it('virtualises above 500 observations', () => { + const rows = Array.from({ length: 600 }, (_, i) => obs({ obs_id: `o${i}`, start: T0 + i, end: T0 + i + 1 })); + const c = render( {}} />); + expect(c.querySelector('.run-virtual')).not.toBeNull(); + expect(c.querySelectorAll('.run-wf-row').length).toBeLessThan(100); + }); +}); + +describe('RunInspector', () => { + const sectionOrder = (c: HTMLElement) => [...c.querySelectorAll('[data-section]')].map((s) => s.dataset.section); + + it('renders sections in the design-brief order, Error first for a failed step', () => { + const failed = obs({ obs_id: 'bad', status: 'error', input: 'hi', output: 'oops', attrs: { error: 'Rate limited' } }); + const c = render(); + expect(sectionOrder(c)).toEqual(['error', 'input', 'output', 'feedback', 'usage', 'attributes']); + const alert = c.querySelector('[role="alert"]') as HTMLElement; + expect(alert.textContent).toContain('Rate limited'); + }); + + it('omits Error for an ok step and Attributes when attrs is null', () => { + const c = render(); + expect(sectionOrder(c)).toEqual(['input', 'output', 'feedback', 'usage']); + expect(c.querySelector('[role="alert"]')).toBeNull(); + expect(c.textContent).toContain('No feedback on this step yet.'); + }); + + it('reads attrs.error.message, falling back to a generic line', () => { + let c = render(); + expect(c.querySelector('[role="alert"]')?.textContent).toContain('boom'); + c = render(); + expect(c.querySelector('[role="alert"]')?.textContent).toContain('This step failed.'); + }); + + it('shows this observation’s scores, plus trace-level scores for a root', () => { + const scores = [ + score({ score_id: 's1', obs_id: 'child', name: 'helpful', value: 0.8, source: 'llm-judge', comment: 'solid' }), + score({ score_id: 's2', obs_id: null, name: 'thumbs', value: 1, source: 'human' }), + score({ score_id: 's3', obs_id: 'other', name: 'nope' }), + ]; + let c = render(); + let names = [...c.querySelectorAll('.run-score-name')].map((n) => n.textContent); + expect(names).toEqual(['helpful']); + expect(c.querySelector('.run-score')?.textContent).toContain('0.8'); + expect(c.querySelector('.run-score')?.textContent).toContain('llm-judge'); + expect(c.querySelector('.run-score')?.textContent).toContain('solid'); + c = render(); + names = [...c.querySelectorAll('.run-score-name')].map((n) => n.textContent); + expect(names).toEqual(['thumbs']); + }); + + it('shows tokens, cost markers, model and the truncated note', () => { + const gen = obs({ + obs_id: 'g', + kind: 'generation', + model: 'gpt-4o', + usage_input_tokens: 10, + usage_output_tokens: 20, + usage_cached_tokens: 5, + usage_reasoning_tokens: 7, + cost_usd: 0.0021, + cost_partial: true, + usage_estimated: true, + truncated: true, + }); + const c = render(); + const usage = c.querySelector('[data-section="usage"]')!.textContent!; + expect(usage).toContain('Input tokens10'); + expect(usage).toContain('Reasoning tokens7'); + expect(usage).toContain('$0.0021 (partial, estimated)'); + expect(usage).toContain('gpt-4o'); + expect(c.querySelector('.run-truncated-note')).not.toBeNull(); + expect(c.querySelector('.run-inspector .run-playground-link')?.getAttribute('href')).toBe('#/playground?fromObs=g'); + }); +}); + +describe('TraceViewer', () => { + const inspected = (c: HTMLElement) => (c.querySelector('.run-inspector') as HTMLElement).dataset.obsId; + + it('opens a failed trace on the earliest error', () => { + const rows = [ + obs({ obs_id: 'root', status: 'error', start: T0, attrs: { error: 'root failed' } }), + obs({ obs_id: 'late', parent_obs_id: 'root', status: 'error', start: T0 + 50 }), + ]; + // Root is earliest here, so it is the first error. + let c = render( {}} mode="tree" />); + expect(inspected(c)).toBe('root'); + const child = [obs({ obs_id: 'root' }), obs({ obs_id: 'e2', parent_obs_id: 'root', status: 'error', start: T0 + 60 }), obs({ obs_id: 'e1', parent_obs_id: 'root', status: 'error', start: T0 + 30 })]; + c = render( {}} mode="tree" />); + expect(inspected(c)).toBe('e1'); + expect(c.querySelector('.run-inspector [role="alert"]')).not.toBeNull(); + expect(row(c, 'e1').classList.contains('selected')).toBe(true); + }); + + it('falls back to the first root, and honours a valid selection', () => { + let c = render( {}} mode="tree" />); + expect(inspected(c)).toBe('root'); + c = render( {}} mode="tree" />); + expect(inspected(c)).toBe('tool1'); + }); + + it('renders the mode the caller asks for, with a toggle only when onModeChange is given', () => { + let c = render( {}} mode="waterfall" />); + expect(c.querySelector('.run-waterfall')).not.toBeNull(); + expect(c.querySelector('.run-tree')).toBeNull(); + expect(c.querySelector('.run-mode-toggle')).toBeNull(); + const onModeChange = vi.fn(); + c = render( {}} mode="tree" onModeChange={onModeChange} />); + expect(c.querySelector('.run-tree')).not.toBeNull(); + const buttons = [...c.querySelectorAll('.run-mode-toggle button')]; + expect(buttons.map((b) => [b.textContent, b.getAttribute('aria-pressed')])).toEqual([ + ['Tree', 'true'], + ['Waterfall', 'false'], + ]); + act(() => buttons[1]!.click()); + expect(onModeChange).toHaveBeenCalledWith('waterfall'); + }); + + it('forwards row clicks to onSelect', () => { + const onSelect = vi.fn(); + const c = render(); + act(() => row(c, 'ev1').click()); + expect(onSelect).toHaveBeenCalledWith('ev1'); + }); + + it('shows an empty state for a trace with no observations', () => { + const c = render( {}} mode="tree" />); + expect(c.querySelector('.empty-state-title')?.textContent).toBe('No steps recorded'); + }); +}); diff --git a/apps/agent-forge/src/ui/run/rows.ts b/apps/agent-forge/src/ui/run/rows.ts new file mode 100644 index 000000000..7eea6082f --- /dev/null +++ b/apps/agent-forge/src/ui/run/rows.ts @@ -0,0 +1,56 @@ +/** + * Shared row helpers for the trace viewer (RunTree, Waterfall, RunInspector). + * Not a component module: the three views import these so the tree and the + * waterfall agree on order, duration, tokens and when to virtualise. + */ +import { useMemo } from 'react'; + +import { flattenObservationTree, nestObservations, type ObservationNode } from '../../observe/tree'; +import type { ObserveObservationRow } from '../../observe/types'; + +/** Above this many observations the tree and waterfall render through `VirtualList`. */ +export const VIRTUALISE_ABOVE = 500; + +/** Fixed row height (px) used when virtualised; must match `.run-row-virtual` in run.css. */ +export const VIRTUAL_ROW_HEIGHT = 32; + +/** Nest then flatten `rows` depth-first: the display order both views share. */ +export function useFlatNodes(rows: ObserveObservationRow[]): ObservationNode[] { + return useMemo(() => flattenObservationTree(nestObservations(rows)), [rows]); +} + +/** The name shown for an observation: its `name`, else a short id. */ +export function displayName(row: ObserveObservationRow): string { + return row.name ?? row.obs_id.slice(0, 12); +} + +/** Total tokens (input + output) when either is reported, else `null`. */ +export function totalTokens(row: ObserveObservationRow): number | null { + if (row.usage_input_tokens === null && row.usage_output_tokens === null) return null; + return (row.usage_input_tokens ?? 0) + (row.usage_output_tokens ?? 0); +} + +/** An open observation (`end === null`) is running, whatever its stored status says. */ +export function isRunning(row: ObserveObservationRow): boolean { + return row.end === null; +} + +/** The status to display: `running` for an open observation, else the stored status. */ +export function displayStatus(row: ObserveObservationRow): string | null { + return isRunning(row) ? 'running' : row.status; +} + +/** Elapsed ms: `end - start`, or `now - start` while still running. */ +export function elapsedMs(row: ObserveObservationRow, now: number): number { + return Math.max(0, (row.end ?? now) - row.start); +} + +/** The href that opens a generation in the Playground. */ +export function playgroundHref(obsId: string): string { + return `#/playground?fromObs=${encodeURIComponent(obsId)}`; +} + +/** `className` join that drops falsy entries. */ +export function cx(...names: (string | false | null | undefined)[]): string { + return names.filter(Boolean).join(' '); +} diff --git a/apps/agent-forge/src/ui/run/run.css b/apps/agent-forge/src/ui/run/run.css new file mode 100644 index 000000000..d2945cf22 --- /dev/null +++ b/apps/agent-forge/src/ui/run/run.css @@ -0,0 +1,335 @@ +/* + * Trace viewer: RunTree, Waterfall, RunInspector and the TraceViewer layout. + * Colours from src/theme.css tokens only. Virtualised rows are a fixed 32px + * (VIRTUAL_ROW_HEIGHT in rows.ts) so their names ellipsise and rely on the + * title tooltip; plain rows wrap the full name. + */ + +/* ---------- layout ---------- */ +.run-viewer { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + min-height: 0; + font-family: var(--font-ui); + color: var(--text); +} +@media (max-width: 900px) { + .run-viewer { + grid-template-columns: minmax(0, 1fr); + } +} +.run-viewer-steps, +.run-viewer-inspector { + min-width: 0; + min-height: 0; + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} +.run-mode-toggle { + display: inline-flex; + gap: 2px; + margin: 8px; + padding: 2px; + background: var(--surface-2); + border-radius: 6px; +} +.run-mode-toggle button { + border: 0; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; +} +.run-mode-toggle button[aria-pressed='true'] { + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} + +/* ---------- shared rows ---------- */ +.run-row { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + border: 0; + border-left: 3px solid transparent; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; +} +.run-row:hover { + background: var(--surface-2); +} +.run-row.selected { + background: var(--accent-soft); + border-left-color: var(--accent); +} +.run-row.run-row-error { + border-left-color: var(--danger); + color: var(--danger); +} +.run-row.run-row-error.selected { + background: var(--danger-soft); +} +.run-row-virtual { + height: 32px; + box-sizing: border-box; + align-items: center; + overflow: hidden; +} +.run-name { + flex: 1 1 auto; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; +} +.run-row-virtual .run-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.run-dur { + flex: none; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; +} +.run-dur-live { + color: var(--info); +} +.run-virtual { + height: 480px; + overflow: auto; +} + +/* ---------- tree ---------- */ +.run-tree-row { + padding: 0; +} +.run-row-main { + display: flex; + align-items: flex-start; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + padding: 6px 8px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.run-row-virtual .run-row-main { + align-items: center; + padding-top: 0; + padding-bottom: 0; + height: 100%; +} +.run-kind { + display: inline-flex; + flex: none; + margin-top: 2px; + color: var(--text-faint); +} +.run-kind-span { + color: var(--info); +} +.run-kind-generation { + color: var(--accent); +} +.run-kind-tool { + color: var(--warning); +} +.run-row-error .run-kind { + color: var(--danger); +} +.run-model-chip { + flex: none; + padding: 1px 6px; + border-radius: 999px; + background: var(--surface-2); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; +} +.run-tokens { + flex: none; + color: var(--text-muted); + font-size: 12px; +} +.run-playground-link { + flex: none; + align-self: center; + margin-right: 8px; + color: var(--accent); + font-size: 12px; + white-space: nowrap; +} + +/* ---------- waterfall ---------- */ +.run-axis, +.run-wf-row { + display: grid; + grid-template-columns: minmax(120px, 30%) minmax(0, 1fr) 80px; + gap: 8px; +} +.run-axis { + padding: 6px 8px; + border-bottom: 1px solid var(--border); + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 11px; +} +.run-axis-track { + position: relative; + height: 14px; +} +.run-axis-tick { + position: absolute; + top: 0; + padding-left: 3px; + border-left: 1px solid var(--border); + white-space: nowrap; +} +.run-wf-row { + padding: 6px 8px; + cursor: pointer; +} +.run-wf-track { + position: relative; + align-self: center; + height: 10px; + background: var(--surface-2); + border-radius: 3px; +} +.run-wf-bar { + position: absolute; + top: 0; + bottom: 0; + min-width: 2px; + border-radius: 3px; + background: var(--accent); +} +.run-wf-bar-span { + background: var(--info); +} +.run-wf-bar-tool { + background: var(--warning); +} +.run-wf-bar-event { + background: var(--text-faint); +} +.run-wf-bar.running { + opacity: 0.6; + background: var(--info); +} +.run-wf-bar.error { + background: var(--danger); +} +.run-wf-row .run-dur { + text-align: right; +} + +/* ---------- inspector ---------- */ +.run-inspector { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; +} +.run-inspector-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.run-inspector-kind { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 12px; +} +.run-inspector-name { + margin: 0; + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; +} +.run-error-banner { + padding: 10px 12px; + border: 1px solid var(--danger); + border-radius: 6px; + background: var(--danger-soft); + color: var(--danger); + overflow-wrap: anywhere; +} +.run-truncated-note { + margin: 0; + color: var(--warning); + font-size: 12px; +} +.run-section-title { + margin: 0 0 6px; + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.run-empty-line { + margin: 0; + color: var(--text-muted); + font-size: 13px; +} +.run-scores { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; +} +.run-score { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 13px; +} +.run-score-name { + font-weight: 600; +} +.run-score-value { + font-family: var(--font-mono); +} +.run-score-source, +.run-score-comment { + color: var(--text-muted); +} +.run-facts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; + margin: 0; +} +.run-fact dt { + color: var(--text-muted); + font-size: 12px; +} +.run-fact dd { + margin: 0; + font-size: 13px; + overflow-wrap: anywhere; +} +.run-marker { + color: var(--warning); +}