Skip to content

Commit 1f80c92

Browse files
authored
refactor: derive every --settle surface from the descriptor trait (#1652) (#1945)
1 parent d0547dc commit 1f80c92

19 files changed

Lines changed: 462 additions & 77 deletions

packages/contracts/src/facades/interaction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export {
152152
} from '../scroll-command.ts';
153153
export type {
154154
ScrollCommandOptions,
155+
ScrollCommandResult,
155156
ScrollDistanceOptions,
156157
ScrollExecutionOptions,
157158
ResolvedScrollExecutionOptions,

packages/contracts/src/scroll-command.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { AppError } from '@agent-device/kernel/errors';
2+
import type { SettleObservation } from './interaction.ts';
3+
import type { ScrollDirection } from './scroll-gesture.ts';
24

35
export const SCROLL_DURATION_MAX_MS = 10_000;
46
export const DEFAULT_MOBILE_SCROLL_DURATION_MS = 300;
@@ -67,3 +69,26 @@ export function honoredScrollDurationMs(
6769
): number | undefined {
6870
return typeof result?.durationMs === 'number' ? result.durationMs : undefined;
6971
}
72+
73+
/**
74+
* `scroll` — the generic-route result built by `buildDispatchedScrollResult`
75+
* (src/core/dispatch-interactions.ts): the resolved direction, the edge-pass
76+
* bookkeeping for `top`/`bottom` scrolls, the honored distance/timing echo,
77+
* and the success message. Platform leaves add gesture-plan coordinates
78+
* (`x1`/`y1`/`x2`/`y2`, reference frame) on top; the output schema stays
79+
* non-strict so those additive fields validate. The one field the dispatcher
80+
* itself may add: `settle`, the opt-in `--settle` observation (#1638),
81+
* attached after the command — same shape as `BackCommandResult`.
82+
*/
83+
export type ScrollCommandResult = {
84+
direction: ScrollDirection;
85+
/** Set for `top`/`bottom` requests: the extreme being scrolled to. */
86+
edge?: 'top' | 'bottom';
87+
/** Edge scrolls only: how many scroll-and-check passes ran. */
88+
passes?: number;
89+
amount?: number;
90+
pixels?: number;
91+
durationMs?: number;
92+
message?: string;
93+
settle?: SettleObservation;
94+
};

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -521,10 +521,8 @@ test('usageForCommand resolves dogfood help topic', async () => {
521521
assert.match(help, /direct Android localhost URL opens with a port auto-configure/);
522522
assert.match(help, /Keep stateful commands serial within the same session/);
523523
assert.match(help, /agent-device wait 'role=tab' 10000/);
524-
assert.match(
525-
help,
526-
/scroll takes direction then amount and does not support a selector or --settle/,
527-
);
524+
assert.match(help, /scroll takes a selector-less direction\+amount form/);
525+
assert.match(help, /Use --settle to wait for the UI to go quiet/);
528526
assert.match(help, /prefer agent-device open "Expo Go" <url>/);
529527
assert.match(help, /dogfood-output\/report\.md/);
530528
assert.match(help, /ID, severity, category, title, affected flow\/screen/);

src/cli-schema/cli-help.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -970,7 +970,7 @@ Rules:
970970
Findings must come from observed runtime behavior, not source reads.
971971
After each mutation, use the --settle diff as evidence when available; otherwise re-snapshot.
972972
Wait timeouts are integer milliseconds in the trailing positional: agent-device wait 'role=tab' 10000. Do not write duration suffixes such as 10s.
973-
scroll takes direction then amount and does not support a selector or --settle: agent-device scroll down 3.
973+
scroll takes a selector-less direction+amount form: agent-device scroll down 3. Use --settle to wait for the UI to go quiet and get the settled diff.
974974
Keep commands in the report reproducible; use selectors or refs from fresh snapshots, not guessed coordinates.
975975
Prefer refs for exploration and selectors for deterministic replay.
976976
Use logs, network, screenshot --overlay-refs, trace, perf frames, perf memory, native profiles, or react-devtools only when they add evidence to a specific issue.

src/commands/cli-grammar/common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export function selectorSnapshotOptionsFromFlags(flags: CliFlags): SelectorSnaps
140140
// Descriptor post-action observation commands use --settle (#1101).
141141
// --timeout doubles as the settle deadline only when --settle is present; a
142142
// bare --timeout stays compatible and is ignored by touch commands.
143+
// #1652: readers do NOT spread this themselves — `readInputFromCli` merges
144+
// `settleInputForCommand` at the one seam every reader passes through.
143145
export function settleInputFromFlags(flags: CliFlags): Record<string, unknown> {
144146
return compactRecord({
145147
settle: flags.settle,

src/commands/cli-grammar/registry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CliFlags } from '@agent-device/contracts/command';
22
import type { CommandName } from '../command-metadata.ts';
33
import { listCommandFamilyCliReaders } from '../family/registry.ts';
4+
import { settleInputForCommand } from '../post-action-observation-grammar.ts';
45

56
const cliReaders = listCommandFamilyCliReaders();
67

@@ -13,5 +14,8 @@ export function readInputFromCli(
1314
if (!reader) {
1415
throw new Error(`Missing CLI reader for command: ${command}`);
1516
}
16-
return reader(positionals, flags);
17+
// #1652: the `--settle` triple merges here, at the one seam every reader
18+
// passes through, instead of being hand-spread per settle-capable reader —
19+
// a drop at any one of those silently disabled the flag.
20+
return { ...reader(positionals, flags), ...settleInputForCommand(command, flags) };
1721
}

src/commands/interaction/interactions.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
request,
3131
requiredDaemonString,
3232
selectorSnapshotInputFromFlags,
33-
settleInputFromFlags,
3433
targetInputFromClientTarget,
3534
} from '../cli-grammar/common.ts';
3635
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
@@ -41,7 +40,6 @@ export const interactionCliReaders = {
4140
...commonInputFromFlags(flags),
4241
...selectorSnapshotInputFromFlags(flags),
4342
...repeatedInputFromFlags(flags),
44-
...settleInputFromFlags(flags),
4543
target: targetInputFromClientTarget(readInteractionTargetFromPositionals(positionals)),
4644
button: flags.clickButton,
4745
verify: flags.verify,
@@ -50,7 +48,6 @@ export const interactionCliReaders = {
5048
...commonInputFromFlags(flags),
5149
...selectorSnapshotInputFromFlags(flags),
5250
...repeatedInputFromFlags(flags),
53-
...settleInputFromFlags(flags),
5451
target: targetInputFromClientTarget(readInteractionTargetFromPositionals(positionals)),
5552
verify: flags.verify,
5653
}),
@@ -59,15 +56,13 @@ export const interactionCliReaders = {
5956
return {
6057
...commonInputFromFlags(flags),
6158
...selectorSnapshotInputFromFlags(flags),
62-
...settleInputFromFlags(flags),
6359
target: targetInputFromClientTarget(decoded),
6460
durationMs: decoded.durationMs,
6561
};
6662
},
6763
hover: (positionals, flags) => ({
6864
...commonInputFromFlags(flags),
6965
...selectorSnapshotInputFromFlags(flags),
70-
...settleInputFromFlags(flags),
7166
target: targetInputFromClientTarget(readInteractionTargetFromPositionals(positionals)),
7267
}),
7368
swipe: (positionals, flags) => ({
@@ -93,7 +88,6 @@ export const interactionCliReaders = {
9388
return {
9489
...commonInputFromFlags(flags),
9590
...selectorSnapshotInputFromFlags(flags),
96-
...settleInputFromFlags(flags),
9791
target: targetInputFromClientTarget(decoded.target),
9892
text: decoded.text,
9993
delayMs: flags.delayMs,
@@ -103,7 +97,6 @@ export const interactionCliReaders = {
10397
},
10498
scroll: (positionals, flags) => ({
10599
...commonInputFromFlags(flags),
106-
...settleInputFromFlags(flags),
107100
direction: readScrollDirection(positionals[0]),
108101
amount: optionalCliNumber(positionals[1]),
109102
pixels: flags.pixels,

src/commands/interaction/output.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts';
55
import { readCommandMessage } from '../../utils/success-text.ts';
66
import {
77
messageCliOutput,
8+
messageOutput,
89
pinnedRefText,
910
resultOutput,
1011
type CliOutputFormatter,
1112
} from '../output-common.ts';
12-
import { appendResponseNotes, messageWithSettleOutput } from '../settle-output.ts';
13+
import { withSettleCapableNotes } from '../settle-output.ts';
1314

1415
function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput {
1516
const data = params.result as Record<string, unknown>;
@@ -73,27 +74,29 @@ function tapCliOutput(result: CommandRequestResult): CliOutput {
7374
const x = data.x;
7475
const y = data.y;
7576
if (!ref || typeof x !== 'number' || typeof y !== 'number') {
76-
const output = defaultCommandCliOutput(data);
77-
return { data: output.data, text: appendResponseNotes(output.text, data) };
77+
return defaultCommandCliOutput(data);
7878
}
79-
return { data, text: appendResponseNotes(`Tapped @${ref} (${x}, ${y})`, data) };
79+
return { data, text: `Tapped @${ref} (${x}, ${y})` };
8080
}
8181

82-
export const interactionCliOutputFormatters = {
82+
// #1652: settle-capable entries (click, press, fill, longpress, hover, scroll)
83+
// get the warning/settle notes appended by the trait-derived wrapper; the rest
84+
// of the map is returned untouched.
85+
export const interactionCliOutputFormatters = withSettleCapableNotes({
8386
click: resultOutput(tapCliOutput),
8487
press: resultOutput(tapCliOutput),
85-
fill: messageWithSettleOutput,
86-
longpress: messageWithSettleOutput,
87-
hover: messageWithSettleOutput,
88-
scroll: messageWithSettleOutput,
88+
fill: messageOutput,
89+
longpress: messageOutput,
90+
hover: messageOutput,
91+
scroll: messageOutput,
8992
get: ({ input, result }) =>
9093
getCliOutput({
9194
result: result as CommandRequestResult,
9295
format: input.format as Parameters<typeof getCliOutput>[0]['format'],
9396
}),
9497
is: resultOutput(isCliOutput),
9598
find: resultOutput(findCliOutput),
96-
} as const satisfies Record<string, CliOutputFormatter>;
99+
} satisfies Record<string, CliOutputFormatter>);
97100

98101
function defaultCommandCliOutput(result: CommandRequestResult): CliOutput {
99102
return messageCliOutput(result as Record<string, unknown>);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type {
2+
ClickOptions,
3+
FillOptions,
4+
HoverOptions,
5+
LongPressOptions,
6+
PressOptions,
7+
ScrollOptions,
8+
SettleCommandOptions,
9+
} from '@agent-device/contracts/client';
10+
import type { PostActionObservationCommandName } from '../core/command-descriptor/post-action-observation.ts';
11+
import type { NavigationCommandOptions } from './system/navigation-projection.ts';
12+
13+
/**
14+
* Compile-time completeness for the contracts half of the `--settle` surface
15+
* (#1652): every client options type whose command carries the descriptor
16+
* post-action observation trait must intersect `SettleCommandOptions`.
17+
*
18+
* The `Record` over the trait command union makes a missing or extra cell a
19+
* compile error, and the `satisfies` assignability check fails when any listed
20+
* option type drops its `& SettleCommandOptions` intersection — the same
21+
* strategy `packages/contracts/src/interaction-guarantees.ts` uses to make
22+
* guarantee completeness a compile error instead of a runtime assertion.
23+
*/
24+
const SETTLE_CAPABLE_CLIENT_OPTION_TYPES = {
25+
click: {} as ClickOptions,
26+
press: {} as PressOptions,
27+
longpress: {} as LongPressOptions,
28+
hover: {} as HoverOptions,
29+
fill: {} as FillOptions,
30+
scroll: {} as ScrollOptions,
31+
back: {} as NavigationCommandOptions<'back'>,
32+
} as const satisfies Record<PostActionObservationCommandName, SettleCommandOptions>;
33+
34+
export type SettleCapableClientOptionCommands =
35+
readonly (keyof typeof SETTLE_CAPABLE_CLIENT_OPTION_TYPES)[];

src/commands/post-action-observation-grammar.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1+
import type { CliFlags } from '@agent-device/contracts/command';
12
import type { PostActionObservationSupportFor } from '../core/command-descriptor/post-action-observation.ts';
23
import {
34
commandSupportsSettleObservation,
45
commandSupportsVerifyEvidence,
56
} from '../core/command-descriptor/registry.ts';
7+
import { settleInputFromFlags } from './cli-grammar/common.ts';
68
import { SETTLE_FLAGS } from './cli-grammar/flag-groups.ts';
79
import type { FlagKey } from './cli-grammar/flag-types.ts';
810
import { booleanField, integerField } from './command-input.ts';
911

1012
/**
11-
* The two caller-facing surfaces a command's post-action observation trait
13+
* The caller-facing surfaces a command's post-action observation trait
1214
* entitles it to (`--verify` / `--settle`, #1047/#1101): the metadata input
13-
* fields (Node SDK options + MCP tool schema) and the CLI allowed flags. Both
14-
* are materialized from the descriptor registry rather than hand-listed per
15-
* command. This lives outside the interaction family because the trait does
16-
* too: `scroll` and `back` carry it on the generic daemon route (#1638), and
17-
* `back` is a system command.
15+
* fields (Node SDK options + MCP tool schema), the CLI allowed flags, and the
16+
* CLI reader input (#1652). All are materialized from the descriptor registry
17+
* rather than hand-listed per command. This lives outside the interaction
18+
* family because the trait does too: `scroll` and `back` carry it on the
19+
* generic daemon route (#1638), and `back` is a system command.
1820
*
1921
* A descriptor gate (`post-action-observation.test.ts`) asserts, over every
2022
* descriptor, that both surfaces are present exactly when the trait is — so a
@@ -60,3 +62,14 @@ export function postActionObservationCliFlags(command: string): readonly FlagKey
6062
if (commandSupportsSettleObservation(command)) flags.push(...SETTLE_FLAGS);
6163
return flags;
6264
}
65+
66+
/**
67+
* #1652: the settle triple a command's reader owes the daemon, merged at the
68+
* `readInputFromCli` seam so no reader can forget it (the pre-seam per-reader
69+
* spreads silently no-op'd when dropped). Non-settle commands get `{}` — and
70+
* their parsers refuse `--settle` anyway via `postActionObservationCliFlags`.
71+
*/
72+
export function settleInputForCommand(command: string, flags: CliFlags): Record<string, unknown> {
73+
if (!commandSupportsSettleObservation(command)) return {};
74+
return settleInputFromFlags(flags);
75+
}

0 commit comments

Comments
 (0)