Skip to content
Open
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Changelog

Notable changes to this repo. Not every commit — see `git log` for that.

## 2026-08-02

### Added

- **AgentPrism trace viewer**: a "View span tree" link on every result row opens a full trace panel — span tree, per-span duration/token badges, tool call input/output, docs activity. Backed by a new `EvalResult → AgentPrism spans` adapter (`packages/core/src/trace-viewer.ts`) and lazy per-eval trace export from `pnpm export-results`.
- Real per-turn token usage surfaced as the trace viewer's token badges (previously only aggregate totals were available).
- `DetailsViewPrettyOutput`: a human-readable "Plain" view for tool call JSON — renders real line breaks instead of forcing multi-line shell commands/output onto one escaped-`\n` line. Now the default view for structured tool call data; exact "JSON" stays available alongside it.

### Fixed

- `--agentprism-secondary` (feeds Button, Avatar, Tabs, SpanCardConnector, TraceListItem, and the trace timeline's track background) inherited the app's own `--secondary` token, which lightens off `--background` — invisible in light mode, where the background is already near-max lightness. Rebased on a foreground-tinted overlay so it stays visible in both themes.
- The trace row's title/timeline split used a hardcoded 600px JS width tuned for a wider reference layout; replaced with a CSS grid (`minmax(0,1fr) auto`) so it adapts to whatever panel width actually exists instead of overflowing or wrapping.
- Per-span-type accent colors (`SPAN_ACCENT_COLORS`) were keyed by short names (`tool`, `agent`, `llm`) against `TraceSpanCategory`'s long-form values (`tool_execution`, `agent_invocation`, `llm_call`) — every span except `event` silently fell back to the same gray "unknown" accent. Since removed in favor of no per-row accent border (see below), but worth noting for anyone re-adding one.
- Removed the per-row `border-l-2` type-accent border (read as visual noise); kept the tree connector guide lines.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ Start the web app development server:
pnpm web
```

### Trace viewer

Every row in the results table has a "View span tree" link that opens an [AgentPrism](https://github.com/evilmartians/agent-prism)-based trace panel: the full span tree for that run — system/user messages, each tool call with its input/output, the assistant's turns, and check results — with per-span duration, token usage, and status.

`pnpm export-results` writes these lazily: alongside the aggregate `eval-results.json`, it emits one `apps/web/src/data/traces/<evalId>.json` per eval (via `evalResultToTraceSpans` in `packages/core/src/trace-viewer.ts`, which adapts a run's raw transcript into AgentPrism's span format). The web app fetches a trace only when its row is opened, so the aggregate bundle stays lean. Pass `--no-traces` to `export-results` to skip writing them.

Tool call input/output defaults to a human-readable "Plain" view with real line breaks — multi-line shell commands and command output are unreadable as JSON's escaped `\n` form. Switch to the "JSON" tab for exact, copy-pasteable JSON.

## Eval Shape

Every eval contains:
Expand Down
99 changes: 83 additions & 16 deletions apps/framework/scripts/export-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { fileURLToPath } from 'node:url';
import { parseEvalMarkdown } from '@supabase-evals/core/eval-markdown';
import { rawEvalResultSchema } from '@supabase-evals/core/eval-metadata';
import {
evalResultToTraceSpans,
getExperimentDisplayMetadata,
type EvalResultTraceInput,
type ExperimentConfig,
type ExperimentDisplayMetadata,
} from '@supabase-evals/core';
Expand All @@ -16,6 +18,7 @@ import type {
EvalSuite,
ExperimentSuite,
} from '@supabase-evals/core/eval-metadata';
import type { ToolCallRecord, TranscriptPart } from '@supabase-evals/core';
import {
normalizeExperimentName,
readExperimentSuiteFilters,
Expand All @@ -36,6 +39,7 @@ const OUTPUT_PATH = join(
'data',
'eval-results.json'
);
const TRACES_DIR = join(ROOT, 'apps', 'web', 'src', 'data', 'traces');

type ExperimentExportMetadata = {
display: ExperimentDisplayMetadata;
Expand Down Expand Up @@ -66,6 +70,10 @@ const EVAL_FILTERS = readRepeatedFlag(rawArgs, 'eval');
const SUITE_FILTERS = readSuiteFilters(rawArgs);
const EXPERIMENT_SUITE_FILTERS = readExperimentSuiteFilters(rawArgs);
const MERGE = rawArgs.includes('--merge');
// Per-eval trace JSON for the web viewer's TracePanel. On by default; flip with
// `--no-traces`. Written lazily (one file per evalId) so the aggregate bundle
// stays lean — the web app fetches a trace only when a row is selected.
const WRITE_TRACES = !rawArgs.includes('--no-traces');

const OUTPUT_FLAG = readRepeatedFlag(rawArgs, 'output')[0];
const outputPath = OUTPUT_FLAG ? resolve(ROOT, OUTPUT_FLAG) : OUTPUT_PATH;
Expand Down Expand Up @@ -98,7 +106,7 @@ async function readResultFile(
filePath: string,
sourcePath: string,
experimentMetadata: Map<string, ExperimentExportMetadata>
): Promise<EvalResult | null> {
): Promise<{ result: EvalResult; traceInput: EvalResultTraceInput } | null> {
const parsed: unknown = JSON.parse(await readFile(filePath, 'utf8'));
const result = rawEvalResultSchema.safeParse(parsed);
if (!result.success) {
Expand All @@ -113,7 +121,7 @@ async function readResultFile(
parsedResult.profile ??
experimentData?.experimentSuite;

return {
const evalResult: EvalResult = {
experiment: parsedResult.experiment,
experimentSuite,
experimentDisplay:
Expand All @@ -134,6 +142,23 @@ async function readResultFile(
attempts: parsedResult.attempts,
sourcePath,
};

// rawEvalResultSchema is loose, so the transcript/toolCalls/agentReport the
// strict evalResultSchema drops are still here — that's what the trace
// adapter consumes.
const traceInput: EvalResultTraceInput = {
evalId: parsedResult.eval,
passed: parsedResult.passed === true,
transcript: parsedResult.transcript as TranscriptPart[] | undefined,
toolCalls: parsedResult.toolCalls as ToolCallRecord[] | undefined,
agentReport: parsedResult.agentReport as string | undefined,
skills: parsedResult.skills,
checks: parsedResult.checks,
experimentDisplay: parsedResult.experimentDisplay,
attempts: parsedResult.attempts,
};

return { result: evalResult, traceInput };
}

function shouldIncludeExperiment(experiment: string): boolean {
Expand Down Expand Up @@ -173,15 +198,30 @@ function shouldIncludeExperimentSuite(
);
}

async function loadEvalResults(): Promise<EvalResult[]> {
async function loadEvalResults(): Promise<{
results: EvalResult[];
traceInputs: Map<string, EvalResultTraceInput>;
}> {
const traceInputs = new Map<string, EvalResultTraceInput>();
if (!existsSync(RESULTS_DIR)) {
return [];
return { results: [], traceInputs };
}

const experimentMetadata = await loadExperimentMetadata();
const results: EvalResult[] = [];
const experiments = await readdir(RESULTS_DIR);

// Same evalId may run under several experiments (different agents/models).
// Prefer a run that actually recorded a transcript over one that didn't, so
// the trace viewer shows a real span tree rather than an empty no-skills run.
const upsertTrace = (input: EvalResultTraceInput) => {
const existing = traceInputs.get(input.evalId);
const hasTranscript = (input.transcript?.length ?? 0) > 0;
if (!existing || (hasTranscript && !existing.transcript?.length)) {
traceInputs.set(input.evalId, input);
}
};

for (const experiment of experiments) {
if (experiment.startsWith('.') || experiment.startsWith('_')) {
continue;
Expand Down Expand Up @@ -209,17 +249,18 @@ async function loadEvalResults(): Promise<EvalResult[]> {
continue;
}

const result = await readResultFile(
const read = await readResultFile(
entryPath,
relativeEntryPath,
experimentMetadata
);
if (
result &&
shouldIncludeSuite(result.suite) &&
shouldIncludeExperimentSuite(result.experimentSuite)
read &&
shouldIncludeSuite(read.result.suite) &&
shouldIncludeExperimentSuite(read.result.experimentSuite)
) {
results.push(result);
results.push(read.result);
upsertTrace(read.traceInput);
}
continue;
}
Expand All @@ -237,29 +278,48 @@ async function loadEvalResults(): Promise<EvalResult[]> {
continue;
}

const result = await readResultFile(
const read = await readResultFile(
summaryPath,
`${relativeEntryPath}/summary.json`,
experimentMetadata
);
if (
result &&
shouldIncludeSuite(result.suite) &&
shouldIncludeExperimentSuite(result.experimentSuite)
read &&
shouldIncludeSuite(read.result.suite) &&
shouldIncludeExperimentSuite(read.result.experimentSuite)
) {
results.push(result);
results.push(read.result);
upsertTrace(read.traceInput);
}
}
}

return results.sort(
results.sort(
(a, b) =>
a.experiment.localeCompare(b.experiment) || a.eval.localeCompare(b.eval)
);
return { results, traceInputs };
}

async function writeTraces(
traceInputs: Map<string, EvalResultTraceInput>
): Promise<number> {
if (!WRITE_TRACES) return 0;
await mkdir(TRACES_DIR, { recursive: true });
let written = 0;
for (const [evalId, input] of traceInputs) {
const data = evalResultToTraceSpans(input);
await writeFile(
join(TRACES_DIR, `${evalId}.json`),
`${JSON.stringify(data, null, 2)}\n`
);
written += 1;
}
return written;
}

async function main() {
const newResults = await loadEvalResults();
const { results: newResults, traceInputs } = await loadEvalResults();
const hasFilters =
EXPERIMENT_FILTERS.length > 0 ||
EVAL_FILTERS.length > 0 ||
Expand Down Expand Up @@ -295,6 +355,13 @@ async function main() {
`Exported ${results.length} result(s) to ${relative(ROOT, outputPath)} ` +
`(${passed} pass, ${results.length - passed} fail)`
);

const tracesWritten = await writeTraces(traceInputs);
if (WRITE_TRACES) {
console.log(
`Exported ${tracesWritten} trace(s) to ${relative(ROOT, TRACES_DIR)}`
);
}
}

main().catch((error: unknown) => {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,28 @@
"preview": "vite preview"
},
"dependencies": {
"@evilmartians/agent-prism-data": "^0.0.9",
"@evilmartians/agent-prism-types": "^0.0.9",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/manrope": "5.2.8",
"@fontsource-variable/source-code-pro": "5.3.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase-evals/core": "workspace:*",
"@tailwindcss/vite": "^4.2.1",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"nuqs": "^2.9.2",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-json-pretty": "^2.2.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.2",
"remark-gfm": "^4.0.1",
"shadcn": "^4.6.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
Expand Down
28 changes: 26 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useQueryStates } from "nuqs"
import { lazy, Suspense } from "react"
import { useQueryState, useQueryStates } from "nuqs"

import { EvalOverviewCards } from "@/components/eval-overview-cards"
import { PageContainer } from "@/components/page-container"
Expand All @@ -8,7 +9,18 @@ import { SiteHeader } from "@/components/site-header"
import { SiteHero } from "@/components/site-hero"
import { TooltipProvider } from "@/components/ui/tooltip"
import { sortedResults } from "@/lib/eval-results"
import { resultsQueryKeys, resultsQueryParsers } from "@/lib/url-state"
import {
resultsQueryKeys,
resultsQueryParsers,
traceEvalParser,
TRACE_EVAL_QUERY_KEY,
} from "@/lib/url-state"

// Lazy so the AgentPrism component tree stays out of the main bundle; the
// trace panel is only mounted when a run is opened.
const TracePanel = lazy(() =>
import("@/components/trace-panel").then((m) => ({ default: m.TracePanel }))
)

export function App() {
const [{ groupBy, experimentSuite }, setResultsQuery] = useQueryStates(
Expand All @@ -18,6 +30,10 @@ export function App() {
clearOnDefault: false,
}
)
const [traceEval, setTraceEval] = useQueryState(
TRACE_EVAL_QUERY_KEY,
traceEvalParser
)
const suiteResults = sortedResults.filter(
(result) => result.experimentSuite === experimentSuite
)
Expand Down Expand Up @@ -55,6 +71,14 @@ export function App() {
No result files found in the repo results directory.
</div>
)}
{traceEval ? (
<Suspense fallback={null}>
<TracePanel
evalId={traceEval}
onClose={() => void setTraceEval(null)}
/>
</Suspense>
) : null}
</main>
</TooltipProvider>
)
Expand Down
Loading