mobile-debug-mcp 0.26.5 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/interact/index.js +352 -185
- package/dist/server/common.js +39 -0
- package/dist/server-core.js +1 -1
- package/docs/CHANGELOG.md +3 -0
- package/docs/ROADMAP.md +109 -11
- package/docs/rfcs/010-verification-stabilization-and-temporal-convergence.md +265 -0
- package/docs/rfcs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md +321 -0
- package/docs/rfcs/011.1-recovery-contract-types-and-runtime-wiring-spec.md +253 -0
- package/docs/rfcs/012.md +203 -0
- package/docs/specs/mcp-tooling-spec-v1.md +12 -0
- package/docs/tools/interact.md +10 -0
- package/package.json +1 -1
- package/src/interact/index.ts +393 -186
- package/src/server/common.ts +44 -1
- package/src/server-core.ts +1 -1
- package/src/types.ts +36 -0
- package/test/unit/interact/adjust_control.test.ts +77 -1
- package/test/unit/interact/verification_stabilization.test.ts +94 -0
- package/test/unit/server/common.test.ts +36 -1
package/src/server/common.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ActionExecutionResult,
|
|
3
3
|
ActionFailureCode,
|
|
4
|
-
ActionTargetResolved
|
|
4
|
+
ActionTargetResolved,
|
|
5
|
+
FailureClass,
|
|
6
|
+
RecoveryState
|
|
5
7
|
} from '../types.js'
|
|
6
8
|
import { ToolsObserve } from '../observe/index.js'
|
|
7
9
|
|
|
10
|
+
export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3
|
|
11
|
+
export const DEFAULT_MAX_RETRY_DEPTH = 3
|
|
12
|
+
|
|
8
13
|
export function wrapResponse<T>(data: T) {
|
|
9
14
|
return {
|
|
10
15
|
content: [{
|
|
@@ -103,6 +108,7 @@ export function normalizeResolvedTarget(value: Partial<ActionTargetResolved> | n
|
|
|
103
108
|
|
|
104
109
|
export function inferGenericFailure(message: string | undefined): { failureCode: ActionFailureCode; retryable: boolean } {
|
|
105
110
|
if (message && /timeout/i.test(message)) return { failureCode: 'TIMEOUT', retryable: true }
|
|
111
|
+
if (message && /semantic mismatch/i.test(message)) return { failureCode: 'SEMANTIC_MISMATCH', retryable: false }
|
|
106
112
|
return { failureCode: 'UNKNOWN', retryable: false }
|
|
107
113
|
}
|
|
108
114
|
|
|
@@ -129,6 +135,42 @@ export function determineActionLifecycleState({
|
|
|
129
135
|
return ACTION_LIFECYCLE_STATE_BY_OUTCOME.success
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
function mapFailureCodeToFailureClass(code: ActionFailureCode): FailureClass {
|
|
139
|
+
switch (code) {
|
|
140
|
+
case 'ELEMENT_NOT_FOUND':
|
|
141
|
+
case 'AMBIGUOUS_TARGET':
|
|
142
|
+
case 'STALE_REFERENCE':
|
|
143
|
+
return 'TargetResolutionFailure'
|
|
144
|
+
case 'ELEMENT_NOT_INTERACTABLE':
|
|
145
|
+
return 'ExecutionFailure'
|
|
146
|
+
case 'TIMEOUT':
|
|
147
|
+
case 'ACTION_REJECTED':
|
|
148
|
+
case 'NAVIGATION_NO_CHANGE':
|
|
149
|
+
case 'UNKNOWN':
|
|
150
|
+
return 'ExecutionFailure'
|
|
151
|
+
case 'VERIFICATION_FAILED':
|
|
152
|
+
case 'EXPECT_STATE_MISMATCH':
|
|
153
|
+
return 'VerificationFailure'
|
|
154
|
+
case 'CONTROL_CONVERGENCE_FAILED':
|
|
155
|
+
return 'ControlConvergenceFailure'
|
|
156
|
+
case 'SEMANTIC_MISMATCH':
|
|
157
|
+
return 'SemanticMismatchFailure'
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildRecoveryState(failureCode: ActionFailureCode, retryable: boolean): RecoveryState {
|
|
162
|
+
return {
|
|
163
|
+
failure_class: mapFailureCodeToFailureClass(failureCode),
|
|
164
|
+
runtime_code: failureCode,
|
|
165
|
+
recovery_attempts: 0,
|
|
166
|
+
max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
|
|
167
|
+
retry_depth: 0,
|
|
168
|
+
max_retry_depth: DEFAULT_MAX_RETRY_DEPTH,
|
|
169
|
+
is_terminal: false,
|
|
170
|
+
retry_allowed: retryable
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
132
174
|
export function buildActionExecutionResult({
|
|
133
175
|
actionType,
|
|
134
176
|
device,
|
|
@@ -167,6 +209,7 @@ export function buildActionExecutionResult({
|
|
|
167
209
|
},
|
|
168
210
|
success,
|
|
169
211
|
...(failure ? { failure_code: failure.failureCode, retryable: failure.retryable } : {}),
|
|
212
|
+
...(failure ? { recovery: buildRecoveryState(failure.failureCode, failure.retryable) } : {}),
|
|
170
213
|
ui_fingerprint_before: uiFingerprintBefore,
|
|
171
214
|
ui_fingerprint_after: uiFingerprintAfter,
|
|
172
215
|
...(details ? { details } : {})
|
package/src/server-core.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -113,6 +113,20 @@ export interface UIElementSemanticMetadata {
|
|
|
113
113
|
state_shape?: 'continuous' | 'discrete' | 'semantic' | null;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export type FailureClass =
|
|
117
|
+
| 'TargetResolutionFailure'
|
|
118
|
+
| 'ExecutionFailure'
|
|
119
|
+
| 'VerificationFailure'
|
|
120
|
+
| 'ControlConvergenceFailure'
|
|
121
|
+
| 'SemanticMismatchFailure';
|
|
122
|
+
|
|
123
|
+
export type RecoveryStrategy =
|
|
124
|
+
| 're_resolve'
|
|
125
|
+
| 'alternate_candidate'
|
|
126
|
+
| 'state_refresh'
|
|
127
|
+
| 'retry_adjustment'
|
|
128
|
+
| 'step_back';
|
|
129
|
+
|
|
116
130
|
export interface LoadingState {
|
|
117
131
|
active: boolean;
|
|
118
132
|
signal: string;
|
|
@@ -240,8 +254,25 @@ export type ActionFailureCode =
|
|
|
240
254
|
| 'NAVIGATION_NO_CHANGE'
|
|
241
255
|
| 'AMBIGUOUS_TARGET'
|
|
242
256
|
| 'STALE_REFERENCE'
|
|
257
|
+
| 'ACTION_REJECTED'
|
|
258
|
+
| 'VERIFICATION_FAILED'
|
|
259
|
+
| 'EXPECT_STATE_MISMATCH'
|
|
260
|
+
| 'CONTROL_CONVERGENCE_FAILED'
|
|
261
|
+
| 'SEMANTIC_MISMATCH'
|
|
243
262
|
| 'UNKNOWN'
|
|
244
263
|
|
|
264
|
+
export interface RecoveryState {
|
|
265
|
+
failure_class: FailureClass;
|
|
266
|
+
runtime_code: ActionFailureCode;
|
|
267
|
+
recovery_strategy?: RecoveryStrategy;
|
|
268
|
+
recovery_attempts: number;
|
|
269
|
+
max_recovery_attempts: number;
|
|
270
|
+
retry_depth: number;
|
|
271
|
+
max_retry_depth: number;
|
|
272
|
+
is_terminal: boolean;
|
|
273
|
+
retry_allowed?: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
245
276
|
export interface ActionTargetResolved {
|
|
246
277
|
elementId: string | null;
|
|
247
278
|
text: string | null;
|
|
@@ -345,6 +376,7 @@ export interface ActionExecutionResult {
|
|
|
345
376
|
success: boolean;
|
|
346
377
|
failure_code?: ActionFailureCode;
|
|
347
378
|
retryable?: boolean;
|
|
379
|
+
recovery?: RecoveryState;
|
|
348
380
|
ui_fingerprint_before?: string | null;
|
|
349
381
|
ui_fingerprint_after?: string | null;
|
|
350
382
|
details?: Record<string, unknown>;
|
|
@@ -415,6 +447,10 @@ export interface ExpectStateResponse {
|
|
|
415
447
|
reason?: string;
|
|
416
448
|
failure_code?: 'ELEMENT_NOT_FOUND' | 'UNKNOWN';
|
|
417
449
|
retryable?: boolean;
|
|
450
|
+
stabilization_attempts?: number;
|
|
451
|
+
stabilization_window_ms?: number;
|
|
452
|
+
stable_observation_count?: number;
|
|
453
|
+
snapshot_freshness_ms?: number;
|
|
418
454
|
}
|
|
419
455
|
|
|
420
456
|
export interface AdjustControlResponse extends ActionExecutionResult {
|
|
@@ -346,9 +346,85 @@ async function run() {
|
|
|
346
346
|
assert.strictEqual(cachedResolveAdjust.success, true)
|
|
347
347
|
assert.strictEqual(cachedResolveAdjust.converged, true)
|
|
348
348
|
assert.strictEqual(cachedResolveAdjust.within_tolerance, true)
|
|
349
|
-
assert.
|
|
349
|
+
assert.ok(cachedResolveAdjust.attempts >= 3)
|
|
350
350
|
assert.strictEqual(treeFetches, 1, 'second attempt should reuse the resolved element instead of refetching the UI tree')
|
|
351
351
|
|
|
352
|
+
const probeTapStart = tapCalls.length
|
|
353
|
+
const probeSwipeStart = swipeCalls.length
|
|
354
|
+
let probeVerificationCount = 0
|
|
355
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => ({
|
|
356
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
357
|
+
screen: '',
|
|
358
|
+
resolution: { width: 1080, height: 2400 },
|
|
359
|
+
elements: [
|
|
360
|
+
{
|
|
361
|
+
text: 'Duration',
|
|
362
|
+
type: 'android.widget.SeekBar',
|
|
363
|
+
contentDescription: null,
|
|
364
|
+
clickable: true,
|
|
365
|
+
enabled: true,
|
|
366
|
+
visible: true,
|
|
367
|
+
bounds: [0, 0, 200, 40],
|
|
368
|
+
resourceId: 'seek_duration',
|
|
369
|
+
state: {
|
|
370
|
+
value: 10,
|
|
371
|
+
raw_value: 10,
|
|
372
|
+
value_range: { min: 0, max: 20 }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
]
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
;(ToolsInteract as any).tapHandler = async ({ platform, x, y, deviceId }: any) => {
|
|
379
|
+
tapCalls.push({ platform, x, y, deviceId })
|
|
380
|
+
return {
|
|
381
|
+
device: { platform: platform || 'android', id: deviceId || 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
382
|
+
success: true,
|
|
383
|
+
x,
|
|
384
|
+
y
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
;(ToolsInteract as any).expectStateHandler = async () => {
|
|
389
|
+
probeVerificationCount++
|
|
390
|
+
const value = probeVerificationCount === 1 ? 11 : 12
|
|
391
|
+
return {
|
|
392
|
+
success: true,
|
|
393
|
+
selector: { text: 'Duration' },
|
|
394
|
+
element_id: wait.element.elementId,
|
|
395
|
+
expected_state: { property: 'value', expected: 12 },
|
|
396
|
+
element: {
|
|
397
|
+
elementId: wait.element.elementId,
|
|
398
|
+
text: 'Duration',
|
|
399
|
+
resource_id: 'seek_duration',
|
|
400
|
+
accessibility_id: null,
|
|
401
|
+
class: 'android.widget.SeekBar',
|
|
402
|
+
bounds: [0, 0, 200, 40],
|
|
403
|
+
index: 0,
|
|
404
|
+
state: { value, raw_value: value, value_range: { min: 0, max: 20 } }
|
|
405
|
+
},
|
|
406
|
+
observed_state: { property: 'value', value, raw_value: value },
|
|
407
|
+
reason: value === 12 ? 'value matches expected value' : 'value still below target'
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const probeAdjust = await ToolsInteract.adjustControlHandler({
|
|
412
|
+
element_id: wait.element.elementId,
|
|
413
|
+
property: 'value',
|
|
414
|
+
targetValue: 12,
|
|
415
|
+
tolerance: 0.5,
|
|
416
|
+
maxAttempts: 3,
|
|
417
|
+
platform: 'android'
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
assert.strictEqual(probeAdjust.success, true)
|
|
421
|
+
assert.strictEqual(probeAdjust.converged, true)
|
|
422
|
+
assert.strictEqual(probeAdjust.within_tolerance, true)
|
|
423
|
+
assert.strictEqual(probeAdjust.adjustment_mode, 'coordinate')
|
|
424
|
+
assert.strictEqual(probeAdjust.attempts, 2)
|
|
425
|
+
assert.strictEqual(tapCalls.length, probeTapStart + 2)
|
|
426
|
+
assert.strictEqual(swipeCalls.length, probeSwipeStart)
|
|
427
|
+
|
|
352
428
|
console.log('adjust_control unit tests passed')
|
|
353
429
|
} finally {
|
|
354
430
|
;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { ToolsInteract } from '../../../src/interact/index.js'
|
|
3
|
+
import * as Observe from '../../../src/observe/index.js'
|
|
4
|
+
|
|
5
|
+
async function run() {
|
|
6
|
+
console.log('Starting verification stabilization unit tests...')
|
|
7
|
+
|
|
8
|
+
const originalGetUITreeHandler = (Observe as any).ToolsObserve.getUITreeHandler
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
let visibilityCalls = 0
|
|
12
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
|
|
13
|
+
visibilityCalls++
|
|
14
|
+
const visible = visibilityCalls >= 3
|
|
15
|
+
return {
|
|
16
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
17
|
+
captured_at_ms: Date.now(),
|
|
18
|
+
resolution: { width: 1080, height: 2400 },
|
|
19
|
+
elements: [
|
|
20
|
+
{
|
|
21
|
+
text: visible ? 'Ready' : 'Loading',
|
|
22
|
+
contentDescription: null,
|
|
23
|
+
type: 'android.widget.TextView',
|
|
24
|
+
resourceId: 'ready_label',
|
|
25
|
+
clickable: false,
|
|
26
|
+
enabled: true,
|
|
27
|
+
visible,
|
|
28
|
+
bounds: [0, 0, 120, 40]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const visibleResult = await ToolsInteract.waitForUIHandler({
|
|
35
|
+
selector: { text: 'Ready' },
|
|
36
|
+
condition: 'visible',
|
|
37
|
+
timeout_ms: 600,
|
|
38
|
+
poll_interval_ms: 50,
|
|
39
|
+
platform: 'android'
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
assert.strictEqual(visibleResult.status, 'success')
|
|
43
|
+
assert.ok(visibilityCalls >= 4, 'visibility should be confirmed across consecutive reads')
|
|
44
|
+
|
|
45
|
+
let stateCalls = 0
|
|
46
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = async () => {
|
|
47
|
+
stateCalls++
|
|
48
|
+
const value = stateCalls >= 3 ? 30 : 10
|
|
49
|
+
const stale = stateCalls === 2
|
|
50
|
+
return {
|
|
51
|
+
device: { platform: 'android', id: 'mock-device', osVersion: '14', model: 'Pixel', simulator: true },
|
|
52
|
+
captured_at_ms: stale ? Date.now() - 1000 : Date.now(),
|
|
53
|
+
resolution: { width: 1080, height: 2400 },
|
|
54
|
+
elements: [
|
|
55
|
+
{
|
|
56
|
+
text: 'Volume',
|
|
57
|
+
contentDescription: null,
|
|
58
|
+
type: 'android.widget.SeekBar',
|
|
59
|
+
resourceId: 'volume_slider',
|
|
60
|
+
clickable: true,
|
|
61
|
+
enabled: true,
|
|
62
|
+
visible: true,
|
|
63
|
+
bounds: [0, 0, 240, 40],
|
|
64
|
+
state: {
|
|
65
|
+
value,
|
|
66
|
+
raw_value: value,
|
|
67
|
+
value_range: { min: 0, max: 100 }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stateResult = await ToolsInteract.expectStateHandler({
|
|
75
|
+
selector: { text: 'Volume' },
|
|
76
|
+
property: 'value',
|
|
77
|
+
expected: 30,
|
|
78
|
+
platform: 'android'
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
assert.strictEqual(stateResult.success, true)
|
|
82
|
+
assert.strictEqual(stateResult.reason, 'value matches expected value')
|
|
83
|
+
assert.ok(stateCalls >= 4, 'state verification should require stable fresh reads')
|
|
84
|
+
|
|
85
|
+
console.log('verification stabilization unit tests passed')
|
|
86
|
+
} finally {
|
|
87
|
+
;(Observe as any).ToolsObserve.getUITreeHandler = originalGetUITreeHandler
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
run().catch((error) => {
|
|
92
|
+
console.error(error)
|
|
93
|
+
process.exit(1)
|
|
94
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'assert'
|
|
2
|
-
import { requireBooleanArg } from '../../../src/server/common.js'
|
|
2
|
+
import { buildActionExecutionResult, inferGenericFailure, requireBooleanArg } from '../../../src/server/common.js'
|
|
3
3
|
|
|
4
4
|
function run() {
|
|
5
5
|
assert.strictEqual(requireBooleanArg({ exact: true }, 'exact'), true)
|
|
@@ -7,6 +7,41 @@ function run() {
|
|
|
7
7
|
assert.throws(() => requireBooleanArg({}, 'exact'), /Missing or invalid boolean argument: exact/)
|
|
8
8
|
assert.throws(() => requireBooleanArg({ exact: 'true' as unknown as boolean }, 'exact'), /Missing or invalid boolean argument: exact/)
|
|
9
9
|
|
|
10
|
+
assert.deepStrictEqual(inferGenericFailure('semantic mismatch between inferred and raw state'), {
|
|
11
|
+
failureCode: 'SEMANTIC_MISMATCH',
|
|
12
|
+
retryable: false
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const recoveryResult = buildActionExecutionResult({
|
|
16
|
+
actionType: 'tap',
|
|
17
|
+
sourceModule: 'server',
|
|
18
|
+
selector: { x: 10, y: 20 },
|
|
19
|
+
success: false,
|
|
20
|
+
uiFingerprintBefore: 'fp_before',
|
|
21
|
+
uiFingerprintAfter: 'fp_after',
|
|
22
|
+
failure: { failureCode: 'SEMANTIC_MISMATCH', retryable: false }
|
|
23
|
+
})
|
|
24
|
+
assert.strictEqual(recoveryResult.failure_code, 'SEMANTIC_MISMATCH')
|
|
25
|
+
assert.strictEqual(recoveryResult.recovery?.failure_class, 'SemanticMismatchFailure')
|
|
26
|
+
assert.strictEqual(recoveryResult.recovery?.runtime_code, 'SEMANTIC_MISMATCH')
|
|
27
|
+
assert.strictEqual(recoveryResult.recovery?.retry_allowed, false)
|
|
28
|
+
assert.strictEqual(recoveryResult.recovery?.max_recovery_attempts, 3)
|
|
29
|
+
assert.strictEqual(recoveryResult.recovery?.max_retry_depth, 3)
|
|
30
|
+
|
|
31
|
+
const notInteractableResult = buildActionExecutionResult({
|
|
32
|
+
actionType: 'tap',
|
|
33
|
+
sourceModule: 'server',
|
|
34
|
+
selector: { x: 5, y: 5 },
|
|
35
|
+
success: false,
|
|
36
|
+
uiFingerprintBefore: 'fp_before',
|
|
37
|
+
uiFingerprintAfter: 'fp_after',
|
|
38
|
+
failure: { failureCode: 'ELEMENT_NOT_INTERACTABLE', retryable: true }
|
|
39
|
+
})
|
|
40
|
+
assert.strictEqual(notInteractableResult.failure_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
41
|
+
assert.strictEqual(notInteractableResult.recovery?.failure_class, 'ExecutionFailure')
|
|
42
|
+
assert.strictEqual(notInteractableResult.recovery?.runtime_code, 'ELEMENT_NOT_INTERACTABLE')
|
|
43
|
+
assert.strictEqual(notInteractableResult.recovery?.retry_allowed, true)
|
|
44
|
+
|
|
10
45
|
console.log('server common tests passed')
|
|
11
46
|
}
|
|
12
47
|
|