mobile-debug-mcp 0.31.0 → 0.32.1
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/README.md +1 -0
- package/dist/interact/android.js +13 -8
- package/dist/interact/index.js +33 -22
- package/dist/interact/ios.js +5 -2
- package/dist/manage/android.js +20 -10
- package/dist/manage/ios.js +19 -10
- package/dist/observe/android-tree-bridge.js +192 -0
- package/dist/observe/android.js +53 -14
- package/dist/observe/index.js +22 -5
- package/dist/observe/ios.js +50 -29
- package/dist/observe/performance-metrics.js +124 -0
- package/dist/observe/snapshot-metadata.js +94 -15
- package/dist/server/common.js +14 -7
- package/dist/server/journey-schema.js +60 -0
- package/dist/server/tool-definitions.js +59 -6
- package/dist/server/tool-handlers.js +381 -56
- package/dist/server-core.js +1 -1
- package/dist/utils/exec.js +18 -5
- package/dist/utils/ios/utils.js +5 -2
- package/dist/utils/operation-budget.js +25 -0
- package/dist/utils/resolve-device.js +12 -1
- package/dist/utils/timing.js +19 -0
- package/dist/utils/ui/index.js +2 -0
- package/docs/CHANGELOG.md +6 -0
- package/docs/ROADMAP.md +9 -0
- package/docs/benchmarks/interaction-latency/README.md +26 -0
- package/docs/benchmarks/interaction-latency/final.json +1810 -0
- package/docs/benchmarks/interaction-latency/final.md +17 -0
- package/docs/benchmarks/interaction-latency/ios-validation.md +11 -0
- package/docs/benchmarks/ui-tree-performance/README.md +51 -0
- package/docs/benchmarks/ui-tree-performance/baseline.json +59546 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +28 -0
- package/docs/benchmarks/ui-tree-performance/candidate.json +51691 -0
- package/docs/benchmarks/ui-tree-performance/comparison-attempt-1.json +110679 -0
- package/docs/benchmarks/ui-tree-performance/comparison.json +111230 -0
- package/docs/benchmarks/ui-tree-performance/comparison.md +41 -0
- package/docs/benchmarks/ui-tree-performance/initial-baseline.json +59560 -0
- package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +301 -0
- package/docs/specs/019-ui-tree-observation-performance/spec.md +148 -0
- package/docs/tools/interact.md +30 -0
- package/docs/tools/observe.md +1 -0
- package/package.json +1 -1
- package/skills/mobile-debug-tools-usage/SKILL.md +74 -0
- package/src/interact/android.ts +13 -8
- package/src/interact/index.ts +32 -23
- package/src/interact/ios.ts +5 -2
- package/src/manage/android.ts +19 -10
- package/src/manage/ios.ts +18 -10
- package/src/observe/android-tree-bridge.ts +170 -0
- package/src/observe/android.ts +48 -17
- package/src/observe/index.ts +21 -5
- package/src/observe/ios.ts +49 -36
- package/src/observe/performance-metrics.ts +125 -0
- package/src/observe/snapshot-metadata.ts +94 -15
- package/src/server/common.ts +13 -6
- package/src/server/journey-schema.ts +63 -0
- package/src/server/tool-definitions.ts +60 -7
- package/src/server/tool-handlers.ts +349 -54
- package/src/server-core.ts +1 -1
- package/src/types.ts +31 -2
- package/src/utils/exec.ts +13 -5
- package/src/utils/ios/utils.ts +5 -2
- package/src/utils/operation-budget.ts +22 -0
- package/src/utils/resolve-device.ts +13 -1
- package/src/utils/timing.ts +15 -0
- package/src/utils/ui/index.ts +2 -0
- package/test/device/automated/latency/benchmark.ts +136 -0
- package/test/device/automated/latency/report.ts +6 -0
- package/test/device/automated/ui-tree-performance/benchmark.ts +401 -0
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +183 -0
- package/test/fixtures/latency-journey-app/AndroidManifest.xml +7 -0
- package/test/fixtures/latency-journey-app/README.md +28 -0
- package/test/fixtures/latency-journey-app/build.ts +26 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +104 -0
- package/test/fixtures/ui-tree-bridge/AndroidManifest.xml +6 -0
- package/test/fixtures/ui-tree-bridge/build.ts +34 -0
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +119 -0
- package/test/unit/manage/restart_budget.test.ts +37 -0
- package/test/unit/observe/android_tree_acquisition.test.ts +70 -0
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +76 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +6 -0
- package/test/unit/observe/ios_tree_acquisition.test.ts +77 -0
- package/test/unit/observe/performance_metrics.test.ts +40 -0
- package/test/unit/observe/snapshot_metadata.test.ts +20 -1
- package/test/unit/observe/state_delta.test.ts +15 -0
- package/test/unit/server/benchmark_report.test.ts +4 -0
- package/test/unit/server/contract.test.ts +1 -1
- package/test/unit/server/interaction_modes.test.ts +60 -0
- package/test/unit/server/operation_budget.test.ts +45 -0
- package/test/unit/server/response_shapes.test.ts +3 -3
- package/test/unit/server/run_journey.test.ts +120 -0
- package/test/unit/server/tap_selector.test.ts +68 -0
- /package/docs/{specs-deltas/001-state-verification.md → specs/001-state-verification/spec.md} +0 -0
- /package/docs/{specs-deltas/002-richer-element-identity → specs/002-richer-element-identity/spec.md} +0 -0
- /package/docs/{specs-deltas/003-wait-and-synchronization-reliability.md → specs/003-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/004-action-verification-routing.md → specs/004-action-verification-routing/spec.md} +0 -0
- /package/docs/{specs-deltas/005-unified-action-execution-and-verification-model.md → specs/005-unified-action-execution-and-verification-model/spec.md} +0 -0
- /package/docs/{specs-deltas/006-runtime-action-instrumentation-and-binding-layer.md → specs/006-runtime-action-instrumentation-and-binding-layer/spec.md} +0 -0
- /package/docs/{specs-deltas/007-actionability-resolution-and-executable-target-selection.md → specs/007-actionability-resolution-and-executable-target-selection/spec.md} +0 -0
- /package/docs/{specs-deltas/008-adjustable-control-support-and-semantic-value-manipulation.md → specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md} +0 -0
- /package/docs/{specs-deltas/009-semantic-control-modeling-for-custom-and-composite-controls.md → specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md} +0 -0
- /package/docs/{specs-deltas/010-verification-stabilization-and-temporal-convergence.md → specs/010-verification-stabilization-and-temporal-convergence/spec.md} +0 -0
- /package/docs/{specs-deltas/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows.md → specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md} +0 -0
- /package/docs/{specs-deltas/011.1-recovery-contract-types-and-runtime-wiring-spec.md → specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md} +0 -0
- /package/docs/{specs-deltas/012-action-trace-and-xecution-observability.md → specs/012-action-trace-and-xecution-observability/spec.md} +0 -0
- /package/docs/{specs-deltas/013-wait-and-synchronization-reliability.md → specs/013-wait-and-synchronization-reliability/spec.md} +0 -0
- /package/docs/{specs-deltas/014-actionability-resolution.md → specs/014-actionability-resolution/spec.md} +0 -0
- /package/docs/{specs-deltas/015-adjustable-control-support.md → specs/015-adjustable-control-support/spec.md} +0 -0
- /package/docs/{specs-deltas/016-environment-tools.md → specs/016-environment-tools/spec.md} +0 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { measure, withTiming } from '../utils/timing.js'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { runWithBudget } from '../utils/operation-budget.js'
|
|
4
|
+
import { journeySchema, tapElementControlsSchema } from './journey-schema.js'
|
|
5
|
+
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
|
|
1
6
|
import type {
|
|
2
7
|
InstallAppResponse,
|
|
3
8
|
ResetAppDataResponse,
|
|
@@ -9,6 +14,7 @@ import { ToolsObserve } from '../observe/index.js'
|
|
|
9
14
|
import { classifyActionOutcome } from '../interact/classify.js'
|
|
10
15
|
import { ToolsNetwork } from '../network/index.js'
|
|
11
16
|
import { getSystemStatus } from '../system/index.js'
|
|
17
|
+
import { recordObservationConsumer, recordObservationEvent, withObservationContext } from '../observe/performance-metrics.js'
|
|
12
18
|
import {
|
|
13
19
|
buildActionExecutionResult,
|
|
14
20
|
captureActionFingerprint,
|
|
@@ -24,6 +30,7 @@ import {
|
|
|
24
30
|
requireObjectArg,
|
|
25
31
|
requireStringArg,
|
|
26
32
|
ToolCallArgs,
|
|
33
|
+
ToolCallResult,
|
|
27
34
|
ToolHandler,
|
|
28
35
|
wrapResponse,
|
|
29
36
|
wrapToolError
|
|
@@ -36,16 +43,122 @@ type WaitForUiMatchArg = { index?: number }
|
|
|
36
43
|
type WaitForUiRetryArg = { max_attempts?: number, backoff_ms?: number }
|
|
37
44
|
type ScrollSelectorArg = { text?: string, resourceId?: string, contentDesc?: string, className?: string }
|
|
38
45
|
type ClassifyNetworkRequestArg = { endpoint: string, status: 'success' | 'failure' | 'retryable' }
|
|
46
|
+
type VerificationMode = 'none' | 'light' | 'full'
|
|
47
|
+
type ResponseMode = 'compact' | 'debug'
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
function interactionControls(args: ToolCallArgs) {
|
|
50
|
+
const verificationMode = (getStringArg(args, 'verificationMode') as VerificationMode | undefined) ?? 'full'
|
|
51
|
+
const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
|
|
52
|
+
if (!['none', 'light', 'full'].includes(verificationMode) || !['compact', 'debug'].includes(responseMode)) throw new Error('INVALID_ARGUMENT')
|
|
53
|
+
for (const key of ['verificationMode', 'responseMode']) if (args[key] !== undefined && typeof args[key] !== 'string') throw new Error('INVALID_ARGUMENT')
|
|
54
|
+
for (const key of ['actionTimeoutMs', 'verificationTimeoutMs']) if (args[key] !== undefined && (typeof args[key] !== 'number' || !Number.isInteger(args[key]))) throw new Error('INVALID_ARGUMENT')
|
|
55
|
+
const actionTimeoutMs = getNumberArg(args, 'actionTimeoutMs')
|
|
56
|
+
const verificationTimeoutMs = getNumberArg(args, 'verificationTimeoutMs')
|
|
57
|
+
return { verificationMode, responseMode,
|
|
58
|
+
actionTimeoutMs: actionTimeoutMs === undefined ? undefined : Math.max(100, Math.min(30000, actionTimeoutMs)),
|
|
59
|
+
verificationTimeoutMs: verificationTimeoutMs === undefined ? 3000 : Math.max(100, Math.min(60000, verificationTimeoutMs)) }
|
|
60
|
+
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function compactAction(result: any, verificationMode: VerificationMode, started = true, stateDelta?: unknown, totalMs = 0) {
|
|
64
|
+
return {
|
|
65
|
+
action_id: result.action_id,
|
|
66
|
+
action_type: result.action_type,
|
|
67
|
+
success: !!result.success,
|
|
68
|
+
lifecycle_state: result.success ? (verificationMode === 'full' ? result.lifecycle_state : 'pending_verification') : 'failed',
|
|
69
|
+
...(result.failure_code ? { failure_code: result.failure_code } : {}),
|
|
70
|
+
...(typeof result.retryable === 'boolean' ? { retryable: result.retryable } : {}),
|
|
71
|
+
delivery_status: result.success ? 'delivered' : (started ? 'unknown' : 'not_delivered'),
|
|
72
|
+
dispatch_started: started,
|
|
73
|
+
verification: { mode: verificationMode, status: result.success ? (verificationMode === 'none' ? 'not_requested' : verificationMode === 'light' ? 'observed' : (result.lifecycle_state === 'verified' ? 'verified' : 'observed')) : 'failed' },
|
|
74
|
+
...(stateDelta ? { state_delta: stateDelta } : {}),
|
|
75
|
+
timing: { total_ms: totalMs }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function actionHandler(type: string, execute: ToolHandler): ToolHandler {
|
|
80
|
+
return async (args) => {
|
|
81
|
+
const timing: Record<string, number> = {}
|
|
82
|
+
return withTiming(timing, async () => {
|
|
83
|
+
const started = performance.now()
|
|
84
|
+
const controls = interactionControls(args)
|
|
85
|
+
const budgetMs = controls.actionTimeoutMs ?? ({ start_app: 10000, restart_app: 15000, scroll_to_element: 10000 }[type] ?? 5000)
|
|
86
|
+
let dispatchStarted = false
|
|
87
|
+
let result: any
|
|
88
|
+
const dispatchWithBudget = (timeoutMs: number) => runWithBudget(timeoutMs, async (budget) => {
|
|
89
|
+
try { return payloadOf(await execute(args)) }
|
|
90
|
+
finally { dispatchStarted = budget.dispatchStarted }
|
|
91
|
+
})
|
|
92
|
+
try {
|
|
93
|
+
// Omitted controls retain the existing adapter budgets.
|
|
94
|
+
result = args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined
|
|
95
|
+
? await dispatchWithBudget(Infinity) : await dispatchWithBudget(budgetMs)
|
|
96
|
+
if (result.success) dispatchStarted = true
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return wrapToolError(type, error)
|
|
99
|
+
result = { success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
|
|
100
|
+
}
|
|
101
|
+
if (result.failure_code === 'TIMEOUT' || /ACTION_TIMEOUT|timeout/i.test(result.error ?? result.reason ?? result.details?.error ?? '')) result.failure_code = 'ACTION_TIMEOUT'
|
|
102
|
+
if (result.failure_code === 'ACTION_TIMEOUT') result.retryable = !dispatchStarted
|
|
103
|
+
if (!result.success && typeof result.retryable !== 'boolean') result.retryable = !dispatchStarted
|
|
104
|
+
result.action_id ??= randomUUID()
|
|
105
|
+
result.action_type ??= type
|
|
106
|
+
recordObservationEvent({
|
|
107
|
+
step_id: args._stepId as string | undefined,
|
|
108
|
+
action_type: type,
|
|
109
|
+
dispatch_ms: timing.dispatch_ms,
|
|
110
|
+
verification_mode: controls.verificationMode,
|
|
111
|
+
dispatch_started: dispatchStarted
|
|
112
|
+
})
|
|
113
|
+
let delta: unknown
|
|
114
|
+
let observationAvailable = false
|
|
115
|
+
if (result.success && controls.verificationMode === 'light') {
|
|
116
|
+
const verificationStarted = performance.now()
|
|
117
|
+
const tree = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'verification' }, () =>
|
|
118
|
+
measure('post_observation_ms', () => runWithBudget(controls.verificationTimeoutMs, () =>
|
|
119
|
+
ToolsObserve.getUITreeHandler({ platform: (args.platform as PlatformArg | undefined) ?? result.device?.platform, deviceId: (args.deviceId as string | undefined) ?? result.device?.id })))).catch(() => null) as any
|
|
120
|
+
observationAvailable = !!tree && !tree.error
|
|
121
|
+
recordObservationEvent({
|
|
122
|
+
verification_budget_ms: controls.verificationTimeoutMs,
|
|
123
|
+
verification_elapsed_ms: performance.now() - verificationStarted,
|
|
124
|
+
verification_outcome: observationAvailable ? 'observed' : 'unavailable',
|
|
125
|
+
verification_budget_expired: !observationAvailable && performance.now() - verificationStarted >= controls.verificationTimeoutMs
|
|
126
|
+
})
|
|
127
|
+
if (observationAvailable && tree.device) delta = ToolsObserve.getLatestStateDelta(tree.device.platform, tree.device.id) ?? undefined
|
|
128
|
+
}
|
|
129
|
+
const compact = compactAction(result, controls.verificationMode, dispatchStarted, delta, performance.now() - started)
|
|
130
|
+
if (result.success && controls.verificationMode === 'light' && !observationAvailable) compact.verification.status = 'unavailable'
|
|
131
|
+
const evidence = {
|
|
132
|
+
...compact,
|
|
133
|
+
...(controls.verificationMode === 'light' ? { state_delta_available: !!delta } : {}),
|
|
134
|
+
...(result.success && controls.verificationMode === 'light' && !observationAvailable ? { verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true } } : {})
|
|
135
|
+
}
|
|
136
|
+
return wrapResponse(controls.responseMode === 'compact' ? evidence : {
|
|
137
|
+
...result, ...evidence, timing: { ...timing, total_ms: performance.now() - started },
|
|
138
|
+
effective_controls: { ...controls, actionTimeoutMs: args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined ? null : budgetMs, timeout_bound: !(args._journey !== true && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined) }
|
|
139
|
+
}, controls.responseMode === 'debug')
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const handleStartApp = actionHandler('start_app', executeStartApp)
|
|
144
|
+
const handleRestartApp = actionHandler('restart_app', executeRestartApp)
|
|
145
|
+
const handleTap = actionHandler('tap', executeTap)
|
|
146
|
+
const handleTapElement = actionHandler('tap_element', executeTapElement)
|
|
147
|
+
const handleSwipe = actionHandler('swipe', executeSwipe)
|
|
148
|
+
const handleScrollToElement = actionHandler('scroll_to_element', executeScrollToElement)
|
|
149
|
+
const handleTypeText = actionHandler('type_text', executeTypeText)
|
|
150
|
+
const handlePressBack = actionHandler('press_back', executePressBack)
|
|
151
|
+
|
|
152
|
+
async function executeStartApp(args: ToolCallArgs) {
|
|
41
153
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
42
154
|
const appId = requireStringArg(args, 'appId')
|
|
43
155
|
const deviceId = getStringArg(args, 'deviceId')
|
|
44
|
-
const
|
|
156
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
157
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
45
158
|
ToolsNetwork.notifyActionStart()
|
|
46
|
-
const res = await (platform === 'android' ? new AndroidManage().startApp(appId, deviceId) : new iOSManage().startApp(appId, deviceId))
|
|
47
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
48
|
-
|
|
159
|
+
const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().startApp(appId, deviceId, actionTimeoutMs ?? (verificationMode === 'full' && getStringArg(args, 'responseMode') === undefined && args._journey !== true ? undefined : 10000)) : new iOSManage().startApp(appId, deviceId, verificationMode === 'full')))
|
|
160
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
161
|
+
const result = buildActionExecutionResult({
|
|
49
162
|
actionType: 'start_app',
|
|
50
163
|
sourceModule: 'server',
|
|
51
164
|
device: res.device,
|
|
@@ -59,9 +172,11 @@ async function handleStartApp(args: ToolCallArgs) {
|
|
|
59
172
|
...(typeof res.output === 'string' ? { output: res.output } : {}),
|
|
60
173
|
...(res.device ? { device_id: res.device.id } : {}),
|
|
61
174
|
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
175
|
+
...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
|
|
62
176
|
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
63
177
|
}
|
|
64
|
-
})
|
|
178
|
+
})
|
|
179
|
+
return wrapResponse(result)
|
|
65
180
|
}
|
|
66
181
|
|
|
67
182
|
async function handleTerminateApp(args: ToolCallArgs) {
|
|
@@ -73,15 +188,17 @@ async function handleTerminateApp(args: ToolCallArgs) {
|
|
|
73
188
|
return wrapResponse(response)
|
|
74
189
|
}
|
|
75
190
|
|
|
76
|
-
async function
|
|
191
|
+
async function executeRestartApp(args: ToolCallArgs) {
|
|
77
192
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
78
193
|
const appId = requireStringArg(args, 'appId')
|
|
79
194
|
const deviceId = getStringArg(args, 'deviceId')
|
|
80
|
-
const
|
|
195
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
196
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
81
197
|
ToolsNetwork.notifyActionStart()
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
198
|
+
const bounded = args._journey === true || verificationMode !== 'full' || args.responseMode !== undefined || actionTimeoutMs !== undefined
|
|
199
|
+
const res = await measure('dispatch_ms', () => (platform === 'android' ? new AndroidManage().restartApp(appId, deviceId, bounded, actionTimeoutMs ?? 15000) : new iOSManage().restartApp(appId, deviceId, verificationMode === 'full', bounded, actionTimeoutMs ?? 15000)))
|
|
200
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
201
|
+
const result = buildActionExecutionResult({
|
|
85
202
|
actionType: 'restart_app',
|
|
86
203
|
sourceModule: 'server',
|
|
87
204
|
device: res.device,
|
|
@@ -96,9 +213,11 @@ async function handleRestartApp(args: ToolCallArgs) {
|
|
|
96
213
|
...(typeof res.terminatedBeforeRestart === 'boolean' ? { terminated_before_restart: res.terminatedBeforeRestart } : {}),
|
|
97
214
|
...(typeof res.terminateError === 'string' ? { terminate_error: res.terminateError } : {}),
|
|
98
215
|
...(typeof res.error === 'string' ? { error: res.error } : {}),
|
|
216
|
+
...(res.diagnostics ? { diagnostics: res.diagnostics } : {}),
|
|
99
217
|
...(res.observedApp ? { observed_app: res.observedApp } : {})
|
|
100
218
|
}
|
|
101
|
-
})
|
|
219
|
+
})
|
|
220
|
+
return wrapResponse(result)
|
|
102
221
|
}
|
|
103
222
|
|
|
104
223
|
async function handleResetAppData(args: ToolCallArgs) {
|
|
@@ -185,10 +304,12 @@ async function handleGetSystemStatus() {
|
|
|
185
304
|
async function handleCaptureScreenshot(args: ToolCallArgs) {
|
|
186
305
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
187
306
|
const deviceId = getStringArg(args, 'deviceId')
|
|
307
|
+
const started = performance.now()
|
|
188
308
|
const res = await ToolsObserve.captureScreenshotHandler({ platform, deviceId })
|
|
309
|
+
const screenshotMs = performance.now() - started
|
|
189
310
|
const mime = (res as any).screenshot_mime || 'image/png'
|
|
190
311
|
const content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }> = [
|
|
191
|
-
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime } }, null, 2) },
|
|
312
|
+
{ type: 'text', text: JSON.stringify({ device: res.device, result: { resolution: (res as any).resolution, mimeType: mime }, timing: { total_ms: screenshotMs, screenshot_ms: screenshotMs } }, null, 2) },
|
|
192
313
|
{ type: 'image', data: (res as any).screenshot, mimeType: mime }
|
|
193
314
|
]
|
|
194
315
|
if ((res as any).screenshot_fallback) {
|
|
@@ -206,14 +327,19 @@ async function handleCaptureDebugSnapshot(args: ToolCallArgs) {
|
|
|
206
327
|
const appId = getStringArg(args, 'appId')
|
|
207
328
|
const deviceId = getStringArg(args, 'deviceId')
|
|
208
329
|
const sessionId = getStringArg(args, 'sessionId')
|
|
209
|
-
const
|
|
330
|
+
const includeScreenshot = getBooleanArg(args, 'includeScreenshot')
|
|
331
|
+
const res = await ToolsObserve.captureDebugSnapshotHandler({ reason, includeLogs, includeScreenshot, logLines, platform, appId, deviceId, sessionId })
|
|
210
332
|
return wrapResponse(res)
|
|
211
333
|
}
|
|
212
334
|
|
|
213
335
|
async function handleGetUITree(args: ToolCallArgs) {
|
|
214
336
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
215
337
|
const deviceId = getStringArg(args, 'deviceId')
|
|
216
|
-
const
|
|
338
|
+
const responseMode = (getStringArg(args, 'responseMode') as ResponseMode | undefined) ?? 'debug'
|
|
339
|
+
const sinceSnapshotRevision = getNumberArg(args, 'sinceSnapshotRevision')
|
|
340
|
+
const metricsStepId = process.env.MOBILE_DEBUG_MCP_UI_TREE_METRICS === '1' ? getStringArg(args, '_metricsStepId') : undefined
|
|
341
|
+
const res = await withObservationContext({ stepId: metricsStepId, purpose: metricsStepId?.includes('post-response') ? 'assertion' : 'debug' }, () =>
|
|
342
|
+
ToolsObserve.getUITreeHandler({ platform, deviceId, responseMode, sinceSnapshotRevision }))
|
|
217
343
|
return wrapResponse(res)
|
|
218
344
|
}
|
|
219
345
|
|
|
@@ -333,20 +459,23 @@ async function handleFindElement(args: ToolCallArgs) {
|
|
|
333
459
|
const timeoutMs = getNumberArg(args, 'timeoutMs') ?? 3000
|
|
334
460
|
const platform = getStringArg(args, 'platform') as PlatformArg | undefined
|
|
335
461
|
const deviceId = getStringArg(args, 'deviceId')
|
|
336
|
-
const
|
|
462
|
+
const targetStarted = performance.now()
|
|
463
|
+
const res = await withObservationContext({ purpose: 'target' }, () => ToolsInteract.findElementHandler({ query, exact, timeoutMs, platform, deviceId }))
|
|
464
|
+
recordObservationEvent({ target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: res.found ? 'found' : 'not_found', operation: 'find_element' })
|
|
337
465
|
return wrapResponse(res)
|
|
338
466
|
}
|
|
339
467
|
|
|
340
|
-
async function
|
|
468
|
+
async function executeTap(args: ToolCallArgs) {
|
|
341
469
|
const platform = getStringArg(args, 'platform') as PlatformArg | undefined
|
|
342
470
|
const x = requireNumberArg(args, 'x')
|
|
343
471
|
const y = requireNumberArg(args, 'y')
|
|
344
472
|
const deviceId = getStringArg(args, 'deviceId')
|
|
345
|
-
const
|
|
473
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
474
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
346
475
|
ToolsNetwork.notifyActionStart()
|
|
347
|
-
const res = await ToolsInteract.tapHandler({ platform, x, y, deviceId })
|
|
348
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
349
|
-
|
|
476
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.tapHandler({ platform, x, y, deviceId, timeoutMs: actionTimeoutMs }))
|
|
477
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
478
|
+
const result = buildActionExecutionResult({
|
|
350
479
|
actionType: 'tap',
|
|
351
480
|
sourceModule: 'server',
|
|
352
481
|
selector: { x, y },
|
|
@@ -354,17 +483,158 @@ async function handleTap(args: ToolCallArgs) {
|
|
|
354
483
|
uiFingerprintBefore,
|
|
355
484
|
uiFingerprintAfter,
|
|
356
485
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
357
|
-
})
|
|
486
|
+
})
|
|
487
|
+
return wrapResponse(result)
|
|
358
488
|
}
|
|
359
489
|
|
|
360
|
-
async function
|
|
361
|
-
const
|
|
490
|
+
async function executeTapElement(args: ToolCallArgs) {
|
|
491
|
+
const controls = tapElementControlsSchema.safeParse({ elementId: args.elementId, selector: args.selector, waitFor: args.waitFor })
|
|
492
|
+
if (!controls.success) throw new Error(`INVALID_ARGUMENT: ${controls.error.message}`)
|
|
493
|
+
const elementId = controls.data.elementId
|
|
494
|
+
const selector = controls.data.selector as ExpectElementSelectorArg | undefined
|
|
495
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
496
|
+
let resolvedId = elementId
|
|
497
|
+
let freshTree: any
|
|
498
|
+
if (selector) {
|
|
499
|
+
const waitFor = getObjectArg<WaitForUiMatchArg>(args, 'waitFor') as any
|
|
500
|
+
const targetStarted = performance.now()
|
|
501
|
+
const resolution = await withObservationContext({ stepId: args._stepId as string | undefined, purpose: 'target' }, () =>
|
|
502
|
+
measure('target_resolution_ms', () => runWithBudget(waitFor?.timeoutMs ?? 1500, () => ToolsInteract.waitForUIHandler({ selector, condition: waitFor?.condition ?? 'clickable', timeout_ms: waitFor?.timeoutMs ?? 1500, poll_interval_ms: waitFor?.pollIntervalMs ?? 100, match: waitFor?.match, singleObservation: true, singleAttempt: !waitFor, rejectAmbiguous: !waitFor, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })))) as any
|
|
503
|
+
recordObservationEvent({ step_id: args._stepId as string | undefined, purpose: 'target', target_resolution_ms: performance.now() - targetStarted, target_resolution_outcome: resolution.status })
|
|
504
|
+
if (resolution.status !== 'success' || !resolution.element?.elementId) return wrapResponse({ success: false, action_type: 'tap_element', failure_code: resolution.error?.code ?? 'ELEMENT_NOT_FOUND', retryable: true })
|
|
505
|
+
resolvedId = resolution.element.elementId
|
|
506
|
+
freshTree = resolution._tree
|
|
507
|
+
}
|
|
362
508
|
ToolsNetwork.notifyActionStart()
|
|
363
|
-
|
|
509
|
+
if (freshTree) recordObservationConsumer(freshTree, 'target', args._stepId as string | undefined)
|
|
510
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.tapElementHandler({ elementId: resolvedId!, freshTree, verificationMode, timeoutMs: actionTimeoutMs, platform: getStringArg(args, 'platform') as PlatformArg | undefined, deviceId: getStringArg(args, 'deviceId') })) as any
|
|
364
511
|
return wrapResponse(res)
|
|
365
512
|
}
|
|
366
513
|
|
|
367
|
-
|
|
514
|
+
function payloadOf(response: ToolCallResult) {
|
|
515
|
+
const text = response.content.find((item: any) => item.type === 'text')?.text
|
|
516
|
+
return text ? JSON.parse(text) : null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function expectElementAbsent({ selector, timeoutMs, pollIntervalMs, platform, deviceId }: { selector: ExpectElementSelectorArg, timeoutMs: number, pollIntervalMs: number, platform: PlatformArg, deviceId?: string }) {
|
|
520
|
+
const deadline = performance.now() + timeoutMs
|
|
521
|
+
let consecutive = 0
|
|
522
|
+
let observed: unknown
|
|
523
|
+
while (performance.now() < deadline) {
|
|
524
|
+
const result = await ToolsInteract.waitForUIHandler({ selector, condition: 'not_exists', timeout_ms: 0, singleObservation: true, singleAttempt: true, platform, deviceId }) as any
|
|
525
|
+
observed = result.observed
|
|
526
|
+
consecutive = result.status === 'success' ? consecutive + 1 : 0
|
|
527
|
+
if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed }
|
|
528
|
+
const delay = Math.max(100, pollIntervalMs)
|
|
529
|
+
if (performance.now() + delay >= deadline) break
|
|
530
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
531
|
+
}
|
|
532
|
+
return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function expectFingerprint({ fingerprint, timeoutMs, platform, deviceId }: { fingerprint: string, timeoutMs: number, platform: PlatformArg, deviceId?: string }) {
|
|
536
|
+
const deadline = performance.now() + timeoutMs
|
|
537
|
+
let consecutive = 0
|
|
538
|
+
let observed: string | null = null
|
|
539
|
+
while (performance.now() < deadline) {
|
|
540
|
+
const result = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as any
|
|
541
|
+
observed = result?.fingerprint ?? null
|
|
542
|
+
consecutive = observed === fingerprint ? consecutive + 1 : 0
|
|
543
|
+
if (consecutive === 2 && performance.now() <= deadline) return { success: true, observed_fingerprint: observed }
|
|
544
|
+
if (performance.now() + 100 >= deadline) break
|
|
545
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
546
|
+
}
|
|
547
|
+
return { success: false, failure_code: 'ASSERTION_TIMEOUT', observed_fingerprint: observed }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function handleRunJourney(args: ToolCallArgs) {
|
|
551
|
+
const parsed = journeySchema.safeParse(args)
|
|
552
|
+
if (!parsed.success) throw new Error(`INVALID_ARGUMENT: ${parsed.error.message}`)
|
|
553
|
+
const { platform, responseMode, defaults, captureOnFailure } = parsed.data
|
|
554
|
+
const steps = parsed.data.steps as Array<Record<string, any>>
|
|
555
|
+
const device = await resolveTargetDevice({ platform, deviceId: parsed.data.deviceId })
|
|
556
|
+
const deviceId = device.id
|
|
557
|
+
return withResolvedDevice(device, async () => {
|
|
558
|
+
const started = performance.now()
|
|
559
|
+
const results: any[] = []
|
|
560
|
+
let stoppedAt: string | null = null
|
|
561
|
+
let pendingLightStep: number | null = null
|
|
562
|
+
for (let index = 0; index < steps.length; index++) {
|
|
563
|
+
const step = steps[index]
|
|
564
|
+
const requestedVerification = step.verificationMode ?? defaults?.verificationMode ?? 'full'
|
|
565
|
+
const next = steps[index + 1]
|
|
566
|
+
const lightCoveredByAssertion = step.type === 'tap' && responseMode === 'compact' && requestedVerification === 'light' && next?.type === 'assert' &&
|
|
567
|
+
next.assertion?.kind === 'element_visible'
|
|
568
|
+
const stepStart = performance.now()
|
|
569
|
+
let result: any
|
|
570
|
+
if (stoppedAt) {
|
|
571
|
+
results.push({ id: step.id, type: step.type, status: 'not_run', success: false, reason: 'PREVIOUS_STEP_FAILED', result: null, timing: { total_ms: 0 } })
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
if (step.type === 'tap') {
|
|
576
|
+
result = payloadOf(await handleTapElement({ ...defaults, ...step, platform, deviceId, responseMode, _journey: true, _stepId: step.id, verificationMode: lightCoveredByAssertion ? 'none' : requestedVerification }))
|
|
577
|
+
} else if (step.type === 'wait') {
|
|
578
|
+
result = await withObservationContext({ stepId: step.id, purpose: 'target' }, () => ToolsInteract.waitForUIHandler({ selector: step.selector as ExpectElementSelectorArg, condition: (step.condition as any) ?? 'exists', timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId }))
|
|
579
|
+
result = { success: result.status === 'success', ...result }
|
|
580
|
+
} else if (step.type === 'assert') {
|
|
581
|
+
result = await withObservationContext({ stepId: step.id, purpose: 'assertion' }, () => runWithBudget((step.timeoutMs as number | undefined) ?? 5000, async () => {
|
|
582
|
+
const assertion = step.assertion as any
|
|
583
|
+
if (assertion?.kind === 'element_visible') result = await ToolsInteract.expectElementVisibleHandler({ selector: assertion.selector, timeout_ms: (step.timeoutMs as number | undefined) ?? 5000, poll_interval_ms: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
|
|
584
|
+
else if (assertion?.kind === 'element_absent') result = await expectElementAbsent({ selector: assertion.selector, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, pollIntervalMs: (step.pollIntervalMs as number | undefined) ?? 100, platform, deviceId })
|
|
585
|
+
else if (assertion?.kind === 'screen_fingerprint') result = await expectFingerprint({ fingerprint: assertion.fingerprint, timeoutMs: (step.timeoutMs as number | undefined) ?? 5000, platform, deviceId })
|
|
586
|
+
else if (assertion?.kind === 'state_equals') result = await ToolsInteract.expectStateHandler({ selector: assertion.selector, element_id: assertion.elementId, property: assertion.property, expected: assertion.expected, platform, deviceId, stabilization_window_ms: step.timeoutMs ?? 5000, poll_interval_ms: step.pollIntervalMs ?? 100, exact_budget: true })
|
|
587
|
+
else result = { success: false, failure_code: 'INVALID_ASSERTION' }
|
|
588
|
+
return result
|
|
589
|
+
}))
|
|
590
|
+
} else if (step.type === 'start_app') {
|
|
591
|
+
result = payloadOf(await handleStartApp({ ...defaults, platform, deviceId, appId: step.appId, responseMode, _journey: true, _stepId: step.id, verificationMode: step.verificationMode ?? defaults?.verificationMode ?? 'full' }))
|
|
592
|
+
} else {
|
|
593
|
+
result = { success: false, failure_code: 'UNSUPPORTED_JOURNEY_STEP' }
|
|
594
|
+
}
|
|
595
|
+
} catch (error) {
|
|
596
|
+
result = { success: false, failure_code: step.type === 'assert' ? 'ASSERTION_TIMEOUT' : 'STEP_FAILED', error: error instanceof Error ? error.message : String(error) }
|
|
597
|
+
}
|
|
598
|
+
if (step.type === 'assert' && !result?.success && /TIMEOUT/.test(result?.failure_code ?? '')) result.failure_code = 'ASSERTION_TIMEOUT'
|
|
599
|
+
const success = !!result?.success
|
|
600
|
+
if (lightCoveredByAssertion) {
|
|
601
|
+
result.verification = { mode: 'light', status: success ? 'unavailable' : 'failed' }
|
|
602
|
+
if (success) {
|
|
603
|
+
result.verification_diagnostic = { code: 'VERIFICATION_UNAVAILABLE', retryable: true }
|
|
604
|
+
pendingLightStep = results.length
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (step.type === 'assert' && pendingLightStep !== null) {
|
|
608
|
+
const pending = results[pendingLightStep].result
|
|
609
|
+
if (typeof result?.observed?.matched_count === 'number') {
|
|
610
|
+
pending.verification.status = 'observed'
|
|
611
|
+
delete pending.verification_diagnostic
|
|
612
|
+
const delta = ToolsObserve.getLatestStateDelta(platform, deviceId)
|
|
613
|
+
if (delta) { pending.state_delta = delta; pending.state_delta_available = true }
|
|
614
|
+
}
|
|
615
|
+
pendingLightStep = null
|
|
616
|
+
}
|
|
617
|
+
if (responseMode === 'compact' && ['wait', 'assert'].includes(step.type)) result = {
|
|
618
|
+
success, ...(result.failure_code ? { failure_code: result.failure_code } : {}),
|
|
619
|
+
...(result.error?.code ? { failure_code: result.error.code } : {}),
|
|
620
|
+
...(result.observed?.matched_count !== undefined ? { matched_count: result.observed.matched_count } : {}),
|
|
621
|
+
...(result.observed_fingerprint !== undefined ? { matched: success } : {}),
|
|
622
|
+
...(result.actual_state ? { observed: result.actual_state } : {})
|
|
623
|
+
}
|
|
624
|
+
results.push({ id: step.id, type: step.type, status: success ? 'passed' : 'failed', success, result, timing: { total_ms: performance.now() - stepStart } })
|
|
625
|
+
if (!success) stoppedAt = step.id as string
|
|
626
|
+
}
|
|
627
|
+
const failureSnapshot = stoppedAt && captureOnFailure && responseMode === 'debug'
|
|
628
|
+
? await ToolsObserve.captureDebugSnapshotHandler({ platform, deviceId, includeScreenshot: false }).catch(() => null) : undefined
|
|
629
|
+
const componentTiming: Record<string, number> = {}
|
|
630
|
+
for (const step of results) for (const [key, value] of Object.entries(step.result?.timing ?? {})) {
|
|
631
|
+
if (key !== 'total_ms' && typeof value === 'number') componentTiming[key] = (componentTiming[key] ?? 0) + value
|
|
632
|
+
}
|
|
633
|
+
return wrapResponse({ success: stoppedAt === null, stopped_at_step_id: stoppedAt, steps: results, ...(failureSnapshot ? { failure_snapshot: failureSnapshot } : {}), timing: responseMode === 'compact' ? { total_ms: performance.now() - started } : { ...componentTiming, total_ms: performance.now() - started } }, responseMode === 'debug')
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function executeSwipe(args: ToolCallArgs) {
|
|
368
638
|
const platform = (getStringArg(args, 'platform') as PlatformArg | undefined) ?? 'android'
|
|
369
639
|
const x1 = requireNumberArg(args, 'x1')
|
|
370
640
|
const y1 = requireNumberArg(args, 'y1')
|
|
@@ -372,11 +642,12 @@ async function handleSwipe(args: ToolCallArgs) {
|
|
|
372
642
|
const y2 = requireNumberArg(args, 'y2')
|
|
373
643
|
const duration = requireNumberArg(args, 'duration')
|
|
374
644
|
const deviceId = getStringArg(args, 'deviceId')
|
|
375
|
-
const
|
|
645
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
646
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
376
647
|
ToolsNetwork.notifyActionStart()
|
|
377
|
-
const res = await ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId })
|
|
378
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
379
|
-
|
|
648
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId, timeoutMs: actionTimeoutMs }))
|
|
649
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
650
|
+
const result = buildActionExecutionResult({
|
|
380
651
|
actionType: 'swipe',
|
|
381
652
|
sourceModule: 'server',
|
|
382
653
|
selector: { x1, y1, x2, y2, duration },
|
|
@@ -384,21 +655,23 @@ async function handleSwipe(args: ToolCallArgs) {
|
|
|
384
655
|
uiFingerprintBefore,
|
|
385
656
|
uiFingerprintAfter,
|
|
386
657
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
387
|
-
})
|
|
658
|
+
})
|
|
659
|
+
return wrapResponse(result)
|
|
388
660
|
}
|
|
389
661
|
|
|
390
|
-
async function
|
|
662
|
+
async function executeScrollToElement(args: ToolCallArgs) {
|
|
391
663
|
const platform = requireStringArg(args, 'platform') as PlatformArg
|
|
392
664
|
const selector = requireObjectArg<ScrollSelectorArg>(args, 'selector')
|
|
393
665
|
const direction = getStringArg(args, 'direction') as 'down' | 'up' | undefined
|
|
394
666
|
const maxScrolls = getNumberArg(args, 'maxScrolls')
|
|
395
667
|
const scrollAmount = getNumberArg(args, 'scrollAmount')
|
|
396
668
|
const deviceId = getStringArg(args, 'deviceId')
|
|
397
|
-
const
|
|
669
|
+
const { verificationMode } = interactionControls(args)
|
|
670
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
398
671
|
ToolsNetwork.notifyActionStart()
|
|
399
|
-
const res = await ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId })
|
|
400
|
-
const uiFingerprintAfter = await captureActionFingerprint(platform, deviceId)
|
|
401
|
-
|
|
672
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId }))
|
|
673
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
674
|
+
const result = buildActionExecutionResult({
|
|
402
675
|
actionType: 'scroll_to_element',
|
|
403
676
|
sourceModule: 'server',
|
|
404
677
|
selector: selector ?? null,
|
|
@@ -415,17 +688,19 @@ async function handleScrollToElement(args: ToolCallArgs) {
|
|
|
415
688
|
uiFingerprintBefore,
|
|
416
689
|
uiFingerprintAfter,
|
|
417
690
|
failure: res.success ? undefined : inferScrollFailure((res as any).reason)
|
|
418
|
-
})
|
|
691
|
+
})
|
|
692
|
+
return wrapResponse(result)
|
|
419
693
|
}
|
|
420
694
|
|
|
421
|
-
async function
|
|
695
|
+
async function executeTypeText(args: ToolCallArgs) {
|
|
422
696
|
const text = requireStringArg(args, 'text')
|
|
423
697
|
const deviceId = getStringArg(args, 'deviceId')
|
|
424
|
-
const
|
|
698
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
699
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
425
700
|
ToolsNetwork.notifyActionStart()
|
|
426
|
-
const res = await ToolsInteract.typeTextHandler({ text, deviceId })
|
|
427
|
-
const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
|
|
428
|
-
|
|
701
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.typeTextHandler({ text, deviceId, timeoutMs: actionTimeoutMs }))
|
|
702
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
703
|
+
const result = buildActionExecutionResult({
|
|
429
704
|
actionType: 'type_text',
|
|
430
705
|
sourceModule: 'server',
|
|
431
706
|
selector: { text },
|
|
@@ -433,16 +708,18 @@ async function handleTypeText(args: ToolCallArgs) {
|
|
|
433
708
|
uiFingerprintBefore,
|
|
434
709
|
uiFingerprintAfter,
|
|
435
710
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
436
|
-
})
|
|
711
|
+
})
|
|
712
|
+
return wrapResponse(result)
|
|
437
713
|
}
|
|
438
714
|
|
|
439
|
-
async function
|
|
715
|
+
async function executePressBack(args: ToolCallArgs) {
|
|
440
716
|
const deviceId = getStringArg(args, 'deviceId')
|
|
441
|
-
const
|
|
717
|
+
const { verificationMode, actionTimeoutMs } = interactionControls(args)
|
|
718
|
+
const uiFingerprintBefore = verificationMode === 'full' ? await measure('pre_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
442
719
|
ToolsNetwork.notifyActionStart()
|
|
443
|
-
const res = await ToolsInteract.pressBackHandler({ deviceId })
|
|
444
|
-
const uiFingerprintAfter = await captureActionFingerprint('android', deviceId)
|
|
445
|
-
|
|
720
|
+
const res = await measure('dispatch_ms', () => ToolsInteract.pressBackHandler({ deviceId, timeoutMs: actionTimeoutMs }))
|
|
721
|
+
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint('android', deviceId)) : null
|
|
722
|
+
const result = buildActionExecutionResult({
|
|
446
723
|
actionType: 'press_back',
|
|
447
724
|
sourceModule: 'server',
|
|
448
725
|
selector: { key: 'back' },
|
|
@@ -450,7 +727,8 @@ async function handlePressBack(args: ToolCallArgs) {
|
|
|
450
727
|
uiFingerprintBefore,
|
|
451
728
|
uiFingerprintAfter,
|
|
452
729
|
failure: res.success ? undefined : inferGenericFailure((res as any).error)
|
|
453
|
-
})
|
|
730
|
+
})
|
|
731
|
+
return wrapResponse(result)
|
|
454
732
|
}
|
|
455
733
|
|
|
456
734
|
async function handleStartLogStream(args: ToolCallArgs) {
|
|
@@ -528,6 +806,7 @@ export const toolHandlers: Record<string, ToolHandler> = {
|
|
|
528
806
|
find_element: handleFindElement,
|
|
529
807
|
tap: handleTap,
|
|
530
808
|
tap_element: handleTapElement,
|
|
809
|
+
run_journey: handleRunJourney,
|
|
531
810
|
swipe: handleSwipe,
|
|
532
811
|
scroll_to_element: handleScrollToElement,
|
|
533
812
|
type_text: handleTypeText,
|
|
@@ -543,10 +822,26 @@ export async function handleToolCall(name: string, args: ToolCallArgs = {}) {
|
|
|
543
822
|
const handler = toolHandlers[name]
|
|
544
823
|
if (!handler) throw new Error(`Unknown tool: ${name}`)
|
|
545
824
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
825
|
+
const started = performance.now()
|
|
826
|
+
const measured: Record<string, number> = {}
|
|
827
|
+
const addTiming = (response: ToolCallResult) => {
|
|
828
|
+
if (!['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'wait_for_ui', 'get_ui_tree', 'capture_screenshot', 'capture_debug_snapshot'].includes(name)) return response
|
|
829
|
+
const item = response.content.find(item => item.type === 'text')
|
|
830
|
+
if (item?.text) {
|
|
831
|
+
try {
|
|
832
|
+
const value = JSON.parse(item.text)
|
|
833
|
+
value.timing = { ...measured, ...value.timing, total_ms: performance.now() - started }
|
|
834
|
+
item.text = JSON.stringify(value)
|
|
835
|
+
} catch { /* Non-JSON text responses retain their original content. */ }
|
|
836
|
+
}
|
|
837
|
+
return response
|
|
551
838
|
}
|
|
839
|
+
return withTiming(measured, async () => {
|
|
840
|
+
try {
|
|
841
|
+
return addTiming(await handler(args))
|
|
842
|
+
} catch (error) {
|
|
843
|
+
console.error(`Error executing tool ${name}:`, error)
|
|
844
|
+
return addTiming(wrapToolError(name, error))
|
|
845
|
+
}
|
|
846
|
+
})
|
|
552
847
|
}
|