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
@@ -95,10 +95,68 @@ export class ToolsInteract {
95
95
  }
96
96
  return null;
97
97
  }
98
+ static _resolveParentIndex(elements, parentId) {
99
+ if (parentId === undefined || parentId === null)
100
+ return null;
101
+ if (typeof parentId === 'number' && Number.isInteger(parentId) && parentId >= 0 && parentId < elements.length) {
102
+ return parentId;
103
+ }
104
+ if (typeof parentId === 'string') {
105
+ const normalized = ToolsInteract._normalize(parentId);
106
+ if (!normalized)
107
+ return null;
108
+ if (/^\d+$/.test(normalized)) {
109
+ const index = Number(normalized);
110
+ if (index >= 0 && index < elements.length)
111
+ return index;
112
+ }
113
+ const foundIndex = elements.findIndex((el) => {
114
+ if (!el)
115
+ return false;
116
+ return ToolsInteract._normalize(el.resourceId ?? el.resourceID ?? el.id ?? '') === normalized ||
117
+ ToolsInteract._normalize(el.stable_id ?? '') === normalized;
118
+ });
119
+ return foundIndex >= 0 ? foundIndex : null;
120
+ }
121
+ return null;
122
+ }
98
123
  static _isVisibleElement(el) {
99
124
  const bounds = ToolsInteract._normalizeBounds(el.bounds);
100
125
  return !!el.visible && !!bounds && bounds[2] > bounds[0] && bounds[3] > bounds[1];
101
126
  }
127
+ static _isTapActionable(el, storedStableId, platform) {
128
+ if (!ToolsInteract._isVisibleElement(el)) {
129
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is not visible' };
130
+ }
131
+ if (el.enabled === false) {
132
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is disabled' };
133
+ }
134
+ const semanticTapActionable = !!el.semantic && (el.semantic.is_clickable ||
135
+ (Array.isArray(el.semantic.supported_actions) && el.semantic.supported_actions.some((action) => ToolsInteract._normalize(action) === 'tap')));
136
+ if (!el.clickable && !(platform === 'ios' && semanticTapActionable)) {
137
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is not clickable' };
138
+ }
139
+ if (storedStableId) {
140
+ if (!el.stable_id || el.stable_id !== storedStableId) {
141
+ return { actionable: false, failureCode: 'STALE_REFERENCE', reason: 'element stable_id changed' };
142
+ }
143
+ }
144
+ return { actionable: true };
145
+ }
146
+ static _isAdjustableActionable(el, storedStableId) {
147
+ if (!ToolsInteract._isVisibleElement(el)) {
148
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is not visible' };
149
+ }
150
+ if (el.enabled === false) {
151
+ return { actionable: false, failureCode: 'ELEMENT_NOT_INTERACTABLE', reason: 'element is disabled' };
152
+ }
153
+ if (storedStableId) {
154
+ if (!el.stable_id || el.stable_id !== storedStableId) {
155
+ return { actionable: false, failureCode: 'STALE_REFERENCE', reason: 'element stable_id changed' };
156
+ }
157
+ }
158
+ return { actionable: true };
159
+ }
102
160
  static _computeElementId(platform, deviceId, el, index) {
103
161
  const identity = {
104
162
  platform,
@@ -120,7 +178,8 @@ export class ToolsInteract {
120
178
  platform,
121
179
  deviceId,
122
180
  bounds,
123
- index
181
+ index,
182
+ stable_id: el.stable_id ?? null
124
183
  });
125
184
  return {
126
185
  text: el.text ?? null,
@@ -138,6 +197,214 @@ export class ToolsInteract {
138
197
  semantic: el.semantic ?? null
139
198
  };
140
199
  }
200
+ static _resolveUiChangeScope(tree, scope, target) {
201
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
202
+ const normalizedScope = scope === 'subtree' ? 'subtree' : 'screen';
203
+ if (normalizedScope === 'screen') {
204
+ return {
205
+ elements,
206
+ resolution: {
207
+ scope: 'screen',
208
+ target: null,
209
+ resolved: true,
210
+ resolvedIndex: null,
211
+ resolvedStableId: null,
212
+ reason: 'screen scope'
213
+ }
214
+ };
215
+ }
216
+ const requestedTarget = typeof target === 'string' && target.trim().length > 0 ? target.trim() : null;
217
+ if (!requestedTarget) {
218
+ return {
219
+ elements: [],
220
+ resolution: {
221
+ scope: 'subtree',
222
+ target: null,
223
+ resolved: false,
224
+ resolvedIndex: null,
225
+ resolvedStableId: null,
226
+ reason: 'subtree scope requires a target element id'
227
+ },
228
+ error: {
229
+ code: 'INVALID_SCOPE',
230
+ message: 'scope=subtree requires a target element_id'
231
+ }
232
+ };
233
+ }
234
+ const resolved = ToolsInteract._findScopedElement(tree, requestedTarget);
235
+ if (!resolved) {
236
+ return {
237
+ elements: [],
238
+ resolution: {
239
+ scope: 'subtree',
240
+ target: requestedTarget,
241
+ resolved: false,
242
+ resolvedIndex: null,
243
+ resolvedStableId: null,
244
+ reason: 'target element could not be resolved'
245
+ },
246
+ error: {
247
+ code: 'ELEMENT_NOT_FOUND',
248
+ message: `Target element ${requestedTarget} could not be resolved for subtree scope`
249
+ }
250
+ };
251
+ }
252
+ const subtreeIndices = ToolsInteract._collectSubtreeIndices(elements, resolved.index);
253
+ const scopedElements = subtreeIndices.map((index) => elements[index]).filter((element) => !!element);
254
+ return {
255
+ elements: scopedElements,
256
+ resolution: {
257
+ scope: 'subtree',
258
+ target: requestedTarget,
259
+ resolved: true,
260
+ resolvedIndex: resolved.index,
261
+ resolvedStableId: resolved.stableId,
262
+ reason: resolved.reason
263
+ }
264
+ };
265
+ }
266
+ static _findScopedElement(tree, targetElementId) {
267
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
268
+ const platform = tree?.device?.platform === 'ios' ? 'ios' : 'android';
269
+ const deviceId = tree?.device?.id ?? undefined;
270
+ const normalizedTarget = ToolsInteract._normalize(targetElementId);
271
+ for (let i = 0; i < elements.length; i++) {
272
+ const el = elements[i];
273
+ if (!el)
274
+ continue;
275
+ const computedElementId = ToolsInteract._computeElementId(platform, deviceId, el, i);
276
+ if (computedElementId === targetElementId) {
277
+ return {
278
+ index: i,
279
+ stableId: el.stable_id ?? null,
280
+ reason: 'element_id_match'
281
+ };
282
+ }
283
+ }
284
+ for (let i = 0; i < elements.length; i++) {
285
+ const el = elements[i];
286
+ if (!el)
287
+ continue;
288
+ if (el.stable_id && ToolsInteract._normalize(el.stable_id) === normalizedTarget) {
289
+ return {
290
+ index: i,
291
+ stableId: el.stable_id,
292
+ reason: 'stable_id_match'
293
+ };
294
+ }
295
+ }
296
+ const storedContext = ToolsInteract._resolvedUiElements.get(targetElementId);
297
+ if (storedContext?.stable_id) {
298
+ const normalizedStoredStableId = ToolsInteract._normalize(storedContext.stable_id);
299
+ for (let i = 0; i < elements.length; i++) {
300
+ const el = elements[i];
301
+ if (!el?.stable_id)
302
+ continue;
303
+ if (ToolsInteract._normalize(el.stable_id) === normalizedStoredStableId) {
304
+ return {
305
+ index: i,
306
+ stableId: el.stable_id,
307
+ reason: 'stored_stable_id_match'
308
+ };
309
+ }
310
+ }
311
+ }
312
+ return null;
313
+ }
314
+ static _collectSubtreeIndices(elements, rootIndex) {
315
+ if (!Array.isArray(elements) || rootIndex < 0 || rootIndex >= elements.length)
316
+ return [];
317
+ const visited = new Set();
318
+ const stack = [rootIndex];
319
+ const result = [];
320
+ while (stack.length > 0) {
321
+ const index = stack.pop();
322
+ if (index === undefined || visited.has(index) || index < 0 || index >= elements.length)
323
+ continue;
324
+ visited.add(index);
325
+ result.push(index);
326
+ const element = elements[index];
327
+ if (!element)
328
+ continue;
329
+ const directChildren = new Set();
330
+ if (Array.isArray(element.children)) {
331
+ for (const childIndex of element.children) {
332
+ if (typeof childIndex === 'number' && Number.isInteger(childIndex) && childIndex >= 0 && childIndex < elements.length) {
333
+ directChildren.add(childIndex);
334
+ }
335
+ }
336
+ }
337
+ for (let i = 0; i < elements.length; i++) {
338
+ if (ToolsInteract._resolveParentIndex(elements, elements[i]?.parentId) === index) {
339
+ directChildren.add(i);
340
+ }
341
+ }
342
+ for (const childIndex of directChildren) {
343
+ if (!visited.has(childIndex))
344
+ stack.push(childIndex);
345
+ }
346
+ }
347
+ return result.sort((left, right) => left - right);
348
+ }
349
+ static _changeIdentityForElement(el, index) {
350
+ const stableId = ToolsInteract._normalize(el.stable_id);
351
+ if (stableId)
352
+ return `stable:${stableId}`;
353
+ return `fallback:${ToolsInteract._hash({
354
+ text: ToolsInteract._normalize(el.text ?? el.label ?? el.value ?? ''),
355
+ contentDescription: ToolsInteract._normalize(el.contentDescription ?? el.contentDesc ?? el.accessibilityLabel ?? ''),
356
+ resourceId: ToolsInteract._normalize(el.resourceId ?? el.resourceID ?? el.id ?? ''),
357
+ type: ToolsInteract._normalize(el.type ?? el.class ?? ''),
358
+ bounds: ToolsInteract._normalizeBounds(el.bounds) ?? [0, 0, 0, 0],
359
+ index
360
+ })}`;
361
+ }
362
+ static _summarizeUiChangeDelta(initialElements, currentElements) {
363
+ const buildMap = (elements) => {
364
+ const map = new Map();
365
+ for (let i = 0; i < elements.length; i++) {
366
+ const element = elements[i];
367
+ if (!element)
368
+ continue;
369
+ const key = ToolsInteract._changeIdentityForElement(element, i);
370
+ map.set(key, ToolsInteract._hash({
371
+ text: ToolsInteract._normalize(element.text ?? element.label ?? element.value ?? ''),
372
+ contentDescription: ToolsInteract._normalize(element.contentDescription ?? element.contentDesc ?? element.accessibilityLabel ?? ''),
373
+ resourceId: ToolsInteract._normalize(element.resourceId ?? element.resourceID ?? element.id ?? ''),
374
+ type: ToolsInteract._normalize(element.type ?? element.class ?? ''),
375
+ bounds: ToolsInteract._normalizeBounds(element.bounds) ?? [0, 0, 0, 0],
376
+ state: element.state ?? null,
377
+ visible: !!element.visible,
378
+ enabled: !!element.enabled,
379
+ clickable: !!element.clickable
380
+ }));
381
+ }
382
+ return map;
383
+ };
384
+ const initialMap = buildMap(initialElements);
385
+ const currentMap = buildMap(currentElements);
386
+ let added = 0;
387
+ let removed = 0;
388
+ let mutated = 0;
389
+ for (const [key, value] of currentMap.entries()) {
390
+ if (!initialMap.has(key)) {
391
+ added++;
392
+ }
393
+ else if (initialMap.get(key) !== value) {
394
+ mutated++;
395
+ }
396
+ }
397
+ for (const key of initialMap.keys()) {
398
+ if (!currentMap.has(key))
399
+ removed++;
400
+ }
401
+ return {
402
+ total_elements: currentElements.length,
403
+ added_elements: added,
404
+ removed_elements: removed,
405
+ mutated_elements: mutated
406
+ };
407
+ }
141
408
  static _rememberResolvedElement(elementId, context) {
142
409
  if (ToolsInteract._resolvedUiElements.has(elementId)) {
143
410
  ToolsInteract._resolvedUiElements.delete(elementId);
@@ -261,13 +528,214 @@ export class ToolsInteract {
261
528
  const stateValue = el.state[property];
262
529
  if (typeof stateValue === 'number' && Number.isFinite(stateValue))
263
530
  return stateValue;
531
+ const parsedStateValue = ToolsInteract._parseNumericControlValue(stateValue);
532
+ if (parsedStateValue !== null)
533
+ return parsedStateValue;
264
534
  if (property === 'value' || property === 'raw_value') {
265
535
  const fallback = el.state.raw_value ?? el.state.value;
266
536
  if (typeof fallback === 'number' && Number.isFinite(fallback))
267
537
  return fallback;
538
+ return ToolsInteract._parseNumericControlValue(fallback);
539
+ }
540
+ return null;
541
+ }
542
+ static _parseNumericControlValue(value) {
543
+ if (typeof value === 'number' && Number.isFinite(value))
544
+ return value;
545
+ if (typeof value !== 'string')
546
+ return null;
547
+ const trimmed = value.trim();
548
+ if (!trimmed)
549
+ return null;
550
+ const percent = trimmed.match(/^([-+]?\d+(?:\.\d+)?)\s*%$/);
551
+ if (percent)
552
+ return Number(percent[1]);
553
+ const fraction = trimmed.match(/^([-+]?\d+(?:\.\d+)?)\s+of\s+[-+]?\d+(?:\.\d+)?$/i);
554
+ if (fraction)
555
+ return Number(fraction[1]);
556
+ const plain = Number(trimmed);
557
+ if (Number.isFinite(plain))
558
+ return plain;
559
+ const trailing = trimmed.match(/,\s*([-+]?\d+(?:\.\d+)?)$/);
560
+ if (trailing)
561
+ return Number(trailing[1]);
562
+ return null;
563
+ }
564
+ static _readNumericControlRange(el) {
565
+ const min = typeof el?.state?.value_range?.min === 'number' ? el.state.value_range.min : null;
566
+ const max = typeof el?.state?.value_range?.max === 'number' ? el.state.value_range.max : null;
567
+ if (min !== null && max !== null && Number.isFinite(min) && Number.isFinite(max) && max > min) {
568
+ return { min, max };
569
+ }
570
+ const value = el?.state?.value ?? el?.state?.raw_value;
571
+ if (typeof value === 'string') {
572
+ const fraction = value.trim().match(/^[-+]?\d+(?:\.\d+)?\s+of\s+([-+]?\d+(?:\.\d+)?)$/i);
573
+ if (fraction) {
574
+ const parsedMax = Number(fraction[1]);
575
+ if (Number.isFinite(parsedMax) && parsedMax > 0)
576
+ return { min: 0, max: parsedMax };
577
+ }
268
578
  }
269
579
  return null;
270
580
  }
581
+ static _readControlStep(el) {
582
+ const candidates = [
583
+ el?.state?.step,
584
+ el?.state?.value_range?.step
585
+ ];
586
+ for (const candidate of candidates) {
587
+ if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0)
588
+ return candidate;
589
+ }
590
+ return null;
591
+ }
592
+ static _quantizeTargetValue(targetValue, min, max, step) {
593
+ if (!step)
594
+ return targetValue;
595
+ const quantized = min + (Math.round((targetValue - min) / step) * step);
596
+ const bounded = Math.max(min, Math.min(max, quantized));
597
+ const precision = ToolsInteract._decimalPrecisionForStep(step);
598
+ return Number(bounded.toFixed(precision));
599
+ }
600
+ static _decimalPrecisionForStep(step) {
601
+ const normalized = String(step).toLowerCase();
602
+ const [coefficient, exponentPart] = normalized.split('e');
603
+ const coefficientDecimals = coefficient.includes('.')
604
+ ? coefficient.split('.')[1]?.length ?? 0
605
+ : 0;
606
+ const exponent = exponentPart !== undefined ? Number(exponentPart) : 0;
607
+ const precision = exponent < 0
608
+ ? coefficientDecimals + Math.abs(exponent)
609
+ : Math.max(0, coefficientDecimals - exponent);
610
+ return Math.max(0, Math.min(12, precision));
611
+ }
612
+ static _defaultAdjustTolerance(range, step) {
613
+ if (step)
614
+ return 0;
615
+ if (!range)
616
+ return 0;
617
+ return Math.max(0, 0.01 * Math.abs(range.max - range.min));
618
+ }
619
+ static _runtimeActionSet(el) {
620
+ const values = [
621
+ ...(Array.isArray(el?.actions) ? el.actions : []),
622
+ ...(Array.isArray(el?.accessibilityActions) ? el.accessibilityActions : []),
623
+ ...(Array.isArray(el?.runtimeActions) ? el.runtimeActions : []),
624
+ ...(Array.isArray(el?.platformActions) ? el.platformActions : [])
625
+ ];
626
+ return new Set(values.map((value) => ToolsInteract._normalize(value)).filter(Boolean));
627
+ }
628
+ static _hasDirectAdjustmentAction(el) {
629
+ const actions = ToolsInteract._runtimeActionSet(el);
630
+ return [
631
+ 'action_set_progress',
632
+ 'set_progress',
633
+ 'setprogress',
634
+ 'semanticsactions.setprogress',
635
+ 'set_value',
636
+ 'setvalue',
637
+ 'direct_set_value'
638
+ ].some((action) => actions.has(action));
639
+ }
640
+ static _adjustmentDirections(el) {
641
+ const actions = ToolsInteract._runtimeActionSet(el);
642
+ return {
643
+ increment: ['increment', 'accessibility_increment', 'action_increment'].some((action) => actions.has(action)),
644
+ decrement: ['decrement', 'accessibility_decrement', 'action_decrement'].some((action) => actions.has(action))
645
+ };
646
+ }
647
+ static _textFromElement(el) {
648
+ return String(el?.text ?? el?.contentDescription ?? el?.contentDesc ?? el?.accessibilityLabel ?? el?.value ?? '').trim();
649
+ }
650
+ static _parseLeadingNumber(text) {
651
+ const match = text.trim().match(/^([-+]?\d+(?:\.\d+)?)(?:\s*[a-zA-Z%]+)?$/);
652
+ if (!match)
653
+ return null;
654
+ const value = Number(match[1]);
655
+ return Number.isFinite(value) ? value : null;
656
+ }
657
+ static _inferLabeledSliderState(elements, track) {
658
+ const trackBounds = ToolsInteract._normalizeBounds(track?.bounds);
659
+ if (!track || !trackBounds)
660
+ return null;
661
+ const type = ToolsInteract._normalize(track.type ?? track.class ?? '');
662
+ const role = ToolsInteract._normalize(track.role ?? '');
663
+ const width = trackBounds[2] - trackBounds[0];
664
+ const height = trackBounds[3] - trackBounds[1];
665
+ const wideTrackLike = width >= 240 && height <= 220;
666
+ const knownTrackLike = /slider|seekbar|range/.test(type) || /slider|range/.test(role);
667
+ if (!wideTrackLike && !knownTrackLike)
668
+ return null;
669
+ const trackTop = trackBounds[1];
670
+ const trackBottom = trackBounds[3];
671
+ const trackCenterX = (trackBounds[0] + trackBounds[2]) / 2;
672
+ const labels = elements
673
+ .map((el, index) => {
674
+ const bounds = ToolsInteract._normalizeBounds(el.bounds);
675
+ const text = ToolsInteract._textFromElement(el);
676
+ const value = ToolsInteract._parseLeadingNumber(text);
677
+ return bounds && text && value !== null
678
+ ? {
679
+ index,
680
+ el,
681
+ text,
682
+ value,
683
+ bounds,
684
+ centerX: (bounds[0] + bounds[2]) / 2,
685
+ centerY: (bounds[1] + bounds[3]) / 2,
686
+ hasUnit: /[a-zA-Z%]/.test(text)
687
+ }
688
+ : null;
689
+ })
690
+ .filter((label) => !!label);
691
+ const rangeLabels = labels
692
+ .filter((label) => {
693
+ const verticallyNearTrack = label.centerY >= trackTop - 520 && label.centerY <= trackBottom + 260;
694
+ const horizontallyNearTrack = label.centerX >= trackBounds[0] - 120 && label.centerX <= trackBounds[2] + 120;
695
+ return verticallyNearTrack && horizontallyNearTrack && label.hasUnit;
696
+ })
697
+ .sort((a, b) => a.centerX - b.centerX);
698
+ if (rangeLabels.length < 2)
699
+ return null;
700
+ const minLabel = rangeLabels[0];
701
+ const maxLabel = rangeLabels[rangeLabels.length - 1];
702
+ if (!minLabel || !maxLabel || maxLabel.value <= minLabel.value)
703
+ return null;
704
+ const currentCandidates = labels
705
+ .filter((label) => {
706
+ if (label.index === minLabel.index || label.index === maxLabel.index)
707
+ return false;
708
+ if (label.value < minLabel.value || label.value > maxLabel.value)
709
+ return false;
710
+ if (label.centerY < trackTop - 950 || label.centerY > minLabel.centerY - 20)
711
+ return false;
712
+ return label.centerX >= trackCenterX - 80;
713
+ })
714
+ .sort((a, b) => {
715
+ const yDistance = Math.abs((trackTop - 420) - a.centerY) - Math.abs((trackTop - 420) - b.centerY);
716
+ if (yDistance !== 0)
717
+ return yDistance;
718
+ return b.centerX - a.centerX;
719
+ });
720
+ const currentLabel = currentCandidates[0];
721
+ if (!currentLabel)
722
+ return null;
723
+ const candidateBelowRangeLabels = minLabel.centerY < trackTop && maxLabel.centerY < trackTop;
724
+ const labelDerivedBounds = [
725
+ Math.min(minLabel.bounds[0], trackBounds[0]),
726
+ Math.max(currentLabel.bounds[3] + 12, Math.min(minLabel.bounds[1], maxLabel.bounds[1]) - 160),
727
+ Math.max(maxLabel.bounds[2], trackBounds[2]),
728
+ Math.max(currentLabel.bounds[3] + 80, Math.min(minLabel.bounds[1], maxLabel.bounds[1]) - 32)
729
+ ];
730
+ return {
731
+ current: currentLabel.value,
732
+ min: minLabel.value,
733
+ max: maxLabel.value,
734
+ value_range: { min: minLabel.value, max: maxLabel.value },
735
+ touch_bounds: candidateBelowRangeLabels ? labelDerivedBounds : trackBounds,
736
+ source: 'nearby_labels'
737
+ };
738
+ }
271
739
  static _buildControlPoint(bounds, ratio, axis) {
272
740
  const clampedRatio = Math.max(0, Math.min(1, ratio));
273
741
  const [left, top, right, bottom] = bounds;
@@ -384,25 +852,12 @@ export class ToolsInteract {
384
852
  let current = chosen;
385
853
  let safety = 0;
386
854
  while (safety < 20 && current.el && !(current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el)) && current.el.parentId !== undefined && current.el.parentId !== null) {
387
- const parentId = current.el.parentId;
388
- let parentIndex = null;
389
- if (typeof parentId === 'number')
390
- parentIndex = parentId;
391
- else if (typeof parentId === 'string' && /^\d+$/.test(parentId))
392
- parentIndex = Number(parentId);
855
+ const parentIndex = ToolsInteract._resolveParentIndex(elements, current.el.parentId);
393
856
  if (parentIndex !== null && elements[parentIndex]) {
394
857
  current = { el: elements[parentIndex], idx: parentIndex };
395
858
  if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el))
396
859
  return current;
397
860
  }
398
- else if (typeof parentId === 'string') {
399
- const foundIndex = elements.findIndex((el) => el.resourceId === parentId || el.id === parentId);
400
- if (foundIndex === -1)
401
- break;
402
- current = { el: elements[foundIndex], idx: foundIndex };
403
- if (current.el.clickable || current.el.focusable || ToolsInteract._isSemanticActionable(current.el))
404
- return current;
405
- }
406
861
  else {
407
862
  break;
408
863
  }
@@ -516,11 +971,9 @@ export class ToolsInteract {
516
971
  return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, fingerprintBefore);
517
972
  }
518
973
  const resolvedTarget = ToolsInteract._resolvedTargetFromElement(resolved.elementId, currentMatch.el, currentMatch.index);
519
- if (!ToolsInteract._isVisibleElement(currentMatch.el)) {
520
- return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore);
521
- }
522
- if (currentMatch.el.enabled === false) {
523
- return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore);
974
+ const tapActionability = ToolsInteract._isTapActionable(currentMatch.el, resolved.stable_id, resolved.platform);
975
+ if (!tapActionability.actionable) {
976
+ return ToolsInteract._actionFailure(actionType, selector, resolvedTarget, tapActionability.failureCode ?? 'ELEMENT_NOT_INTERACTABLE', true, fingerprintBefore);
524
977
  }
525
978
  const bounds = ToolsInteract._normalizeBounds(currentMatch.el.bounds) ?? resolved.bounds;
526
979
  if (!bounds || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) {
@@ -545,14 +998,17 @@ export class ToolsInteract {
545
998
  sourceModule: 'interact'
546
999
  });
547
1000
  }
548
- static async adjustControlHandler({ selector, element_id, property = 'value', targetValue, tolerance = 0, maxAttempts = 3, platform, deviceId }) {
1001
+ static async adjustControlHandler({ selector, element_id, property = 'value', targetValue, tolerance, maxAttempts, platform, deviceId }) {
549
1002
  const actionType = 'adjust_control';
550
1003
  const targetSelector = selector ?? (element_id ? { elementId: element_id } : null);
551
- const normalizedTolerance = Number.isFinite(tolerance) ? Math.max(0, tolerance) : 0;
552
- const attemptsLimit = Math.max(1, Math.floor(Number(maxAttempts) || 1));
1004
+ const requestedTolerance = typeof tolerance === 'number' && Number.isFinite(tolerance) ? Math.max(0, tolerance) : null;
1005
+ const attemptsLimit = maxAttempts === undefined || maxAttempts === null
1006
+ ? 5
1007
+ : Math.max(1, Math.floor(Number(maxAttempts) || 1));
553
1008
  const sourcePlatform = platform || 'android';
554
1009
  let resolvedPlatform = sourcePlatform;
555
1010
  let resolvedDeviceId = deviceId;
1011
+ const storedResolvedTarget = element_id ? ToolsInteract._resolvedUiElements.get(element_id) ?? null : null;
556
1012
  const fingerprintBefore = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
557
1013
  let semanticFallbackElement = null;
558
1014
  const traceSteps = [];
@@ -582,6 +1038,7 @@ export class ToolsInteract {
582
1038
  retry_depth: attempts
583
1039
  });
584
1040
  }
1041
+ const responseTolerance = requestedTolerance ?? 0;
585
1042
  const base = buildActionExecutionResult({
586
1043
  actionType,
587
1044
  sourceModule: 'interact',
@@ -594,7 +1051,7 @@ export class ToolsInteract {
594
1051
  failure: { failureCode, retryable },
595
1052
  details: {
596
1053
  target_value: targetValue,
597
- tolerance: normalizedTolerance,
1054
+ tolerance: responseTolerance,
598
1055
  property,
599
1056
  attempts,
600
1057
  adjustment_mode: adjustmentMode,
@@ -610,7 +1067,7 @@ export class ToolsInteract {
610
1067
  target_state: {
611
1068
  property,
612
1069
  target_value: targetValue,
613
- tolerance: normalizedTolerance
1070
+ tolerance: responseTolerance
614
1071
  },
615
1072
  actual_state: actualState,
616
1073
  within_tolerance: false,
@@ -702,8 +1159,8 @@ export class ToolsInteract {
702
1159
  }
703
1160
  return null;
704
1161
  };
705
- if (!selector && !element_id) {
706
- return buildFailure('ELEMENT_NOT_FOUND', 'selector or element_id is required', null, undefined, null, 0, 'gesture', false);
1162
+ if ((!selector && !element_id) || (selector && element_id)) {
1163
+ return buildFailure('ELEMENT_NOT_FOUND', 'exactly one of selector or element_id is required', null, undefined, null, 0, 'gesture', false);
707
1164
  }
708
1165
  if (selector && !element_id) {
709
1166
  const waitResult = await ToolsInteract.waitForUIHandler({
@@ -742,7 +1199,8 @@ export class ToolsInteract {
742
1199
  let currentDevice = undefined;
743
1200
  let attemptCount = 0;
744
1201
  let cachedResolvedMatch = null;
745
- for (let attempt = 0; attempt < attemptsLimit; attempt++) {
1202
+ let coordinateTapsAwaitingFreshGesture = false;
1203
+ adjustmentAttempts: for (let attempt = 0; attempt < attemptsLimit && attemptCount < attemptsLimit; attempt++) {
746
1204
  const resolved = cachedResolvedMatch
747
1205
  ? {
748
1206
  tree: null,
@@ -758,11 +1216,33 @@ export class ToolsInteract {
758
1216
  resolvedTarget = resolved.resolvedTarget;
759
1217
  const currentEl = resolved.match.el;
760
1218
  cachedResolvedMatch = resolved.match;
761
- const bounds = ToolsInteract._normalizeBounds(currentEl.bounds);
762
- const valueRange = currentEl.state?.value_range ?? null;
763
- const currentValue = ToolsInteract._readNumericControlValue(currentEl, property);
1219
+ const adjustableActionability = ToolsInteract._isAdjustableActionable(currentEl, storedResolvedTarget?.stable_id);
1220
+ if (!adjustableActionability.actionable) {
1221
+ return buildFailure(adjustableActionability.failureCode ?? 'ELEMENT_NOT_INTERACTABLE', adjustableActionability.reason ?? 'adjustable control is not actionable', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true);
1222
+ }
1223
+ const elementsForInference = Array.isArray(resolved.tree?.elements)
1224
+ ? resolved.tree.elements
1225
+ : cachedResolvedMatch
1226
+ ? [cachedResolvedMatch.el]
1227
+ : [currentEl];
1228
+ const inferredSliderState = ToolsInteract._inferLabeledSliderState(elementsForInference, currentEl);
1229
+ const nativeBounds = ToolsInteract._normalizeBounds(currentEl.bounds);
1230
+ const bounds = inferredSliderState?.touch_bounds ?? nativeBounds;
1231
+ const valueRange = ToolsInteract._readNumericControlRange(currentEl) ?? inferredSliderState?.value_range ?? null;
1232
+ const step = ToolsInteract._readControlStep(currentEl);
1233
+ const normalizedTargetValue = valueRange
1234
+ ? ToolsInteract._quantizeTargetValue(targetValue, valueRange.min, valueRange.max, step)
1235
+ : targetValue;
1236
+ const effectiveTolerance = requestedTolerance ?? ToolsInteract._defaultAdjustTolerance(valueRange, step);
1237
+ const currentValue = ToolsInteract._readNumericControlValue(currentEl, property) ?? inferredSliderState?.current ?? null;
764
1238
  const actualState = currentValue !== null
765
- ? { property, value: currentValue, raw_value: typeof currentEl.state?.raw_value === 'number' ? currentEl.state.raw_value : undefined }
1239
+ ? {
1240
+ property,
1241
+ value: currentValue,
1242
+ raw_value: typeof currentEl.state?.raw_value === 'number'
1243
+ ? currentEl.state.raw_value
1244
+ : inferredSliderState?.current
1245
+ }
766
1246
  : null;
767
1247
  lastObservedState = actualState;
768
1248
  if (!traceSteps.some((step) => step.stage === 'resolve')) {
@@ -775,10 +1255,20 @@ export class ToolsInteract {
775
1255
  if (property !== 'value' && property !== 'raw_value') {
776
1256
  return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjust_control currently supports numeric value and raw_value properties only', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
777
1257
  }
778
- if (currentValue !== null && Math.abs(currentValue - targetValue) <= normalizedTolerance) {
1258
+ const isAdjustableControl = ToolsInteract._isAdjustableControl(currentEl) || !!inferredSliderState;
1259
+ if (!isAdjustableControl) {
1260
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'target is not an adjustable control', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
1261
+ }
1262
+ if (currentValue === null) {
1263
+ return buildFailure('UNKNOWN', 'current numeric value is unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
1264
+ }
1265
+ if (valueRange && (targetValue < valueRange.min || targetValue > valueRange.max)) {
1266
+ return buildFailure('CONTROL_CONVERGENCE_FAILED', `targetValue ${targetValue} is outside the control range ${valueRange.min}..${valueRange.max}`, resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
1267
+ }
1268
+ if (currentValue !== null && Math.abs(currentValue - normalizedTargetValue) <= effectiveTolerance) {
779
1269
  recordTraceStep('verify', 'success', {
780
1270
  property,
781
- target_value: targetValue,
1271
+ target_value: normalizedTargetValue,
782
1272
  actual_state: actualState,
783
1273
  reason: 'control already within tolerance'
784
1274
  });
@@ -793,8 +1283,8 @@ export class ToolsInteract {
793
1283
  uiFingerprintBefore: fingerprintBefore,
794
1284
  uiFingerprintAfter,
795
1285
  details: {
796
- target_value: targetValue,
797
- tolerance: normalizedTolerance,
1286
+ target_value: normalizedTargetValue,
1287
+ tolerance: effectiveTolerance,
798
1288
  property,
799
1289
  attempts: attemptCount,
800
1290
  adjustment_mode: 'semantic',
@@ -809,8 +1299,8 @@ export class ToolsInteract {
809
1299
  ...base,
810
1300
  target_state: {
811
1301
  property,
812
- target_value: targetValue,
813
- tolerance: normalizedTolerance
1302
+ target_value: normalizedTargetValue,
1303
+ tolerance: effectiveTolerance
814
1304
  },
815
1305
  actual_state: actualState,
816
1306
  within_tolerance: true,
@@ -819,40 +1309,40 @@ export class ToolsInteract {
819
1309
  adjustment_mode: 'semantic'
820
1310
  };
821
1311
  }
822
- if (!ToolsInteract._isAdjustableControl(currentEl)) {
823
- return buildFailure('ELEMENT_NOT_INTERACTABLE', 'target is not an adjustable control', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
824
- }
825
1312
  if (!bounds) {
826
1313
  return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
827
1314
  }
828
- const min = typeof valueRange?.min === 'number' ? valueRange.min : null;
829
- const max = typeof valueRange?.max === 'number' ? valueRange.max : null;
830
- if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
831
- return buildFailure('ELEMENT_NOT_INTERACTABLE', 'value_range unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
832
- }
833
- if (targetValue < min || targetValue > max) {
834
- return buildFailure('UNKNOWN', `targetValue ${targetValue} is outside the control range ${min}..${max}`, resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
835
- }
836
- const axis = ToolsInteract._controlAxis(currentEl, bounds);
837
- const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis);
838
- const currentPoint = currentValue !== null
839
- ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
840
- : ToolsInteract._buildControlPoint(bounds, 0.5, axis);
841
- const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis);
842
1315
  const runVerification = async () => {
1316
+ if (inferredSliderState) {
1317
+ const tree = await ToolsObserve.getUITreeHandler({ platform: resolvedPlatform, deviceId: resolvedDeviceId });
1318
+ const elements = Array.isArray(tree?.elements) ? tree.elements : [];
1319
+ const refreshed = ToolsInteract._inferLabeledSliderState(elements, currentEl);
1320
+ const observedValue = refreshed?.current ?? null;
1321
+ const observedState = observedValue !== null
1322
+ ? { property, value: observedValue, raw_value: observedValue }
1323
+ : actualState;
1324
+ return {
1325
+ verification: {
1326
+ success: observedValue !== null && Math.abs(observedValue - normalizedTargetValue) <= effectiveTolerance,
1327
+ observed_state: observedState,
1328
+ reason: observedValue !== null
1329
+ ? 'visible labeled value read from nearby slider labels'
1330
+ : 'visible labeled value unavailable'
1331
+ },
1332
+ observedState,
1333
+ withinTolerance: observedValue !== null && Math.abs(observedValue - normalizedTargetValue) <= effectiveTolerance
1334
+ };
1335
+ }
843
1336
  const verification = await ToolsInteract.expectStateHandler({
844
1337
  element_id: resolvedTarget?.elementId ?? element_id,
845
1338
  selector: selector ?? undefined,
846
1339
  property,
847
- expected: targetValue,
1340
+ expected: normalizedTargetValue,
848
1341
  platform: resolvedPlatform,
849
1342
  deviceId: resolvedDeviceId
850
1343
  });
851
- const observedValue = typeof verification?.observed_state?.value === 'number'
852
- ? verification.observed_state.value
853
- : typeof verification?.observed_state?.raw_value === 'number'
854
- ? verification.observed_state.raw_value
855
- : null;
1344
+ const observedValue = ToolsInteract._parseNumericControlValue(verification?.observed_state?.value) ??
1345
+ ToolsInteract._parseNumericControlValue(verification?.observed_state?.raw_value);
856
1346
  const observedState = observedValue !== null
857
1347
  ? {
858
1348
  property,
@@ -863,88 +1353,266 @@ export class ToolsInteract {
863
1353
  return {
864
1354
  verification,
865
1355
  observedState,
866
- withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
1356
+ withinTolerance: observedValue !== null && Math.abs(observedValue - normalizedTargetValue) <= effectiveTolerance
867
1357
  };
868
1358
  };
869
1359
  let actionDevice = currentDevice;
870
1360
  let observedState = actualState;
871
1361
  let verification = null;
872
1362
  let verificationResult = { verification: null, observedState: actualState, withinTolerance: false };
873
- for (let i = 0; i < probePoints.length; i++) {
874
- const probePoint = probePoints[i];
875
- lastAdjustmentMode = 'coordinate';
876
- recordTraceStep('execute', 'retry', {
877
- attempt: attemptCount + 1,
878
- mode: 'coordinate',
879
- point: probePoint
880
- });
881
- const actionResult = await ToolsInteract.tapHandler({
882
- platform: resolvedPlatform,
883
- x: probePoint.x,
884
- y: probePoint.y,
885
- deviceId: resolvedDeviceId
886
- });
887
- attemptCount++;
888
- actionDevice = actionResult.device ?? actionDevice;
889
- if (!actionResult.success) {
1363
+ const directActionSupported = ToolsInteract._hasDirectAdjustmentAction(currentEl);
1364
+ if (directActionSupported && attemptCount < attemptsLimit) {
1365
+ const { interact, resolved: actionResolved } = await ToolsInteract.getInteractionService(resolvedPlatform, resolvedDeviceId);
1366
+ if (typeof interact.setAdjustableValue === 'function') {
1367
+ lastAdjustmentMode = 'semantic';
890
1368
  recordTraceStep('execute', 'retry', {
891
- attempt: attemptCount,
892
- mode: 'coordinate',
893
- point: probePoint,
894
- success: false
1369
+ attempt: attemptCount + 1,
1370
+ mode: 'semantic',
1371
+ strategy: 'direct_set_value',
1372
+ target_value: normalizedTargetValue
895
1373
  });
896
- continue;
897
- }
898
- verificationResult = await runVerification();
899
- observedState = verificationResult.observedState;
900
- lastObservedState = observedState;
901
- recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
902
- attempt: attemptCount,
903
- property,
904
- target_value: targetValue,
905
- actual_state: observedState,
906
- reason: verificationResult.verification?.reason ?? 'control did not converge yet'
907
- });
908
- if (verificationResult.withinTolerance) {
909
- const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
910
- const base = buildActionExecutionResult({
911
- actionType,
912
- sourceModule: 'interact',
913
- device: actionDevice ?? currentDevice,
914
- selector: targetSelector,
915
- resolved: resolvedTarget,
916
- success: true,
917
- uiFingerprintBefore: fingerprintBefore,
918
- uiFingerprintAfter,
919
- details: {
920
- target_value: targetValue,
921
- tolerance: normalizedTolerance,
1374
+ const actionResult = await interact.setAdjustableValue({
1375
+ deviceId: actionResolved.id,
1376
+ target: resolvedTarget,
1377
+ property,
1378
+ value: normalizedTargetValue
1379
+ });
1380
+ attemptCount++;
1381
+ actionDevice = actionResult?.device ?? actionDevice;
1382
+ if (actionResult?.success !== false) {
1383
+ verificationResult = await runVerification();
1384
+ observedState = verificationResult.observedState;
1385
+ lastObservedState = observedState;
1386
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1387
+ attempt: attemptCount,
922
1388
  property,
923
- attempts: attemptCount,
924
- adjustment_mode: lastAdjustmentMode,
1389
+ target_value: normalizedTargetValue,
1390
+ actual_state: observedState,
1391
+ reason: verificationResult.verification?.reason ?? 'direct adjustment did not converge yet'
1392
+ });
1393
+ if (verificationResult.withinTolerance) {
1394
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
1395
+ const base = buildActionExecutionResult({
1396
+ actionType,
1397
+ sourceModule: 'interact',
1398
+ device: actionDevice ?? currentDevice,
1399
+ selector: targetSelector,
1400
+ resolved: resolvedTarget,
1401
+ success: true,
1402
+ uiFingerprintBefore: fingerprintBefore,
1403
+ uiFingerprintAfter,
1404
+ details: {
1405
+ target_value: normalizedTargetValue,
1406
+ tolerance: effectiveTolerance,
1407
+ property,
1408
+ attempts: attemptCount,
1409
+ adjustment_mode: lastAdjustmentMode,
1410
+ actual_state: observedState,
1411
+ converged: true,
1412
+ within_tolerance: true,
1413
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1414
+ },
1415
+ traceSteps
1416
+ });
1417
+ return {
1418
+ ...base,
1419
+ target_state: {
1420
+ property,
1421
+ target_value: normalizedTargetValue,
1422
+ tolerance: effectiveTolerance
1423
+ },
1424
+ actual_state: observedState,
1425
+ within_tolerance: true,
1426
+ converged: true,
1427
+ attempts: attemptCount,
1428
+ adjustment_mode: lastAdjustmentMode
1429
+ };
1430
+ }
1431
+ cachedResolvedMatch = null;
1432
+ if (attemptCount < attemptsLimit) {
1433
+ continue;
1434
+ }
1435
+ }
1436
+ }
1437
+ }
1438
+ const directions = ToolsInteract._adjustmentDirections(currentEl);
1439
+ if ((directions.increment || directions.decrement) && attemptCount < attemptsLimit) {
1440
+ const { interact, resolved: actionResolved } = await ToolsInteract.getInteractionService(resolvedPlatform, resolvedDeviceId);
1441
+ const adjustByDirection = typeof interact.adjustAccessibleValue === 'function'
1442
+ ? interact.adjustAccessibleValue.bind(interact)
1443
+ : null;
1444
+ while (adjustByDirection && attemptCount < attemptsLimit) {
1445
+ const direction = normalizedTargetValue > (observedState?.value ?? currentValue) ? 'increment' : 'decrement';
1446
+ if ((direction === 'increment' && !directions.increment) || (direction === 'decrement' && !directions.decrement))
1447
+ break;
1448
+ lastAdjustmentMode = 'semantic';
1449
+ recordTraceStep('execute', 'retry', {
1450
+ attempt: attemptCount + 1,
1451
+ mode: 'semantic',
1452
+ strategy: 'accessibility_increment_decrement',
1453
+ direction
1454
+ });
1455
+ const actionResult = await adjustByDirection({
1456
+ deviceId: actionResolved.id,
1457
+ target: resolvedTarget,
1458
+ property,
1459
+ direction
1460
+ });
1461
+ attemptCount++;
1462
+ actionDevice = actionResult?.device ?? actionDevice;
1463
+ if (actionResult?.success === false)
1464
+ break;
1465
+ verificationResult = await runVerification();
1466
+ observedState = verificationResult.observedState;
1467
+ lastObservedState = observedState;
1468
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1469
+ attempt: attemptCount,
1470
+ property,
1471
+ target_value: normalizedTargetValue,
1472
+ actual_state: observedState,
1473
+ reason: verificationResult.verification?.reason ?? 'incremental adjustment did not converge yet'
1474
+ });
1475
+ if (verificationResult.withinTolerance) {
1476
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
1477
+ const base = buildActionExecutionResult({
1478
+ actionType,
1479
+ sourceModule: 'interact',
1480
+ device: actionDevice ?? currentDevice,
1481
+ selector: targetSelector,
1482
+ resolved: resolvedTarget,
1483
+ success: true,
1484
+ uiFingerprintBefore: fingerprintBefore,
1485
+ uiFingerprintAfter,
1486
+ details: {
1487
+ target_value: normalizedTargetValue,
1488
+ tolerance: effectiveTolerance,
1489
+ property,
1490
+ attempts: attemptCount,
1491
+ adjustment_mode: lastAdjustmentMode,
1492
+ actual_state: observedState,
1493
+ converged: true,
1494
+ within_tolerance: true,
1495
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1496
+ },
1497
+ traceSteps
1498
+ });
1499
+ return {
1500
+ ...base,
1501
+ target_state: {
1502
+ property,
1503
+ target_value: normalizedTargetValue,
1504
+ tolerance: effectiveTolerance
1505
+ },
925
1506
  actual_state: observedState,
926
- converged: true,
927
1507
  within_tolerance: true,
928
- reason: verificationResult.verification?.reason ?? 'control converged to target value'
929
- },
930
- traceSteps
1508
+ converged: true,
1509
+ attempts: attemptCount,
1510
+ adjustment_mode: lastAdjustmentMode
1511
+ };
1512
+ }
1513
+ cachedResolvedMatch = null;
1514
+ if (attemptCount < attemptsLimit) {
1515
+ continue adjustmentAttempts;
1516
+ }
1517
+ }
1518
+ }
1519
+ if (!bounds) {
1520
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
1521
+ }
1522
+ if (!valueRange) {
1523
+ return buildFailure('UNKNOWN', 'value_range unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false);
1524
+ }
1525
+ const min = valueRange.min;
1526
+ const max = valueRange.max;
1527
+ const axis = ToolsInteract._controlAxis(currentEl, bounds);
1528
+ const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, normalizedTargetValue, currentValue, min, max, axis);
1529
+ const currentPoint = ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis);
1530
+ if (!coordinateTapsAwaitingFreshGesture) {
1531
+ const probePoints = inferredSliderState
1532
+ ? [targetPoint]
1533
+ : ToolsInteract._buildAdjustmentProbePoints(bounds, normalizedTargetValue, currentValue, min, max, axis);
1534
+ for (let i = 0; i < probePoints.length && attemptCount < attemptsLimit; i++) {
1535
+ const probePoint = probePoints[i];
1536
+ lastAdjustmentMode = 'coordinate';
1537
+ recordTraceStep('execute', 'retry', {
1538
+ attempt: attemptCount + 1,
1539
+ mode: 'coordinate',
1540
+ point: probePoint
931
1541
  });
932
- return {
933
- ...base,
934
- target_state: {
935
- property,
936
- target_value: targetValue,
937
- tolerance: normalizedTolerance
938
- },
1542
+ const actionResult = await ToolsInteract.tapHandler({
1543
+ platform: resolvedPlatform,
1544
+ x: probePoint.x,
1545
+ y: probePoint.y,
1546
+ deviceId: resolvedDeviceId
1547
+ });
1548
+ attemptCount++;
1549
+ actionDevice = actionResult.device ?? actionDevice;
1550
+ if (!actionResult.success) {
1551
+ recordTraceStep('execute', 'retry', {
1552
+ attempt: attemptCount,
1553
+ mode: 'coordinate',
1554
+ point: probePoint,
1555
+ success: false
1556
+ });
1557
+ continue;
1558
+ }
1559
+ verificationResult = await runVerification();
1560
+ observedState = verificationResult.observedState;
1561
+ lastObservedState = observedState;
1562
+ recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1563
+ attempt: attemptCount,
1564
+ property,
1565
+ target_value: normalizedTargetValue,
939
1566
  actual_state: observedState,
940
- within_tolerance: true,
941
- converged: true,
942
- attempts: attemptCount,
943
- adjustment_mode: lastAdjustmentMode
944
- };
1567
+ reason: verificationResult.verification?.reason ?? 'control did not converge yet'
1568
+ });
1569
+ if (verificationResult.withinTolerance) {
1570
+ const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
1571
+ const base = buildActionExecutionResult({
1572
+ actionType,
1573
+ sourceModule: 'interact',
1574
+ device: actionDevice ?? currentDevice,
1575
+ selector: targetSelector,
1576
+ resolved: resolvedTarget,
1577
+ success: true,
1578
+ uiFingerprintBefore: fingerprintBefore,
1579
+ uiFingerprintAfter,
1580
+ details: {
1581
+ target_value: normalizedTargetValue,
1582
+ tolerance: effectiveTolerance,
1583
+ property,
1584
+ attempts: attemptCount,
1585
+ adjustment_mode: lastAdjustmentMode,
1586
+ actual_state: observedState,
1587
+ converged: true,
1588
+ within_tolerance: true,
1589
+ reason: verificationResult.verification?.reason ?? 'control converged to target value'
1590
+ },
1591
+ traceSteps
1592
+ });
1593
+ return {
1594
+ ...base,
1595
+ target_state: {
1596
+ property,
1597
+ target_value: normalizedTargetValue,
1598
+ tolerance: effectiveTolerance
1599
+ },
1600
+ actual_state: observedState,
1601
+ within_tolerance: true,
1602
+ converged: true,
1603
+ attempts: attemptCount,
1604
+ adjustment_mode: lastAdjustmentMode
1605
+ };
1606
+ }
1607
+ coordinateTapsAwaitingFreshGesture = true;
1608
+ cachedResolvedMatch = null;
1609
+ if (attemptCount < attemptsLimit) {
1610
+ continue adjustmentAttempts;
1611
+ }
945
1612
  }
946
1613
  }
947
- if (currentValue !== null) {
1614
+ if (attemptCount < attemptsLimit) {
1615
+ coordinateTapsAwaitingFreshGesture = false;
948
1616
  lastAdjustmentMode = 'gesture';
949
1617
  recordTraceStep('execute', 'retry', {
950
1618
  attempt: attemptCount + 1,
@@ -958,7 +1626,7 @@ export class ToolsInteract {
958
1626
  y1: currentPoint.y,
959
1627
  x2: targetPoint.x,
960
1628
  y2: targetPoint.y,
961
- duration: 220,
1629
+ duration: inferredSliderState ? 520 : 220,
962
1630
  deviceId: resolvedDeviceId
963
1631
  });
964
1632
  attemptCount++;
@@ -979,7 +1647,7 @@ export class ToolsInteract {
979
1647
  recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
980
1648
  attempt: attemptCount,
981
1649
  property,
982
- target_value: targetValue,
1650
+ target_value: normalizedTargetValue,
983
1651
  actual_state: observedState,
984
1652
  reason: verificationResult.verification?.reason ?? 'gesture adjustment did not converge yet'
985
1653
  });
@@ -995,8 +1663,8 @@ export class ToolsInteract {
995
1663
  uiFingerprintBefore: fingerprintBefore,
996
1664
  uiFingerprintAfter,
997
1665
  details: {
998
- target_value: targetValue,
999
- tolerance: normalizedTolerance,
1666
+ target_value: normalizedTargetValue,
1667
+ tolerance: effectiveTolerance,
1000
1668
  property,
1001
1669
  attempts: attemptCount,
1002
1670
  adjustment_mode: lastAdjustmentMode,
@@ -1011,8 +1679,8 @@ export class ToolsInteract {
1011
1679
  ...base,
1012
1680
  target_state: {
1013
1681
  property,
1014
- target_value: targetValue,
1015
- tolerance: normalizedTolerance
1682
+ target_value: normalizedTargetValue,
1683
+ tolerance: effectiveTolerance
1016
1684
  },
1017
1685
  actual_state: observedState,
1018
1686
  within_tolerance: true,
@@ -1028,7 +1696,7 @@ export class ToolsInteract {
1028
1696
  recordTraceStep('verify', 'success', {
1029
1697
  attempt: attemptCount,
1030
1698
  property,
1031
- target_value: targetValue,
1699
+ target_value: normalizedTargetValue,
1032
1700
  actual_state: observedState,
1033
1701
  reason: verification?.reason ?? 'control converged to target value'
1034
1702
  });
@@ -1043,8 +1711,8 @@ export class ToolsInteract {
1043
1711
  uiFingerprintBefore: fingerprintBefore,
1044
1712
  uiFingerprintAfter,
1045
1713
  details: {
1046
- target_value: targetValue,
1047
- tolerance: normalizedTolerance,
1714
+ target_value: normalizedTargetValue,
1715
+ tolerance: effectiveTolerance,
1048
1716
  property,
1049
1717
  attempts: attemptCount,
1050
1718
  adjustment_mode: lastAdjustmentMode,
@@ -1059,8 +1727,8 @@ export class ToolsInteract {
1059
1727
  ...base,
1060
1728
  target_state: {
1061
1729
  property,
1062
- target_value: targetValue,
1063
- tolerance: normalizedTolerance
1730
+ target_value: normalizedTargetValue,
1731
+ tolerance: effectiveTolerance
1064
1732
  },
1065
1733
  actual_state: observedState,
1066
1734
  within_tolerance: true,
@@ -1069,22 +1737,24 @@ export class ToolsInteract {
1069
1737
  adjustment_mode: lastAdjustmentMode
1070
1738
  };
1071
1739
  }
1072
- cachedResolvedMatch = {
1073
- el: {
1074
- ...currentEl,
1075
- state: {
1076
- ...(currentEl.state ?? null),
1077
- ...(observedState ? {
1078
- [observedState.property]: observedState.value,
1079
- raw_value: observedState.raw_value ?? observedState.value
1080
- } : {})
1081
- }
1082
- },
1083
- idx: resolved.match.idx
1084
- };
1740
+ cachedResolvedMatch = inferredSliderState
1741
+ ? null
1742
+ : {
1743
+ el: {
1744
+ ...currentEl,
1745
+ state: {
1746
+ ...(currentEl.state ?? null),
1747
+ ...(observedState ? {
1748
+ [observedState.property]: observedState.value,
1749
+ raw_value: observedState.raw_value ?? observedState.value
1750
+ } : {})
1751
+ }
1752
+ },
1753
+ idx: resolved.match.idx
1754
+ };
1085
1755
  }
1086
1756
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId);
1087
- return buildFailure('TIMEOUT', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter);
1757
+ return buildFailure('CONTROL_CONVERGENCE_FAILED', 'control did not converge within the allotted attempts', resolvedTarget, currentDevice, lastObservedState, attemptCount, lastAdjustmentMode, true, uiFingerprintAfter);
1088
1758
  }
1089
1759
  static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
1090
1760
  const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
@@ -1711,24 +2381,65 @@ export class ToolsInteract {
1711
2381
  }
1712
2382
  };
1713
2383
  }
1714
- static async waitForUIChangeHandler({ platform, deviceId, timeout_ms = 60000, stability_window_ms = 300, expected_change }) {
2384
+ static async waitForUIChangeHandler({ platform, deviceId, timeout_ms = 60000, stability_window_ms = 300, expected_change, scope = 'screen', target = null }) {
1715
2385
  const start = Date.now();
1716
2386
  const pollIntervalMs = 300;
1717
2387
  const stabilityWindow = Math.max(0, typeof stability_window_ms === 'number' ? stability_window_ms : 300);
1718
2388
  let baseline = null;
2389
+ let baselineScope = null;
1719
2390
  let lastObservedRevision = null;
1720
2391
  let lastLoadingState = null;
2392
+ let lastSnapshotFreshnessMs = null;
1721
2393
  let candidateSignatures = null;
1722
2394
  let candidateObservedChange = null;
1723
2395
  let candidateSinceMs = null;
2396
+ let lastChangeSummary = null;
2397
+ let lastScopeResolution = {
2398
+ scope: scope === 'subtree' ? 'subtree' : 'screen',
2399
+ target: target && typeof target === 'string' ? target : null,
2400
+ resolved: scope !== 'subtree',
2401
+ resolvedIndex: null,
2402
+ resolvedStableId: null,
2403
+ reason: scope === 'subtree' ? 'target not resolved yet' : 'screen scope'
2404
+ };
1724
2405
  while (Date.now() - start < timeout_ms) {
1725
2406
  try {
1726
2407
  const tree = await ToolsObserve.getUITreeHandler({ platform, deviceId });
1727
- const signatures = ToolsInteract._buildUiChangeSignatures(tree);
2408
+ const scopedTree = ToolsInteract._resolveUiChangeScope(tree, scope, target);
2409
+ if (scopedTree.error) {
2410
+ lastScopeResolution = scopedTree.resolution;
2411
+ return {
2412
+ success: false,
2413
+ observed_change: null,
2414
+ snapshot_revision: typeof tree?.snapshot_revision === 'number' ? tree.snapshot_revision : lastObservedRevision ?? undefined,
2415
+ snapshot_freshness_ms: typeof tree?.captured_at_ms === 'number' ? Math.max(0, Date.now() - tree.captured_at_ms) : lastSnapshotFreshnessMs ?? null,
2416
+ timeout: true,
2417
+ elapsed_ms: Date.now() - start,
2418
+ expected_change,
2419
+ loading_state: tree?.loading_state ?? lastLoadingState ?? null,
2420
+ scope: scopedTree.resolution.scope,
2421
+ target: scopedTree.resolution.target,
2422
+ stability_state: 'transient',
2423
+ change_summary: lastChangeSummary,
2424
+ reason: scopedTree.error.message,
2425
+ error: scopedTree.error
2426
+ };
2427
+ }
2428
+ const scopedElements = scopedTree.elements;
2429
+ const scopedSignatureTree = {
2430
+ ...tree,
2431
+ elements: scopedElements
2432
+ };
2433
+ const signatures = ToolsInteract._buildUiChangeSignatures(scopedSignatureTree);
1728
2434
  lastObservedRevision = typeof tree?.snapshot_revision === 'number' ? tree.snapshot_revision : lastObservedRevision;
1729
2435
  lastLoadingState = tree?.loading_state ?? lastLoadingState;
2436
+ lastSnapshotFreshnessMs = typeof tree?.captured_at_ms === 'number' ? Math.max(0, Date.now() - tree.captured_at_ms) : lastSnapshotFreshnessMs;
2437
+ lastChangeSummary = baseline ? ToolsInteract._summarizeUiChangeDelta((baselineScope?.elements ?? []), scopedElements) : lastChangeSummary;
2438
+ lastScopeResolution = scopedTree.resolution;
2439
+ baselineScope = baselineScope ?? scopedTree;
1730
2440
  if (!baseline) {
1731
2441
  baseline = signatures;
2442
+ baselineScope = scopedTree;
1732
2443
  }
1733
2444
  else {
1734
2445
  const observedChange = ToolsInteract._matchesUiChange(expected_change, baseline, signatures);
@@ -1744,10 +2455,15 @@ export class ToolsInteract {
1744
2455
  success: true,
1745
2456
  observed_change: candidateObservedChange ?? observedChange,
1746
2457
  snapshot_revision: lastObservedRevision ?? undefined,
2458
+ snapshot_freshness_ms: lastSnapshotFreshnessMs ?? null,
1747
2459
  timeout: false,
1748
2460
  elapsed_ms: Date.now() - start,
1749
2461
  expected_change,
1750
2462
  loading_state: lastLoadingState ?? null,
2463
+ scope: lastScopeResolution.scope,
2464
+ target: lastScopeResolution.target,
2465
+ stability_state: 'stable',
2466
+ change_summary: lastChangeSummary,
1751
2467
  reason: 'UI change observed'
1752
2468
  };
1753
2469
  }
@@ -1768,10 +2484,15 @@ export class ToolsInteract {
1768
2484
  success: false,
1769
2485
  observed_change: null,
1770
2486
  snapshot_revision: lastObservedRevision ?? undefined,
2487
+ snapshot_freshness_ms: lastSnapshotFreshnessMs ?? null,
1771
2488
  timeout: true,
1772
2489
  elapsed_ms: Date.now() - start,
1773
2490
  expected_change,
1774
2491
  loading_state: lastLoadingState ?? null,
2492
+ scope: lastScopeResolution.scope,
2493
+ target: lastScopeResolution.target,
2494
+ stability_state: 'transient',
2495
+ change_summary: lastChangeSummary,
1775
2496
  reason: 'timeout'
1776
2497
  };
1777
2498
  }