Skip to content

Commit 10a8e07

Browse files
docs(batch): name the step shape and the accepted commands in help and refusals
`batch` accepts one step shape and `help batch` documented none of it: the usage line, one sentence, and the flags. Every refusal named only what was wrong. A caller reaching for `press` through `batch` therefore saw "Invalid batch step 1." for `["press @E12"]` and "unknown field(s): args" for `{"command":"press","args":[...]}`, and reasonably concluded the mutating verbs were excluded (#2062). They are not, and never were: `press`, `click`, `fill`, `longpress`, `scroll` and `back` all carry `batchable: true` in the command-descriptor registry, including at 0.20.10. The exclusions are `batch`/`replay` (which never nest) and the session/daemon/connection/host-tooling commands. Nothing about the allowlist changes here; what changes is that it is stated. - `help batch` documents the step shape, serial semantics, and RENDERS the accepted commands from the registry's `batchable` trait, so the listing cannot drift from the runtime allowlist. - The step-shape refusals (non-object step, unknown field, non-object input) share one hint naming `{"command":"<name>","input":{...}}`, owned by `batch-contract.ts` next to the checks that raise them. - The non-batchable-command refusal points at that listing and says which families are excluded and why. - `assertAllowedKeys` takes an optional hint so the batch call sites can attach theirs without a second unknown-key check. Closes #2062
1 parent b1ee2a7 commit 10a8e07

10 files changed

Lines changed: 134 additions & 7 deletions

File tree

packages/contracts/src/batch-contract.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,22 @@ export function assertBatchStepCount(stepCount: number, maxSteps: number): void
1414
}
1515
}
1616

17+
/**
18+
* The one sentence every batch-step shape refusal owes the caller. `batch` accepts a single step
19+
* shape, and none of its refusals named it: a string step answered "Invalid batch step 1." and an
20+
* `args`/`target`/`argv` step answered "unknown field(s)", neither of which says what a step
21+
* looks like or where to find a command's input keys (#2062).
22+
*/
23+
export const BATCH_STEP_SHAPE_HINT =
24+
'Each batch step is {"command":"<name>","input":{...}} — the same input object that command ' +
25+
'takes on its own. There is no positional step form: run agent-device help <command> for its ' +
26+
'arguments, and agent-device help batch for the commands batch accepts.';
27+
1728
export function readBatchStepRecord(step: unknown, stepNumber: number): Record<string, unknown> {
1829
if (!isRecord(step)) {
19-
throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`);
30+
throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`, {
31+
hint: BATCH_STEP_SHAPE_HINT,
32+
});
2033
}
2134
return step;
2235
}
@@ -27,7 +40,9 @@ export function readBatchStepInputObject(
2740
): Record<string, unknown> {
2841
const input = record.input;
2942
if (!isRecord(input)) {
30-
throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`);
43+
throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`, {
44+
hint: BATCH_STEP_SHAPE_HINT,
45+
});
3146
}
3247
return input;
3348
}

packages/contracts/src/facades/command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
BATCH_STEP_SHAPE_HINT,
23
DEFAULT_BATCH_MAX_STEPS,
34
assertBatchStepCount,
45
isValidBatchMaxSteps,

src/cli-schema/cli-help-topics.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ test('gesture help documents selectors and pinned refs for both drag endpoints',
3737
assert.match(help, /drag <source-selector\|pinned-ref> <destination-selector\|pinned-ref>/);
3838
});
3939

40+
// `help batch` documented neither the step shape nor which commands batch accepts, so both were
41+
// only reachable by trial (#2062). The accepted list is rendered from the registry, so this asserts
42+
// membership rather than an exact roster.
43+
test('batch help documents the step shape and the commands batch accepts', async () => {
44+
const help = await usageForCommand('batch');
45+
if (help === null) throw new Error('Expected batch help text');
46+
47+
assert.match(help, /\{"command":"<name>","input":\{\.\.\.\}\}/);
48+
assert.match(help, /no positional step form/);
49+
assert.match(help, /Available through batch:/);
50+
for (const command of ['press', 'click', 'fill', 'longpress', 'scroll', 'back']) {
51+
assert.match(help, new RegExp(`\\b${command}\\b`), `expected ${command} in batch help`);
52+
}
53+
assert.match(help, /batch and replay never nest/);
54+
});
55+
4056
test('commands topic includes only global flags in its global flags section', async () => {
4157
const usageText = await usageForCommand('commands');
4258
if (usageText === null) throw new Error('Expected commands help text');

src/cli/batch-steps.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { BatchStep } from '@agent-device/contracts/client';
22
import {
3+
BATCH_STEP_SHAPE_HINT,
34
parseBatchStepRuntime,
45
readBatchStepInputObject,
56
readBatchStepRecord,
@@ -31,7 +32,12 @@ function readCliBatchStep(step: unknown, stepNumber: number): BatchStep {
3132
`Batch step ${stepNumber} uses removed field(s): ${fields}. Use {"command":"...","input":{...}}. Example: {"command":"open","input":{"app":"settings","platform":"ios"}}.`,
3233
);
3334
}
34-
assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`);
35+
assertAllowedKeys(
36+
record,
37+
['command', 'input', 'runtime'],
38+
`Batch step ${stepNumber}`,
39+
BATCH_STEP_SHAPE_HINT,
40+
);
3541
const runtime = parseBatchStepRuntime(record.runtime, stepNumber);
3642
return {
3743
command: readStructuredBatchCommandName(record.command, stepNumber),

src/commands/batch/cli.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,55 @@ test('batch rejects structured replay steps before daemon dispatch', async () =>
120120
assert.match(result.stderr, /not available through command batch/);
121121
});
122122

123+
// Every step-shape refusal used to name only what was wrong, never what a step looks like, and
124+
// `help batch` documented no shape and no exclusions — so the boundary was reachable only by
125+
// trial (#2062). Each refusal now carries the missing half.
126+
127+
test('a non-object step names the step shape', async () => {
128+
const result = await runCliCapture(['batch', '--steps', '["press @e12"]']);
129+
130+
assert.equal(result.code, 1);
131+
assert.equal(result.calls.length, 0);
132+
assert.match(result.stderr, /Invalid batch step 1/);
133+
assert.match(result.stderr, /\{"command":"<name>","input":\{\.\.\.\}\}/);
134+
assert.match(result.stderr, /no positional step form/);
135+
});
136+
137+
test('an args/target step names the step shape instead of only the unknown field', async () => {
138+
const result = await runCliCapture(['batch', '--steps', '[{"command":"press","args":["@e12"]}]']);
139+
140+
assert.equal(result.code, 1);
141+
assert.match(result.stderr, /has unknown field\(s\): args/);
142+
assert.match(result.stderr, /\{"command":"<name>","input":\{\.\.\.\}\}/);
143+
});
144+
145+
test('a non-batchable command points at the listing of the ones that are', async () => {
146+
const result = await runCliCapture(['batch', '--steps', '[{"command":"session","input":{}}]']);
147+
148+
assert.equal(result.code, 1);
149+
assert.match(result.stderr, /not available through command batch: session/);
150+
assert.match(result.stderr, /help batch/);
151+
});
152+
153+
// The reported blocker (#2062) was the step shape, not the verb: press/click/fill were never
154+
// excluded from batch. Pin that so a future exclusion has to be a deliberate registry change.
155+
test('mutating UI verbs reach daemon dispatch through batch', async () => {
156+
const result = await runCliCapture([
157+
'batch',
158+
'--steps',
159+
'[{"command":"press","input":{"target":{"kind":"ref","ref":"@e12"}}},{"command":"fill","input":{"target":{"kind":"ref","ref":"@e13"},"text":"x"}}]',
160+
'--json',
161+
]);
162+
163+
assert.equal(result.code, null);
164+
assert.equal(result.calls.length, 1);
165+
const steps = result.calls[0]?.flags?.batchSteps ?? [];
166+
assert.deepEqual(
167+
steps.map((step) => step.command),
168+
['press', 'fill'],
169+
);
170+
});
171+
123172
test('batch rejects invalid structured runtime', async () => {
124173
const result = await runCliCapture([
125174
'batch',

src/commands/batch/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { defineExecutableCommand } from '../command-contract.ts';
77
import { commonToClientOptions } from '../command-input.ts';
88
import { batchCliOutputFormatters } from './output.ts';
99
import { createBatchCommandMetadata, type BatchInput } from './metadata.ts';
10+
import { STRUCTURED_BATCH_COMMAND_NAMES } from '../../core/batch-policy.ts';
1011
import { createBatchDaemonWriter } from './projection.ts';
1112

1213
const batchCommandMetadata = createBatchCommandMetadata();
@@ -29,10 +30,26 @@ const batchCliReader: CliReader = (_positionals, flags) => ({
2930
out: flags.out,
3031
});
3132

33+
/**
34+
* `help batch` documented neither the step shape nor which commands batch accepts, so an agent
35+
* discovered both by trial: `["press @e12"]` and `{"command":"press","args":[...]}` were refused
36+
* without either being named (#2062). The accepted set is RENDERED from the command-descriptor
37+
* registry's `batchable` trait, so it cannot drift from what the runtime allowlist enforces.
38+
*/
39+
function buildBatchCliDetail(): string {
40+
return [
41+
'Each step is {"command":"<name>","input":{...}}: the same input object that command takes on its own — run agent-device help <command> for its arguments. There is no positional step form; args, target, and argv are not step fields.',
42+
'Steps run serially in one daemon request against the same session, in order. Mutating UI verbs are included (press, click, fill, longpress, scroll, back), which is where the round-trip saving is; --on-error stop halts at the first failing step.',
43+
`Available through batch: ${[...STRUCTURED_BATCH_COMMAND_NAMES].sort().join(', ')}.`,
44+
'Every other command is excluded: batch and replay never nest, and session, daemon, connection, and host tooling commands own lifecycle a batch request cannot carry. Run those on their own.',
45+
].join(' ');
46+
}
47+
3248
const batchCommandFacet = defineCommandFacet({
3349
name: 'batch',
3450
text: {
3551
summary: 'Run multiple commands',
52+
cliDetail: buildBatchCliDetail(),
3653
},
3754
metadata: batchCommandMetadata,
3855
definition: batchCommandDefinition,

src/commands/batch/metadata.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
BATCH_STEP_SHAPE_HINT,
23
DEFAULT_BATCH_MAX_STEPS,
34
assertBatchStepCount,
45
isValidBatchMaxSteps,
@@ -49,7 +50,7 @@ export function createBatchCommandMetadata(
4950
const fields = batchFields(nestedCommands);
5051
return defineCommandMetadata({
5152
name: 'batch',
52-
description: 'Execute multiple commands in one daemon request',
53+
description: 'Execute multiple commands in one daemon request.',
5354
inputSchema: fieldsInputSchema(fields),
5455
readInput: (input) => readBatchInput(input, fields),
5556
});
@@ -128,7 +129,12 @@ function readBatchStep(
128129
nestedCommands: readonly string[],
129130
): BatchCommandStep {
130131
const record = readBatchStepRecord(step, stepNumber);
131-
assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`);
132+
assertAllowedKeys(
133+
record,
134+
['command', 'input', 'runtime'],
135+
`Batch step ${stepNumber}`,
136+
BATCH_STEP_SHAPE_HINT,
137+
);
132138
return {
133139
command: readBatchStepCommand(record, stepNumber, nestedCommands),
134140
input: readBatchStepInputObject(record, stepNumber),

src/commands/cli-grammar/flag-definitions-workflow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
135135
names: ['--steps'],
136136
type: 'string',
137137
usageLabel: '--steps <json>',
138-
usageDescription: 'Batch: JSON array of steps',
138+
usageDescription: 'Batch: JSON array of {"command","input"} steps',
139139
},
140140
{
141141
key: 'stepsFile',

src/commands/command-input.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,11 +529,16 @@ export function assertAllowedKeys(
529529
record: Record<string, unknown>,
530530
allowedKeys: readonly string[],
531531
label: string,
532+
hint?: string,
532533
): void {
533534
const allowed = new Set(allowedKeys);
534535
const unknownKeys = Object.keys(record).filter((key) => !allowed.has(key));
535536
if (unknownKeys.length > 0) {
536-
throw new AppError('INVALID_ARGS', `${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
537+
throw new AppError(
538+
'INVALID_ARGS',
539+
`${label} has unknown field(s): ${unknownKeys.join(', ')}.`,
540+
hint === undefined ? undefined : { hint },
541+
);
537542
}
538543
}
539544

src/core/batch-policy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ export const INHERITED_PARENT_FLAG_KEYS = [
4949
'out',
5050
] as const;
5151

52+
/**
53+
* The refusal above names the rejected command but never named the accepted set, and `help batch`
54+
* documented no exclusions either, so the boundary was only discoverable by trial (#2062). Point at
55+
* the one derived listing rather than restating it: `help batch` renders
56+
* {@link STRUCTURED_BATCH_COMMAND_NAMES} itself, so a registry change cannot leave prose behind.
57+
*/
58+
const BATCH_AVAILABLE_COMMANDS_HINT =
59+
'Run agent-device help batch for the commands available through batch. Session, daemon, ' +
60+
'connection, and host tooling commands are excluded — they own lifecycle a batch cannot ' +
61+
'carry — and batch and replay never nest. Run an excluded command on its own.';
62+
5263
const structuredBatchCommandNames = new Set<string>(STRUCTURED_BATCH_COMMAND_NAMES);
5364

5465
function isStructuredBatchCommandName(command: string): command is StructuredBatchCommandName {
@@ -68,6 +79,7 @@ export function readStructuredBatchCommandName(
6879
throw new AppError(
6980
'INVALID_ARGS',
7081
`Batch step ${stepNumber} command is not available through command batch: ${String(command)}`,
82+
{ hint: BATCH_AVAILABLE_COMMANDS_HINT },
7183
);
7284
}
7385

0 commit comments

Comments
 (0)