diff --git a/examples/dice_agent/agent.py b/examples/dice_agent/agent.py
index c2551e1..38866b4 100644
--- a/examples/dice_agent/agent.py
+++ b/examples/dice_agent/agent.py
@@ -56,7 +56,7 @@ def is_prime(n: int) -> bool:
dice_agent = Agent(
name="dice_agent",
# model="gemini-2.5-flash",
- model="gemini-2.5-flash-lite",
+ model="gemini-3-flash-preview",
instruction="""You are a helpful assistant that can roll dice and check if numbers are prime.
When a user asks you to roll a die, use the roll_die tool with the appropriate number of sides.
diff --git a/examples/zero-code-examples/adk/run.py b/examples/zero-code-examples/adk/run.py
index e0d10a5..f5e8419 100644
--- a/examples/zero-code-examples/adk/run.py
+++ b/examples/zero-code-examples/adk/run.py
@@ -44,6 +44,7 @@ async def main():
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
print(f"OTLP endpoint: {endpoint}")
+ os.environ.setdefault("OTEL_SERVICE_NAME", "adk-agent")
os.environ.setdefault(
"OTEL_RESOURCE_ATTRIBUTES",
"agentevals.eval_set_id=dice_agent_eval,agentevals.session_name=adk-zero-code",
diff --git a/examples/zero-code-examples/langchain/run.py b/examples/zero-code-examples/langchain/run.py
index 51174cd..7d283ee 100644
--- a/examples/zero-code-examples/langchain/run.py
+++ b/examples/zero-code-examples/langchain/run.py
@@ -48,6 +48,7 @@ def main():
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
+ os.environ.setdefault("OTEL_SERVICE_NAME", "langchain-agent")
os.environ.setdefault(
"OTEL_RESOURCE_ATTRIBUTES",
"agentevals.eval_set_id=langchain_agent_eval,agentevals.session_name=langchain-zero-code",
diff --git a/examples/zero-code-examples/ollama/run.py b/examples/zero-code-examples/ollama/run.py
index d6f407d..9686d37 100644
--- a/examples/zero-code-examples/ollama/run.py
+++ b/examples/zero-code-examples/ollama/run.py
@@ -112,6 +112,7 @@ def main():
print(f"Local model: {model}")
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
+ os.environ.setdefault("OTEL_SERVICE_NAME", "ollama-agent")
os.environ.setdefault(
"OTEL_RESOURCE_ATTRIBUTES",
"agentevals.eval_set_id=langchain_local_ollama_openai_eval,agentevals.session_name=langchain-ollama-openai-zero-code",
diff --git a/examples/zero-code-examples/openai-agents/run.py b/examples/zero-code-examples/openai-agents/run.py
index ca4b0d7..c1ab13d 100644
--- a/examples/zero-code-examples/openai-agents/run.py
+++ b/examples/zero-code-examples/openai-agents/run.py
@@ -58,6 +58,7 @@ def main():
os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "span_and_event")
os.environ.setdefault("OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai_latest_experimental")
+ os.environ.setdefault("OTEL_SERVICE_NAME", "openai-agents-agent")
os.environ.setdefault(
"OTEL_RESOURCE_ATTRIBUTES",
"agentevals.eval_set_id=openai_agents_eval,agentevals.session_name=openai-agents-zero-code",
diff --git a/examples/zero-code-examples/pydantic-ai/run.py b/examples/zero-code-examples/pydantic-ai/run.py
index 19f34d5..dfc1e3a 100644
--- a/examples/zero-code-examples/pydantic-ai/run.py
+++ b/examples/zero-code-examples/pydantic-ai/run.py
@@ -54,6 +54,7 @@ def main():
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
print(f"OTLP endpoint: {endpoint}")
+ os.environ.setdefault("OTEL_SERVICE_NAME", "pydantic-ai-agent")
os.environ.setdefault(
"OTEL_RESOURCE_ATTRIBUTES",
"agentevals.eval_set_id=pydantic_ai_eval,agentevals.session_name=pydantic-ai-zero-code",
@@ -72,6 +73,7 @@ def main():
agent = Agent(
"openai:gpt-4o-mini",
+ # "openai:gpt-5.4-mini-2026-03-17",
instructions="You are a helpful assistant. You can roll dice and check if numbers are prime.",
)
agent.tool_plain(roll_die)
diff --git a/examples/zero-code-examples/strands/run.py b/examples/zero-code-examples/strands/run.py
index 3de52ef..39d34f6 100644
--- a/examples/zero-code-examples/strands/run.py
+++ b/examples/zero-code-examples/strands/run.py
@@ -40,6 +40,7 @@ def main():
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
print(f"OTLP endpoint: {endpoint}")
+ os.environ.setdefault("OTEL_SERVICE_NAME", "strands-agent")
os.environ.setdefault(
"OTEL_RESOURCE_ATTRIBUTES",
"agentevals.eval_set_id=strands_agent_eval,agentevals.session_name=strands-zero-code",
diff --git a/src/agentevals/api/streaming_routes.py b/src/agentevals/api/streaming_routes.py
index b6a3872..22746c2 100644
--- a/src/agentevals/api/streaming_routes.py
+++ b/src/agentevals/api/streaming_routes.py
@@ -7,16 +7,15 @@
import logging
from typing import TYPE_CHECKING
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field
-from ..config import BuiltinMetricDef, EvalRunConfig, EvaluatorDef
+from ..config import BuiltinMetricDef, EvalParams, EvalRunConfig, EvaluatorDef
from ..converter import convert_traces
from ..loader.otlp import OtlpJsonLoader
-from ..runner import run_evaluation
+from ..runner import RunResult, run_evaluation
from ..trace_attrs import OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_REQUEST_MODEL
-from ..utils.log_enrichment import enrich_spans_with_logs
from .dependencies import require_trace_manager
from .models import (
CreateEvalSetData,
@@ -167,9 +166,47 @@ async def create_eval_set_from_session(
return await _do_create_eval_set(request, manager)
+async def _persist_sessions_run(
+ http_request: Request,
+ *,
+ evaluators: list[EvaluatorDef],
+ eval_set_dict: dict,
+ session_ids: list[str],
+ run_results: list[RunResult],
+ session_errors: list[str],
+) -> None:
+ """Best-effort: persist a UI-driven session evaluation as a single Run row
+ (plus Result rows) when ``app.state.run_service`` is configured (postgres
+ backend), mirroring the offline ``/api/evaluate`` persistence.
+
+ Every evaluated session's traces are aggregated into one run so the run
+ history counts one evaluation per "Evaluate" click, the same way one
+ offline upload of N traces produces one run. No-op (and never raises) on
+ the memory backend or on persistence failure, so the live eval results are
+ always returned to the caller regardless."""
+ service = getattr(http_request.app.state, "run_service", None)
+ if service is None:
+ return
+ trace_results = [tr for rr in run_results for tr in rr.trace_results]
+ if not trace_results:
+ return
+ combined = RunResult(trace_results=trace_results, errors=session_errors)
+ try:
+ await service.record_eval_run(
+ params=EvalParams(evaluators=evaluators),
+ eval_set_dict=eval_set_dict,
+ trace_format="otlp-json",
+ upload_filenames=session_ids,
+ run_result=combined,
+ )
+ except Exception:
+ logger.exception("failed to persist evaluate-sessions run; results still returned to caller")
+
+
@streaming_router.post("/evaluate-sessions", response_model=StandardResponse[EvaluateSessionsData])
async def evaluate_sessions(
request: EvaluateSessionsRequest,
+ http_request: Request,
manager: StreamingTraceManager = Depends(require_trace_manager),
):
"""Evaluate all sessions against a golden session converted to EvalSet."""
@@ -200,7 +237,7 @@ async def evaluate_sessions(
sem = asyncio.Semaphore(5)
- async def eval_one_session(session_id: str, session) -> SessionEvalResult:
+ async def eval_one_session(session_id: str, session) -> tuple[SessionEvalResult, RunResult | None]:
async with sem:
try:
trace_file = await manager._save_spans_to_temp_file(session)
@@ -216,34 +253,47 @@ async def eval_one_session(session_id: str, session) -> SessionEvalResult:
if eval_result.trace_results:
trace_result = eval_result.trace_results[0]
- return SessionEvalResult(
- session_id=session_id,
- trace_id=trace_result.trace_id,
- num_invocations=trace_result.num_invocations,
- metric_results=[
- {
- "metricName": mr.metric_name,
- "score": mr.score,
- "evalStatus": mr.eval_status,
- "error": mr.error,
- "perInvocationScores": mr.per_invocation_scores,
- "details": mr.details,
- }
- for mr in trace_result.metric_results
- ],
+ return (
+ SessionEvalResult(
+ session_id=session_id,
+ trace_id=trace_result.trace_id,
+ num_invocations=trace_result.num_invocations,
+ metric_results=[
+ {
+ "metricName": mr.metric_name,
+ "score": mr.score,
+ "evalStatus": mr.eval_status,
+ "error": mr.error,
+ "perInvocationScores": mr.per_invocation_scores,
+ "details": mr.details,
+ }
+ for mr in trace_result.metric_results
+ ],
+ ),
+ eval_result,
)
else:
logger.warning("No trace results for session %s", session_id)
- return SessionEvalResult(session_id=session_id, error="No trace results")
+ return SessionEvalResult(session_id=session_id, error="No trace results"), None
except Exception as exc:
logger.error(f"Failed to evaluate session {session_id}: {exc}", exc_info=True)
- return SessionEvalResult(session_id=session_id, error=str(exc))
+ return SessionEvalResult(session_id=session_id, error=str(exc)), None
- results = await asyncio.gather(*[eval_one_session(sid, sess) for sid, sess in sessions_to_evaluate])
+ evaluated = await asyncio.gather(*[eval_one_session(sid, sess) for sid, sess in sessions_to_evaluate])
+ results = [session_result for session_result, _ in evaluated]
logger.info("Evaluation complete. Total results: %d", len(results))
+ await _persist_sessions_run(
+ http_request,
+ evaluators=request.evaluators,
+ eval_set_dict=eval_set_response.data.eval_set,
+ session_ids=[sid for sid, _ in sessions_to_evaluate],
+ run_results=[run_result for _, run_result in evaluated if run_result is not None],
+ session_errors=[f"{r.session_id}: {r.error}" for r in results if r.error],
+ )
+
return StandardResponse(
data=EvaluateSessionsData(
golden_session_id=request.golden_session_id,
@@ -344,12 +394,6 @@ async def get_trace(
raise HTTPException(status_code=404, detail="Session not found")
try:
- import tempfile
-
- temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False)
-
- unified_trace_id = session.trace_id
-
has_genai_spans = any(
span.get("attributes", [])
and any(
@@ -366,23 +410,20 @@ async def get_trace(
request.session_id,
)
- enriched_spans = enrich_spans_with_logs(session.spans, session.logs)
-
- for span in enriched_spans:
- span_copy = span.copy()
- span_copy["traceId"] = unified_trace_id
- temp_file.write(json.dumps(span_copy) + "\n")
-
- temp_file.close()
-
- with open(temp_file.name) as f: # noqa: ASYNC230
+ # Reuse the canonical serializer so the trace handed to the UI (and
+ # re-uploaded to /api/evaluate) carries service.name, exactly like the
+ # evaluate-sessions path. Serializing spans here independently would
+ # drop the resource attribute and lose agent identity on the run.
+ trace_file = await manager._save_spans_to_temp_file(session)
+ with open(trace_file) as f: # noqa: ASYNC230
trace_content = f.read()
+ num_spans = sum(1 for line in trace_content.splitlines() if line.strip())
return StandardResponse(
data=GetTraceData(
session_id=request.session_id,
trace_content=trace_content,
- num_spans=len(enriched_spans),
+ num_spans=num_spans,
)
)
diff --git a/src/agentevals/run/result_builder.py b/src/agentevals/run/result_builder.py
index 8c18a03..0b18a28 100644
--- a/src/agentevals/run/result_builder.py
+++ b/src/agentevals/run/result_builder.py
@@ -99,27 +99,63 @@ def build_results(run_id: UUID, params: EvalParams, run_result: RunResult) -> li
return out
+def _status_key(mr: MetricResult) -> str:
+ """Map a :class:`MetricResult` onto a :class:`ResultStatus` string,
+ matching :func:`result_from_metric_result` so the summary counts and the
+ persisted ``result`` rows never disagree."""
+ if mr.error:
+ return "errored"
+ status = (mr.eval_status or "").upper()
+ if status == "PASSED":
+ return "passed"
+ if status == "FAILED":
+ return "failed"
+ return "skipped"
+
+
def summarize_run_result(run_result: RunResult) -> dict[str, Any]:
"""Summary blob persisted alongside the run row.
Counts mirror :class:`agentevals.storage.models.ResultStatus` values so a
caller polling ``GET /api/runs/{id}`` can compute pass/fail rates without
fetching the full result list.
+
+ ``per_metric`` aggregates the same statuses by ``evaluator_name`` plus a
+ mean ``score``, so the run-history dashboard can chart per-metric trends
+ across runs from the list response alone, without an N+1 over
+ ``/api/runs/{id}/results``. ``avg_score`` is ``None`` when no invocation of
+ that evaluator produced a numeric score (e.g. it only errored).
"""
counts = {"passed": 0, "failed": 0, "errored": 0, "skipped": 0}
+ per_metric: dict[str, dict[str, Any]] = {}
+ agents: set[str] = set()
for tr in run_result.trace_results:
+ identity = tr.service_name or tr.agent_name
+ if identity:
+ agents.add(identity)
for mr in tr.metric_results:
- if mr.error:
- counts["errored"] += 1
- elif (mr.eval_status or "").upper() == "PASSED":
- counts["passed"] += 1
- elif (mr.eval_status or "").upper() == "FAILED":
- counts["failed"] += 1
- else:
- counts["skipped"] += 1
+ key = _status_key(mr)
+ counts[key] += 1
+
+ metric = per_metric.setdefault(
+ mr.metric_name,
+ {"passed": 0, "failed": 0, "errored": 0, "skipped": 0, "_score_sum": 0.0, "_score_count": 0},
+ )
+ metric[key] += 1
+ if mr.score is not None:
+ metric["_score_sum"] += mr.score
+ metric["_score_count"] += 1
+
+ for metric in per_metric.values():
+ score_count = metric.pop("_score_count")
+ score_sum = metric.pop("_score_sum")
+ metric["avg_score"] = (score_sum / score_count) if score_count else None
+
return {
"trace_count": len(run_result.trace_results),
"result_counts": counts,
+ "per_metric": per_metric,
+ "agents": sorted(agents),
"errors": list(run_result.errors),
"performance_metrics": run_result.performance_metrics,
}
diff --git a/src/agentevals/runner.py b/src/agentevals/runner.py
index 0ebf1fb..ebca577 100644
--- a/src/agentevals/runner.py
+++ b/src/agentevals/runner.py
@@ -22,7 +22,7 @@
from .converter import ConversionResult, convert_traces
from .loader import load_traces
from .loader.base import Trace
-from .trace_metrics import _calc_percentiles, extract_performance_metrics
+from .trace_metrics import _calc_percentiles, extract_agent_identity, extract_performance_metrics
logger = logging.getLogger(__name__)
@@ -50,6 +50,8 @@ class TraceResult(BaseModel):
metric_results: list[MetricResult] = Field(default_factory=list)
conversion_warnings: list[str] = Field(default_factory=list)
performance_metrics: dict[str, Any] | None = None
+ service_name: str | None = None
+ agent_name: str | None = None
class RunResult(BaseModel):
@@ -111,7 +113,7 @@ async def _evaluate_trace_bounded(idx: int, conv_result: ConversionResult) -> Tr
trace = trace_map.get(conv_result.trace_id)
- return await _evaluate_trace(
+ trace_result = await _evaluate_trace(
conv_result=conv_result,
evaluators=config.evaluators,
eval_set=eval_set,
@@ -121,6 +123,11 @@ async def _evaluate_trace_bounded(idx: int, conv_result: ConversionResult) -> Tr
trace=trace,
performance_metrics=perf_metrics_map.get(conv_result.trace_id),
)
+ if trace is not None:
+ identity = extract_agent_identity(trace)
+ trace_result.service_name = identity["service_name"]
+ trace_result.agent_name = identity["agent_name"]
+ return trace_result
trace_results = await asyncio.gather(
*[_evaluate_trace_bounded(idx, conv_result) for idx, conv_result in enumerate(conversion_results)],
diff --git a/src/agentevals/streaming/ws_server.py b/src/agentevals/streaming/ws_server.py
index 0163245..f7448fc 100644
--- a/src/agentevals/streaming/ws_server.py
+++ b/src/agentevals/streaming/ws_server.py
@@ -28,7 +28,7 @@
)
from ..loader.base import Trace
from ..loader.otlp import OtlpJsonLoader
-from ..trace_attrs import OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_REQUEST_MODEL
+from ..trace_attrs import OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_REQUEST_MODEL, OTEL_SERVICE_NAME
from ..utils.log_enrichment import enrich_spans_with_logs
from .incremental_processor import IncrementalInvocationExtractor
from .session import TraceSession
@@ -658,10 +658,21 @@ async def _save_spans_to_temp_file(self, session: TraceSession) -> Path:
enriched_spans = enrich_spans_with_logs(session.spans, session.logs, session.session_id)
+ # service.name is an OTel resource attribute, so it lives on the session
+ # (lifted from resource attrs at ingest) rather than on individual spans.
+ # The JSONL trace format has no resource envelope, so re-attach it to each
+ # span here; otherwise it's lost on reload and runs can't group by agent.
+ service_name = (session.metadata or {}).get(OTEL_SERVICE_NAME)
+
with open(temp_file, "w") as f: # noqa: ASYNC230
for span in enriched_spans:
span_copy = span.copy()
span_copy["traceId"] = session.trace_id
+ if service_name:
+ attrs = list(span_copy.get("attributes", []))
+ if not any(a.get("key") == OTEL_SERVICE_NAME for a in attrs):
+ attrs.append({"key": OTEL_SERVICE_NAME, "value": {"stringValue": service_name}})
+ span_copy["attributes"] = attrs
f.write(json.dumps(span_copy) + "\n")
return temp_file
diff --git a/src/agentevals/trace_attrs.py b/src/agentevals/trace_attrs.py
index 5aedc88..6bfc0df 100644
--- a/src/agentevals/trace_attrs.py
+++ b/src/agentevals/trace_attrs.py
@@ -6,6 +6,9 @@
Covers OTel GenAI semantic conventions up to v1.40.0.
"""
+# OTel resource
+OTEL_SERVICE_NAME = "service.name"
+
# OTel scope
OTEL_SCOPE = "otel.scope.name"
OTEL_SCOPE_VERSION = "otel.scope.version"
diff --git a/src/agentevals/trace_metrics.py b/src/agentevals/trace_metrics.py
index a349817..8009957 100644
--- a/src/agentevals/trace_metrics.py
+++ b/src/agentevals/trace_metrics.py
@@ -16,9 +16,41 @@
OTEL_GENAI_AGENT_NAME,
OTEL_GENAI_REQUEST_MODEL,
OTEL_GENAI_TOOL_NAME,
+ OTEL_SERVICE_NAME,
)
+def _first_service_name(trace) -> str | None:
+ """The OTel ``service.name`` resource attribute, merged onto every span at
+ ingest. Cross-framework, so it's the stable identifier for grouping runs by
+ agent regardless of instrumentation."""
+ for span in trace.all_spans:
+ value = span.get_tag(OTEL_SERVICE_NAME)
+ if value:
+ return value
+ return None
+
+
+def extract_agent_identity(trace, extractor=None) -> dict[str, str | None]:
+ """Best-effort agent identity for grouping runs. Prefers ``service.name``;
+ falls back only to a real ``gen_ai.agent.name``.
+
+ Deliberately does NOT fall back to the root span's operation name: for
+ OTel GenAI traces that's the LLM call name (e.g. "chat gpt-4o-mini"), which
+ is a model, not an agent, and would mislabel agent groups in the dashboard.
+ """
+ agent_name = None
+ try:
+ if extractor is None:
+ extractor = get_extractor(trace)
+ invocation_spans = extractor.find_invocation_spans(trace)
+ if invocation_spans:
+ agent_name = invocation_spans[0].get_tag(OTEL_GENAI_AGENT_NAME)
+ except Exception:
+ agent_name = None
+ return {"service_name": _first_service_name(trace), "agent_name": agent_name}
+
+
def _truncate(text: str, max_length: int = 200) -> str:
if len(text) <= max_length:
return text
@@ -149,6 +181,7 @@ def extract_trace_metadata(trace, extractor=None) -> dict[str, Any]:
metadata: dict[str, Any] = {
"agent_name": None,
"agent_id": None,
+ "service_name": _first_service_name(trace),
"model": None,
"response_model": None,
"provider": None,
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index dfffdbe..d7d66bd 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -7,6 +7,7 @@ import { InspectorView } from './components/inspector/InspectorView';
import { BuilderView } from './components/builder/BuilderView';
import { LiveStreamingView } from './components/streaming/LiveStreamingView';
import { AnnotationQueueView } from './components/annotation-queue/AnnotationQueueView';
+import { RunsView } from './components/runs/RunsView';
import { Sidebar } from './components/sidebar/Sidebar';
function AppContent() {
@@ -24,6 +25,7 @@ function AppContent() {
{state.currentView === 'builder' && }
{state.currentView === 'streaming' && }
{state.currentView === 'annotation-queue' && }
+ {state.currentView === 'runs' && }
{state.currentView === 'comparison' && (
Comparison view coming soon...
diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts
index feb20f6..ac81f1a 100644
--- a/ui/src/api/client.ts
+++ b/ui/src/api/client.ts
@@ -5,9 +5,21 @@ import type {
MetricMetadata,
StandardResponse,
ConvertTracesResponse,
+ Run,
+ RunStatus,
+ RunResultRow,
} from '../lib/types';
import { config } from '../config';
+export class StorageUnavailableError extends Error {
+ constructor() {
+ super(
+ 'Run history requires the durable storage backend. Start the server with AGENTEVALS_STORAGE_BACKEND=postgres and AGENTEVALS_DATABASE_URL set.',
+ );
+ this.name = 'StorageUnavailableError';
+ }
+}
+
const API_BASE_URL = `${config.api.baseUrl}/api`;
async function unwrap
(response: Response): Promise {
@@ -186,6 +198,42 @@ export async function evaluateTracesStreaming(
}
}
+export async function listRuns(options?: {
+ status?: RunStatus[];
+ limit?: number;
+ before?: string;
+}): Promise {
+ const params = new URLSearchParams();
+ (options?.status ?? []).forEach(s => params.append('status', s));
+ if (options?.limit != null) params.set('limit', String(options.limit));
+ if (options?.before) params.set('before', options.before);
+ const query = params.toString();
+
+ const response = await fetch(`${config.api.endpoints.runs}${query ? `?${query}` : ''}`);
+
+ if (response.status === 503) {
+ throw new StorageUnavailableError();
+ }
+ if (!response.ok) {
+ throw new Error(`Failed to list runs: ${response.statusText}`);
+ }
+ return unwrap(response);
+}
+
+export async function getRun(runId: string): Promise {
+ const response = await fetch(`${config.api.endpoints.runs}/${runId}`);
+ if (response.status === 503) throw new StorageUnavailableError();
+ if (!response.ok) throw new Error(`Failed to fetch run: ${response.statusText}`);
+ return unwrap(response);
+}
+
+export async function getRunResults(runId: string): Promise {
+ const response = await fetch(`${config.api.endpoints.runs}/${runId}/results`);
+ if (response.status === 503) throw new StorageUnavailableError();
+ if (!response.ok) throw new Error(`Failed to fetch run results: ${response.statusText}`);
+ return unwrap(response);
+}
+
export async function listMetrics(): Promise {
try {
const response = await fetch(`${API_BASE_URL}/metrics`);
diff --git a/ui/src/components/dashboard/DashboardView.tsx b/ui/src/components/dashboard/DashboardView.tsx
index 9057431..b0e05c2 100644
--- a/ui/src/components/dashboard/DashboardView.tsx
+++ b/ui/src/components/dashboard/DashboardView.tsx
@@ -129,6 +129,17 @@ export const DashboardView: React.FC = () => {
setHoveredTraceId(traceId);
};
+ // The golden/reference trace is plotted in the performance charts (as a
+ // baseline) but excluded from pass/fail scoring - summary stats and the
+ // results table. Charts get the full set plus the golden id for labeling.
+ const goldenTraceId = state.goldenTraceId;
+ const scoredResults = goldenTraceId
+ ? state.results.filter((r) => r.traceId !== goldenTraceId)
+ : state.results;
+ const scoredRows = Array.from(state.tableRows.values()).filter(
+ (r) => !goldenTraceId || r.traceId !== goldenTraceId,
+ );
+
return (
@@ -178,9 +189,13 @@ export const DashboardView: React.FC = () => {
{state.tableRows.size > 0 && (
<>
- {state.results.length > 0 &&
}
+ {scoredResults.length > 0 &&
}
-
+
{state.isEvaluating && (
@@ -192,7 +207,7 @@ export const DashboardView: React.FC = () => {
)}
= ({ traceResults, hoveredTraceId }) => {
+export const PerformanceCharts: React.FC = ({ traceResults, hoveredTraceId, goldenTraceId }) => {
const tracesWithPerf = traceResults.filter(tr => tr.performanceMetrics);
if (tracesWithPerf.length === 0) {
return null;
}
+ const isGolden = (tr: TraceResult) => goldenTraceId != null && tr.traceId === goldenTraceId;
+
const sortedTraces = [...tracesWithPerf].sort((a, b) => {
+ // Golden reference first, so it reads as the baseline the runs compare to.
+ if (isGolden(a) !== isGolden(b)) return isGolden(a) ? -1 : 1;
const aSession = a.sessionId || a.traceId;
const bSession = b.sessionId || b.traceId;
return aSession.localeCompare(bSession);
});
- const labels = sortedTraces.map(tr => tr.sessionId || tr.traceId.substring(0, 12));
+ const labels = sortedTraces.map(tr => {
+ const base = tr.sessionId || tr.traceId.substring(0, 12);
+ return isGolden(tr) ? `${base} (reference)` : base;
+ });
const hoveredIndex = hoveredTraceId
? sortedTraces.findIndex(tr => tr.traceId === hoveredTraceId)
diff --git a/ui/src/components/dashboard/TraceTable.tsx b/ui/src/components/dashboard/TraceTable.tsx
index 75f9c66..890cf4e 100644
--- a/ui/src/components/dashboard/TraceTable.tsx
+++ b/ui/src/components/dashboard/TraceTable.tsx
@@ -3,8 +3,19 @@ import { Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { css } from '@emotion/react';
import { Clock, User, Cpu, MessageSquare, CheckCircle2, XCircle, Loader2, ChevronRight, ChevronDown } from 'lucide-react';
-import type { TraceTableRow, Annotation } from '../../lib/types';
+import type { TraceTableRow, Annotation, TrajectoryComparison } from '../../lib/types';
import { formatTimestamp } from '../../lib/utils';
+import { TrajectoryComparisonDetails } from '../inspector/TrajectoryComparisonDetails';
+
+const TRAJECTORY_METRIC = 'tool_trajectory_avg_score';
+
+function getTrajectoryComparisons(record: TraceTableRow): TrajectoryComparison[] {
+ return record.metricResults.get(TRAJECTORY_METRIC)?.details?.comparisons ?? [];
+}
+
+function hasTrajectoryMismatch(record: TraceTableRow): boolean {
+ return getTrajectoryComparisons(record).some(c => !c.matched);
+}
interface TraceTableProps {
rows: TraceTableRow[];
@@ -121,6 +132,19 @@ const tableStyle = css`
white-space: nowrap;
}
+ .diff-badge {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--status-failure);
+ background: rgba(255, 87, 87, 0.12);
+ border: 1px solid rgba(255, 87, 87, 0.3);
+ border-radius: 4px;
+ padding: 1px 6px;
+ cursor: pointer;
+ }
+
@keyframes spin {
from {
transform: rotate(0deg);
@@ -238,27 +262,35 @@ export const TraceTable: React.FC = ({
const numTurns = record.invocations?.length || record.numInvocations || 1;
const isExpanded = expandedRowKeys.includes(record.traceId);
+ const mismatch = hasTrajectoryMismatch(record);
+ const expandable = numTurns > 1 || mismatch;
+
+ const toggle = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setExpandedRowKeys(
+ isExpanded
+ ? expandedRowKeys.filter(k => k !== record.traceId)
+ : [...expandedRowKeys, record.traceId],
+ );
+ };
- if (numTurns > 1) {
+ if (expandable) {
return (
- {
- e.stopPropagation();
- if (isExpanded) {
- setExpandedRowKeys(expandedRowKeys.filter(k => k !== record.traceId));
- } else {
- setExpandedRowKeys([...expandedRowKeys, record.traceId]);
- }
- }}
- style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
- >
+
{isExpanded ? : }
-
- {numTurns} turns
-
+ {numTurns > 1 ? (
+ {numTurns} turns
+ ) : (
+ {record.userInputPreview}
+ )}
+ {mismatch && (
+
+ diff
+
+ )}
);
}
@@ -385,7 +417,9 @@ export const TraceTable: React.FC = ({
const expandedRowRender = (record: TraceTableRow) => {
const invocations = record.invocations || [];
- if (invocations.length === 0) return null;
+ const comparisons = getTrajectoryComparisons(record);
+ const showDiff = comparisons.some(c => !c.matched);
+ if (invocations.length === 0 && !showDiff) return null;
return (
= ({
);
})}
+ {showDiff && (
+ 0 ? '16px' : '0' }}>
+
+
+ )}
);
};
@@ -524,7 +563,7 @@ export const TraceTable: React.FC
= ({
}
},
expandedRowRender,
- rowExpandable: (record) => (record.invocations?.length || 0) > 1,
+ rowExpandable: (record) => (record.invocations?.length || 0) > 1 || hasTrajectoryMismatch(record),
expandIcon: () => null, // Hide default expand icon since we show it in the Conversation column
}}
onRow={(record) => ({
diff --git a/ui/src/components/runs/PassRateTrendChart.tsx b/ui/src/components/runs/PassRateTrendChart.tsx
new file mode 100644
index 0000000..5d6d16d
--- /dev/null
+++ b/ui/src/components/runs/PassRateTrendChart.tsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import { css } from '@emotion/react';
+import { Line } from 'react-chartjs-2';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Filler,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import type { Run } from '../../lib/types';
+import {
+ CHART_COLORS,
+ METRIC_PALETTE,
+ agentNamesAcross,
+ formatTimestamp,
+ passRate,
+ runAgents,
+} from './runHistory';
+
+ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip, Legend);
+
+interface PassRateTrendChartProps {
+ runs: Run[];
+}
+
+const toPercent = (rate: number) => Math.round(rate * 1000) / 10;
+
+export const PassRateTrendChart: React.FC = ({ runs }) => {
+ const hasDecided = runs.some(run => passRate(run) !== null);
+ if (!hasDecided) {
+ return (
+
+
Pass rate over time
+
No decided (pass/fail) results yet.
+
+ );
+ }
+
+ const agentNames = agentNamesAcross(runs);
+ const labels = runs.map(run => formatTimestamp(run.createdAt));
+
+ // One line per agent (service.name) on a shared run-ordered time axis; a run
+ // contributes a point only to the agent(s) it ran, null elsewhere so lines
+ // break across gaps. Runs with no agent identity fall back to one aggregate
+ // line so older runs still chart.
+ const datasets = agentNames.length
+ ? agentNames.map((name, index) => {
+ const color = METRIC_PALETTE[index % METRIC_PALETTE.length];
+ return {
+ label: name,
+ data: runs.map(run => {
+ const rate = passRate(run);
+ return runAgents(run).includes(name) && rate !== null ? toPercent(rate) : null;
+ }),
+ borderColor: color,
+ backgroundColor: color,
+ spanGaps: false,
+ tension: 0.25,
+ pointRadius: 3,
+ pointHoverRadius: 5,
+ borderWidth: 2,
+ };
+ })
+ : [
+ {
+ label: 'Pass rate',
+ data: runs.map(run => {
+ const rate = passRate(run);
+ return rate === null ? null : toPercent(rate);
+ }),
+ borderColor: CHART_COLORS.passRate,
+ backgroundColor: CHART_COLORS.passRateFill,
+ fill: true,
+ spanGaps: false,
+ tension: 0.25,
+ pointRadius: 3,
+ pointHoverRadius: 5,
+ borderWidth: 2,
+ },
+ ];
+
+ const multiAgent = agentNames.length > 0;
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { mode: 'index' as const, intersect: false },
+ plugins: {
+ legend: {
+ display: multiAgent,
+ position: 'bottom' as const,
+ labels: { color: CHART_COLORS.text, font: { size: 12 }, padding: 14, usePointStyle: true },
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
+ titleColor: '#fff',
+ bodyColor: '#fff',
+ padding: 12,
+ cornerRadius: 6,
+ callbacks: {
+ label: (ctx: { dataset: { label?: string }; parsed: { y: number } }) =>
+ `${ctx.dataset.label}: ${ctx.parsed.y}%`,
+ },
+ },
+ },
+ scales: {
+ y: {
+ min: 0,
+ max: 100,
+ ticks: {
+ color: CHART_COLORS.text,
+ font: { size: 12 },
+ callback: (value: string | number) => `${value}%`,
+ },
+ grid: { color: CHART_COLORS.grid },
+ },
+ x: {
+ ticks: { color: CHART_COLORS.text, font: { size: 11 }, maxRotation: 0, autoSkip: true },
+ grid: { color: CHART_COLORS.grid },
+ },
+ },
+ };
+
+ return (
+
+
Pass rate over time
+
+
+
+
+ );
+};
+
+const cardStyle = css`
+ background: var(--bg-surface);
+ border: 1px solid var(--border-default);
+ border-radius: 8px;
+ padding: 20px;
+
+ h3 {
+ margin: 0 0 16px 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+`;
+
+const chartWrapperStyle = css`
+ height: 300px;
+ position: relative;
+`;
+
+const emptyStyle = css`
+ color: var(--text-tertiary);
+ font-size: 0.875rem;
+ margin: 0;
+`;
diff --git a/ui/src/components/runs/PerMetricTrendChart.tsx b/ui/src/components/runs/PerMetricTrendChart.tsx
new file mode 100644
index 0000000..28e32c0
--- /dev/null
+++ b/ui/src/components/runs/PerMetricTrendChart.tsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import { css } from '@emotion/react';
+import { Line } from 'react-chartjs-2';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import type { Run } from '../../lib/types';
+import { CHART_COLORS, METRIC_PALETTE, formatTimestamp, metricNamesAcross } from './runHistory';
+
+ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend);
+
+interface PerMetricTrendChartProps {
+ runs: Run[];
+}
+
+export const PerMetricTrendChart: React.FC = ({ runs }) => {
+ const metricNames = metricNamesAcross(runs);
+
+ if (metricNames.length === 0) {
+ return (
+
+
Per-metric score trend
+
+ No per-metric scores recorded. Runs created before this feature won't have them.
+
+
+ );
+ }
+
+ const labels = runs.map(run => formatTimestamp(run.createdAt));
+
+ const datasets = metricNames.map((name, index) => {
+ const color = METRIC_PALETTE[index % METRIC_PALETTE.length];
+ return {
+ label: name,
+ // null where this metric is absent or produced no numeric score in a run,
+ // so the line breaks instead of implying a real drop to zero.
+ data: runs.map(run => {
+ const score = run.summary?.per_metric?.[name]?.avg_score;
+ return score == null ? null : Math.round(score * 1000) / 1000;
+ }),
+ borderColor: color,
+ backgroundColor: color,
+ spanGaps: false,
+ tension: 0.25,
+ pointRadius: 3,
+ pointHoverRadius: 5,
+ borderWidth: 2,
+ };
+ });
+
+ const options = {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { mode: 'index' as const, intersect: false },
+ plugins: {
+ legend: {
+ position: 'bottom' as const,
+ labels: { color: CHART_COLORS.text, font: { size: 12 }, padding: 14, usePointStyle: true },
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
+ titleColor: '#fff',
+ bodyColor: '#fff',
+ padding: 12,
+ cornerRadius: 6,
+ },
+ },
+ scales: {
+ y: {
+ min: 0,
+ max: 1,
+ ticks: { color: CHART_COLORS.text, font: { size: 12 } },
+ grid: { color: CHART_COLORS.grid },
+ title: { display: true, text: 'Avg score', color: CHART_COLORS.text, font: { size: 12 } },
+ },
+ x: {
+ ticks: { color: CHART_COLORS.text, font: { size: 11 }, maxRotation: 0, autoSkip: true },
+ grid: { color: CHART_COLORS.grid },
+ },
+ },
+ };
+
+ return (
+
+
Per-metric score trend
+
+
+
+
+ );
+};
+
+const cardStyle = css`
+ background: var(--bg-surface);
+ border: 1px solid var(--border-default);
+ border-radius: 8px;
+ padding: 20px;
+
+ h3 {
+ margin: 0 0 16px 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+`;
+
+const chartWrapperStyle = css`
+ height: 300px;
+ position: relative;
+`;
+
+const emptyStyle = css`
+ color: var(--text-tertiary);
+ font-size: 0.875rem;
+ margin: 0;
+`;
diff --git a/ui/src/components/runs/RunDetailView.tsx b/ui/src/components/runs/RunDetailView.tsx
new file mode 100644
index 0000000..bb67370
--- /dev/null
+++ b/ui/src/components/runs/RunDetailView.tsx
@@ -0,0 +1,536 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { css } from '@emotion/react';
+import {
+ ChevronLeft,
+ CheckCircle2,
+ XCircle,
+ AlertCircle,
+ MinusCircle,
+ ChevronRight,
+ ChevronDown,
+} from 'lucide-react';
+import type { Run, RunResultRow, ResultStatus2, ToolCallComparison } from '../../lib/types';
+import { getRun, getRunResults, StorageUnavailableError } from '../../api/client';
+import { STATUS_COLORS, formatDuration, formatTimestamp, passRate, runDurationMs } from './runHistory';
+
+interface RunDetailViewProps {
+ runId: string;
+ onBack: () => void;
+}
+
+const RESULT_STATUS: Record = {
+ passed: { color: 'var(--status-success)', Icon: CheckCircle2 },
+ failed: { color: 'var(--status-failure)', Icon: XCircle },
+ errored: { color: 'var(--status-warning)', Icon: AlertCircle },
+ skipped: { color: 'var(--text-tertiary)', Icon: MinusCircle },
+};
+
+function formatToolCall(tc: ToolCallComparison): string {
+ const args = tc.args && Object.keys(tc.args).length ? JSON.stringify(tc.args) : '';
+ return `${tc.name ?? '?'}(${args})`;
+}
+
+export const RunDetailView: React.FC = ({ runId, onBack }) => {
+ const [run, setRun] = useState(null);
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [expanded, setExpanded] = useState>(new Set());
+ const [showGolden, setShowGolden] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+ (async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [r, rows] = await Promise.all([getRun(runId), getRunResults(runId)]);
+ if (!active) return;
+ setRun(r);
+ setResults(rows);
+ } catch (err) {
+ if (!active) return;
+ setError(
+ err instanceof StorageUnavailableError
+ ? err.message
+ : err instanceof Error
+ ? err.message
+ : 'Failed to load run',
+ );
+ } finally {
+ if (active) setLoading(false);
+ }
+ })();
+ return () => {
+ active = false;
+ };
+ }, [runId]);
+
+ const groupedResults = useMemo(() => {
+ const byCase = new Map();
+ for (const row of results) {
+ const list = byCase.get(row.evalSetItemName) ?? [];
+ list.push(row);
+ byCase.set(row.evalSetItemName, list);
+ }
+ return [...byCase.entries()];
+ }, [results]);
+
+ const toggle = (id: string) =>
+ setExpanded(prev => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+
+ const evaluators = run?.spec?.evalConfig?.evaluators ?? [];
+ const goldenCases = (run?.spec?.evalSet?.eval_cases ?? []) as unknown[];
+ const rate = run ? passRate(run) : null;
+ const counts = run?.summary?.result_counts;
+
+ return (
+
+
+
+ Run history
+
+
+ {loading &&
Loading run...
}
+ {error && (
+
+ )}
+
+ {run && !loading && (
+ <>
+
+ {run.spec?.evalSet?.name || run.spec?.evalSet?.eval_set_id || `Run ${run.runId.slice(0, 8)}`}
+
+
+
+ {run.status}
+
+ {run.summary?.agents?.length ? (
+
+ agents: {run.summary.agents.join(', ')}
+
+ ) : null}
+ {formatTimestamp(run.createdAt)}
+ duration: {formatDuration(runDurationMs(run))}
+
+ target: {run.spec?.target?.kind ?? '-'}
+ {run.summary?.trace_count != null ? ` (${run.summary.trace_count} traces)` : ''}
+
+ {rate !== null && (
+ = 0.5 ? 'var(--status-success)' : 'var(--status-failure)' }}>
+ {Math.round(rate * 100)}% pass
+
+ )}
+
+ {counts && (
+
+ {counts.passed} passed
+ {counts.failed} failed
+ {counts.errored > 0 && {counts.errored} errored }
+ {counts.skipped > 0 && {counts.skipped} skipped }
+
+ )}
+ {run.error && }
+
+
+
+ Configuration
+ {evaluators.length === 0 ? (
+ No evaluator configuration recorded.
+ ) : (
+
+
+
+ Evaluator
+ Type
+ Threshold
+ Judge model
+ Match
+
+
+
+ {evaluators.map((e, i) => (
+
+ {e.name ?? '-'}
+ {e.type ?? '-'}
+ {e.threshold ?? '-'}
+ {e.judge_model ?? '-'}
+ {e.trajectory_match_type ?? '-'}
+
+ ))}
+
+
+ )}
+
+
+
+ Results
+ {groupedResults.length === 0 ? (
+ No per-result rows persisted for this run.
+ ) : (
+ groupedResults.map(([caseName, rows]) => (
+
+
+ {caseName}
+ {rows[0]?.traceId && rows[0].traceId !== caseName && (
+ trace {rows[0].traceId.slice(0, 12)}
+ )}
+
+ {rows.map(row => {
+ const meta = RESULT_STATUS[row.status];
+ const Icon = meta.Icon;
+ const comparisons = row.details?.comparisons ?? [];
+ const isOpen = expanded.has(row.resultId);
+ return (
+
+
comparisons.length && toggle(row.resultId)}
+ style={{ cursor: comparisons.length ? 'pointer' : 'default' }}
+ >
+ {comparisons.length ? (
+ isOpen ? :
+ ) : (
+
+ )}
+
+ {row.evaluatorName}
+ {row.score !== null && (
+ {row.score.toFixed(3)}
+ )}
+ {row.perInvocationScores.length > 0 && (
+
+ [{row.perInvocationScores.map(s => (s === null ? '-' : s.toFixed(2))).join(', ')}]
+
+ )}
+ {row.latencyMs != null && {row.latencyMs}ms }
+
+ {row.errorText &&
{row.errorText}
}
+ {isOpen && comparisons.length > 0 && (
+
+ {comparisons.map((c, idx) => (
+
+
+ {c.matched ? (
+
+ ) : (
+
+ )}
+ invocation {idx + 1}
+
+
+
+
Expected
+ {(c.expected ?? []).length === 0 ? (
+
(no tool calls)
+ ) : (
+ (c.expected ?? []).map((tc, i) => (
+
{formatToolCall(tc)}
+ ))
+ )}
+
+
+
Actual
+ {(c.actual ?? []).length === 0 ? (
+
(no tool calls)
+ ) : (
+ (c.actual ?? []).map((tc, i) => (
+
+ {formatToolCall(tc)}
+
+ ))
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ ))
+ )}
+
+
+ {goldenCases.length > 0 && (
+
+ setShowGolden(v => !v)}
+ >
+ {showGolden ? : }
+ Reference: golden eval set ({goldenCases.length} case{goldenCases.length === 1 ? '' : 's'})
+
+ {showGolden && (
+ {JSON.stringify(run.spec?.evalSet?.eval_cases, null, 2)}
+ )}
+
+ )}
+ >
+ )}
+
+ );
+};
+
+const pageStyle = css`
+ padding: 32px;
+ max-width: 1100px;
+ margin: 0 auto;
+`;
+
+const backStyle = css`
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ padding: 4px 0;
+ margin-bottom: 16px;
+
+ &:hover {
+ color: var(--accent-primary);
+ }
+`;
+
+const headerStyle = css`
+ margin-bottom: 24px;
+
+ h1 {
+ margin: 0 0 12px;
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: var(--font-display);
+ }
+`;
+
+const metaRowStyle = css`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ align-items: center;
+`;
+
+const metaItemStyle = css`
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+
+ strong {
+ color: var(--accent-primary);
+ font-family: var(--font-mono);
+ }
+`;
+
+const badgeStyle = css`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ text-transform: capitalize;
+ font-size: 0.8125rem;
+ font-weight: 600;
+`;
+
+const dotStyle = css`
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+`;
+
+const countsRowStyle = css`
+ display: flex;
+ gap: 16px;
+ margin-top: 10px;
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ font-weight: 600;
+`;
+
+const cardStyle = css`
+ background: var(--bg-surface);
+ border: 1px solid var(--border-default);
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+
+ h2 {
+ margin: 0 0 16px;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+`;
+
+const collapsibleHeadingStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ user-select: none;
+ margin: 0 !important;
+`;
+
+const configTableStyle = css`
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8125rem;
+
+ th {
+ text-align: left;
+ color: var(--text-tertiary);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 0.6875rem;
+ letter-spacing: 0.5px;
+ padding: 6px 12px 6px 0;
+ }
+
+ td {
+ padding: 6px 12px 6px 0;
+ color: var(--text-secondary);
+ border-top: 1px solid var(--border-subtle);
+ }
+`;
+
+const caseBlockStyle = css`
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ margin-bottom: 12px;
+ overflow: hidden;
+`;
+
+const caseHeaderStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: var(--bg-elevated);
+ border-bottom: 1px solid var(--border-subtle);
+`;
+
+const resultRowStyle = css`
+ padding: 8px 14px;
+ border-bottom: 1px solid var(--border-subtle);
+
+ &:last-child {
+ border-bottom: none;
+ }
+`;
+
+const resultHeadStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+`;
+
+const evaluatorNameStyle = css`
+ flex: 1;
+ font-size: 0.875rem;
+ color: var(--text-primary);
+`;
+
+const monoStyle = css`
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+`;
+
+const subtleMonoStyle = css`
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+`;
+
+const resultErrorStyle = css`
+ margin: 6px 0 0 24px;
+ font-size: 0.8125rem;
+ color: var(--status-failure);
+ font-family: var(--font-mono);
+`;
+
+const comparisonsStyle = css`
+ margin: 10px 0 4px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+`;
+
+const invocationStyle = css`
+ border-left: 2px solid var(--border-default);
+ padding-left: 12px;
+`;
+
+const invocationHeadStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+ margin-bottom: 6px;
+`;
+
+const diffGridStyle = css`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+`;
+
+const diffLabelStyle = css`
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-tertiary);
+ margin-bottom: 4px;
+`;
+
+const toolCallStyle = css`
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+ word-break: break-all;
+`;
+
+const jsonStyle = css`
+ background: var(--bg-primary);
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ padding: 12px;
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ overflow-x: auto;
+ max-height: 400px;
+`;
+
+const mutedStyle = css`
+ color: var(--text-tertiary);
+ font-size: 0.9rem;
+`;
+
+const errorStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-top: 12px;
+ background: rgba(255, 87, 87, 0.08);
+ color: var(--status-failure);
+ font-size: 0.875rem;
+`;
diff --git a/ui/src/components/runs/RunsHistoryTable.tsx b/ui/src/components/runs/RunsHistoryTable.tsx
new file mode 100644
index 0000000..a810091
--- /dev/null
+++ b/ui/src/components/runs/RunsHistoryTable.tsx
@@ -0,0 +1,273 @@
+import React from 'react';
+import { css } from '@emotion/react';
+import { Table } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import type { Run } from '../../lib/types';
+import {
+ STATUS_COLORS,
+ evalSetGroup,
+ formatDuration,
+ formatTimestamp,
+ passRate,
+ runDurationMs,
+} from './runHistory';
+
+interface RunsHistoryTableProps {
+ runs: Run[];
+ onSelectRun?: (runId: string) => void;
+}
+
+export const RunsHistoryTable: React.FC = ({ runs, onSelectRun }) => {
+ const rows = [...runs].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+
+ const columns: ColumnsType = [
+ {
+ title: 'When',
+ dataIndex: 'createdAt',
+ key: 'when',
+ render: (value: string) => {formatTimestamp(value)} ,
+ },
+ {
+ title: 'Status',
+ dataIndex: 'status',
+ key: 'status',
+ render: (status: Run['status']) => (
+
+
+ {status}
+
+ ),
+ },
+ {
+ title: 'Eval set',
+ key: 'evalSet',
+ render: (_: unknown, run: Run) => (
+ {evalSetGroup(run).label}
+ ),
+ },
+ {
+ title: 'Agent',
+ key: 'agent',
+ render: (_: unknown, run: Run) => {
+ const agents = run.summary?.agents;
+ if (!Array.isArray(agents) || agents.length === 0) return - ;
+ return {agents.join(', ')} ;
+ },
+ },
+ {
+ title: 'Traces',
+ key: 'traces',
+ align: 'right',
+ render: (_: unknown, run: Run) => (
+ {run.summary?.trace_count ?? '-'}
+ ),
+ },
+ {
+ title: 'Results',
+ key: 'results',
+ render: (_: unknown, run: Run) => {
+ const counts = run.summary?.result_counts;
+ if (!counts) return - ;
+ return (
+
+ {counts.passed}P
+ {counts.failed}F
+ {counts.errored > 0 && (
+ {counts.errored}E
+ )}
+ {counts.skipped > 0 && (
+ {counts.skipped}S
+ )}
+
+ );
+ },
+ },
+ {
+ title: 'Pass rate',
+ key: 'passRate',
+ render: (_: unknown, run: Run) => {
+ const rate = passRate(run);
+ if (rate === null) return - ;
+ const pct = Math.round(rate * 100);
+ return (
+
+
+
= 0.5 ? 'var(--status-success)' : 'var(--status-failure)',
+ }}
+ />
+
+
{pct}%
+
+ );
+ },
+ },
+ {
+ title: 'Duration',
+ key: 'duration',
+ align: 'right',
+ render: (_: unknown, run: Run) => (
+
{formatDuration(runDurationMs(run))}
+ ),
+ },
+ {
+ title: 'Models',
+ key: 'models',
+ render: (_: unknown, run: Run) => {
+ const models = run.summary?.performance_metrics?.models;
+ if (!Array.isArray(models) || models.length === 0) return
- ;
+ return
{models.join(', ')} ;
+ },
+ },
+ ];
+
+ return (
+
+
+ rowKey="runId"
+ columns={columns}
+ dataSource={rows}
+ pagination={{ pageSize: 10, hideOnSinglePage: true }}
+ size="small"
+ onRow={run => ({
+ onClick: () => onSelectRun?.(run.runId),
+ style: { cursor: onSelectRun ? 'pointer' : 'default' },
+ })}
+ />
+
+ );
+};
+
+const tableStyle = css`
+ .ant-table {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-default);
+ border-radius: 8px;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ }
+
+ .ant-table-thead > tr > th {
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ font-weight: 600;
+ border-bottom: 2px solid var(--border-default);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 12px 16px;
+ }
+
+ .ant-table-tbody > tr {
+ cursor: pointer;
+ transition: all 0.2s ease;
+ border-bottom: 1px solid var(--border-subtle);
+ border-left: 4px solid transparent;
+ background: transparent !important;
+ outline: 2px solid transparent;
+ outline-offset: -2px;
+
+ &:hover {
+ border-left-color: var(--accent-primary);
+ outline-color: var(--accent-primary);
+ background: transparent !important;
+ }
+ }
+
+ .ant-table-tbody > tr > td {
+ padding: 12px 16px;
+ color: var(--text-secondary);
+ background: transparent !important;
+ }
+
+ .ant-table-tbody > tr:hover > td {
+ background: transparent !important;
+ }
+
+ .ant-pagination .ant-pagination-item a,
+ .ant-pagination .ant-pagination-item-link {
+ color: var(--text-secondary);
+ }
+
+ .ant-pagination .ant-pagination-item-active {
+ background: var(--bg-elevated);
+ border-color: var(--accent-primary);
+ }
+
+ .ant-pagination .ant-pagination-item-active a {
+ color: var(--accent-primary);
+ }
+`;
+
+const monoStyle = css`
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+`;
+
+const statusStyle = css`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ text-transform: capitalize;
+ font-size: 0.8125rem;
+ color: var(--text-primary);
+`;
+
+const dotStyle = css`
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+`;
+
+const evalSetCellStyle = css`
+ color: var(--text-primary);
+ font-size: 0.8125rem;
+`;
+
+const agentCellStyle = css`
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ color: var(--accent-primary);
+`;
+
+const countsStyle = css`
+ display: inline-flex;
+ gap: 8px;
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ font-weight: 600;
+`;
+
+const barCellStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+const barTrackStyle = css`
+ width: 64px;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--bg-elevated);
+ overflow: hidden;
+`;
+
+const barFillStyle = css`
+ height: 100%;
+ border-radius: 3px;
+ transition: width 0.3s ease;
+`;
+
+const mutedStyle = css`
+ color: var(--text-tertiary);
+`;
+
+const modelsStyle = css`
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+`;
diff --git a/ui/src/components/runs/RunsView.tsx b/ui/src/components/runs/RunsView.tsx
new file mode 100644
index 0000000..3c9d1c5
--- /dev/null
+++ b/ui/src/components/runs/RunsView.tsx
@@ -0,0 +1,376 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { css } from '@emotion/react';
+import { Segmented, Select } from 'antd';
+import { RefreshCw, History, AlertCircle } from 'lucide-react';
+import type { Run } from '../../lib/types';
+import { listRuns, StorageUnavailableError } from '../../api/client';
+import {
+ groupRuns,
+ metricNamesAcross,
+ passRate,
+ sortByCreatedAsc,
+ type GroupBy,
+} from './runHistory';
+import { PassRateTrendChart } from './PassRateTrendChart';
+import { PerMetricTrendChart } from './PerMetricTrendChart';
+import { RunsHistoryTable } from './RunsHistoryTable';
+import { RunDetailView } from './RunDetailView';
+
+const ALL_GROUPS = '__all__';
+
+export const RunsView: React.FC = () => {
+ const [runs, setRuns] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [storageUnavailable, setStorageUnavailable] = useState(false);
+ const [groupBy, setGroupBy] = useState('evalSet');
+ const [selectedGroup, setSelectedGroup] = useState(ALL_GROUPS);
+ const [detailRunId, setDetailRunId] = useState(null);
+
+ // Group keys differ per dimension, so reset the picker to "all" when the
+ // grouping dimension changes to avoid pointing at a now-nonexistent group.
+ const changeGroupBy = (next: GroupBy) => {
+ setGroupBy(next);
+ setSelectedGroup(ALL_GROUPS);
+ };
+
+ const load = async () => {
+ setLoading(true);
+ setError(null);
+ setStorageUnavailable(false);
+ try {
+ const fetched = await listRuns({ limit: 200 });
+ setRuns(fetched);
+ } catch (err) {
+ if (err instanceof StorageUnavailableError) {
+ setStorageUnavailable(true);
+ } else {
+ setError(err instanceof Error ? err.message : 'Failed to load runs');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ const groups = useMemo(() => groupRuns(runs, groupBy), [runs, groupBy]);
+
+ const selectedRuns = useMemo(() => {
+ if (selectedGroup === ALL_GROUPS) return runs;
+ return groups.find(g => g.group.key === selectedGroup)?.runs ?? [];
+ }, [runs, groups, selectedGroup]);
+
+ const trendRuns = useMemo(() => sortByCreatedAsc(selectedRuns), [selectedRuns]);
+
+ const stats = useMemo(() => {
+ const rates = trendRuns.map(passRate).filter((r): r is number => r !== null);
+ const latest = rates.length ? rates[rates.length - 1] : null;
+ const avg = rates.length ? rates.reduce((a, b) => a + b, 0) / rates.length : null;
+ return {
+ total: selectedRuns.length,
+ latest,
+ avg,
+ metrics: metricNamesAcross(selectedRuns).length,
+ };
+ }, [selectedRuns, trendRuns]);
+
+ if (detailRunId) {
+ return setDetailRunId(null)} />;
+ }
+
+ return (
+
+
+
+
+
Run history
+
+
+ {runs.length > 0 && (
+
+ value={groupBy}
+ onChange={changeGroupBy}
+ options={[
+ { value: 'evalSet', label: 'Eval set' },
+ { value: 'agent', label: 'Agent' },
+ ]}
+ />
+ )}
+ {groups.length > 0 && (
+ ({
+ value: g.group.key,
+ label: `${g.group.label} (${g.runs.length})`,
+ })),
+ ]}
+ />
+ )}
+
+
+ Refresh
+
+
+
+
+ {storageUnavailable && (
+
+
+
+ Run history requires the durable storage backend. Start the server with{' '}
+ AGENTEVALS_STORAGE_BACKEND=postgres and AGENTEVALS_DATABASE_URL{' '}
+ set, then run an evaluation to populate it.
+
+
+ )}
+
+ {error && (
+
+ )}
+
+ {loading &&
Loading runs...
}
+
+ {!loading && !storageUnavailable && !error && runs.length === 0 && (
+
+
+
No evaluation runs recorded yet.
+
Run an evaluation and it will appear here, tracked over time.
+
+ )}
+
+ {!loading && !storageUnavailable && runs.length > 0 && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+interface StatCardProps {
+ label: string;
+ value: string;
+ accent?: 'success' | 'failure';
+}
+
+const StatCard: React.FC = ({ label, value, accent }) => {
+ const color =
+ accent === 'failure'
+ ? 'var(--status-failure)'
+ : accent === 'success'
+ ? 'var(--status-success)'
+ : 'var(--accent-primary)';
+ return (
+
+
+ {value}
+
+
{label}
+
+ );
+};
+
+const pageStyle = css`
+ padding: 32px;
+ max-width: 1400px;
+ margin: 0 auto;
+`;
+
+const headerStyle = css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24px;
+ gap: 16px;
+ flex-wrap: wrap;
+`;
+
+const titleStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--accent-primary);
+
+ h1 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: var(--font-display);
+ }
+`;
+
+const controlsStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`;
+
+const selectStyle = css`
+ min-width: 200px;
+`;
+
+const refreshButtonStyle = css`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ border-radius: 8px;
+ border: 1px solid var(--border-default);
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ font-weight: 600;
+ font-size: 0.8125rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover:not(:disabled) {
+ color: var(--accent-primary);
+ border-color: var(--accent-primary);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+ }
+
+ .spinning {
+ animation: spin 1s linear infinite;
+ }
+
+ @keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+ }
+`;
+
+const noticeStyle = css`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 18px;
+ border-radius: 8px;
+ margin-bottom: 24px;
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
+ border: 1px solid var(--border-default);
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+
+ code {
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ color: var(--accent-primary);
+ background: var(--bg-elevated);
+ padding: 1px 6px;
+ border-radius: 4px;
+ }
+`;
+
+const errorNoticeStyle = css`
+ background: rgba(255, 87, 87, 0.08);
+ color: var(--status-failure);
+`;
+
+const mutedTextStyle = css`
+ color: var(--text-tertiary);
+ font-size: 0.9rem;
+`;
+
+const emptyStateStyle = css`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 80px 20px;
+ color: var(--text-tertiary);
+ text-align: center;
+
+ p {
+ margin: 8px 0 0;
+ font-size: 1.05rem;
+ color: var(--text-secondary);
+ font-weight: 600;
+ }
+
+ span {
+ font-size: 0.875rem;
+ }
+`;
+
+const statsGridStyle = css`
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+`;
+
+const statCardStyle = css`
+ background: var(--bg-surface);
+ border: 1px solid var(--border-default);
+ border-radius: 12px;
+ padding: 20px 24px;
+ text-align: center;
+`;
+
+const statValueStyle = css`
+ font-size: 2rem;
+ font-weight: 700;
+ font-family: var(--font-mono);
+ margin-bottom: 4px;
+`;
+
+const statLabelStyle = css`
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-tertiary);
+ font-weight: 600;
+`;
+
+const chartsGridStyle = css`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ margin-bottom: 24px;
+
+ @media (max-width: 1100px) {
+ grid-template-columns: 1fr;
+ }
+`;
diff --git a/ui/src/components/runs/runHistory.ts b/ui/src/components/runs/runHistory.ts
new file mode 100644
index 0000000..dcdaa2e
--- /dev/null
+++ b/ui/src/components/runs/runHistory.ts
@@ -0,0 +1,171 @@
+import type { Run, RunStatus } from '../../lib/types';
+
+export interface RunGroup {
+ key: string;
+ label: string;
+}
+
+// Derive the "which eval is this" grouping key from the stored spec, without a
+// backend schema change. Prefer the ADK eval set's name/id; fall back to its
+// case count, then to the configured evaluator signature, then to ungrouped.
+// This is what lets the trends compare like with like over time.
+export function evalSetGroup(run: Run): RunGroup {
+ const evalSet = run.spec?.evalSet;
+ if (evalSet) {
+ const name = evalSet.name || evalSet.eval_set_id;
+ if (name) return { key: `set:${name}`, label: name };
+ const caseCount = Array.isArray(evalSet.eval_cases) ? evalSet.eval_cases.length : 0;
+ if (caseCount) return { key: `set:cases:${caseCount}`, label: `Eval set (${caseCount} cases)` };
+ }
+
+ const evaluators = run.spec?.evalConfig?.evaluators;
+ if (Array.isArray(evaluators) && evaluators.length) {
+ const signature = evaluators
+ .map(e => e?.name)
+ .filter((n): n is string => Boolean(n))
+ .sort()
+ .join(', ');
+ if (signature) return { key: `cfg:${signature}`, label: signature };
+ }
+
+ return { key: 'ungrouped', label: 'Ungrouped runs' };
+}
+
+// Agent identity for grouping, derived from summary.agents (service.name, with
+// the backend's agent_name fallback already applied). Cross-framework, so this
+// is the dimension to use when comparing the same agent's runs over time.
+export function agentGroup(run: Run): RunGroup {
+ const agents = run.summary?.agents;
+ if (Array.isArray(agents) && agents.length) {
+ return { key: `agent:${[...agents].sort().join('|')}`, label: agents.join(', ') };
+ }
+ return { key: 'agent:unknown', label: 'Unknown agent' };
+}
+
+export type GroupBy = 'evalSet' | 'agent';
+
+const GROUPERS: Record RunGroup> = {
+ evalSet: evalSetGroup,
+ agent: agentGroup,
+};
+
+export interface GroupedRuns {
+ group: RunGroup;
+ runs: Run[];
+}
+
+// Returns groups sorted by run count desc, so the busiest group surfaces first.
+export function groupRuns(runs: Run[], groupBy: GroupBy = 'evalSet'): GroupedRuns[] {
+ const grouper = GROUPERS[groupBy];
+ const byKey = new Map();
+ for (const run of runs) {
+ const group = grouper(run);
+ const existing = byKey.get(group.key);
+ if (existing) existing.runs.push(run);
+ else byKey.set(group.key, { group, runs: [run] });
+ }
+ return [...byKey.values()].sort((a, b) => b.runs.length - a.runs.length);
+}
+
+// Pass rate over decided (pass/fail) results. Errored/skipped are excluded from
+// the denominator so a config that only errors doesn't read as 0% quality.
+// Returns null when nothing was decided, so trend lines render a gap rather
+// than a misleading zero.
+export function passRate(run: Run): number | null {
+ const counts = run.summary?.result_counts;
+ if (!counts) return null;
+ const decided = counts.passed + counts.failed;
+ if (decided === 0) return null;
+ return counts.passed / decided;
+}
+
+export function runDurationMs(run: Run): number | null {
+ if (!run.startedAt || !run.finishedAt) return null;
+ const ms = Date.parse(run.finishedAt) - Date.parse(run.startedAt);
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
+}
+
+export function formatDuration(ms: number | null): string {
+ if (ms == null) return '-';
+ if (ms < 1000) return `${ms} ms`;
+ const seconds = ms / 1000;
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
+ const minutes = Math.floor(seconds / 60);
+ return `${minutes}m ${Math.round(seconds % 60)}s`;
+}
+
+export function formatTimestamp(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return iso;
+ return date.toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+// Oldest first, so trends read left-to-right as time advances.
+export function sortByCreatedAsc(runs: Run[]): Run[] {
+ return [...runs].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
+}
+
+export function runAgents(run: Run): string[] {
+ const agents = run.summary?.agents;
+ return Array.isArray(agents) ? agents : [];
+}
+
+// Union of agent identities (service.name) across runs, ordered by frequency so
+// the most active agent leads the legend. Used to draw one trend line per agent.
+export function agentNamesAcross(runs: Run[]): string[] {
+ const frequency = new Map();
+ for (const run of runs) {
+ for (const agent of runAgents(run)) {
+ frequency.set(agent, (frequency.get(agent) ?? 0) + 1);
+ }
+ }
+ return [...frequency.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
+}
+
+// Union of evaluator names seen across a set of runs, ordered by how often they
+// appear so the most consistently-tracked metrics lead the legend.
+export function metricNamesAcross(runs: Run[]): string[] {
+ const frequency = new Map();
+ for (const run of runs) {
+ const perMetric = run.summary?.per_metric;
+ if (!perMetric) continue;
+ for (const name of Object.keys(perMetric)) {
+ frequency.set(name, (frequency.get(name) ?? 0) + 1);
+ }
+ }
+ return [...frequency.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
+}
+
+export const STATUS_COLORS: Record = {
+ succeeded: 'var(--status-success)',
+ failed: 'var(--status-failure)',
+ cancelled: 'var(--text-tertiary)',
+ running: 'var(--accent-primary)',
+ queued: 'var(--text-secondary)',
+};
+
+// Canvas (chart.js) can't read CSS variables, so trend charts use literal
+// theme-matched hex values; DOM elements keep using the CSS variables above.
+export const CHART_COLORS = {
+ passRate: '#7cff6b',
+ passRateFill: 'rgba(124, 255, 107, 0.12)',
+ grid: 'rgba(209, 213, 219, 0.12)',
+ text: 'rgb(168, 178, 193)',
+};
+
+export const METRIC_PALETTE = [
+ '#A855F7',
+ '#36a2eb',
+ '#ff9f43',
+ '#7cff6b',
+ '#ff5757',
+ '#ffce56',
+ '#4bc0c0',
+ '#c77dff',
+ '#f78fb3',
+];
diff --git a/ui/src/components/sidebar/Sidebar.tsx b/ui/src/components/sidebar/Sidebar.tsx
index 31ee52d..1de3150 100644
--- a/ui/src/components/sidebar/Sidebar.tsx
+++ b/ui/src/components/sidebar/Sidebar.tsx
@@ -1,12 +1,12 @@
import React, { useRef, useState } from 'react';
import { css } from '@emotion/react';
-import { Radio, Play, FileJson, Bug } from 'lucide-react';
+import { Radio, Play, FileJson, Bug, History } from 'lucide-react';
import { useTraceContext } from '../../context/TraceContext';
import type { ViewType } from '../../lib/types';
import { BugReportModal } from '../bug-report/BugReportModal';
import { loadBugReport } from '../../api/client';
-type SidebarSection = 'streaming' | 'offline' | 'builder';
+type SidebarSection = 'streaming' | 'offline' | 'builder' | 'runs';
function getActiveSection(currentView: ViewType): SidebarSection | null {
switch (currentView) {
@@ -17,6 +17,7 @@ function getActiveSection(currentView: ViewType): SidebarSection | null {
case 'comparison':
return 'offline';
case 'builder': return 'builder';
+ case 'runs': return 'runs';
default: return null;
}
}
@@ -67,6 +68,14 @@ export const Sidebar: React.FC = () => {
Evaluations
+ actions.setCurrentView('runs')}
+ >
+
+ Run History
+
+
actions.setCurrentView('builder')}
diff --git a/ui/src/components/streaming/LiveStreamingView.tsx b/ui/src/components/streaming/LiveStreamingView.tsx
index 85d229b..9f1758b 100644
--- a/ui/src/components/streaming/LiveStreamingView.tsx
+++ b/ui/src/components/streaming/LiveStreamingView.tsx
@@ -441,7 +441,12 @@ export function LiveStreamingView() {
if (import.meta.env.DEV) {
console.log('Fetching traces for all sessions');
}
+ // Include the golden alongside the other sessions so its performance is
+ // plotted as a reference in the charts. It's tagged via goldenTraceId so
+ // the results view keeps it out of pass/fail scoring (scoring it against
+ // the eval set derived from itself is a trivial pass).
const sessionIds = Array.from(activeSessions.keys());
+ const goldenTraceId = activeSessions.get(selectedGoldenId)?.traceId ?? null;
const traceFiles = await Promise.all(
sessionIds.map(async (sessionId) => {
@@ -480,6 +485,7 @@ export function LiveStreamingView() {
actions.setEvaluationOrigin('streaming');
actions.setTraceFiles(validTraceFiles);
actions.setEvalSet(evalSetFile);
+ actions.setGoldenTraceId(goldenTraceId);
actions.setCurrentView('upload');
} catch (error) {
diff --git a/ui/src/config.ts b/ui/src/config.ts
index daf7a2c..5346f1f 100644
--- a/ui/src/config.ts
+++ b/ui/src/config.ts
@@ -13,6 +13,7 @@ export const config = {
metrics: `${API_BASE_URL}/api/metrics`,
evaluate: `${API_BASE_URL}/api/evaluate`,
evaluateStream: `${API_BASE_URL}/api/evaluate/stream`,
+ runs: `${API_BASE_URL}/api/runs`,
validateEvalSet: `${API_BASE_URL}/api/validate/eval-set`,
streamingCreateEvalSet: `${API_BASE_URL}/api/streaming/create-eval-set`,
streamingGetTrace: `${API_BASE_URL}/api/streaming/get-trace`,
diff --git a/ui/src/context/TraceContext.tsx b/ui/src/context/TraceContext.tsx
index dee1d79..aeb52b2 100644
--- a/ui/src/context/TraceContext.tsx
+++ b/ui/src/context/TraceContext.tsx
@@ -32,6 +32,9 @@ export interface TraceState {
currentView: ViewType;
evaluationOrigin: ViewType | null;
selectedTraceId: string | null;
+ // Trace id of the golden/reference session in the current results, if any.
+ // Plotted in the performance charts but excluded from pass/fail scoring.
+ goldenTraceId: string | null;
version: string | null;
// Streaming state
@@ -63,6 +66,7 @@ export interface TraceContextType {
removeSession: (sessionId: string) => void;
clearAllSessions: () => void;
selectTrace: (traceId: string | null) => void;
+ setGoldenTraceId: (traceId: string | null) => void;
clearResults: () => void;
// Annotation queue actions
diff --git a/ui/src/context/TraceProvider.tsx b/ui/src/context/TraceProvider.tsx
index 556bee1..c5e3d0a 100644
--- a/ui/src/context/TraceProvider.tsx
+++ b/ui/src/context/TraceProvider.tsx
@@ -30,6 +30,7 @@ export const TraceProvider: React.FC = ({ children }) => {
currentView: 'welcome',
evaluationOrigin: null,
selectedTraceId: null,
+ goldenTraceId: null,
version: null,
streamingSessions: new Map(),
annotationQueues: [],
@@ -292,11 +293,15 @@ export const TraceProvider: React.FC = ({ children }) => {
selectTrace: (traceId: string | null) =>
setState((prev) => ({ ...prev, selectedTraceId: traceId })),
+ setGoldenTraceId: (traceId: string | null) =>
+ setState((prev) => ({ ...prev, goldenTraceId: traceId })),
+
clearResults: () =>
setState((prev) => ({
...prev,
results: [],
errors: [],
+ goldenTraceId: null,
currentView: 'upload',
})),
diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts
index 02a26ad..49cd295 100644
--- a/ui/src/lib/types.ts
+++ b/ui/src/lib/types.ts
@@ -233,7 +233,100 @@ export interface EvalSet {
}
// View types
-export type ViewType = 'welcome' | 'upload' | 'dashboard' | 'inspector' | 'comparison' | 'builder' | 'streaming' | 'annotation-queue';
+export type ViewType = 'welcome' | 'upload' | 'dashboard' | 'inspector' | 'comparison' | 'builder' | 'streaming' | 'annotation-queue' | 'runs';
+
+// Persisted run history (durable storage, GET /api/runs)
+export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
+
+export interface ResultCounts {
+ passed: number;
+ failed: number;
+ errored: number;
+ skipped: number;
+}
+
+// One entry per evaluator in summary.per_metric. avg_score is null when no
+// invocation of that evaluator produced a numeric score (e.g. it only errored).
+export interface PerMetricSummary extends ResultCounts {
+ avg_score: number | null;
+}
+
+// summary is a free-form dict on the backend, so its keys stay snake_case on
+// the wire (the camelCase alias generator only renames declared model fields).
+export interface RunSummary {
+ trace_count: number;
+ result_counts: ResultCounts;
+ per_metric?: Record;
+ agents?: string[];
+ errors?: string[];
+ performance_metrics?: { models?: string[] } & Record;
+}
+
+// spec.evalSet/evalConfig are stored as raw dicts, so their inner keys keep
+// the original snake_case (e.g. ADK eval_set_id) rather than being camelCased.
+export interface RunEvaluatorConfig {
+ name?: string;
+ type?: string;
+ threshold?: number;
+ judge_model?: string;
+ trajectory_match_type?: string;
+}
+
+export interface RunSpecSummary {
+ approach?: string;
+ evalSet?: { eval_set_id?: string; name?: string; eval_cases?: unknown[] } | null;
+ evalConfig?: { evaluators?: RunEvaluatorConfig[] } & Record;
+ target?: { kind?: string; trace_count?: number; trace_files?: string[] } & Record;
+}
+
+// One persisted Result row (GET /api/runs/{id}/results). Top-level fields are
+// camelCased by the API; the free-form `details` keeps snake_case inner keys.
+export type ResultStatus2 = 'passed' | 'failed' | 'errored' | 'skipped';
+
+export interface PersistedComparison {
+ invocation_id?: string | null;
+ matched?: boolean;
+ expected?: ToolCallComparison[];
+ actual?: ToolCallComparison[];
+}
+
+export interface ResultDetails {
+ comparisons?: PersistedComparison[];
+ [key: string]: unknown;
+}
+
+export interface RunResultRow {
+ resultId: string;
+ runId: string;
+ evalSetItemId: string;
+ evalSetItemName: string;
+ evaluatorName: string;
+ evaluatorType: string;
+ status: ResultStatus2;
+ score: number | null;
+ perInvocationScores: (number | null)[];
+ traceId?: string | null;
+ spanId?: string | null;
+ details?: ResultDetails;
+ errorText?: string | null;
+ tokensUsed?: Record | null;
+ latencyMs?: number | null;
+ createdAt: string;
+}
+
+export interface Run {
+ runId: string;
+ status: RunStatus;
+ spec: RunSpecSummary;
+ attempt?: number;
+ workerId?: string | null;
+ error?: string | null;
+ summary?: RunSummary | null;
+ createdAt: string;
+ startedAt?: string | null;
+ finishedAt?: string | null;
+ cancelRequested?: boolean;
+}
// Metric metadata type
export interface MetricMetadata {