Files
callstack__agent-device/scripts/__tests__/help-conformance-bench.test.ts
Michał Pierzchała 983625fc5d feat: fix codex runner, add --override-doc grading, port skillgym quiz cases to help-conformance bench (#1176)
* feat: fix codex runner, add override-doc grading, port skillgym quiz cases

The 2026-07-09 evaluation of scripts/help-conformance-bench.mjs found it
structurally right but broken for the codex runner (two bugs), thin on
coverage (4 cases), sequential, and unable to grade a draft help rewrite
without a rebuild.

- Fix runCodex: (1) codex exec reads stdin until EOF when not attached to
  a TTY, and execFile never closes the child's stdin, so every codex call
  hung until RUN_TIMEOUT_MS with empty output — close stdin right after
  spawn. (2) `-o outFile` writes the same final JSON that codex also
  prints to stdout, so concatenating both produced two back-to-back JSON
  objects that broke every JSON.parse candidate and silently zeroed
  extractCommands() — prefer the clean -o payload, fall back to stdout
  only when it's empty.
- Add `--override-doc <topicId>=<path>` (repeatable): loads a topic's
  text from a file instead of shelling out to
  `node bin/agent-device.mjs help <topic>`, so a draft help rewrite can
  be A/B graded with zero rebuild.
- Port three cases from
  test/skillgym/suites/agent-device-smoke-suite.ts
  (settle-diff-is-observation, sample-output-settled-diff-next-target,
  sample-output-not-settled-needs-observe) as self-contained
  "next-command quiz" cases, generalizing the scorer to support regex
  matchers/forbidden patterns alongside the existing named expectations.
  Fixture output text matches the CURRENT settle rendering in
  src/commands/interaction/output.ts, including the "unchanged
  interactive (N):" tail added by #1167/#1172.
- Parallelize the runner x case matrix with a concurrency cap
  (HELP_BENCH_CONCURRENCY, default 4); results still print in the
  original matrix order.
- Extend test/skillgym/README.md's existing pointer to this bench with
  the new flags.

Validated with real LLM calls (both runners, all 7 cases, 14 calls,
~$0.25 total): 13/14 pass; the one fail (claude-haiku-4-5 on
dogfood-mode) is a genuine model miss (returned an empty command plan
asking for the app name instead of committing to a generic plan), not a
bench bug. `--override-doc` demonstrated live: stripping the dogfood
doc's evidence-command examples regresses codex:gpt-5.4-mini from 3/3 to
2/3 on the same case, showing the flag both loads and changes grading.

* fix: apply live-doc post-processing to --override-doc, fail fast on bad overrides

Review findings on the initial version (all reproduced):

- HIGH: an override for the --help:first30 doc id skipped the live path's
  firstLines(text, 30) cap, so a 49-line draft leaked lines 31-49 into the
  prompt — grading content a live run never shows, on the doc id every case
  uses. loadDoc now splits source (live shell-out vs override file) from
  post-processing, and the post-processing applies to both, so an override
  differs ONLY in where the text comes from.
- MEDIUM: an --override-doc topic id no selected case uses was silently
  ignored (exit 0, real doc graded). Now fails fast listing the valid doc
  ids for the selection.
- LOW: a missing override file threw a raw ENOENT stack trace; expected
  failures now print one clean Error line. Added --help usage text that
  documents last-wins semantics for repeated same-topic overrides and the
  post-processing parity.

Guard tests (scripts/__tests__/help-conformance-bench.test.ts, wired into
the unit-core vitest project by explicit path): a 49-line fixture whose
prompt must keep line 30 and drop line 31, unknown-topic fail-fast with
valid ids listed, clean no-stack error for a missing file, and last-wins
for repeated overrides. All spawn the script in --dry-run with every
required doc overridden, so they need no LLM calls and no built CLI.

Live re-validation: a 33-line override of --help:first30 whose lines
31-33 instruct the model to emit a sentinel command; neither
claude-haiku-4-5 nor codex:gpt-5.4-mini emitted it (both scored 4/4,
matching the live-doc baseline), proving the cap applies end-to-end.
2026-07-10 09:06:58 +02:00

125 lines
4.7 KiB
TypeScript

import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { test } from 'vitest';
const execFileAsync = promisify(execFile);
const SCRIPT = join(import.meta.dirname, '..', 'help-conformance-bench.mjs');
// These tests spawn the real script in --dry-run mode with every required doc
// overridden, so no LLM call and no built CLI is needed: the raw-first-screen
// case's only doc is --help:first30, and the override replaces the shell-out.
async function runBench(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
try {
const { stdout, stderr } = await execFileAsync('node', [SCRIPT, ...args]);
return { code: 0, stdout, stderr };
} catch (error) {
const payload = error as { code?: number; stdout?: string; stderr?: string };
return { code: payload.code ?? 1, stdout: payload.stdout ?? '', stderr: payload.stderr ?? '' };
}
}
async function readDryRunPrompt(outDir: string): Promise<string> {
const reportName = (await readdir(outDir)).find((name) => name.startsWith('report-'));
assert.ok(reportName, 'dry-run must write a report file');
const report = JSON.parse(await readFile(join(outDir, reportName), 'utf8')) as Array<{
prompt: string;
}>;
assert.ok(report.length > 0 && typeof report[0]?.prompt === 'string');
return report[0].prompt;
}
// Guards the --override-doc contract: an override swaps only WHERE the doc
// text comes from, never how it is sliced. The --help:first30 doc id caps the
// live `--help` output at 30 lines, so an override longer than that must be
// capped identically or the A/B comparison grades content a live run never
// shows (the exact bug found in review of the initial version).
test('override for --help:first30 goes through the same 30-line cap as the live doc', async () => {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const draftPath = join(dir, 'draft-help.txt');
const lines = Array.from({ length: 49 }, (_, i) => `draft help line ${i + 1}`);
await writeFile(draftPath, lines.join('\n'));
const run = await runBench([
'--dry-run',
'--case',
'raw-first-screen-bluesky',
'--runner',
'claude:test-model',
'--override-doc',
`--help:first30=${draftPath}`,
'--out',
dir,
]);
assert.equal(run.code, 0, run.stderr);
const prompt = await readDryRunPrompt(dir);
assert.ok(prompt.includes('draft help line 30'), 'line 30 is inside the cap and must survive');
assert.ok(!prompt.includes('draft help line 31'), 'line 31 is past the cap and must be cut');
});
test('an override topic id no selected case uses fails fast and lists valid doc ids', async () => {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const draftPath = join(dir, 'draft.txt');
await writeFile(draftPath, 'irrelevant');
const run = await runBench([
'--dry-run',
'--case',
'raw-first-screen-bluesky',
'--override-doc',
`totally-bogus-topic=${draftPath}`,
'--out',
dir,
]);
assert.notEqual(run.code, 0);
assert.match(run.stderr, /totally-bogus-topic/);
assert.match(run.stderr, /Valid doc ids: --help:first30/);
assert.equal(
(await readdir(dir)).some((name) => name.startsWith('report-')),
false,
);
});
test('a missing override file reports one clean error line, not a stack trace', async () => {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const run = await runBench([
'--dry-run',
'--case',
'raw-first-screen-bluesky',
'--override-doc',
`--help:first30=${join(dir, 'does-not-exist.txt')}`,
'--out',
dir,
]);
assert.notEqual(run.code, 0);
assert.match(run.stderr, /--override-doc file for "--help:first30" is not readable/);
assert.doesNotMatch(run.stderr, /at .*help-conformance-bench\.mjs/);
});
test('repeated overrides for the same topic id are last-wins', async () => {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const firstPath = join(dir, 'first.txt');
const secondPath = join(dir, 'second.txt');
await writeFile(firstPath, 'first draft body');
await writeFile(secondPath, 'second draft body');
const run = await runBench([
'--dry-run',
'--case',
'raw-first-screen-bluesky',
'--runner',
'claude:test-model',
'--override-doc',
`--help:first30=${firstPath}`,
'--override-doc',
`--help:first30=${secondPath}`,
'--out',
dir,
]);
assert.equal(run.code, 0, run.stderr);
const prompt = await readDryRunPrompt(dir);
assert.ok(prompt.includes('second draft body'));
assert.ok(!prompt.includes('first draft body'));
});