Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/contracts/src/batch-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
BATCH_STEP_SHAPE_HINT,
readBatchStepInputObject,
readBatchStepRecord,
} from './batch-contract.ts';

function hintOf(read: () => unknown): string | undefined {
try {
read();
} catch (error) {
return error instanceof AppError ? error.details?.hint : undefined;
}
throw new Error('Expected the read to refuse');
}

// This module validates batch steps for the Node client and the MCP tools as well as the CLI, so
// a terminal recovery step here would be unrunnable advice on two of the three surfaces (#2062).
test('the shared step-shape hint names the shape without naming a surface', () => {
expect(BATCH_STEP_SHAPE_HINT).toContain('{"command":"<name>","input":{...}}');
expect(BATCH_STEP_SHAPE_HINT).not.toMatch(/agent-device |--\w/);
});

test('shape refusals carry the shared hint, or the caller-supplied one', () => {
expect(hintOf(() => readBatchStepRecord('press @e12', 1))).toBe(BATCH_STEP_SHAPE_HINT);
expect(hintOf(() => readBatchStepInputObject({ command: 'press' }, 1))).toBe(
BATCH_STEP_SHAPE_HINT,
);
expect(hintOf(() => readBatchStepRecord('press @e12', 1, 'surface hint'))).toBe('surface hint');
expect(hintOf(() => readBatchStepInputObject({ command: 'press' }, 1, 'surface hint'))).toBe(
'surface hint',
);
});
28 changes: 25 additions & 3 deletions packages/contracts/src/batch-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,42 @@ export function assertBatchStepCount(stepCount: number, maxSteps: number): void
}
}

export function readBatchStepRecord(step: unknown, stepNumber: number): Record<string, unknown> {
/**
* The one sentence every batch-step shape refusal owes the caller. `batch` accepts a single step
* shape, and none of its refusals named it: a string step answered "Invalid batch step 1." and an
* `args`/`target`/`argv` step answered "unknown field(s)", neither of which says what a step
* looks like (#2062).
*
* It describes the shape only. This module validates for the Node client and the MCP tools as
* well as the CLI, so a terminal recovery step ("run agent-device help ...") belongs to the CLI
* call sites, which pass their own hint through the `hint` parameters below.
*/
export const BATCH_STEP_SHAPE_HINT =
'Each batch step is {"command":"<name>","input":{...}}, where input is that command\'s own ' +
'structured input object, keyed by field name. There is no positional step form: args, argv, ' +
'positionals, and flags are not step fields.';

export function readBatchStepRecord(
step: unknown,
stepNumber: number,
hint: string = BATCH_STEP_SHAPE_HINT,
): Record<string, unknown> {
if (!isRecord(step)) {
throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`);
throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`, { hint });
}
return step;
}

export function readBatchStepInputObject(
record: Record<string, unknown>,
stepNumber: number,
hint: string = BATCH_STEP_SHAPE_HINT,
): Record<string, unknown> {
const input = record.input;
if (!isRecord(input)) {
throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`);
throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`, {
hint,
});
}
return input;
}
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
BATCH_STEP_SHAPE_HINT,
DEFAULT_BATCH_MAX_STEPS,
assertBatchStepCount,
isValidBatchMaxSteps,
Expand Down
53 changes: 52 additions & 1 deletion src/cli-schema/cli-help-examples.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import type { BatchStep } from '@agent-device/contracts/client';
import { parseArgs } from '../cli/parser/args.ts';
import { readCliBatchStepsJson } from '../cli/batch-steps.ts';
import { buildCommandUsageText, buildUsageText, helpTopicIds } from './cli-help.ts';
import { listCliCommandNames } from '../command-catalog.ts';
import { readInputFromCli } from '../commands/cli-grammar.ts';
import { isCommandName } from '../commands/command-metadata.ts';
import { findCommandMetadata, isCommandName } from '../commands/command-metadata.ts';
import { readVersion } from '../utils/version.ts';

// Help is the agent-facing contract, and agents copy its example lines verbatim, so an example the
Expand Down Expand Up @@ -106,6 +108,55 @@ test('every runnable example printed by CLI help is accepted by the CLI schema',
);
});

/** The `--steps` payload of every batch example help prints, paired with the line it came from. */
function collectBatchStepExamples(): Array<{ line: string; steps: BatchStep[] }> {
return collectHelpExamples()
.filter((example) => example.argv[0] === 'batch' && example.argv.includes('--steps'))
.map((example) => {
const json = example.argv[example.argv.indexOf('--steps') + 1];
assert.ok(json, `Expected a --steps payload in help example: ${example.line}`);
return { line: example.line, steps: readCliBatchStepsJson(json) };
});
}

// A step's `input` is keyed by the command's structured field names, and no CLI help text spells
// those out — `help press` documents the positional and the flags, not `target: {kind, ref}`. So
// the printed steps ARE the terminal's only statement of them, and the check above cannot see it:
// the CLI parser validates the step envelope and stops, never reading `input`. Each printed input
// therefore goes through its own command's `readInput` — the same reader the daemon, the client,
// and the MCP tool run (#2062).
test('every batch step printed by CLI help is accepted by its own command reader', () => {
const examples = collectBatchStepExamples();
const covered = new Set<string>();
for (const { line, steps } of examples) {
for (const step of steps) {
assert.ok(isCommandName(step.command), `Unknown command in help example: ${line}`);
let parsed: unknown;
try {
parsed = findCommandMetadata(step.command).readInput(step.input);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert.fail(
`Help advertises a batch step ${step.command} rejects:\n${line}\n ${message}`,
);
}
// readInput IGNORES unknown keys, so acceptance alone cannot pin an optional field: a
// renamed `settle` or `interactiveOnly` would parse to a step that silently does less
// than the help advertises. Every printed key must survive into the parsed input.
assert.deepEqual(
Object.keys(parsed as Record<string, unknown>).sort(),
Object.keys(step.input).sort(),
`Help advertises a batch step ${step.command} key its reader drops:\n${line}`,
);
covered.add(step.command);
}
}
// #2062 was reported against the mutating verbs; a snapshot step is what precedes them.
for (const command of ['press', 'fill', 'snapshot']) {
assert.ok(covered.has(command), `Expected a ${command} step among help's batch examples`);
}
});

test('help react-native keeps its multi-worktree open example runnable', () => {
const examples = collectHelpExamples().filter(
(example) => example.surface === 'react-native' && example.argv.includes('--metro-port'),
Expand Down
40 changes: 38 additions & 2 deletions src/cli-schema/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,42 @@ test('commands topic lists the shared device selectors in their own section', as
assert.match(selectionSection, /--session <name>/);
});

// `help batch` documented neither the step shape nor which commands batch accepts, so both were
// only reachable by trial (#2062). The accepted list is rendered from the registry, so this asserts
// membership rather than an exact roster.
test('batch help documents the step shape and the commands batch accepts', async () => {
const help = await usageForCommand('batch');
if (help === null) throw new Error('Expected batch help text');

assert.match(help, /\{"command":"<name>","input":\{\.\.\.\}\}/);
assert.match(help, /no positional step form/);
assert.match(help, /Available through batch:/);
for (const command of ['press', 'click', 'fill', 'longpress', 'scroll', 'back']) {
assert.match(help, new RegExp(`\\b${command}\\b`), `expected ${command} in batch help`);
}
assert.match(help, /batch and replay never nest/);
// A step's input is keyed by structured field names no CLI help spells out, so the examples are
// this text's only statement of them. `cli-help-examples.test.ts` runs them through the readers.
assert.match(help, /\n\nExamples:\n {2}agent-device batch --steps '\[/);
assert.match(help, /"command":"press","input":\{"target":\{"kind":"ref"/);
assert.match(help, /"command":"fill",.*"text":"qa@example\.com"/);
assert.match(help, /"command":"snapshot","input":\{"interactiveOnly":true\}/);
});

// #2046 removed the positionals/flags step payload and dropped positional step input, but the
// topics kept describing both: `help scripting` still weighed the removed shape against the
// accepted one, and `help workflow` still named `batch ./steps.json` as the known flow (#2062).
test('scripting and workflow topics name only the accepted batch step source', async () => {
const scripting = await usageForCommand('scripting');
const workflow = await usageForCommand('workflow');
if (scripting === null || workflow === null) throw new Error('Expected both help texts');

assert.match(scripting, /agent-device batch --steps-file \.\/steps\.json/);
assert.doesNotMatch(scripting, /positionals\/flags/);
assert.match(workflow, /batch --steps-file \.\/steps\.json/);
assert.doesNotMatch(workflow, /batch \.\/steps\.json/);
});

test('commands topic includes only global flags in its global flags section', async () => {
const usageText = await usageForCommand('commands');
if (usageText === null) throw new Error('Expected commands help text');
Expand Down Expand Up @@ -151,7 +187,7 @@ test('usageForCommand resolves workflow help topic', async () => {
help,
/iOS rejects a stale pinned ref -- refresh with snapshot -i or use a selector/,
);
assert.match(help, /Known flow: batch \.\/steps\.json \(help scripting\)/);
assert.match(help, /Known flow: batch --steps-file \.\/steps\.json \(help batch\)/);
assert.match(help, /Shapes and platform quirks: help gestures/);
assert.match(
help,
Expand Down Expand Up @@ -254,7 +290,7 @@ test('usageForCommand resolves scripting help topic', async () => {
assert.match(help, /state-repair means the script is correct but app state is not/);
assert.match(help, /close --save-script\[=<out>\] \(default <stem>\.healed\.ad\)/);
assert.match(help, /agent-device batch --steps '\[\{"command":"open"/);
assert.match(help, /removed positionals\/flags shape fails with an example/);
assert.match(help, /Step keys are command, input, and optional runtime -- that is the whole/);
assert.match(help, /test \.\/e2e\/maestro --maestro --device udid1,emulator-5554 --shard-all 2/);
assert.match(help, /Android adb screenrecord has a 180s limit/);
assert.match(help, /--hide-touches skips that for the fastest raw recording/);
Expand Down
5 changes: 3 additions & 2 deletions src/cli-schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Command shape:
Command lines only -- no prose, numbering, fences, pipes, or grep/head/tail/jq on agent-device output; raw output carries the refs/hints the next step needs. Subcommand first, then positionals, then flags: agent-device open com.example.app --session checkout --platform android --relaunch
Chain confident consecutive steps with &&: press 'label="Search"' --settle && fill 'label="Search"' "query" --settle. Fall back to one command at a time when a step is uncertain (ambiguous match, network-backed result, unseen screen).
Refs look like @e12; use the exact ref from the latest snapshot -i, never a placeholder (@ref, @eN, @Label_Name). Pin with ~s<n> (press @e12~s4); iOS rejects a stale pinned ref -- refresh with snapshot -i or use a selector.
close = agent-device close. App back is back; system back is back --system. Taps are press/click. type never takes --settle: run type, then diff snapshot to verify. Known flow: batch ./steps.json (help scripting).
close = agent-device close. App back is back; system back is back --system. Taps are press/click. type never takes --settle: run type, then diff snapshot to verify. Known flow: batch --steps-file ./steps.json (help batch).
Gestures: scroll/swipe for lists/flicks; gesture pan|fling|pinch|rotate|transform|drag for multi-touch. Shapes and platform quirks: help gestures.

Bootstrap:
Expand Down Expand Up @@ -225,7 +225,8 @@ Replay divergence and repair:

Batch:
agent-device batch --steps '[{"command":"open","input":{"app":"settings"}},{"command":"wait","input":{"kind":"duration","durationMs":100}}]'
Step keys are command, input, and optional runtime; put command arguments inside input using the same fields as the MCP/Node command. The removed positionals/flags shape fails with an example showing how to migrate it.
agent-device batch --steps-file ./steps.json --json
Step keys are command, input, and optional runtime -- that is the whole accepted shape. input holds the command's structured fields, not its terminal spelling: a CLI positional becomes a named field (target, text, direction) and a flag becomes a camelCase key (--settle -> "settle":true). Accepted commands, that mapping, and runnable press/fill/snapshot steps: help batch.
Maestro full-suite validation on connected devices uses one test command with a comma-separated --device list and --shard-all (--shard-split only to split suite entries across devices):
agent-device test ./e2e/maestro --maestro --device udid1,emulator-5554 --shard-all 2

Expand Down
33 changes: 28 additions & 5 deletions src/cli/batch-steps.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import type { BatchStep } from '@agent-device/contracts/client';
import {
BATCH_STEP_SHAPE_HINT,
parseBatchStepRuntime,
readBatchStepInputObject,
readBatchStepRecord,
} from '@agent-device/contracts/command';
import { AppError } from '@agent-device/kernel/errors';
import { readStructuredBatchCommandName } from '../core/batch-policy.ts';
import {
BATCH_AVAILABLE_COMMANDS_HINT,
readStructuredBatchCommandName,
} from '../core/batch-policy.ts';
import { assertAllowedKeys } from '../commands/command-input.ts';

/**
* The terminal half of the step-shape refusal. `@agent-device/contracts` states the shape for
* every surface and stops there; only a terminal caller can be told to run a help command, so the
* recovery step is attached here rather than in the shared contract (#2062).
*/
const CLI_BATCH_STEP_SHAPE_HINT = `${BATCH_STEP_SHAPE_HINT} Run agent-device help batch for the commands batch accepts and for runnable step examples.`;

const CLI_BATCH_AVAILABLE_COMMANDS_HINT = `${BATCH_AVAILABLE_COMMANDS_HINT} Run agent-device help batch for the commands batch accepts and for runnable step examples.`;

export function readCliBatchStepsJson(raw: string): BatchStep[] {
let parsed: unknown;
try {
Expand All @@ -22,20 +35,30 @@ export function readCliBatchStepsJson(raw: string): BatchStep[] {
}

function readCliBatchStep(step: unknown, stepNumber: number): BatchStep {
const record = readBatchStepRecord(step, stepNumber);
const record = readBatchStepRecord(step, stepNumber, CLI_BATCH_STEP_SHAPE_HINT);
const removedFields = ['positionals', 'flags'].filter((field) => field in record);
if (removedFields.length > 0) {
const fields = removedFields.map((field) => `"${field}"`).join(', ');
throw new AppError(
'INVALID_ARGS',
`Batch step ${stepNumber} uses removed field(s): ${fields}. Use {"command":"...","input":{...}}. Example: {"command":"open","input":{"app":"settings","platform":"ios"}}.`,
{ hint: CLI_BATCH_STEP_SHAPE_HINT },
);
}
assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`);
assertAllowedKeys(
record,
['command', 'input', 'runtime'],
`Batch step ${stepNumber}`,
CLI_BATCH_STEP_SHAPE_HINT,
);
const runtime = parseBatchStepRuntime(record.runtime, stepNumber);
return {
command: readStructuredBatchCommandName(record.command, stepNumber),
input: readBatchStepInputObject(record, stepNumber),
command: readStructuredBatchCommandName(
record.command,
stepNumber,
CLI_BATCH_AVAILABLE_COMMANDS_HINT,
),
input: readBatchStepInputObject(record, stepNumber, CLI_BATCH_STEP_SHAPE_HINT),
...(runtime === undefined ? {} : { runtime }),
};
}
Loading
Loading