mobile-debug-mcp 0.30.1 → 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 (36) hide show
  1. package/dist/interact/index.js +546 -133
  2. package/dist/server/tool-definitions.js +13 -9
  3. package/dist/server/tool-handlers.js +4 -4
  4. package/dist/server-core.js +1 -1
  5. package/docs/CHANGELOG.md +3 -0
  6. package/docs/ROADMAP.md +85 -209
  7. package/docs/specs-deltas/015-adjustable-control-support.md +541 -0
  8. package/package.json +1 -1
  9. package/skills/spec-review/SKILL.md +52 -0
  10. package/skills/spec-review/references/spec-review-checklist.md +13 -0
  11. package/skills/spec-review/references/spec-review-template.md +28 -0
  12. package/src/interact/index.ts +581 -138
  13. package/src/server/tool-definitions.ts +13 -9
  14. package/src/server/tool-handlers.ts +4 -4
  15. package/src/server-core.ts +1 -1
  16. package/src/types.ts +2 -0
  17. package/test/unit/interact/adjust_control.test.ts +586 -33
  18. package/test/unit/server/contract.test.ts +4 -0
  19. package/skills/rfc-review/SKILL.md +0 -52
  20. package/skills/rfc-review/references/rfc-review-checklist.md +0 -12
  21. package/skills/rfc-review/references/rfc-review-template.md +0 -28
  22. /package/docs/{rfcs → specs-deltas}/001-state-verification.md +0 -0
  23. /package/docs/{rfcs → specs-deltas}/002-richer-element-identity +0 -0
  24. /package/docs/{rfcs → specs-deltas}/003-wait-and-synchronization-reliability.md +0 -0
  25. /package/docs/{rfcs → specs-deltas}/004-action-verification-routing.md +0 -0
  26. /package/docs/{rfcs → specs-deltas}/005-unified-action-execution-and-verification-model.md +0 -0
  27. /package/docs/{rfcs → specs-deltas}/006-runtime-action-instrumentation-and-binding-layer.md +0 -0
  28. /package/docs/{rfcs → specs-deltas}/007-actionability-resolution-and-executable-target-selection.md +0 -0
  29. /package/docs/{rfcs → specs-deltas}/008-adjustable-control-support-and-semantic-value-manipulation.md +0 -0
  30. /package/docs/{rfcs → specs-deltas}/009-semantic-control-modeling-for-custom-and-composite-controls.md +0 -0
  31. /package/docs/{rfcs → specs-deltas}/010-verification-stabilization-and-temporal-convergence.md +0 -0
  32. /package/docs/{rfcs → specs-deltas}/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md +0 -0
  33. /package/docs/{rfcs → specs-deltas}/011.1-recovery-contract-types-and-runtime-wiring-spec.md +0 -0
  34. /package/docs/{rfcs → specs-deltas}/012-action-trace-and-xecution-observability.md +0 -0
  35. /package/docs/{rfcs → specs-deltas}/013-wait-and-synchronization-reliability.md +0 -0
  36. /package/docs/{rfcs → specs-deltas}/014-actionability-resolution.md +0 -0
@@ -53,6 +53,10 @@ interface UiElement {
53
53
  test_tag?: string | null
54
54
  selector?: { value: string | null, confidence: { score: number, reason: string } | null } | null
55
55
  semantic?: UIElementSemanticMetadata | null
56
+ actions?: string[] | null
57
+ accessibilityActions?: string[] | null
58
+ runtimeActions?: string[] | null
59
+ platformActions?: string[] | null
56
60
  }
57
61
 
58
62
  interface ResolvedUiElementContext {
@@ -101,6 +105,15 @@ interface RankedResolutionCandidate {
101
105
  interactable: boolean
102
106
  }
103
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
+
104
117
  function buildObservationTrace({
105
118
  actionType,
106
119
  stage,
@@ -719,13 +732,217 @@ export class ToolsInteract {
719
732
  if (!el?.state) return null
720
733
  const stateValue = el.state[property as keyof UIElementState]
721
734
  if (typeof stateValue === 'number' && Number.isFinite(stateValue)) return stateValue
735
+ const parsedStateValue = ToolsInteract._parseNumericControlValue(stateValue)
736
+ if (parsedStateValue !== null) return parsedStateValue
722
737
  if (property === 'value' || property === 'raw_value') {
723
738
  const fallback = el.state.raw_value ?? el.state.value
724
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
725
792
  }
726
793
  return null
727
794
  }
728
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
+
729
946
  private static _buildControlPoint(bounds: [number, number, number, number], ratio: number, axis: 'horizontal' | 'vertical') {
730
947
  const clampedRatio = Math.max(0, Math.min(1, ratio))
731
948
  const [left, top, right, bottom] = bounds
@@ -1080,8 +1297,8 @@ export class ToolsInteract {
1080
1297
  element_id,
1081
1298
  property = 'value',
1082
1299
  targetValue,
1083
- tolerance = 0,
1084
- maxAttempts = 3,
1300
+ tolerance,
1301
+ maxAttempts,
1085
1302
  platform,
1086
1303
  deviceId
1087
1304
  }: {
@@ -1096,8 +1313,10 @@ export class ToolsInteract {
1096
1313
  }): Promise<AdjustControlResponse> {
1097
1314
  const actionType = 'adjust_control'
1098
1315
  const targetSelector = selector ?? (element_id ? { elementId: element_id } : null)
1099
- const normalizedTolerance = Number.isFinite(tolerance) ? Math.max(0, tolerance) : 0
1100
- 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))
1101
1320
  const sourcePlatform: 'android' | 'ios' = platform || 'android'
1102
1321
  let resolvedPlatform = sourcePlatform
1103
1322
  let resolvedDeviceId = deviceId
@@ -1148,6 +1367,7 @@ export class ToolsInteract {
1148
1367
  retry_depth: attempts
1149
1368
  })
1150
1369
  }
1370
+ const responseTolerance = requestedTolerance ?? 0
1151
1371
  const base = buildActionExecutionResult({
1152
1372
  actionType,
1153
1373
  sourceModule: 'interact',
@@ -1160,7 +1380,7 @@ export class ToolsInteract {
1160
1380
  failure: { failureCode, retryable },
1161
1381
  details: {
1162
1382
  target_value: targetValue,
1163
- tolerance: normalizedTolerance,
1383
+ tolerance: responseTolerance,
1164
1384
  property,
1165
1385
  attempts,
1166
1386
  adjustment_mode: adjustmentMode,
@@ -1177,7 +1397,7 @@ export class ToolsInteract {
1177
1397
  target_state: {
1178
1398
  property,
1179
1399
  target_value: targetValue,
1180
- tolerance: normalizedTolerance
1400
+ tolerance: responseTolerance
1181
1401
  },
1182
1402
  actual_state: actualState,
1183
1403
  within_tolerance: false,
@@ -1295,8 +1515,8 @@ export class ToolsInteract {
1295
1515
  return null
1296
1516
  }
1297
1517
 
1298
- if (!selector && !element_id) {
1299
- 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)
1300
1520
  }
1301
1521
 
1302
1522
  if (selector && !element_id) {
@@ -1358,8 +1578,10 @@ export class ToolsInteract {
1358
1578
  let currentDevice: any = undefined
1359
1579
  let attemptCount = 0
1360
1580
  let cachedResolvedMatch: { el: UiElement, idx: number } | null = null
1581
+ let coordinateTapsAwaitingFreshGesture = false
1361
1582
 
1362
- for (let attempt = 0; attempt < attemptsLimit; attempt++) {
1583
+ adjustmentAttempts:
1584
+ for (let attempt = 0; attempt < attemptsLimit && attemptCount < attemptsLimit; attempt++) {
1363
1585
  const resolved: {
1364
1586
  tree: any
1365
1587
  device: any
@@ -1400,11 +1622,29 @@ export class ToolsInteract {
1400
1622
  )
1401
1623
  }
1402
1624
 
1403
- const bounds = ToolsInteract._normalizeBounds(currentEl.bounds)
1404
- const valueRange = currentEl.state?.value_range ?? null
1405
- const currentValue = ToolsInteract._readNumericControlValue(currentEl, property)
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
1406
1640
  const actualState = currentValue !== null
1407
- ? { 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
+ }
1408
1648
  : null
1409
1649
 
1410
1650
  lastObservedState = actualState
@@ -1421,10 +1661,23 @@ export class ToolsInteract {
1421
1661
  return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjust_control currently supports numeric value and raw_value properties only', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1422
1662
  }
1423
1663
 
1424
- 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) {
1425
1678
  recordTraceStep('verify', 'success', {
1426
1679
  property,
1427
- target_value: targetValue,
1680
+ target_value: normalizedTargetValue,
1428
1681
  actual_state: actualState,
1429
1682
  reason: 'control already within tolerance'
1430
1683
  })
@@ -1439,8 +1692,8 @@ export class ToolsInteract {
1439
1692
  uiFingerprintBefore: fingerprintBefore,
1440
1693
  uiFingerprintAfter,
1441
1694
  details: {
1442
- target_value: targetValue,
1443
- tolerance: normalizedTolerance,
1695
+ target_value: normalizedTargetValue,
1696
+ tolerance: effectiveTolerance,
1444
1697
  property,
1445
1698
  attempts: attemptCount,
1446
1699
  adjustment_mode: 'semantic',
@@ -1456,8 +1709,8 @@ export class ToolsInteract {
1456
1709
  ...base,
1457
1710
  target_state: {
1458
1711
  property,
1459
- target_value: targetValue,
1460
- tolerance: normalizedTolerance
1712
+ target_value: normalizedTargetValue,
1713
+ tolerance: effectiveTolerance
1461
1714
  },
1462
1715
  actual_state: actualState,
1463
1716
  within_tolerance: true,
@@ -1467,50 +1720,48 @@ export class ToolsInteract {
1467
1720
  }
1468
1721
  }
1469
1722
 
1470
- if (!ToolsInteract._isAdjustableControl(currentEl)) {
1471
- return buildFailure('ELEMENT_NOT_INTERACTABLE', 'target is not an adjustable control', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1472
- }
1473
-
1474
1723
  if (!bounds) {
1475
1724
  return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1476
1725
  }
1477
1726
 
1478
- const min = typeof valueRange?.min === 'number' ? valueRange.min : null
1479
- const max = typeof valueRange?.max === 'number' ? valueRange.max : null
1480
- if (min === null || max === null || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
1481
- return buildFailure('ELEMENT_NOT_INTERACTABLE', 'value_range unavailable', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1482
- }
1483
-
1484
- if (targetValue < min || targetValue > max) {
1485
- return buildFailure('UNKNOWN', `targetValue ${targetValue} is outside the control range ${min}..${max}`, resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1486
- }
1487
-
1488
- const axis = ToolsInteract._controlAxis(currentEl, bounds)
1489
- const targetPoint = ToolsInteract._buildConservativeControlPoint(bounds, targetValue, currentValue, min, max, axis)
1490
- const currentPoint = currentValue !== null
1491
- ? ToolsInteract._buildControlPoint(bounds, (currentValue - min) / (max - min), axis)
1492
- : ToolsInteract._buildControlPoint(bounds, 0.5, axis)
1493
- const probePoints = ToolsInteract._buildAdjustmentProbePoints(bounds, targetValue, currentValue, min, max, axis)
1494
-
1495
1727
  const runVerification = async (): Promise<{
1496
1728
  verification: any
1497
1729
  observedState: { property: string; value: number | null; raw_value?: number | null } | null
1498
1730
  withinTolerance: boolean
1499
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
+
1500
1754
  const verification = await ToolsInteract.expectStateHandler({
1501
1755
  element_id: resolvedTarget?.elementId ?? element_id,
1502
1756
  selector: selector ?? undefined,
1503
1757
  property,
1504
- expected: targetValue,
1758
+ expected: normalizedTargetValue,
1505
1759
  platform: resolvedPlatform,
1506
1760
  deviceId: resolvedDeviceId
1507
1761
  }) as any
1508
1762
 
1509
- const observedValue = typeof verification?.observed_state?.value === 'number'
1510
- ? verification.observed_state.value
1511
- : typeof verification?.observed_state?.raw_value === 'number'
1512
- ? verification.observed_state.raw_value
1513
- : null
1763
+ const observedValue = ToolsInteract._parseNumericControlValue(verification?.observed_state?.value) ??
1764
+ ToolsInteract._parseNumericControlValue(verification?.observed_state?.raw_value)
1514
1765
  const observedState = observedValue !== null
1515
1766
  ? {
1516
1767
  property,
@@ -1522,7 +1773,7 @@ export class ToolsInteract {
1522
1773
  return {
1523
1774
  verification,
1524
1775
  observedState,
1525
- withinTolerance: observedValue !== null && Math.abs(observedValue - targetValue) <= normalizedTolerance
1776
+ withinTolerance: observedValue !== null && Math.abs(observedValue - normalizedTargetValue) <= effectiveTolerance
1526
1777
  }
1527
1778
  }
1528
1779
 
@@ -1531,86 +1782,276 @@ export class ToolsInteract {
1531
1782
  let verification: any = null
1532
1783
  let verificationResult: any = { verification: null, observedState: actualState, withinTolerance: false }
1533
1784
 
1534
- for (let i = 0; i < probePoints.length; i++) {
1535
- const probePoint = probePoints[i]
1536
- lastAdjustmentMode = 'coordinate'
1537
- recordTraceStep('execute', 'retry', {
1538
- attempt: attemptCount + 1,
1539
- mode: 'coordinate',
1540
- point: probePoint
1541
- })
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
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
+ }
1856
+
1857
+ cachedResolvedMatch = null
1858
+ if (attemptCount < attemptsLimit) {
1859
+ continue
1860
+ }
1861
+ }
1862
+ }
1863
+ }
1550
1864
 
1551
- if (!actionResult.success) {
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'
1552
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', {
1553
1895
  attempt: attemptCount,
1554
- mode: 'coordinate',
1555
- point: probePoint,
1556
- success: false
1896
+ property,
1897
+ target_value: normalizedTargetValue,
1898
+ actual_state: observedState,
1899
+ reason: verificationResult.verification?.reason ?? 'incremental adjustment did not converge yet'
1557
1900
  })
1558
- 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
+ }
1559
1946
  }
1947
+ }
1560
1948
 
1561
- verificationResult = await runVerification()
1562
- observedState = verificationResult.observedState
1563
- lastObservedState = observedState
1564
- recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1565
- attempt: attemptCount,
1566
- property,
1567
- target_value: targetValue,
1568
- actual_state: observedState,
1569
- reason: verificationResult.verification?.reason ?? 'control did not converge yet'
1570
- })
1949
+ if (!bounds) {
1950
+ return buildFailure('ELEMENT_NOT_INTERACTABLE', 'adjustable control has no bounds', resolvedTarget, currentDevice, actualState, attemptCount, lastAdjustmentMode, false)
1951
+ }
1571
1952
 
1572
- if (verificationResult.withinTolerance) {
1573
- const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1574
- const base = buildActionExecutionResult({
1575
- actionType,
1576
- sourceModule: 'interact',
1577
- device: actionDevice ?? currentDevice,
1578
- selector: targetSelector,
1579
- resolved: resolvedTarget,
1580
- success: true,
1581
- uiFingerprintBefore: fingerprintBefore,
1582
- uiFingerprintAfter,
1583
- details: {
1584
- target_value: targetValue,
1585
- tolerance: normalizedTolerance,
1586
- property,
1587
- attempts: attemptCount,
1588
- 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
+ },
1589
2037
  actual_state: observedState,
1590
- converged: true,
1591
2038
  within_tolerance: true,
1592
- reason: verificationResult.verification?.reason ?? 'control converged to target value'
1593
- },
1594
- traceSteps
1595
- }) as AdjustControlResponse
2039
+ converged: true,
2040
+ attempts: attemptCount,
2041
+ adjustment_mode: lastAdjustmentMode
2042
+ }
2043
+ }
1596
2044
 
1597
- return {
1598
- ...base,
1599
- target_state: {
1600
- property,
1601
- target_value: targetValue,
1602
- tolerance: normalizedTolerance
1603
- },
1604
- actual_state: observedState,
1605
- within_tolerance: true,
1606
- converged: true,
1607
- attempts: attemptCount,
1608
- adjustment_mode: lastAdjustmentMode
2045
+ coordinateTapsAwaitingFreshGesture = true
2046
+ cachedResolvedMatch = null
2047
+ if (attemptCount < attemptsLimit) {
2048
+ continue adjustmentAttempts
1609
2049
  }
1610
2050
  }
1611
2051
  }
1612
2052
 
1613
- if (currentValue !== null) {
2053
+ if (attemptCount < attemptsLimit) {
2054
+ coordinateTapsAwaitingFreshGesture = false
1614
2055
  lastAdjustmentMode = 'gesture'
1615
2056
  recordTraceStep('execute', 'retry', {
1616
2057
  attempt: attemptCount + 1,
@@ -1624,7 +2065,7 @@ export class ToolsInteract {
1624
2065
  y1: currentPoint.y,
1625
2066
  x2: targetPoint.x,
1626
2067
  y2: targetPoint.y,
1627
- duration: 220,
2068
+ duration: inferredSliderState ? 520 : 220,
1628
2069
  deviceId: resolvedDeviceId
1629
2070
  })
1630
2071
  attemptCount++
@@ -1646,7 +2087,7 @@ export class ToolsInteract {
1646
2087
  recordTraceStep('verify', verificationResult.withinTolerance ? 'success' : 'retry', {
1647
2088
  attempt: attemptCount,
1648
2089
  property,
1649
- target_value: targetValue,
2090
+ target_value: normalizedTargetValue,
1650
2091
  actual_state: observedState,
1651
2092
  reason: verificationResult.verification?.reason ?? 'gesture adjustment did not converge yet'
1652
2093
  })
@@ -1663,8 +2104,8 @@ export class ToolsInteract {
1663
2104
  uiFingerprintBefore: fingerprintBefore,
1664
2105
  uiFingerprintAfter,
1665
2106
  details: {
1666
- target_value: targetValue,
1667
- tolerance: normalizedTolerance,
2107
+ target_value: normalizedTargetValue,
2108
+ tolerance: effectiveTolerance,
1668
2109
  property,
1669
2110
  attempts: attemptCount,
1670
2111
  adjustment_mode: lastAdjustmentMode,
@@ -1680,8 +2121,8 @@ export class ToolsInteract {
1680
2121
  ...base,
1681
2122
  target_state: {
1682
2123
  property,
1683
- target_value: targetValue,
1684
- tolerance: normalizedTolerance
2124
+ target_value: normalizedTargetValue,
2125
+ tolerance: effectiveTolerance
1685
2126
  },
1686
2127
  actual_state: observedState,
1687
2128
  within_tolerance: true,
@@ -1699,7 +2140,7 @@ export class ToolsInteract {
1699
2140
  recordTraceStep('verify', 'success', {
1700
2141
  attempt: attemptCount,
1701
2142
  property,
1702
- target_value: targetValue,
2143
+ target_value: normalizedTargetValue,
1703
2144
  actual_state: observedState,
1704
2145
  reason: verification?.reason ?? 'control converged to target value'
1705
2146
  })
@@ -1714,8 +2155,8 @@ export class ToolsInteract {
1714
2155
  uiFingerprintBefore: fingerprintBefore,
1715
2156
  uiFingerprintAfter,
1716
2157
  details: {
1717
- target_value: targetValue,
1718
- tolerance: normalizedTolerance,
2158
+ target_value: normalizedTargetValue,
2159
+ tolerance: effectiveTolerance,
1719
2160
  property,
1720
2161
  attempts: attemptCount,
1721
2162
  adjustment_mode: lastAdjustmentMode,
@@ -1731,8 +2172,8 @@ export class ToolsInteract {
1731
2172
  ...base,
1732
2173
  target_state: {
1733
2174
  property,
1734
- target_value: targetValue,
1735
- tolerance: normalizedTolerance
2175
+ target_value: normalizedTargetValue,
2176
+ tolerance: effectiveTolerance
1736
2177
  },
1737
2178
  actual_state: observedState,
1738
2179
  within_tolerance: true,
@@ -1742,23 +2183,25 @@ export class ToolsInteract {
1742
2183
  }
1743
2184
  }
1744
2185
 
1745
- cachedResolvedMatch = {
1746
- el: {
1747
- ...currentEl,
1748
- state: {
1749
- ...(currentEl.state ?? null),
1750
- ...(observedState ? {
1751
- [observedState.property]: observedState.value,
1752
- raw_value: observedState.raw_value ?? observedState.value
1753
- } : {})
1754
- }
1755
- },
1756
- idx: resolved.match.idx
1757
- }
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
+ }
1758
2201
  }
1759
2202
 
1760
2203
  const uiFingerprintAfter = await ToolsInteract._captureFingerprint(resolvedPlatform, resolvedDeviceId)
1761
- 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)
1762
2205
  }
1763
2206
 
1764
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 }) {