mobile-debug-mcp 0.31.0 → 0.32.1

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 (109) hide show
  1. package/README.md +1 -0
  2. package/dist/interact/android.js +13 -8
  3. package/dist/interact/index.js +33 -22
  4. package/dist/interact/ios.js +5 -2
  5. package/dist/manage/android.js +20 -10
  6. package/dist/manage/ios.js +19 -10
  7. package/dist/observe/android-tree-bridge.js +192 -0
  8. package/dist/observe/android.js +53 -14
  9. package/dist/observe/index.js +22 -5
  10. package/dist/observe/ios.js +50 -29
  11. package/dist/observe/performance-metrics.js +124 -0
  12. package/dist/observe/snapshot-metadata.js +94 -15
  13. package/dist/server/common.js +14 -7
  14. package/dist/server/journey-schema.js +60 -0
  15. package/dist/server/tool-definitions.js +59 -6
  16. package/dist/server/tool-handlers.js +381 -56
  17. package/dist/server-core.js +1 -1
  18. package/dist/utils/exec.js +18 -5
  19. package/dist/utils/ios/utils.js +5 -2
  20. package/dist/utils/operation-budget.js +25 -0
  21. package/dist/utils/resolve-device.js +12 -1
  22. package/dist/utils/timing.js +19 -0
  23. package/dist/utils/ui/index.js +2 -0
  24. package/docs/CHANGELOG.md +6 -0
  25. package/docs/ROADMAP.md +9 -0
  26. package/docs/benchmarks/interaction-latency/README.md +26 -0
  27. package/docs/benchmarks/interaction-latency/final.json +1810 -0
  28. package/docs/benchmarks/interaction-latency/final.md +17 -0
  29. package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
  30. package/docs/benchmarks/ui-tree-performance/README.md +51 -0
  31. package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
  32. package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
  33. package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
  34. package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
  35. package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
  36. package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
  37. package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
  38. package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
  39. package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
  40. package/docs/tools/interact.md +30 -0
  41. package/docs/tools/observe.md +1 -0
  42. package/package.json +1 -1
  43. package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
  44. package/src/interact/android.ts +13 -8
  45. package/src/interact/index.ts +32 -23
  46. package/src/interact/ios.ts +5 -2
  47. package/src/manage/android.ts +19 -10
  48. package/src/manage/ios.ts +18 -10
  49. package/src/observe/android-tree-bridge.ts +170 -0
  50. package/src/observe/android.ts +48 -17
  51. package/src/observe/index.ts +21 -5
  52. package/src/observe/ios.ts +49 -36
  53. package/src/observe/performance-metrics.ts +125 -0
  54. package/src/observe/snapshot-metadata.ts +94 -15
  55. package/src/server/common.ts +13 -6
  56. package/src/server/journey-schema.ts +63 -0
  57. package/src/server/tool-definitions.ts +60 -7
  58. package/src/server/tool-handlers.ts +349 -54
  59. package/src/server-core.ts +1 -1
  60. package/src/types.ts +31 -2
  61. package/src/utils/exec.ts +13 -5
  62. package/src/utils/ios/utils.ts +5 -2
  63. package/src/utils/operation-budget.ts +22 -0
  64. package/src/utils/resolve-device.ts +13 -1
  65. package/src/utils/timing.ts +15 -0
  66. package/src/utils/ui/index.ts +2 -0
  67. package/test/device/automated/latency/benchmark.ts +136 -0
  68. package/test/device/automated/latency/report.ts +6 -0
  69. package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
  70. package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
  71. package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
  72. package/test/fixtures/latency-journey-app/README.md +28 -0
  73. package/test/fixtures/latency-journey-app/build.ts +26 -0
  74. package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
  75. package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
  76. package/test/fixtures/ui-tree-bridge/build.ts +34 -0
  77. package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
  78. package/test/unit/manage/restart_budget.test.ts +37 -0
  79. package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
  80. package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
  81. package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
  82. package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
  83. package/test/unit/observe/performance_metrics.test.ts +40 -0
  84. package/test/unit/observe/snapshot_metadata.test.ts +20 -1
  85. package/test/unit/observe/state_delta.test.ts +15 -0
  86. package/test/unit/server/benchmark_report.test.ts +4 -0
  87. package/test/unit/server/contract.test.ts +1 -1
  88. package/test/unit/server/interaction_modes.test.ts +60 -0
  89. package/test/unit/server/operation_budget.test.ts +45 -0
  90. package/test/unit/server/response_shapes.test.ts +3 -3
  91. package/test/unit/server/run_journey.test.ts +120 -0
  92. package/test/unit/server/tap_selector.test.ts +68 -0
  93. /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
  94. /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
  95. /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
  96. /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
  97. /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
  98. /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
  99. /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
  100. /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
  101. /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
  102. /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
  103. /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
  104. /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
  105. /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
  106. /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
  107. /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
  108. /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
  109. /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
@@ -8,6 +8,9 @@ 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'
12
+ import { createObservationMeasurement } from './performance-metrics.js'
13
+ import { androidTreeBridgeEnabled, readAndroidTreeFromBridge } from './android-tree-bridge.js'
11
14
 
12
15
  const activeLogStreams: Map<string, { proc: any, file: string }> = new Map()
13
16
 
@@ -17,12 +20,20 @@ export class AndroidObserve {
17
20
  }
18
21
 
19
22
  async getUITree(deviceId?: string): Promise<GetUITreeResponse> {
20
- const metadata = await getAndroidDeviceMetadata("", deviceId)
23
+ const measurement = createObservationMeasurement('android')
24
+ let metadata
25
+ try {
26
+ metadata = await measurement.timed('metadata_ms', () => getAndroidDeviceMetadata("", deviceId))
27
+ } catch (error) {
28
+ measurement.setOutcome('unavailable')
29
+ measurement.finish()
30
+ throw error
31
+ }
21
32
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
22
33
 
23
34
  try {
24
35
  // Get screen resolution first
25
- const resolution = await getScreenResolution(deviceId);
36
+ const resolution = await measurement.timed('resolution_ms', () => getScreenResolution(deviceId));
26
37
  if (resolution.width === 0 && resolution.height === 0) {
27
38
  throw new Error("Failed to get screen resolution. Is the device connected and authorized?");
28
39
  }
@@ -33,22 +44,37 @@ export class AndroidObserve {
33
44
  const maxAttempts = 3;
34
45
 
35
46
  while (attempts < maxAttempts) {
47
+ remainingBudget()
36
48
  attempts++;
37
49
  try {
38
- // Stabilization delay
39
- await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
40
-
41
- // Dump UI hierarchy
42
- await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
43
-
44
- // Read the file
45
- xmlContent = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
46
-
47
- // Check validity
48
- if (xmlContent && xmlContent.trim().length > 0 && !xmlContent.includes("ERROR:")) {
49
- break; // Success
50
+ // The first read starts immediately so it remains fresh for in-place UI changes.
51
+ // Delay only a retry after a failed or invalid acquisition.
52
+ if (attempts > 1) {
53
+ await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
54
+ }
55
+ remainingBudget()
56
+
57
+ let bridgeContent: string | null = null
58
+ if (androidTreeBridgeEnabled()) {
59
+ try {
60
+ bridgeContent = await measurement.physicalRead(async () => {
61
+ const content = await readAndroidTreeFromBridge(deviceInfo.id)
62
+ if (!content) throw new Error('Android UI tree bridge unavailable')
63
+ return content
64
+ })
65
+ } catch (error) {
66
+ if (error instanceof Error && (error.message === 'ACTION_TIMEOUT' || error.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw error
67
+ }
50
68
  }
69
+ xmlContent = bridgeContent ?? await measurement.physicalRead(async () => {
70
+ await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId)
71
+ const content = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId)
72
+ if (!content || !content.trim() || content.includes('ERROR:')) throw new Error('Empty or invalid UI dump')
73
+ return content
74
+ })
75
+ break
51
76
  } catch (e) {
77
+ if (e instanceof Error && (e.message === 'ACTION_TIMEOUT' || e.message.startsWith('BRIDGE_CLEANUP_UNCONFIRMED'))) throw e
52
78
  console.error(`Attempt ${attempts} failed: ${e}`);
53
79
  }
54
80
 
@@ -61,7 +87,7 @@ export class AndroidObserve {
61
87
  ignoreAttributes: false,
62
88
  attributeNamePrefix: "@_"
63
89
  });
64
- const result = parser.parse(xmlContent);
90
+ const result = await measurement.timed('parse_ms', async () => parser.parse(xmlContent));
65
91
 
66
92
  const elements: UIElement[] = [];
67
93
 
@@ -81,17 +107,22 @@ export class AndroidObserve {
81
107
  elements
82
108
  }, 'ui_tree')
83
109
 
84
- return {
110
+ measurement.setOutcome('observed')
111
+ const response = measurement.bindObservation({
85
112
  device: deviceInfo,
86
113
  screen: "",
87
114
  resolution,
88
115
  elements,
89
116
  ...snapshotMetadata
90
- };
117
+ })
118
+ measurement.finish()
119
+ return response
91
120
  } catch (e) {
92
121
  const errorMessage = `Failed to get UI tree. ADB Path: '${getAdbCmd()}'. Error: ${e instanceof Error ? e.message : String(e)}`;
93
122
  console.error(errorMessage);
94
123
  const snapshotMetadata = deriveSnapshotMetadata(`android:${deviceInfo.id}`, null, 'ui_tree')
124
+ measurement.setOutcome('unavailable')
125
+ measurement.finish()
95
126
  return {
96
127
  device: deviceInfo,
97
128
  screen: "",
@@ -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,7 @@
1
1
  import { spawn } from "child_process"
2
+ import { remainingBudget } from '../utils/operation-budget.js'
3
+ import { execCmd } from '../utils/exec.js'
4
+ import { createObservationMeasurement } from './performance-metrics.js'
2
5
  import { promises as fs } from "fs"
3
6
  import { GetLogsResponse, CaptureIOSScreenshotResponse, GetUITreeResponse, UIElement, DeviceInfo, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../types.js"
4
7
  import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "../utils/ios/utils.js"
@@ -524,12 +527,22 @@ export class iOSObserve {
524
527
  }
525
528
 
526
529
  async getUITree(deviceId: string = "booted"): Promise<GetUITreeResponse> {
527
- const device = await getIOSDeviceMetadata(deviceId);
530
+ const measurement = createObservationMeasurement('ios')
531
+ let device
532
+ try {
533
+ device = await measurement.timed('metadata_ms', () => getIOSDeviceMetadata(deviceId))
534
+ } catch (error) {
535
+ measurement.setOutcome('unavailable')
536
+ measurement.finish()
537
+ throw error
538
+ }
528
539
  const deviceKey = `ios:${device.id}`
529
540
 
530
- const idbExists = await isIDBInstalled();
541
+ const idbExists = await measurement.timed('capability_check_ms', () => isIDBInstalled());
531
542
  if (!idbExists) {
532
543
  const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
544
+ measurement.setOutcome('unavailable')
545
+ measurement.finish()
533
546
  return {
534
547
  device,
535
548
  screen: "",
@@ -547,44 +560,38 @@ export class iOSObserve {
547
560
  const maxAttempts = 3;
548
561
 
549
562
  while (attempts < maxAttempts) {
563
+ remainingBudget()
550
564
  attempts++;
551
565
  try {
552
- await delay(300 + (attempts * 100));
566
+ await measurement.timed('observation_wait_ms', () => delay(Math.min(remainingBudget(300 + (attempts * 100)), 300 + (attempts * 100))));
567
+ remainingBudget()
553
568
 
554
569
  const args = ['ui', 'describe-all', '--json'];
555
570
  if (targetUdid) {
556
571
  args.push('--udid', targetUdid);
557
572
  }
558
573
 
559
- const output = await new Promise<string>((resolve, reject) => {
560
- const child = spawn(getIdbCmd(), args);
561
- let stdout = '';
562
- let stderr = '';
563
-
564
- child.stdout.on('data', (data) => stdout += data.toString());
565
- child.stderr.on('data', (data) => stderr += data.toString());
566
-
567
- child.on('error', (err) => reject(new Error(`Failed to execute idb: ${err.message}`)));
568
-
569
- child.on('close', (code) => {
570
- if (code !== 0) {
571
- reject(new Error(`idb failed (code ${code}): ${stderr.trim()}`));
572
- } else {
573
- resolve(stdout);
574
- }
575
- });
576
- });
574
+ const output = await measurement.physicalRead(async () => {
575
+ const result = await execCmd(getIdbCmd(), args, { timeout: remainingBudget() })
576
+ if (result.stderr === 'ACTION_TIMEOUT') throw new Error('ACTION_TIMEOUT')
577
+ if (result.exitCode !== 0) throw new Error(`idb failed (code ${result.exitCode}): ${result.stderr}`)
578
+ if (!result.stdout.trim()) throw new Error('Empty idb UI tree output')
579
+ return result.stdout
580
+ })
577
581
 
578
582
  if (output && output.trim().length > 0) {
579
- jsonContent = JSON.parse(output);
583
+ jsonContent = await measurement.timed('parse_ms', async () => JSON.parse(output));
580
584
  break; // Success
581
585
  }
582
586
  } catch (e) {
587
+ if (e instanceof Error && e.message === 'ACTION_TIMEOUT') throw e
583
588
  console.error(`Attempt ${attempts} failed: ${e}`);
584
589
  }
585
590
 
586
591
  if (attempts === maxAttempts) {
587
592
  const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
593
+ measurement.setOutcome('unavailable')
594
+ measurement.finish()
588
595
  return {
589
596
  device,
590
597
  screen: "",
@@ -597,14 +604,15 @@ export class iOSObserve {
597
604
  }
598
605
 
599
606
  try {
600
- const elements: UIElement[] = [];
601
- if (Array.isArray(jsonContent)) {
602
- for (const node of jsonContent) {
603
- traverseIDBNode(node, elements);
607
+ const elements: UIElement[] = await measurement.timed('conversion_ms', async () => {
608
+ const parsed: UIElement[] = []
609
+ if (Array.isArray(jsonContent)) {
610
+ for (const node of jsonContent) traverseIDBNode(node, parsed)
611
+ } else {
612
+ traverseIDBNode(jsonContent, parsed)
604
613
  }
605
- } else {
606
- traverseIDBNode(jsonContent, elements);
607
- }
614
+ return parsed
615
+ })
608
616
 
609
617
  let width = 0;
610
618
  let height = 0;
@@ -620,15 +628,20 @@ export class iOSObserve {
620
628
  elements
621
629
  }, 'ui_tree')
622
630
 
623
- return {
624
- device,
625
- screen: "",
626
- resolution: { width, height },
627
- elements,
628
- ...snapshotMetadata
629
- };
631
+ measurement.setOutcome('observed')
632
+ const response = measurement.bindObservation({
633
+ device,
634
+ screen: "",
635
+ resolution: { width, height },
636
+ elements,
637
+ ...snapshotMetadata
638
+ })
639
+ measurement.finish()
640
+ return response
630
641
  } catch (e) {
631
642
  const snapshotMetadata = deriveSnapshotMetadata(deviceKey, null, 'ui_tree')
643
+ measurement.setOutcome('unavailable')
644
+ measurement.finish()
632
645
  return {
633
646
  device,
634
647
  screen: "",
@@ -0,0 +1,125 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+
4
+ export type ObservationPurpose = 'target' | 'verification' | 'assertion' | 'debug' | 'unspecified'
5
+
6
+ interface ObservationContext {
7
+ stepId?: string
8
+ purpose: ObservationPurpose
9
+ }
10
+
11
+ const context = new AsyncLocalStorage<ObservationContext>()
12
+ const observationIds = new WeakMap<object, string>()
13
+
14
+ export function withObservationContext<T>(value: Partial<ObservationContext>, operation: () => Promise<T>): Promise<T> {
15
+ if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return operation()
16
+ return context.run({ purpose: value.purpose ?? context.getStore()?.purpose ?? 'unspecified', stepId: value.stepId ?? context.getStore()?.stepId }, operation)
17
+ }
18
+
19
+ export function recordObservationEvent(event: Record<string, unknown>) {
20
+ if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1') return
21
+ const current = context.getStore()
22
+ process.stderr.write(`${JSON.stringify({
23
+ event: 'ui_tree_metric',
24
+ host_monotonic_ns: process.hrtime.bigint().toString(),
25
+ type: 'action_observation',
26
+ step_id: current?.stepId,
27
+ purpose: current?.purpose ?? 'unspecified',
28
+ ...event
29
+ })}\n`)
30
+ }
31
+
32
+ export function recordObservationConsumer(observation: unknown, purpose: ObservationPurpose, stepId?: string) {
33
+ if (process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS !== '1' || !observation || typeof observation !== 'object') return
34
+ const acquisitionId = observationIds.get(observation)
35
+ if (!acquisitionId) return
36
+ const current = context.getStore()
37
+ process.stderr.write(`${JSON.stringify({
38
+ event: 'ui_tree_metric',
39
+ host_monotonic_ns: process.hrtime.bigint().toString(),
40
+ type: 'observation_consumer',
41
+ acquisition_id: acquisitionId,
42
+ consumer_step_id: stepId ?? current?.stepId,
43
+ consumer_purpose: purpose,
44
+ reused_observations: 1
45
+ })}\n`)
46
+ }
47
+
48
+ export function createObservationMeasurement(platform: 'android' | 'ios') {
49
+ const enabled = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1'
50
+ const startedAt = enabled ? performance.now() : 0
51
+ const id = enabled ? randomUUID() : undefined
52
+ const current = context.getStore()
53
+ const durations: Record<string, number> = {}
54
+ let successfulOutput = false
55
+ let outcome: 'observed' | 'unavailable' = 'unavailable'
56
+ let readAttempt = 0
57
+
58
+ const emit = (event: Record<string, unknown>) => {
59
+ if (!enabled) return
60
+ process.stderr.write(`${JSON.stringify({
61
+ event: 'ui_tree_metric',
62
+ host_monotonic_ns: process.hrtime.bigint().toString(),
63
+ acquisition_id: id,
64
+ platform,
65
+ step_id: current?.stepId,
66
+ purpose: current?.purpose ?? 'unspecified',
67
+ ...event
68
+ })}\n`)
69
+ }
70
+
71
+ return {
72
+ id,
73
+ bindObservation<T extends object>(observation: T): T {
74
+ if (enabled && id) observationIds.set(observation, id)
75
+ return observation
76
+ },
77
+ timed: async <T>(stage: string, operation: () => Promise<T>): Promise<T> => {
78
+ if (!enabled) return operation()
79
+ const start = performance.now()
80
+ let success = true
81
+ try {
82
+ return await operation()
83
+ } catch (error) {
84
+ success = false
85
+ throw error
86
+ } finally {
87
+ const elapsed = performance.now() - start
88
+ durations[stage] = (durations[stage] ?? 0) + elapsed
89
+ emit({ type: 'stage', stage, attempt_index: stage === 'parse_ms' ? readAttempt : undefined, elapsed_ms: elapsed, success })
90
+ }
91
+ },
92
+ physicalRead: async <T>(operation: () => Promise<T>): Promise<T> => {
93
+ if (!enabled) return operation()
94
+ const start = performance.now()
95
+ readAttempt++
96
+ emit({ type: 'physical_read_started', attempt_index: readAttempt })
97
+ try {
98
+ const result = await operation()
99
+ successfulOutput = true
100
+ emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: true })
101
+ return result
102
+ } catch (error) {
103
+ emit({ type: 'physical_read_completed', attempt_index: readAttempt, elapsed_ms: performance.now() - start, success: false })
104
+ throw error
105
+ }
106
+ },
107
+ setOutcome(value: 'observed' | 'unavailable') { if (enabled) outcome = value },
108
+ finish() {
109
+ if (!enabled) return
110
+ emit({
111
+ type: 'observation_consumer',
112
+ consumer_step_id: current?.stepId,
113
+ consumer_purpose: current?.purpose ?? 'unspecified',
114
+ reused_observations: 0
115
+ })
116
+ emit({
117
+ type: 'acquisition_completed',
118
+ elapsed_ms: performance.now() - startedAt,
119
+ successful_output: successfulOutput,
120
+ outcome,
121
+ durations_ms: durations
122
+ })
123
+ }
124
+ }
125
+ }
@@ -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'