Files
callstack__agent-device/packages/contracts/src/batch-contract.ts
Nicolas Bataille b44f882c83 docs(batch): name the step shape and the accepted commands in help batch and in its refusals (#2067)
* 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

* docs(batch): ground the step-shape guidance in the structured schema and keep contracts surface-neutral

`help batch` now prints runnable snapshot/press/fill steps carrying the real
structured field names (`target: {kind, ref}`, `text`, `interactiveOnly`), which
no `help <command>` text states, and says so instead of pointing at command help
for them. `cli-help-examples.test.ts` reads those steps back out of the rendered
help and runs each `input` through its own command's `readInput`, so a renamed
field fails there rather than shipping guidance nobody can run.

Fixes the stale batch guidance the audit missed: `help workflow` named
`batch ./steps.json`, which positional input rejects, and `help scripting` still
weighed the removed positionals/flags shape against the accepted one.

`BATCH_STEP_SHAPE_HINT` in `@agent-device/contracts` describes the shape only;
`readBatchStepRecord`/`readBatchStepInputObject` take the hint as a parameter so
the CLI attaches its own `agent-device help batch` recovery step while the Node
client and MCP tools keep the surface-neutral one.

* test(batch): pin every advertised step key, and hint the removed-shape refusal

The example-validation test accepted a step whose optional key the
reader silently dropped — readInput ignores unknown keys, so a renamed
settle or interactiveOnly kept the test green while help advertised a
step that does less than it claims. Every printed key must now survive
into the parsed input.

The removed positionals/flags refusal carries the CLI shape hint like
its three sibling refusals.

* fix(batch): keep the availability refusal surface-neutral; CLI attaches its recovery

readStructuredBatchCommandName emitted 'Run agent-device help batch'
unconditionally, and the same reader backs the MCP/Node batch metadata
— an MCP caller got a terminal-only, unrunnable recovery step. The
shared default now states the exclusion boundary itself with no
terminal vocabulary (MCP/Node read the accepted commands off the step
schema's command enum), and the CLI admission appends the help pointer
via the same optional-hint parameter the shape hint uses.

Found while fixing it: hint strings are redaction-capped at 400
characters, so enumerating the derived roster inline truncates the
hint — recovery pointer and all. The regression pins neutrality AND
that the hint survives the cap whole.

Addresses the P2 review on #2067.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-27 13:10:18 +02:00

71 lines
2.5 KiB
TypeScript

import { daemonRuntimeSchema, type SessionRuntimeHints } from '@agent-device/kernel/contracts';
import { AppError } from '@agent-device/kernel/errors';
import { isRecord } from './json.ts';
export const DEFAULT_BATCH_MAX_STEPS = 100;
export function isValidBatchMaxSteps(maxSteps: number): boolean {
return Number.isInteger(maxSteps) && maxSteps >= 1 && maxSteps <= 1000;
}
export function assertBatchStepCount(stepCount: number, maxSteps: number): void {
if (stepCount > maxSteps) {
throw new AppError('INVALID_ARGS', `batch has ${stepCount} steps; max allowed is ${maxSteps}.`);
}
}
/**
* 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}.`, { 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.`, {
hint,
});
}
return input;
}
export function parseBatchStepRuntime(
value: unknown,
stepNumber: number,
): SessionRuntimeHints | undefined {
if (value === undefined) return undefined;
try {
return daemonRuntimeSchema.parse(value);
} catch (error) {
throw new AppError(
'INVALID_ARGS',
`Batch step ${stepNumber} runtime is invalid: ${error instanceof Error ? error.message : String(error)}`,
);
}
}