mobile-debug-mcp 0.25.0 → 0.26.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.
@@ -240,7 +240,7 @@ Failure Handling:
240
240
  },
241
241
  {
242
242
  name: 'capture_debug_snapshot',
243
- description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON.',
243
+ description: 'Capture a complete debug snapshot (raw observation layer plus optional derived semantic layer). Returns structured JSON with snapshot_revision, captured_at_ms, and loading_state when detectable.',
244
244
  inputSchema: {
245
245
  type: 'object',
246
246
  properties: {
@@ -291,7 +291,7 @@ Failure Handling:
291
291
  },
292
292
  {
293
293
  name: 'get_ui_tree',
294
- description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content.',
294
+ description: 'Get the current UI hierarchy from an Android device or iOS simulator. Returns a structured JSON representation of the screen content with snapshot metadata when available.',
295
295
  inputSchema: {
296
296
  type: 'object',
297
297
  properties: {
@@ -363,6 +363,34 @@ Recommended Usage:
363
363
  required: ['previousFingerprint']
364
364
  }
365
365
  },
366
+ {
367
+ name: 'wait_for_ui_change',
368
+ description: `Purpose:
369
+ Wait for a non-navigation UI mutation or in-place update to become stable.
370
+
371
+ Inputs:
372
+ - expected_change (optional): hierarchy_diff, text_change, or state_change
373
+ - timeout_ms (optional)
374
+ - stability_window_ms (optional)
375
+
376
+ Guidance:
377
+ - Prefer wait_for_screen_change for navigation transitions.
378
+ - Prefer wait_for_ui_change for in-place mutations and non-navigation updates.
379
+ - Use the returned snapshot_revision as the observed synchronization point when available.
380
+
381
+ Failure Handling:
382
+ - TIMEOUT means the UI did not change in a stable way within the allotted time.`,
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ platform: { type: 'string', enum: ['android', 'ios'], description: 'Optional platform override (android|ios)' },
387
+ deviceId: { type: 'string', description: 'Optional device id/udid to target' },
388
+ expected_change: { type: 'string', enum: ['hierarchy_diff', 'text_change', 'state_change'], description: 'Optional type of UI change to wait for' },
389
+ timeout_ms: { type: 'number', description: 'Timeout in ms to wait for change (default 60000)', default: 60000 },
390
+ stability_window_ms: { type: 'number', description: 'How long the change must remain stable before success (default 250)', default: 250 }
391
+ }
392
+ }
393
+ },
366
394
  {
367
395
  name: 'expect_screen',
368
396
  description: `Purpose:
@@ -288,6 +288,16 @@ async function handleWaitForUI(args: ToolCallArgs) {
288
288
  return wrapResponse(res)
289
289
  }
290
290
 
291
+ async function handleWaitForUIChange(args: ToolCallArgs) {
292
+ const platform = getStringArg(args, 'platform') as PlatformArg | undefined
293
+ const deviceId = getStringArg(args, 'deviceId')
294
+ const timeout_ms = getNumberArg(args, 'timeout_ms') ?? 60000
295
+ const stability_window_ms = getNumberArg(args, 'stability_window_ms') ?? 250
296
+ const expected_change = getStringArg(args, 'expected_change') as 'hierarchy_diff' | 'text_change' | 'state_change' | undefined
297
+ const res = await ToolsInteract.waitForUIChangeHandler({ platform, deviceId, timeout_ms, stability_window_ms, expected_change })
298
+ return wrapResponse(res)
299
+ }
300
+
291
301
  async function handleFindElement(args: ToolCallArgs) {
292
302
  const query = requireStringArg(args, 'query')
293
303
  const exact = getBooleanArg(args, 'exact') ?? false
@@ -473,6 +483,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
473
483
  get_current_screen: handleGetCurrentScreen,
474
484
  get_screen_fingerprint: handleGetScreenFingerprint,
475
485
  wait_for_screen_change: handleWaitForScreenChange,
486
+ wait_for_ui_change: handleWaitForUIChange,
476
487
  expect_screen: handleExpectScreen,
477
488
  expect_element_visible: handleExpectElementVisible,
478
489
  expect_state: handleExpectState,
@@ -13,7 +13,7 @@ export { wrapResponse, toolDefinitions, handleToolCall }
13
13
 
14
14
  export const serverInfo = {
15
15
  name: 'mobile-debug-mcp',
16
- version: '0.25.0'
16
+ version: '0.26.0'
17
17
  }
18
18
 
19
19
  export function createServer() {
package/src/types.ts CHANGED
@@ -94,6 +94,27 @@ export interface UIElementState {
94
94
  } | null;
95
95
  }
96
96
 
97
+ export interface SelectorConfidence {
98
+ score: number;
99
+ reason: string;
100
+ }
101
+
102
+ export interface UIResolutionSelector {
103
+ value: string | null;
104
+ confidence: SelectorConfidence | null;
105
+ }
106
+
107
+ export interface UIElementSemanticMetadata {
108
+ is_clickable: boolean;
109
+ is_container: boolean;
110
+ }
111
+
112
+ export interface LoadingState {
113
+ active: boolean;
114
+ signal: string;
115
+ source: string;
116
+ }
117
+
97
118
  export interface CaptureAndroidScreenResponse {
98
119
  device: DeviceInfo;
99
120
  screenshot: string; // base64 encoded string
@@ -132,6 +153,11 @@ export interface UIElement {
132
153
  center?: [number, number];
133
154
  depth?: number;
134
155
  state?: UIElementState | null;
156
+ stable_id?: string | null;
157
+ role?: string | null;
158
+ test_tag?: string | null;
159
+ selector?: UIResolutionSelector | null;
160
+ semantic?: UIElementSemanticMetadata | null;
135
161
  }
136
162
 
137
163
  export interface GetUITreeResponse {
@@ -142,6 +168,9 @@ export interface GetUITreeResponse {
142
168
  height: number;
143
169
  };
144
170
  elements: UIElement[];
171
+ snapshot_revision: number;
172
+ captured_at_ms: number;
173
+ loading_state?: LoadingState | null;
145
174
  error?: string;
146
175
  }
147
176
 
@@ -163,12 +192,15 @@ export interface SnapshotSemanticResponse {
163
192
 
164
193
  export interface CaptureDebugSnapshotRawResponse {
165
194
  timestamp: number;
195
+ snapshot_revision: number;
196
+ captured_at_ms: number;
166
197
  reason: string;
167
198
  activity: string | null;
168
199
  fingerprint: string | null;
169
200
  screenshot: string | null;
170
- ui_tree: unknown | null;
201
+ ui_tree: GetUITreeResponse | null;
171
202
  logs: StructuredLogEntry[];
203
+ loading_state?: LoadingState | null;
172
204
  device?: DeviceInfo;
173
205
  screenshot_error?: string;
174
206
  activity_error?: string;
@@ -215,6 +247,11 @@ export interface ActionTargetResolved {
215
247
  bounds: [number, number, number, number] | null;
216
248
  index: number | null;
217
249
  state?: UIElementState | null;
250
+ stable_id?: string | null;
251
+ role?: string | null;
252
+ test_tag?: string | null;
253
+ selector?: UIResolutionSelector | null;
254
+ semantic?: UIElementSemanticMetadata | null;
218
255
  }
219
256
 
220
257
  export interface ActionExecutionResult {
@@ -301,6 +338,17 @@ export interface ExpectStateResponse {
301
338
  retryable?: boolean;
302
339
  }
303
340
 
341
+ export interface WaitForUIChangeResponse {
342
+ success: boolean;
343
+ observed_change: 'hierarchy_diff' | 'text_change' | 'state_change' | null;
344
+ snapshot_revision?: number;
345
+ timeout: boolean;
346
+ elapsed_ms: number;
347
+ expected_change?: 'hierarchy_diff' | 'text_change' | 'state_change';
348
+ reason?: string;
349
+ loading_state?: LoadingState | null;
350
+ }
351
+
304
352
  export interface SwipeResponse {
305
353
  device: DeviceInfo;
306
354
  success: boolean;
@@ -1,4 +1,4 @@
1
- import { DeviceInfo, UIElement, UIElementState } from "../../types.js"
1
+ import { DeviceInfo, UIElement, UIElementSemanticMetadata, UIElementState, UIResolutionSelector, SelectorConfidence } from "../../types.js"
2
2
  import { promises as fsPromises, existsSync } from 'fs'
3
3
  import path from 'path'
4
4
  import { detectJavaHome } from '../java.js'
@@ -336,6 +336,52 @@ function parseNumberAttr(value: unknown): number | null {
336
336
  return Number.isFinite(parsed) ? parsed : null
337
337
  }
338
338
 
339
+ function normalizeClassName(value: unknown): string {
340
+ return typeof value === 'string' ? value.trim().toLowerCase() : ''
341
+ }
342
+
343
+ function inferAndroidRole(className: string): string | null {
344
+ if (/seekbar|slider|progress/.test(className)) return 'slider'
345
+ if (/switch|toggle/.test(className)) return 'switch'
346
+ if (/checkbox/.test(className)) return 'checkbox'
347
+ if (/radiobutton|radio/.test(className)) return 'radio'
348
+ if (/edittext|textfield|search/.test(className)) return 'text_field'
349
+ if (/button|fab/.test(className)) return 'button'
350
+ if (/imageview|icon/.test(className)) return 'image'
351
+ if (/recyclerview|scroll|layout|viewgroup|frame/.test(className)) return 'container'
352
+ return null
353
+ }
354
+
355
+ function buildAndroidSelectorConfidence(source: 'resource_id' | 'content_desc' | 'text' | 'class' | 'none'): SelectorConfidence | null {
356
+ switch (source) {
357
+ case 'resource_id':
358
+ return { score: 1, reason: 'resource_id' }
359
+ case 'content_desc':
360
+ return { score: 0.9, reason: 'content_description' }
361
+ case 'text':
362
+ return { score: 0.6, reason: 'text_match' }
363
+ case 'class':
364
+ return { score: 0.35, reason: 'class_match' }
365
+ default:
366
+ return null
367
+ }
368
+ }
369
+
370
+ function buildAndroidSelector(text: string | null, contentDescription: string | null, resourceId: string | null, className: string): UIResolutionSelector | null {
371
+ if (resourceId) return { value: resourceId, confidence: buildAndroidSelectorConfidence('resource_id') }
372
+ if (contentDescription) return { value: contentDescription, confidence: buildAndroidSelectorConfidence('content_desc') }
373
+ if (text) return { value: text, confidence: buildAndroidSelectorConfidence('text') }
374
+ if (className) return { value: className, confidence: buildAndroidSelectorConfidence('class') }
375
+ return null
376
+ }
377
+
378
+ function buildAndroidSemantic(clickable: boolean, className: string): UIElementSemanticMetadata {
379
+ return {
380
+ is_clickable: clickable,
381
+ is_container: /recyclerview|scroll|layout|viewgroup|frame/.test(className)
382
+ }
383
+ }
384
+
339
385
  function isSliderLikeAndroid(node: any): boolean {
340
386
  const className = String(node['@_class'] || '').toLowerCase()
341
387
  return /seekbar|slider|range|progress/i.test(className)
@@ -401,29 +447,41 @@ export function traverseNode(node: any, elements: UIElement[], parentIndex: numb
401
447
 
402
448
  let currentIndex = -1;
403
449
 
404
- if (node['@_class']) {
405
- const text = node['@_text'] || null;
406
- const contentDescription = node['@_content-desc'] || null;
407
- const clickable = node['@_clickable'] === 'true';
408
- const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
409
- const state = extractAndroidState(node);
410
-
411
- const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
412
-
413
- if (isUseful) {
414
- const element: UIElement = {
450
+ if (node['@_class']) {
451
+ const text = node['@_text'] || null;
452
+ const contentDescription = node['@_content-desc'] || null;
453
+ const clickable = node['@_clickable'] === 'true';
454
+ const className = String(node['@_class'] || 'unknown');
455
+ const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
456
+ const state = extractAndroidState(node);
457
+ const role = inferAndroidRole(normalizeClassName(className));
458
+ const resourceId = typeof node['@_resource-id'] === 'string' && node['@_resource-id'].trim().length > 0 ? node['@_resource-id'] : null
459
+ const stableId = resourceId ?? (typeof contentDescription === 'string' && contentDescription.trim().length > 0 ? contentDescription : null)
460
+ const testTag = stableId
461
+ const selector = buildAndroidSelector(text, contentDescription, resourceId, normalizeClassName(className))
462
+ const semantic = buildAndroidSemantic(clickable, normalizeClassName(className))
463
+
464
+ const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
465
+
466
+ if (isUseful) {
467
+ const element: UIElement = {
415
468
  text,
416
469
  contentDescription,
417
- type: node['@_class'] || 'unknown',
418
- resourceId: node['@_resource-id'] || null,
470
+ type: className,
471
+ resourceId,
419
472
  clickable,
420
473
  enabled: node['@_enabled'] === 'true',
421
- visible: true,
422
- bounds,
423
- center: getCenter(bounds),
424
- depth,
425
- state
426
- };
474
+ visible: true,
475
+ bounds,
476
+ center: getCenter(bounds),
477
+ depth,
478
+ state,
479
+ stable_id: stableId,
480
+ role,
481
+ test_tag: testTag,
482
+ selector,
483
+ semantic
484
+ };
427
485
 
428
486
  if (parentIndex !== -1) {
429
487
  element.parentId = parentIndex;
@@ -0,0 +1,76 @@
1
+ import assert from 'assert'
2
+ import { ToolsInteract } from '../../../src/interact/index.js'
3
+ import { ToolsObserve } from '../../../src/observe/index.js'
4
+
5
+ async function run() {
6
+ const originalGetUITreeHandler = (ToolsObserve as any).getUITreeHandler
7
+
8
+ try {
9
+ let calls = 0
10
+ ;(ToolsObserve as any).getUITreeHandler = async () => {
11
+ calls++
12
+ if (calls === 1) {
13
+ return {
14
+ device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
15
+ screen: 'Loading',
16
+ resolution: { width: 1080, height: 2400 },
17
+ elements: [{ text: 'Loading', type: 'TextView', bounds: [0, 0, 100, 40], visible: true }],
18
+ snapshot_revision: 1,
19
+ captured_at_ms: 1000
20
+ }
21
+ }
22
+
23
+ return {
24
+ device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
25
+ screen: 'Loaded',
26
+ resolution: { width: 1080, height: 2400 },
27
+ elements: [{ text: 'Loaded', type: 'TextView', bounds: [0, 0, 100, 40], visible: true }],
28
+ snapshot_revision: 2,
29
+ captured_at_ms: 2000
30
+ }
31
+ }
32
+
33
+ const success = await ToolsInteract.waitForUIChangeHandler({
34
+ platform: 'android',
35
+ deviceId: 'mock',
36
+ expected_change: 'text_change',
37
+ timeout_ms: 1500,
38
+ stability_window_ms: 1
39
+ })
40
+
41
+ assert.strictEqual(success.success, true)
42
+ assert.strictEqual(success.observed_change, 'text_change')
43
+ assert.strictEqual(success.snapshot_revision, 2)
44
+ assert.strictEqual(success.timeout, false)
45
+
46
+ ;(ToolsObserve as any).getUITreeHandler = async () => ({
47
+ device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
48
+ screen: 'Static',
49
+ resolution: { width: 1080, height: 2400 },
50
+ elements: [{ text: 'Static', type: 'TextView', bounds: [0, 0, 100, 40], visible: true }],
51
+ snapshot_revision: 9,
52
+ captured_at_ms: 3000
53
+ })
54
+
55
+ const timeout = await ToolsInteract.waitForUIChangeHandler({
56
+ platform: 'android',
57
+ deviceId: 'mock',
58
+ expected_change: 'state_change',
59
+ timeout_ms: 700,
60
+ stability_window_ms: 1
61
+ })
62
+
63
+ assert.strictEqual(timeout.success, false)
64
+ assert.strictEqual(timeout.observed_change, null)
65
+ assert.strictEqual(timeout.timeout, true)
66
+
67
+ console.log('wait_for_ui_change tests passed')
68
+ } finally {
69
+ ;(ToolsObserve as any).getUITreeHandler = originalGetUITreeHandler
70
+ }
71
+ }
72
+
73
+ run().catch((error) => {
74
+ console.error(error)
75
+ process.exit(1)
76
+ })
@@ -8,6 +8,7 @@ async function run() {
8
8
  '@_class': 'android.widget.SeekBar',
9
9
  '@_text': '',
10
10
  '@_content-desc': 'Duration',
11
+ '@_resource-id': 'com.example:id/duration',
11
12
  '@_clickable': 'true',
12
13
  '@_enabled': 'true',
13
14
  '@_selected': 'true',
@@ -21,18 +22,64 @@ async function run() {
21
22
  assert.strictEqual(androidElements[0].state?.raw_value, 7)
22
23
  assert.strictEqual(androidElements[0].state?.value, 50)
23
24
  assert.deepStrictEqual(androidElements[0].state?.value_range, { min: 0, max: 14 })
25
+ assert.strictEqual(androidElements[0].stable_id, 'com.example:id/duration')
26
+ assert.strictEqual(androidElements[0].role, 'slider')
27
+ assert.strictEqual(androidElements[0].test_tag, 'com.example:id/duration')
28
+ assert.deepStrictEqual(androidElements[0].selector, {
29
+ value: 'com.example:id/duration',
30
+ confidence: { score: 1, reason: 'resource_id' }
31
+ })
32
+ assert.deepStrictEqual(androidElements[0].semantic, { is_clickable: true, is_container: false })
33
+
34
+ const androidFallbackElements: any[] = []
35
+ traverseNode({
36
+ '@_class': 'android.widget.Button',
37
+ '@_text': '',
38
+ '@_content-desc': 'Save',
39
+ '@_clickable': 'true',
40
+ '@_enabled': 'true',
41
+ '@_bounds': '[0,0][100,50]'
42
+ }, androidFallbackElements)
43
+
44
+ assert.strictEqual(androidFallbackElements.length, 1)
45
+ assert.strictEqual(androidFallbackElements[0].resourceId, null)
46
+ assert.strictEqual(androidFallbackElements[0].stable_id, 'Save')
47
+ assert.deepStrictEqual(androidFallbackElements[0].selector, {
48
+ value: 'Save',
49
+ confidence: { score: 0.9, reason: 'content_description' }
50
+ })
24
51
 
25
52
  const iosElements: any[] = []
26
53
  traverseIDBNode({
27
54
  AXElementType: 'Slider',
28
55
  AXLabel: 'Playback speed',
29
56
  AXValue: '0.75',
57
+ AXUniqueId: 'playback_speed_slider',
30
58
  AXTraits: ['UIAccessibilityTraitAdjustable']
31
59
  }, iosElements)
32
60
 
33
61
  assert.strictEqual(iosElements.length, 1)
34
62
  assert.strictEqual(iosElements[0].state?.value, 75)
35
63
  assert.strictEqual(iosElements[0].state?.raw_value, 0.75)
64
+ assert.strictEqual(iosElements[0].stable_id, 'playback_speed_slider')
65
+ assert.strictEqual(iosElements[0].role, 'slider')
66
+ assert.strictEqual(iosElements[0].test_tag, 'playback_speed_slider')
67
+ assert.deepStrictEqual(iosElements[0].selector, {
68
+ value: 'playback_speed_slider',
69
+ confidence: { score: 1, reason: 'accessibility_identifier' }
70
+ })
71
+ assert.deepStrictEqual(iosElements[0].semantic, { is_clickable: true, is_container: false })
72
+
73
+ const iosFallbackElements: any[] = []
74
+ traverseIDBNode({
75
+ AXElementType: 'Button',
76
+ AXLabel: 'Save',
77
+ AXTraits: ['UIAccessibilityTraitButton'],
78
+ AXUniqueId: 'fallback_unique_id'
79
+ }, iosFallbackElements)
80
+
81
+ assert.strictEqual(iosFallbackElements.length, 1)
82
+ assert.strictEqual(iosFallbackElements[0].stable_id, 'fallback_unique_id')
36
83
 
37
84
  console.log('state extraction tests passed')
38
85
  }
@@ -8,6 +8,7 @@ import { ToolsObserve } from '../../../src/observe/index.js'
8
8
  async function run() {
9
9
  const originalInstallAppHandler = (ToolsManage as any).installAppHandler
10
10
  const originalWaitForUIHandler = (ToolsInteract as any).waitForUIHandler
11
+ const originalWaitForUIChangeHandler = (ToolsInteract as any).waitForUIChangeHandler
11
12
  const originalTapElementHandler = (ToolsInteract as any).tapElementHandler
12
13
  const originalTapHandler = (ToolsInteract as any).tapHandler
13
14
  const originalExpectScreenHandler = (ToolsInteract as any).expectScreenHandler
@@ -145,12 +146,16 @@ async function run() {
145
146
  ;(ToolsObserve as any).getUITreeHandler = async () => ({
146
147
  device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
147
148
  resolution: { width: 1080, height: 2400 },
149
+ screen: 'Notifications',
148
150
  elements: [{
149
151
  text: 'Notifications',
150
152
  depth: 0,
151
153
  center: { x: 50, y: 20 },
152
154
  state: { checked: true, selected: 'Notifications' }
153
- }]
155
+ }],
156
+ snapshot_revision: 12,
157
+ captured_at_ms: 1710000000123,
158
+ loading_state: { active: true, signal: 'progress_indicator', source: 'ui_tree' }
154
159
  })
155
160
 
156
161
  ;(ToolsInteract as any).expectStateHandler = async () => ({
@@ -227,8 +232,12 @@ async function run() {
227
232
 
228
233
  ;(ToolsObserve as any).getUITreeHandler = async () => ({
229
234
  device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true },
235
+ screen: 'Login',
230
236
  resolution: { width: 1080, height: 2400 },
231
- elements: [{ text: 'Login', depth: 0, center: { x: 50, y: 20 } }]
237
+ elements: [{ text: 'Login', depth: 0, center: { x: 50, y: 20 } }],
238
+ snapshot_revision: 12,
239
+ captured_at_ms: 1710000000123,
240
+ loading_state: { active: true, signal: 'progress_indicator', source: 'ui_tree' }
232
241
  })
233
242
 
234
243
  const uiTreeResponse = await handleToolCall('get_ui_tree', { platform: 'android' })
@@ -236,16 +245,21 @@ async function run() {
236
245
  assert.strictEqual(uiTreePayload.elements.length, 1)
237
246
  assert.strictEqual(uiTreePayload.resolution.height, 2400)
238
247
  assert.strictEqual(uiTreePayload.elements[0].text, 'Login')
248
+ assert.strictEqual(uiTreePayload.snapshot_revision, 12)
249
+ assert.strictEqual(uiTreePayload.loading_state.signal, 'progress_indicator')
239
250
 
240
251
  ;(ToolsObserve as any).captureDebugSnapshotHandler = async () => ({
241
252
  raw: {
242
253
  timestamp: 1710000000000,
254
+ snapshot_revision: 12,
255
+ captured_at_ms: 1710000000123,
243
256
  reason: 'manual',
244
257
  activity: 'com.example.MainActivity',
245
258
  fingerprint: 'fp_raw',
246
259
  screenshot: 'base64',
247
- ui_tree: { screen: 'Home', elements: [] },
260
+ ui_tree: { screen: 'Home', elements: [], snapshot_revision: 12, captured_at_ms: 1710000000123, loading_state: { active: true, signal: 'spinner', source: 'snapshot' } },
248
261
  logs: [],
262
+ loading_state: { active: true, signal: 'spinner', source: 'snapshot' },
249
263
  device: { platform: 'android', id: 'mock', osVersion: '14', model: 'Pixel', simulator: true }
250
264
  },
251
265
  semantic: {
@@ -260,13 +274,33 @@ async function run() {
260
274
  const snapshotResponse = await handleToolCall('capture_debug_snapshot', { platform: 'android' })
261
275
  const snapshotPayload = JSON.parse((snapshotResponse as any).content[0].text)
262
276
  assert.strictEqual(snapshotPayload.raw.fingerprint, 'fp_raw')
277
+ assert.strictEqual(snapshotPayload.raw.snapshot_revision, 12)
278
+ assert.strictEqual(snapshotPayload.raw.loading_state.signal, 'spinner')
263
279
  assert.strictEqual(snapshotPayload.semantic.screen, 'Home')
264
280
  assert.strictEqual(snapshotPayload.semantic.confidence, 0.8)
265
281
 
282
+ ;(ToolsInteract as any).waitForUIChangeHandler = async () => ({
283
+ success: true,
284
+ observed_change: 'text_change',
285
+ snapshot_revision: 13,
286
+ timeout: false,
287
+ elapsed_ms: 1550,
288
+ expected_change: 'text_change',
289
+ loading_state: { active: false, signal: 'spinner', source: 'ui_tree' },
290
+ reason: 'UI change observed'
291
+ })
292
+
293
+ const waitForUIChangeResponse = await handleToolCall('wait_for_ui_change', { expected_change: 'text_change' })
294
+ const waitForUIChangePayload = JSON.parse((waitForUIChangeResponse as any).content[0].text)
295
+ assert.strictEqual(waitForUIChangePayload.success, true)
296
+ assert.strictEqual(waitForUIChangePayload.observed_change, 'text_change')
297
+ assert.strictEqual(waitForUIChangePayload.snapshot_revision, 13)
298
+
266
299
  console.log('server response-shape tests passed')
267
300
  } finally {
268
301
  ;(ToolsManage as any).installAppHandler = originalInstallAppHandler
269
302
  ;(ToolsInteract as any).waitForUIHandler = originalWaitForUIHandler
303
+ ;(ToolsInteract as any).waitForUIChangeHandler = originalWaitForUIChangeHandler
270
304
  ;(ToolsInteract as any).tapElementHandler = originalTapElementHandler
271
305
  ;(ToolsInteract as any).tapHandler = originalTapHandler
272
306
  ;(ToolsInteract as any).expectScreenHandler = originalExpectScreenHandler