From 5cce778d8ca12d28cdc2d542c78afdb4fe78ab87 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 30 Jun 2026 06:51:51 +0200 Subject: [PATCH 1/2] fix(artifacts): rename generated task bundle to test bundle --- CONCEPTS.md | 2 +- apps/cli/src/commands/eval/artifact-writer.ts | 14 ++-- apps/cli/src/commands/eval/task-bundle.ts | 22 ++--- apps/cli/src/commands/results/combine-run.ts | 1 + apps/cli/src/commands/results/manifest.ts | 1 + .../src/commands/results/projection-bundle.ts | 2 + apps/cli/src/commands/results/serve.ts | 2 + apps/cli/src/commands/runs/rerun.ts | 37 +++++---- .../commands/eval/artifact-writer.test.ts | 50 ++++++----- .../test/commands/eval/task-bundle.test.ts | 19 +++-- apps/cli/test/commands/results/serve.test.ts | 26 +++++- .../test/commands/results/validate.test.ts | 63 ++++++++++++++ apps/cli/test/commands/runs/rerun.test.ts | 83 ++++++++++++++----- .../docs/docs/evaluation/running-evals.mdx | 10 +-- .../docs/docs/guides/benchmark-provenance.mdx | 4 +- .../docs/docs/reference/result-artifacts.mdx | 6 +- .../src/content/docs/docs/tools/dashboard.mdx | 2 +- .../src/content/docs/docs/tools/results.mdx | 4 +- .../docs/docs/tools/wip-checkpoints.mdx | 2 +- ...-result-identity-and-default-experiment.md | 2 +- .../0011-result-output-artifact-contract.md | 6 +- .../core/src/evaluation/result-row-schema.ts | 1 + packages/core/src/evaluation/run-artifacts.ts | 4 +- 23 files changed, 255 insertions(+), 108 deletions(-) diff --git a/CONCEPTS.md b/CONCEPTS.md index 92c2136b4..41ec016e2 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -24,7 +24,7 @@ Shared domain vocabulary for this project — entities, named processes, and sta **Workspace** — The task environment an eval prepares for the agent: repositories, templates, fixture files, and lifecycle hooks. It is not prompt input; use `input` for instructions and `workspace.repos[]` for multi-repo workspaces the agent can inspect or modify through tools. -**Run manifest** — The root `index.jsonl` file in a run bundle. It is the dashboard and tooling loading contract for per-case result rows and artifact locations, including fields such as `result_dir`, `task_dir`, `summary_path`, and `grading_path`. +**Run manifest** — The root `index.jsonl` file in a run bundle. It is the dashboard and tooling loading contract for per-case result rows and artifact locations, including fields such as `result_dir`, `test_dir`, `summary_path`, and `grading_path`. **Result source identity** — The stable source identity for a result row: repo-relative `eval_path`, `test_id`, and `target`. `suite` and `name` are display metadata, not storage or routing identity. diff --git a/apps/cli/src/commands/eval/artifact-writer.ts b/apps/cli/src/commands/eval/artifact-writer.ts index 32efbaad5..7f9c03c01 100644 --- a/apps/cli/src/commands/eval/artifact-writer.ts +++ b/apps/cli/src/commands/eval/artifact-writer.ts @@ -74,13 +74,13 @@ function buildTaskBundleIndexFields( taskBundle: MaterializedTaskBundlePaths | undefined, ): Pick< IndexArtifactEntry, - 'task_dir' | 'eval_path' | 'targets_path' | 'files_path' | 'graders_path' + 'test_dir' | 'eval_path' | 'targets_path' | 'files_path' | 'graders_path' > { if (!taskBundle) { return {}; } return { - task_dir: toRelativeArtifactPath(outputDir, taskBundle.taskDir), + test_dir: toRelativeArtifactPath(outputDir, taskBundle.testDir), eval_path: toRelativeArtifactPath(outputDir, taskBundle.evalPath), targets_path: toRelativeArtifactPath(outputDir, taskBundle.targetsPath), ...(taskBundle.filesPath @@ -123,14 +123,14 @@ export function buildResultIndexArtifact( const artifactSubdir = (buildCoreResultIndexArtifact(result).result_dir ?? '').trim(); const extraIndexFields = taskBundle ? { - task_dir: path.posix.join(artifactSubdir, 'task'), - eval_path: path.posix.join(artifactSubdir, 'task', 'EVAL.yaml'), - targets_path: path.posix.join(artifactSubdir, 'task', 'targets.yaml'), + test_dir: path.posix.join(artifactSubdir, 'test'), + eval_path: path.posix.join(artifactSubdir, 'test', 'EVAL.yaml'), + targets_path: path.posix.join(artifactSubdir, 'test', 'targets.yaml'), ...(taskBundle.filesPath - ? { files_path: path.posix.join(artifactSubdir, 'task', 'files') } + ? { files_path: path.posix.join(artifactSubdir, 'test', 'files') } : {}), ...(taskBundle.gradersPath - ? { graders_path: path.posix.join(artifactSubdir, 'task', 'graders') } + ? { graders_path: path.posix.join(artifactSubdir, 'test', 'graders') } : {}), } : undefined; diff --git a/apps/cli/src/commands/eval/task-bundle.ts b/apps/cli/src/commands/eval/task-bundle.ts index 80287621b..f6e263d2a 100644 --- a/apps/cli/src/commands/eval/task-bundle.ts +++ b/apps/cli/src/commands/eval/task-bundle.ts @@ -15,7 +15,7 @@ import { stringify as stringifyYaml } from 'yaml'; import { toSnakeCaseDeep } from '../../utils/case-conversion.js'; -const TASK_DIRNAME = 'task'; +const TEST_BUNDLE_DIRNAME = 'test'; const TASK_EVAL_FILENAME = 'EVAL.yaml'; const TASK_TARGETS_FILENAME = 'targets.yaml'; const TASK_FILES_DIRNAME = 'files'; @@ -62,7 +62,7 @@ export interface MaterializeTaskBundleOptions { } export interface MaterializedTaskBundlePaths { - readonly taskDir: string; + readonly testDir: string; readonly evalPath: string; readonly targetsPath: string; readonly filesPath?: string; @@ -950,14 +950,14 @@ export async function materializeTaskBundle( return undefined; } - const taskDir = path.join(options.outputDir, TASK_DIRNAME); - await mkdir(taskDir, { recursive: true }); + const testDir = path.join(options.outputDir, TEST_BUNDLE_DIRNAME); + await mkdir(testDir, { recursive: true }); - const copiedReferences = await copyReferences(options.test.source.references, taskDir, options); + const copiedReferences = await copyReferences(options.test.source.references, testDir, options); const rewrites = buildPathRewrites(copiedReferences); const evalCase = buildEvalCase(options.test, rewrites); - const evalPath = path.join(taskDir, TASK_EVAL_FILENAME); - const targetsPath = path.join(taskDir, TASK_TARGETS_FILENAME); + const evalPath = path.join(testDir, TASK_EVAL_FILENAME); + const targetsPath = path.join(testDir, TASK_TARGETS_FILENAME); await writeYamlFile(evalPath, { execution: { target: options.targetName }, @@ -966,14 +966,14 @@ export async function materializeTaskBundle( await writeYamlFile(targetsPath, { targets: targetDefinitions }); return { - taskDir, + testDir, evalPath, targetsPath, ...(hasCopiedBucket(copiedReferences, 'files') - ? { filesPath: path.join(taskDir, TASK_FILES_DIRNAME) } + ? { filesPath: path.join(testDir, TASK_FILES_DIRNAME) } : {}), ...(hasCopiedBucket(copiedReferences, 'graders') - ? { gradersPath: path.join(taskDir, TASK_GRADERS_DIRNAME) } + ? { gradersPath: path.join(testDir, TASK_GRADERS_DIRNAME) } : {}), }; } @@ -982,7 +982,7 @@ export async function materializeTaskBundle( * Materialize a whole eval suite as a portable directory. * * This reuses the same source snapshots, dependency copying, path rewriting, - * target slicing, and secret redaction used by per-result task bundles. The + * target slicing, and secret redaction used by per-result test bundles. The * output eval is intentionally explicit: inherited suite defaults are written * onto each bundled test case so the bundle can run without the source tree. */ diff --git a/apps/cli/src/commands/results/combine-run.ts b/apps/cli/src/commands/results/combine-run.ts index 56b0382b6..2d2440562 100644 --- a/apps/cli/src/commands/results/combine-run.ts +++ b/apps/cli/src/commands/results/combine-run.ts @@ -377,6 +377,7 @@ const MANIFEST_PATH_FIELDS = [ 'transcript_raw_path', 'metrics_path', 'raw_provider_log_path', + 'test_dir', 'task_dir', 'eval_path', 'targets_path', diff --git a/apps/cli/src/commands/results/manifest.ts b/apps/cli/src/commands/results/manifest.ts index 823be5a1f..ceb4c9754 100644 --- a/apps/cli/src/commands/results/manifest.ts +++ b/apps/cli/src/commands/results/manifest.ts @@ -66,6 +66,7 @@ export interface ResultManifestRecord { readonly external_trace?: ExternalTraceMetadataWire; readonly response_path?: string; readonly result_dir?: string; + readonly test_dir?: string; readonly task_dir?: string; readonly eval_path?: string; readonly targets_path?: string; diff --git a/apps/cli/src/commands/results/projection-bundle.ts b/apps/cli/src/commands/results/projection-bundle.ts index 4a2c58fd1..1d4ee85c7 100644 --- a/apps/cli/src/commands/results/projection-bundle.ts +++ b/apps/cli/src/commands/results/projection-bundle.ts @@ -95,6 +95,7 @@ export type ProjectionBundleArtifactRefs = Partial< | 'transcript_path' | 'transcript_raw_path' | 'metrics_path' + | 'test_dir' | 'task_dir' | 'eval_path' | 'targets_path' @@ -173,6 +174,7 @@ function artifactRefs( transcript_path: indexEntry.transcript_path, transcript_raw_path: indexEntry.transcript_raw_path, metrics_path: indexEntry.metrics_path, + test_dir: indexEntry.test_dir, task_dir: indexEntry.task_dir, eval_path: indexEntry.eval_path, targets_path: indexEntry.targets_path, diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index 23e6370aa..3e618cfaa 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -883,6 +883,7 @@ function resultArtifactTreeRootPaths( ): string[] { return [ ...catalog.filter((entry) => entry.storage === 'local').map((entry) => entry.displayPath), + record.test_dir, record.task_dir, record.files_path, record.graders_path, @@ -1212,6 +1213,7 @@ function attachRunDetailReadModelFields>( ...(record.aggregation && { aggregation: record.aggregation }), ...(record.eval_path && { eval_path: record.eval_path }), ...(record.result_dir && { result_dir: record.result_dir }), + ...(record.test_dir && { test_dir: record.test_dir }), ...(record.summary_path && { summary_path: record.summary_path }), ...(record.grading_path && { grading_path: record.grading_path }), ...(record.timing_path && { timing_path: record.timing_path }), diff --git a/apps/cli/src/commands/runs/rerun.ts b/apps/cli/src/commands/runs/rerun.ts index 465efa3ec..abab40290 100644 --- a/apps/cli/src/commands/runs/rerun.ts +++ b/apps/cli/src/commands/runs/rerun.ts @@ -33,7 +33,7 @@ interface SelectedTaskBundle { readonly testId: string; readonly sourceTarget: string; readonly resultDir: string; - readonly taskDir: string; + readonly testDir: string; readonly evalPath: string; readonly targetsPath: string; readonly taskTarget: string; @@ -254,7 +254,7 @@ function forbiddenOutputRoots( ): readonly string[] { return [ path.resolve(sourceRunDir), - ...selected.flatMap((bundle) => [path.resolve(bundle.resultDir), path.resolve(bundle.taskDir)]), + ...selected.flatMap((bundle) => [path.resolve(bundle.resultDir), path.resolve(bundle.testDir)]), ]; } @@ -322,40 +322,41 @@ async function loadSelectedTaskBundles(options: { } const recordLabel = displayRecord(record); + const bundleDir = record.test_dir ?? record.task_dir; const evalPath = resolveRelativeRunPath(options.sourceRunDir, record.eval_path) ?? resolveRelativeRunPath( options.sourceRunDir, - record.task_dir && `${record.task_dir}/${TASK_EVAL_FILENAME}`, + bundleDir && `${bundleDir}/${TASK_EVAL_FILENAME}`, ); const targetsPath = resolveRelativeRunPath(options.sourceRunDir, record.targets_path) ?? resolveRelativeRunPath( options.sourceRunDir, - record.task_dir && `${record.task_dir}/${TASK_TARGETS_FILENAME}`, + bundleDir && `${bundleDir}/${TASK_TARGETS_FILENAME}`, ); - const taskDir = - resolveRelativeRunPath(options.sourceRunDir, record.task_dir) ?? + const testDir = + resolveRelativeRunPath(options.sourceRunDir, bundleDir) ?? (evalPath ? path.dirname(evalPath) : undefined); const resultDir = resolveRelativeRunPath(options.sourceRunDir, record.result_dir) ?? - (taskDir ? path.dirname(taskDir) : undefined); + (testDir ? path.dirname(testDir) : undefined); - if (!evalPath || !targetsPath || !taskDir || !resultDir) { + if (!evalPath || !targetsPath || !testDir || !resultDir) { throw new Error( - `Selected result ${recordLabel} is missing task bundle paths. Re-run requires task/EVAL.yaml and task/targets.yaml.`, + `Selected result ${recordLabel} is missing test bundle paths. Re-run requires test/EVAL.yaml and test/targets.yaml.`, ); } - await ensureFile(evalPath, `Task eval for ${recordLabel}`); - await ensureFile(targetsPath, `Task targets for ${recordLabel}`); + await ensureFile(evalPath, `Test eval for ${recordLabel}`); + await ensureFile(targetsPath, `Test targets for ${recordLabel}`); const taskTarget = await readTaskTarget(evalPath, sourceTarget); selected.push({ record, testId, sourceTarget, resultDir, - taskDir, + testDir, evalPath, targetsPath, taskTarget, @@ -364,7 +365,7 @@ async function loadSelectedTaskBundles(options: { if (selected.length === 0) { throw new Error( - 'No captured task bundles matched the provided --test-id/--source-target filters.', + 'No captured test bundles matched the provided --test-id/--source-target filters.', ); } return selected; @@ -384,7 +385,7 @@ function buildSourceMetadataByEvalFile( sourceRunDir: path.resolve(sourceRunDir), sourceIndexPath: path.resolve(indexPath), sourceResultDir: path.resolve(bundle.resultDir), - sourceTaskDir: path.resolve(bundle.taskDir), + sourceTestDir: path.resolve(bundle.testDir), sourceTestId: bundle.testId, sourceTarget: bundle.sourceTarget, sourceTimestamp: bundle.record.timestamp, @@ -396,12 +397,12 @@ function buildSourceMetadataByEvalFile( export const runsRerunCommand = command({ name: 'rerun', - description: 'Rerun captured task bundles with local target environment', + description: 'Rerun captured test bundles with local target environment', args: { runDir: positional({ type: string, displayName: 'run-dir', - description: 'Run workspace directory or run manifest containing task bundles', + description: 'Run workspace directory or run manifest containing test bundles', }), testId: multioption({ type: array(string), @@ -490,11 +491,11 @@ export const runsRerunCommand = command({ targetNamesByFile.set(bundle.targetsPath, names); } for (const [targetsPath, names] of targetNamesByFile.entries()) { - await validateTargetFile(targetsPath, [...names], 'Task bundle targets'); + await validateTargetFile(targetsPath, [...names], 'Test bundle targets'); } } - console.log(`Rerunning ${selected.length} captured task bundle(s) from: ${sourceRunDir}`); + console.log(`Rerunning ${selected.length} captured test bundle(s) from: ${sourceRunDir}`); console.log(`Rerun output directory: ${outputDir}`); const result = await runEvalCommand({ diff --git a/apps/cli/test/commands/eval/artifact-writer.test.ts b/apps/cli/test/commands/eval/artifact-writer.test.ts index 8e736131f..2bc53aa26 100644 --- a/apps/cli/test/commands/eval/artifact-writer.test.ts +++ b/apps/cli/test/commands/eval/artifact-writer.test.ts @@ -1061,6 +1061,7 @@ describe('writeArtifactsFromResults', () => { }); expect(indexEntry?.result_dir).toBe(repeatRowDir); expect(indexEntry?.summary_path).toBe(`${repeatRowDir}/summary.json`); + expect(indexEntry?.test_dir).toBeUndefined(); expect(indexEntry?.task_dir).toBeUndefined(); expect(indexEntry?.input_path).toBeUndefined(); expect(indexEntry?.grading_path).toBeUndefined(); @@ -1939,7 +1940,7 @@ describe('writeArtifactsFromResults', () => { expect(alpha.result_dir).not.toBe(beta.result_dir); }); - it('writes task bundle artifacts with local source paths when source metadata is provided', async () => { + it('writes test bundle artifacts with local source paths when source metadata is provided', async () => { const sourceRoot = path.join(testDir, 'src'); await mkdir(sourceRoot, { recursive: true }); const evalFile = path.join(sourceRoot, 'trace.eval.yaml'); @@ -2058,28 +2059,31 @@ describe('writeArtifactsFromResults', () => { const [indexLine] = await readIndexLines(paths.indexPath); const rowDir = expectRowDir(indexLine, 'trace-case'); - const taskDir = path.join(outputDir, rowDir, 'task'); - const evalPath = path.join(taskDir, 'EVAL.yaml'); - const targetsPath = path.join(taskDir, 'targets.yaml'); + const testBundleDir = path.join(outputDir, rowDir, 'test'); + const evalPath = path.join(testBundleDir, 'EVAL.yaml'); + const targetsPath = path.join(testBundleDir, 'targets.yaml'); const taskEval = await readFile(evalPath, 'utf8'); const taskTargets = await readFile(targetsPath, 'utf8'); expect(indexLine).toMatchObject({ result_dir: rowDir, - task_dir: `${rowDir}/task`, - eval_path: `${rowDir}/task/EVAL.yaml`, - targets_path: `${rowDir}/task/targets.yaml`, - files_path: `${rowDir}/task/files`, - graders_path: `${rowDir}/task/graders`, + test_dir: `${rowDir}/test`, + eval_path: `${rowDir}/test/EVAL.yaml`, + targets_path: `${rowDir}/test/targets.yaml`, + files_path: `${rowDir}/test/files`, + graders_path: `${rowDir}/test/graders`, }); - expect(await readFile(path.join(taskDir, 'files', 'src', 'input.txt'), 'utf8')).toBe( + expect(indexLine.task_dir).toBeUndefined(); + expect(await readFile(path.join(testBundleDir, 'files', 'src', 'input.txt'), 'utf8')).toBe( 'input fixture\n', ); - expect(await readFile(path.join(taskDir, 'files', 'src', '.env'), 'utf8')).toBe('[redacted]\n'); - expect(await readFile(path.join(taskDir, 'graders', 'src', 'grader.md'), 'utf8')).toBe( + expect(await readFile(path.join(testBundleDir, 'files', 'src', '.env'), 'utf8')).toBe( + '[redacted]\n', + ); + expect(await readFile(path.join(testBundleDir, 'graders', 'src', 'grader.md'), 'utf8')).toBe( 'grade this response\n', ); - expect(await readFile(path.join(taskDir, 'graders', 'src', 'prompt.ts'), 'utf8')).toBe( + expect(await readFile(path.join(testBundleDir, 'graders', 'src', 'prompt.ts'), 'utf8')).toBe( 'console.log("prompt");\n', ); @@ -2102,10 +2106,10 @@ describe('writeArtifactsFromResults', () => { expect(taskEval).not.toContain('literal-secret'); expect(taskTargets).not.toContain('literal-secret'); await expect(readdir(path.join(outputDir, rowDir, '.agentv', 'results'))).rejects.toThrow(); - await expect(readdir(path.join(taskDir, '.agentv', 'results'))).rejects.toThrow(); + await expect(readdir(path.join(testBundleDir, '.agentv', 'results'))).rejects.toThrow(); }); - it('writes task bundle index links for multi-test runs', async () => { + it('writes test bundle index links for multi-test runs', async () => { const evalFile = path.join(testDir, 'multi.eval.yaml'); await mkdir(path.dirname(evalFile), { recursive: true }); await writeFile( @@ -2152,18 +2156,19 @@ describe('writeArtifactsFromResults', () => { const indexLines = await readIndexLines(paths.indexPath); const rowDirs = indexLines.map((line) => expectRowDir(line, line.test_id)); - expect(indexLines.map((line, index) => line.task_dir)).toEqual( - rowDirs.map((rowDir) => `${rowDir}/task`), + expect(indexLines.map((line) => line.test_dir)).toEqual( + rowDirs.map((rowDir) => `${rowDir}/test`), ); - expect(await readdir(path.join(testDir, 'multi-out', rowDirs[0] ?? '', 'task'))).toContain( + expect(indexLines.map((line) => line.task_dir)).toEqual([undefined, undefined]); + expect(await readdir(path.join(testDir, 'multi-out', rowDirs[0] ?? '', 'test'))).toContain( 'EVAL.yaml', ); - expect(await readdir(path.join(testDir, 'multi-out', rowDirs[1] ?? '', 'task'))).toContain( + expect(await readdir(path.join(testDir, 'multi-out', rowDirs[1] ?? '', 'test'))).toContain( 'EVAL.yaml', ); }); - it('matches task bundle targets by resolved result target while preserving selected target name', async () => { + it('matches test bundle targets by resolved result target while preserving selected target name', async () => { const evalFile = path.join(testDir, 'resolved-target.eval.yaml'); await mkdir(path.dirname(evalFile), { recursive: true }); await writeFile(evalFile, 'tests:\n - id: alias-case\n input: hello\n'); @@ -2204,10 +2209,11 @@ describe('writeArtifactsFromResults', () => { const [indexLine] = await readIndexLines(paths.indexPath); const rowDir = expectRowDir(indexLine, 'alias-case'); - expect(indexLine.task_dir).toBe(`${rowDir}/task`); + expect(indexLine.test_dir).toBe(`${rowDir}/test`); + expect(indexLine.task_dir).toBeUndefined(); const taskEval = await readFile( - path.join(testDir, 'resolved-target-out', rowDir, 'task', 'EVAL.yaml'), + path.join(testDir, 'resolved-target-out', rowDir, 'test', 'EVAL.yaml'), 'utf8', ); const parsedEval = parseYamlValue(taskEval) as Record; diff --git a/apps/cli/test/commands/eval/task-bundle.test.ts b/apps/cli/test/commands/eval/task-bundle.test.ts index ef80d6e0f..98613183a 100644 --- a/apps/cli/test/commands/eval/task-bundle.test.ts +++ b/apps/cli/test/commands/eval/task-bundle.test.ts @@ -18,7 +18,7 @@ describe('materializeTaskBundle', () => { await rm(tempDir, { recursive: true, force: true }); }); - it('writes a self-contained task bundle without executing a provider', async () => { + it('writes a self-contained test bundle without executing a provider', async () => { const evalFile = path.join(tempDir, 'evals', 'demo.eval.yaml'); const fixturePath = path.join(tempDir, 'fixtures', 'input.txt'); const promptPath = path.join(tempDir, 'graders', 'prompt.md'); @@ -104,15 +104,16 @@ describe('materializeTaskBundle', () => { }); expect(paths).toBeDefined(); - const taskDir = paths?.taskDir ?? ''; - expect(await readdir(taskDir)).toContain('EVAL.yaml'); - expect(await readFile(path.join(taskDir, 'files', 'fixtures', 'input.txt'), 'utf8')).toBe( + const testBundleDir = paths?.testDir ?? ''; + expect(path.basename(testBundleDir)).toBe('test'); + expect(await readdir(testBundleDir)).toContain('EVAL.yaml'); + expect(await readFile(path.join(testBundleDir, 'files', 'fixtures', 'input.txt'), 'utf8')).toBe( 'fixture text\n', ); - expect(await readFile(path.join(taskDir, 'graders', 'graders', 'prompt.md'), 'utf8')).toBe( - 'grade carefully\n', - ); - expect(await readFile(path.join(taskDir, 'graders', 'graders', 'check.ts'), 'utf8')).toBe( + expect( + await readFile(path.join(testBundleDir, 'graders', 'graders', 'prompt.md'), 'utf8'), + ).toBe('grade carefully\n'); + expect(await readFile(path.join(testBundleDir, 'graders', 'graders', 'check.ts'), 'utf8')).toBe( 'console.log("ok");\n', ); @@ -133,6 +134,6 @@ describe('materializeTaskBundle', () => { expect(taskEval).not.toContain('literal-secret'); expect(taskTargets).not.toContain('literal-secret'); await expect(readdir(path.join(tempDir, 'out', '.agentv', 'results'))).rejects.toThrow(); - await expect(readdir(path.join(taskDir, '.agentv', 'results'))).rejects.toThrow(); + await expect(readdir(path.join(testBundleDir, '.agentv', 'results'))).rejects.toThrow(); }); }); diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index 3e867b91e..a41fe79a9 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -2751,7 +2751,7 @@ describe('serve app', () => { expect(data.source_label).toBe(filename); }); - it('loads historical runs without task bundle metadata', async () => { + it('loads historical runs without test bundle metadata', async () => { const runId = writeLocalRunArtifact( tempDir, 'historical', @@ -2766,8 +2766,32 @@ describe('serve app', () => { results: Array>; }; expect(data.results[0]).not.toHaveProperty('task_dir'); + expect(data.results[0]).not.toHaveProperty('test_dir'); expect(data.results[0]).not.toHaveProperty('source_traceability'); }); + + it('loads generated test bundle metadata from manifest rows', async () => { + const runId = writeLocalRunArtifact(tempDir, 'generated-bundle', '2026-03-25T12-00-01-000Z', { + ...RESULT_A, + result_dir: 'demo/test-greeting', + test_dir: 'demo/test-greeting/test', + eval_path: 'demo/test-greeting/test/EVAL.yaml', + targets_path: 'demo/test-greeting/test/targets.yaml', + }); + + const app = createApp([], tempDir, tempDir, undefined, { studioDir }); + const res = await app.request(`/api/runs/${encodeURIComponent(runId)}`); + expect(res.status).toBe(200); + const data = (await res.json()) as { + results: Array>; + }; + expect(data.results[0]).toMatchObject({ + result_dir: 'demo/test-greeting', + test_dir: 'demo/test-greeting/test', + eval_path: 'demo/test-greeting/test/EVAL.yaml', + }); + expect(data.results[0]).not.toHaveProperty('task_dir'); + }); }); describe('run combine API', () => { diff --git a/apps/cli/test/commands/results/validate.test.ts b/apps/cli/test/commands/results/validate.test.ts index 7b82d7da9..8b483a7bc 100644 --- a/apps/cli/test/commands/results/validate.test.ts +++ b/apps/cli/test/commands/results/validate.test.ts @@ -118,4 +118,67 @@ describe('results validate', () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + it('accepts new test_dir and legacy task_dir bundle metadata', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'agentv-validate-test-')); + + try { + const runDir = path.join( + tempDir, + '.agentv', + 'results', + 'with-bundles', + '2026-03-27T12-42-24-429Z', + ); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + path.join(runDir, 'index.jsonl'), + `${[ + JSON.stringify({ + timestamp: '2026-03-27T12:42:24.429Z', + test_id: 'test-new', + score: 1, + target: 'gpt-4o', + scores: [{ name: 'quality', type: 'llm', score: 1, verdict: 'pass' }], + execution_status: 'ok', + summary_path: 'test-new/summary.json', + test_dir: 'test-new/test', + eval_path: 'test-new/test/EVAL.yaml', + targets_path: 'test-new/test/targets.yaml', + }), + JSON.stringify({ + timestamp: '2026-03-27T12:42:24.429Z', + test_id: 'test-legacy', + score: 1, + target: 'gpt-4o', + scores: [{ name: 'quality', type: 'llm', score: 1, verdict: 'pass' }], + execution_status: 'ok', + summary_path: 'test-legacy/summary.json', + task_dir: 'test-legacy/task', + eval_path: 'test-legacy/task/EVAL.yaml', + targets_path: 'test-legacy/task/targets.yaml', + }), + ].join('\n')}\n`, + ); + for (const testId of ['test-new', 'test-legacy']) { + mkdirSync(path.join(runDir, testId), { recursive: true }); + writeFileSync( + path.join(runDir, testId, 'summary.json'), + `${JSON.stringify({ + test_id: testId, + score: 1, + target: 'gpt-4o', + execution_status: 'ok', + })}\n`, + ); + } + writeFileSync(path.join(runDir, 'summary.json'), '{}\n'); + + const { diagnostics } = validateRunDirectory(runDir); + + expect(diagnostics.filter((d) => d.severity === 'error')).toEqual([]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/cli/test/commands/runs/rerun.test.ts b/apps/cli/test/commands/runs/rerun.test.ts index 2a6be746a..ee9d9701e 100644 --- a/apps/cli/test/commands/runs/rerun.test.ts +++ b/apps/cli/test/commands/runs/rerun.test.ts @@ -38,15 +38,17 @@ async function writeTaskBundle(options: { readonly sourceRunDir: string; readonly testId: string; readonly targetsYaml: string; + readonly legacyTaskDir?: boolean; }): Promise> { const artifactDir = path.join(options.sourceRunDir, options.testId); - const taskDir = path.join(artifactDir, 'task'); + const bundleDirname = options.legacyTaskDir ? 'task' : 'test'; + const bundleDir = path.join(artifactDir, bundleDirname); const outputsDir = path.join(artifactDir, 'outputs'); - await mkdir(taskDir, { recursive: true }); + await mkdir(bundleDir, { recursive: true }); await mkdir(outputsDir, { recursive: true }); await writeFile( - path.join(taskDir, 'EVAL.yaml'), + path.join(bundleDir, 'EVAL.yaml'), `execution: target: captured tests: @@ -58,11 +60,17 @@ tests: `, 'utf8', ); - await writeFile(path.join(taskDir, 'targets.yaml'), options.targetsYaml, 'utf8'); + await writeFile(path.join(bundleDir, 'targets.yaml'), options.targetsYaml, 'utf8'); await writeFile(path.join(artifactDir, 'grading.json'), '{"assertions":[]}\n', 'utf8'); await writeFile(path.join(artifactDir, 'timing.json'), '{"duration_ms":1}\n', 'utf8'); await writeFile(path.join(outputsDir, 'answer.md'), '@[assistant]:\nCaptured answer\n', 'utf8'); + const bundlePaths = { + [`${options.legacyTaskDir ? 'task' : 'test'}_dir`]: `${options.testId}/${bundleDirname}`, + eval_path: `${options.testId}/${bundleDirname}/EVAL.yaml`, + targets_path: `${options.testId}/${bundleDirname}/targets.yaml`, + }; + return { timestamp: '2024-01-01T00:00:00.000Z', test_id: options.testId, @@ -73,13 +81,14 @@ tests: timing_path: `${options.testId}/timing.json`, output_path: `${options.testId}/outputs/answer.md`, answer_path: `${options.testId}/outputs/answer.md`, - task_dir: `${options.testId}/task`, - eval_path: `${options.testId}/task/EVAL.yaml`, - targets_path: `${options.testId}/task/targets.yaml`, + ...bundlePaths, }; } -async function createBundleFixture(targetsYaml = DEFAULT_TARGETS): Promise { +async function createBundleFixture( + targetsYaml = DEFAULT_TARGETS, + options?: { readonly legacyTaskDir?: boolean }, +): Promise { const baseDir = await mkdtemp(path.join(tmpdir(), 'agentv-rerun-')); const cwd = path.join(baseDir, 'workspace'); const sourceRunDir = path.join(baseDir, 'source-run'); @@ -88,8 +97,18 @@ async function createBundleFixture(targetsYaml = DEFAULT_TARGETS): Promise { return created; } - it('reruns captured task bundles into an explicit output directory with source metadata', async () => { + it('reruns captured test bundles into an explicit output directory with source metadata', async () => { const created = await fixture(); const result = await runCli(created, [ @@ -210,13 +229,14 @@ describe('agentv runs rerun', () => { ]); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Rerunning 2 captured task bundle(s)'); + expect(result.stdout).toContain('Rerunning 2 captured test bundle(s)'); const { indexPath, rows } = await readOutputBundle(created.outputDir); expect(rows.map((row) => row.test_id)).toEqual(['case-alpha', 'case-beta']); expect(rows.every((row) => row.target === 'captured')).toBe(true); expect(rows[0].metadata).toMatchObject({ rerun_source: { mode: 'rerun', + source_test_dir: path.join(created.sourceRunDir, 'case-alpha', 'test'), source_test_id: 'case-alpha', source_target: 'captured', }, @@ -228,6 +248,29 @@ describe('agentv runs rerun', () => { expect(answer).not.toContain('Captured answer'); }, 30_000); + it('reruns legacy task_dir bundles for backward compatibility', async () => { + const created = await createBundleFixture(DEFAULT_TARGETS, { legacyTaskDir: true }); + + const result = await runCli(created, [ + 'runs', + 'rerun', + created.sourceRunDir, + '--test-id', + 'case-alpha', + '--output', + created.outputDir, + ]); + + expect(result.exitCode).toBe(0); + const { rows } = await readOutputBundle(created.outputDir); + expect(rows.map((row) => row.test_id)).toEqual(['case-alpha']); + expect(rows[0].metadata).toMatchObject({ + rerun_source: { + source_test_dir: path.join(created.sourceRunDir, 'case-alpha', 'task'), + }, + }); + }, 30_000); + it('fails clearly for missing env and accepts an explicit env file', async () => { const created = await fixture(`targets: - name: captured @@ -269,7 +312,7 @@ describe('agentv runs rerun', () => { it('fails loudly when selected bundle artifacts are missing', async () => { const created = await fixture(); - await rm(path.join(created.sourceRunDir, 'case-beta', 'task', 'targets.yaml')); + await rm(path.join(created.sourceRunDir, 'case-beta', 'test', 'targets.yaml')); const result = await runCli(created, [ 'runs', @@ -282,7 +325,7 @@ describe('agentv runs rerun', () => { ]); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Task targets for case-beta@captured not found'); + expect(result.stderr).toContain('Test targets for case-beta@captured not found'); }, 30_000); it('reruns a selected test subset from index.jsonl', async () => { @@ -303,29 +346,29 @@ describe('agentv runs rerun', () => { expect(rows.map((row) => row.test_id)).toEqual(['case-alpha']); }, 30_000); - it('chooses a default output directory outside the source task folder', async () => { + it('chooses a default output directory outside the source test bundle folder', async () => { const created = await fixture(); - const taskDir = path.join(created.sourceRunDir, 'case-alpha', 'task'); + const testBundleDir = path.join(created.sourceRunDir, 'case-alpha', 'test'); const result = await runCli( created, ['runs', 'rerun', created.sourceRunDir, '--test-id', 'case-alpha'], - { cwd: taskDir }, + { cwd: testBundleDir }, ); expect(result.exitCode).toBe(0); const outputDir = extractRerunOutputDir(result.stdout); - expect(path.relative(taskDir, outputDir).startsWith('..')).toBe(true); + expect(path.relative(testBundleDir, outputDir).startsWith('..')).toBe(true); const { rows } = await readOutputBundle(outputDir); expect(rows.map((row) => row.test_id)).toEqual(['case-alpha']); }, 30_000); - it('rejects explicit output nested under a source task folder', async () => { + it('rejects explicit output nested under a source test bundle folder', async () => { const created = await fixture(); const nestedOutput = path.join( created.sourceRunDir, 'case-alpha', - 'task', + 'test', '.agentv', 'results', ); diff --git a/apps/web/src/content/docs/docs/evaluation/running-evals.mdx b/apps/web/src/content/docs/docs/evaluation/running-evals.mdx index bb76a9e89..6095d640d 100644 --- a/apps/web/src/content/docs/docs/evaluation/running-evals.mdx +++ b/apps/web/src/content/docs/docs/evaluation/running-evals.mdx @@ -118,9 +118,9 @@ agentv eval evals/my-eval.yaml --output ./my-results cat ./my-results/index.jsonl ``` -### Generated Task Bundles +### Generated Test Bundles -Each result can also include a generated task bundle inside its per-test result +Each result can also include a generated test bundle inside its per-test result directory. The bundle captures the eval slice and target settings that produced that row, so reviewers and rerun tooling can inspect the exact run-local source instead of relying on a mutable checkout. @@ -141,7 +141,7 @@ my-results/ transcript.jsonl transcript-raw.jsonl outputs/answer.md - task/ + test/ EVAL.yaml targets.yaml files/ # copied input files when the case references them @@ -149,7 +149,7 @@ my-results/ ``` The `index.jsonl` row links to these generated paths with snake_case fields such -as `result_dir`, `task_dir`, `eval_path`, `targets_path`, `files_path`, and +as `result_dir`, `test_dir`, `eval_path`, `targets_path`, `files_path`, and `graders_path`. Treat those paths as relative to the run directory. When you need a portable artifact for audit, review, Dashboard inspection, or rerun workflows, share the generated run directory and its `index.jsonl` manifest. Source-side @@ -165,7 +165,7 @@ for a result row. `suite` and `name` are display metadata only; do not use them to infer storage paths or pick a Dashboard detail row. If the source eval uses the `PROMPT.md` fallback instead of inline `input`, -AgentV records the generated task bundle metadata when source artifacts are +AgentV records the generated test bundle metadata when source artifacts are available. It no longer emits a generated prompt sidecar for result rows. ### Manual or External-Agent Attempts diff --git a/apps/web/src/content/docs/docs/guides/benchmark-provenance.mdx b/apps/web/src/content/docs/docs/guides/benchmark-provenance.mdx index e6a195983..7cd9893a5 100644 --- a/apps/web/src/content/docs/docs/guides/benchmark-provenance.mdx +++ b/apps/web/src/content/docs/docs/guides/benchmark-provenance.mdx @@ -80,11 +80,11 @@ Benchmark task packs map cleanly onto AgentV fields at authoring time: Use this separation only when it makes the source eval easier to maintain. It is not a first-class artifact schema. After an eval runs, AgentV writes the portable audit surface into the generated run folder: each result can link from -`index.jsonl` to a run-local `task/` bundle containing `EVAL.yaml`, +`index.jsonl` to a run-local `test/` bundle containing `EVAL.yaml`, `targets.yaml`, and copied `files/` or `graders/` snapshots where applicable. Review, Dashboard files views, and rerun workflows should inspect those generated run artifacts instead of requiring authors to maintain a parallel source-side -bundle layout. See [Generated Task Bundles](/docs/evaluation/running-evals/#generated-task-bundles). +bundle layout. See [Generated Test Bundles](/docs/evaluation/running-evals/#generated-test-bundles). ## SWE-Style Case diff --git a/apps/web/src/content/docs/docs/reference/result-artifacts.mdx b/apps/web/src/content/docs/docs/reference/result-artifacts.mdx index 37b6e39b4..dcb0c8150 100644 --- a/apps/web/src/content/docs/docs/reference/result-artifacts.mdx +++ b/apps/web/src/content/docs/docs/reference/result-artifacts.mdx @@ -31,7 +31,7 @@ The default local layout is: tags.json # optional mutable Dashboard tags / summary.json # optional per-case aggregate, especially repeats - task/ # optional generated task bundle + test/ # optional generated test bundle EVAL.yaml targets.yaml files/ @@ -81,7 +81,7 @@ query. | `timing.json` | Duration, token usage, cost usage, and source labels such as `provider_reported`, `token_estimated`, `aggregate`, or `unavailable`. | Cost/latency reporting and provider-accounting audits. | | `transcript.jsonl` | AgentV-normalized transcript/timeline rows. | Portable human review, replay, transcript-aware graders, and tool-trajectory analysis. | | `transcript-raw.jsonl` | Native provider or harness evidence when available. | Parser debugging, forensic review, and preserving source bytes without making provider schemas public AgentV fields. | -| `task/` | Generated task bundle for the exact eval slice and target settings that produced a row. | Audit, external review, and rerun workflows that should not depend on a mutable source checkout. | +| `test/` | Generated test bundle for the exact eval slice and target settings that produced a row. | Audit, external review, and rerun workflows that should not depend on a mutable source checkout. | | `artifact_pointers` | Offload indirection for large detached payload bytes. | Finding payloads published outside the primary metadata/control-plane branch, such as transcript bytes on `agentv/artifacts/v1`. | `summary.json` and `index.jsonl` are complementary, not redundant. A run list @@ -127,7 +127,7 @@ Example row: "transcript_raw_path": "refund-eligibility/run-1/transcript-raw.jsonl", "output_path": "refund-eligibility/run-1/outputs/answer.md", "answer_path": "refund-eligibility/run-1/outputs/answer.md", - "task_dir": "refund-eligibility/task" + "test_dir": "refund-eligibility/test" } ``` diff --git a/apps/web/src/content/docs/docs/tools/dashboard.mdx b/apps/web/src/content/docs/docs/tools/dashboard.mdx index 64b59a597..66e646d8f 100644 --- a/apps/web/src/content/docs/docs/tools/dashboard.mdx +++ b/apps/web/src/content/docs/docs/tools/dashboard.mdx @@ -101,7 +101,7 @@ You can also set the same field globally in `$AGENTV_HOME/config.yaml` or `~/.ag ## Run Detail -Click any run to see a breakdown by suite, per-test scores, target, duration, and cost. The source label (`local` or `remote`) tells you where the run came from. Files and source views resolve against the generated run artifacts referenced by `index.jsonl`—including per-result task bundles when present—so Dashboard does not require authors to create a separate source-side bundle structure. +Click any run to see a breakdown by suite, per-test scores, target, duration, and cost. The source label (`local` or `remote`) tells you where the run came from. Files and source views resolve against the generated run artifacts referenced by `index.jsonl`—including per-result test bundles when present—so Dashboard does not require authors to create a separate source-side bundle structure. In the per-test results table, click a test ID to open its checks, transcript, source, files, and feedback in a row detail panel while the table, filters, and scroll position stay in place. Use **Full page** from the panel when you want the standalone eval detail route. diff --git a/apps/web/src/content/docs/docs/tools/results.mdx b/apps/web/src/content/docs/docs/tools/results.mdx index c0d0e3d5a..6795a45b1 100644 --- a/apps/web/src/content/docs/docs/tools/results.mdx +++ b/apps/web/src/content/docs/docs/tools/results.mdx @@ -99,7 +99,7 @@ Use `results export` when you need the artifact workspace layout itself rather t agentv results export [--out ] [--duplicate-policy update] ``` -This is useful when a manifest needs to be materialized into a predictable artifact tree for other tooling, review, or archiving. The run workspace is also where generated task bundles live: `index.jsonl` rows may point to per-result `task_dir`, `eval_path`, `targets_path`, `files_path`, and `graders_path` entries. Keep those generated artifacts with the run when sharing or auditing results. +This is useful when a manifest needs to be materialized into a predictable artifact tree for other tooling, review, or archiving. The run workspace is also where generated test bundles live: `index.jsonl` rows may point to per-result `test_dir`, `eval_path`, `targets_path`, `files_path`, and `graders_path` entries. Keep those generated artifacts with the run when sharing or auditing results. The export source is still the canonical run bundle described in the [Result Artifact Contract](/docs/reference/result-artifacts/): `summary.json` @@ -175,7 +175,7 @@ Agent Skills eval artifacts map into AgentV like this: | Agent Skills pattern | AgentV field | Artifact location | |----------------------|--------------|-------------------| -| Authored `evals/evals.json` cases | AgentV eval cases and task bundle paths | Eval source plus optional `task_dir`, `eval_path`, `targets_path`, `files_path`, and `graders_path` in `index.jsonl` | +| Authored `evals/evals.json` cases | AgentV eval cases and test bundle paths | Eval source plus optional `test_dir`, `eval_path`, `targets_path`, `files_path`, and `graders_path` in `index.jsonl` | | Per-case answer | Generated target output artifact | `run-N/outputs/answer.md` | | Per-attempt sidecars | Normalized transcript, metrics, and raw provider evidence | `run-N/transcript.jsonl`, `run-N/transcript-raw.jsonl`, `run-N/metrics.json` | | Per-attempt `timing.json` | Duration, token totals, cost, and usage source labels | `run-N/timing.json` | diff --git a/apps/web/src/content/docs/docs/tools/wip-checkpoints.mdx b/apps/web/src/content/docs/docs/tools/wip-checkpoints.mdx index 2eb389fa9..9baf16d93 100644 --- a/apps/web/src/content/docs/docs/tools/wip-checkpoints.mdx +++ b/apps/web/src/content/docs/docs/tools/wip-checkpoints.mdx @@ -85,7 +85,7 @@ git push origin --delete agentv/wip// - The first remote checkpoint happens on the periodic interval, so a process that dies immediately after startup may only have the local `summary.json` stub. - The WIP branch is force-pushed and keeps one snapshot commit. Do not treat it as an audit log. -- Checkpoint contents can include prompts, outputs, grader evidence, traces, and generated task bundles. Protect the results repo like any other eval artifact store. +- Checkpoint contents can include prompts, outputs, grader evidence, traces, and generated test bundles. Protect the results repo like any other eval artifact store. - Authentication and branch permissions are the same as normal results auto-push. If git or GitHub authentication is missing, AgentV warns and keeps evaluating locally. - WIP worktrees are based on the configured storage branch. Missing storage branches are initialized automatically; missing remotes or authentication still prevent WIP pushes until Git credentials are available. - Failed or interrupted runs intentionally leave WIP branches behind. Periodically delete old `agentv/wip/...` branches once recovered or obsolete. diff --git a/docs/adr/0009-eval-path-result-identity-and-default-experiment.md b/docs/adr/0009-eval-path-result-identity-and-default-experiment.md index 9ce000bbb..6b18791c7 100644 --- a/docs/adr/0009-eval-path-result-identity-and-default-experiment.md +++ b/docs/adr/0009-eval-path-result-identity-and-default-experiment.md @@ -102,7 +102,7 @@ they must not drive visible storage hierarchy, semantic routing, Dashboard detail selection, rerun lookup, import identity, or artifact discovery. `index.jsonl` is authoritative for all bundle-relative artifact paths. Per-row -directories are exposed with `result_dir`. Sidecar paths such as `task_dir`, +directories are exposed with `result_dir`. Sidecar paths such as `test_dir`, `summary_path`, `grading_path`, `metrics_path`, `transcript_path`, `targets_path`, `files_path`, and `graders_path` are explicit manifest fields. Consumers must use these fields instead of reconstructing paths from diff --git a/docs/adr/0011-result-output-artifact-contract.md b/docs/adr/0011-result-output-artifact-contract.md index a5f2a76c9..bf3e5224a 100644 --- a/docs/adr/0011-result-output-artifact-contract.md +++ b/docs/adr/0011-result-output-artifact-contract.md @@ -52,7 +52,7 @@ An AgentV result output is a run-centric bundle with this root contract: tags.json # optional mutable overlay / summary.json # optional case aggregate, especially repeats - task/ # optional generated task bundle + test/ # optional generated test bundle run-1/ result.json grading.json @@ -82,7 +82,7 @@ aggregate summaries. ordinary per-case sidecars through explicit fields such as `result_dir`, `summary_path`, `grading_path`, `metrics_path`, `timing_path`, `transcript_path`, `transcript_raw_path`, `answer_path`, `output_path`, -`task_dir`, `eval_path`, `targets_path`, `files_path`, and `graders_path` when +`test_dir`, `eval_path`, `targets_path`, `files_path`, and `graders_path` when those artifacts exist. `artifact_pointers` remain an offload indirection for large detached payload @@ -154,7 +154,7 @@ This aligns with Margin Evals' manifest-first and run-centric lessons: AgentV does not copy Margin Evals' exact layout. AgentV keeps its own `summary.json` plus `index.jsonl` split, AgentV transcript sidecars, repeat-run -attempt folders, generated task bundles, Git-backed result branch model, and +attempt folders, generated test bundles, Git-backed result branch model, and optional detached `artifact_pointers`. ## Consequences diff --git a/packages/core/src/evaluation/result-row-schema.ts b/packages/core/src/evaluation/result-row-schema.ts index d5b771031..f222c09e2 100644 --- a/packages/core/src/evaluation/result-row-schema.ts +++ b/packages/core/src/evaluation/result-row-schema.ts @@ -41,6 +41,7 @@ const RESULT_ROW_ALIASES = { summaryPath: 'summary_path', targetsPath: 'targets_path', taskDir: 'task_dir', + testDir: 'test_dir', testId: 'test_id', timingPath: 'timing_path', tokenUsage: 'token_usage', diff --git a/packages/core/src/evaluation/run-artifacts.ts b/packages/core/src/evaluation/run-artifacts.ts index 53d59989e..5bd0609bc 100644 --- a/packages/core/src/evaluation/run-artifacts.ts +++ b/packages/core/src/evaluation/run-artifacts.ts @@ -465,6 +465,7 @@ export interface IndexArtifactEntry { readonly runtime_source?: RunRuntimeSourceMetadata; readonly raw_provider_log_path?: string; readonly input_path?: string; + readonly test_dir?: string; readonly task_dir?: string; readonly eval_path?: string; readonly targets_path?: string; @@ -485,6 +486,7 @@ export type ResultIndexArtifact = IndexArtifactEntry; export type AdditionalResultIndexFields = Partial< Pick< IndexArtifactEntry, + | 'test_dir' | 'task_dir' | 'eval_path' | 'targets_path' @@ -762,7 +764,7 @@ function toIndexRerunSource(value: unknown): Record | undefined source_run_dir: value.sourceRunDir, source_index_path: value.sourceIndexPath, source_result_dir: value.sourceResultDir, - source_task_dir: value.sourceTaskDir, + source_test_dir: value.sourceTestDir ?? value.sourceTaskDir, source_test_id: value.sourceTestId, source_target: value.sourceTarget, source_timestamp: value.sourceTimestamp, From bea4b3c02c258b9ad7e4f6c3e708638324146cd7 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 30 Jun 2026 09:19:16 +0200 Subject: [PATCH 2/2] fix(results): preserve generated test bundles on export --- apps/cli/src/commands/eval/artifact-writer.ts | 6 +- apps/cli/src/commands/results/export.ts | 121 +++++++++++++++++- apps/cli/test/commands/results/export.test.ts | 105 +++++++++++++++ 3 files changed, 223 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/commands/eval/artifact-writer.ts b/apps/cli/src/commands/eval/artifact-writer.ts index 7f9c03c01..edf3704d5 100644 --- a/apps/cli/src/commands/eval/artifact-writer.ts +++ b/apps/cli/src/commands/eval/artifact-writer.ts @@ -220,6 +220,7 @@ export async function writePerTestArtifacts( repoRoot?: string; sourceTests?: readonly EvalTest[]; taskBundleTargets?: readonly TaskBundleTargetSelection[]; + additionalArtifacts?: AdditionalResultArtifactsWriter; runtimeSource?: RunRuntimeSourceMetadata; }, ): Promise { @@ -229,7 +230,7 @@ export async function writePerTestArtifacts( runId: options?.runId, duplicatePolicy: options?.duplicatePolicy, sourceTests: options?.sourceTests, - additionalArtifacts: createTaskBundleArtifactsWriter(options), + additionalArtifacts: options?.additionalArtifacts ?? createTaskBundleArtifactsWriter(options), runtimeSource: options?.runtimeSource, }); } @@ -249,6 +250,7 @@ export async function writeArtifactsFromResults( repoRoot?: string; sourceTests?: readonly EvalTest[]; taskBundleTargets?: readonly TaskBundleTargetSelection[]; + additionalArtifacts?: AdditionalResultArtifactsWriter; runtimeSource?: RunRuntimeSourceMetadata; }, ): Promise<{ @@ -265,7 +267,7 @@ export async function writeArtifactsFromResults( duplicatePolicy: options?.duplicatePolicy, resultGroup: options?.resultGroup, sourceTests: options?.sourceTests, - additionalArtifacts: createTaskBundleArtifactsWriter(options), + additionalArtifacts: options?.additionalArtifacts ?? createTaskBundleArtifactsWriter(options), runtimeSource: options?.runtimeSource, }); } diff --git a/apps/cli/src/commands/results/export.ts b/apps/cli/src/commands/results/export.ts index ba21b0d8e..aa0994624 100644 --- a/apps/cli/src/commands/results/export.ts +++ b/apps/cli/src/commands/results/export.ts @@ -20,12 +20,18 @@ * - To add new per-test workspace files, add them under each test directory. */ -import { readFileSync } from 'node:fs'; +import { cpSync, existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { command, flag, oneOf, option, optional, positional, string } from 'cmd-ts'; -import type { EvaluationResult, ExportDuplicatePolicy, IndexArtifactEntry } from '@agentv/core'; +import type { + AdditionalResultArtifactsWriter, + AdditionalResultIndexFields, + EvaluationResult, + ExportDuplicatePolicy, + IndexArtifactEntry, +} from '@agentv/core'; import { parseJsonlResults, writeArtifactsFromResults } from '../eval/artifact-writer.js'; import { @@ -51,6 +57,7 @@ export async function exportResults( options?: { duplicatePolicy?: ExportDuplicatePolicy }, ): Promise { const results = parseJsonlResults(content); + const sourceIndexRecords = parseIndexArtifactEntries(content); if (results.length === 0) { throw new Error(`No results found in ${sourceFile}`); @@ -60,6 +67,11 @@ export async function exportResults( evalFile: sourceFile, runId: deriveExportRunId(sourceFile), duplicatePolicy: options?.duplicatePolicy ?? 'update', + additionalArtifacts: createExportBundleArtifactsWriter({ + outputDir, + sourceBaseDir: path.dirname(sourceFile), + sourceRecordsByResult: buildSourceRecordMap(results, sourceIndexRecords), + }), }); } @@ -100,20 +112,109 @@ export function deriveExportRunId(sourceFile: string): string { export async function loadExportSource( source: string | undefined, cwd: string, -): Promise<{ sourceFile: string; results: readonly EvaluationResult[] }> { +): Promise<{ + sourceFile: string; + results: readonly EvaluationResult[]; + indexRecords?: readonly IndexArtifactEntry[]; +}> { const { sourceFile } = await resolveSourceFile(source, cwd); const { results } = await loadSharedResults(source, cwd); - return { sourceFile, results }; + const indexRecords = isRunManifestPath(sourceFile) + ? readIndexArtifactEntries(sourceFile) + : undefined; + return { sourceFile, results, indexRecords }; } -function readIndexArtifactEntries(indexPath: string): IndexArtifactEntry[] { - return readFileSync(indexPath, 'utf8') +function parseIndexArtifactEntries(content: string): IndexArtifactEntry[] { + return content .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line) as IndexArtifactEntry); } +function readIndexArtifactEntries(indexPath: string): IndexArtifactEntry[] { + return parseIndexArtifactEntries(readFileSync(indexPath, 'utf8')); +} + +function buildSourceRecordMap( + results: readonly EvaluationResult[], + sourceRecords: readonly IndexArtifactEntry[], +): ReadonlyMap { + return new Map( + results.flatMap((result, index) => { + const sourceRecord = sourceRecords[index]; + return sourceRecord ? [[result, sourceRecord] as const] : []; + }), + ); +} + +function isSafeRelativePath(relativePath: string | undefined): relativePath is string { + return ( + typeof relativePath === 'string' && + relativePath.trim().length > 0 && + !path.isAbsolute(relativePath) && + !relativePath.split(/[\\/]+/).includes('..') + ); +} + +function toRelativeArtifactPath(outputDir: string, filePath: string): string { + return path.relative(outputDir, filePath).split(path.sep).join('/'); +} + +function hasCopiedSubdir(testBundleDir: string, dirname: string): boolean { + return existsSync(path.join(testBundleDir, dirname)); +} + +function createExportBundleArtifactsWriter(options: { + readonly outputDir: string; + readonly sourceBaseDir: string; + readonly sourceRecordsByResult: ReadonlyMap; +}): AdditionalResultArtifactsWriter | undefined { + if (options.sourceRecordsByResult.size === 0) { + return undefined; + } + + return async ({ result, testDir }): Promise => { + const sourceRecord = options.sourceRecordsByResult.get(result); + const sourceBundleDir = sourceRecord?.test_dir ?? sourceRecord?.task_dir; + if (!isSafeRelativePath(sourceBundleDir)) { + return undefined; + } + + const sourceBundlePath = path.join(options.sourceBaseDir, sourceBundleDir); + const testBundlePath = path.join(testDir, 'test'); + if (existsSync(sourceBundlePath)) { + cpSync(sourceBundlePath, testBundlePath, { recursive: true, force: true }); + } + + return { + test_dir: toRelativeArtifactPath(options.outputDir, testBundlePath), + eval_path: toRelativeArtifactPath(options.outputDir, path.join(testBundlePath, 'EVAL.yaml')), + targets_path: toRelativeArtifactPath( + options.outputDir, + path.join(testBundlePath, 'targets.yaml'), + ), + ...(sourceRecord?.files_path || hasCopiedSubdir(testBundlePath, 'files') + ? { + files_path: toRelativeArtifactPath( + options.outputDir, + path.join(testBundlePath, 'files'), + ), + } + : {}), + ...(sourceRecord?.graders_path || hasCopiedSubdir(testBundlePath, 'graders') + ? { + graders_path: toRelativeArtifactPath( + options.outputDir, + path.join(testBundlePath, 'graders'), + ), + } + : {}), + }; + }; +} + export function buildProjectionBundleFromExportedIndex(options: { readonly sourceFile: string; readonly outputDir: string; @@ -197,7 +298,7 @@ export const resultsExportCommand = command({ const shouldIncludeRawContent = includeRawContent; try { - const { sourceFile, results } = await loadExportSource(source, cwd); + const { sourceFile, results, indexRecords } = await loadExportSource(source, cwd); const outputDir = out ? path.isAbsolute(out) @@ -212,6 +313,7 @@ export const resultsExportCommand = command({ cwd, duplicatePolicy: policy, includeRawContent: shouldIncludeRawContent, + indexRecords, }); if (shouldDryRun) { @@ -223,6 +325,11 @@ export const resultsExportCommand = command({ evalFile: sourceFile, runId: deriveExportRunId(sourceFile), duplicatePolicy: policy, + additionalArtifacts: createExportBundleArtifactsWriter({ + outputDir, + sourceBaseDir: path.dirname(sourceFile), + sourceRecordsByResult: buildSourceRecordMap(results, indexRecords ?? []), + }), }); const bundlePath = shouldWriteProjectionBundle diff --git a/apps/cli/test/commands/results/export.test.ts b/apps/cli/test/commands/results/export.test.ts index 88cfa4564..da4df1a0c 100644 --- a/apps/cli/test/commands/results/export.test.ts +++ b/apps/cli/test/commands/results/export.test.ts @@ -572,6 +572,111 @@ describe('results export', () => { ); }); + it('exports generated test bundle refs and files from source manifests', async () => { + const sourceDir = path.join(tempDir, 'source-run'); + mkdirSync(path.join(sourceDir, 'case', 'test'), { recursive: true }); + writeFileSync( + path.join(sourceDir, 'case', 'test', 'EVAL.yaml'), + 'tests:\n - id: test-greeting\n', + ); + writeFileSync(path.join(sourceDir, 'case', 'test', 'targets.yaml'), 'targets: []\n'); + const sourceFile = path.join(sourceDir, RESULT_INDEX_FILENAME); + const outputDir = path.join(tempDir, 'output'); + const content = toJsonl({ + ...RESULT_FULL, + result_dir: 'case', + test_dir: 'case/test', + eval_path: 'case/test/EVAL.yaml', + targets_path: 'case/test/targets.yaml', + }); + + await exportResults(sourceFile, content, outputDir); + + const [entry] = readIndex(outputDir); + expect(entry.test_dir).toBe(`${entry.result_dir}/test`); + expect(entry.task_dir).toBeUndefined(); + expect(entry.eval_path).toBe(`${entry.result_dir}/test/EVAL.yaml`); + expect(entry.targets_path).toBe(`${entry.result_dir}/test/targets.yaml`); + expect(readFileSync(path.join(outputDir, entry.eval_path ?? ''), 'utf8')).toContain( + 'test-greeting', + ); + + const bundle = buildProjectionBundleFromExportedIndex({ + sourceFile, + outputDir, + cwd: tempDir, + includeRawContent: true, + }); + expect(bundle.entries[0].artifact_refs).toMatchObject({ + status: 'emitted', + test_dir: entry.test_dir, + eval_path: entry.eval_path, + targets_path: entry.targets_path, + }); + }); + + it('exports legacy task_dir bundles as new test_dir artifacts', async () => { + const sourceDir = path.join(tempDir, 'legacy-run'); + mkdirSync(path.join(sourceDir, 'case', 'task'), { recursive: true }); + writeFileSync( + path.join(sourceDir, 'case', 'task', 'EVAL.yaml'), + 'tests:\n - id: test-greeting\n', + ); + writeFileSync(path.join(sourceDir, 'case', 'task', 'targets.yaml'), 'targets: []\n'); + const sourceFile = path.join(sourceDir, RESULT_INDEX_FILENAME); + const outputDir = path.join(tempDir, 'output'); + const content = toJsonl({ + ...RESULT_FULL, + result_dir: 'case', + task_dir: 'case/task', + eval_path: 'case/task/EVAL.yaml', + targets_path: 'case/task/targets.yaml', + }); + + await exportResults(sourceFile, content, outputDir); + + const [entry] = readIndex(outputDir); + expect(entry.test_dir).toBe(`${entry.result_dir}/test`); + expect(entry.task_dir).toBeUndefined(); + expect(entry.eval_path).toBe(`${entry.result_dir}/test/EVAL.yaml`); + expect(entry.targets_path).toBe(`${entry.result_dir}/test/targets.yaml`); + expect(readFileSync(path.join(outputDir, entry.eval_path ?? ''), 'utf8')).toContain( + 'test-greeting', + ); + }); + + it('preserves source bundle refs in dry-run projection inputs', async () => { + const sourceDir = path.join(tempDir, 'source-run'); + mkdirSync(sourceDir, { recursive: true }); + const sourceFile = path.join(sourceDir, RESULT_INDEX_FILENAME); + writeFileSync( + sourceFile, + toJsonl({ + ...RESULT_FULL, + result_dir: 'case', + task_dir: 'case/task', + eval_path: 'case/task/EVAL.yaml', + targets_path: 'case/task/targets.yaml', + }), + ); + + const source = await loadExportSource(sourceFile, tempDir); + const bundle = buildProjectionBundle(source.results, { + sourceFile: source.sourceFile, + runId: deriveExportRunId(source.sourceFile), + cwd: tempDir, + includeRawContent: true, + indexRecords: source.indexRecords, + }); + + expect(bundle.entries[0].artifact_refs).toMatchObject({ + status: 'planned_export', + task_dir: 'case/task', + eval_path: 'case/task/EVAL.yaml', + targets_path: 'case/task/targets.yaml', + }); + }); + it('fails duplicate projection artifacts when duplicate policy is error', async () => { const sourceFile = path.join(tempDir, 'runs', 'retry-run', 'index.jsonl'); const outputDir = path.join(tempDir, 'output');