mobile-debug-mcp 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/interact/android.js +13 -8
- package/dist/interact/index.js +33 -22
- package/dist/interact/ios.js +5 -2
- package/dist/manage/android.js +20 -10
- package/dist/manage/ios.js +19 -10
- package/dist/observe/android.js +6 -1
- package/dist/observe/index.js +22 -5
- package/dist/observe/ios.js +7 -2
- package/dist/observe/snapshot-metadata.js +94 -15
- package/dist/server/common.js +14 -7
- package/dist/server/journey-schema.js +60 -0
- package/dist/server/tool-definitions.js +59 -6
- package/dist/server/tool-handlers.js +358 -55
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +4 -2
- package/dist/utils/ios/utils.js +5 -2
- package/dist/utils/operation-budget.js +25 -0
- package/dist/utils/resolve-device.js +12 -1
- package/dist/utils/timing.js +19 -0
- package/dist/utils/ui/index.js +2 -0
- package/docs/CHANGELOG.md +3 -0
- package/docs/benchmarks/interaction-latency/README.md +26 -0
- package/docs/benchmarks/interaction-latency/final.json +1810 -0
- package/docs/benchmarks/interaction-latency/final.md +17 -0
- package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
- package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
- package/docs/tools/interact.md +30 -0
- package/package.json +1 -1
- package/src/interact/android.ts +13 -8
- package/src/interact/index.ts +32 -23
- package/src/interact/ios.ts +5 -2
- package/src/manage/android.ts +19 -10
- package/src/manage/ios.ts +18 -10
- package/src/observe/android.ts +5 -1
- package/src/observe/index.ts +21 -5
- package/src/observe/ios.ts +6 -2
- package/src/observe/snapshot-metadata.ts +94 -15
- package/src/server/common.ts +13 -6
- package/src/server/journey-schema.ts +63 -0
- package/src/server/tool-definitions.ts +60 -7
- package/src/server/tool-handlers.ts +324 -53
- package/src/server-core.ts +1 -1
- package/src/types.ts +31 -2
- package/src/utils/exec.ts +4 -2
- package/src/utils/ios/utils.ts +5 -2
- package/src/utils/operation-budget.ts +22 -0
- package/src/utils/resolve-device.ts +13 -1
- package/src/utils/timing.ts +15 -0
- package/src/utils/ui/index.ts +2 -0
- package/test/device/automated/latency/benchmark.ts +136 -0
- package/test/device/automated/latency/report.ts +6 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
- package/test/fixtures/latency-journey-app/README.md +10 -0
- package/test/fixtures/latency-journey-app/build.ts +26 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
- package/test/unit/manage/restart_budget.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
- package/test/unit/observe/snapshot_metadata.test.ts +20 -1
- package/test/unit/observe/state_delta.test.ts +15 -0
- package/test/unit/server/benchmark_report.test.ts +4 -0
- package/test/unit/server/contract.test.ts +1 -1
- package/test/unit/server/interaction_modes.test.ts +60 -0
- package/test/unit/server/operation_budget.test.ts +32 -0
- package/test/unit/server/response_shapes.test.ts +3 -3
- package/test/unit/server/run_journey.test.ts +90 -0
- package/test/unit/server/tap_selector.test.ts +68 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Interaction latency benchmark
|
|
2
|
+
|
|
3
|
+
Commit: d3e6ae720db1ad1da294795790689df5a80b56b0 (working tree candidate). Fixture: 1.0, SHA-256 98244deeeba0a21da7d99a3e4b43c97806d6f8a8787ad7fe38249e5d311a6fb6.
|
|
4
|
+
|
|
5
|
+
Environment: Pixel_9_Pro, Android 16, API 36. Three warm-ups and ten measured runs per mode. Failed runs excluded.
|
|
6
|
+
|
|
7
|
+
| Mode | p50 ms | p95 ms | Failures |
|
|
8
|
+
|---|---:|---:|---:|
|
|
9
|
+
| baseline | 124094.50754200004 | 124597.88916599983 | 0 |
|
|
10
|
+
| full | 87262.29954100028 | 88067.6383750001 | 0 |
|
|
11
|
+
| light | 51817.59008300025 | 53873.19891700009 | 0 |
|
|
12
|
+
|
|
13
|
+
First failure by mode: baseline: none; full: none; light: none.
|
|
14
|
+
|
|
15
|
+
Full p95 gate: true. Light p50 gate: true.
|
|
16
|
+
|
|
17
|
+
See final.json for requests, timing components, and failure selectors.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Spec 018 iOS device validation
|
|
2
|
+
|
|
3
|
+
Validated on 27 September 2026 with the booted iPhone Air simulator (iOS 26.4.1).
|
|
4
|
+
|
|
5
|
+
- `npm run test:device` passed all six Android and iOS screenshot, log, and UI-tree smoke checks.
|
|
6
|
+
- Over a spawned stdio MCP server, `start_app` launched Settings with `light`/`compact` and returned a fresh UI observation.
|
|
7
|
+
- A selector-based `tap_element` opened General with `light`/`compact`; its result included an available state delta.
|
|
8
|
+
- `run_journey` used two selector taps to return to General and open About, then asserted a visible `Model Name` label. All three steps passed with `light`/`compact`.
|
|
9
|
+
- A separate `full`/`debug` selector tap returned to General and reported a fresh observation. A `none`/`compact` selector tap reopened About without requesting post-action verification; a separate UI-tree read confirmed the destination.
|
|
10
|
+
|
|
11
|
+
The performance benchmark and its fixture are Android-only. These iOS checks exercised the interaction contract on a system app; they did not measure latency on an iOS app with network requests.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# RFC 018 — Interaction Latency, Verification Modes, and Batched Journeys
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Implemented; Android and iOS device validation passed.
|
|
6
|
+
|
|
7
|
+
## 1. Summary
|
|
8
|
+
|
|
9
|
+
This RFC reduces mobile-debug MCP round-trip latency while retaining an explicit path to the current verification guarantees.
|
|
10
|
+
|
|
11
|
+
It adds optional execution controls to action tools, compact responses, selector-based tapping, state-delta observations, and a batched `run_journey` tool. Existing callers remain compatible: omitted controls retain the current full verification and detailed response behavior.
|
|
12
|
+
|
|
13
|
+
## 2. Problem
|
|
14
|
+
|
|
15
|
+
The current path performs more device work and returns more data than most interaction turns need.
|
|
16
|
+
|
|
17
|
+
- `start_app` captures a screen fingerprint before and after launch, in addition to the launch adapter's current-screen observation.
|
|
18
|
+
- Coordinate actions capture fingerprints before and after dispatch.
|
|
19
|
+
- `tap_element` captures a fingerprint, re-reads the full UI tree to revalidate a stored handle, dispatches, then captures another fingerprint.
|
|
20
|
+
- `capture_debug_snapshot` always starts a screenshot collection, even when the caller needs only hierarchy or logs.
|
|
21
|
+
- A normal agent journey requires separate resolve, tap, wait, expect, and snapshot calls, each with a protocol round trip and repeated device metadata.
|
|
22
|
+
|
|
23
|
+
These checks are valuable when diagnosing an ambiguous result. They are unnecessary for every known-safe, immediately-followed step, and they make the server appear slow even when device execution is prompt.
|
|
24
|
+
|
|
25
|
+
## 3. Goals
|
|
26
|
+
|
|
27
|
+
1. Make the latency of `start_app`, `tap_element`, `wait_for_ui`, `capture_screenshot`, and `capture_debug_snapshot` measurable and reportable.
|
|
28
|
+
2. Let callers choose `none`, `light`, or `full` verification per interaction without weakening the compatibility default.
|
|
29
|
+
3. Make normal responses small and use `debug` only when detailed traces and device metadata are needed.
|
|
30
|
+
4. Resolve and tap a selector in one server request.
|
|
31
|
+
5. Run ordered interaction journeys in one device session and stop at the first failed assertion.
|
|
32
|
+
6. Return changed accessibility nodes rather than repeated complete trees when a prior snapshot is available.
|
|
33
|
+
7. Use UI evidence as soon as it is available; do not add a fixed settling delay after a fresh expected state is observed.
|
|
34
|
+
|
|
35
|
+
## 4. Non-goals
|
|
36
|
+
|
|
37
|
+
- Changing the meaning of existing `expect_*` tools or their stabilization rules.
|
|
38
|
+
- Inferring app-specific expected outcomes when the caller has not supplied one.
|
|
39
|
+
- Removing `capture_screenshot` or the detailed debug snapshot.
|
|
40
|
+
- Bypassing target actionability checks for selector-based actions.
|
|
41
|
+
- Making `none` appropriate for actions with an asserted business outcome.
|
|
42
|
+
- Reporting benchmark figures without a controlled device run.
|
|
43
|
+
|
|
44
|
+
## 5. Compatibility and defaults
|
|
45
|
+
|
|
46
|
+
All new inputs are optional. For every existing action request:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
verificationMode = "full"
|
|
50
|
+
responseMode = "debug"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
is the effective behavior. Existing response fields, including action envelopes, fingerprints, device metadata, and traces, remain available in that mode. No existing tool is removed or renamed.
|
|
54
|
+
|
|
55
|
+
New clients SHOULD use `responseMode: "compact"` and select the least costly verification mode that satisfies their next decision. `full` remains required for a final assertion unless that assertion is separately expressed as an `expect_*` or journey assertion.
|
|
56
|
+
|
|
57
|
+
## 6. Common interaction controls
|
|
58
|
+
|
|
59
|
+
The following optional properties are added to `start_app`, `restart_app`, `tap`, `tap_element`, `swipe`, `scroll_to_element`, `type_text`, and `press_back`. A tool that cannot apply a field MUST reject it with `INVALID_ARGUMENT`, rather than silently changing its semantics.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
verificationMode?: "none" | "light" | "full" // default: "full"
|
|
63
|
+
responseMode?: "compact" | "debug" // default: "debug"
|
|
64
|
+
actionTimeoutMs?: number // adapter dispatch limit
|
|
65
|
+
verificationTimeoutMs?: number // verification budget
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Values are finite integers. `actionTimeoutMs` is clamped to 100–30,000 ms, and `verificationTimeoutMs` to 100–60,000 ms. The server reports effective values in debug responses. Platform adapters MUST receive and enforce `actionTimeoutMs`; it must not be a cosmetic field.
|
|
69
|
+
|
|
70
|
+
Unless supplied, the effective dispatch budget is 5,000 ms and the effective light-verification budget is 3,000 ms. `start_app` has a 10,000 ms dispatch default because launch includes an adapter-owned foreground check. `restart_app` has a 15,000 ms total dispatch budget: at most 5,000 ms for termination and the remaining budget, capped at 10,000 ms, for launch. `scroll_to_element` has a 10,000 ms total dispatch budget; the budget covers every scroll and resolve attempt, so no attempt may start after its deadline. Existing `full` calls retain their present adapter and stabilization behavior when the fields are omitted; a supplied budget bounds only work introduced by this RFC.
|
|
71
|
+
|
|
72
|
+
The server creates one deadline at dispatch start and passes its remaining duration to each nested operation. It cancels local polling and child processes when the deadline expires. A timeout before the command is issued is `ACTION_TIMEOUT`, `success: false`, `delivery_status: "not_delivered"`, and `retryable: true`. A timeout after dispatch begins is `ACTION_TIMEOUT`, `success: false`, `delivery_status: "unknown"`, and `retryable: false`: the device may have acted, so the client MUST observe state before retrying. The response includes `dispatch_started: boolean` and elapsed timing in both response modes.
|
|
73
|
+
|
|
74
|
+
### 6.1 Verification modes
|
|
75
|
+
|
|
76
|
+
| Mode | Required work | Result meaning |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `none` | Resolve only when required to dispatch; issue the command. No post-dispatch UI read. | `success` means the adapter accepted the action. |
|
|
79
|
+
| `light` | Dispatch, then take one fresh accessibility-tree observation. | `success` means delivery succeeded; `state_delta` is best-effort evidence, not a passed assertion. |
|
|
80
|
+
| `full` | Preserve the current tool's pre/post evidence, stale-reference checks, stabilization, and trace behavior. | Existing meaning is unchanged. |
|
|
81
|
+
|
|
82
|
+
`light` MUST not wait for an arbitrary settling interval. It MAY poll only until the first fresh observation arrives, bounded by `verificationTimeoutMs`. If the observation cannot be obtained in time, return `success: true`, `verification.status: "unavailable"`, and a retryable verification diagnostic; do not recast a delivered action as a dispatch failure. `verification.status: "unavailable"` is therefore a successful action with incomplete evidence, never a failed action.
|
|
83
|
+
|
|
84
|
+
`none` and `light` return `lifecycle_state: "pending_verification"` after delivery. `full` retains current lifecycle-state behavior.
|
|
85
|
+
|
|
86
|
+
### 6.2 Response modes
|
|
87
|
+
|
|
88
|
+
`debug` preserves the existing response shape. It includes device metadata, full action trace, fingerprints, resolved target detail, timing breakdown, and adapter diagnostics when present.
|
|
89
|
+
|
|
90
|
+
`compact` returns only:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
{
|
|
94
|
+
action_id: string,
|
|
95
|
+
action_type: string,
|
|
96
|
+
success: boolean,
|
|
97
|
+
lifecycle_state: "pending_verification" | "verified" | "failed",
|
|
98
|
+
failure_code?: string,
|
|
99
|
+
retryable?: boolean,
|
|
100
|
+
delivery_status?: "delivered" | "not_delivered" | "unknown",
|
|
101
|
+
dispatch_started: boolean,
|
|
102
|
+
verification?: { mode: "none" | "light" | "full", status: "not_requested" | "observed" | "verified" | "unavailable" | "failed" },
|
|
103
|
+
state_delta?: StateDelta,
|
|
104
|
+
timing: { total_ms: number }
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The compact form MUST retain failure and retry information. It MUST NOT emit device identifiers, full tree contents, raw adapter output, screenshots, fingerprints, or trace steps. A client can repeat an action only with a new `responseMode: "debug"`; it cannot expand a past compact result.
|
|
109
|
+
|
|
110
|
+
## 7. Selector-based tap
|
|
111
|
+
|
|
112
|
+
`tap_element` accepts exactly one of the existing `elementId` or a new `selector`; this does not change the `elementId` path.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
selector?: {
|
|
116
|
+
text?: string,
|
|
117
|
+
resource_id?: string,
|
|
118
|
+
accessibility_id?: string,
|
|
119
|
+
contains?: boolean
|
|
120
|
+
}
|
|
121
|
+
waitFor?: {
|
|
122
|
+
condition?: "exists" | "visible" | "clickable",
|
|
123
|
+
timeoutMs?: number, // default 1,500; range 100–10,000
|
|
124
|
+
pollIntervalMs?: number // default 100; range 50–1,000
|
|
125
|
+
match?: { index?: number }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
With a selector, the server resolves a fresh UI tree, verifies that the selected node is actionable, and taps it in the same request. `waitFor` means wait for the target before resolving; it is not post-tap verification. Without `waitFor`, resolution occurs once and fails with `ELEMENT_NOT_FOUND`, `AMBIGUOUS_TARGET`, or `ELEMENT_NOT_INTERACTABLE` as applicable. A selector match must use the same matching and index semantics as `wait_for_ui`.
|
|
130
|
+
|
|
131
|
+
When `verificationMode: "full"`, selector resolution replaces the separate client resolve call but does not skip required post-action verification. The resolved element returned in debug mode includes only the selected node, never the complete tree.
|
|
132
|
+
|
|
133
|
+
## 8. State-delta snapshots and screenshot policy
|
|
134
|
+
|
|
135
|
+
The snapshot metadata layer already tracks stable node identities and aggregate change counts. It is extended to retain the prior normalized node record by device and expose changed nodes.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
type StateDelta = {
|
|
139
|
+
base_snapshot_revision: number | null,
|
|
140
|
+
snapshot_revision: number,
|
|
141
|
+
added: UIElement[],
|
|
142
|
+
removed: Array<{ stable_id?: string, resourceId?: string, contentDescription?: string, text?: string, index: number }>,
|
|
143
|
+
changed: UIElement[],
|
|
144
|
+
truncated: boolean
|
|
145
|
+
omitted_changes?: number
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
For each `(platform, deviceId)`, the server retains normalized node records for the latest eight revisions or ten minutes, whichever expires first. It retains at most 500 nodes per revision. A delta contains at most 200 changed nodes; additional changes set `truncated: true` and report `omitted_changes`. Revision state is process-local and is discarded on server restart, device disconnect, or retention expiry.
|
|
150
|
+
|
|
151
|
+
`get_ui_tree` adds `responseMode` and `sinceSnapshotRevision`:
|
|
152
|
+
|
|
153
|
+
- `responseMode: "debug"` remains the complete tree response.
|
|
154
|
+
- `responseMode: "compact"` with a matching `sinceSnapshotRevision` returns `state_delta`; it does not include unchanged nodes.
|
|
155
|
+
- `responseMode: "compact"` without `sinceSnapshotRevision` returns `{ delta_available: false, snapshot_revision }` and no nodes. The caller obtains a base with a debug tree.
|
|
156
|
+
- If the requested base revision is unavailable, return `{ delta_available: false, snapshot_revision, reason: "BASE_REVISION_UNAVAILABLE" }` and no nodes. The client recovers by requesting a debug tree, then resumes from its returned revision.
|
|
157
|
+
- A truncated delta is valid only as a change signal. The client MUST request a debug tree before using it to make a target-selection decision.
|
|
158
|
+
|
|
159
|
+
For a `light` action, the server takes exactly one post-dispatch tree. It computes `state_delta` against the most recent retained revision for the same device, if one exists; otherwise it returns `state_delta: undefined` and `state_delta_available: false`. It never takes a pre-dispatch tree merely to create a delta. The returned `base_snapshot_revision` tells the client whether its local state can safely apply that delta.
|
|
160
|
+
|
|
161
|
+
Within `run_journey`, an immediately following `element_visible` assertion may supply that fresh post-dispatch tree for a `light` tap. The tap reports `verification.status: "observed"` when the assertion returns a matched count, even if the assertion fails; it reports `"unavailable"` with a retryable diagnostic if the observation cannot be confirmed.
|
|
162
|
+
|
|
163
|
+
`capture_debug_snapshot` adds `includeScreenshot?: boolean`, defaulting to `true` for compatibility. New compact callers MUST set it explicitly; `includeScreenshot: false` collects no screenshot. `capture_screenshot` remains the explicit screenshot tool. No interaction tool captures a screenshot unless its request explicitly asks for one through its observation configuration.
|
|
164
|
+
|
|
165
|
+
## 9. `run_journey`
|
|
166
|
+
|
|
167
|
+
`run_journey` runs a bounded ordered sequence against one resolved platform and device. It avoids MCP round trips; it does not hold a cross-request device lock or conceal failures.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
{
|
|
171
|
+
platform: "android" | "ios",
|
|
172
|
+
deviceId?: string,
|
|
173
|
+
responseMode?: "compact" | "debug", // default compact
|
|
174
|
+
captureOnFailure?: boolean, // default false
|
|
175
|
+
defaults?: { verificationMode?: "none" | "light" | "full", actionTimeoutMs?: number, verificationTimeoutMs?: number },
|
|
176
|
+
steps: Array<
|
|
177
|
+
{ id: string, type: "start_app", appId: string, verificationMode?: VerificationMode } |
|
|
178
|
+
{ id: string, type: "wait", selector: Selector, condition?: "exists" | "not_exists" | "visible" | "clickable", timeoutMs?: number, pollIntervalMs?: number } |
|
|
179
|
+
{ id: string, type: "tap", selector?: Selector, elementId?: string, waitFor?: WaitFor, verificationMode?: VerificationMode } |
|
|
180
|
+
{ id: string, type: "assert", assertion: ElementVisibleAssertion | ElementAbsentAssertion | ScreenFingerprintAssertion | StateEqualsAssertion, timeoutMs?: number, pollIntervalMs?: number }
|
|
181
|
+
>
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
type ElementVisibleAssertion = { kind: "element_visible", selector: Selector }
|
|
187
|
+
type ElementAbsentAssertion = { kind: "element_absent", selector: Selector }
|
|
188
|
+
type ScreenFingerprintAssertion = { kind: "screen_fingerprint", fingerprint: string }
|
|
189
|
+
type StateEqualsAssertion = {
|
|
190
|
+
kind: "state_equals",
|
|
191
|
+
property: string,
|
|
192
|
+
expected: boolean | number | string,
|
|
193
|
+
selector?: Selector,
|
|
194
|
+
elementId?: string
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Step IDs must be unique. Maximum steps: 50. A journey validates its complete schema before device work begins. `tap` requires exactly one target. `state_equals` requires exactly one of `selector` or `elementId`; every other assertion rejects irrelevant fields. `start_app` is the journey equivalent of `start_app`.
|
|
199
|
+
|
|
200
|
+
Journey assertions use a 5,000 ms timeout and 100 ms poll interval unless supplied; their ranges are 100–60,000 ms and 50–1,000 ms. `element_visible` is the existing `expect_element_visible` predicate. `element_absent` is a new journey-only predicate: zero matching nodes must be seen in two fresh trees at least 100 ms apart, within the assertion timeout. `screen_fingerprint` matches the current fingerprint on two consecutive reads 100 ms apart. `state_equals` uses the existing `expect_state` stabilization contract, with the journey's supplied or default timeout and poll interval applied as that contract's budget and observation interval; it does not add a second two-read rule. Assertions return `ASSERTION_TIMEOUT` on budget expiry and include the last observed value or matched count. A `wait` remains synchronization, not proof of a business outcome.
|
|
201
|
+
|
|
202
|
+
The journey resolves the platform and device once before the first step and passes that exact context to every step. An `elementId` is valid only when its stored platform and device ID equal the journey context. A mismatch is `STALE_REFERENCE`, no tap is dispatched, and the journey stops.
|
|
203
|
+
|
|
204
|
+
The response is:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
{
|
|
208
|
+
success: boolean,
|
|
209
|
+
stopped_at_step_id: string | null,
|
|
210
|
+
steps: Array<ExecutedJourneyStep | NotRunJourneyStep>,
|
|
211
|
+
timing: TimingBreakdown
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type ExecutedJourneyStep = {
|
|
215
|
+
id: string, type: string, status: "passed" | "failed",
|
|
216
|
+
success: boolean, result: CompactOrDebugStepResult, timing: TimingBreakdown
|
|
217
|
+
}
|
|
218
|
+
type NotRunJourneyStep = {
|
|
219
|
+
id: string, type: string, status: "not_run", success: false,
|
|
220
|
+
reason: "PREVIOUS_STEP_FAILED", result: null, timing: { total_ms: 0 }
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
It stops immediately after a failed assertion, failed wait, target-resolution failure, or dispatch failure. Later steps have the `NotRunJourneyStep` shape and are never attempted. An action with `verification.status: "unavailable"` has `status: "passed"` because dispatch succeeded; a following assertion decides whether its intended outcome occurred. Debug mode includes the failing step's trace and a snapshot only when `captureOnFailure: true` is passed at journey level. The failure snapshot has `includeScreenshot: false` unless the caller separately requests a screenshot.
|
|
225
|
+
|
|
226
|
+
## 10. Timing measurement and reporting
|
|
227
|
+
|
|
228
|
+
Every specified tool records monotonic durations; wall clock is used only for timestamps. The timing schema is additive:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
type TimingBreakdown = {
|
|
232
|
+
total_ms: number,
|
|
233
|
+
device_resolution_ms?: number,
|
|
234
|
+
pre_observation_ms?: number,
|
|
235
|
+
target_resolution_ms?: number,
|
|
236
|
+
dispatch_ms?: number,
|
|
237
|
+
post_observation_ms?: number,
|
|
238
|
+
verification_ms?: number,
|
|
239
|
+
screenshot_ms?: number,
|
|
240
|
+
serialization_ms?: number
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`debug` returns the full applicable breakdown. `compact` returns `total_ms`; `run_journey` additionally returns each step's `total_ms`. Errors still return elapsed timing.
|
|
245
|
+
|
|
246
|
+
A checked-in benchmark script must emit JSON and Markdown to `docs/benchmarks/interaction-latency/`; reports contain commit SHA, server version, platform, device model/OS, app build identifier, sample count, warm-up policy, verification/response modes, and p50/p95/min/max for each timing component. Raw data must contain no screenshots, UI text, app package identifiers, or device serials.
|
|
247
|
+
|
|
248
|
+
## 11. Benchmark protocol and acceptance criteria
|
|
249
|
+
|
|
250
|
+
The repository owns an Android benchmark fixture at `test/fixtures/latency-journey-app/`. It is self-contained, has no network dependency, and exposes stable accessibility IDs for a repeatable navigation-and-control flow. The benchmark harness builds and installs the fixture.
|
|
251
|
+
|
|
252
|
+
Before every run, the harness must force-stop the fixture, clear its data, install the known fixture APK, and verify the reset-state `saved-session` selector. The benchmark runs against one named Android emulator image, records its API level and fixture APK SHA-256, and performs no work in parallel. It executes this exact flow:
|
|
253
|
+
|
|
254
|
+
```text
|
|
255
|
+
launch app → open detail → activate control → return Home → reopen detail → change control state → stop
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The fixture and benchmark harness MUST define and use their own stable selector map, and assert each resulting state. It performs 3 warm-up runs and 10 measured runs per mode.
|
|
259
|
+
|
|
260
|
+
The baseline invokes the current public tools over a spawned `dist/server.js` stdio MCP connection: `start_app`, `wait_for_ui`, `tap_element` with a handle returned by `wait_for_ui`, and the existing `expect_*` tools. The candidate invokes the new selector `tap_element` and `run_journey`. Both variants include the same waits/assertions and use `full`/`debug` for the compatibility comparison; the candidate additionally runs `light`/`compact`. End-to-end latency is measured by the benchmark client from immediately before its JSON-RPC request write until the corresponding response is fully read, excluding server process startup. The harness separately times explicit `capture_screenshot` and `capture_debug_snapshot` calls outside the journey.
|
|
261
|
+
|
|
262
|
+
A failed run is reported separately and excluded from percentile calculation; the report must state its failure count, first failed fixture selector, and failed tool/step. The harness emits the exact request sequence and fixture version in the report, with selectors but no UI text or serial number.
|
|
263
|
+
|
|
264
|
+
Acceptance gates:
|
|
265
|
+
|
|
266
|
+
- `full` has no regression greater than 10% at p95 for the representative journey and preserves all existing contract tests.
|
|
267
|
+
- `light` compact reduces p50 end-to-end MCP journey latency by at least 30% versus full/debug on the same benchmark environment.
|
|
268
|
+
- `none` compact has no post-dispatch accessibility read, proven by a focused unit test and its timing breakdown.
|
|
269
|
+
- A compact state delta never includes an unchanged node; a requested screenshot is the only path that captures screenshot bytes.
|
|
270
|
+
- The benchmark report is checked in for baseline and final runs. If environmental variance prevents the 30% threshold, implementation stops for diagnosis rather than relaxing verification semantics or claiming success.
|
|
271
|
+
|
|
272
|
+
## 12. Test plan
|
|
273
|
+
|
|
274
|
+
Add focused unit coverage for:
|
|
275
|
+
|
|
276
|
+
1. Every verification mode on `tap_element` and `start_app`, including the exact adapter/observation calls and lifecycle/result semantics.
|
|
277
|
+
2. Compact/debug compatibility: omitted fields produce the legacy debug envelope; compact retains actionable failure fields and omits verbose metadata.
|
|
278
|
+
3. Selector tap success, wait-until-present success, ambiguity, stale/non-actionable rejection, and timeout.
|
|
279
|
+
4. `run_journey` ordering, single platform/device resolution, first failed assertion, unexecuted remaining steps, and per-step timing.
|
|
280
|
+
5. Delta added/removed/changed identity behavior, revision miss, truncation, and no screenshot when disabled.
|
|
281
|
+
6. Action and verification timeout bounds and the absence of a fixed delay once a fresh expected state is observed.
|
|
282
|
+
7. Deterministic benchmark report aggregation from fixture timing data.
|
|
283
|
+
|
|
284
|
+
Existing unit, lint, build, and device checks remain required. Run the real benchmark only after the automated contract gates pass.
|
|
285
|
+
|
|
286
|
+
## 13. Implementation ownership
|
|
287
|
+
|
|
288
|
+
- `src/server/tool-definitions.ts`: additive schemas and documentation.
|
|
289
|
+
- `src/server/tool-handlers.ts` and `src/server/common.ts`: mode parsing, response shaping, timing envelope, and journey orchestration.
|
|
290
|
+
- `src/interact/index.ts`: selector resolve-and-tap, verification policy, deadline propagation, and step execution primitives.
|
|
291
|
+
- `src/observe/snapshot-metadata.ts` and `src/observe/index.ts`: changed-node deltas and explicit screenshot collection.
|
|
292
|
+
- `src/manage/*`: action-timeout propagation and truthful launch timing.
|
|
293
|
+
- `test/unit/{server,interact,observe}/`: contract coverage; `test/device/automated/`: benchmark journey harness.
|
|
294
|
+
|
|
295
|
+
## 14. Rollout
|
|
296
|
+
|
|
297
|
+
1. Land timing instrumentation and baseline benchmark without changing defaults.
|
|
298
|
+
2. Land verification/response modes with `full`/`debug` defaults and compatibility tests.
|
|
299
|
+
3. Land selector tap and state deltas.
|
|
300
|
+
4. Land `run_journey` and run before/after device benchmarks.
|
|
301
|
+
5. Publish the report and recommend `light`/`compact` for routine agent navigation only after the acceptance gates pass.
|
package/docs/tools/interact.md
CHANGED
|
@@ -616,3 +616,33 @@ Guidance:
|
|
|
616
616
|
- `uiChanged=true` or `expectedElementVisible=true` means the action outcome is already verified
|
|
617
617
|
- local-state actions should prefer refreshed snapshots, `expect_state`, or `expect_element_visible` over default network inspection
|
|
618
618
|
- network activity is auxiliary evidence, not mandatory proof
|
|
619
|
+
|
|
620
|
+
## Interaction execution controls (RFC 018)
|
|
621
|
+
|
|
622
|
+
`start_app`, `restart_app`, `tap`, `tap_element`, `swipe`, `scroll_to_element`,
|
|
623
|
+
`type_text`, and `press_back` accept `verificationMode` (`none`, `light`, `full`)
|
|
624
|
+
and `responseMode` (`compact`, `debug`). Omitted controls retain `full`/`debug`.
|
|
625
|
+
`none` reports dispatch without post-action accessibility reads. `light` requests
|
|
626
|
+
one fresh tree; an unavailable observation does not turn successful delivery into
|
|
627
|
+
a failed action. Use an explicit assertion to establish the intended outcome.
|
|
628
|
+
Compact responses retain delivery, retry, lifecycle, and timing fields.
|
|
629
|
+
|
|
630
|
+
`actionTimeoutMs` and `verificationTimeoutMs` are integer budgets, clamped to
|
|
631
|
+
100–30,000 and 100–60,000 ms respectively. An action timeout after dispatch starts
|
|
632
|
+
has unknown delivery and must be observed before retrying.
|
|
633
|
+
|
|
634
|
+
`tap_element` accepts exactly one `elementId` or `selector`. A selector supports
|
|
635
|
+
`text`, `resource_id`, `accessibility_id`, and `contains`. Optional `waitFor` waits
|
|
636
|
+
for the target before tapping; without it, resolution uses one observation.
|
|
637
|
+
Ambiguous targets require an explicit `waitFor.match.index`.
|
|
638
|
+
|
|
639
|
+
`run_journey` accepts 1–50 steps with unique IDs, on one platform/device. Supported
|
|
640
|
+
step types are `start_app`, `tap`, `wait`, and `assert`. Assertions support
|
|
641
|
+
`element_visible`, `element_absent`, `screen_fingerprint`, and `state_equals`.
|
|
642
|
+
The complete request is validated before device work. The first failed step stops
|
|
643
|
+
the journey; remaining steps report `not_run`. Journey responses default to compact.
|
|
644
|
+
`defaults` supplies execution controls; step verification overrides the default.
|
|
645
|
+
`captureOnFailure: true` includes a screenshot-free failure snapshot in debug mode.
|
|
646
|
+
|
|
647
|
+
See [RFC 018](../specs-deltas/018-interaction-latency-and-batched-journeys.md) for
|
|
648
|
+
contracts and the device acceptance criteria.
|
package/package.json
CHANGED
package/src/interact/android.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markDispatchStarted } from '../utils/operation-budget.js'
|
|
1
2
|
import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
3
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
4
|
import { AndroidObserve } from "../observe/index.js"
|
|
@@ -7,31 +8,33 @@ import { scrollToElementShared } from "../utils/ui/index.js"
|
|
|
7
8
|
export class AndroidInteract {
|
|
8
9
|
private observe = new AndroidObserve();
|
|
9
10
|
|
|
10
|
-
async tap(x: number, y: number, deviceId?: string): Promise<TapResponse> {
|
|
11
|
+
async tap(x: number, y: number, deviceId?: string, timeoutMs?: number): Promise<TapResponse> {
|
|
11
12
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
12
13
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
13
14
|
|
|
14
15
|
try {
|
|
15
|
-
|
|
16
|
+
markDispatchStarted()
|
|
17
|
+
await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId, { timeout: timeoutMs })
|
|
16
18
|
return { device: deviceInfo, success: true, x, y }
|
|
17
19
|
} catch (e) {
|
|
18
20
|
return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) }
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string): Promise<SwipeResponse> {
|
|
24
|
+
async swipe(x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string, timeoutMs?: number): Promise<SwipeResponse> {
|
|
23
25
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
24
26
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
25
27
|
|
|
26
28
|
try {
|
|
27
|
-
|
|
29
|
+
markDispatchStarted()
|
|
30
|
+
await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId, { timeout: timeoutMs })
|
|
28
31
|
return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration }
|
|
29
32
|
} catch (e) {
|
|
30
33
|
return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) }
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
async typeText(text: string, deviceId?: string): Promise<TypeTextResponse> {
|
|
37
|
+
async typeText(text: string, deviceId?: string, timeoutMs?: number): Promise<TypeTextResponse> {
|
|
35
38
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
36
39
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
37
40
|
|
|
@@ -39,19 +42,21 @@ export class AndroidInteract {
|
|
|
39
42
|
// Encode spaces as %s to ensure proper input handling by adb shell input text
|
|
40
43
|
const encodedText = text.replace(/\s/g, '%s')
|
|
41
44
|
// Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
|
|
42
|
-
|
|
45
|
+
markDispatchStarted()
|
|
46
|
+
await execAdb(['shell', 'input', 'text', encodedText], deviceId, { timeout: timeoutMs })
|
|
43
47
|
return { device: deviceInfo, success: true, text }
|
|
44
48
|
} catch (e) {
|
|
45
49
|
return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) }
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
async pressBack(deviceId?: string): Promise<PressBackResponse> {
|
|
53
|
+
async pressBack(deviceId?: string, timeoutMs?: number): Promise<PressBackResponse> {
|
|
50
54
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
51
55
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
52
56
|
|
|
53
57
|
try {
|
|
54
|
-
|
|
58
|
+
markDispatchStarted()
|
|
59
|
+
await execAdb(['shell', 'input', 'keyevent', '4'], deviceId, { timeout: timeoutMs })
|
|
55
60
|
return { device: deviceInfo, success: true }
|
|
56
61
|
} catch (e) {
|
|
57
62
|
return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) }
|
package/src/interact/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
1
2
|
import { createHash } from 'crypto'
|
|
2
3
|
import { AndroidInteract } from './android.js';
|
|
3
4
|
import { iOSInteract } from './ios.js';
|
|
@@ -1226,22 +1227,22 @@ export class ToolsInteract {
|
|
|
1226
1227
|
return { interact: interact as any, resolved, platform: effectivePlatform }
|
|
1227
1228
|
}
|
|
1228
1229
|
|
|
1229
|
-
static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
|
|
1230
|
+
static async tapHandler({ platform, x, y, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string, timeoutMs?: number }) {
|
|
1230
1231
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
1231
|
-
return await interact.tap(x, y, resolved.id)
|
|
1232
|
+
return await interact.tap(x, y, resolved.id, timeoutMs)
|
|
1232
1233
|
}
|
|
1233
1234
|
|
|
1234
|
-
static async tapElementHandler({ elementId }: { elementId: string }): Promise<TapElementResponse> {
|
|
1235
|
+
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }: { elementId: string, verificationMode?: 'none' | 'light' | 'full', timeoutMs?: number, platform?: 'android' | 'ios', deviceId?: string, freshTree?: any }): Promise<TapElementResponse> {
|
|
1235
1236
|
const actionType = 'tap_element'
|
|
1236
1237
|
const selector = { elementId }
|
|
1237
1238
|
const resolved = ToolsInteract._resolvedUiElements.get(elementId)
|
|
1238
|
-
if (!resolved) {
|
|
1239
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) {
|
|
1239
1240
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null)
|
|
1240
1241
|
}
|
|
1241
1242
|
|
|
1242
|
-
const fingerprintBefore = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1243
|
+
const fingerprintBefore = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1243
1244
|
|
|
1244
|
-
const tree = await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
|
|
1245
|
+
const tree = freshTree ?? await ToolsObserve.getUITreeHandler({ platform: resolved.platform, deviceId: resolved.deviceId }) as any
|
|
1245
1246
|
const treePlatform = tree?.device?.platform === 'ios' ? 'ios' : resolved.platform
|
|
1246
1247
|
const treeDeviceId = tree?.device?.id || resolved.deviceId
|
|
1247
1248
|
const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
|
|
@@ -1272,14 +1273,14 @@ export class ToolsInteract {
|
|
|
1272
1273
|
|
|
1273
1274
|
const x = Math.floor((bounds[0] + bounds[2]) / 2)
|
|
1274
1275
|
const y = Math.floor((bounds[1] + bounds[3]) / 2)
|
|
1275
|
-
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId })
|
|
1276
|
+
const tapResult = await ToolsInteract.tapHandler({ platform: resolved.platform, x, y, deviceId: resolved.deviceId, timeoutMs })
|
|
1276
1277
|
|
|
1277
1278
|
if (!tapResult.success) {
|
|
1278
|
-
const fingerprintAfterFailure = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1279
|
-
return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure)
|
|
1279
|
+
const fingerprintAfterFailure = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1280
|
+
return { ...ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'UNKNOWN', false, fingerprintBefore, fingerprintAfterFailure), error: tapResult.error }
|
|
1280
1281
|
}
|
|
1281
1282
|
|
|
1282
|
-
const fingerprintAfter = await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId)
|
|
1283
|
+
const fingerprintAfter = verificationMode === 'full' ? await ToolsInteract._captureFingerprint(resolved.platform, resolved.deviceId) : null
|
|
1283
1284
|
return buildActionExecutionResult({
|
|
1284
1285
|
actionType,
|
|
1285
1286
|
device: tree?.device,
|
|
@@ -2204,19 +2205,19 @@ export class ToolsInteract {
|
|
|
2204
2205
|
return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter)
|
|
2205
2206
|
}
|
|
2206
2207
|
|
|
2207
|
-
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
|
|
2208
|
+
static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId, timeoutMs }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string, timeoutMs?: number }) {
|
|
2208
2209
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
2209
|
-
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id)
|
|
2210
|
+
return await interact.swipe(x1, y1, x2, y2, duration, resolved.id, timeoutMs)
|
|
2210
2211
|
}
|
|
2211
2212
|
|
|
2212
|
-
static async typeTextHandler({ text, deviceId }: { text: string, deviceId?: string }) {
|
|
2213
|
+
static async typeTextHandler({ text, deviceId, timeoutMs }: { text: string, deviceId?: string, timeoutMs?: number }) {
|
|
2213
2214
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
2214
|
-
return await new AndroidInteract().typeText(text, resolved.id)
|
|
2215
|
+
return await new AndroidInteract().typeText(text, resolved.id, timeoutMs)
|
|
2215
2216
|
}
|
|
2216
2217
|
|
|
2217
|
-
static async pressBackHandler({ deviceId }: { deviceId?: string }) {
|
|
2218
|
+
static async pressBackHandler({ deviceId, timeoutMs }: { deviceId?: string, timeoutMs?: number }) {
|
|
2218
2219
|
const resolved = await resolveTargetDevice({ platform: 'android', deviceId })
|
|
2219
|
-
return await new AndroidInteract().pressBack(resolved.id)
|
|
2220
|
+
return await new AndroidInteract().pressBack(resolved.id, timeoutMs)
|
|
2220
2221
|
}
|
|
2221
2222
|
|
|
2222
2223
|
static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }: { platform: 'android' | 'ios', selector: { text?: string, resourceId?: string, contentDesc?: string, className?: string }, direction?: 'down' | 'up', maxScrolls?: number, scrollAmount?: number, deviceId?: string }) {
|
|
@@ -2490,7 +2491,7 @@ export class ToolsInteract {
|
|
|
2490
2491
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal, resolution }
|
|
2491
2492
|
}
|
|
2492
2493
|
|
|
2493
|
-
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string }) {
|
|
2494
|
+
static async waitForUIHandler({ selector, condition = 'exists', timeout_ms = 60000, poll_interval_ms = 300, match, retry = { max_attempts: 1, backoff_ms: 0 }, platform, deviceId, singleObservation = false, singleAttempt = false, rejectAmbiguous = false }: { selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean }, condition?: 'exists'|'not_exists'|'visible'|'clickable', timeout_ms?: number, poll_interval_ms?: number, match?: { index?: number }, retry?: { max_attempts?: number, backoff_ms?: number }, platform?: 'android'|'ios', deviceId?: string, singleObservation?: boolean, singleAttempt?: boolean, rejectAmbiguous?: boolean }) {
|
|
2494
2495
|
const overallStart = Date.now()
|
|
2495
2496
|
const requestedIndex = typeof match?.index === 'number' ? match.index : null
|
|
2496
2497
|
const requested = {
|
|
@@ -2538,7 +2539,7 @@ export class ToolsInteract {
|
|
|
2538
2539
|
let lastConditionSatisfied = false
|
|
2539
2540
|
let matchedAt: number | null = null
|
|
2540
2541
|
let stableMatchCount = 0
|
|
2541
|
-
const stableObservationCount = 2
|
|
2542
|
+
const stableObservationCount = singleObservation ? 1 : 2
|
|
2542
2543
|
const snapshotStaleThresholdMs = 500
|
|
2543
2544
|
|
|
2544
2545
|
// Precompute normalized selector values and helpers (constant across polls)
|
|
@@ -2554,7 +2555,8 @@ export class ToolsInteract {
|
|
|
2554
2555
|
const attemptStart = Date.now()
|
|
2555
2556
|
const deadline = attemptStart + (timeout_ms || 0)
|
|
2556
2557
|
|
|
2557
|
-
while (Date.now() <= deadline) {
|
|
2558
|
+
while (Date.now() <= deadline || (singleAttempt && totalPollCount === 0)) {
|
|
2559
|
+
remainingBudget()
|
|
2558
2560
|
totalPollCount++
|
|
2559
2561
|
try {
|
|
2560
2562
|
const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
|
|
@@ -2601,6 +2603,7 @@ export class ToolsInteract {
|
|
|
2601
2603
|
|
|
2602
2604
|
// Evaluate condition
|
|
2603
2605
|
const matchedCount = matches.length
|
|
2606
|
+
if (rejectAmbiguous && matchedCount > 1 && match?.index === undefined) return { status: 'timeout', error: { code: 'AMBIGUOUS_TARGET' } }
|
|
2604
2607
|
const pickIndex = (typeof match?.index === 'number') ? match!.index as number : undefined
|
|
2605
2608
|
let chosen: { el: any, idx: number } | null = null
|
|
2606
2609
|
if (matches.length > 0) {
|
|
@@ -2659,6 +2662,7 @@ export class ToolsInteract {
|
|
|
2659
2662
|
|
|
2660
2663
|
return {
|
|
2661
2664
|
status: 'success',
|
|
2665
|
+
...(singleObservation ? { _tree: tree } : {}),
|
|
2662
2666
|
matched: matchedCount,
|
|
2663
2667
|
element: outEl,
|
|
2664
2668
|
metrics: { latency_ms, poll_count: totalPollCount, attempts },
|
|
@@ -2677,10 +2681,12 @@ export class ToolsInteract {
|
|
|
2677
2681
|
}
|
|
2678
2682
|
|
|
2679
2683
|
} catch (e) {
|
|
2684
|
+
if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
|
|
2680
2685
|
// Non-fatal per-poll error; record and continue
|
|
2681
2686
|
console.warn('waitForUI: poll error (non-fatal):', e instanceof Error ? e.message : String(e))
|
|
2682
2687
|
}
|
|
2683
2688
|
|
|
2689
|
+
if (singleAttempt) break
|
|
2684
2690
|
// Sleep until next poll
|
|
2685
2691
|
await new Promise(r => setTimeout(r, effectivePoll || 50))
|
|
2686
2692
|
}
|
|
@@ -2712,6 +2718,7 @@ export class ToolsInteract {
|
|
|
2712
2718
|
}
|
|
2713
2719
|
|
|
2714
2720
|
} catch (err) {
|
|
2721
|
+
if (err instanceof Error && err.message === 'ACTION_TIMEOUT') throw err
|
|
2715
2722
|
const elapsed = Date.now() - overallStart
|
|
2716
2723
|
return {
|
|
2717
2724
|
status: 'timeout',
|
|
@@ -3139,7 +3146,8 @@ export class ToolsInteract {
|
|
|
3139
3146
|
stabilization_window_ms = 1000,
|
|
3140
3147
|
stable_observation_count = 2,
|
|
3141
3148
|
snapshot_stale_threshold_ms = 500,
|
|
3142
|
-
poll_interval_ms = 150
|
|
3149
|
+
poll_interval_ms = 150,
|
|
3150
|
+
exact_budget = false
|
|
3143
3151
|
}: {
|
|
3144
3152
|
selector?: { text?: string, resource_id?: string, accessibility_id?: string, contains?: boolean },
|
|
3145
3153
|
element_id?: string,
|
|
@@ -3150,16 +3158,17 @@ export class ToolsInteract {
|
|
|
3150
3158
|
stabilization_window_ms?: number,
|
|
3151
3159
|
stable_observation_count?: number,
|
|
3152
3160
|
snapshot_stale_threshold_ms?: number,
|
|
3153
|
-
poll_interval_ms?: number
|
|
3161
|
+
poll_interval_ms?: number,
|
|
3162
|
+
exact_budget?: boolean
|
|
3154
3163
|
}): Promise<ExpectStateResponse> {
|
|
3155
3164
|
const compareBoolean = (value: unknown) => typeof value === 'boolean' ? value : null
|
|
3156
3165
|
const compareString = (value: unknown) => typeof value === 'string' ? value : null
|
|
3157
3166
|
const compareNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
3158
3167
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
3159
3168
|
const start = Date.now()
|
|
3160
|
-
const deadline = start + Math.max(500, stabilization_window_ms)
|
|
3169
|
+
const deadline = start + (exact_budget ? stabilization_window_ms : Math.max(500, stabilization_window_ms))
|
|
3161
3170
|
const stableTarget = Math.max(1, Math.floor(stable_observation_count || 2))
|
|
3162
|
-
const pollDelay = Math.max(100, Math.min(poll_interval_ms || 150, 200))
|
|
3171
|
+
const pollDelay = exact_budget ? Math.max(50, Math.min(poll_interval_ms || 150, 1000)) : Math.max(100, Math.min(poll_interval_ms || 150, 200))
|
|
3163
3172
|
const staleThreshold = Math.max(300, Math.min(snapshot_stale_threshold_ms || 500, 800))
|
|
3164
3173
|
|
|
3165
3174
|
let attempts = 0
|