Files
callstack__agent-device/scripts/__tests__/help-conformance-bench.test.ts
Michał Pierzchała 2d1d70613f feat(bench): renderer-pinned samples, topic-coverage gate, error-recovery quizzes; trim skillgym to agentic checks (#1411)
* feat(bench): renderer-pinned samples, topic-coverage gate, error quizzes; trim skillgym to agentic checks

The help conformance bench's quoted CLI output is now sourced from
scripts/help-conformance-sample-outputs.mjs, and every sample is rebuilt
through the real production renderers (settle output formatters,
printHumanError, formatSnapshotText, refMutationAdmissionResponse) by
scripts/__tests__/help-conformance-sample-outputs.test.ts — a rendering or
message change fails deterministically instead of leaving the bench grading
against output the CLI no longer prints. This retires the fabricated
recoverable-failure envelope (production never throws a textual settle
timeout; that case is replaced by a real DEVICE_IN_USE recovery quiz).

Bench cases move to scripts/help-conformance-cases.mjs and are enumerated
against the help-topic registry: helpTopicIds() is exported from cli-help,
and scripts/__tests__/help-conformance-topic-coverage.test.ts fails when a
help topic has neither a bench case nor an explicit waiver. New case
families: error-envelope recovery quizzes (device-in-use, stale pinned ref,
ambiguous find match, app-not-installed) pinned to real error text, topic
coverage for tv/web/react-native/debugging/workflow, and a metamorphic twin
of the settled-diff quiz.

The skillgym smoke suite shrinks from 119 cases to the 5 that measure what
only an agentic runner can show: skill routing plus output interpretation
with a proven local CLI help probe (local-cli-help-policy). Its embedded
samples now import the same pinned constants, replacing hand-transcribed
output that had already drifted from the renderer. Knowledge checks belong
to the bench; live fixture behavior belongs to the iOS simulator e2e suite.

* review: drive error samples through the real producers; enforce local-help on the routing smoke

The DEVICE_IN_USE, AMBIGUOUS_MATCH, and APP_NOT_INSTALLED parity tests no
longer hand-author the producer message before rendering: each drives the
actual producer — buildDeviceInUseBySessionError (extracted in
session-open.ts and called by the handler), buildAmbiguousMatchError (now
exported from find.ts), and buildAppNotInstalledError (extracted in
app-resolution.ts and thrown by the resolver). Because each factory is
exported from its producer file and called by the production path, dropping
the production call would make it test-only and fail
check:production-exports — the wiring is gate-enforced, not conventional.

open-and-snapshot now sets requireLocalCliHelp and
allowOnlyLocalCliHelpCommands, so the 'skill plus local help' claim is
observed rather than assumed; without them the case can pass on model prior
alone.
2026-07-27 14:24:36 +02:00

524 lines
18 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';
import { scoreExpectations } from '../help-conformance-case-checks.mjs';
import { validateAgentDeviceCommand } from '../help-conformance-command-validator.ts';
import { opensAndCloses, usesValidationPrep } from '../help-conformance-expectations.mjs';
import { validatePlanCommands } from '../help-conformance-plan-validator.mjs';
import { detectRunnerError, extractCommands } from '../help-conformance-runner-output.mjs';
import { summarizeResults } from '../help-conformance-summary.mjs';
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 readDryRunReport(
outDir: string,
): Promise<Array<{ prompt: string; runner: string; caseId: string; trial: number }>> {
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'));
assert.ok(report.length > 0 && typeof report[0]?.prompt === 'string');
return report;
}
async function readDryRunPrompts(outDir: string): Promise<string[]> {
return (await readDryRunReport(outDir)).map(({ prompt }) => prompt);
}
async function readDryRunPrompt(outDir: string): Promise<string> {
return (await readDryRunPrompts(outDir))[0]!;
}
// 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'));
});
test('--repeat expands each runner x case pair into numbered independent trials', async () => {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const draftPath = join(dir, 'first-screen.txt');
await writeFile(draftPath, 'minimal first-screen help');
const run = await runBench([
'--dry-run',
'--case',
'metamorphic-community-search',
'--runner',
'claude:test-model',
'--repeat',
'3',
'--override-doc',
`--help:first30=${draftPath}`,
'--out',
dir,
]);
assert.equal(run.code, 0, run.stderr);
const report = await readDryRunReport(dir);
assert.deepEqual(
report.map(({ runner, caseId, trial }) => ({ runner, caseId, trial })),
[1, 2, 3].map((trial) => ({
runner: 'claude:test-model',
caseId: 'metamorphic-community-search',
trial,
})),
);
});
test('invalid repeat counts and mixed known/unknown case ids fail before a report is written', async () => {
for (const args of [
['--repeat', '0', '--case', 'raw-first-screen-bluesky'],
['--repeat', 'many', '--case', 'raw-first-screen-bluesky'],
['--cases', 'raw-first-screen-bluesky,typo-case'],
]) {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const run = await runBench(['--dry-run', ...args, '--out', dir]);
assert.notEqual(run.code, 0);
assert.equal(
(await readdir(dir)).some((name) => name.startsWith('report-')),
false,
);
}
});
test('metamorphic case changes domain nouns without leaking the original benchmark answer', async () => {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const draftPath = join(dir, 'first-screen.txt');
await writeFile(draftPath, 'minimal first-screen help');
const run = await runBench([
'--dry-run',
'--case',
'metamorphic-community-search',
'--runner',
'claude:test-model',
'--override-doc',
`--help:first30=${draftPath}`,
'--out',
dir,
]);
assert.equal(run.code, 0, run.stderr);
const prompt = await readDryRunPrompt(dir);
assert.match(prompt, /com\.example\.community/);
assert.match(prompt, /@react\.dev/);
assert.doesNotMatch(prompt, /bluesky|callstack|@e64/i);
});
test('runtime-output cases do not coach the command that their output should imply', async () => {
const dir = await mkdtemp(join(tmpdir(), 'help-bench-'));
const draftPath = join(dir, 'first-screen.txt');
await writeFile(draftPath, 'minimal first-screen help');
const run = await runBench([
'--dry-run',
'--cases',
[
'settle-diff-is-observation',
'sample-output-settled-diff-next-target',
'metamorphic-settled-diff-next-target-notes',
'sample-output-not-settled-needs-observe',
'sample-output-device-in-use-reuses-session',
'sample-output-stale-ref-resnapshots',
'sample-output-ambiguous-match-reobserves',
'sample-output-app-not-installed-discovers-first',
].join(','),
'--runner',
'claude:test-model',
'--override-doc',
`--help:first30=${draftPath}`,
'--out',
dir,
]);
assert.equal(run.code, 0, run.stderr);
const prompts = (await readDryRunPrompts(dir)).join('\n');
assert.doesNotMatch(prompts, /Do not take another snapshot/i);
assert.doesNotMatch(prompts, /Use the ref exposed by the settled diff/i);
assert.doesNotMatch(prompts, /Follow the output hint/i);
assert.doesNotMatch(prompts, /Retry the same target/i);
});
test('command validator accepts production flags and interaction grammar', () => {
assert.deepEqual(validateAgentDeviceCommand(['--version']), { valid: true });
assert.deepEqual(validateAgentDeviceCommand(['fill', 'label=Search', 'callstack', '--settle']), {
valid: true,
});
assert.deepEqual(
validateAgentDeviceCommand(['fill', 'label=Search', '@callstack.com', '--settle']),
{ valid: true },
);
assert.deepEqual(validateAgentDeviceCommand(['is', 'visible', 'label=Following']), {
valid: true,
});
});
test('command validator rejects unsupported flags and malformed interaction grammar', () => {
for (const argv of [
['close', '--settle'],
['wait', '--selector', 'role=button'],
['press', 'key=Enter', '--settle'],
['is', 'role=button', 'label=Following'],
]) {
const result = validateAgentDeviceCommand(argv);
assert.equal(result.valid, false, `${argv.join(' ')} should fail`);
assert.ok(result.error);
}
});
test('command validator rejects pseudo refs and excess production-schema positionals', () => {
for (const argv of [
['press', '@<search-ref>', '--settle'],
['fill', '@search_field', 'callstack', '--settle'],
['get', 'text', '@search-field'],
['focus', '@search-field', '10'],
['scroll', '@down'],
['snapshot', '-i', './evidence.json'],
['open', 'com.example.community', 'https://example.com', 'close'],
['close', 'first-app', 'second-app'],
]) {
const result = validateAgentDeviceCommand(argv);
assert.equal(result.valid, false, `${argv.join(' ')} should fail`);
assert.ok(result.error);
}
});
test('open and close scoring requires separate top-level commands', () => {
assert.equal(
opensAndCloses({
commands: ['agent-device open com.example.community close'],
}),
false,
);
assert.equal(
opensAndCloses({
commands: ['agent-device open com.example.community', 'agent-device close'],
}),
true,
);
});
test('validation prep accepts intervening checks and the Android build path in order', () => {
assert.equal(
usesValidationPrep({
commands: ['agent-device doctor', 'pnpm build', 'pnpm clean:daemon'],
}),
true,
);
assert.equal(
usesValidationPrep({
commands: ['pnpm run build:android', 'agent-device doctor', 'pnpm clean:daemon'],
}),
true,
);
assert.equal(
usesValidationPrep({
commands: ['pnpm clean:daemon', 'pnpm build'],
}),
false,
);
});
test('runner output distinguishes model commands from infrastructure errors', () => {
const successEnvelope = JSON.stringify({
is_error: false,
result: JSON.stringify({ commands: ['agent-device snapshot -i'] }),
});
assert.deepEqual(extractCommands(successEnvelope), ['agent-device snapshot -i']);
assert.equal(detectRunnerError(successEnvelope), undefined);
const claudeError = JSON.stringify({
is_error: true,
result: 'API Error: Unable to connect to API',
});
assert.equal(detectRunnerError(claudeError), 'API Error: Unable to connect to API');
assert.deepEqual(extractCommands(claudeError), []);
assert.equal(
detectRunnerError(JSON.stringify({ type: 'error', message: 'rate limit exceeded' })),
'rate limit exceeded',
);
assert.equal(
detectRunnerError(
JSON.stringify({
status: 'failed',
commands: ['agent-device snapshot -i'],
}),
),
undefined,
);
assert.equal(detectRunnerError(''), 'Runner returned empty output.');
});
test('plan validator rejects shell projection and non-permitted executables', async () => {
const [redirected, piped, newline, carriageReturn, escapedNewline, shellCommand] =
await validatePlanCommands([
'agent-device snapshot -i > evidence.json',
'agent-device snapshot -i | tee evidence.txt',
'agent-device press @e1 --settle\ncat',
'agent-device press @e1 --settle\rcat',
'agent-device press @e1 --settle\\\ncat',
'echo done',
]);
for (const result of [redirected, piped, newline, carriageReturn, escapedNewline]) {
assert.equal(result.issues[0]?.kind, 'shell-projection');
}
assert.equal(shellCommand.issues[0]?.kind, 'executable-policy');
const [quotedNewline] = await validatePlanCommands([
'agent-device fill label=Biography "line one\nline two" --settle',
]);
assert.equal(quotedNewline.issues.length, 0);
assert.deepEqual(quotedNewline.tokens, [
'agent-device',
'fill',
'label=Biography',
'line one\nline two',
'--settle',
]);
const [placeholder] = await validatePlanCommands(['agent-device press @<search-ref> --settle']);
assert.ok(placeholder.issues.some(({ kind }) => kind === 'pseudo-ref'));
assert.ok(placeholder.issues.some(({ kind }) => kind === 'shell-projection'));
});
test('case matchers score parsed tokens so shell quoting does not change results', async () => {
const commands = [
'agent-device open "com.example.shop"',
`agent-device fill 'label=Search' "react native" --settle`,
'agent-device press label="@react.dev" --settle',
`agent-device open 'settings'`,
];
const commandValidation = await validatePlanCommands(commands);
const checks = scoreExpectations(
{
matchers: [
{
id: 'opensKnownDogfoodApp',
pattern: /\bagent-device\s+open\s+com\.example\.shop\b/i,
},
{
id: 'fillsExpectedSearch',
pattern:
/\bagent-device\s+fill\b[^\n]*(?:"react native"|'react native')[^\n]*--settle\b/i,
},
{
id: 'usesLiteralHandleSelector',
pattern:
/\bagent-device\s+(?:press|click|tap)\b[^\n]*(?:label|text)=@react\.dev\b[^\n]*--settle\b/i,
},
{
id: 'opensSettings',
pattern: /\bagent-device\s+open\s+(?:settings|com\.apple\.Preferences)\b/i,
},
],
},
commands,
'',
commandValidation,
);
assert.deepEqual(checks, {
opensKnownDogfoodApp: true,
fillsExpectedSearch: true,
usesLiteralHandleSelector: true,
opensSettings: true,
});
});
test('plan validator applies narrow grammar to permitted external commands', async () => {
const results = await validatePlanCommands(
[
'mkdir -p dogfood-output',
'mkdir dogfood-output',
'pnpm build',
'pnpm run build:android',
'pnpm clean:daemon --prune-dev',
'pnpm test',
],
{ allowedExternalCommands: ['mkdir', 'pnpm'] },
);
assert.equal(results[0].issues.length, 0);
assert.equal(results[1].issues[0]?.kind, 'external-command-grammar');
assert.equal(results[2].issues.length, 0);
assert.equal(results[3].issues.length, 0);
assert.equal(results[4].issues.length, 0);
assert.equal(results[5].issues[0]?.kind, 'external-command-grammar');
});
test('plan validator strips shell comments without mutating command arguments', async () => {
const [snapshot, press] = await validatePlanCommands([
'agent-device snapshot -i # capture evidence',
'agent-device press @e64 --settle # tap result',
]);
assert.equal(snapshot.issues.length, 0);
assert.deepEqual(snapshot.tokens, ['agent-device', 'snapshot', '-i']);
assert.equal(press.issues.length, 0);
assert.deepEqual(press.tokens, ['agent-device', 'press', '@e64', '--settle']);
});
test('plan validator keeps agent result alignment across skipped and external lines', async () => {
const results = await validatePlanCommands(
[
'agent-device snapshot -i > evidence.json',
'mkdir -p evidence',
'agent-device snapshot -i',
'pnpm build',
'agent-device get text @search-field',
],
{ allowedExternalCommands: ['mkdir', 'pnpm'] },
);
assert.equal(results[0].issues[0]?.kind, 'shell-projection');
assert.equal(results[1].issues.length, 0);
assert.equal(results[2].issues.length, 0);
assert.equal(results[3].issues.length, 0);
assert.equal(results[4].issues[0]?.kind, 'pseudo-ref');
});
test('aggregate summary exposes stability and failure taxonomy per runner x case', () => {
const summary = summarizeResults([
{
runner: 'claude:haiku',
caseId: 'metamorphic',
passed: true,
checks: { validPlanCommands: true, usesSettle: true },
commandValidation: [],
},
{
runner: 'claude:haiku',
caseId: 'metamorphic',
passed: false,
checks: { validPlanCommands: false, usesSettle: true },
commandValidation: [
{
issues: [
{ kind: 'pseudo-ref', error: 'bad ref' },
{ kind: 'shell-projection', error: 'bad shell' },
],
},
],
runnerError: 'failed',
},
{
runner: 'claude:haiku',
caseId: 'metamorphic',
passed: false,
checks: { validPlanCommands: false, usesSettle: true },
commandValidation: [
{
issues: [{ kind: 'pseudo-ref', error: 'bad ref' }],
},
],
},
]);
assert.deepEqual(summary, [
{
runner: 'claude:haiku',
caseId: 'metamorphic',
trials: 3,
evaluatedTrials: 2,
passed: 1,
failedChecks: { validPlanCommands: 1 },
validationIssues: { 'pseudo-ref': 1 },
runnerErrors: 1,
passRate: 0.5,
},
]);
});