diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index 49804b389..57ec699c0 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?status=error', '#/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', '#/threads', '#/threads/s1', '#/threads?status=active', '#/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/__tests__/observeClient.test.ts b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts index 3278ff4c1..54ed8cf0b 100644 --- a/apps/agent-forge/src/observe/__tests__/observeClient.test.ts +++ b/apps/agent-forge/src/observe/__tests__/observeClient.test.ts @@ -27,6 +27,19 @@ describe('ObserveClient', () => { ); }); + it('lists sessions with their filter params and reads one session, undefined on 404', async () => { + const fetchFn = vi.fn(async (url: string) => + url.includes('/sessions/') + ? jsonResponse(404, { error: 'No session', code: 'LOUSHO_REQUEST_INVALID' }) + : jsonResponse(200, { sessions: [], total: 0, limit: 50, offset: 0 }) + ); + const client = clientWith(fetchFn as unknown as typeof fetch); + await client.listSessions({ agent_id: 'a1', status: 'active', from: 1, to: 2, limit: 50, offset: 0 }); + expect(fetchFn).toHaveBeenCalledWith('/observe/api/v1/sessions?agent_id=a1&status=active&from=1&to=2&limit=50&offset=0', expect.anything()); + expect(await client.session('s/1')).toBeUndefined(); + expect(fetchFn).toHaveBeenLastCalledWith('/observe/api/v1/sessions/s%2F1', 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/observeClient.ts b/apps/agent-forge/src/observe/observeClient.ts index 760b7a88f..b35c30ddf 100644 --- a/apps/agent-forge/src/observe/observeClient.ts +++ b/apps/agent-forge/src/observe/observeClient.ts @@ -16,6 +16,8 @@ import type { ObserveInsights, ObserveScorePassRate, ObserveScoreRow, + ObserveSessionDetail, + ObserveSessionList, ObserveTraceDetail, ObserveTraceList, } from './types'; @@ -85,6 +87,16 @@ export interface TraceListFilter extends TraceListQuery { /** The trace list's server-side sort keys. */ export type TraceSort = 'start' | 'duration' | 'tokens' | 'cost'; +/** `GET /api/v1/sessions` params: sessions whose `started_at` is in the half-open `[from, to)` window (ms epoch). */ +export interface SessionListFilter { + agent_id?: string; + status?: string; + from?: number; + to?: number; + limit?: number; + offset?: number; +} + export interface ScoreListFilter { trace_id?: string; /** Exact score name (`` is the eval-level row of each case). */ @@ -207,6 +219,21 @@ export class ObserveClient { } } + /** `GET /api/v1/sessions` - the paginated thread list, newest first. */ + listSessions(filter: SessionListFilter = {}): Promise { + return this.request('/api/v1/sessions', { query: { ...filter } }); + } + + /** `GET /api/v1/sessions/:id` - one thread plus its traces, oldest first; `undefined` on the daemon's 404. */ + async session(sessionId: string): Promise { + try { + return await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`); + } catch (error) { + if (error instanceof ObserveApiError && error.status === 404) return undefined; + throw error; + } + } + /** `GET /api/v1/agents` - the fleet registry with read-time freshness. */ async listAgents(project?: string): Promise { const body = await this.request<{ agents: ObserveAgentRow[] }>('/api/v1/agents', { query: { project } }); diff --git a/apps/agent-forge/src/observe/types.ts b/apps/agent-forge/src/observe/types.ts index 82048c9d7..95157efb5 100644 --- a/apps/agent-forge/src/observe/types.ts +++ b/apps/agent-forge/src/observe/types.ts @@ -48,6 +48,39 @@ export interface ObserveTraceList { offset: number; } +/** One row of `GET /api/v1/sessions`: a conversation thread with its run totals (`traces` = turns). */ +export interface ObserveSessionRow { + session_id: string; + agent_id: string | null; + /** The agent's registered name, or null. */ + agent_name: string | null; + /** `active` until the agent's crash sweep marks it `interrupted`. */ + status: string; + started_at: number; + /** The latest observation end seen in the session, or null while nothing has finished. */ + ended_at: number | null; + /** Distinct traces (turns) in the session. */ + traces: number; + input_tokens: number; + output_tokens: number; + cost_usd: number; + /** The latest turn's root input, first 200 chars. */ + last_input_preview: string | null; +} + +export interface ObserveSessionList { + sessions: ObserveSessionRow[]; + total: number; + limit: number; + offset: number; +} + +/** `GET /api/v1/sessions/:id`: the thread and its traces, oldest first (capped at 500). */ +export interface ObserveSessionDetail { + session: ObserveSessionRow; + traces: ObserveTraceRow[]; +} + /** One flat observation row of `GET /api/v1/traces/:id` - the client nests them by `parent_obs_id`. */ export interface ObserveObservationRow { obs_id: string; diff --git a/apps/agent-forge/src/observe/useObserve.ts b/apps/agent-forge/src/observe/useObserve.ts index d5a4e7b2a..cee067f21 100644 --- a/apps/agent-forge/src/observe/useObserve.ts +++ b/apps/agent-forge/src/observe/useObserve.ts @@ -6,13 +6,15 @@ * duplicating effect logic. */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { observeClient, ObserveDaemonDownError, type InsightsFilter, type ScoreListFilter, type TraceListFilter } from './observeClient'; +import { observeClient, ObserveDaemonDownError, type InsightsFilter, type ScoreListFilter, type SessionListFilter, type TraceListFilter } from './observeClient'; import type { ObserveAgentRow, ObserveHealth, ObserveInsights, ObserveScorePassRate, ObserveScoreRow, + ObserveSessionDetail, + ObserveSessionList, ObserveTraceDetail, ObserveTraceList, } from './types'; @@ -97,6 +99,14 @@ export function useTraceDetail(traceId: string | undefined, refreshKey?: unknown ); } +export function useSessions(filter: SessionListFilter, refreshKey?: unknown): ObserveQuery { + return useObserveQuery(() => observeClient.listSessions(filter), [JSON.stringify(filter), refreshKey]); +} + +export function useSession(sessionId: string, refreshKey?: unknown): ObserveQuery { + return useObserveQuery(() => observeClient.session(sessionId), [sessionId, refreshKey]); +} + export function useAgents(project?: string, refreshKey?: unknown): ObserveQuery { return useObserveQuery(() => observeClient.listAgents(project), [project, refreshKey]); } diff --git a/apps/agent-forge/src/screens/listFilters.css b/apps/agent-forge/src/screens/listFilters.css new file mode 100644 index 000000000..26b12c3a3 --- /dev/null +++ b/apps/agent-forge/src/screens/listFilters.css @@ -0,0 +1,9 @@ +/* Shared list-screen cells (#/traces, #/threads). Tokens only. */ +/* Preview cells: one line, ellipsis; the full text is in the title attribute. */ +.list-clip { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} diff --git a/apps/agent-forge/src/screens/listFilters.tsx b/apps/agent-forge/src/screens/listFilters.tsx new file mode 100644 index 000000000..b92aad1ca --- /dev/null +++ b/apps/agent-forge/src/screens/listFilters.tsx @@ -0,0 +1,79 @@ +/** + * Filter-bar pieces shared by the list screens (`#/traces`, `#/threads`): + * single-value facets over FilterBar's multi-select, the agent facet, the + * URL-backed time range, and the one-line preview cell. + */ +import './listFilters.css'; +import { useRef } from 'react'; +import type { ObserveAgentRow } from '../observe/types'; +import type { FacetDef } from '../ui/FilterBar'; +import { formatTimeRange, toWindow, type TimeRange } from '../ui/TimeRangePicker'; + +type Window = { from: number; to: number }; +type Preset = Extract['preset']; + +/** A preview cell: one line, ellipsised by CSS, the full text in `title`. */ +export function Clip({ text, className = 'list-clip' }: { text: string | null; className?: string }) { + if (!text) return null; + return ( + + {text} + + ); +} + +/** Single-value facet over FilterBar's multi-select: the newest pick wins, clearing it drops the filter. */ +export 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)), + }; +} + +/** The agent facet: values are agent ids, labels the registered names. */ +export 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 time-range control for a list whose URL holds only absolute bounds. + * `defaultPreset` is the rolling window an empty URL means; picking it clears + * the bounds. Any other preset is snapshotted to bounds and remembered next to + * them, so the picker keeps showing e.g. "7d" until the window changes (a + * reload then shows it as Custom). + */ +export function useRangeFilter( + window: Window | undefined, + defaultPreset: Preset, + setWindow: (w: Window | undefined) => void +): { value: TimeRange; onChange: (r: TimeRange) => void } { + const picked = useRef<{ range: TimeRange; key: string } | undefined>(undefined); + let value: TimeRange = { preset: defaultPreset }; + if (window) { + const key = formatTimeRange(window); + value = picked.current?.key === key ? picked.current.range : window; + } + const onChange = (r: TimeRange) => { + if ('preset' in r && r.preset === defaultPreset) { + picked.current = undefined; + setWindow(undefined); + return; + } + const next = toWindow(r, Date.now()); + picked.current = { range: r, key: formatTimeRange(next) }; + setWindow(next); + }; + return { value, onChange }; +} diff --git a/apps/agent-forge/src/screens/routes.tsx b/apps/agent-forge/src/screens/routes.tsx index fc9589cee..e4f713230 100644 --- a/apps/agent-forge/src/screens/routes.tsx +++ b/apps/agent-forge/src/screens/routes.tsx @@ -4,6 +4,7 @@ import { useRoute } from '../router/useRoute'; import { Topbar } from '../components/Topbar'; import { SettingsPanel } from '../components/SettingsPanel'; import { TracesScreen } from './traces/TracesScreen'; +import { ThreadsScreen } from './threads/ThreadsScreen'; import { AgentsScreen } from './agents/AgentsScreen'; import { TraceScreen } from './traces/TraceScreen'; import { MonitoringScreen } from './monitoring/MonitoringScreen'; @@ -66,7 +67,6 @@ export function useScreen(): ScreenId { } 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.' }, prompts: { title: 'Prompts', blurb: 'Versioned prompts you can compare, pin and pull into your agents.' }, hub: { title: 'Context Hub', blurb: 'The shared context namespaces your agents read from, browsable by namespace.' }, @@ -102,6 +102,7 @@ export function Screens() { else if (screen === 'monitoring') content = ; else if (screen === 'scores') content = ; else if (screen === 'playground') content = ; + else if (screen === 'threads') content = ; else if (placeholder) content = ; else content = ( diff --git a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx new file mode 100644 index 000000000..7fac3c343 --- /dev/null +++ b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx @@ -0,0 +1,92 @@ +/** + * `#/threads/:sessionId` - one conversation: every turn (one trace, oldest + * first) as its input and output messages, with a header line and a link to + * the turn's trace. Reads `GET /api/v1/sessions/:id` (traces capped at 500). + */ +import { useState } from 'react'; +import { useSession } from '../../observe/useObserve'; +import { ObserveError } from '../../observe/chrome'; +import type { ObserveTraceRow } from '../../observe/types'; +import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format'; +import { EmptyState } from '../../ui/EmptyState'; +import { MessageView } from '../../ui/MessageView'; +import { StatusPill } from '../../ui/StatusPill'; +import { threadsListPath } from './threadsUrl'; + +const BackLink = () => Back to threads; + +/** One turn: time, status, duration and tokens, then the input and the output (or the run's error). */ +function Turn({ trace, index, now }: { trace: ObserveTraceRow; index: number; now: number }) { + return ( +
  • +
    + #{index + 1} + + + {fmtDuration(trace.end - trace.start)} + + {fmtInt(trace.input_tokens + trace.output_tokens)} tokens + + + Open trace + +
    + + {trace.error_message ? ( +

    + Error {trace.error_message} +

    + ) : ( + + )} +
  • + ); +} + +/** `#/threads/:sessionId`; an unknown session shows an empty state with the way back. */ +export function ThreadScreen({ sessionId }: { sessionId: string }) { + const thread = useSession(sessionId); + const [now] = useState(() => Date.now()); + + const session = thread.data?.session; + const traces = thread.data?.traces ?? []; + let body; + if (thread.loading && !thread.data) body =

    Loading thread...

    ; + else if (thread.error) body = ; + else if (!session) body = } />; + else + body = ( + <> +

    + + {session.agent_name ?? session.agent_id ?? 'unknown agent'} + + {fmtInt(session.traces)} {session.traces === 1 ? 'turn' : 'turns'} + + started {fmtStarted(session.started_at, now)} + {fmtInt(session.input_tokens + session.output_tokens)} tokens + {fmtCost(session.cost_usd)} +

    + {traces.length < session.traces && ( +

    Showing the first {fmtInt(traces.length)} turns of {fmtInt(session.traces)}.

    + )} +
      + {traces.map((t, i) => ( + + ))} +
    + + ); + + return ( +
    + +

    + Thread {sessionId} +

    + {body} +
    + ); +} diff --git a/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx new file mode 100644 index 000000000..7f8d39a64 --- /dev/null +++ b/apps/agent-forge/src/screens/threads/ThreadsScreen.tsx @@ -0,0 +1,137 @@ +/** + * `#/threads` - the session list (agent / status / time-range facets, + * server-side paging over `GET /api/v1/sessions`) - and + * `#/threads/:sessionId`, one conversation (see `ThreadScreen.tsx`). + * + * All list state lives in the URL query (see `threadsUrl.ts` for the keys). + * Filter and page edits replace the current history entry; opening a thread + * pushes one, 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 './threads.css'; +import { useEffect, useMemo } from 'react'; +import { matchRoute, navigate } from '../../router/router'; +import { useRoute } from '../../router/useRoute'; +import { useAgents, useDaemonHealth, useSessions } from '../../observe/useObserve'; +import { ObserveDaemonDown, ObserveError } from '../../observe/chrome'; +import { ObserveDaemonDownError } from '../../observe/observeClient'; +import type { ObserveSessionRow } from '../../observe/types'; +import { fmtCost, fmtDuration, fmtInt, fmtStarted } from '../../observe/format'; +import { DataTable, type Column } from '../../ui/DataTable'; +import { FilterBar } from '../../ui/FilterBar'; +import { EmptyState } from '../../ui/EmptyState'; +import { StatusPill } from '../../ui/StatusPill'; +import { Clip, agentFacet, facet, useRangeFilter } from '../listFilters'; +import { ThreadScreen } from './ThreadScreen'; +import { + DEFAULT_THREADS_RANGE, + THREADS_PAGE_SIZE, + formatThreadsQuery, + parseThreadsQuery, + rememberThreadsQuery, + toSessionFilter, + type ThreadsUrlState, +} from './threadsUrl'; + +/** Session statuses the daemon writes: `active`, then `interrupted` when the agent's crash sweep closes it. */ +const STATUSES = ['active', 'interrupted']; + +/** The session's span, start to its latest finished observation; `-` while nothing has finished. */ +const span = (s: ObserveSessionRow): number | null => (s.ended_at === null ? null : s.ended_at - s.started_at); + +function columns(now: number): Column[] { + return [ + { key: 'status', header: 'Status', width: 110, render: (s) => }, + { + key: 'session', + header: 'Session', + width: 130, + render: (s) => {s.session_id.slice(0, 12)}, + }, + { key: 'agent', header: 'Agent', width: 130, hideBelow: 720, render: (s) => }, + { key: 'input', header: 'Last input', width: 240, render: (s) => }, + { key: 'turns', header: 'Turns', align: 'right', width: 70, render: (s) => fmtInt(s.traces) }, + { key: 'started', header: 'Started', width: 130, hideBelow: 900, render: (s) => fmtStarted(s.started_at, now) }, + { + key: 'duration', + header: 'Duration', + align: 'right', + width: 96, + hideBelow: 1000, + render: (s) => ( + {fmtDuration(span(s))} + ), + }, + { + key: 'tokens', + header: 'Tokens', + align: 'right', + width: 90, + render: (s) => ( + {fmtInt(s.input_tokens + s.output_tokens)} + ), + }, + { key: 'cost', header: 'Cost', align: 'right', width: 84, hideBelow: 720, render: (s) => fmtCost(s.cost_usd) }, + ]; +} + +function ThreadsList() { + const route = useRoute(); + const queryString = route.query.toString(); + const state = useMemo(() => parseThreadsQuery(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(() => toSessionFilter(state, now), [state, now]); + const sessions = useSessions(filter); + const agents = useAgents(); + + useEffect(() => rememberThreadsQuery(formatThreadsQuery(state)), [state]); + + // Every filter edit restarts at the first page; replace keeps Back off the filter edits. + const update = (patch: Partial) => + navigate(`/threads${formatThreadsQuery({ ...state, offset: 0, ...patch })}`, { replace: true }); + const range = useRangeFilter(state.window, DEFAULT_THREADS_RANGE, (window) => update({ window })); + const facets = [ + agentFacet(agents.data ?? [], state.agent, (agent) => update({ agent })), + facet('status', 'Status', STATUSES, state.status, (status) => update({ status })), + ]; + if (sessions.error instanceof ObserveDaemonDownError) return ; + + return ( +
    +

    Threads

    + + + s.session_id} + onRowOpen={(s) => navigate(`/threads/${encodeURIComponent(s.session_id)}`)} + page={{ + offset: state.offset, + limit: THREADS_PAGE_SIZE, + total: sessions.data?.total ?? 0, + onChange: (offset) => update({ offset }), + }} + loading={sessions.loading} + empty={ + + } + /> +
    + ); +} + +/** `#/threads` and `#/threads/:sessionId`, behind the daemon-down state. */ +export function ThreadsScreen() { + const { path } = useRoute(); + const health = useDaemonHealth(); + if (health.down) return ; + const detail = matchRoute(`#${path}`, ['/threads/:sessionId']); + return detail ? : ; +} diff --git a/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx b/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx new file mode 100644 index 000000000..2e17d4ba0 --- /dev/null +++ b/apps/agent-forge/src/screens/threads/__tests__/threads.test.tsx @@ -0,0 +1,195 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ThreadsScreen } from '../ThreadsScreen'; +import { formatThreadsQuery, parseThreadsQuery } from '../threadsUrl'; +import { render } from '../../../ui/__tests__/render'; +import type { ObserveSessionRow, ObserveTraceRow } from '../../../observe/types'; + +const NOW = new Date(2026, 9, 25, 15, 30, 0).getTime(); + +const SESSION: ObserveSessionRow = { + session_id: 'sess-abcdef123456', + agent_id: 'agent-1', + agent_name: 'support-bot', + status: 'active', + started_at: NOW - 120_000, + ended_at: NOW - 20_000, + traces: 2, + input_tokens: 200, + output_tokens: 50, + cost_usd: 0.004, + last_input_preview: 'Can I get a refund?', +}; + +const TURN_1: ObserveTraceRow = { + trace_id: 'trace-1', + name: 'answer', + agent_id: 'agent-1', + agent_name: 'support-bot', + session_id: SESSION.session_id, + start: NOW - 120_000, + end: NOW - 118_000, + status: 'ok', + observations: 3, + generations: 1, + tools: 0, + errors: 0, + input_tokens: 100, + output_tokens: 30, + cost_usd: 0.002, + model: 'gpt-4o-mini', + error_message: null, + input_preview: 'Where is my order?', + output_preview: 'It ships tomorrow.', +}; + +const TURN_2: ObserveTraceRow = { + ...TURN_1, + trace_id: 'trace-2', + start: NOW - 30_000, + end: NOW - 20_000, + status: 'error', + errors: 1, + input_preview: 'Can I get a refund?', + output_preview: null, + error_message: 'Tool refund_lookup timed out', +}; + +let fetchFn: ReturnType>>; + +/** Daemon stub: healthy, one agent, one session with two turns; any other session id is a 404. */ +function stubDaemon() { + fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://x'); + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + if (url.pathname.endsWith('/health')) return json({ service: 'lousho-observe', ok: true }); + if (url.pathname.includes('/api/v1/agents')) return json({ agents: [{ agent_id: 'agent-1', name: 'support-bot', project: 'shop' }] }); + if (url.pathname.endsWith(`/api/v1/sessions/${SESSION.session_id}`)) return json({ session: SESSION, traces: [TURN_1, TURN_2] }); + if (url.pathname.includes('/api/v1/sessions/')) return json({ error: 'No session', code: 'LOUSHO_REQUEST_INVALID' }, 404); + return json({ sessions: [SESSION], total: 1, limit: 50, offset: 0 }); + }); + vi.stubGlobal('fetch', fetchFn); +} + +/** The session-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/sessions')) + .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('threadsUrl', () => { + it('round-trips agent, status, window and offset, and leaves the defaults out', () => { + expect(formatThreadsQuery(parseThreadsQuery(new URLSearchParams('')))).toBe(''); + const qs = 'agent=agent-1&status=active&from=10&to=20&offset=50'; + expect(formatThreadsQuery(parseThreadsQuery(new URLSearchParams(qs)))).toBe(`?${qs}`); + expect(parseThreadsQuery(new URLSearchParams('from=20&to=10&offset=x'))).toEqual({ offset: 0 }); + }); +}); + +describe('ThreadsScreen list', () => { + it('renders the sessions from the API with their totals', async () => { + const container = await mountAt('#/threads'); + expect(window.location.hash).toBe('#/threads'); + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.textContent).toContain('sess-abcdef1'); + expect(row.textContent).toContain('support-bot'); + expect(row.querySelector('[title="Can I get a refund?"]')).not.toBeNull(); + expect(row.querySelector('.status-pill')?.textContent).toContain('active'); + expect(row.textContent).toContain('250'); + expect(row.textContent).toContain('$0.0040'); + const last = listRequests().at(-1)!; + expect(last.get('limit')).toBe('50'); + expect(last.get('offset')).toBe('0'); + }); + + it('reproduces the URL filters on load', async () => { + const container = await mountAt('#/threads?agent=agent-1&status=interrupted&from=1000&to=2000&offset=50'); + const last = listRequests().at(-1)!; + expect(last.get('agent_id')).toBe('agent-1'); + expect(last.get('status')).toBe('interrupted'); + expect(last.get('from')).toBe('1000'); + expect(last.get('to')).toBe('2000'); + expect(last.get('offset')).toBe('50'); + 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('opening a row navigates to #/threads/:sessionId', async () => { + const container = await mountAt('#/threads?status=active'); + 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(`#/threads/${SESSION.session_id}`); + }); +}); + +describe('ThreadsScreen thread', () => { + it('renders each turn as Input and Output MessageViews, oldest first, each linking to its trace', async () => { + const container = await mountAt(`#/threads/${SESSION.session_id}`); + const turns = [...container.querySelectorAll('.thread-turn')]; + expect(turns).toHaveLength(2); + // Turn 1: an input and an output message. + expect(turns[0].querySelectorAll('section.mv')).toHaveLength(2); + expect(turns[0].textContent).toContain('Where is my order?'); + expect(turns[0].textContent).toContain('It ships tomorrow.'); + // Turn 2 failed: its input message, then the error text in the danger style. + expect(turns[1].querySelectorAll('section.mv')).toHaveLength(1); + expect(turns[1].querySelector('.thread-error')?.textContent).toContain('Tool refund_lookup timed out'); + expect(turns[1].querySelector('.status-pill')?.textContent).toContain('error'); + const links = turns.map((t) => [...t.querySelectorAll('a')].find((a) => a.textContent === 'Open trace')?.getAttribute('href')); + expect(links).toEqual(['#/traces/trace-1', '#/traces/trace-2']); + }); + + it('list -> thread -> trace: Back to threads keeps the list query', async () => { + await mountAt('#/threads?status=active'); + window.history.replaceState(null, '', `/#/threads/${SESSION.session_id}`); + const container = render(); + await flush(); + const back = [...container.querySelectorAll('a')].find((a) => a.textContent?.includes('Back to threads')); + expect(back?.getAttribute('href')).toBe('#/threads?status=active'); + }); + + it('an unknown session shows the empty state with a way back', async () => { + const container = await mountAt('#/threads/nope'); + expect(container.querySelector('.empty-state-title')?.textContent).toBe('Thread not found'); + const back = container.querySelector('.empty-state a'); + expect(back?.textContent).toBe('Back to threads'); + expect(container.querySelector('.thread-turn')).toBeNull(); + }); +}); diff --git a/apps/agent-forge/src/screens/threads/threads.css b/apps/agent-forge/src/screens/threads/threads.css new file mode 100644 index 000000000..020486e22 --- /dev/null +++ b/apps/agent-forge/src/screens/threads/threads.css @@ -0,0 +1,74 @@ +/* Threads (#/threads, #/threads/:sessionId). Tokens only. */ +.threads-screen, +.thread-screen { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 12px 16px; +} +.thread-title { + margin: 0; + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; +} +.thread-title code { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-muted); +} +.thread-summary, +.thread-turn-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + margin: 0; + font-size: 13px; + color: var(--text-muted); +} +.thread-note, +.thread-loading { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} +.thread-turns { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} +.thread-turn { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} +.thread-turn-index { + font-family: var(--font-mono); + color: var(--text-faint); +} +.thread-turn-link { + margin-left: auto; + color: var(--accent); +} +.thread-error { + margin: 0; + padding: 8px 10px; + border-radius: 6px; + background: var(--danger-soft); + color: var(--danger); + font-size: 13px; + overflow-wrap: anywhere; +} +.thread-error-label { + font-weight: 600; +} diff --git a/apps/agent-forge/src/screens/threads/threadsUrl.ts b/apps/agent-forge/src/screens/threads/threadsUrl.ts new file mode 100644 index 000000000..faa4dbff9 --- /dev/null +++ b/apps/agent-forge/src/screens/threads/threadsUrl.ts @@ -0,0 +1,74 @@ +/** + * The Threads list's filter state, held entirely in the `#/threads?...` query. + * + * Keys: `agent` (agent_id), `status` (session status), `from` + `to` (an + * absolute half-open window over the session's start, ms epoch) and `offset` + * (paging). As on Traces, the time range has one URL representation: the + * default rolling window (30d - threads are long-lived) writes nothing, and + * any other preset is snapshotted to absolute bounds when picked. Defaults are + * never written, so a bare `#/threads` stays bare. + */ +import type { SessionListFilter } from '../../observe/observeClient'; +import { parseWindow, toInt, writeWindow } from '../urlState'; + +/** Rows per page, requested from the daemon (`limit`). */ +export const THREADS_PAGE_SIZE = 50; +/** The picker preset an empty URL means. */ +export const DEFAULT_THREADS_RANGE = '30d'; +const DEFAULT_WINDOW_MS = 2_592_000_000; + +/** The parsed `#/threads` query; absent fields are the defaults. */ +export interface ThreadsUrlState { + agent?: string; + status?: string; + window?: { from: number; to: number }; + offset: number; +} + +/** Read the list state from a `#/threads` query; malformed values fall back to the defaults. */ +export function parseThreadsQuery(query: URLSearchParams): ThreadsUrlState { + const state: ThreadsUrlState = { offset: toInt(query.get('offset')) ?? 0 }; + const agent = query.get('agent'); + const status = query.get('status'); + if (agent) state.agent = agent; + if (status) state.status = status; + const window = parseWindow(query); + if (window) state.window = window; + return state; +} + +/** The query string (`''` or `?a=b...`) for a list state, leaving every default out. */ +export function formatThreadsQuery(state: ThreadsUrlState): string { + const params = new URLSearchParams(); + if (state.agent) params.set('agent', state.agent); + if (state.status) params.set('status', state.status); + writeWindow(params, state.window); + 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 toSessionFilter(state: ThreadsUrlState, now: number): SessionListFilter { + const window = state.window ?? { from: now - DEFAULT_WINDOW_MS, to: now }; + return { + agent_id: state.agent, + status: state.status, + from: window.from, + to: window.to, + limit: THREADS_PAGE_SIZE, + offset: state.offset, + }; +} + +let lastListQuery = ''; + +/** Remember the list's query string so a thread's "Back to threads" returns to the same filters. */ +export function rememberThreadsQuery(query: string): void { + lastListQuery = query; +} + +/** `/threads` plus the last list query seen this session (bare when none). */ +export function threadsListPath(): string { + return `/threads${lastListQuery}`; +} diff --git a/apps/agent-forge/src/screens/traces/TracesScreen.tsx b/apps/agent-forge/src/screens/traces/TracesScreen.tsx index 9fb44603f..4a6deb479 100644 --- a/apps/agent-forge/src/screens/traces/TracesScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TracesScreen.tsx @@ -10,19 +10,19 @@ * itself once the health probe sees the daemon. */ import './traces.css'; -import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; +import { useEffect, useMemo } 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 type { 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 { FilterBar } from '../../ui/FilterBar'; import { EmptyState } from '../../ui/EmptyState'; import { StatusPill } from '../../ui/StatusPill'; +import { Clip, agentFacet, facet, useRangeFilter } from '../listFilters'; import { PAGE_SIZE, formatTracesQuery, @@ -35,16 +35,6 @@ import { 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) => }, @@ -61,7 +51,7 @@ function columns(now: number): Column[] { key: 'error', header: 'Error', width: 200, - render: (t) => , + render: (t) => , }, { key: 'model', header: 'Model', width: 130, hideBelow: 1000, render: (t) => }, { key: 'started', header: 'Started', width: 130, render: (t) => fmtStarted(t.start, now) }, @@ -82,38 +72,9 @@ function columns(now: number): Column[] { ]; } -/** 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(); @@ -123,7 +84,6 @@ function TracesList() { 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]); @@ -132,16 +92,7 @@ function TracesList() { 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 range = useRangeFilter(state.window, '24h', (window) => update({ window })); const onSort = (next: TableSort | undefined) => update({ sort: next && SORTABLE.includes(next.key) ? { key: next.key as TraceSort, order: next.order } : undefined }); @@ -161,7 +112,7 @@ function TracesList() { setText('q')(q.trim()), placeholder: 'Search name, input or output' }} facets={facets} - range={{ value: rangeValue(state, picked), onChange: onRange }} + range={range} /> > & { 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; @@ -58,10 +51,7 @@ export function parseTracesQuery(query: URLSearchParams): TracesUrlState { 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)); - } + writeWindow(params, state.window); const { sort } = state; if (sort && !(sort.key === 'start' && sort.order === 'desc')) { params.set('sort', sort.key); diff --git a/apps/agent-forge/src/screens/urlState.ts b/apps/agent-forge/src/screens/urlState.ts new file mode 100644 index 000000000..950159d6b --- /dev/null +++ b/apps/agent-forge/src/screens/urlState.ts @@ -0,0 +1,21 @@ +/** + * Pure query-string helpers shared by the list screens' URL state + * (`traces/tracesUrl.ts`, `threads/threadsUrl.ts`). + */ + +/** A non-negative integer query value, or `undefined` when missing or malformed. */ +export const toInt = (s: string | null): number | undefined => (s !== null && /^\d+$/.test(s) ? Number(s) : undefined); + +/** An absolute `from`/`to` window (ms epoch, half-open); dropped unless both are present and `from < to`. */ +export function parseWindow(query: URLSearchParams): { from: number; to: number } | undefined { + const from = toInt(query.get('from')); + const to = toInt(query.get('to')); + return from !== undefined && to !== undefined && from < to ? { from, to } : undefined; +} + +/** Write a window (when set) as `from` + `to`. */ +export function writeWindow(params: URLSearchParams, window: { from: number; to: number } | undefined): void { + if (!window) return; + params.set('from', String(window.from)); + params.set('to', String(window.to)); +}