Skip to content
Closed
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
13 changes: 7 additions & 6 deletions packages/contracts/src/client-gesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@ export type RotateGestureOptions = DeviceCommandBaseOptions & {

export type TransformGestureOptions = DeviceCommandBaseOptions & TransformGestureParams;

export type ScrollOptions = DeviceCommandBaseOptions & {
direction: ScrollInputDirection;
amount?: number;
pixels?: number;
durationMs?: number;
};
export type ScrollOptions = DeviceCommandBaseOptions &
SettleCommandOptions & {
direction: ScrollInputDirection;
amount?: number;
pixels?: number;
durationMs?: number;
};
2 changes: 1 addition & 1 deletion src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ test('usage includes agent workflows, config, environment, and examples footers'
assert.match(usageText, /Default app loop: agent-device open <app>/);
assert.match(
usageText,
/Use --settle only on planned press, click, fill, or longpress commands; never add it to open, snapshot, or close/,
/Use --settle only on planned press, click, fill, longpress, scroll, or back commands; never add it to open, snapshot, or close/,
);
assert.match(usageText, /type never accepts --settle/);
assert.match(usageText, /explicit success confirmation is visible, stop/);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const AGENT_START_LINES = [
// Haiku from 0/2 baseline to 4/4; generic structured-hint recovery passed 8/8
// uncoached output cases versus 7/8 with the longer special-case prose.
'Default app loop: agent-device open <app> -> agent-device snapshot -i -> mutate a current target with --settle -> continue from that settled diff -> agent-device close.',
'Use --settle only on planned press, click, fill, or longpress commands; never add it to open, snapshot, or close. type never accepts --settle: run agent-device type "text", then diff snapshot if verification is needed. Once the task\'s requested end state or an explicit success confirmation is visible, stop; do not tap transient follow-up controls or navigate away only to re-verify.',
'Use --settle only on planned press, click, fill, longpress, scroll, or back commands; never add it to open, snapshot, or close. type never accepts --settle: run agent-device type "text", then diff snapshot if verification is needed. Once the task\'s requested end state or an explicit success confirmation is visible, stop; do not tap transient follow-up controls or navigate away only to re-verify.',
'Follow structured command hints before choosing a recovery action.',
'Targets are concrete refs or selectors: @e12, label="Query", role=button label="Submit".',
'Selector keys are only: id, role, text, label, value, appname, windowtitle, visible, hidden, editable, selected, focused, enabled, hittable. placeholder, index, and key are not selector keys.',
Expand Down
24 changes: 6 additions & 18 deletions src/commands/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,9 @@ import type {
TypeTextOptions,
} from '@agent-device/contracts/client';
import type { CommandSchemaOverride } from '../../cli-schema/types.ts';
import {
commandSupportsSettleObservation,
commandSupportsVerifyEvidence,
} from '../../core/command-descriptor/registry.ts';
import {
REPEATED_TOUCH_FLAGS,
SELECTOR_SNAPSHOT_FLAGS,
SETTLE_FLAGS,
} from '../cli-grammar/flag-groups.ts';
import { type FlagKey } from '../cli-grammar/flag-types.ts';
import { REPEATED_TOUCH_FLAGS, SELECTOR_SNAPSHOT_FLAGS } from '../cli-grammar/flag-groups.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { postActionObservationCliFlags } from '../post-action-observation-surface.ts';
import {
commonToClientOptions,
toClientElementTarget,
Expand Down Expand Up @@ -137,20 +129,15 @@ const interactionCliSchemas = {
],
},
scroll: {
usageOverride: 'scroll <direction|top|bottom> [amount] [--pixels <n>] [--duration-ms <ms>]',
usageOverride:
'scroll <direction|top|bottom> [amount] [--pixels <n>] [--duration-ms <ms>] [--settle]',
positionalArgs: ['directionOrEdge', 'amount?'],
allowedFlags: ['pixels', 'durationMs'],
allowedFlags: ['pixels', 'durationMs', ...postActionObservationCliFlags('scroll')],
},
} as const satisfies Record<string, CommandSchemaOverride>;

type InteractionCommandMetadata = (typeof interactionCommandMetadata)[number];
type InteractionCommandName = InteractionCommandMetadata['name'];
function postActionObservationCliFlags(command: InteractionCommandName): readonly FlagKey[] {
const flags: FlagKey[] = [];
if (commandSupportsVerifyEvidence(command)) flags.push('verify');
if (commandSupportsSettleObservation(command)) flags.push(...SETTLE_FLAGS);
return flags;
}

const clickCommandDefinition = defineExecutableCommand(metadata('click'), (client, input) =>
client.interactions.click(toClickOptions(input)),
Expand Down Expand Up @@ -318,6 +305,7 @@ const scrollCommandFacet = defineCommandFacet({
cliSchema: interactionCliSchemas.scroll,
cliReader: interactionCliReaders.scroll,
daemonWriter: interactionDaemonWriters.scroll,
cliOutputFormatter: interactionCliOutputFormatters.scroll,
});

const getCommandFacet = defineCommandFacet({
Expand Down
1 change: 1 addition & 0 deletions src/commands/interaction/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const interactionCliReaders = {
},
scroll: (positionals, flags) => ({
...commonInputFromFlags(flags),
...settleInputFromFlags(flags),
direction: readScrollDirection(positionals[0]),
amount: optionalCliNumber(positionals[1]),
pixels: flags.pixels,
Expand Down
41 changes: 4 additions & 37 deletions src/commands/interaction/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@ import {
type SwipeGesturePayload,
type TransformGesturePayload,
} from '@agent-device/contracts/interaction';
import type { PostActionObservationSupportFor } from '../../core/command-descriptor/post-action-observation.ts';
import {
commandSupportsSettleObservation,
commandSupportsVerifyEvidence,
} from '../../core/command-descriptor/registry.ts';
import { FIND_LOCATORS } from '@agent-device/selectors';
import { defineCommandMetadata } from '../command-contract.ts';
import { postActionObservationFields } from '../post-action-observation-surface.ts';
import {
booleanField,
elementTargetField,
Expand Down Expand Up @@ -70,7 +66,8 @@ const interactionCommandDescriptions = {
focus:
'Move input focus to explicit screen coordinates without entering text. Prefer semantic interactions when a snapshot ref or selector is available; use type or fill after focus.',
type: 'Append text to the currently focused input. Use fill when the existing field value should be replaced, and focus first when no input is active.',
scroll: 'Scroll in a direction, or toward the top/bottom edge of scrollable content.',
scroll:
'Scroll in a direction, or toward the top/bottom edge of scrollable content. Use settle to get the scrolled-into-view diff without a follow-up snapshot.',
get: 'Read text or accessibility attributes from a snapshot ref or selector without changing the app. Use format text for visible content or attrs for the element attribute map.',
is: 'Check whether a selector satisfies a UI predicate such as visible, hidden, editable, selected, focused, or text. Use wait when the condition may appear asynchronously.',
find: 'Find by text/label/value/role/id and run action',
Expand All @@ -80,37 +77,6 @@ const interactionCommandDescriptions = {

type InteractionCommandName = keyof typeof interactionCommandDescriptions;

const verifyField = () =>
booleanField(
'Capture cheap post-action evidence (AX digest, node counts, changedFromBefore) instead of a follow-up snapshot.',
);

const settleFields = () => ({
settle: booleanField(
'After the action, wait for the UI to go quiet and return the settled diff vs the pre-action tree in the same response. Best-effort; never fails the action.',
),
settleQuietMs: integerField('Settle: quiet window in milliseconds (default 500).', { min: 0 }),
timeoutMs: integerField('Settle: wait deadline in milliseconds (default 10000).', { min: 1 }),
});

type VerifyFieldMap = { verify: ReturnType<typeof verifyField> };
type SettleFieldMap = ReturnType<typeof settleFields>;
type PostActionObservationFields<TName extends string> =
PostActionObservationSupportFor<TName> extends 'settle-and-verify'
? VerifyFieldMap & SettleFieldMap
: PostActionObservationSupportFor<TName> extends 'settle'
? SettleFieldMap
: {};

function postActionObservationFields<const TName extends InteractionCommandName>(
command: TName,
): PostActionObservationFields<TName> {
return {
...(commandSupportsVerifyEvidence(command) ? { verify: verifyField() } : {}),
...(commandSupportsSettleObservation(command) ? settleFields() : {}),
} as PostActionObservationFields<TName>;
}

const clickFields = {
target: requiredField(interactionTargetField()),
button: enumField(CLICK_BUTTONS, 'Pointer button for platforms that support mouse buttons.'),
Expand Down Expand Up @@ -170,6 +136,7 @@ const scrollFields = {
min: 0,
max: SCROLL_DURATION_MAX_MS,
}),
...postActionObservationFields('scroll'),
};

// #1271 stage 2 (ADR 0012 amendment): `get`/`is`/`find` are observation-only,
Expand Down
39 changes: 39 additions & 0 deletions src/commands/interaction/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const formatFill = (result: Record<string, unknown>) =>
const formatLongPress = (result: Record<string, unknown>) =>
interactionCliOutputFormatters.longpress({ input: {}, result });

const formatScroll = (result: Record<string, unknown>) =>
interactionCliOutputFormatters.scroll({ input: {}, result });

describe('find CLI output', () => {
test('click prints the same success line as a direct press', () => {
const output = formatFind({
Expand Down Expand Up @@ -275,3 +278,39 @@ describe('longpress CLI output', () => {
);
});
});

describe('scroll CLI output', () => {
test('prints the scroll message alone without a settle observation', () => {
const output = formatScroll({ message: 'Scrolled down' });

expect(output.text).toBe('Scrolled down');
});

// #1638: the generic-route settle renders exactly like the touch commands.
test('appends settle verdict and diff lines when present', () => {
const output = formatScroll({
message: 'Scrolled down',
settle: {
settled: true,
waitedMs: 400,
refsGeneration: 7,
diff: {
summary: { additions: 1, removals: 1, unchanged: 9 },
lines: [
{ kind: 'removed', text: '@e2 [button] "Load more"' },
{ kind: 'added', text: '@e2 [button] "Next page"', ref: 'e2' },
],
},
},
});

expect(output.text).toBe(
[
'Scrolled down',
'settled after 400ms: +1 -1 (~9 unchanged)',
'- @e2 [button] "Load more"',
'+ @e2 [button] "Next page"',
].join('\n'),
);
});
});
89 changes: 4 additions & 85 deletions src/commands/interaction/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CliOutput } from '../command-contract.ts';
import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts';
import { readCommandMessage } from '../../utils/success-text.ts';
import { messageCliOutput, resultOutput, type CliOutputFormatter } from '../output-common.ts';
import { appendResponseNotes, pinnedRefText } from '../settle-output.ts';

function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput {
const data = params.result as Record<string, unknown>;
Expand All @@ -16,17 +17,6 @@ function getCliOutput(params: { result: CommandRequestResult; format?: string })
return defaultCommandCliOutput(data);
}

// ADR 0014: a reusable ref in a PARTIAL result renders in ready-to-copy
// `@eN~s<refsGeneration>` form so a human CLI caller can paste it into the next
// mutation without a separate pin step. A mutating result carries no
// `refsGeneration`, so its acted ref is never pinned.
function pinnedRefText(ref: unknown, refsGeneration: unknown): string | undefined {
if (typeof ref !== 'string' || ref.length === 0) return undefined;
if (typeof refsGeneration !== 'number') return undefined;
const body = ref.startsWith('@') ? ref.slice(1) : ref;
return `@${body}~s${refsGeneration}`;
}

function findCliOutput(result: CommandRequestResult): CliOutput {
const data = result as Record<string, unknown>;
// Interactive find actions (click/fill/focus/type) carry the same success message as
Expand Down Expand Up @@ -90,85 +80,14 @@ function messageWithSettleCliOutput(result: CommandRequestResult): CliOutput {
return { data: output.data, text: appendResponseNotes(output.text, data) };
}

function appendResponseNotes(
text: string | null | undefined,
data: Record<string, unknown>,
): string {
const warning = typeof data.warning === 'string' ? `\nWarning: ${data.warning}` : '';
return `${text ?? ''}${warning}${formatSettleText(data.settle)}`;
}

type SettleTextView = {
settled?: boolean;
waitedMs?: number;
hint?: string;
diff?: {
summary?: { additions?: number; removals?: number; unchanged?: number };
lines?: Array<{ kind?: string; text?: string }>;
truncated?: boolean;
};
tail?: Array<{ ref?: string; role?: string; label?: string }>;
tailTruncated?: boolean;
refsGeneration?: number;
};

/**
* Compact `--settle` (#1101) rendering appended to the tap line: the verdict,
* the changed-count summary, and the changed lines themselves (the payload the
* agent acts on). Empty for non-settle responses.
*/
function formatSettleText(settle: unknown): string {
if (!settle || typeof settle !== 'object') return '';
const view = settle as SettleTextView;
const parts = [
formatSettleVerdict(view),
...formatSettleDiffLines(view.diff),
...formatSettleTailLines(view),
];
if (view.hint) parts.push(`hint: ${view.hint}`);
return `\n${parts.join('\n')}`;
}

function formatSettleDiffLines(diff: SettleTextView['diff']): string[] {
const lines = (diff?.lines ?? []).map(
(line) => `${line.kind === 'removed' ? '-' : '+'} ${line.text ?? ''}`,
);
if (diff?.truncated) lines.push('… changed lines truncated');
return lines;
}

// Unchanged interactive tail: only present when the diff's added lines
// carried zero refs (modal-dismiss/toast-only diff), so the settled tree's
// remaining actionable elements would otherwise be invisible.
function formatSettleTailLines(view: SettleTextView): string[] {
const tail = view.tail ?? [];
if (tail.length === 0) return [];
const lines = [`unchanged interactive (${tail.length}):`];
for (const entry of tail) {
const label = entry.label ? ` "${entry.label}"` : '';
// ADR 0014: the settled tail refs are reusable, so render them pinned when
// the settle response carried its generation.
const ref = pinnedRefText(entry.ref, view.refsGeneration) ?? `@${entry.ref ?? ''}`;
lines.push(`= ${ref} [${entry.role ?? ''}]${label}`);
}
if (view.tailTruncated) {
lines.push('… more interactive elements not shown, use snapshot -i');
}
return lines;
}

function formatSettleVerdict(view: SettleTextView): string {
const verdict = view.settled === true ? 'settled' : 'not settled';
const summary = view.diff?.summary;
if (!summary) return `${verdict} after ${view.waitedMs ?? 0}ms`;
return `${verdict} after ${view.waitedMs ?? 0}ms: +${summary.additions ?? 0} -${summary.removals ?? 0} (~${summary.unchanged ?? 0} unchanged)`;
}

export const interactionCliOutputFormatters = {
click: resultOutput(tapCliOutput),
press: resultOutput(tapCliOutput),
fill: resultOutput(messageWithSettleCliOutput),
longpress: resultOutput(messageWithSettleCliOutput),
// #1638: scroll takes --settle on the generic route, so its line renders the
// settled diff exactly like the touch commands.
scroll: resultOutput(messageWithSettleCliOutput),
get: ({ input, result }) =>
getCliOutput({
result: result as CommandRequestResult,
Expand Down
11 changes: 11 additions & 0 deletions src/commands/interaction/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
type GestureCommandOptions,
type GestureCommandResult,
} from './gesture-command.ts';
import { settleObservationCommand, type SettleObservationCommandOptions } from './settle.ts';
import type { SettleObservation } from '@agent-device/contracts/interaction';

export type SelectorCommands = {
find: RuntimeCommand<FindReadCommandOptions, FindReadCommandResult>;
Expand All @@ -78,6 +80,12 @@ export type InteractionCommands = {
longPress: RuntimeCommand<LongPressCommandOptions, LongPressCommandResult>;
scroll: RuntimeCommand<ScrollCommandOptions, ScrollCommandResult>;
gesture: RuntimeCommand<GestureCommandOptions, GestureCommandResult>;
/**
* `--settle` (#1101) for a caller that resolved no target — the daemon's
* generic scroll/back route (#1638). Every other settle-carrying command gets
* its observation folded into its own result.
*/
observeSettle: RuntimeCommand<SettleObservationCommandOptions, SettleObservation>;
};

export type BoundSelectorCommands = {
Expand Down Expand Up @@ -135,6 +143,7 @@ export type BoundInteractionCommands = {
) => Promise<LongPressCommandResult>;
scroll: BoundRuntimeCommand<ScrollCommandOptions, ScrollCommandResult>;
gesture: BoundRuntimeCommand<GestureCommandOptions, GestureCommandResult>;
observeSettle: BoundRuntimeCommand<SettleObservationCommandOptions, SettleObservation>;
};

export const selectorCommands: SelectorCommands = {
Expand All @@ -158,6 +167,7 @@ export const interactionCommands: InteractionCommands = {
longPress: longPressCommand,
scroll: scrollCommand,
gesture: gestureCommand,
observeSettle: settleObservationCommand,
};

export function bindSelectorCommands(runtime: AgentDeviceRuntime): BoundSelectorCommands {
Expand Down Expand Up @@ -188,6 +198,7 @@ export function bindInteractionCommands(runtime: AgentDeviceRuntime): BoundInter
interactionCommands.longPress(runtime, { ...options, target }),
scroll: (options) => interactionCommands.scroll(runtime, options),
gesture: (options) => interactionCommands.gesture(runtime, options),
observeSettle: (options) => interactionCommands.observeSettle(runtime, options),
};
}

Expand Down
Loading
Loading