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
@@ -1,3 +1,4 @@
1
+ import { remainingBudget, markDispatchStarted } from '../utils/operation-budget.js'
1
2
  import { spawn } from "child_process"
2
3
  import { TapResponse, SwipeResponse } from "../types.js"
3
4
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
@@ -31,7 +32,8 @@ export class iOSInteract {
31
32
  }
32
33
 
33
34
  await new Promise<void>((resolve, reject) => {
34
- const proc = spawn(getIdbCmd(), args);
35
+ markDispatchStarted()
36
+ const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
35
37
  let stderr = '';
36
38
  proc.stderr.on('data', d => stderr += d.toString());
37
39
  proc.on('close', code => {
@@ -72,7 +74,8 @@ export class iOSInteract {
72
74
  }
73
75
 
74
76
  await new Promise<void>((resolve, reject) => {
75
- const proc = spawn(getIdbCmd(), args);
77
+ markDispatchStarted()
78
+ const proc = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
76
79
  let stderr = '';
77
80
  proc.stderr.on('data', d => stderr += d.toString());
78
81
  proc.on('close', code => {
@@ -1,3 +1,4 @@
1
+ import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
1
2
  import { promises as fs } from 'fs'
2
3
  import { spawn } from 'child_process'
3
4
  import path from 'path'
@@ -149,16 +150,18 @@ export class AndroidManage {
149
150
  }
150
151
  }
151
152
 
152
- async startApp(appId: string, deviceId?: string): Promise<StartAppResponse> {
153
+ async startApp(appId: string, deviceId?: string, timeoutMs?: number): Promise<StartAppResponse> {
154
+ const started = performance.now()
153
155
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
154
156
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
155
157
  try {
156
- const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
158
+ markDispatchStarted()
159
+ const output = await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId, { timeout: timeoutMs })
157
160
  const current = await new AndroidObserve().getCurrentScreen(deviceId).catch(() => null)
158
161
  return {
159
162
  device: deviceInfo,
160
163
  appStarted: true,
161
- launchTimeMs: 1000,
164
+ launchTimeMs: performance.now() - started,
162
165
  output,
163
166
  observedApp: {
164
167
  appId,
@@ -169,8 +172,8 @@ export class AndroidManage {
169
172
  }
170
173
  }
171
174
  } catch (e: unknown) {
172
- const diag = execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId)
173
- return { device: deviceInfo, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
175
+ const diagnostics = timeoutMs === undefined ? execAdbWithDiagnostics(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId) : undefined
176
+ return { device: deviceInfo, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
174
177
  }
175
178
  }
176
179
 
@@ -178,17 +181,22 @@ export class AndroidManage {
178
181
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
179
182
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
180
183
  try {
184
+ markDispatchStarted()
181
185
  await execAdb(['shell', 'am', 'force-stop', appId], deviceId)
182
186
  return { device: deviceInfo, appTerminated: true }
183
187
  } catch (e: unknown) {
184
- const diag = execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
185
- return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag }
188
+ const diagnostics = hasFiniteBudget() ? undefined : execAdbWithDiagnostics(['shell', 'am', 'force-stop', appId], deviceId)
189
+ return { device: deviceInfo, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) }
186
190
  }
187
191
  }
188
192
 
189
- async restartApp(appId: string, deviceId?: string): Promise<RestartAppResponse> {
190
- const terminateResult = await this.terminateApp(appId, deviceId)
191
- const startResult = await this.startApp(appId, deviceId)
193
+ async restartApp(appId: string, deviceId?: string, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
194
+ const started = performance.now()
195
+ const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(appId, deviceId)) : await this.terminateApp(appId, deviceId)
196
+ const remaining = timeoutMs - (performance.now() - started)
197
+ if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
198
+ const launchBudget = remainingBudget(remaining)
199
+ const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(appId, deviceId, remainingBudget(launchBudget))) : await this.startApp(appId, deviceId)
192
200
  return {
193
201
  device: startResult.device,
194
202
  appRestarted: startResult.appStarted,
@@ -206,6 +214,7 @@ export class AndroidManage {
206
214
  const metadata = await getAndroidDeviceMetadata(appId, deviceId)
207
215
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
208
216
  try {
217
+ markDispatchStarted()
209
218
  const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId)
210
219
  return { device: deviceInfo, dataCleared: output === 'Success' }
211
220
  } catch (e: unknown) {
package/src/manage/ios.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { hasFiniteBudget, markDispatchStarted, remainingBudget, runWithBudget } from '../utils/operation-budget.js'
1
2
  import { promises as fs } from "fs"
2
3
  import { spawn, spawnSync } from "child_process"
3
4
  import { StartAppResponse, TerminateAppResponse, RestartAppResponse, ResetAppDataResponse, InstallAppResponse } from "../types.js"
@@ -310,17 +311,19 @@ export class iOSManage {
310
311
  }
311
312
  }
312
313
 
313
- async startApp(bundleId: string, deviceId: string = "booted"): Promise<StartAppResponse> {
314
+ async startApp(bundleId: string, deviceId: string = "booted", observe = true): Promise<StartAppResponse> {
315
+ const started = performance.now()
314
316
  validateBundleId(bundleId)
315
317
  try {
318
+ markDispatchStarted()
316
319
  const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId)
317
320
  const device = await getIOSDeviceMetadata(deviceId)
318
- const fingerprint = await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null)
321
+ const fingerprint = observe ? await new iOSObserve().getScreenFingerprint(deviceId).catch(() => null) : null
319
322
  const pidMatch = result.output.match(/:\s*(\d+)\s*$/)
320
323
  return {
321
324
  device,
322
325
  appStarted: !!result.output,
323
- launchTimeMs: 1000,
326
+ launchTimeMs: performance.now() - started,
324
327
  output: result.output,
325
328
  observedApp: {
326
329
  appId: bundleId,
@@ -330,28 +333,33 @@ export class iOSManage {
330
333
  }
331
334
  }
332
335
  } catch (e: unknown) {
333
- const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
334
336
  const device = await getIOSDeviceMetadata(deviceId)
335
- return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
337
+ const diagnostics = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId)
338
+ return { device, appStarted: false, launchTimeMs: performance.now() - started, error: e instanceof Error ? e.message : String(e), ...(diagnostics ? { diagnostics } : {}) } as any
336
339
  }
337
340
  }
338
341
 
339
342
  async terminateApp(bundleId: string, deviceId: string = "booted"): Promise<TerminateAppResponse> {
340
343
  validateBundleId(bundleId)
341
344
  try {
345
+ markDispatchStarted()
342
346
  await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId)
343
347
  const device = await getIOSDeviceMetadata(deviceId)
344
348
  return { device, appTerminated: true }
345
349
  } catch (e: unknown) {
346
- const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
350
+ const diag = hasFiniteBudget() ? undefined : execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId)
347
351
  const device = await getIOSDeviceMetadata(deviceId)
348
- return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag } as any
352
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), ...(diag ? { diagnostics: diag } : {}) } as any
349
353
  }
350
354
  }
351
355
 
352
- async restartApp(bundleId: string, deviceId: string = "booted"): Promise<RestartAppResponse> {
353
- const terminateResult = await this.terminateApp(bundleId, deviceId)
354
- const startResult = await this.startApp(bundleId, deviceId)
356
+ async restartApp(bundleId: string, deviceId: string = "booted", observe = true, bounded = false, timeoutMs = 15000): Promise<RestartAppResponse> {
357
+ const started = performance.now()
358
+ const terminateResult = bounded ? await runWithBudget(Math.min(5000, timeoutMs), () => this.terminateApp(bundleId, deviceId)) : await this.terminateApp(bundleId, deviceId)
359
+ const remaining = timeoutMs - (performance.now() - started)
360
+ if (bounded && remaining <= 0) throw new Error('ACTION_TIMEOUT')
361
+ const launchBudget = remainingBudget(remaining)
362
+ const startResult = bounded ? await runWithBudget(launchBudget, () => this.startApp(bundleId, deviceId, observe)) : await this.startApp(bundleId, deviceId, observe)
355
363
  return {
356
364
  device: startResult.device,
357
365
  appRestarted: startResult.appStarted,
@@ -8,6 +8,7 @@ import path from "path"
8
8
  import { computeScreenFingerprint } from "../utils/ui/index.js"
9
9
  import { parsePngSize } from "../utils/image.js"
10
10
  import { deriveSnapshotMetadata } from "./snapshot-metadata.js"
11
+ import { remainingBudget } from '../utils/operation-budget.js'
11
12
 
12
13
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
13
14
 
@@ -33,10 +34,12 @@ export class AndroidObserve {
33
34
  const maxAttempts = 3;
34
35
 
35
36
  while (attempts < maxAttempts) {
37
+ remainingBudget()
36
38
  attempts++;
37
39
  try {
38
40
  // Stabilization delay
39
- await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
41
+ await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))); // 300ms, 400ms, 500ms...
42
+ remainingBudget()
40
43
 
41
44
  // Dump UI hierarchy
42
45
  await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
@@ -49,6 +52,7 @@ export class AndroidObserve {
49
52
  break; // Success
50
53
  }
51
54
  } catch (e) {
55
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
52
56
  console.error(`Attempt ${attempts} failed: ${e}`);
53
57
  }
54
58
 
@@ -1,3 +1,4 @@
1
+ import { measure } from '../utils/timing.js'
1
2
  import { resolveTargetDevice } from '../utils/resolve-device.js'
2
3
  import { AndroidObserve } from './android.js'
3
4
  import { iOSObserve } from './ios.js'
@@ -5,7 +6,7 @@ import type {
5
6
  CaptureDebugSnapshotRawResponse,
6
7
  SnapshotSemanticResponse
7
8
  } from '../types.js'
8
- import { deriveSnapshotMetadata } from './snapshot-metadata.js'
9
+ import { deriveSnapshotMetadata, getLatestStateDelta, getStateDelta } from './snapshot-metadata.js'
9
10
 
10
11
  export { AndroidObserve } from './android.js'
11
12
  export { iOSObserve } from './ios.js'
@@ -149,6 +150,9 @@ function deriveSnapshotSemantic(raw: CaptureDebugSnapshotRawResponse): SnapshotS
149
150
  }
150
151
 
151
152
  export class ToolsObserve {
153
+ static getLatestStateDelta(platform: 'android' | 'ios', deviceId: string) {
154
+ return getLatestStateDelta(`${platform}:${deviceId}`)
155
+ }
152
156
  // Resolve a target device and return the appropriate observe instance and resolved info.
153
157
  private static async resolveObserve(platform?: 'android' | 'ios', deviceId?: string, appId?: string) {
154
158
  if (platform === 'android') {
@@ -170,9 +174,21 @@ export class ToolsObserve {
170
174
  }
171
175
  }
172
176
 
173
- static async getUITreeHandler({ platform, deviceId }: { platform?: 'android' | 'ios', deviceId?: string }) {
177
+ static async getUITreeHandler({ platform, deviceId, responseMode = 'debug', sinceSnapshotRevision }: { platform?: 'android' | 'ios', deviceId?: string, responseMode?: 'compact' | 'debug', sinceSnapshotRevision?: number }) {
174
178
  const { observe, resolved } = await ToolsObserve.resolveObserve(platform, deviceId)
175
- return await observe.getUITree(resolved.id)
179
+ const tree = await observe.getUITree(resolved.id) as any
180
+ if (responseMode !== 'compact') return tree
181
+ const revision = tree?.snapshot_revision ?? null
182
+ const baseRevision = sinceSnapshotRevision
183
+ if (!Number.isInteger(baseRevision) || revision === null) {
184
+ return { delta_available: false, snapshot_revision: revision }
185
+ }
186
+ const delta = getStateDelta(`${resolved.platform}:${resolved.id}`, baseRevision as number, revision)
187
+ return delta ? { delta_available: true, state_delta: delta, snapshot_revision: revision } : {
188
+ delta_available: false,
189
+ snapshot_revision: revision,
190
+ reason: 'BASE_REVISION_UNAVAILABLE'
191
+ }
176
192
  }
177
193
 
178
194
  static async getCurrentScreenHandler({ deviceId }: { deviceId?: string }) {
@@ -244,7 +260,7 @@ export class ToolsObserve {
244
260
  return await (observe as any).getScreenFingerprint(resolved.id)
245
261
  }
246
262
 
247
- static async captureDebugSnapshotHandler({ reason, includeLogs = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
263
+ static async captureDebugSnapshotHandler({ reason, includeLogs = true, includeScreenshot = true, logLines = 200, platform, appId, deviceId, sessionId }: { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string } = {}) {
248
264
  const timestamp = Date.now()
249
265
  const raw: CaptureDebugSnapshotRawResponse = {
250
266
  timestamp,
@@ -261,7 +277,7 @@ export class ToolsObserve {
261
277
  // Parallel fetches for performance: screenshot, current screen, fingerprint, ui tree, and log stream/get logs
262
278
  const sid = sessionId || 'default'
263
279
  const tasks: Record<string, Promise<any>> = {
264
- screenshot: ToolsObserve.captureScreenshotHandler({ platform, deviceId }),
280
+ screenshot: includeScreenshot ? measure('screenshot_ms', () => ToolsObserve.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
265
281
  currentScreen: (!platform || platform === 'android') ? ToolsObserve.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
266
282
  fingerprint: ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }),
267
283
  uiTree: ToolsObserve.getUITreeHandler({ platform, deviceId }),
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "child_process"
2
+ import { remainingBudget } from '../utils/operation-budget.js'
2
3
  import { promises as fs } from "fs"
3
4
  import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
4
5
  import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
@@ -547,9 +548,11 @@ export class iOSObserve {
547
548
  const maxAttempts = 3;
548
549
 
549
550
  while (attempts < maxAttempts) {
551
+ remainingBudget()
550
552
  attempts++;
551
553
  try {
552
- await delay(300 + (attempts * 100));
554
+ await delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100)));
555
+ remainingBudget()
553
556
 
554
557
  const args = ['ui', 'describe-all', '--json'];
555
558
  if (targetUdid) {
@@ -557,7 +560,7 @@ export class iOSObserve {
557
560
  }
558
561
 
559
562
  const output = await new Promise<string>((resolve, reject) => {
560
- const child = spawn(getIdbCmd(), args);
563
+ const child = spawn(getIdbCmd(), args, { timeout: remainingBudget() || undefined });
561
564
  let stdout = '';
562
565
  let stderr = '';
563
566
 
@@ -580,6 +583,7 @@ export class iOSObserve {
580
583
  break; // Success
581
584
  }
582
585
  } catch (e) {
586
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
583
587
  console.error(`Attempt ${attempts} failed: ${e}`);
584
588
  }
585
589
 
@@ -5,8 +5,15 @@ interface SnapshotState {
5
5
  revision: number
6
6
  signature: string | null
7
7
  elementSignatures: Map<string, string>
8
+ elements: Map<string, UIElement>
9
+ history: Map<number, Map<string, UIElement>>
10
+ historyTimes: Map<number, number>
11
+ oversized: Map<number, number>
12
+ previousRevision: number | null
13
+ updatedAt: number
8
14
  }
9
15
 
16
+ let nextRevision = 1
10
17
  const snapshotStateByDevice = new Map<string, SnapshotState>()
11
18
 
12
19
  function normalize(value: unknown): string {
@@ -53,18 +60,20 @@ function stableElementIdentity(element: UIElement, index: number) {
53
60
  })).digest('hex')}`
54
61
  }
55
62
 
56
- function buildElementSignatures(tree: Pick<GetUITreeResponse, 'elements'> | null | undefined) {
63
+ function buildElementRecords(tree: Pick<GetUITreeResponse, 'elements'> | null | undefined) {
57
64
  const signatures = new Map<string, string>()
65
+ const elementsByIdentity = new Map<string, UIElement>()
58
66
  const elements = Array.isArray(tree?.elements) ? tree!.elements! : []
59
67
 
60
- for (let index = 0; index < elements.length; index++) {
68
+ for (let index = 0; index < Math.min(elements.length, 500); index++) {
61
69
  const element = elements[index]
62
70
  if (!element) continue
63
71
  const identity = stableElementIdentity(element, index)
64
72
  signatures.set(identity, crypto.createHash('sha1').update(JSON.stringify(stableElementSignature(element))).digest('hex'))
73
+ elementsByIdentity.set(identity, structuredClone(element))
65
74
  }
66
75
 
67
- return signatures
76
+ return { signatures, elementsByIdentity }
68
77
  }
69
78
 
70
79
  function summarizeSnapshotDelta(previous: SnapshotState | undefined, currentElements: Map<string, string>): SnapshotDelta | null {
@@ -140,25 +149,45 @@ export function deriveSnapshotMetadata(
140
149
  signatureOverride?: string | null
141
150
  ) {
142
151
  const signature = signatureOverride ?? computeSnapshotSignature(tree)
143
- const previous = snapshotStateByDevice.get(deviceKey)
152
+ const existing = snapshotStateByDevice.get(deviceKey)
153
+ const previous = existing && Date.now() - existing.updatedAt <= 10 * 60 * 1000 ? existing : undefined
154
+ if (!previous && existing) snapshotStateByDevice.delete(deviceKey)
144
155
  const hasValidTree = !!tree && !tree.error
145
- const currentElementSignatures = hasValidTree
146
- ? buildElementSignatures(tree)
147
- : previous?.elementSignatures ?? new Map<string, string>()
148
-
149
- let revision = 1
150
- if (previous) {
151
- if (signature === null) {
152
- revision = previous.revision
153
- } else {
154
- revision = previous.signature === signature ? previous.revision : previous.revision + 1
156
+ const records = hasValidTree ? buildElementRecords(tree) : null
157
+ const currentElementSignatures = records?.signatures ?? previous?.elementSignatures ?? new Map<string, string>()
158
+ const currentElements = records?.elementsByIdentity ?? previous?.elements ?? new Map<string, UIElement>()
159
+
160
+ const revision = previous && (signature === null || previous.signature === signature) ? previous.revision : nextRevision++
161
+ const history = previous?.history ?? new Map<number, Map<string, UIElement>>()
162
+ const historyTimes = previous?.historyTimes ?? new Map<number, number>()
163
+ const oversized = previous?.oversized ?? new Map<number, number>()
164
+ if (hasValidTree) {
165
+ history.set(revision, currentElements)
166
+ if (!historyTimes.has(revision)) historyTimes.set(revision, Date.now())
167
+ if ((tree.elements?.length ?? 0) > 500) oversized.set(revision, (tree.elements?.length ?? 0) - 500)
168
+ }
169
+ for (const [id, timestamp] of historyTimes) {
170
+ if (Date.now() - timestamp > 10 * 60 * 1000) {
171
+ history.delete(id)
172
+ historyTimes.delete(id)
173
+ oversized.delete(id)
155
174
  }
156
175
  }
176
+ while (history.size > 8) {
177
+ const oldest = history.keys().next().value
178
+ if (oldest === undefined) break
179
+ history.delete(oldest)
180
+ historyTimes.delete(oldest)
181
+ oversized.delete(oldest)
182
+ }
157
183
 
158
184
  snapshotStateByDevice.set(deviceKey, {
159
185
  revision,
160
186
  signature,
161
- elementSignatures: currentElementSignatures
187
+ elementSignatures: currentElementSignatures,
188
+ elements: currentElements,
189
+ history, historyTimes, oversized, previousRevision: previous?.revision ?? null,
190
+ updatedAt: Date.now()
162
191
  })
163
192
 
164
193
  return {
@@ -169,6 +198,56 @@ export function deriveSnapshotMetadata(
169
198
  }
170
199
  }
171
200
 
201
+ export function getStateDelta(deviceKey: string, baseRevision: number, currentRevision: number) {
202
+ const state = snapshotStateByDevice.get(deviceKey)
203
+ if (!state || Date.now() - (state.historyTimes.get(baseRevision) ?? 0) > 10 * 60 * 1000 || Date.now() - state.updatedAt > 10 * 60 * 1000) return null
204
+ const base = state.history.get(baseRevision)
205
+ const current = state?.history.get(currentRevision)
206
+ if (!base || !current) return null
207
+
208
+ const added: UIElement[] = []
209
+ const changed: UIElement[] = []
210
+ const removed: Array<{ stable_id?: string, resourceId?: string, contentDescription?: string, text?: string, index: number }> = []
211
+ for (const [identity, element] of current) {
212
+ const before = base.get(identity)
213
+ if (!before) added.push(element)
214
+ else if (JSON.stringify(stableElementSignature(before)) !== JSON.stringify(stableElementSignature(element))) changed.push(element)
215
+ }
216
+ let index = 0
217
+ for (const [identity, element] of base) {
218
+ if (!current.has(identity)) removed.push({ stable_id: element.stable_id ?? undefined, resourceId: element.resourceId ?? undefined, contentDescription: element.contentDescription ?? undefined, text: element.text ?? undefined, index })
219
+ index++
220
+ }
221
+ const all = added.length + changed.length + removed.length
222
+ const limit = 200
223
+ return {
224
+ base_snapshot_revision: baseRevision,
225
+ snapshot_revision: currentRevision,
226
+ added: added.slice(0, limit),
227
+ changed: changed.slice(0, Math.max(0, limit - added.length)),
228
+ removed: removed.slice(0, Math.max(0, limit - added.length - changed.length)),
229
+ truncated: all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision),
230
+ ...((all > limit || state.oversized.has(baseRevision) || state.oversized.has(currentRevision))
231
+ ? { omitted_changes: Math.max(all - limit, state.oversized.get(baseRevision) ?? 0, state.oversized.get(currentRevision) ?? 0) } : {})
232
+ }
233
+ }
234
+
235
+ export function getLatestStateDelta(deviceKey: string) {
236
+ const state = snapshotStateByDevice.get(deviceKey)
237
+ if (!state || state.previousRevision === null) return null
238
+ return getStateDelta(deviceKey, state.previousRevision, state.revision)
239
+ }
240
+
241
+ export function retainConnectedDeviceSnapshots(platform: string, deviceIds: string[]) {
242
+ const keys = new Set(deviceIds.map(id => `${platform}:${id}`))
243
+ for (const key of snapshotStateByDevice.keys()) if (key.startsWith(`${platform}:`) && !keys.has(key)) snapshotStateByDevice.delete(key)
244
+ }
245
+
246
+ export function clearDeviceSnapshots(deviceKey: string) {
247
+ snapshotStateByDevice.delete(deviceKey)
248
+ }
249
+
172
250
  export function resetSnapshotMetadataForTests() {
173
251
  snapshotStateByDevice.clear()
252
+ nextRevision = 1
174
253
  }
@@ -1,3 +1,4 @@
1
+ import { recordTiming } from '../utils/timing.js'
1
2
  import type {
2
3
  ActionTrace,
3
4
  ActionExecutionResult,
@@ -14,13 +15,17 @@ import { ToolsObserve } from '../observe/index.js'
14
15
  export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
15
16
  export const DEFAULT_MAX_RETRY_DEPTH = 3
16
17
 
17
- export function wrapResponse<T>(data: T) {
18
- return {
19
- content: [{
20
- type: 'text' as const,
21
- text: JSON.stringify(data, null, 2)
22
- }]
18
+ export function wrapResponse<T>(data: T, includeSerializationTiming = true) {
19
+ const start = performance.now()
20
+ let text = JSON.stringify(data, null, 2)
21
+ const elapsed = performance.now() - start
22
+ const value = data as any
23
+ if (includeSerializationTiming && value && typeof value === 'object' && value.timing && typeof value.timing === 'object') {
24
+ value.timing.serialization_ms = (value.timing.serialization_ms ?? 0) + elapsed
25
+ recordTiming('serialization_ms', elapsed)
26
+ text = JSON.stringify(value, null, 2)
23
27
  }
28
+ return { content: [{ type: 'text' as const, text }] }
24
29
  }
25
30
 
26
31
  export type ToolCallArgs = Record<string, unknown>
@@ -312,6 +317,7 @@ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
312
317
  case 'STALE_REFERENCE':
313
318
  return 'TargetResolutionFailure'
314
319
  case 'ELEMENT_NOT_INTERACTABLE':
320
+ case 'ACTION_TIMEOUT':
315
321
  return 'ExecutionFailure'
316
322
  case 'TIMEOUT':
317
323
  case 'ACTION_REJECTED':
@@ -320,6 +326,7 @@ function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
320
326
  return 'ExecutionFailure'
321
327
  case 'VERIFICATION_FAILED':
322
328
  case 'EXPECT_STATE_MISMATCH':
329
+ case 'ASSERTION_TIMEOUT':
323
330
  return 'VerificationFailure'
324
331
  case 'CONTROL_CONVERGENCE_FAILED':
325
332
  return 'ControlConvergenceFailure'
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod'
2
+
3
+ const selector = z.object({
4
+ text: z.string().min(1).optional(), resource_id: z.string().min(1).optional(),
5
+ accessibility_id: z.string().min(1).optional(), contains: z.boolean().optional()
6
+ }).strict().refine(value => !!(value.text || value.resource_id || value.accessibility_id), 'Selector requires a target')
7
+ const verificationMode = z.enum(['none', 'light', 'full'])
8
+ const timeoutMs = z.number().int().min(100).max(60000).optional()
9
+ const pollIntervalMs = z.number().int().min(50).max(1000).optional()
10
+ const waitFor = z.object({
11
+ condition: z.enum(['exists', 'visible', 'clickable']).optional(),
12
+ timeoutMs: z.number().int().min(100).max(10000).optional(), pollIntervalMs,
13
+ match: z.object({ index: z.number().int().nonnegative().optional() }).strict().optional()
14
+ }).strict()
15
+ const target = { selector: selector.optional(), elementId: z.string().min(1).optional() }
16
+ const exactlyOneTarget = (value: { selector?: unknown, elementId?: string }) => Number(!!value.selector) + Number(!!value.elementId) === 1
17
+ const assertion = z.union([
18
+ z.object({ kind: z.literal('element_visible'), selector }).strict(),
19
+ z.object({ kind: z.literal('element_absent'), selector }).strict(),
20
+ z.object({ kind: z.literal('screen_fingerprint'), fingerprint: z.string().min(1) }).strict(),
21
+ z.object({ kind: z.literal('state_equals'), property: z.string().min(1), expected: z.union([z.boolean(), z.number().finite(), z.string()]), ...target }).strict().refine(exactlyOneTarget)
22
+ ])
23
+ const id = z.string().min(1)
24
+ const step = z.union([
25
+ z.object({ id, type: z.literal('start_app'), appId: z.string().min(1), verificationMode: verificationMode.optional() }).strict(),
26
+ z.object({ id, type: z.literal('tap'), ...target, waitFor: waitFor.optional(), verificationMode: verificationMode.optional() }).strict().refine(exactlyOneTarget),
27
+ z.object({ id, type: z.literal('wait'), selector, condition: z.enum(['exists', 'not_exists', 'visible', 'clickable']).optional(), timeoutMs, pollIntervalMs }).strict(),
28
+ z.object({ id, type: z.literal('assert'), assertion, timeoutMs, pollIntervalMs }).strict()
29
+ ])
30
+ export const tapElementControlsSchema = z.object({
31
+ elementId: z.string().min(1).optional(), selector: selector.optional(), waitFor: waitFor.optional()
32
+ }).strict().refine(value => Number(!!value.elementId) + Number(!!value.selector) === 1 && (!value.elementId || !value.waitFor))
33
+
34
+ export const journeySchema = z.object({
35
+ platform: z.enum(['android', 'ios']), deviceId: z.string().min(1).optional(),
36
+ responseMode: z.enum(['compact', 'debug']).default('compact'), captureOnFailure: z.boolean().default(false),
37
+ defaults: z.object({ verificationMode: verificationMode.optional(), actionTimeoutMs: z.number().int().finite().optional(), verificationTimeoutMs: z.number().int().finite().optional() }).strict().optional(),
38
+ steps: z.array(step).min(1).max(50)
39
+ }).strict().refine(value => new Set(value.steps.map(step => step.id)).size === value.steps.length, 'Step IDs must be unique')
40
+
41
+ const selectorJson = { type: 'object', additionalProperties: false, properties: {
42
+ text: { type: 'string', minLength: 1 }, resource_id: { type: 'string', minLength: 1 }, accessibility_id: { type: 'string', minLength: 1 }, contains: { type: 'boolean' }
43
+ }, anyOf: ['text', 'resource_id', 'accessibility_id'].map(key => ({ required: [key] })) }
44
+ const targetJson = { selector: selectorJson, elementId: { type: 'string', minLength: 1 } }
45
+ const oneTargetJson = [{ required: ['selector'], not: { required: ['elementId'] } }, { required: ['elementId'], not: { required: ['selector'] } }]
46
+ const intervalJson = { timeoutMs: { type: 'integer', minimum: 100, maximum: 60000 }, pollIntervalMs: { type: 'integer', minimum: 50, maximum: 1000 } }
47
+ const modeJson = { type: 'string', enum: ['none', 'light', 'full'] }
48
+ const objectJson = (properties: Record<string, unknown>, required: string[], extra = {}) => ({ type: 'object', additionalProperties: false, properties, required, ...extra })
49
+ export const journeyStepsJson = {
50
+ type: 'array', minItems: 1, maxItems: 50, items: { oneOf: [
51
+ objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'start_app' }, appId: { type: 'string', minLength: 1 }, verificationMode: modeJson }, ['id', 'type', 'appId']),
52
+ objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'tap' }, ...targetJson, verificationMode: modeJson,
53
+ waitFor: objectJson({ ...intervalJson, timeoutMs: { type: 'integer', minimum: 100, maximum: 10000 }, condition: { enum: ['exists', 'visible', 'clickable'] }, match: objectJson({ index: { type: 'integer', minimum: 0 } }, []) }, [])
54
+ }, ['id', 'type'], { oneOf: oneTargetJson }),
55
+ objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'wait' }, selector: selectorJson, condition: { enum: ['exists', 'not_exists', 'visible', 'clickable'] }, ...intervalJson }, ['id', 'type', 'selector']),
56
+ objectJson({ id: { type: 'string', minLength: 1 }, type: { const: 'assert' }, ...intervalJson, assertion: { oneOf: [
57
+ objectJson({ kind: { const: 'element_visible' }, selector: selectorJson }, ['kind', 'selector']),
58
+ objectJson({ kind: { const: 'element_absent' }, selector: selectorJson }, ['kind', 'selector']),
59
+ objectJson({ kind: { const: 'screen_fingerprint' }, fingerprint: { type: 'string', minLength: 1 } }, ['kind', 'fingerprint']),
60
+ objectJson({ kind: { const: 'state_equals' }, ...targetJson, property: { type: 'string', minLength: 1 }, expected: { type: ['boolean', 'number', 'string'] } }, ['kind', 'property', 'expected'], { oneOf: oneTargetJson })
61
+ ] } }, ['id', 'type', 'assertion'])
62
+ ] }
63
+ }