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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- New `hover <x y|@ref|selector>` command for `--platform web` (#1783). It moves the pointer over the target without pressing, so hover-gated UI — a message row's `...` toolbar, a menu that opens on pointer enter — becomes reachable through agent-device the way it already was through the underlying `agent-browser` backend (`mouse move`). It is a member of the targeted-touch family: same `@ref`/selector/coordinate targeting, occlusion and off-screen guards, and `--settle` (the settled diff carries the revealed controls with fresh refs, e.g. `+ @e4 [button] "Delete"`), but no `--verify`, since hover reveals rather than activates. `hover @ref` publishes as a portable selector line in recorded scripts, and the Node client exposes `interactions.hover`. Hover is a pointer state that touch platforms do not have, so `capabilities` advertises it on web only and iOS/Android/Linux reject it during admission with `UNSUPPORTED_OPERATION` and a hint naming `--platform web`; `longpress` remains the mobile hold-gesture verb.
- Internal: session recording is now derived from the script-publication lifecycle instead of being stored beside it. `SessionState.recordSession` is removed; `isRecordingPublication` answers the question from the aggregate (ordinary authoring records only while ARMED, a repair transaction records for its whole lifetime). The stored flag was a second source of truth that handler surfaces set directly, which is how #1533's aborted-recording drift arose — no surface can now arm recording without moving the lifecycle that authorizes it, and the script writer's publication gate is answered entirely by the aggregate. Behavior-preserving: the derivation reproduces what the flag held at every transition.
- Fixed: a script recording aborted by a second `open` is no longer published by a later bare `close` (#1533). `open <app> --save-script` followed by a second successful `open` terminates the recording and warns "Script publication was aborted…", and `close --save-script` correctly refuses it with "Retry with plain close; it will tear down the session without writing." But when that second `open` itself carried `--save-script`, the flag re-armed recording behind the terminal status, and a bare `close` then wrote the full session log to disk — publishing a recording the caller had been told was aborted, and breaking the promise the refusal makes. An aborted authoring lifecycle is now terminal by construction: `--save-script` arms nothing on any surface that handles it — the re-open builder, the close finalizer, and the recorded-action ingress — and the script writer refuses the lifecycle from every path that reaches it (bare `close`, teardown, idle-reap, active publication). This also stops an aborted session from paying recording-time costs it can never publish: a re-opened aborted recording no longer keeps the direct iOS selector fast paths for `click` and `get` disabled. Armed recordings, published recordings, and every repair transaction are unaffected.
- `agent-device mcp` now serves the stateless MCP `2026-07-28` revision alongside the handshake-based revisions it already spoke, as the spec's "dual-era server". Modern clients probe `server/discover`, which advertises the supported revisions, the tools capability, and server identity; their requests declare a protocol version in `_meta`, and their results carry `resultType: "complete"` plus `_meta["io.modelcontextprotocol/serverInfo"]`. `tools/list` and `server/discover` now return the `ttlMs`/`cacheScope` cache hints, so a client can cache the 55-tool, ~223KB tool list for an hour instead of re-fetching it on every start; the list was already emitted in a deterministic (sorted) order, which is the other half of what makes it cacheable. Each revision is answered on its own wire contract: a request declaring `2025-11-25` or `2025-06-18` through modern framing still gets the legacy result shape, and `initialize` never agrees to `2026-07-28`, which has no handshake to establish. A declared revision this server does not implement is rejected with `UnsupportedProtocolVersionError` (`-32022`) naming the ones it does, rather than being served under a version the client did not ask for, and modern framing that omits its required `protocolVersion`/`clientCapabilities` metadata — or supplies a `clientInfo` that is not a valid `Implementation` — is rejected as invalid params. `initialize` and `ping` were removed in `2026-07-28`, so a modern-framed call to either is answered `-32601` rather than served inside a `resultType: "complete"` envelope. Responses to legacy clients are unchanged byte-for-byte — `initialize` and `ping` are still served, and no cache, `resultType`, or `_meta` field is added to their results. Nothing here affects the CLI, Node, or daemon surfaces: the stdio transport, the tool set, and every tool's input/output schema are untouched.
Expand Down
6 changes: 4 additions & 2 deletions packages/ad-script/src/internal/script-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ export function isClickLikeCommand(command: string): command is 'click' | 'press
return command === 'click' || command === 'press';
}

export function isTouchTargetCommand(command: string): command is 'click' | 'press' | 'longpress' {
return isClickLikeCommand(command) || command === 'longpress';
export function isTouchTargetCommand(
command: string,
): command is 'click' | 'press' | 'longpress' | 'hover' {
return isClickLikeCommand(command) || command === 'longpress' || command === 'hover';
}

function isTypingCommand(command: string): command is 'type' | 'fill' {
Expand Down
7 changes: 4 additions & 3 deletions packages/ad-script/src/internal/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,10 +463,11 @@ function parseReplayScriptLine(line: string): SessionAction | null {
return action;
}

// wait @ref [timeout] and longpress @ref [durationMs] flow through this
// generic branch: strip recorded generation pins like the branches above.
// wait @ref [timeout], longpress @ref [durationMs], and hover @ref flow
// through this generic branch: strip recorded generation pins like the
// branches above.
action.positionals =
command === 'wait' || command === 'longpress'
command === 'wait' || command === 'longpress' || command === 'hover'
? args.map((token) => stripRecordedRefGeneration(token))
: args;
return action;
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/client-gesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export type LongPressOptions = DeviceCommandBaseOptions &
durationMs?: number;
};

export type HoverOptions = DeviceCommandBaseOptions &
SelectorSnapshotCommandOptions &
InteractionTarget &
SettleCommandOptions;

export type SwipeOptions = DeviceCommandBaseOptions & {
from: { x: number; y: number };
to: { x: number; y: number };
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type {
FillOptions,
FlingOptions,
FocusOptions,
HoverOptions,
LongPressOptions,
PanOptions,
PinchOptions,
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/facades/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export type {
FillCommandResponseData,
FillCommandResult,
FindCommandResponseData,
HoverCommandResponseData,
HoverCommandResult,
InteractionEvidence,
InteractionTarget,
LongPressCommandResponseData,
Expand Down
18 changes: 9 additions & 9 deletions packages/contracts/src/interaction-guarantees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const RUNTIME_TREE_SHARED_GUARANTEES = {
export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPathContract> = {
'runtime-selector': {
description: 'Daemon tree capture, selector chain resolution, guarded coordinate tap.',
commands: ['press', 'click', 'fill', 'longpress'],
commands: ['press', 'click', 'fill', 'longpress', 'hover'],
guarantees: {
...RUNTIME_TREE_SHARED_GUARANTEES,
disambiguation: {
Expand All @@ -221,7 +221,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
'runtime-ref': {
description:
'Session snapshot ref lookup, guarded coordinate tap. #1654: when the caller already resolved the node (a mutating `find`), the lookup is replaced by that node and every guarantee below is enforced against it — the guards are unchanged, only the lookup is skipped.',
commands: ['press', 'click', 'fill', 'longpress'],
commands: ['press', 'click', 'fill', 'longpress', 'hover'],
guarantees: {
...RUNTIME_TREE_SHARED_GUARANTEES,
disambiguation: {
Expand Down Expand Up @@ -361,13 +361,13 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
},
'native-ref': {
// WEB-ONLY in production: apple/android backends never define
// tapTarget/fillTarget - the sole wiring is the web provider's clickRef
// (a stable DOM-handle click). Verified 2026-07-04 while designing the
// #1088 retirement experiment, which this finding dissolved: there is no
// iOS runner round trip to retire.
// tapTarget/fillTarget/hoverTarget - the sole wiring is the web provider's
// clickRef/fillRef/hoverRef (stable DOM-handle actions). Verified
// 2026-07-04 while designing the #1088 retirement experiment, which this
// finding dissolved: there is no iOS runner round trip to retire.
description:
'click @ref / fill @ref dispatch to backend.tapTarget/fillTarget (web provider clickRef only; no mobile backend implements these) without runtime resolution when no non-default options are set. A zero-round-trip preflight (preflightNativeRefInteraction) runs the shared guards against the stored session snapshot node first; no snapshot / no usable rect makes the preflight a no-op.',
commands: ['click', 'fill'],
'click @ref / fill @ref / hover @ref dispatch to backend.tapTarget/fillTarget/hoverTarget (web provider clickRef/fillRef/hoverRef only; no mobile backend implements these) without runtime resolution when no non-default options are set. A zero-round-trip preflight (preflightNativeRefInteraction) runs the shared guards against the stored session snapshot node first; no snapshot / no usable rect makes the preflight a no-op.',
commands: ['click', 'fill', 'hover'],
guarantees: {
disambiguation: {
kind: 'inapplicable',
Expand Down Expand Up @@ -428,7 +428,7 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
},
coordinate: {
description: 'Raw x/y tap. Semantics are intentionally minimal.',
commands: ['press', 'click', 'fill', 'longpress'],
commands: ['press', 'click', 'fill', 'longpress', 'hover'],
guarantees: {
disambiguation: {
kind: 'inapplicable',
Expand Down
20 changes: 20 additions & 0 deletions packages/contracts/src/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,15 @@ export type LongPressCommandResponseData =
| (TouchResponseRef & TouchLongPressExtras)
| (TouchResponseSelector & TouchLongPressExtras);

type TouchHoverExtras = {
gesture: 'hover';
};

export type HoverCommandResponseData =
| (TouchResponsePoint & TouchHoverExtras)
| (TouchResponseRef & TouchHoverExtras)
| (TouchResponseSelector & TouchHoverExtras);

/**
* Internal runtime result for press/click. The daemon response layer turns
* this into `PressCommandResponseData` via `buildInteractionResponseData`.
Expand Down Expand Up @@ -408,6 +417,17 @@ export type LongPressCommandResult = ResolvedInteractionTarget & {
settle?: SettleObservation;
};

/**
* Internal runtime result for hover. The daemon response layer turns this
* into `HoverCommandResponseData` via `buildInteractionResponseData`.
*/
export type HoverCommandResult = ResolvedInteractionTarget & {
backendResult?: Record<string, unknown>;
message?: string;
warning?: string;
settle?: SettleObservation;
};

/**
* Daemon response data for the `find` command. Read-only actions (`exists`,
* `wait`, `get_text`, `get_attrs`) may issue a pinnable ref with
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ export type Interactor = {
tapElementSelector?(selector: ElementSelectorTapOptions): Promise<Record<string, unknown> | void>;
doubleTap(x: number, y: number): Promise<Record<string, unknown> | void>;
longPress(x: number, y: number, durationMs?: number): Promise<Record<string, unknown> | void>;
/**
* Move the pointer to a point without pressing. Only pointer-driven
* platforms (web today) implement it; touch platforms have no hover state
* and leave it undefined, which the `hover` command reports as unsupported.
*/
hover?(x: number, y: number): Promise<Record<string, unknown> | void>;
focus(x: number, y: number): Promise<Record<string, unknown> | void>;
type(text: string, delayMs?: number): Promise<TypeTextBackendResult | void>;
fillElementSelector?(
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/test-utils/property-arbitraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ const REPLAY_SCRIPT_LINE_PLANS = {
.map(([target, button]) => `press ${target} --button ${button}`),
),
longpress: scriptTargetArb.map((target) => `longpress ${target}`),
hover: scriptTargetArb.map((target) => `hover ${target}`),
wait: fc
.tuple(scriptTargetArb, fc.integer({ min: 100, max: 5000 }))
.map(([target, timeout]) => `wait ${target} ${timeout}`),
Expand Down
1 change: 1 addition & 0 deletions src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ export function createAgentDeviceClient(
click: async (options) => await executeCommand('click', options),
press: async (options) => await executeCommand('press', options),
longPress: async (options) => await executeCommand('longpress', options),
hover: async (options) => await executeCommand('hover', options),
swipe: async (options) => await executeCommand('swipe', options),
pan: async (options) => await executeCommand('gesture', panGestureInput(options)),
drag: async (options) => await executeCommand('gesture', dragGestureInput(options)),
Expand Down
5 changes: 5 additions & 0 deletions src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,11 @@ export type AgentDeviceBackend = {
point: Point,
options?: BackendLongPressOptions,
): Promise<BackendActionResult>;
hover?(context: BackendCommandContext, point: Point): Promise<BackendActionResult>;
hoverTarget?(
context: BackendCommandContext,
target: BackendRefTarget,
): Promise<BackendActionResult>;
scroll?(
context: BackendCommandContext,
target: BackendScrollTarget,
Expand Down
3 changes: 2 additions & 1 deletion src/cli/parser/cli-help-overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Start:

Loop:
press|click|fill|longpress <target> ... --settle
hover <target> --settle (web only; reveals hover-gated UI)
scroll <direction|top|bottom> [amount] --settle; back --settle
acts, waits for quiet, and prints the UI diff. Continue from that diff.
Run snapshot -i only when the diff lacks the next target or did not settle.
Expand All @@ -37,7 +38,7 @@ Targets:
Then screenshot, press <x> <y>, and re-snapshot on the changed screen.

Rules:
--settle is only for press/click/fill/longpress/scroll/back; never open,
--settle is only for press/click/fill/longpress/hover/scroll/back; never open,
snapshot, or close. type never accepts --settle.
fill <target> <text> --settle replaces; type <text> appends after focus.
Late network/debounce result: wait text "Expected", not snapshot polling.
Expand Down
4 changes: 3 additions & 1 deletion src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,7 @@ First-slice loop:
agent-device is visible 'label="Welcome"' --platform web
agent-device find text "Welcome" exists --platform web
agent-device click @e12 --platform web
agent-device hover @e14 --settle --platform web
agent-device fill @e13 "qa@example.com" --platform web
agent-device wait text "Welcome" 3000 --platform web
agent-device record start ./artifacts/web-flow.webm --platform web
Expand All @@ -898,7 +899,8 @@ First-slice loop:
agent-device close --platform web

Supported in agent-device web sessions:
open <url>, snapshot -i, get text/attrs, is visible/exists/text, find text/selector, click/press @ref or selector, fill/type @ref or selector, wait text/selector, network dump, audio probe, screenshot, record start/stop with WebM output, close, and replay scripts made from those commands.
open <url>, snapshot -i, get text/attrs, is visible/exists/text, find text/selector, click/press @ref or selector, hover @ref or selector, fill/type @ref or selector, wait text/selector, network dump, audio probe, screenshot, record start/stop with WebM output, close, and replay scripts made from those commands.
hover moves the pointer without pressing so hover-gated UI (row toolbars, menus) appears; use --settle to read what it revealed, then act on the fresh refs. hover @ref hovers the browser element handle directly; pair --settle with a selector or coordinates (web refs carry no geometry, as with click @ref --settle). Web only: touch platforms have no hover state, so hover-gated flows there need a different entry point.

Out of scope for agent-device web support:
Browser runtime debugging, tabs/windows/devtools control, network routing/interception/HAR, storage/cookie management, arbitrary page scripting, downloads/uploads, multi-page orchestration, and agent-browser-specific diagnostics. Use agent-browser directly for those browser-specific workflows.
Expand Down
2 changes: 2 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import type {
FlingOptions,
FocusOptions,
GetOptions,
HoverOptions,
IsOptions,
KeyboardCommandOptions,
Lease,
Expand Down Expand Up @@ -271,6 +272,7 @@ export type AgentDeviceClient = {
click: (options: ClickOptions) => Promise<CommandResult<'click'>>;
press: (options: PressOptions) => Promise<CommandResult<'press'>>;
longPress: (options: LongPressOptions) => Promise<CommandResult<'longpress'>>;
hover: (options: HoverOptions) => Promise<CommandResult<'hover'>>;
swipe: (options: SwipeOptions) => Promise<CommandRequestResult>;
pan: (options: PanOptions) => Promise<CommandRequestResult>;
drag: (options: DragOptions) => Promise<CommandRequestResult>;
Expand Down
37 changes: 37 additions & 0 deletions src/commands/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
FlingOptions,
FocusOptions,
GetOptions,
HoverOptions,
IsOptions,
LongPressOptions,
PanOptions,
Expand Down Expand Up @@ -39,6 +40,7 @@ import {
type FillInput,
type FlingInput,
type GetInput,
type HoverInput,
type LongPressInput,
type PanInput,
type PinchInput,
Expand Down Expand Up @@ -95,6 +97,12 @@ const interactionCliSchemas = {
allowsExtraPositionals: true,
allowedFlags: [...postActionObservationCliFlags('longpress'), ...SELECTOR_SNAPSHOT_FLAGS],
},
hover: {
usageOverride: 'hover <x y|@ref|selector>',
positionalArgs: ['targetOrX', 'y?'],
allowsExtraPositionals: true,
allowedFlags: [...postActionObservationCliFlags('hover'), ...SELECTOR_SNAPSHOT_FLAGS],
},
swipe: {
positionalArgs: ['x1', 'y1', 'x2', 'y2'],
// Arity is enforced by swipePayloadFromPositionals (assertGestureArity), so
Expand Down Expand Up @@ -155,6 +163,10 @@ const longPressCommandDefinition = defineExecutableCommand(metadata('longpress')
client.interactions.longPress(toLongPressOptions(input)),
);

const hoverCommandDefinition = defineExecutableCommand(metadata('hover'), (client, input) =>
client.interactions.hover(toHoverOptions(input)),
);

const swipeCommandDefinition = defineExecutableCommand(metadata('swipe'), (client, input) =>
client.interactions.swipe(input as SwipeOptions),
);
Expand Down Expand Up @@ -259,6 +271,21 @@ const longPressCommandFacet = defineCommandFacet({
cliOutputFormatter: interactionCliOutputFormatters.longpress,
});

const hoverCommandFacet = defineCommandFacet({
name: 'hover',
text: {
summary: 'Hover the pointer over a UI target (web only)',
cliDetail:
'The pointer stays where hover left it: read the revealed UI (--settle or snapshot -i) and act on it before another click or hover moves the pointer away.',
},
metadata: metadata('hover'),
definition: hoverCommandDefinition,
cliSchema: interactionCliSchemas.hover,
cliReader: interactionCliReaders.hover,
daemonWriter: interactionDaemonWriters.hover,
cliOutputFormatter: interactionCliOutputFormatters.hover,
});

const swipeCommandFacet = defineCommandFacet({
name: 'swipe',
text: {
Expand Down Expand Up @@ -369,6 +396,7 @@ export const interactionCommandFamily = defineCommandFamilyFromFacets({
pressCommandFacet,
fillCommandFacet,
longPressCommandFacet,
hoverCommandFacet,
swipeCommandFacet,
focusCommandFacet,
typeCommandFacet,
Expand Down Expand Up @@ -434,6 +462,15 @@ function toLongPressOptions(input: LongPressInput): LongPressOptions {
};
}

function toHoverOptions(input: HoverInput): HoverOptions {
return {
...commonToClientOptions(input),
...toClientInteractionTarget(input.target),
...toSelectorSnapshotOptions(input),
...toSettleOptions(input),
};
}

function toSettleOptions(input: {
settle?: boolean;
settleQuietMs?: number;
Expand Down
Loading
Loading