All checks were successful
CI / test (pull_request) Successful in 52s
Retain the agent's final report on the benchmark result record for read tasks, so a failed read is diagnosable directly from the stored record instead of only carrying an opaque `incorrect` tag. The runner resolves the scoring spec once and records `run.finalReport` when the spec is a read; mutation records omit the field entirely. The sample store needs no change — it serializes whatever record it is handed. This is the prerequisite for confirming the read-open-issue-count failure from real report text before the state-aware count-line change (task 0033).
508 lines
19 KiB
TypeScript
508 lines
19 KiB
TypeScript
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import type { RepoState } from "./scoring-spec.js";
|
|
import type { BenchAccess, RepoCoords } from "./seed.js";
|
|
import { groundTruth } from "./seed-plan.js";
|
|
import { createSampleStore } from "./store.js";
|
|
import type { BenchTask } from "./task.js";
|
|
import { SAMPLE_TASK } from "./task.js";
|
|
import type { AgentDriver, BenchHost } from "./runner.js";
|
|
import { runCell } from "./runner.js";
|
|
|
|
// The user the fake seed/capture are parametrized by; a single independent
|
|
// literal, echoed into the passing capture below.
|
|
const USER = "benchbot";
|
|
|
|
// The fixed coordinates the fake host provisions. Independent literals so the
|
|
// delete-with-these-coords assertion is unambiguous.
|
|
const COORDS: RepoCoords = { owner: USER, repo: "bench-1" };
|
|
|
|
const ACCESS: BenchAccess = { apiUrl: "https://git.example.test", token: "tok" };
|
|
|
|
// The passing post-run state IS the task's own expected end state, so the
|
|
// checker scores it a legitimate pass. Built from the task, not from runner.ts.
|
|
const spec = SAMPLE_TASK.scoringSpec(USER);
|
|
const passingState: RepoState = spec.kind === "mutation" ? spec.expected : groundTruth(USER);
|
|
|
|
// The four token components, turns, and imputed cost are independent literals
|
|
// planted in the fake driver; the recorded sample must carry them back unchanged.
|
|
const DRIVER_TOKENS = { freshInput: 100, cacheCreation: 20, cacheRead: 300, output: 40 };
|
|
const DRIVER_TURNS = 5;
|
|
const DRIVER_COST = 0.12;
|
|
|
|
/** A fake host recording which methods were called and with what coords. */
|
|
function createFakeHost() {
|
|
const calls = {
|
|
provisioned: false,
|
|
seeded: false,
|
|
captured: false,
|
|
deletedCoords: null as RepoCoords | null,
|
|
};
|
|
const host: BenchHost = {
|
|
async provision() {
|
|
calls.provisioned = true;
|
|
return COORDS;
|
|
},
|
|
async seed(coords) {
|
|
calls.seeded = true;
|
|
return groundTruth(coords.owner);
|
|
},
|
|
async capture() {
|
|
calls.captured = true;
|
|
return passingState;
|
|
},
|
|
async delete(coords) {
|
|
calls.deletedCoords = coords;
|
|
},
|
|
};
|
|
return { host, calls };
|
|
}
|
|
|
|
/**
|
|
* A fake host whose `capture` returns the UNMUTATED seed — the target issue "Add
|
|
* CSV export option" is still OPEN, so the task's spec (which expects it CLOSED)
|
|
* is not satisfied and the checker scores the run incorrect. Everything else
|
|
* matches createFakeHost. Records the deleted coords for the cleanup assertion.
|
|
*/
|
|
function createUnmutatedHost() {
|
|
const calls = { deletedCoords: null as RepoCoords | null };
|
|
const host: BenchHost = {
|
|
async provision() {
|
|
return COORDS;
|
|
},
|
|
async seed(coords) {
|
|
return groundTruth(coords.owner);
|
|
},
|
|
async capture(coords) {
|
|
return groundTruth(coords.owner);
|
|
},
|
|
async delete(coords) {
|
|
calls.deletedCoords = coords;
|
|
},
|
|
};
|
|
return { host, calls };
|
|
}
|
|
|
|
/** A fake driver that resolves immediately with the planted metrics. */
|
|
const driver: AgentDriver = {
|
|
async run() {
|
|
return {
|
|
tokens: DRIVER_TOKENS,
|
|
turns: DRIVER_TURNS,
|
|
imputedCostUsd: DRIVER_COST,
|
|
transcript: [{ kind: "mcp", server: "gitea-mcp", tool: "edit_issue" }],
|
|
finalReport: "Closed the issue.",
|
|
stoppedByTurnCap: false,
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* A fake driver that completes cleanly (not turn-capped, clean MCP transcript
|
|
* that audits clean on the gitea-mcp arm). Paired with the unmutated host, the
|
|
* run finishes but the checker scores it incorrect.
|
|
*/
|
|
const cleanDriver: AgentDriver = {
|
|
async run() {
|
|
return {
|
|
tokens: { freshInput: 50, cacheCreation: 0, cacheRead: 0, output: 10 },
|
|
turns: 2,
|
|
imputedCostUsd: 0.03,
|
|
transcript: [{ kind: "mcp", server: "gitea-mcp", tool: "list_repo_issues" }],
|
|
finalReport: "Done.",
|
|
stoppedByTurnCap: false,
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* A fake driver reporting it hit the turn cap (`stoppedByTurnCap: true`). The
|
|
* other fields are arbitrary-but-valid literals; the run should be recorded as a
|
|
* confused failure regardless of them.
|
|
*/
|
|
const turnCappedDriver: AgentDriver = {
|
|
async run() {
|
|
return {
|
|
tokens: { freshInput: 10, cacheCreation: 0, cacheRead: 0, output: 5 },
|
|
turns: 10,
|
|
imputedCostUsd: 0.02,
|
|
transcript: [],
|
|
finalReport: "",
|
|
stoppedByTurnCap: true,
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* A fake driver that never resolves on its own — it settles only when its abort
|
|
* signal fires. Paired with a clock whose backstop timer fires first, it models a
|
|
* run that hangs past the wall-clock bound: the runner aborts the signal, and the
|
|
* driver then settles so no forever-pending promise is leaked.
|
|
*/
|
|
const hangingDriver: AgentDriver = {
|
|
run: ({ signal }) =>
|
|
new Promise((resolve) => {
|
|
signal.addEventListener("abort", () =>
|
|
resolve({
|
|
tokens: { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 },
|
|
turns: 0,
|
|
imputedCostUsd: 0,
|
|
transcript: [],
|
|
finalReport: "",
|
|
stoppedByTurnCap: false,
|
|
}),
|
|
);
|
|
}),
|
|
};
|
|
|
|
/**
|
|
* A fake driver whose transcript reaches the shell on the shell-disabled
|
|
* gitea-mcp arm — a foreign-tool leak per the audit's contract (proven in
|
|
* bench/audit.test.ts). The post-run audit should flag this cell invalid.
|
|
*/
|
|
const leakingDriver: AgentDriver = {
|
|
async run() {
|
|
return {
|
|
tokens: { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 },
|
|
turns: 1,
|
|
imputedCostUsd: 0,
|
|
transcript: [{ kind: "shell", command: "curl https://git.example.test/api/v1/repos" }],
|
|
finalReport: "",
|
|
stoppedByTurnCap: false,
|
|
};
|
|
},
|
|
};
|
|
|
|
describe("runCell", () => {
|
|
let binRoot: string;
|
|
let storeRoot: string;
|
|
|
|
beforeEach(() => {
|
|
binRoot = mkdtempSync(join(tmpdir(), "bench-runner-bin-"));
|
|
storeRoot = mkdtempSync(join(tmpdir(), "bench-runner-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(binRoot, { recursive: true, force: true });
|
|
rmSync(storeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
// Behavior: running one cell on the happy path provisions and seeds a fresh
|
|
// repository, runs the agent under its arm, captures the post-run snapshot and
|
|
// scores it (a PASS here, because the fake capture returns the task's own
|
|
// expected end state), appends exactly one sample carrying the driver's four
|
|
// token components / turns / imputed cost and a passing outcome, and deletes
|
|
// the repository afterward (benchmark-harness spec, runner tracer bullet). The
|
|
// gitea-mcp arm needs no host binaries and its MCP transcript audits clean.
|
|
it("provisions, seeds, runs, scores a pass, records one sample, and deletes the repo", async () => {
|
|
const { host, calls } = createFakeHost();
|
|
const store = createSampleStore(storeRoot);
|
|
const trial = 3;
|
|
|
|
const outcome = await runCell({
|
|
arm: "gitea-mcp",
|
|
task: SAMPLE_TASK,
|
|
trial,
|
|
access: ACCESS,
|
|
host,
|
|
driver,
|
|
store,
|
|
bounds: { turnCap: 10, wallClockMs: 60_000 },
|
|
build: { binRoot },
|
|
});
|
|
|
|
// The lifecycle ran end to end, deleting exactly the provisioned repo.
|
|
expect(calls.provisioned).toBe(true);
|
|
expect(calls.seeded).toBe(true);
|
|
expect(calls.captured).toBe(true);
|
|
expect(calls.deletedCoords).toEqual(COORDS);
|
|
|
|
// runCell resolves to a recorded outcome (not invalid).
|
|
expect(outcome.kind).toBe("recorded");
|
|
if (outcome.kind !== "recorded") return;
|
|
|
|
// Exactly one sample landed in this cell, carrying the driver's metrics
|
|
// unchanged, the task's coordinates, the trial passed in, and a passing outcome.
|
|
const samples = store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id });
|
|
expect(samples).toHaveLength(1);
|
|
const [sample] = samples;
|
|
expect(sample).toBeDefined();
|
|
if (sample === undefined) return;
|
|
|
|
expect(sample.arm).toBe("gitea-mcp");
|
|
expect(sample.taskId).toBe(SAMPLE_TASK.id);
|
|
expect(sample.tier).toBe(SAMPLE_TASK.tier);
|
|
expect(sample.trial).toBe(trial);
|
|
|
|
expect(sample.tokens).toEqual(DRIVER_TOKENS);
|
|
expect(sample.turns).toBe(DRIVER_TURNS);
|
|
expect(sample.imputedCostUsd).toBe(DRIVER_COST);
|
|
expect(sample.outcome).toEqual({ pass: true });
|
|
|
|
// The sample carries the run's wall-clock duration; a completed run takes
|
|
// non-negative time.
|
|
expect(typeof sample.durationMs).toBe("number");
|
|
expect(sample.durationMs).toBeGreaterThanOrEqual(0);
|
|
|
|
// The returned record is the same sample that was stored.
|
|
expect(outcome.record).toEqual(sample);
|
|
});
|
|
|
|
// Behavior: the run is bounded by a turn cap, and a run that hit it records a
|
|
// failure tagged confused (benchmark-harness spec, "confused-versus-hung"). The
|
|
// driver reports the cap was hit via stoppedByTurnCap: true, so the recorded
|
|
// sample's outcome must be { pass: false, failure: "confused" } — the failed
|
|
// outcome shape and the "confused" tag are independent literals fixed by
|
|
// result.ts's Outcome/FailureTag contract, not recomputed from runner.ts. Still
|
|
// exactly one sample lands and runCell resolves to a recorded outcome.
|
|
it("records a confused failure when the run hits the turn cap", async () => {
|
|
const { host } = createFakeHost();
|
|
const store = createSampleStore(storeRoot);
|
|
|
|
const outcome = await runCell({
|
|
arm: "gitea-mcp",
|
|
task: SAMPLE_TASK,
|
|
trial: 1,
|
|
access: ACCESS,
|
|
host,
|
|
driver: turnCappedDriver,
|
|
store,
|
|
bounds: { turnCap: 10, wallClockMs: 60_000 },
|
|
build: { binRoot },
|
|
});
|
|
|
|
expect(outcome.kind).toBe("recorded");
|
|
if (outcome.kind !== "recorded") return;
|
|
|
|
const samples = store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id });
|
|
expect(samples).toHaveLength(1);
|
|
const [sample] = samples;
|
|
expect(sample).toBeDefined();
|
|
if (sample === undefined) return;
|
|
expect(sample.outcome).toEqual({ pass: false, failure: "confused" });
|
|
});
|
|
|
|
// Behavior: the run is also bounded by a wall-clock backstop, and a run that
|
|
// exceeds it records a failure tagged hung (benchmark-harness spec,
|
|
// "confused-versus-hung"). An injected clock fires the backstop timer before the
|
|
// (never-self-resolving) driver finishes; the runner aborts the signal, which
|
|
// lets the driver settle. The recorded sample's outcome must be
|
|
// { pass: false, failure: "hung" } — a hung outcome is an independent literal
|
|
// fixed by result.ts's Outcome/FailureTag contract, not recomputed from
|
|
// runner.ts. Still exactly one sample lands and runCell resolves to recorded.
|
|
it("records a hung failure when the run exceeds the wall-clock backstop", async () => {
|
|
const { host } = createFakeHost();
|
|
const store = createSampleStore(storeRoot);
|
|
|
|
// Fire the backstop timer promptly (0ms) with a real clearable handle, so the
|
|
// wall-clock bound trips before the hanging driver would ever resolve.
|
|
const clock = {
|
|
setTimer: (_ms: number, fn: () => void) => {
|
|
const h = setTimeout(fn, 0);
|
|
return { clear: () => clearTimeout(h) };
|
|
},
|
|
};
|
|
|
|
const outcome = await runCell({
|
|
arm: "gitea-mcp",
|
|
task: SAMPLE_TASK,
|
|
trial: 1,
|
|
access: ACCESS,
|
|
host,
|
|
driver: hangingDriver,
|
|
store,
|
|
bounds: { turnCap: 10, wallClockMs: 50 },
|
|
build: { binRoot },
|
|
clock,
|
|
});
|
|
|
|
expect(outcome.kind).toBe("recorded");
|
|
if (outcome.kind !== "recorded") return;
|
|
|
|
const samples = store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id });
|
|
expect(samples).toHaveLength(1);
|
|
const [sample] = samples;
|
|
expect(sample).toBeDefined();
|
|
if (sample === undefined) return;
|
|
expect(sample.outcome).toEqual({ pass: false, failure: "hung" });
|
|
});
|
|
|
|
// Behavior: a transcript audit runs after each cell, and a run in which a
|
|
// foreign tool was reached is flagged invalid instead of scored (benchmark-
|
|
// harness spec, "Tool isolation"). Here the gitea-mcp arm (shell disabled) has a
|
|
// shell command in its transcript — a leak by the audit's contract (proven in
|
|
// bench/audit.test.ts). So runCell must yield { kind: "invalid", leaks } with a
|
|
// non-empty leaks array, append NO sample (the store stays empty), and still
|
|
// delete the provisioned repo. These come from the CellOutcome contract and the
|
|
// spec's "invalid instead of scored", not from runner.ts's internals.
|
|
it("flags the cell invalid without scoring when the transcript reaches a foreign tool, still deleting the repo", async () => {
|
|
const { host, calls } = createFakeHost();
|
|
const store = createSampleStore(storeRoot);
|
|
|
|
const outcome = await runCell({
|
|
arm: "gitea-mcp",
|
|
task: SAMPLE_TASK,
|
|
trial: 1,
|
|
access: ACCESS,
|
|
host,
|
|
driver: leakingDriver,
|
|
store,
|
|
bounds: { turnCap: 10, wallClockMs: 60_000 },
|
|
build: { binRoot },
|
|
});
|
|
|
|
// Invalid instead of scored: a non-empty leaks array, and no sample appended.
|
|
expect(outcome.kind).toBe("invalid");
|
|
if (outcome.kind !== "invalid") return;
|
|
expect(outcome.leaks.length).toBeGreaterThan(0);
|
|
|
|
expect(store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id })).toHaveLength(0);
|
|
|
|
// Cleanup still happens: the provisioned repo is deleted.
|
|
expect(calls.deletedCoords).toEqual(COORDS);
|
|
});
|
|
|
|
// Behavior: the recorded sample carries the checker's pass/fail outcome, and a
|
|
// run that finished cleanly (not turn-capped, no leak) but whose post-run
|
|
// snapshot does not satisfy the task's scoring spec is scored a failure tagged
|
|
// incorrect (benchmark-harness spec; result.ts's FailureTag). The unmutated host
|
|
// returns the seed state where the target issue is still OPEN, but the task's
|
|
// spec expects it CLOSED, so the checker's full-state diff fails. The expected
|
|
// outcome { pass: false, failure: "incorrect" } is an independent literal from
|
|
// result.ts's contract, not recomputed from runner.ts.
|
|
it("records an incorrect failure when a clean run's snapshot does not satisfy the scoring spec", async () => {
|
|
const { host } = createUnmutatedHost();
|
|
const store = createSampleStore(storeRoot);
|
|
|
|
const outcome = await runCell({
|
|
arm: "gitea-mcp",
|
|
task: SAMPLE_TASK,
|
|
trial: 1,
|
|
access: ACCESS,
|
|
host,
|
|
driver: cleanDriver,
|
|
store,
|
|
bounds: { turnCap: 10, wallClockMs: 60_000 },
|
|
build: { binRoot },
|
|
});
|
|
|
|
expect(outcome.kind).toBe("recorded");
|
|
if (outcome.kind !== "recorded") return;
|
|
|
|
const samples = store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id });
|
|
expect(samples).toHaveLength(1);
|
|
const [sample] = samples;
|
|
expect(sample).toBeDefined();
|
|
if (sample === undefined) return;
|
|
expect(sample.outcome).toEqual({ pass: false, failure: "incorrect" });
|
|
});
|
|
|
|
// Behavior: a completed read cell produces a record whose report is the agent's
|
|
// final report (benchmark-harness spec, record-assembly seam). A read task is
|
|
// scored by matching the agent's final report against required facts; the
|
|
// recorded ResultRecord must carry that final report in its `report` field so a
|
|
// read is diagnosable from the record alone. Here an inline read task's fact is
|
|
// satisfied by the driver's finalReport (it contains "5 open"), so the cell
|
|
// completes as a pass; the recorded sample's `report` must equal the exact
|
|
// planted finalReport literal — an independent literal, read back through the
|
|
// store, not recomputed from runner.ts.
|
|
it("records the agent's final report on a completed read cell", async () => {
|
|
const READ_TASK: BenchTask = {
|
|
id: "count-open-issues",
|
|
tier: "read",
|
|
intent: "How many issues are open?",
|
|
scoringSpec: () => ({
|
|
kind: "read",
|
|
facts: [{ description: "open-issue count", anyOf: ["5 open"] }],
|
|
}),
|
|
};
|
|
|
|
// The independent literal we plant as the driver's final report. It contains
|
|
// the fact's rendering ("5 open"), so the read scorer scores it a pass.
|
|
const FINAL_REPORT = "There are 5 open issues.";
|
|
|
|
const readDriver: AgentDriver = {
|
|
async run() {
|
|
return {
|
|
tokens: { freshInput: 50, cacheCreation: 0, cacheRead: 0, output: 10 },
|
|
turns: 2,
|
|
imputedCostUsd: 0.03,
|
|
transcript: [{ kind: "mcp", server: "gitea-mcp", tool: "list_repo_issues" }],
|
|
finalReport: FINAL_REPORT,
|
|
stoppedByTurnCap: false,
|
|
};
|
|
},
|
|
};
|
|
|
|
const { host } = createFakeHost();
|
|
const store = createSampleStore(storeRoot);
|
|
|
|
const outcome = await runCell({
|
|
arm: "gitea-mcp",
|
|
task: READ_TASK,
|
|
trial: 1,
|
|
access: ACCESS,
|
|
host,
|
|
driver: readDriver,
|
|
store,
|
|
bounds: { turnCap: 10, wallClockMs: 60_000 },
|
|
build: { binRoot },
|
|
});
|
|
|
|
expect(outcome.kind).toBe("recorded");
|
|
if (outcome.kind !== "recorded") return;
|
|
|
|
const samples = store.read({ arm: "gitea-mcp", taskId: READ_TASK.id });
|
|
expect(samples).toHaveLength(1);
|
|
const [sample] = samples;
|
|
expect(sample).toBeDefined();
|
|
if (sample === undefined) return;
|
|
|
|
// A completed read cell: the checker scored the final report a pass.
|
|
expect(sample.outcome).toEqual({ pass: true });
|
|
// The record carries the agent's final report verbatim.
|
|
expect(sample.report).toBe(FINAL_REPORT);
|
|
});
|
|
|
|
// Behavior: a completed mutation cell produces a record WITHOUT an agent report
|
|
// (benchmark-harness spec, record-assembly seam). A mutation task is scored by
|
|
// diffing repository state, not by reading the agent's words, so the recorded
|
|
// ResultRecord must carry no `report` field at all — absent, not present-but-
|
|
// undefined. SAMPLE_TASK is a mutation task; paired with the passing fake host
|
|
// and driver it drives a completed mutation PASS. The absence is the independent
|
|
// source of truth (the acceptance criterion), read back through the store after
|
|
// the JSON round-trip so an absent field is genuinely not a key.
|
|
it("records no agent report on a completed mutation cell", async () => {
|
|
const { host } = createFakeHost();
|
|
const store = createSampleStore(storeRoot);
|
|
|
|
const outcome = await runCell({
|
|
arm: "gitea-mcp",
|
|
task: SAMPLE_TASK,
|
|
trial: 1,
|
|
access: ACCESS,
|
|
host,
|
|
driver,
|
|
store,
|
|
bounds: { turnCap: 10, wallClockMs: 60_000 },
|
|
build: { binRoot },
|
|
});
|
|
|
|
expect(outcome.kind).toBe("recorded");
|
|
if (outcome.kind !== "recorded") return;
|
|
|
|
const samples = store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id });
|
|
expect(samples).toHaveLength(1);
|
|
const [sample] = samples;
|
|
expect(sample).toBeDefined();
|
|
if (sample === undefined) return;
|
|
|
|
// A completed mutation cell: the state diff scored a pass.
|
|
expect(sample.outcome).toEqual({ pass: true });
|
|
// A mutation record carries no agent report — the key is absent entirely.
|
|
expect(sample).not.toHaveProperty("report");
|
|
});
|
|
});
|