Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/agent-forge/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ test('Ctrl+K opens the command palette; ? lists the shortcuts', async ({ page })
});

// One <main>, 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', '#/settings', '#/playground']) {
for (const route of ['#/traces', '#/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();
Expand Down Expand Up @@ -145,6 +145,15 @@ test('#/monitoring with no daemon shows the down state and keeps window/agent in
expect(new URL(page.url()).hash).toBe('#/monitoring?window=7d&agent=e2e-agent');
});

test('#/evals/scores with no daemon shows the down state and keeps the filters in the URL', async ({ page }) => {
await page.route('**/observe/**', (route) =>
route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"down"}' }),
);
await page.goto('/?token=e2e-studio-token#/evals/scores?eval=e2e-eval&source=eval&range=7d');
await expect(page.getByRole('main').getByText('lousho observe start')).toBeVisible();
expect(new URL(page.url()).hash).toBe('#/evals/scores?eval=e2e-eval&source=eval&range=7d');
});

test('#/settings shows the settings panel inside the screen', async ({ page }) => {
await page.goto('/?token=e2e-studio-token#/settings');
const main = page.getByRole('main');
Expand Down
11 changes: 11 additions & 0 deletions apps/agent-forge/src/observe/__tests__/observeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,15 @@ describe('ObserveClient', () => {
const down = clientWith((async () => jsonResponse(502, {})) as typeof fetch);
await expect(down.postScore({ trace_id: 'x', name: 'feedback', value: 0 })).rejects.toBeInstanceOf(ObserveDaemonDownError);
});

it('scorePassRates asks for the eval aggregate with the score filters', async () => {
const evals = [{ name: 'support', count: 2, passed: 1, pass_rate: 0.5, avg_value: 0.5, last_ts: 1 }];
const fetchFn = vi.fn(async () => jsonResponse(200, { evals }));
const client = clientWith(fetchFn as typeof fetch);
expect(await client.scorePassRates({ agent_id: 'a1', source: 'eval', since: 10, until: 20 })).toEqual(evals);
expect(fetchFn).toHaveBeenCalledWith(
'/observe/api/v1/scores?agent_id=a1&source=eval&since=10&until=20&aggregate=eval',
expect.anything()
);
});
});
18 changes: 18 additions & 0 deletions apps/agent-forge/src/observe/observeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ObserveAgentRow,
ObserveHealth,
ObserveInsights,
ObserveScorePassRate,
ObserveScoreRow,
ObserveTraceDetail,
ObserveTraceList,
Expand Down Expand Up @@ -72,8 +73,17 @@ export interface TraceListFilter {

export interface ScoreListFilter {
trace_id?: string;
/** Exact score name (`<evalName>` is the eval-level row of each case). */
name?: string;
/** Eval name: every score named `<eval>` or `<eval>/<check>`. */
eval?: string;
agent_id?: string;
/** `eval` | `judge` | `human`. */
source?: string;
/** `created_at` lower bound (epoch ms). */
since?: number;
/** `created_at` upper bound (epoch ms). */
until?: number;
limit?: number;
}

Expand Down Expand Up @@ -200,6 +210,14 @@ export class ObserveClient {
return body.scores;
}

/** `GET /api/v1/scores?aggregate=eval` - pass rate, mean and last seen per eval name (same filters as {@link listScores}). */
async scorePassRates(filter: Omit<ScoreListFilter, 'limit'> = {}): Promise<ObserveScorePassRate[]> {
const body = await this.request<{ evals: ObserveScorePassRate[] }>('/api/v1/scores', {
query: { ...filter, aggregate: 'eval' },
});
return body.evals;
}

/** `POST /api/v1/scores` - a human feedback score (append-only); the daemon's 404/400 surface as `ObserveApiError`. */
postScore(score: NewScore): Promise<{ score_id: string }> {
return this.request('/api/v1/scores', { method: 'POST', body: score });
Expand Down
16 changes: 16 additions & 0 deletions apps/agent-forge/src/observe/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ export interface ObserveScoreRow {
created_at: number;
}

/** One row of `GET /api/v1/scores?aggregate=eval`: every score whose name is `<eval>` or `<eval>/*`, folded. */
export interface ObserveScorePassRate {
/** The eval name (the score-name prefix before the first '/'). */
name: string;
/** Score rows in the group (the eval-level row of each case plus its per-check rows). */
count: number;
/** Rows that passed: no failure comment, or a perfect 1. */
passed: number;
/** `passed / count`. */
pass_rate: number;
/** Mean value over the group's non-null values; null when every value is null. */
avg_value: number | null;
/** Newest row's `created_at`. */
last_ts: number;
}

export interface ObserveTraceDetail {
trace_id: string;
observations: ObserveObservationRow[];
Expand Down
49 changes: 49 additions & 0 deletions apps/agent-forge/src/observe/useObserve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ObserveAgentRow,
ObserveHealth,
ObserveInsights,
ObserveScorePassRate,
ObserveScoreRow,
ObserveTraceDetail,
ObserveTraceList,
Expand Down Expand Up @@ -107,3 +108,51 @@ export function useInsights(filter: InsightsFilter, refreshKey?: unknown): Obser
export function useScores(filter: ScoreListFilter, refreshKey?: unknown): ObserveQuery<ObserveScoreRow[]> {
return useObserveQuery(() => observeClient.listScores(filter), [JSON.stringify(filter), refreshKey]);
}

/**
* One eval's score rows (`eval=<name>` plus `filter`); no request while
* `evalName` is empty. `data` is only ever rows of the current `evalName`:
* after a switch it stays undefined (loading) until the new eval's rows land.
*/
export function useEvalScores(
evalName: string,
filter: Omit<ScoreListFilter, 'eval'>,
refreshKey?: unknown
): ObserveQuery<ObserveScoreRow[] | undefined> {
const query = useObserveQuery(
async () => (evalName ? { evalName, rows: await observeClient.listScores({ ...filter, eval: evalName }) } : undefined),
[evalName, JSON.stringify(filter), refreshKey]
);
return { ...query, data: query.data?.evalName === evalName ? query.data.rows : undefined };
}

export function useScorePassRates(
filter: Omit<ScoreListFilter, 'limit'>,
refreshKey?: unknown
): ObserveQuery<ObserveScorePassRate[]> {
return useObserveQuery(() => observeClient.scorePassRates(filter), [JSON.stringify(filter), refreshKey]);
}

/**
* The observe screens' shared "daemon down" verdict: the health probe says so,
* or one of `queries` failed to reach it. While down, every query re-runs each
* `pollMs`, so the screen recovers in place (URL untouched) once the daemon is
* back even if the health probe never saw it go.
*/
export function useDaemonDown(
healthDown: boolean,
queries: readonly Pick<ObserveQuery<unknown>, 'error' | 'refresh'>[],
pollMs = 5000
): boolean {
const down = healthDown || queries.some((q) => q.error instanceof ObserveDaemonDownError);
const latest = useRef(queries);
latest.current = queries;
useEffect(() => {
if (!down || pollMs <= 0) return undefined;
const timer = setInterval(() => {
for (const q of latest.current) q.refresh();
}, pollMs);
return () => clearInterval(timer);
}, [down, pollMs]);
return down;
}
9 changes: 4 additions & 5 deletions apps/agent-forge/src/screens/monitoring/MonitoringScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from 'react';

import { AgentSelect, ObserveDaemonDown, ObserveError } from '../../observe/chrome';
import { ObserveDaemonDownError } from '../../observe/observeClient';
import { useAgents, useDaemonHealth, useInsights } from '../../observe/useObserve';
import { useAgents, useDaemonDown, useDaemonHealth, useInsights } from '../../observe/useObserve';
import { navigate } from '../../router/router';
import { useQueryState } from '../../router/useRoute';
import { EmptyState } from '../../ui/EmptyState';
Expand Down Expand Up @@ -68,9 +67,9 @@ function useMonitoringData(win: MonitoringWindow, agentId: string) {
{ since: chart.from, until: chart.to, agent_id: agentId || undefined, granularity: chart.granularity },
refreshKey,
);
const errors = [agents.error, fleetInsights.error, insights.error];
const down = health.down || errors.some((e) => e instanceof ObserveDaemonDownError);
return { agents, fleetInsights, insights, chart, down, error: errors.find((e) => e !== undefined) };
const queries = [agents, fleetInsights, insights];
const down = useDaemonDown(health.down, queries);
return { agents, fleetInsights, insights, chart, down, error: queries.find((q) => q.error)?.error };
}

/**
Expand Down
6 changes: 4 additions & 2 deletions apps/agent-forge/src/screens/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ObserveView, type ObserveTab } from '../observe/ObserveView';
import { AgentsScreen } from './agents/AgentsScreen';
import { TraceScreen } from './traces/TraceScreen';
import { MonitoringScreen } from './monitoring/MonitoringScreen';
import { ScoresScreen } from './scores/ScoresScreen';
import { useLeaveAgentsGuard } from './agents/useLeaveAgentsGuard';
import { Placeholder } from './Placeholder';

Expand Down Expand Up @@ -63,8 +64,8 @@ export function useScreen(): ScreenId {
return screen ?? 'agents';
}

/** Until the Phase 3 screens land, Traces/Scores are the existing observe panels, one per route. */
const OBSERVE_TAB: Partial<Record<ScreenId, ObserveTab>> = { traces: 'traces', scores: 'scores' };
/** Until its Phase 3 screen lands, Traces is the existing observe panel (Scores has its own screen now). */
const OBSERVE_TAB: Partial<Record<ScreenId, ObserveTab>> = { traces: 'traces' };
const TAB_PATH: Record<ObserveTab, string> = {
traces: '/traces',
scores: '/evals/scores',
Expand Down Expand Up @@ -110,6 +111,7 @@ export function Screens() {
// Same element type at the same position for all three, so the drill-down filter survives a route change.
if (screen === 'trace') content = <TraceRoute />;
else if (screen === 'monitoring') content = <MonitoringScreen />;
else if (screen === 'scores') content = <ScoresScreen />;
else if (observeTab) content = <ObserveView tab={observeTab} onTabChange={(tab) => navigate(TAB_PATH[tab])} />;
else if (placeholder) content = <Placeholder {...placeholder} />;
else
Expand Down
108 changes: 108 additions & 0 deletions apps/agent-forge/src/screens/scores/EvalDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useMemo, type ReactNode } from 'react';
import { ObserveError } from '../../observe/chrome';
import { fmtDateTime, fmtInt, fmtPercent } from '../../observe/format';
import type { ObserveQuery } from '../../observe/useObserve';
import type { ObserveScoreRow } from '../../observe/types';
import { DataTable, type Column } from '../../ui/DataTable';
import { EmptyState } from '../../ui/EmptyState';
import { TimeSeriesChart } from '../../ui/TimeSeriesChart';
import { groupEvalScores, passRateSeries, scoreStats, traceHref, type CheckGroup, type TrendWindow } from './scoresData';

/** Rows fetched for one eval; a full page means older rows were left out. */
export const EVAL_ROW_LIMIT = 1000;

const fmtMean = (v: number | null) => (v === null ? '-' : v.toFixed(2));

const CHECK_COLUMNS: Column<CheckGroup>[] = [
{ key: 'check', header: 'Check', render: (c) => <span className="scores-name">{c.check}</span> },
{ key: 'pass', header: 'Pass rate', align: 'right', render: (c) => fmtPercent(c.passRate) },
{ key: 'count', header: 'Scores', align: 'right', render: (c) => fmtInt(c.count) },
{ key: 'mean', header: 'Mean', align: 'right', hideBelow: 480, render: (c) => fmtMean(c.mean) },
];

function TraceLink({ row }: { row: ObserveScoreRow }) {
return (
<a className="scores-link" href={traceHref(row)} title={row.trace_id}>
{row.trace_id.slice(0, 10)}
</a>
);
}

function Failures({ evalName, rows }: { evalName: string; rows: ObserveScoreRow[] }) {
if (rows.length === 0) return <p className="scores-muted">No failing scores in this window.</p>;
return (
<ul className="scores-failures" aria-label="Latest failures">
{rows.map((row) => (
<li key={row.score_id} className="scores-failure">
<span className="scores-when">{fmtDateTime(row.created_at)}</span>
<span className="scores-name">{row.name === evalName ? 'eval' : row.name.slice(evalName.length + 1)}</span>
<span className="scores-value">{row.value === null ? '-' : row.value}</span>
<TraceLink row={row} />
<span className="scores-comment">{row.comment}</span>
</li>
))}
</ul>
);
}

/**
* One expanded eval: its pass-rate trend (from the eval-level row of each
* case), the per-check pass rates (worst first) and the newest failing rows,
* each linking to its trace.
*/
export function EvalDetail({
evalName,
query,
window: w,
}: {
evalName: string;
query: ObserveQuery<ObserveScoreRow[] | undefined>;
window: TrendWindow;
}) {
const rows = query.data;
const groups = useMemo(() => groupEvalScores(evalName, rows ?? []), [evalName, rows]);
const runs = scoreStats(groups.evalRows);
const series = useMemo(() => passRateSeries(groups.evalRows, w), [groups.evalRows, w]);

let body: ReactNode;
if (query.error) body = <ObserveError error={query.error} />;
else if (rows === undefined) body = <div className="scores-muted">Loading {evalName}...</div>;
else
body = (
<>
<p className="scores-muted" data-eval-runs={runs.count}>
{fmtInt(runs.count)} runs · Run pass rate {fmtPercent(runs.passRate)}
{rows.length >= EVAL_ROW_LIMIT && ` (newest ${fmtInt(EVAL_ROW_LIMIT)} scores only)`}
</p>
<section className="scores-trend" aria-label={`${evalName} pass-rate trend`}>
<h4 className="scores-subtitle">Pass-rate trend</h4>
{series.points.length ? (
<TimeSeriesChart series={[series]} from={w.from} to={w.to} bucketMs={w.bucketMs} yFormat={fmtPercent} height={160} />
) : (
<p className="scores-muted">No eval-level scores in this window.</p>
)}
</section>
<section aria-label={`${evalName} checks`}>
<h4 className="scores-subtitle">Checks</h4>
<DataTable
id="scores-checks"
columns={CHECK_COLUMNS}
rows={groups.checks}
rowKey={(c) => c.name}
density="compact"
empty={<EmptyState title="No per-check scores" hint="This eval reported no check rows in the window." />}
/>
</section>
<section aria-label={`${evalName} latest failures`}>
<h4 className="scores-subtitle">Latest failures</h4>
<Failures evalName={evalName} rows={groups.failures} />
</section>
</>
);
return (
<section className="scores-detail" id="scores-detail" aria-label={`Eval ${evalName}`} data-eval={evalName}>
<h3 className="scores-detail-title">{evalName}</h3>
{body}
</section>
);
}
Loading
Loading