mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
983625fc5d
* 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.
125 lines
4.7 KiB
TypeScript
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'));
|
|
});
|