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
10 changes: 7 additions & 3 deletions evalboard/app/api/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@ import { createZip, type ZipEntry } from "@/lib/zip";
export const dynamic = "force-dynamic";

// Bundle a task folder, or a whole run, into a zip download.
// ?run=<id>&task=<id> → just that task's folder (default/<taskId>/)
// ?run=<id> → the entire run folder (run.json + every task dir)
// ?run=<id>&task=<id>[&v=<variant>] → just that task's folder
// (<variant>/<taskId>/, variant default "default")
// ?run=<id> → the entire run folder (run.json + every task dir)
// minus the usual scaffolding noise. In blob mode the collect* helpers fetch
// the needed blobs first, so this mirrors what the page would load.
export async function GET(req: Request) {
const url = new URL(req.url);
const runId = url.searchParams.get("run");
const taskId = url.searchParams.get("task");
// Which A/B variant's copy of the task to zip. Absent → "default" (the
// single-config subdir), so single-model download links are unchanged.
const variant = url.searchParams.get("v") ?? undefined;
if (!runId) {
return new NextResponse("missing run", { status: 400 });
}

const files = taskId
? await collectTaskFiles(runId, taskId)
? await collectTaskFiles(runId, taskId, variant)
: await collectRunFiles(runId);
if (!files) {
return new NextResponse("not found", { status: 404 });
Expand Down
54 changes: 42 additions & 12 deletions evalboard/app/runs/[id]/[...task]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ export default async function TaskPage({
searchParams,
}: {
params: Promise<{ id: string; task: string[] }>;
searchParams: Promise<{ r?: string }>;
searchParams: Promise<{ r?: string; v?: string }>;
}) {
const { id, task: taskSegments } = await params;
const { r } = await searchParams;
const { r, v } = await searchParams;
const taskId = taskSegments.join("/");
// Replicate index from ?r=NN — repeated runs of one task share this task
// path, so the query param is what selects which replicate's <NN>/ dir to
Expand All @@ -45,28 +45,41 @@ export default async function TaskPage({
const parsedR = Number(r);
const replicate =
r != null && Number.isInteger(parsedR) && parsedR >= 0 ? parsedR : 0;
const task = await readTaskDetail(id, taskId, replicate);
// Variant (model) from ?v=NAME — in a multi-model (A/B) run several variants
// share this task path, so ?v selects which model's <variant>/ subdir to
// open. Absent → "default" (the single-config subdir); the readers sanitize
// and fall back to "default" for anything unsafe or unknown.
const variant = v ?? "default";
const task = await readTaskDetail(id, taskId, replicate, variant);
if (!task) notFound();

// Replicate indices available for this task — drives the run selector below.
// [0] (or fewer) for a non-repeated task, so the selector self-hides.
const replicates = await readTaskReplicates(id, taskId);
// Replicate indices available for this task/variant — drives the run
// selector below. [0] (or fewer) for a non-repeated task, so it self-hides.
const replicates = await readTaskReplicates(id, taskId, variant);

// variant is always "default" here; the replicate selects the <NN>/ dir.
// The replicate selects the <NN>/ dir within the variant's subtree.
// readTaskReview returns null for older runs that predate the review feature.
const review = await readTaskReview(
id,
"default",
variant,
taskId,
replicateDirName(replicate),
);

const log = await readLogTail(id, taskId, replicate);
const log = await readLogTail(id, taskId, replicate, variant);
const conversation = parseConversation(
await readConversationLog(id, taskId, replicate),
await readConversationLog(id, taskId, replicate, variant),
);
const { flowDebug } = task;

// Preserve ?v= on in-page links (replicate selector, download) so switching
// replicates or downloading stays on the SAME model. "default" is the
// implicit fallback, so it's omitted to keep single-config URLs clean.
const variantParam =
variant && variant !== "default"
? `&v=${encodeURIComponent(variant)}`
: "";

return (
<div className="space-y-6">
<nav className="text-sm text-gray-500 flex items-center gap-2 flex-wrap">
Expand Down Expand Up @@ -100,7 +113,7 @@ export default async function TaskPage({
return (
<Link
key={ri}
href={`/runs/${id}/${taskId}?r=${ri}`}
href={`/runs/${id}/${taskId}?r=${ri}${variantParam}`}
// Keep the scroll position when switching runs
// — Next scrolls to top on nav by default.
scroll={false}
Expand All @@ -125,14 +138,31 @@ export default async function TaskPage({
{humanizeTaskId(taskId)}
</h1>
<StatusPill status={task.status} relabel />
{/* Which LLM ran this task. Always shown when known so a
single-model run still names its model, and an A/B run's
per-model page is unambiguous. The variant alias (e.g.
"kimi-k3") rides in the tooltip when it differs. */}
{task.model && (
<span
className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2 py-0.5 font-mono text-xs text-gray-700"
title={
variant !== "default"
? `model ${task.model} · variant ${variant}`
: `model ${task.model}`
}
>
<span className="text-gray-400">model</span>
{task.model}
</span>
)}
{/* Download zips the task folder from blob storage — an
internal-hosting surface (no blob backend in the public
OSS edition). See lib/edition.ts. */}
{isInternal && (
<a
href={`/api/download?run=${encodeURIComponent(
id,
)}&task=${encodeURIComponent(taskId)}`}
)}&task=${encodeURIComponent(taskId)}${variantParam}`}
className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:text-studio-blue"
download
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function row(
cacheCreationTokens: null,
cacheReadTokens: null,
model: null,
variant: null,
tags: [],
skill: null,
matureSkipped: false,
Expand Down
1 change: 1 addition & 0 deletions evalboard/app/runs/[id]/__tests__/run-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function row(
cacheCreationTokens: null,
cacheReadTokens: null,
model: null,
variant: null,
tags: [],
skill: null,
matureSkipped: false,
Expand Down
68 changes: 68 additions & 0 deletions evalboard/app/runs/[id]/__tests__/task-grid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function row(
cacheCreationTokens: null,
cacheReadTokens: null,
model: null,
variant: null,
tags: [],
skill: null,
matureSkipped: false,
Expand Down Expand Up @@ -397,3 +398,70 @@ describe("TaskGrid — replicates", () => {
).toBe("1/2✓");
});
});

describe("TaskGrid — multi-model (A/B) runs", () => {
test("shows a Model column and one row per model when models differ", () => {
render(
<TaskGrid
runId="r1"
tasks={[
row("t", 3, 5, {
variant: "kimi-k3",
model: "moonshotai/kimi-k3",
}),
row("t", 3, 5, {
variant: "glm-5-2",
model: "z-ai/glm-5.2",
}),
]}
/>,
);
const table = screen.getByRole("table");
// The Model column header appears only for multi-model runs.
expect(
within(table).getByRole("columnheader", { name: /Model/i }),
).toBeInTheDocument();
// Both models are rendered — the two variants stay distinct rows, not
// collapsed into one (the multi-model "no info displayed" bug).
expect(within(table).getByText("moonshotai/kimi-k3")).toBeInTheDocument();
expect(within(table).getByText("z-ai/glm-5.2")).toBeInTheDocument();
});

test("each model's row links to its OWN variant via ?v=", () => {
render(
<TaskGrid
runId="r1"
tasks={[
row("t", 3, 5, {
variant: "kimi-k3",
model: "moonshotai/kimi-k3",
}),
row("t", 3, 5, {
variant: "glm-5-2",
model: "z-ai/glm-5.2",
}),
]}
/>,
);
const table = screen.getByRole("table");
const links = within(table).getAllByRole("link", { name: /^t/i });
const hrefs = links.map((l) => l.getAttribute("href")).sort();
expect(hrefs).toEqual(["/runs/r1/t?v=glm-5-2", "/runs/r1/t?v=kimi-k3"]);
});

test("hides the Model column for a single-model run", () => {
render(
<TaskGrid
runId="r1"
tasks={[
row("a", 3, 5, { variant: "default", model: "claude-sonnet-4-6" }),
row("b", 3, 5, { variant: "default", model: "claude-sonnet-4-6" }),
]}
/>,
);
const table = screen.getByRole("table");
expect(
within(table).queryByRole("columnheader", { name: /Model/i }),
).toBeNull();
});
});
Loading
Loading