Files
callstack__agent-device/scripts/help-conformance-case-checks.mjs
Michał Pierzchała a158434a9c feat(cli): compact workflow help card + version header (#1663)
* feat(cli): compact workflow help card + version header

Shrinks the per-task agent protocol tax of the help/skill surface.
`agent-device help workflow` drops from 41025 to 8466 bytes (-79%) by
moving depth into new `help scripting` (save-script, secret-safe
fills, batch JSON, replay divergence/repair) and `help gestures`
(multi-touch shapes/quirks) topics, and folding a few paragraphs into
topics that already owned the subject (help debugging,
help physical-device, help validate). Content is moved, not deleted.

Every `help <topic>` first line is now `agent-device <version> —
<topic>`, so the skill router reads the CLI version off the mandatory
first help read instead of a separate `agent-device --version` call.
SKILL.md is updated to do that and stays a thin router otherwise.

The compact card also gains two terse behavioral rules: chain
confident consecutive steps with `&&` (falling back to one command at
a time when uncertain), and confirm the requested end state is
actually visible on screen before declaring a task done.

help-conformance-bench (22 cases x 2 runners) improves after the
change: 29/44 -> 32/44 passing checks.

* fix(cli): review follow-ups on the compact workflow card (#1663)

Three fixes from PR review:

- Extend the help-conformance plan validator to split a command line
  on unquoted && and validate each chained segment independently, so
  a plan that follows the workflow card's "chain confident consecutive
  steps with &&" guidance is accepted instead of rejected as one
  shell-projection violation. && inside a quoted selector value (e.g.
  label="A && B") is not a chain boundary and does not split. Adds
  unit tests for the splitter and a chains-confident-consecutive-
  settle-steps conformance case. batch stays out of this: it is
  deliberately stop-only.

- Replace the literal @ref placeholder the compact card used in its
  own "snapshot -s @ref" example with a concrete ref
  (snapshot -s @e12 (the current concrete ref)), matching the same
  card's rule against placeholder targets. Reverts the test to demand
  the concrete shape.

- Give help scripting and help gestures real conformance cases
  instead of waivers: a secret-safe recorded-fill + publish case, and
  an Android transform-then-verify case whose exact verification text
  only appears in the gestures topic. Removes both waivers.

help-conformance-bench (25 cases x 2 runners, repeat=1) after these
fixes: two full runs landed at 32/50 and 33/50. That is on par with
the pre-change baseline (29/44) once the topic-untouched cases'
run-to-run swings are accounted for (confirmed noise: one case with
zero exposure to any change here flipped 10/10 -> 1/10 on a runner API
error, and another swung across all three post-fix runs). The new
scripting case now passes 8/8 for both runners; the new chaining case
correctly reports the model's choice not to chain as a soft signal,
not a validator failure.

* fix(cli): update session.test.ts help pointer for moved script-authoring content

* fix(cli): reject empty && chain operands in the plan validator (#1663)

splitOnUnquotedAnd() previously trimmed and filtered out empty
segments, so a plan with a leading (`&& press ...`), trailing
(`press ... &&`), or doubled (`a && && b`) operator passed
validPlanCommands even though a real shell rejects all three as a
syntax error. The validator would bless a plan that fails at
execution.

Empty segments are now surfaced as an `empty-chain-operand` issue
instead of being silently dropped. The quoted-&& non-split behavior
(label="A && B") is unchanged, and a normal single command with no
chain still parses identically to before.

Adds regression tests for all three empty-operand shapes plus the
quoted-&& case.
2026-08-07 13:26:48 +02:00

114 lines
3.9 KiB
JavaScript

import { opensAndCloses, usesValidationPrep } from './help-conformance-expectations.mjs';
const EXPECTATION_SCORERS = {
validPlanCommands: ({ commands, commandValidation }) =>
commands.length > 0 && commandValidation.every(({ issues }) => issues.length === 0),
fullPrefix: ({ commands }) =>
commands.length > 0 &&
commands.every(
(command) => !/^(open|snapshot|press|fill|click|longpress|wait|close)\b/.test(command),
),
usesSnapshotI: ({ commands }) => commands.some((command) => /\bsnapshot\b.*\s-i\b/.test(command)),
usesSettleOnMutations: ({ commands }) => allMutationsUseSettle(commands),
// help workflow teaches chaining confident consecutive steps with an
// unquoted &&. This reads the raw (pre-split) command lines, not `joined`
// (canonicalPlan flattens a chain into separate lines once the plan
// validator splits and validates each segment), so it is the only place
// that can tell whether the model actually chained.
usesConfidentChaining: ({ commands }) => commands.some((command) => /&&/.test(command)),
noWaitStable: ({ joined }) => !joined.includes('wait stable'),
verifiesNamedExpectation: ({ joined }) => /\b(wait|is|get|find)\b/.test(joined),
usesDogfoodEvidence: ({ joined }) =>
/(?:\bscreenshot\b|\brecord\b|\blogs\b|\bnetwork\b|\bperf\b|\btrace\b|dogfood-output)/i.test(
joined,
),
usesValidationPrep,
opensAndCloses,
};
export function assertCaseDefinitions(cases) {
assertUniqueIds(
cases.map(({ id }) => id),
'benchmark case',
);
for (const testCase of cases) assertCaseDefinition(testCase);
}
export function countChecks(testCase) {
return resolveChecks(testCase).length;
}
export function scoreExpectations(testCase, commands, raw, commandValidation) {
const context = {
commands,
joined: canonicalPlan(commandValidation).toLowerCase(),
raw,
commandValidation,
};
return Object.fromEntries(resolveChecks(testCase).map(({ id, test }) => [id, test(context)]));
}
function resolveChecks(testCase) {
const named = (testCase.expectations ?? []).map((id) => ({
id,
test: (context) => scoreExpectation(id, context),
}));
const matched = (testCase.matchers ?? []).map(({ id, pattern }) => ({
id,
test: (context) => pattern.test(context.joined),
}));
const forbidden = (testCase.forbidden ?? []).map(({ id, pattern }) => ({
id,
test: (context) => !pattern.test(context.joined),
}));
return [...named, ...matched, ...forbidden];
}
function canonicalPlan(commandValidation) {
return commandValidation.map(({ tokens }) => tokens.map(canonicalToken).join(' ')).join('\n');
}
function canonicalToken(token) {
return /\s/.test(token) ? JSON.stringify(token) : token;
}
function assertCaseDefinition(testCase) {
const unknownExpectation = (testCase.expectations ?? []).find(
(expectation) => !(expectation in EXPECTATION_SCORERS),
);
if (unknownExpectation) {
throw new Error(`Unknown expectation "${unknownExpectation}" in case "${testCase.id}".`);
}
assertUniqueIds(
resolveChecks(testCase).map(({ id }) => id),
`check in case "${testCase.id}"`,
);
}
function assertUniqueIds(ids, label) {
const seen = new Set();
const duplicates = new Set();
for (const id of ids) {
if (seen.has(id)) duplicates.add(id);
seen.add(id);
}
if (duplicates.size > 0) {
throw new Error(`Duplicate ${label} id(s): ${[...duplicates].join(', ')}`);
}
}
function scoreExpectation(expectation, context) {
const scorer = EXPECTATION_SCORERS[expectation];
if (!scorer) throw new Error(`Unknown expectation: ${expectation}`);
return scorer(context);
}
function allMutationsUseSettle(commands) {
const mutating = commands.filter(isMutationCommand);
return mutating.length > 0 && mutating.every((command) => command.includes('--settle'));
}
function isMutationCommand(command) {
return /^agent-device\s+(?:press|click|fill|longpress)\b/.test(command);
}