mobile-debug-mcp 0.30.0 → 0.30.2

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 (44) hide show
  1. package/dist/interact/index.js +876 -155
  2. package/dist/observe/index.js +1 -0
  3. package/dist/observe/snapshot-metadata.js +62 -1
  4. package/dist/server/tool-definitions.js +20 -11
  5. package/dist/server/tool-handlers.js +7 -5
  6. package/dist/server-core.js +1 -1
  7. package/docs/CHANGELOG.md +9 -0
  8. package/docs/ROADMAP.md +85 -209
  9. package/docs/{rfcs → specs-deltas}/013-wait-and-synchronization-reliability.md +15 -35
  10. package/docs/{rfcs → specs-deltas}/014-actionability-resolution.md +32 -30
  11. package/docs/specs-deltas/015-adjustable-control-support.md +541 -0
  12. package/package.json +1 -1
  13. package/skills/spec-review/SKILL.md +52 -0
  14. package/skills/spec-review/references/spec-review-checklist.md +13 -0
  15. package/skills/spec-review/references/spec-review-template.md +28 -0
  16. package/src/interact/index.ts +1008 -171
  17. package/src/observe/index.ts +1 -0
  18. package/src/observe/snapshot-metadata.ts +69 -2
  19. package/src/server/tool-definitions.ts +20 -11
  20. package/src/server/tool-handlers.ts +7 -5
  21. package/src/server-core.ts +1 -1
  22. package/src/types.ts +26 -0
  23. package/test/unit/interact/adjust_control.test.ts +690 -33
  24. package/test/unit/interact/subtree_collection.test.ts +24 -0
  25. package/test/unit/interact/tap_element.test.ts +71 -0
  26. package/test/unit/interact/wait_for_ui_change.test.ts +92 -1
  27. package/test/unit/observe/snapshot_metadata.test.ts +67 -0
  28. package/test/unit/server/contract.test.ts +4 -0
  29. package/skills/rfc-review/SKILL.md +0 -52
  30. package/skills/rfc-review/references/rfc-review-checklist.md +0 -12
  31. package/skills/rfc-review/references/rfc-review-template.md +0 -28
  32. /package/docs/{rfcs → specs-deltas}/001-state-verification.md +0 -0
  33. /package/docs/{rfcs → specs-deltas}/002-richer-element-identity +0 -0
  34. /package/docs/{rfcs → specs-deltas}/003-wait-and-synchronization-reliability.md +0 -0
  35. /package/docs/{rfcs → specs-deltas}/004-action-verification-routing.md +0 -0
  36. /package/docs/{rfcs → specs-deltas}/005-unified-action-execution-and-verification-model.md +0 -0
  37. /package/docs/{rfcs → specs-deltas}/006-runtime-action-instrumentation-and-binding-layer.md +0 -0
  38. /package/docs/{rfcs → specs-deltas}/007-actionability-resolution-and-executable-target-selection.md +0 -0
  39. /package/docs/{rfcs → specs-deltas}/008-adjustable-control-support-and-semantic-value-manipulation.md +0 -0
  40. /package/docs/{rfcs → specs-deltas}/009-semantic-control-modeling-for-custom-and-composite-controls.md +0 -0
  41. /package/docs/{rfcs → specs-deltas}/010-verification-stabilization-and-temporal-convergence.md +0 -0
  42. /package/docs/{rfcs → specs-deltas}/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md +0 -0
  43. /package/docs/{rfcs → specs-deltas}/011.1-recovery-contract-types-and-runtime-wiring-spec.md +0 -0
  44. /package/docs/{rfcs → specs-deltas}/012-action-trace-and-xecution-observability.md +0 -0
@@ -43,6 +43,7 @@ interface UiElement {
43
43
  focusable?: boolean
44
44
  visible?: boolean
45
45
  parentId?: number | string | null
46
+ children?: number[]
46
47
  _index?: number
47
48
  _interactable?: boolean
48
49
  _sliderLike?: boolean
@@ -52,6 +53,10 @@ interface UiElement {
52
53
  test_tag?: string | null
53
54
  selector?: { value: string | null, confidence: { score: number, reason: string } | null } | null
54
55
  semantic?: UIElementSemanticMetadata | null
56
+ actions?: string[] | null
57
+ accessibilityActions?: string[] | null
58
+ runtimeActions?: string[] | null
59
+ platformActions?: string[] | null
55
60
  }
56
61
 
57
62
  interface ResolvedUiElementContext {
@@ -60,6 +65,7 @@ interface ResolvedUiElementContext {
60
65
  deviceId?: string
61
66
  bounds: [number, number, number, number] | null
62
67
  index: number
68
+ stable_id?: string | null
63
69
  }
64
70
 
65
71
  interface UiResolution {
@@ -73,6 +79,24 @@ interface UiChangeSignatureSet {
73
79
  state: string | null
74
80
  }
75
81
 
82
+ interface UiChangeScopeResolution {
83
+ scope: 'screen' | 'subtree'
84
+ target: string | null
85
+ resolved: boolean
86
+ resolvedIndex: number | null
87
+ resolvedStableId: string | null
88
+ reason: string
89
+ }
90
+
91
+ interface UiChangeScopeResult {
92
+ elements: UiElement[]
93
+ resolution: UiChangeScopeResolution
94
+ error?: {
95
+ code: 'INVALID_SCOPE' | 'ELEMENT_NOT_FOUND'
96
+ message: string
97
+ }
98
+ }
99
+
76
100
  interface RankedResolutionCandidate {
77
101
  el: UiElement
78
102
  idx: number
@@ -81,6 +105,15 @@ interface RankedResolutionCandidate {
81
105
  interactable: boolean
82
106
  }
83
107
 
108
+ interface InferredLabeledSliderState {
109
+ current: number
110
+ min: number
111
+ max: number
112
+ value_range: { min: number; max: number }
113
+ touch_bounds: [number, number, number, number]
114
+ source: 'nearby_labels'
115
+ }
116
+
84
117
  function buildObservationTrace({
85
118
  actionType,
86
119
  stage,
@@ -201,11 +234,88 @@ export class ToolsInteract {
201
234
  return null
202
235
  }
203
236
 
237
+ private static _resolveParentIndex(elements: UiElement[], parentId: number | string | null | undefined): number | null {
238
+ if (parentId === undefined || parentId === null) return null
239
+
240
+ if (typeof parentId === 'number' && Number.isInteger(parentId) && parentId >= 0 && parentId < elements.length) {
241
+ return parentId
242
+ }
243
+
244
+ if (typeof parentId === 'string') {
245
+ const normalized = ToolsInteract._normalize(parentId)
246
+ if (!normalized) return null
247
+
248
+ if (/^\d+$/.test(normalized)) {
249
+ const index = Number(normalized)
250
+ if (index >= 0 && index < elements.length) return index
251
+ }
252
+
253
+ const foundIndex = elements.findIndex((el) => {
254
+ if (!el) return false
255
+ return ToolsInteract._normalize(el.resourceId ?? el.resourceID ?? el.id ?? '') === normalized ||
256
+ ToolsInteract._normalize(el.stable_id ?? '') === normalized
257
+ })
258
+
259
+ return foundIndex >= 0 ? foundIndex : null
260
+ }
261
+
262
+ return null
263
+ }
264
+
204
265
  private static _isVisibleElement(el: UiElement): boolean {
205
266
  const bounds = ToolsInteract._normalizeBounds(el.bounds)
206
267
  return !!el.visible && !!bounds && bounds[2] > bounds[0] && bounds[3] > bounds[1]
207
268
  }
208
269
 
270
+ private static _isTapActionable(
271
+ el: UiElement,
272
+ storedStableId?: string | null,
273
+ platform?: 'android' | 'ios'
274
+ ): { actionable: boolean, failureCode?: ActionFailureCode, reason?: string } {
275
+ if (!ToolsInteract._isVisibleElement(el)) {
276
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is not visible' }
277
+ }
278
+
279
+ if (el.enabled === false) {
280
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is disabled' }
281
+ }
282
+
283
+ const semanticTapActionable = !!el.semantic && (
284
+ el.semantic.is_clickable ||
285
+ (Array.isArray(el.semantic.supported_actions) && el.semantic.supported_actions.some((action) => ToolsInteract._normalize(action) === 'tap'))
286
+ )
287
+
288
+ if (!el.clickable && !(platform === 'ios' && semanticTapActionable)) {
289
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is not clickable' }
290
+ }
291
+
292
+ if (storedStableId) {
293
+ if (!el.stable_id || el.stable_id !== storedStableId) {
294
+ return { actionable: false, failureCode: 'STALE_REFERENCE', reason: 'element stable_id changed' }
295
+ }
296
+ }
297
+
298
+ return { actionable: true }
299
+ }
300
+
301
+ private static _isAdjustableActionable(el: UiElement, storedStableId?: string | null): { actionable: boolean, failureCode?: ActionFailureCode, reason?: string } {
302
+ if (!ToolsInteract._isVisibleElement(el)) {
303
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is not visible' }
304
+ }
305
+
306
+ if (el.enabled === false) {
307
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is disabled' }
308
+ }
309
+
310
+ if (storedStableId) {
311
+ if (!el.stable_id || el.stable_id !== storedStableId) {
312
+ return { actionable: false, failureCode: 'STALE_REFERENCE', reason: 'element stable_id changed' }
313
+ }
314
+ }
315
+
316
+ return { actionable: true }
317
+ }
318
+
209
319
  private static _computeElementId(platform: 'android' | 'ios', deviceId: string | undefined, el: UiElement, index: number): string {
210
320
  const identity = {
211
321
  platform,
@@ -229,7 +339,8 @@ export class ToolsInteract {
229
339
  platform,
230
340
  deviceId,
231
341
  bounds,
232
- index
342
+ index,
343
+ stable_id: el.stable_id ?? null
233
344
  })
234
345
 
235
346
  return {
@@ -249,6 +360,235 @@ export class ToolsInteract {
249
360
  }
250
361
  }
251
362
 
363
+ private static _resolveUiChangeScope(
364
+ tree: any,
365
+ scope: 'screen' | 'subtree' | undefined,
366
+ target: string | null | undefined
367
+ ): UiChangeScopeResult {
368
+ const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
369
+ const normalizedScope = scope === 'subtree' ? 'subtree' : 'screen'
370
+
371
+ if (normalizedScope === 'screen') {
372
+ return {
373
+ elements,
374
+ resolution: {
375
+ scope: 'screen',
376
+ target: null,
377
+ resolved: true,
378
+ resolvedIndex: null,
379
+ resolvedStableId: null,
380
+ reason: 'screen scope'
381
+ }
382
+ }
383
+ }
384
+
385
+ const requestedTarget = typeof target === 'string' && target.trim().length > 0 ? target.trim() : null
386
+ if (!requestedTarget) {
387
+ return {
388
+ elements: [],
389
+ resolution: {
390
+ scope: 'subtree',
391
+ target: null,
392
+ resolved: false,
393
+ resolvedIndex: null,
394
+ resolvedStableId: null,
395
+ reason: 'subtree scope requires a target element id'
396
+ },
397
+ error: {
398
+ code: 'INVALID_SCOPE',
399
+ message: 'scope=subtree requires a target element_id'
400
+ }
401
+ }
402
+ }
403
+
404
+ const resolved = ToolsInteract._findScopedElement(tree, requestedTarget)
405
+ if (!resolved) {
406
+ return {
407
+ elements: [],
408
+ resolution: {
409
+ scope: 'subtree',
410
+ target: requestedTarget,
411
+ resolved: false,
412
+ resolvedIndex: null,
413
+ resolvedStableId: null,
414
+ reason: 'target element could not be resolved'
415
+ },
416
+ error: {
417
+ code: 'ELEMENT_NOT_FOUND',
418
+ message: `Target element ${requestedTarget} could not be resolved for subtree scope`
419
+ }
420
+ }
421
+ }
422
+
423
+ const subtreeIndices = ToolsInteract._collectSubtreeIndices(elements, resolved.index)
424
+ const scopedElements = subtreeIndices.map((index) => elements[index]).filter((element): element is UiElement => !!element)
425
+
426
+ return {
427
+ elements: scopedElements,
428
+ resolution: {
429
+ scope: 'subtree',
430
+ target: requestedTarget,
431
+ resolved: true,
432
+ resolvedIndex: resolved.index,
433
+ resolvedStableId: resolved.stableId,
434
+ reason: resolved.reason
435
+ }
436
+ }
437
+ }
438
+
439
+ private static _findScopedElement(tree: any, targetElementId: string): { index: number, stableId: string | null, reason: string } | null {
440
+ const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
441
+ const platform = tree?.device?.platform === 'ios' ? 'ios' : 'android'
442
+ const deviceId = tree?.device?.id ?? undefined
443
+ const normalizedTarget = ToolsInteract._normalize(targetElementId)
444
+
445
+ for (let i = 0; i < elements.length; i++) {
446
+ const el = elements[i]
447
+ if (!el) continue
448
+
449
+ const computedElementId = ToolsInteract._computeElementId(platform, deviceId, el, i)
450
+ if (computedElementId === targetElementId) {
451
+ return {
452
+ index: i,
453
+ stableId: el.stable_id ?? null,
454
+ reason: 'element_id_match'
455
+ }
456
+ }
457
+ }
458
+
459
+ for (let i = 0; i < elements.length; i++) {
460
+ const el = elements[i]
461
+ if (!el) continue
462
+
463
+ if (el.stable_id && ToolsInteract._normalize(el.stable_id) === normalizedTarget) {
464
+ return {
465
+ index: i,
466
+ stableId: el.stable_id,
467
+ reason: 'stable_id_match'
468
+ }
469
+ }
470
+ }
471
+
472
+ const storedContext = ToolsInteract._resolvedUiElements.get(targetElementId)
473
+ if (storedContext?.stable_id) {
474
+ const normalizedStoredStableId = ToolsInteract._normalize(storedContext.stable_id)
475
+ for (let i = 0; i < elements.length; i++) {
476
+ const el = elements[i]
477
+ if (!el?.stable_id) continue
478
+ if (ToolsInteract._normalize(el.stable_id) === normalizedStoredStableId) {
479
+ return {
480
+ index: i,
481
+ stableId: el.stable_id,
482
+ reason: 'stored_stable_id_match'
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ return null
489
+ }
490
+
491
+ private static _collectSubtreeIndices(elements: UiElement[], rootIndex: number): number[] {
492
+ if (!Array.isArray(elements) || rootIndex < 0 || rootIndex >= elements.length) return []
493
+
494
+ const visited = new Set<number>()
495
+ const stack = [rootIndex]
496
+ const result: number[] = []
497
+
498
+ while (stack.length > 0) {
499
+ const index = stack.pop()
500
+ if (index === undefined || visited.has(index) || index < 0 || index >= elements.length) continue
501
+ visited.add(index)
502
+ result.push(index)
503
+
504
+ const element = elements[index]
505
+ if (!element) continue
506
+
507
+ const directChildren = new Set<number>()
508
+ if (Array.isArray(element.children)) {
509
+ for (const childIndex of element.children) {
510
+ if (typeof childIndex === 'number' && Number.isInteger(childIndex) && childIndex >= 0 && childIndex < elements.length) {
511
+ directChildren.add(childIndex)
512
+ }
513
+ }
514
+ }
515
+
516
+ for (let i = 0; i < elements.length; i++) {
517
+ if (ToolsInteract._resolveParentIndex(elements, elements[i]?.parentId) === index) {
518
+ directChildren.add(i)
519
+ }
520
+ }
521
+
522
+ for (const childIndex of directChildren) {
523
+ if (!visited.has(childIndex)) stack.push(childIndex)
524
+ }
525
+ }
526
+
527
+ return result.sort((left, right) => left - right)
528
+ }
529
+
530
+ private static _changeIdentityForElement(el: UiElement, index: number): string {
531
+ const stableId = ToolsInteract._normalize(el.stable_id)
532
+ if (stableId) return `stable:${stableId}`
533
+
534
+ return `fallback:${ToolsInteract._hash({
535
+ text: ToolsInteract._normalize(el.text ?? el.label ?? el.value ?? ''),
536
+ contentDescription: ToolsInteract._normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? ''),
537
+ resourceId: ToolsInteract._normalize(el.resourceId ?? el.resourceID ?? el.id ?? ''),
538
+ type: ToolsInteract._normalize(el.type ?? el.class ?? ''),
539
+ bounds: ToolsInteract._normalizeBounds(el.bounds) ?? [0, 0, 0, 0],
540
+ index
541
+ })}`
542
+ }
543
+
544
+ private static _summarizeUiChangeDelta(initialElements: UiElement[], currentElements: UiElement[]) {
545
+ const buildMap = (elements: UiElement[]) => {
546
+ const map = new Map<string, string>()
547
+ for (let i = 0; i < elements.length; i++) {
548
+ const element = elements[i]
549
+ if (!element) continue
550
+ const key = ToolsInteract._changeIdentityForElement(element, i)
551
+ map.set(key, ToolsInteract._hash({
552
+ text: ToolsInteract._normalize(element.text ?? element.label ?? element.value ?? ''),
553
+ contentDescription: ToolsInteract._normalize(element.contentDescription ?? element.contentDesc ?? element.accessibilityLabel ?? ''),
554
+ resourceId: ToolsInteract._normalize(element.resourceId ?? element.resourceID ?? element.id ?? ''),
555
+ type: ToolsInteract._normalize(element.type ?? element.class ?? ''),
556
+ bounds: ToolsInteract._normalizeBounds(element.bounds) ?? [0, 0, 0, 0],
557
+ state: element.state ?? null,
558
+ visible: !!element.visible,
559
+ enabled: !!element.enabled,
560
+ clickable: !!element.clickable
561
+ }))
562
+ }
563
+ return map
564
+ }
565
+
566
+ const initialMap = buildMap(initialElements)
567
+ const currentMap = buildMap(currentElements)
568
+ let added = 0
569
+ let removed = 0
570
+ let mutated = 0
571
+
572
+ for (const [key, value] of currentMap.entries()) {
573
+ if (!initialMap.has(key)) {
574
+ added++
575
+ } else if (initialMap.get(key) !== value) {
576
+ mutated++
577
+ }
578
+ }
579
+
580
+ for (const key of initialMap.keys()) {
581
+ if (!currentMap.has(key)) removed++
582
+ }
583
+
584
+ return {
585
+ total_elements: currentElements.length,
586
+ added_elements: added,
587
+ removed_elements: removed,
588
+ mutated_elements: mutated
589
+ }
590
+ }
591
+
252
592
  private static _rememberResolvedElement(elementId: string, context: ResolvedUiElementContext) {
253
593
  if (ToolsInteract._resolvedUiElements.has(elementId)) {
254
594
  ToolsInteract._resolvedUiElements.delete(elementId)
@@ -392,13 +732,217 @@ export class ToolsInteract {
392
732
  if (!el?.state) return null
393
733
  const stateValue = el.state[property as keyof UIElementState]
394
734
  if (typeof stateValue === 'number' && Number.isFinite(stateValue)) return stateValue
735
+ const parsedStateValue = ToolsInteract._parseNumericControlValue(stateValue)
736
+ if (parsedStateValue !== null) return parsedStateValue
395
737
  if (property === 'value' || property === 'raw_value') {
396
738
  const fallback = el.state.raw_value ?? el.state.value
397
739
  if (typeof fallback === 'number' && Number.isFinite(fallback)) return fallback
740
+ return ToolsInteract._parseNumericControlValue(fallback)
741
+ }
742
+ return null
743
+ }
744
+
745
+ private static _parseNumericControlValue(value: unknown): number | null {
746
+ if (typeof value === 'number' && Number.isFinite(value)) return value
747
+ if (typeof value !== 'string') return null
748
+ const trimmed = value.trim()
749
+ if (!trimmed) return null
750
+
751
+ const percent = trimmed.match(/^([-+]?\d+(?:\.\d+)?)\s*%$/)
752
+ if (percent) return Number(percent[1])
753
+
754
+ const fraction = trimmed.match(/^([-+]?\d+(?:\.\d+)?)\s+of\s+[-+]?\d+(?:\.\d+)?$/i)
755
+ if (fraction) return Number(fraction[1])
756
+
757
+ const plain = Number(trimmed)
758
+ if (Number.isFinite(plain)) return plain
759
+
760
+ const trailing = trimmed.match(/,\s*([-+]?\d+(?:\.\d+)?)$/)
761
+ if (trailing) return Number(trailing[1])
762
+
763
+ return null
764
+ }
765
+
766
+ private static _readNumericControlRange(el: UiElement | null): { min: number; max: number } | null {
767
+ const min = typeof el?.state?.value_range?.min === 'number' ? el.state.value_range.min : null
768
+ const max = typeof el?.state?.value_range?.max === 'number' ? el.state.value_range.max : null
769
+ if (min !== null && max !== null && Number.isFinite(min) && Number.isFinite(max) && max > min) {
770
+ return { min, max }
771
+ }
772
+
773
+ const value = el?.state?.value ?? el?.state?.raw_value
774
+ if (typeof value === 'string') {
775
+ const fraction = value.trim().match(/^[-+]?\d+(?:\.\d+)?\s+of\s+([-+]?\d+(?:\.\d+)?)$/i)
776
+ if (fraction) {
777
+ const parsedMax = Number(fraction[1])
778
+ if (Number.isFinite(parsedMax) && parsedMax > 0) return { min: 0, max: parsedMax }
779
+ }
780
+ }
781
+
782
+ return null
783
+ }
784
+
785
+ private static _readControlStep(el: UiElement | null): number | null {
786
+ const candidates = [
787
+ el?.state?.step,
788
+ el?.state?.value_range?.step
789
+ ]
790
+ for (const candidate of candidates) {
791
+ if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) return candidate
398
792
  }
399
793
  return null
400
794
  }
401
795
 
796
+ private static _quantizeTargetValue(targetValue: number, min: number, max: number, step: number | null): number {
797
+ if (!step) return targetValue
798
+ const quantized = min + (Math.round((targetValue - min) / step) * step)
799
+ const bounded = Math.max(min, Math.min(max, quantized))
800
+ const precision = ToolsInteract._decimalPrecisionForStep(step)
801
+ return Number(bounded.toFixed(precision))
802
+ }
803
+
804
+ private static _decimalPrecisionForStep(step: number): number {
805
+ const normalized = String(step).toLowerCase()
806
+ const [coefficient, exponentPart] = normalized.split('e')
807
+ const coefficientDecimals = coefficient.includes('.')
808
+ ? coefficient.split('.')[1]?.length ?? 0
809
+ : 0
810
+ const exponent = exponentPart !== undefined ? Number(exponentPart) : 0
811
+ const precision = exponent < 0
812
+ ? coefficientDecimals + Math.abs(exponent)
813
+ : Math.max(0, coefficientDecimals - exponent)
814
+ return Math.max(0, Math.min(12, precision))
815
+ }
816
+
817
+ private static _defaultAdjustTolerance(range: { min: number; max: number } | null, step: number | null): number {
818
+ if (step) return 0
819
+ if (!range) return 0
820
+ return Math.max(0, 0.01 * Math.abs(range.max - range.min))
821
+ }
822
+
823
+ private static _runtimeActionSet(el: UiElement | null): Set<string> {
824
+ const values = [
825
+ ...(Array.isArray(el?.actions) ? el!.actions! : []),
826
+ ...(Array.isArray(el?.accessibilityActions) ? el!.accessibilityActions! : []),
827
+ ...(Array.isArray(el?.runtimeActions) ? el!.runtimeActions! : []),
828
+ ...(Array.isArray(el?.platformActions) ? el!.platformActions! : [])
829
+ ]
830
+ return new Set(values.map((value) => ToolsInteract._normalize(value)).filter(Boolean))
831
+ }
832
+
833
+ private static _hasDirectAdjustmentAction(el: UiElement | null): boolean {
834
+ const actions = ToolsInteract._runtimeActionSet(el)
835
+ return [
836
+ 'action_set_progress',
837
+ 'set_progress',
838
+ 'setprogress',
839
+ 'semanticsactions.setprogress',
840
+ 'set_value',
841
+ 'setvalue',
842
+ 'direct_set_value'
843
+ ].some((action) => actions.has(action))
844
+ }
845
+
846
+ private static _adjustmentDirections(el: UiElement | null): { increment: boolean; decrement: boolean } {
847
+ const actions = ToolsInteract._runtimeActionSet(el)
848
+ return {
849
+ increment: ['increment', 'accessibility_increment', 'action_increment'].some((action) => actions.has(action)),
850
+ decrement: ['decrement', 'accessibility_decrement', 'action_decrement'].some((action) => actions.has(action))
851
+ }
852
+ }
853
+
854
+ private static _textFromElement(el: UiElement | null): string {
855
+ return String(el?.text ?? el?.contentDescription ?? el?.contentDesc ?? el?.accessibilityLabel ?? el?.value ?? '').trim()
856
+ }
857
+
858
+ private static _parseLeadingNumber(text: string): number | null {
859
+ const match = text.trim().match(/^([-+]?\d+(?:\.\d+)?)(?:\s*[a-zA-Z%]+)?$/)
860
+ if (!match) return null
861
+ const value = Number(match[1])
862
+ return Number.isFinite(value) ? value : null
863
+ }
864
+
865
+ private static _inferLabeledSliderState(elements: UiElement[], track: UiElement | null): InferredLabeledSliderState | null {
866
+ const trackBounds = ToolsInteract._normalizeBounds(track?.bounds)
867
+ if (!track || !trackBounds) return null
868
+ const type = ToolsInteract._normalize(track.type ?? track.class ?? '')
869
+ const role = ToolsInteract._normalize(track.role ?? '')
870
+ const width = trackBounds[2] - trackBounds[0]
871
+ const height = trackBounds[3] - trackBounds[1]
872
+ const wideTrackLike = width >= 240 && height <= 220
873
+ const knownTrackLike = /slider|seekbar|range/.test(type) || /slider|range/.test(role)
874
+ if (!wideTrackLike && !knownTrackLike) return null
875
+
876
+ const trackTop = trackBounds[1]
877
+ const trackBottom = trackBounds[3]
878
+ const trackCenterX = (trackBounds[0] + trackBounds[2]) / 2
879
+ const labels = elements
880
+ .map((el, index) => {
881
+ const bounds = ToolsInteract._normalizeBounds(el.bounds)
882
+ const text = ToolsInteract._textFromElement(el)
883
+ const value = ToolsInteract._parseLeadingNumber(text)
884
+ return bounds && text && value !== null
885
+ ? {
886
+ index,
887
+ el,
888
+ text,
889
+ value,
890
+ bounds,
891
+ centerX: (bounds[0] + bounds[2]) / 2,
892
+ centerY: (bounds[1] + bounds[3]) / 2,
893
+ hasUnit: /[a-zA-Z%]/.test(text)
894
+ }
895
+ : null
896
+ })
897
+ .filter((label): label is NonNullable<typeof label> => !!label)
898
+
899
+ const rangeLabels = labels
900
+ .filter((label) => {
901
+ const verticallyNearTrack = label.centerY >= trackTop - 520 && label.centerY <= trackBottom + 260
902
+ const horizontallyNearTrack = label.centerX >= trackBounds[0] - 120 && label.centerX <= trackBounds[2] + 120
903
+ return verticallyNearTrack && horizontallyNearTrack && label.hasUnit
904
+ })
905
+ .sort((a, b) => a.centerX - b.centerX)
906
+
907
+ if (rangeLabels.length < 2) return null
908
+ const minLabel = rangeLabels[0]
909
+ const maxLabel = rangeLabels[rangeLabels.length - 1]
910
+ if (!minLabel || !maxLabel || maxLabel.value <= minLabel.value) return null
911
+
912
+ const currentCandidates = labels
913
+ .filter((label) => {
914
+ if (label.index === minLabel.index || label.index === maxLabel.index) return false
915
+ if (label.value < minLabel.value || label.value > maxLabel.value) return false
916
+ if (label.centerY < trackTop - 950 || label.centerY > minLabel.centerY - 20) return false
917
+ return label.centerX >= trackCenterX - 80
918
+ })
919
+ .sort((a, b) => {
920
+ const yDistance = Math.abs((trackTop - 420) - a.centerY) - Math.abs((trackTop - 420) - b.centerY)
921
+ if (yDistance !== 0) return yDistance
922
+ return b.centerX - a.centerX
923
+ })
924
+
925
+ const currentLabel = currentCandidates[0]
926
+ if (!currentLabel) return null
927
+
928
+ const candidateBelowRangeLabels = minLabel.centerY < trackTop && maxLabel.centerY < trackTop
929
+ const labelDerivedBounds: [number, number, number, number] = [
930
+ Math.min(minLabel.bounds[0], trackBounds[0]),
931
+ Math.max(currentLabel.bounds[3] + 12, Math.min(minLabel.bounds[1], maxLabel.bounds[1]) - 160),
932
+ Math.max(maxLabel.bounds[2], trackBounds[2]),
933
+ Math.max(currentLabel.bounds[3] + 80, Math.min(minLabel.bounds[1], maxLabel.bounds[1]) - 32)
934
+ ]
935
+
936
+ return {
937
+ current: currentLabel.value,
938
+ min: minLabel.value,
939
+ max: maxLabel.value,
940
+ value_range: { min: minLabel.value, max: maxLabel.value },
941
+ touch_bounds: candidateBelowRangeLabels ? labelDerivedBounds : trackBounds,
942
+ source: 'nearby_labels'
943
+ }
944
+ }
945
+
402
946
  private static _buildControlPoint(bounds: [number, number, number, number], ratio: number, axis: 'horizontal' | 'vertical') {
403
947
  const clampedRatio = Math.max(0, Math.min(1, ratio))
404
948
  const [left, top, right, bottom] = bounds
@@ -565,20 +1109,11 @@ export class ToolsInteract {
565
1109
  let safety = 0
566
1110
 
567
1111
  while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) && current.el.parentId !== undefined && current.el.parentId !== null) {
568
- const parentId = current.el.parentId
569
- let parentIndex: number | null = null
570
-
571
- if (typeof parentId === 'number') parentIndex = parentId
572
- else if (typeof parentId === 'string' && /^\d+$/.test(parentId)) parentIndex = Number(parentId)
1112
+ const parentIndex = ToolsInteract._resolveParentIndex(elements, current.el.parentId)
573
1113
 
574
1114
  if (parentIndex !== null && elements[parentIndex]) {
575
1115
  current = { el: elements[parentIndex], idx: parentIndex }
576
1116
  if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) return current
577
- } else if (typeof parentId === 'string') {
578
- const foundIndex = elements.findIndex((el) => el.resourceId === parentId || el.id === parentId)
579
- if (foundIndex === -1) break
580
- current = { el: elements[foundIndex], idx: foundIndex }
581
- if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) return current
582
1117
  } else {
583
1118
  break
584
1119
  }
@@ -718,12 +1253,16 @@ export class ToolsInteract {
718
1253
 
719
1254
  const resolvedTarget = ToolsInteract._resolvedTargetFromElement(resolved.elementId, currentMatch.el, currentMatch.index)
720
1255
 
721
- if (!ToolsInteract._isVisibleElement(currentMatch.el)) {
722
- return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore)
723
- }
724
-
725
- if (currentMatch.el.enabled === false) {
726
- return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore)
1256
+ const tapActionability = ToolsInteract._isTapActionable(currentMatch.el, resolved.stable_id, resolved.platform)
1257
+ if (!tapActionability.actionable) {
1258
+ return ToolsInteract._actionFailure(
1259
+ actionType,
1260
+ selector,
1261
+ resolvedTarget,
1262
+ tapActionability.failureCode ?? 'ELEMENT_NOT_INTERACTABLE',
1263
+ true,
1264
+ fingerprintBefore
1265
+ )
727
1266
  }
728
1267
 
729
1268
  const bounds = ToolsInteract._normalizeBounds(currentMatch.el.bounds) ?? resolved.bounds
@@ -758,8 +1297,8 @@ export class ToolsInteract {
758
1297
  element_id,
759
1298
  property = 'value',
760
1299
  targetValue,
761
- tolerance = 0,
762
- maxAttempts = 3,
1300
+ tolerance,
1301
+ maxAttempts,
763
1302
  platform,
764
1303
  deviceId
765
1304
  }: {
@@ -774,11 +1313,14 @@ export class ToolsInteract {
774
1313
  }): Promise<AdjustControlResponse> {
775
1314
  const actionType = 'adjust_control'
776
1315
  const targetSelector = selector ?? (element_id ? { elementId: element_id } : null)
777
- const normalizedTolerance = Number.isFinite(tolerance) ? Math.max(0, tolerance) : 0
778
- const attemptsLimit = Math.max(1, Math.floor(Number(maxAttempts) || 1))
1316
+ const requestedTolerance = typeof tolerance === 'number' && Number.isFinite(tolerance) ? Math.max(0, tolerance) : null
1317
+ const attemptsLimit = maxAttempts === undefined || maxAttempts === null
1318
+ ? 5
1319
+ : Math.max(1, Math.floor(Number(maxAttempts) || 1))
779
1320
  const sourcePlatform: 'android' | 'ios' = platform || 'android'
780
1321
  let resolvedPlatform = sourcePlatform
781
1322
  let resolvedDeviceId = deviceId
1323
+ const storedResolvedTarget = element_id ? ToolsInteract._resolvedUiElements.get(element_id) ?? null : null
782
1324
  const fingerprintBefore = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
783
1325
  let semanticFallbackElement: FindElementResponse['element'] | null = null
784
1326
  const traceSteps: TraceStep[] = []
@@ -825,6 +1367,7 @@ export class ToolsInteract {
825
1367
  retry_depth: attempts
826
1368
  })
827
1369
  }
1370
+ const responseTolerance = requestedTolerance ?? 0
828
1371
  const base = buildActionExecutionResult({
829
1372
  actionType,
830
1373
  sourceModule: 'interact',
@@ -837,7 +1380,7 @@ export class ToolsInteract {
837
1380
  failure: { failureCode, retryable },
838
1381
  details: {
839
1382
  target_value: targetValue,
840
- tolerance: normalizedTolerance,
1383
+ tolerance: responseTolerance,
841
1384
  property,
842
1385
  attempts,
843
1386
  adjustment_mode: adjustmentMode,
@@ -854,7 +1397,7 @@ export class ToolsInteract {
854
1397
  target_state: {
855
1398
  property,
856
1399
  target_value: targetValue,
857
- tolerance: normalizedTolerance
1400
+ tolerance: responseTolerance
858
1401
  },
859
1402
  actual_state: actualState,
860
1403
  within_tolerance: false,
@@ -972,8 +1515,8 @@ export class ToolsInteract {
972
1515
  return null
973
1516
  }
974
1517
 
975
- if (!selector && !element_id) {
976
- return buildFailure('ELEMENT_NOT_FOUND', 'selector or element_id is required', null, undefined, null, 0, 'gesture', false)
1518
+ if ((!selector && !element_id) || (selector && element_id)) {
1519
+ return buildFailure('ELEMENT_NOT_FOUND', 'exactly one of selector or element_id is required', null, undefined, null, 0, 'gesture', false)
977
1520
  }
978
1521
 
979
1522
  if (selector && !element_id) {
@@ -1035,8 +1578,10 @@ export class ToolsInteract {
1035
1578
  let currentDevice: any = undefined
1036
1579
  let attemptCount = 0
1037
1580
  let cachedResolvedMatch: { el: UiElement, idx: number } | null = null
1581
+ let coordinateTapsAwaitingFreshGesture = false
1038
1582
 
1039
- for (let attempt = 0; attempt < attemptsLimit; attempt++) {
1583
+ adjustmentAttempts:
1584
+ for (let attempt = 0; attempt < attemptsLimit && attemptCount < attemptsLimit; attempt++) {
1040
1585
  const resolved: {
1041
1586
  tree: any
1042
1587
  device: any
@@ -1062,11 +1607,44 @@ export class ToolsInteract {
1062
1607
  resolvedTarget = resolved.resolvedTarget
1063
1608
  const currentEl: UiElement = resolved.match.el
1064
1609
  cachedResolvedMatch = resolved.match
1065
- const bounds = ToolsInteract._normalizeBounds(currentEl.bounds)
1066
- const valueRange = currentEl.state?.value_range ?? null
1067
- const currentValue = ToolsInteract._readNumericControlValue(currentEl, property)
1610
+
1611
+ const adjustableActionability = ToolsInteract._isAdjustableActionable(currentEl, storedResolvedTarget?.stable_id)
1612
+ if (!adjustableActionability.actionable) {
1613
+ return buildFailure(
1614
+ adjustableActionability.failureCode ?? 'ELEMENT_NOT_INTERACTABLE',
1615
+ adjustableActionability.reason ?? 'adjustable control is not actionable',
1616
+ resolvedTarget,
1617
+ currentDevice,
1618
+ lastObservedState,
1619
+ attemptCount,
1620
+ lastAdjustmentMode,
1621
+ true
1622
+ )
1623
+ }
1624
+
1625
+ const elementsForInference = Array.isArray(resolved.tree?.elements)
1626
+ ? resolved.tree.elements as UiElement[]
1627
+ : cachedResolvedMatch
1628
+ ? [cachedResolvedMatch.el]
1629
+ : [currentEl]
1630
+ const inferredSliderState = ToolsInteract._inferLabeledSliderState(elementsForInference, currentEl)
1631
+ const nativeBounds = ToolsInteract._normalizeBounds(currentEl.bounds)
1632
+ const bounds = inferredSliderState?.touch_bounds ?? nativeBounds
1633
+ const valueRange = ToolsInteract._readNumericControlRange(currentEl) ?? inferredSliderState?.value_range ?? null
1634
+ const step = ToolsInteract._readControlStep(currentEl)
1635
+ const normalizedTargetValue = valueRange
1636
+ ? ToolsInteract._quantizeTargetValue(targetValue, valueRange.min, valueRange.max, step)
1637
+ : targetValue
1638
+ const effectiveTolerance = requestedTolerance ?? ToolsInteract._defaultAdjustTolerance(valueRange, step)
1639
+ const currentValue = ToolsInteract._readNumericControlValue(currentEl, property) ?? inferredSliderState?.current ?? null
1068
1640
  const actualState = currentValue !== null
1069
- ? { property, value: currentValue, raw_value: typeof currentEl.state?.raw_value === 'number' ? currentEl.state.raw_value : undefined }
1641
+ ? {
1642
+ property,
1643
+ value: currentValue,
1644
+ raw_value: typeof currentEl.state?.raw_value === 'number'
1645
+ ? currentEl.state.raw_value
1646
+ : inferredSliderState?.current
1647
+ }
1070
1648
  : null
1071
1649
 
1072
1650
  lastObservedState = actualState
@@ -1083,10 +1661,23 @@ export class ToolsInteract {
1083
1661
  return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjust_control currently supports numeric value and raw_value properties only', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1084
1662
  }
1085
1663
 
1086
- if (currentValue !== null && Math.abs(currentValue - targetValue) <= normalizedTolerance) {
1664
+ const isAdjustableControl = ToolsInteract._isAdjustableControl(currentEl) || !!inferredSliderState
1665
+ if (!isAdjustableControl) {
1666
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'target is not an adjustable control', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1667
+ }
1668
+
1669
+ if (currentValue === null) {
1670
+ return buildFailure('UNKNOWN', 'current numeric value is unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1671
+ }
1672
+
1673
+ if (valueRange && (targetValue < valueRange.min || targetValue > valueRange.max)) {
1674
+ return buildFailure('CONTROL_CONVERGENCE_FAILED', `targetValue ${targetValue} is outside the control range ${valueRange.min}..${valueRange.max}`, resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1675
+ }
1676
+
1677
+ if (currentValue !== null && Math.abs(currentValue - normalizedTargetValue) <= effectiveTolerance) {
1087
1678
  recordTraceStep('verify', 'success', {
1088
1679
  property,
1089
- target_value: targetValue,
1680
+ target_value: normalizedTargetValue,
1090
1681
  actual_state: actualState,
1091
1682
  reason: 'control already within tolerance'
1092
1683
  })
@@ -1101,8 +1692,8 @@ export class ToolsInteract {
1101
1692
  uiFingerprintBefore: fingerprintBefore,
1102
1693
  uiFingerprintAfter,
1103
1694
  details: {
1104
- target_value: targetValue,
1105
- tolerance: normalizedTolerance,
1695
+ target_value: normalizedTargetValue,
1696
+ tolerance: effectiveTolerance,
1106
1697
  property,
1107
1698
  attempts: attemptCount,
1108
1699
  adjustment_mode: 'semantic',
@@ -1118,8 +1709,8 @@ export class ToolsInteract {
1118
1709
  ...base,
1119
1710
  target_state: {
1120
1711
  property,
1121
- target_value: targetValue,
1122
- tolerance: normalizedTolerance
1712
+ target_value: normalizedTargetValue,
1713
+ tolerance: effectiveTolerance
1123
1714
  },
1124
1715
  actual_state: actualState,
1125
1716
  within_tolerance: true,
@@ -1129,50 +1720,48 @@ export class ToolsInteract {
1129
1720
  }
1130
1721
  }
1131
1722
 
1132
- if (!ToolsInteract._isAdjustableControl(currentEl)) {
1133
- return buildFailure('ELEMENT_NOT_INTERACTABLE', 'target is not an adjustable control', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1134
- }
1135
-
1136
1723
  if (!bounds) {
1137
1724
  return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1138
1725
  }
1139
1726
 
1140
- const min = typeof valueRange?.min === 'number' ? valueRange.min : null
1141
- const max = typeof valueRange?.max === 'number' ? valueRange.max : null
1142
- if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
1143
- return buildFailure('ELEMENT_NOT_INTERACTABLE', 'value_range unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1144
- }
1145
-
1146
- if (targetValue < min || targetValue > max) {
1147
- return buildFailure('UNKNOWN', `targetValue ${targetValue} is outside the control range ${min}..${max}`, resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1148
- }
1149
-
1150
- const axis = ToolsInteract._controlAxis(currentEl, bounds)
1151
- const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis)
1152
- const currentPoint = currentValue !== null
1153
- ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
1154
- : ToolsInteract._buildControlPoint(bounds, 0.5, axis)
1155
- const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis)
1156
-
1157
1727
  const runVerification = async (): Promise<{
1158
1728
  verification: any
1159
1729
  observedState: { property: string; value: number | null; raw_value?: number | null } | null
1160
1730
  withinTolerance: boolean
1161
1731
  }> => {
1732
+ if (inferredSliderState) {
1733
+ const tree = await ToolsObserve.getUITreeHandler({ platform: resolvedPlatform, deviceId: resolvedDeviceId }) as any
1734
+ const elements = Array.isArray(tree?.elements) ? tree.elements as UiElement[] : []
1735
+ const refreshed = ToolsInteract._inferLabeledSliderState(elements, currentEl)
1736
+ const observedValue = refreshed?.current ?? null
1737
+ const observedState = observedValue !== null
1738
+ ? { property, value: observedValue, raw_value: observedValue }
1739
+ : actualState
1740
+
1741
+ return {
1742
+ verification: {
1743
+ success: observedValue !== null && Math.abs(observedValue - normalizedTargetValue) <= effectiveTolerance,
1744
+ observed_state: observedState,
1745
+ reason: observedValue !== null
1746
+ ? 'visible labeled value read from nearby slider labels'
1747
+ : 'visible labeled value unavailable'
1748
+ },
1749
+ observedState,
1750
+ withinTolerance: observedValue !== null && Math.abs(observedValue - normalizedTargetValue) <= effectiveTolerance
1751
+ }
1752
+ }
1753
+
1162
1754
  const verification = await ToolsInteract.expectStateHandler({
1163
1755
  element_id: resolvedTarget?.elementId ?? element_id,
1164
1756
  selector: selector ?? undefined,
1165
1757
  property,
1166
- expected: targetValue,
1758
+ expected: normalizedTargetValue,
1167
1759
  platform: resolvedPlatform,
1168
1760
  deviceId: resolvedDeviceId
1169
1761
  }) as any
1170
1762
 
1171
- const observedValue = typeof verification?.observed_state?.value === 'number'
1172
- ? verification.observed_state.value
1173
- : typeof verification?.observed_state?.raw_value === 'number'
1174
- ? verification.observed_state.raw_value
1175
- : null
1763
+ const observedValue = ToolsInteract._parseNumericControlValue(verification?.observed_state?.value) ??
1764
+ ToolsInteract._parseNumericControlValue(verification?.observed_state?.raw_value)
1176
1765
  const observedState = observedValue !== null
1177
1766
  ? {
1178
1767
  property,
@@ -1184,7 +1773,7 @@ export class ToolsInteract {
1184
1773
  return {
1185
1774
  verification,
1186
1775
  observedState,
1187
- withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
1776
+ withinTolerance: observedValue !== null && Math.abs(observedValue - normalizedTargetValue) <= effectiveTolerance
1188
1777
  }
1189
1778
  }
1190
1779
 
@@ -1193,86 +1782,276 @@ export class ToolsInteract {
1193
1782
  let verification: any = null
1194
1783
  let verificationResult: any = { verification: null, observedState: actualState, withinTolerance: false }
1195
1784
 
1196
- for (let i = 0; i < probePoints.length; i++) {
1197
- const probePoint = probePoints[i]
1198
- lastAdjustmentMode = 'coordinate'
1199
- recordTraceStep('execute', 'retry', {
1200
- attempt: attemptCount + 1,
1201
- mode: 'coordinate',
1202
- point: probePoint
1203
- })
1204
- const actionResult = await ToolsInteract.tapHandler({
1205
- platform: resolvedPlatform,
1206
- x: probePoint.x,
1207
- y: probePoint.y,
1208
- deviceId: resolvedDeviceId
1209
- })
1210
- attemptCount++
1211
- actionDevice = actionResult.device ?? actionDevice
1785
+ const directActionSupported = ToolsInteract._hasDirectAdjustmentAction(currentEl)
1786
+ if (directActionSupported && attemptCount < attemptsLimit) {
1787
+ const { interact, resolved: actionResolved } = await ToolsInteract.getInteractionService(resolvedPlatform, resolvedDeviceId)
1788
+ if (typeof interact.setAdjustableValue === 'function') {
1789
+ lastAdjustmentMode = 'semantic'
1790
+ recordTraceStep('execute', 'retry', {
1791
+ attempt: attemptCount + 1,
1792
+ mode: 'semantic',
1793
+ strategy: 'direct_set_value',
1794
+ target_value: normalizedTargetValue
1795
+ })
1796
+ const actionResult = await interact.setAdjustableValue({
1797
+ deviceId: actionResolved.id,
1798
+ target: resolvedTarget,
1799
+ property,
1800
+ value: normalizedTargetValue
1801
+ })
1802
+ attemptCount++
1803
+ actionDevice = actionResult?.device ?? actionDevice
1804
+
1805
+ if (actionResult?.success !== false) {
1806
+ verificationResult = await runVerification()
1807
+ observedState = verificationResult.observedState
1808
+ lastObservedState = observedState
1809
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1810
+ attempt: attemptCount,
1811
+ property,
1812
+ target_value: normalizedTargetValue,
1813
+ actual_state: observedState,
1814
+ reason: verificationResult.verification?.reason ?? 'direct adjustment did not converge yet'
1815
+ })
1816
+
1817
+ if (verificationResult.withinTolerance) {
1818
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1819
+ const base = buildActionExecutionResult({
1820
+ actionType,
1821
+ sourceModule: 'interact',
1822
+ device: actionDevice ?? currentDevice,
1823
+ selector: targetSelector,
1824
+ resolved: resolvedTarget,
1825
+ success: true,
1826
+ uiFingerprintBefore: fingerprintBefore,
1827
+ uiFingerprintAfter,
1828
+ details: {
1829
+ target_value: normalizedTargetValue,
1830
+ tolerance: effectiveTolerance,
1831
+ property,
1832
+ attempts: attemptCount,
1833
+ adjustment_mode: lastAdjustmentMode,
1834
+ actual_state: observedState,
1835
+ converged: true,
1836
+ within_tolerance: true,
1837
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1838
+ },
1839
+ traceSteps
1840
+ }) as AdjustControlResponse
1841
+
1842
+ return {
1843
+ ...base,
1844
+ target_state: {
1845
+ property,
1846
+ target_value: normalizedTargetValue,
1847
+ tolerance: effectiveTolerance
1848
+ },
1849
+ actual_state: observedState,
1850
+ within_tolerance: true,
1851
+ converged: true,
1852
+ attempts: attemptCount,
1853
+ adjustment_mode: lastAdjustmentMode
1854
+ }
1855
+ }
1212
1856
 
1213
- if (!actionResult.success) {
1857
+ cachedResolvedMatch = null
1858
+ if (attemptCount < attemptsLimit) {
1859
+ continue
1860
+ }
1861
+ }
1862
+ }
1863
+ }
1864
+
1865
+ const directions = ToolsInteract._adjustmentDirections(currentEl)
1866
+ if ((directions.increment || directions.decrement) && attemptCount < attemptsLimit) {
1867
+ const { interact, resolved: actionResolved } = await ToolsInteract.getInteractionService(resolvedPlatform, resolvedDeviceId)
1868
+ const adjustByDirection = typeof interact.adjustAccessibleValue === 'function'
1869
+ ? interact.adjustAccessibleValue.bind(interact)
1870
+ : null
1871
+ while (adjustByDirection && attemptCount < attemptsLimit) {
1872
+ const direction = normalizedTargetValue > (observedState?.value ?? currentValue) ? 'increment' : 'decrement'
1873
+ if ((direction === 'increment' && !directions.increment) || (direction === 'decrement' && !directions.decrement)) break
1874
+ lastAdjustmentMode = 'semantic'
1214
1875
  recordTraceStep('execute', 'retry', {
1876
+ attempt: attemptCount + 1,
1877
+ mode: 'semantic',
1878
+ strategy: 'accessibility_increment_decrement',
1879
+ direction
1880
+ })
1881
+ const actionResult = await adjustByDirection({
1882
+ deviceId: actionResolved.id,
1883
+ target: resolvedTarget,
1884
+ property,
1885
+ direction
1886
+ })
1887
+ attemptCount++
1888
+ actionDevice = actionResult?.device ?? actionDevice
1889
+ if (actionResult?.success === false) break
1890
+
1891
+ verificationResult = await runVerification()
1892
+ observedState = verificationResult.observedState
1893
+ lastObservedState = observedState
1894
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1215
1895
  attempt: attemptCount,
1216
- mode: 'coordinate',
1217
- point: probePoint,
1218
- success: false
1896
+ property,
1897
+ target_value: normalizedTargetValue,
1898
+ actual_state: observedState,
1899
+ reason: verificationResult.verification?.reason ?? 'incremental adjustment did not converge yet'
1219
1900
  })
1220
- continue
1901
+
1902
+ if (verificationResult.withinTolerance) {
1903
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1904
+ const base = buildActionExecutionResult({
1905
+ actionType,
1906
+ sourceModule: 'interact',
1907
+ device: actionDevice ?? currentDevice,
1908
+ selector: targetSelector,
1909
+ resolved: resolvedTarget,
1910
+ success: true,
1911
+ uiFingerprintBefore: fingerprintBefore,
1912
+ uiFingerprintAfter,
1913
+ details: {
1914
+ target_value: normalizedTargetValue,
1915
+ tolerance: effectiveTolerance,
1916
+ property,
1917
+ attempts: attemptCount,
1918
+ adjustment_mode: lastAdjustmentMode,
1919
+ actual_state: observedState,
1920
+ converged: true,
1921
+ within_tolerance: true,
1922
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1923
+ },
1924
+ traceSteps
1925
+ }) as AdjustControlResponse
1926
+
1927
+ return {
1928
+ ...base,
1929
+ target_state: {
1930
+ property,
1931
+ target_value: normalizedTargetValue,
1932
+ tolerance: effectiveTolerance
1933
+ },
1934
+ actual_state: observedState,
1935
+ within_tolerance: true,
1936
+ converged: true,
1937
+ attempts: attemptCount,
1938
+ adjustment_mode: lastAdjustmentMode
1939
+ }
1940
+ }
1941
+
1942
+ cachedResolvedMatch = null
1943
+ if (attemptCount < attemptsLimit) {
1944
+ continue adjustmentAttempts
1945
+ }
1221
1946
  }
1947
+ }
1222
1948
 
1223
- verificationResult = await runVerification()
1224
- observedState = verificationResult.observedState
1225
- lastObservedState = observedState
1226
- recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1227
- attempt: attemptCount,
1228
- property,
1229
- target_value: targetValue,
1230
- actual_state: observedState,
1231
- reason: verificationResult.verification?.reason ?? 'control did not converge yet'
1232
- })
1949
+ if (!bounds) {
1950
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1951
+ }
1233
1952
 
1234
- if (verificationResult.withinTolerance) {
1235
- const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1236
- const base = buildActionExecutionResult({
1237
- actionType,
1238
- sourceModule: 'interact',
1239
- device: actionDevice ?? currentDevice,
1240
- selector: targetSelector,
1241
- resolved: resolvedTarget,
1242
- success: true,
1243
- uiFingerprintBefore: fingerprintBefore,
1244
- uiFingerprintAfter,
1245
- details: {
1246
- target_value: targetValue,
1247
- tolerance: normalizedTolerance,
1248
- property,
1249
- attempts: attemptCount,
1250
- adjustment_mode: lastAdjustmentMode,
1953
+ if (!valueRange) {
1954
+ return buildFailure('UNKNOWN', 'value_range unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1955
+ }
1956
+
1957
+ const min = valueRange.min
1958
+ const max = valueRange.max
1959
+ const axis = ToolsInteract._controlAxis(currentEl, bounds)
1960
+ const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, normalizedTargetValue, currentValue, min, max, axis)
1961
+ const currentPoint = ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
1962
+ if (!coordinateTapsAwaitingFreshGesture) {
1963
+ const probePoints = inferredSliderState
1964
+ ? [targetPoint]
1965
+ : ToolsInteract._buildAdjustmentProbePoints(bounds, normalizedTargetValue, currentValue, min, max, axis)
1966
+
1967
+ for (let i = 0; i < probePoints.length && attemptCount < attemptsLimit; i++) {
1968
+ const probePoint = probePoints[i]
1969
+ lastAdjustmentMode = 'coordinate'
1970
+ recordTraceStep('execute', 'retry', {
1971
+ attempt: attemptCount + 1,
1972
+ mode: 'coordinate',
1973
+ point: probePoint
1974
+ })
1975
+ const actionResult = await ToolsInteract.tapHandler({
1976
+ platform: resolvedPlatform,
1977
+ x: probePoint.x,
1978
+ y: probePoint.y,
1979
+ deviceId: resolvedDeviceId
1980
+ })
1981
+ attemptCount++
1982
+ actionDevice = actionResult.device ?? actionDevice
1983
+
1984
+ if (!actionResult.success) {
1985
+ recordTraceStep('execute', 'retry', {
1986
+ attempt: attemptCount,
1987
+ mode: 'coordinate',
1988
+ point: probePoint,
1989
+ success: false
1990
+ })
1991
+ continue
1992
+ }
1993
+
1994
+ verificationResult = await runVerification()
1995
+ observedState = verificationResult.observedState
1996
+ lastObservedState = observedState
1997
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1998
+ attempt: attemptCount,
1999
+ property,
2000
+ target_value: normalizedTargetValue,
2001
+ actual_state: observedState,
2002
+ reason: verificationResult.verification?.reason ?? 'control did not converge yet'
2003
+ })
2004
+
2005
+ if (verificationResult.withinTolerance) {
2006
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
2007
+ const base = buildActionExecutionResult({
2008
+ actionType,
2009
+ sourceModule: 'interact',
2010
+ device: actionDevice ?? currentDevice,
2011
+ selector: targetSelector,
2012
+ resolved: resolvedTarget,
2013
+ success: true,
2014
+ uiFingerprintBefore: fingerprintBefore,
2015
+ uiFingerprintAfter,
2016
+ details: {
2017
+ target_value: normalizedTargetValue,
2018
+ tolerance: effectiveTolerance,
2019
+ property,
2020
+ attempts: attemptCount,
2021
+ adjustment_mode: lastAdjustmentMode,
2022
+ actual_state: observedState,
2023
+ converged: true,
2024
+ within_tolerance: true,
2025
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
2026
+ },
2027
+ traceSteps
2028
+ }) as AdjustControlResponse
2029
+
2030
+ return {
2031
+ ...base,
2032
+ target_state: {
2033
+ property,
2034
+ target_value: normalizedTargetValue,
2035
+ tolerance: effectiveTolerance
2036
+ },
1251
2037
  actual_state: observedState,
1252
- converged: true,
1253
2038
  within_tolerance: true,
1254
- reason: verificationResult.verification?.reason ?? 'control converged to target value'
1255
- },
1256
- traceSteps
1257
- }) as AdjustControlResponse
2039
+ converged: true,
2040
+ attempts: attemptCount,
2041
+ adjustment_mode: lastAdjustmentMode
2042
+ }
2043
+ }
1258
2044
 
1259
- return {
1260
- ...base,
1261
- target_state: {
1262
- property,
1263
- target_value: targetValue,
1264
- tolerance: normalizedTolerance
1265
- },
1266
- actual_state: observedState,
1267
- within_tolerance: true,
1268
- converged: true,
1269
- attempts: attemptCount,
1270
- adjustment_mode: lastAdjustmentMode
2045
+ coordinateTapsAwaitingFreshGesture = true
2046
+ cachedResolvedMatch = null
2047
+ if (attemptCount < attemptsLimit) {
2048
+ continue adjustmentAttempts
1271
2049
  }
1272
2050
  }
1273
2051
  }
1274
2052
 
1275
- if (currentValue !== null) {
2053
+ if (attemptCount < attemptsLimit) {
2054
+ coordinateTapsAwaitingFreshGesture = false
1276
2055
  lastAdjustmentMode = 'gesture'
1277
2056
  recordTraceStep('execute', 'retry', {
1278
2057
  attempt: attemptCount + 1,
@@ -1286,7 +2065,7 @@ export class ToolsInteract {
1286
2065
  y1: currentPoint.y,
1287
2066
  x2: targetPoint.x,
1288
2067
  y2: targetPoint.y,
1289
- duration: 220,
2068
+ duration: inferredSliderState ? 520 : 220,
1290
2069
  deviceId: resolvedDeviceId
1291
2070
  })
1292
2071
  attemptCount++
@@ -1308,7 +2087,7 @@ export class ToolsInteract {
1308
2087
  recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1309
2088
  attempt: attemptCount,
1310
2089
  property,
1311
- target_value: targetValue,
2090
+ target_value: normalizedTargetValue,
1312
2091
  actual_state: observedState,
1313
2092
  reason: verificationResult.verification?.reason ?? 'gesture adjustment did not converge yet'
1314
2093
  })
@@ -1325,8 +2104,8 @@ export class ToolsInteract {
1325
2104
  uiFingerprintBefore: fingerprintBefore,
1326
2105
  uiFingerprintAfter,
1327
2106
  details: {
1328
- target_value: targetValue,
1329
- tolerance: normalizedTolerance,
2107
+ target_value: normalizedTargetValue,
2108
+ tolerance: effectiveTolerance,
1330
2109
  property,
1331
2110
  attempts: attemptCount,
1332
2111
  adjustment_mode: lastAdjustmentMode,
@@ -1342,8 +2121,8 @@ export class ToolsInteract {
1342
2121
  ...base,
1343
2122
  target_state: {
1344
2123
  property,
1345
- target_value: targetValue,
1346
- tolerance: normalizedTolerance
2124
+ target_value: normalizedTargetValue,
2125
+ tolerance: effectiveTolerance
1347
2126
  },
1348
2127
  actual_state: observedState,
1349
2128
  within_tolerance: true,
@@ -1361,7 +2140,7 @@ export class ToolsInteract {
1361
2140
  recordTraceStep('verify', 'success', {
1362
2141
  attempt: attemptCount,
1363
2142
  property,
1364
- target_value: targetValue,
2143
+ target_value: normalizedTargetValue,
1365
2144
  actual_state: observedState,
1366
2145
  reason: verification?.reason ?? 'control converged to target value'
1367
2146
  })
@@ -1376,8 +2155,8 @@ export class ToolsInteract {
1376
2155
  uiFingerprintBefore: fingerprintBefore,
1377
2156
  uiFingerprintAfter,
1378
2157
  details: {
1379
- target_value: targetValue,
1380
- tolerance: normalizedTolerance,
2158
+ target_value: normalizedTargetValue,
2159
+ tolerance: effectiveTolerance,
1381
2160
  property,
1382
2161
  attempts: attemptCount,
1383
2162
  adjustment_mode: lastAdjustmentMode,
@@ -1393,8 +2172,8 @@ export class ToolsInteract {
1393
2172
  ...base,
1394
2173
  target_state: {
1395
2174
  property,
1396
- target_value: targetValue,
1397
- tolerance: normalizedTolerance
2175
+ target_value: normalizedTargetValue,
2176
+ tolerance: effectiveTolerance
1398
2177
  },
1399
2178
  actual_state: observedState,
1400
2179
  within_tolerance: true,
@@ -1404,23 +2183,25 @@ export class ToolsInteract {
1404
2183
  }
1405
2184
  }
1406
2185
 
1407
- cachedResolvedMatch = {
1408
- el: {
1409
- ...currentEl,
1410
- state: {
1411
- ...(currentEl.state ?? null),
1412
- ...(observedState ? {
1413
- [observedState.property]: observedState.value,
1414
- raw_value: observedState.raw_value ?? observedState.value
1415
- } : {})
1416
- }
1417
- },
1418
- idx: resolved.match.idx
1419
- }
2186
+ cachedResolvedMatch = inferredSliderState
2187
+ ? null
2188
+ : {
2189
+ el: {
2190
+ ...currentEl,
2191
+ state: {
2192
+ ...(currentEl.state ?? null),
2193
+ ...(observedState ? {
2194
+ [observedState.property]: observedState.value,
2195
+ raw_value: observedState.raw_value ?? observedState.value
2196
+ } : {})
2197
+ }
2198
+ },
2199
+ idx: resolved.match.idx
2200
+ }
1420
2201
  }
1421
2202
 
1422
2203
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1423
- return buildFailure('TIMEOUT', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter)
2204
+ return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter)
1424
2205
  }
1425
2206
 
1426
2207
  static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }: { platform?: 'android' | 'ios', x1: number, y1: number, x2: number, y2: number, duration: number, deviceId?: string }) {
@@ -2021,33 +2802,79 @@ export class ToolsInteract {
2021
2802
  deviceId,
2022
2803
  timeout_ms = 60000,
2023
2804
  stability_window_ms = 300,
2024
- expected_change
2805
+ expected_change,
2806
+ scope = 'screen',
2807
+ target = null
2025
2808
  }: {
2026
2809
  platform?: 'android' | 'ios',
2027
2810
  deviceId?: string,
2028
2811
  timeout_ms?: number,
2029
2812
  stability_window_ms?: number,
2030
- expected_change?: 'hierarchy_diff' | 'text_change' | 'state_change'
2813
+ expected_change?: 'hierarchy_diff' | 'text_change' | 'state_change',
2814
+ scope?: 'screen' | 'subtree',
2815
+ target?: string | null
2031
2816
  }): Promise<WaitForUIChangeResponse> {
2032
2817
  const start = Date.now()
2033
2818
  const pollIntervalMs = 300
2034
2819
  const stabilityWindow = Math.max(0, typeof stability_window_ms === 'number' ? stability_window_ms : 300)
2035
2820
  let baseline: UiChangeSignatureSet | null = null
2821
+ let baselineScope: UiChangeScopeResult | null = null
2036
2822
  let lastObservedRevision: number | null = null
2037
2823
  let lastLoadingState: any = null
2824
+ let lastSnapshotFreshnessMs: number | null = null
2038
2825
  let candidateSignatures: UiChangeSignatureSet | null = null
2039
2826
  let candidateObservedChange: 'hierarchy_diff' | 'text_change' | 'state_change' | null = null
2040
2827
  let candidateSinceMs: number | null = null
2828
+ let lastChangeSummary: ReturnType<typeof ToolsInteract._summarizeUiChangeDelta> | null = null
2829
+ let lastScopeResolution: UiChangeScopeResolution = {
2830
+ scope: scope === 'subtree' ? 'subtree' : 'screen',
2831
+ target: target && typeof target === 'string' ? target : null,
2832
+ resolved: scope !== 'subtree',
2833
+ resolvedIndex: null,
2834
+ resolvedStableId: null,
2835
+ reason: scope === 'subtree' ? 'target not resolved yet' : 'screen scope'
2836
+ }
2041
2837
 
2042
2838
  while (Date.now() - start < timeout_ms) {
2043
2839
  try {
2044
2840
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId }) as any
2045
- const signatures = ToolsInteract._buildUiChangeSignatures(tree)
2841
+ const scopedTree = ToolsInteract._resolveUiChangeScope(tree, scope, target)
2842
+ if (scopedTree.error) {
2843
+ lastScopeResolution = scopedTree.resolution
2844
+ return {
2845
+ success: false,
2846
+ observed_change: null,
2847
+ snapshot_revision: typeof tree?.snapshot_revision === 'number' ? tree.snapshot_revision : lastObservedRevision ?? undefined,
2848
+ snapshot_freshness_ms: typeof tree?.captured_at_ms === 'number' ? Math.max(0, Date.now() - tree.captured_at_ms) : lastSnapshotFreshnessMs ?? null,
2849
+ timeout: true,
2850
+ elapsed_ms: Date.now() - start,
2851
+ expected_change,
2852
+ loading_state: tree?.loading_state ?? lastLoadingState ?? null,
2853
+ scope: scopedTree.resolution.scope,
2854
+ target: scopedTree.resolution.target,
2855
+ stability_state: 'transient',
2856
+ change_summary: lastChangeSummary,
2857
+ reason: scopedTree.error.message,
2858
+ error: scopedTree.error
2859
+ }
2860
+ }
2861
+
2862
+ const scopedElements = scopedTree.elements
2863
+ const scopedSignatureTree = {
2864
+ ...tree,
2865
+ elements: scopedElements
2866
+ }
2867
+ const signatures = ToolsInteract._buildUiChangeSignatures(scopedSignatureTree)
2046
2868
  lastObservedRevision = typeof tree?.snapshot_revision === 'number' ? tree.snapshot_revision : lastObservedRevision
2047
2869
  lastLoadingState = tree?.loading_state ?? lastLoadingState
2870
+ lastSnapshotFreshnessMs = typeof tree?.captured_at_ms === 'number' ? Math.max(0, Date.now() - tree.captured_at_ms) : lastSnapshotFreshnessMs
2871
+ lastChangeSummary = baseline ? ToolsInteract._summarizeUiChangeDelta((baselineScope?.elements ?? []), scopedElements) : lastChangeSummary
2872
+ lastScopeResolution = scopedTree.resolution
2873
+ baselineScope = baselineScope ?? scopedTree
2048
2874
 
2049
2875
  if (!baseline) {
2050
2876
  baseline = signatures
2877
+ baselineScope = scopedTree
2051
2878
  } else {
2052
2879
  const observedChange = ToolsInteract._matchesUiChange(expected_change, baseline, signatures)
2053
2880
  if (observedChange) {
@@ -2059,20 +2886,25 @@ export class ToolsInteract {
2059
2886
 
2060
2887
  const stableForMs = candidateSinceMs === null ? 0 : Date.now() - candidateSinceMs
2061
2888
  if (stabilityWindow === 0 || stableForMs >= stabilityWindow) {
2062
- return {
2063
- success: true,
2064
- observed_change: candidateObservedChange ?? observedChange,
2065
- snapshot_revision: lastObservedRevision ?? undefined,
2066
- timeout: false,
2067
- elapsed_ms: Date.now() - start,
2068
- expected_change,
2069
- loading_state: lastLoadingState ?? null,
2070
- reason: 'UI change observed'
2889
+ return {
2890
+ success: true,
2891
+ observed_change: candidateObservedChange ?? observedChange,
2892
+ snapshot_revision: lastObservedRevision ?? undefined,
2893
+ snapshot_freshness_ms: lastSnapshotFreshnessMs ?? null,
2894
+ timeout: false,
2895
+ elapsed_ms: Date.now() - start,
2896
+ expected_change,
2897
+ loading_state: lastLoadingState ?? null,
2898
+ scope: lastScopeResolution.scope,
2899
+ target: lastScopeResolution.target,
2900
+ stability_state: 'stable',
2901
+ change_summary: lastChangeSummary,
2902
+ reason: 'UI change observed'
2903
+ }
2071
2904
  }
2072
- }
2073
- } else {
2074
- candidateSignatures = null
2075
- candidateObservedChange = null
2905
+ } else {
2906
+ candidateSignatures = null
2907
+ candidateObservedChange = null
2076
2908
  candidateSinceMs = null
2077
2909
  }
2078
2910
  }
@@ -2087,10 +2919,15 @@ export class ToolsInteract {
2087
2919
  success: false,
2088
2920
  observed_change: null,
2089
2921
  snapshot_revision: lastObservedRevision ?? undefined,
2922
+ snapshot_freshness_ms: lastSnapshotFreshnessMs ?? null,
2090
2923
  timeout: true,
2091
2924
  elapsed_ms: Date.now() - start,
2092
2925
  expected_change,
2093
2926
  loading_state: lastLoadingState ?? null,
2927
+ scope: lastScopeResolution.scope,
2928
+ target: lastScopeResolution.target,
2929
+ stability_state: 'transient',
2930
+ change_summary: lastChangeSummary,
2094
2931
  reason: 'timeout'
2095
2932
  }
2096
2933
  }