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.
Files changed (66) hide show
  1. package/dist/interact/android.js +13 -8
  2. package/dist/interact/index.js +33 -22
  3. package/dist/interact/ios.js +5 -2
  4. package/dist/manage/android.js +20 -10
  5. package/dist/manage/ios.js +19 -10
  6. package/dist/observe/android.js +6 -1
  7. package/dist/observe/index.js +22 -5
  8. package/dist/observe/ios.js +7 -2
  9. package/dist/observe/snapshot-metadata.js +94 -15
  10. package/dist/server/common.js +14 -7
  11. package/dist/server/journey-schema.js +60 -0
  12. package/dist/server/tool-definitions.js +59 -6
  13. package/dist/server/tool-handlers.js +358 -55
  14. package/dist/server-core.js +1 -1
  15. package/dist/utils/exec.js +4 -2
  16. package/dist/utils/ios/utils.js +5 -2
  17. package/dist/utils/operation-budget.js +25 -0
  18. package/dist/utils/resolve-device.js +12 -1
  19. package/dist/utils/timing.js +19 -0
  20. package/dist/utils/ui/index.js +2 -0
  21. package/docs/CHANGELOG.md +3 -0
  22. package/docs/benchmarks/interaction-latency/README.md +26 -0
  23. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  24. package/docs/benchmarks/interaction-latency/final.md +17 -0
  25. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  26. package/docs/specs-deltas/018-interaction-latency-and-batched-journeys.md +301 -0
  27. package/docs/tools/interact.md +30 -0
  28. package/package.json +1 -1
  29. package/src/interact/android.ts +13 -8
  30. package/src/interact/index.ts +32 -23
  31. package/src/interact/ios.ts +5 -2
  32. package/src/manage/android.ts +19 -10
  33. package/src/manage/ios.ts +18 -10
  34. package/src/observe/android.ts +5 -1
  35. package/src/observe/index.ts +21 -5
  36. package/src/observe/ios.ts +6 -2
  37. package/src/observe/snapshot-metadata.ts +94 -15
  38. package/src/server/common.ts +13 -6
  39. package/src/server/journey-schema.ts +63 -0
  40. package/src/server/tool-definitions.ts +60 -7
  41. package/src/server/tool-handlers.ts +324 -53
  42. package/src/server-core.ts +1 -1
  43. package/src/types.ts +31 -2
  44. package/src/utils/exec.ts +4 -2
  45. package/src/utils/ios/utils.ts +5 -2
  46. package/src/utils/operation-budget.ts +22 -0
  47. package/src/utils/resolve-device.ts +13 -1
  48. package/src/utils/timing.ts +15 -0
  49. package/src/utils/ui/index.ts +2 -0
  50. package/test/device/automated/latency/benchmark.ts +136 -0
  51. package/test/device/automated/latency/report.ts +6 -0
  52. package/test/fixtures/latency-journey-app/AndroidManifest.xml +6 -0
  53. package/test/fixtures/latency-journey-app/README.md +10 -0
  54. package/test/fixtures/latency-journey-app/build.ts +26 -0
  55. package/test/fixtures/latency-journey-app/src/MainActivity.java +48 -0
  56. package/test/unit/manage/restart_budget.test.ts +37 -0
  57. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  58. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  59. package/test/unit/observe/state_delta.test.ts +15 -0
  60. package/test/unit/server/benchmark_report.test.ts +4 -0
  61. package/test/unit/server/contract.test.ts +1 -1
  62. package/test/unit/server/interaction_modes.test.ts +60 -0
  63. package/test/unit/server/operation_budget.test.ts +32 -0
  64. package/test/unit/server/response_shapes.test.ts +3 -3
  65. package/test/unit/server/run_journey.test.ts +90 -0
  66. 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-debug-mcp",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId)
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
- await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId)
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
- await execAdb(['shell', 'input', 'text', encodedText], deviceId)
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
- await execAdb(['shell', 'input', 'keyevent', '4'], deviceId)
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) }
@@ -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