mobile-debug-mcp 0.36.0 → 0.37.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/README.md +10 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +1 -1
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +10 -2
- package/dist/contracts/device-readiness.js +1 -0
- package/dist/interact/waits/ui-change-wait.js +9 -2
- package/dist/interact/waits/ui-wait.js +7 -3
- package/dist/manage/index.js +3 -0
- package/dist/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/sessions.js +5 -3
- package/dist/observe/readiness-service.js +21 -9
- package/dist/observe/readiness.js +30 -0
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- package/dist/server/definitions/manage.js +3 -2
- package/dist/server/handlers/action-execution.js +23 -14
- package/dist/server/handlers/compact-interaction.js +106 -0
- package/dist/server/handlers/compact-state.js +47 -0
- package/dist/server/handlers/input.js +2 -0
- package/dist/server/handlers/interact.js +2 -0
- package/dist/server/handlers/interaction-diagnostics.js +62 -0
- package/dist/server/handlers/journey.js +18 -20
- package/dist/server/handlers/observe.js +2 -1
- package/dist/server/handlers/tool-handlers.js +9 -5
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +1 -1
- package/dist/system/android.js +19 -15
- package/dist/system/index.js +2 -1
- package/dist/utils/android/utils.js +7 -16
- package/dist/utils/app-installation.js +77 -0
- package/dist/utils/benchmark-metrics.js +43 -0
- package/dist/utils/exec.js +4 -0
- package/dist/utils/ios/utils.js +4 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +25 -0
- package/docs/benchmarks/README.md +1 -0
- package/docs/benchmarks/end-to-end-latency/README.md +98 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
- package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
- package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
- package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
- package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
- package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
- package/docs/specs/031-ios-system-traces/plan.md +28 -0
- package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
- package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
- package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
- package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
- package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
- package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
- package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
- package/docs/specs/034-app-installation-status/intent.md +41 -0
- package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
- package/docs/specs/034-app-installation-status/plan.md +73 -0
- package/docs/specs/034-app-installation-status/spec.md +114 -0
- package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
- package/docs/specs/035-compact-interaction-results/intent.md +38 -0
- package/docs/specs/035-compact-interaction-results/plan.md +43 -0
- package/docs/specs/035-compact-interaction-results/spec.md +118 -0
- package/docs/specs/035-compact-interaction-results/verification.md +111 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
- package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
- package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
- package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
- package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
- package/docs/tools/TOOLS.md +1 -0
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- package/docs/tools/interact.md +17 -8
- package/docs/tools/manage.md +10 -2
- package/docs/tools/observe.md +42 -2
- package/docs/tools/system.md +6 -0
- package/package.json +1 -1
- package/src/contracts/device-readiness.ts +8 -0
- package/src/interact/waits/ui-change-wait.ts +7 -2
- package/src/interact/waits/ui-wait.ts +6 -3
- package/src/manage/index.ts +2 -0
- package/src/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/sessions.ts +4 -2
- package/src/observe/readiness-service.ts +21 -9
- package/src/observe/readiness.ts +33 -0
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- package/src/server/common.ts +2 -2
- package/src/server/definitions/manage.ts +3 -2
- package/src/server/handlers/action-execution.ts +22 -14
- package/src/server/handlers/compact-interaction.ts +91 -0
- package/src/server/handlers/compact-state.ts +43 -0
- package/src/server/handlers/input.ts +2 -0
- package/src/server/handlers/interact.ts +2 -0
- package/src/server/handlers/interaction-diagnostics.ts +50 -0
- package/src/server/handlers/journey.ts +15 -17
- package/src/server/handlers/observe.ts +2 -1
- package/src/server/handlers/payload.ts +2 -1
- package/src/server/handlers/tool-handlers.ts +9 -5
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +1 -1
- package/src/system/android.ts +18 -11
- package/src/system/index.ts +2 -1
- package/src/types.ts +2 -0
- package/src/utils/android/utils.ts +8 -10
- package/src/utils/app-installation.ts +73 -0
- package/src/utils/benchmark-metrics.ts +42 -0
- package/src/utils/exec.ts +5 -0
- package/src/utils/ios/utils.ts +4 -10
- package/src/utils/resolve-device.ts +4 -2
- package/src/utils/timing.ts +3 -2
- package/test/device/automated/debugger/runtime.android.test.ts +5 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +7 -1
- package/test/device/automated/interact/compact-results.smoke.ts +58 -0
- package/test/device/automated/latency/end-to-end-environment.ts +61 -0
- package/test/device/automated/latency/end-to-end-report.ts +90 -0
- package/test/device/automated/latency/end-to-end-session.ts +98 -0
- package/test/device/automated/latency/end-to-end.ts +149 -0
- package/test/device/automated/latency/owned-transport.ts +81 -0
- package/test/device/automated/latency/snapshot-pair.ts +143 -0
- package/test/device/automated/latency-ios/benchmark.ts +126 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
- package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
- package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +2 -2
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +51 -15
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/unit/debugger/handle-guards.test.ts +2 -1
- package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
- package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
- package/test/unit/observe/device-readiness.test.ts +97 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +23 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
- package/test/unit/server/compact-interaction.test.ts +87 -0
- package/test/unit/server/compact-journey-evidence.test.ts +40 -0
- package/test/unit/server/compact-review-regressions.test.ts +78 -0
- package/test/unit/server/conditional_actions.test.ts +7 -3
- package/test/unit/server/correlated-evidence.test.ts +18 -6
- package/test/unit/server/end-to-end-report.test.ts +46 -0
- package/test/unit/server/input-device-readiness.test.ts +55 -0
- package/test/unit/server/interaction-diagnostics.test.ts +45 -0
- package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
- package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
- package/test/unit/server/readiness.test.ts +24 -1
- package/test/unit/server/run_journey.test.ts +2 -1
- package/test/unit/server/selector_tap_target.test.ts +2 -1
- package/test/unit/server/system-trace.test.ts +6 -4
- package/test/unit/utils/app-installation.test.ts +78 -0
- package/test/unit/utils/benchmark-metrics.test.ts +36 -0
- package/test/unit/utils/resolve_device.test.ts +11 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import type { DeviceReadiness } from '../contracts/device-readiness.js'
|
|
3
|
+
import type { DeviceInfo } from '../types.js'
|
|
4
|
+
import { pausedReadiness } from '../debugger/guard.js'
|
|
5
|
+
import { execCmd } from '../utils/exec.js'
|
|
6
|
+
import { getAdbCmd } from '../utils/android/commands.js'
|
|
7
|
+
import { getXcrunCmd } from '../utils/ios/utils.js'
|
|
8
|
+
import { remainingBudget } from '../utils/operation-budget.js'
|
|
9
|
+
import { benchmarkStage } from '../utils/benchmark-metrics.js'
|
|
10
|
+
|
|
11
|
+
export function unknownDeviceReadiness(code?: string): DeviceReadiness {
|
|
12
|
+
return { wakefulness: 'unknown', lock_state: 'unknown', input_ready: null, blockers: [], evidence: [], limitations: code ? [code] : [] }
|
|
13
|
+
}
|
|
14
|
+
function values(text: string, pattern: RegExp) { return [...text.matchAll(pattern)].map(match => match[1]) }
|
|
15
|
+
function unique(items: string[]) { return items.length > 0 && new Set(items).size === 1 ? items[0] : null }
|
|
16
|
+
export function parseAndroidPower(text: string): DeviceReadiness['wakefulness'] {
|
|
17
|
+
// Later vendor/debug sections can repeat mWakefulness as a numeric enum.
|
|
18
|
+
// Classify only the named current-state section when its boundary is present.
|
|
19
|
+
const main = text.split('Power Manager State:')[1]
|
|
20
|
+
if (main?.includes('Settings and Configuration:')) text = main.split('Settings and Configuration:')[0]
|
|
21
|
+
const wake = unique(values(text, /^\s*mWakefulness=(\w+)\s*$/gm))
|
|
22
|
+
const changing = unique(values(text, /^\s*mWakefulnessChanging=(true|false)\s*$/gm))
|
|
23
|
+
if (!wake || !changing) return 'unknown'
|
|
24
|
+
if (changing === 'true') return 'transitioning'
|
|
25
|
+
return wake === 'Awake' ? 'awake' : ['Asleep', 'Dozing'].includes(wake) ? 'asleep' : 'unknown'
|
|
26
|
+
}
|
|
27
|
+
export function parseAndroidLock(text: string): DeviceReadiness['lock_state'] {
|
|
28
|
+
const section = text.split(/\bKeyguardServiceDelegate\s*\n/)[1]
|
|
29
|
+
if (!section) return 'unknown'
|
|
30
|
+
const showing = unique(values(section, /^\s*(?:showing|mIsShowing)=(true|false)\s*$/gm))
|
|
31
|
+
return showing === 'true' ? 'locked' : showing === 'false' ? 'unlocked' : 'unknown'
|
|
32
|
+
}
|
|
33
|
+
export function finishDeviceReadiness(state: DeviceReadiness): DeviceReadiness {
|
|
34
|
+
if (state.wakefulness === 'asleep') state.blockers.push('DEVICE_ASLEEP')
|
|
35
|
+
if (state.wakefulness === 'transitioning') state.blockers.push('DEVICE_TRANSITIONING')
|
|
36
|
+
if (state.lock_state === 'locked') state.blockers.push('DEVICE_LOCKED')
|
|
37
|
+
state.blockers = [...new Set(state.blockers)]
|
|
38
|
+
state.input_ready = state.blockers.length ? false : null
|
|
39
|
+
return state
|
|
40
|
+
}
|
|
41
|
+
export function parseIosLock(text: string): DeviceReadiness['lock_state'] {
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(text)
|
|
44
|
+
if (data.error?.domain === 'com.apple.dt.CoreDeviceError' && data.error.code === 1001 &&
|
|
45
|
+
data.error.userInfo?.CapabilityFeatureIdentifier?.string === 'com.apple.coredevice.feature.getlockstate') return 'unsupported'
|
|
46
|
+
// Successful response semantics require runtime validation before classification.
|
|
47
|
+
} catch { /* Non-JSON output supplies no state evidence. */ }
|
|
48
|
+
return 'unknown'
|
|
49
|
+
}
|
|
50
|
+
type Probe = (command: string, args: string[], timeout: number) => Promise<{ exitCode: number | null; stdout: string; stderr: string }>
|
|
51
|
+
const probes = new AsyncLocalStorage<Probe>()
|
|
52
|
+
export function withDeviceReadinessProbe<T>(probe: Probe, operation: () => Promise<T>) { return probes.run(probe, operation) }
|
|
53
|
+
const nativeProbe: Probe = (command, args, timeout) => execCmd(command, args, { timeout })
|
|
54
|
+
export async function observeDeviceReadiness(device?: Pick<DeviceInfo, 'platform' | 'id'> | null, probe: Probe = probes.getStore() ?? nativeProbe): Promise<DeviceReadiness> {
|
|
55
|
+
return benchmarkStage('device_readiness_ms', () => observe(device, probe))
|
|
56
|
+
}
|
|
57
|
+
async function observe(device: Pick<DeviceInfo, 'platform' | 'id'> | null | undefined, probe: Probe): Promise<DeviceReadiness> {
|
|
58
|
+
const state = unknownDeviceReadiness()
|
|
59
|
+
if (!device?.id || !['android', 'ios'].includes(device.platform) || ['booted', 'default'].includes(device.id)) return unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE')
|
|
60
|
+
const paused = pausedReadiness(device.id, device.platform)
|
|
61
|
+
if (paused) {
|
|
62
|
+
state.blockers.push('DEBUGGER_PAUSED')
|
|
63
|
+
state.evidence.push({ code: 'DEBUGGER_PAUSED', source: 'debugger', captured_at_ms: paused.captured_at_ms })
|
|
64
|
+
return finishDeviceReadiness(state)
|
|
65
|
+
}
|
|
66
|
+
if (device.platform === 'android') {
|
|
67
|
+
await androidProbe(state, device.id, 'power', probe)
|
|
68
|
+
await androidProbe(state, device.id, 'window', probe)
|
|
69
|
+
} else {
|
|
70
|
+
state.limitations.push('DEVICE_WAKEFULNESS_UNVERIFIED')
|
|
71
|
+
try {
|
|
72
|
+
const timeout = remainingBudget(1000)
|
|
73
|
+
// CoreDevice requires a CLI timeout of at least five seconds; the parent
|
|
74
|
+
// subprocess deadline still caps this read at one second.
|
|
75
|
+
const result = await probe(getXcrunCmd(), ['devicectl', 'device', 'info', 'lockState', '--device', device.id, '--timeout', '5', '--json-output', '-'], timeout)
|
|
76
|
+
remainingBudget()
|
|
77
|
+
state.lock_state = parseIosLock(result.stdout)
|
|
78
|
+
if (state.lock_state === 'unsupported') state.evidence.push({ code: 'DEVICE_LOCK_SIGNAL_UNSUPPORTED', source: 'coredevice', captured_at_ms: Date.now() })
|
|
79
|
+
else state.limitations.push('DEVICE_LOCK_OBSERVATION_UNAVAILABLE')
|
|
80
|
+
} catch { state.limitations.push('DEVICE_LOCK_OBSERVATION_FAILED') }
|
|
81
|
+
}
|
|
82
|
+
return finishDeviceReadiness(state)
|
|
83
|
+
}
|
|
84
|
+
async function androidProbe(state: DeviceReadiness, id: string, kind: 'power' | 'window', probe: Probe) {
|
|
85
|
+
try {
|
|
86
|
+
const timeout = remainingBudget(1000)
|
|
87
|
+
const result = await probe(getAdbCmd(), ['-s', id, 'shell', 'dumpsys', kind, ...(kind === 'window' ? ['policy'] : [])], timeout)
|
|
88
|
+
remainingBudget()
|
|
89
|
+
if (result.exitCode !== 0) throw new Error('PROBE_FAILED')
|
|
90
|
+
const value = kind === 'power' ? parseAndroidPower(result.stdout) : parseAndroidLock(result.stdout)
|
|
91
|
+
if (kind === 'power') state.wakefulness = value as DeviceReadiness['wakefulness']
|
|
92
|
+
else state.lock_state = value as DeviceReadiness['lock_state']
|
|
93
|
+
if (value === 'unknown') state.limitations.push(kind === 'power' ? 'DEVICE_WAKEFULNESS_UNVERIFIED' : 'DEVICE_LOCK_STATE_UNVERIFIED')
|
|
94
|
+
else state.evidence.push({ code: `DEVICE_${value.toUpperCase()}`, source: `android_${kind}`, captured_at_ms: Date.now() })
|
|
95
|
+
} catch { state.limitations.push(kind === 'power' ? 'DEVICE_POWER_OBSERVATION_FAILED' : 'DEVICE_LOCK_OBSERVATION_FAILED') }
|
|
96
|
+
}
|
package/src/observe/evidence.ts
CHANGED
|
@@ -214,15 +214,25 @@ export function refreshJourneyEvidence(steps: Array<{ result?: { evidence?: unkn
|
|
|
214
214
|
const current = context.getStore()
|
|
215
215
|
if (!current) return
|
|
216
216
|
for (const step of steps) {
|
|
217
|
-
const previous = step.result?.evidence as { action_id?: string } | undefined
|
|
217
|
+
const previous = step.result?.evidence as { action_id?: string; observations?: Array<{ evidence_id: string }> } | undefined
|
|
218
218
|
const child = current.children.find(c => c.action?.id === previous?.action_id)
|
|
219
219
|
if (child && step.result) step.result.evidence = assemble(child)
|
|
220
|
+
else if (previous?.observations && step.result) {
|
|
221
|
+
const ids = new Set(previous.observations.map(o => o.evidence_id))
|
|
222
|
+
step.result.evidence = assemble({ ...current, observations: current.observations.filter(o => ids.has(o.value.evidence_id)) })
|
|
223
|
+
}
|
|
220
224
|
}
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
export function completeJourneyVerification(actionId: string) {
|
|
224
228
|
const child = context.getStore()?.children.find(c => c.action?.id === actionId)
|
|
225
229
|
if (child?.action) {
|
|
230
|
+
const parent = context.getStore()!
|
|
231
|
+
const completed = child.action.dispatchEnd?.mono
|
|
232
|
+
if (completed !== undefined) {
|
|
233
|
+
const after = parent.observations.filter(o => o.start.mono >= completed && sameDevice(o.value.device, child.action!.device))
|
|
234
|
+
child.observations.push(...after)
|
|
235
|
+
}
|
|
226
236
|
child.verification = stamp()
|
|
227
237
|
child.action.verification = child.verification
|
|
228
238
|
}
|
|
@@ -312,3 +322,22 @@ function recordCaptureTime(acquisition: Acquisition, result: EvidencePayload | n
|
|
|
312
322
|
acquisition.reused = source < start.wall
|
|
313
323
|
}
|
|
314
324
|
}
|
|
325
|
+
|
|
326
|
+
export class JourneyStepError extends Error {
|
|
327
|
+
constructor(readonly evidence: ReturnType<typeof assemble>, cause: unknown) {
|
|
328
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Retain per-step evidence without changing the parent journey's acquisition flow. */
|
|
333
|
+
export async function withJourneyStepEvidence<T>(operation: () => Promise<T>) {
|
|
334
|
+
const current = context.getStore()
|
|
335
|
+
const start = current?.observations.length ?? 0
|
|
336
|
+
try {
|
|
337
|
+
const result = await operation()
|
|
338
|
+
return { ...result, ...(current ? { evidence: assemble({ ...current, observations: current.observations.slice(start) }) } : {}) }
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (current) throw new JourneyStepError(assemble({ ...current, observations: current.observations.slice(start) }), error)
|
|
341
|
+
throw error
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -143,10 +143,12 @@ export class IosTraceSessions {
|
|
|
143
143
|
} catch (error) {
|
|
144
144
|
this.fail(s, reasonFor(error, s.recording?.recorder.alive() ? 'TRACE_STOP_FAILED' : 'TRACE_ARTIFACT_FAILED'))
|
|
145
145
|
s.result.cleanup.status = 'failed'
|
|
146
|
-
|
|
146
|
+
// Stop timeouts can throw after SIGKILL has already closed the recorder.
|
|
147
|
+
// Discarding is safe only after exit; keep the failure but release owned files.
|
|
148
|
+
if ((s.discard || s.result.reason === 'STORAGE_LIMIT') && !s.recording?.recorder.alive()) await this.discardStoppedFiles(s)
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
|
-
private async
|
|
151
|
+
private async discardStoppedFiles(s: Session) {
|
|
150
152
|
try {
|
|
151
153
|
if (s.directory) await fs.rm(s.directory, { recursive: true, force: true })
|
|
152
154
|
s.result.cleanup = { status: 'complete', remaining_resources: [] }; s.terminal = this.time.mono()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unknownDeviceReadiness, observeDeviceReadiness } from './device-readiness.js'
|
|
1
2
|
import { pausedReadiness } from '../debugger/guard.js'
|
|
2
3
|
import type { DeviceInfo } from '../types.js'
|
|
3
4
|
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js'
|
|
@@ -20,10 +21,15 @@ export async function readinessDelay(ms: number) {
|
|
|
20
21
|
remainingBudget()
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
function observationComplete(tracker: ReadinessTracker, wait: boolean, count: number) {
|
|
25
|
+
return tracker.ready || (!wait && (tracker.summary.state === 'loading' || count === 2))
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
export async function getAppReadiness(args: Target & { timeoutMs?: number; stabilityMs?: number; pollIntervalMs?: number }, wait = false) {
|
|
24
29
|
const started = performance.now()
|
|
25
30
|
let device: DeviceInfo | null = null
|
|
26
31
|
let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '')
|
|
32
|
+
tracker.deviceReadiness = unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE')
|
|
27
33
|
let failureCode: string | undefined
|
|
28
34
|
let debugPause: ReturnType<typeof pausedReadiness> = null
|
|
29
35
|
await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
|
|
@@ -36,7 +42,13 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
|
|
|
36
42
|
while (wait || count < 2) {
|
|
37
43
|
remainingBudget()
|
|
38
44
|
debugPause = pausedReadiness(device!.id, device!.platform)
|
|
39
|
-
if (debugPause) { failureCode = wait ? 'DEBUGGER_PAUSED' : undefined; return }
|
|
45
|
+
if (debugPause) { tracker.deviceReadiness = await observeDeviceReadiness(device); failureCode = wait ? 'DEBUGGER_PAUSED' : undefined; return }
|
|
46
|
+
await tracker.observeDevice()
|
|
47
|
+
if (tracker.deviceReadiness.input_ready === false) {
|
|
48
|
+
if (!wait) return
|
|
49
|
+
await readinessDelay(args.pollIntervalMs ?? 500)
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
40
52
|
const captureStarted = Date.now()
|
|
41
53
|
try {
|
|
42
54
|
const tree = await ToolsObserve.getUITreeHandler({ platform: device!.platform as 'android' | 'ios', deviceId: device!.id })
|
|
@@ -49,8 +61,7 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
|
|
|
49
61
|
if (isDeviceUnavailable(error)) { failureCode = 'DEVICE_UNAVAILABLE'; return }
|
|
50
62
|
}
|
|
51
63
|
count++
|
|
52
|
-
if (tracker
|
|
53
|
-
if (!wait && count === 2) return
|
|
64
|
+
if (observationComplete(tracker, wait, count)) return
|
|
54
65
|
await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500)
|
|
55
66
|
}
|
|
56
67
|
})
|
|
@@ -61,11 +72,11 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
|
|
|
61
72
|
failureCode = !expired && (!device || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE'
|
|
62
73
|
}
|
|
63
74
|
})
|
|
64
|
-
if (debugPause) return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) }
|
|
75
|
+
if (debugPause) return { success: !wait, device, readiness: debugPause, device_readiness: tracker.deviceReadiness, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) }
|
|
65
76
|
const state = tracker.summary.state
|
|
66
|
-
const success = !failureCode && (wait ?
|
|
77
|
+
const success = !failureCode && (wait ? tracker.ready : state !== 'unknown' || tracker.deviceReadiness.input_ready === false)
|
|
67
78
|
return {
|
|
68
|
-
success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
|
|
79
|
+
success, device, readiness: tracker.summary, device_readiness: tracker.deviceReadiness, elapsed_ms: performance.now() - started,
|
|
69
80
|
...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {})
|
|
70
81
|
}
|
|
71
82
|
}
|
|
@@ -73,6 +84,7 @@ export async function getAppReadiness(args: Target & { timeoutMs?: number; stabi
|
|
|
73
84
|
export async function withReadyWait<T extends object | undefined, F extends object>(target: Target, timeoutMs: number, operation: (device: DeviceInfo) => Promise<T>, failure: (code: string, elapsedMs: number, samples: number) => F) {
|
|
74
85
|
const started = performance.now()
|
|
75
86
|
let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '')
|
|
87
|
+
tracker.deviceReadiness = unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE')
|
|
76
88
|
let resolved = false
|
|
77
89
|
return runWithBudget(timeoutMs, async () => {
|
|
78
90
|
try {
|
|
@@ -83,14 +95,14 @@ export async function withReadyWait<T extends object | undefined, F extends obje
|
|
|
83
95
|
const result = await withResolvedDevice(device, () => withReadinessTracker(tracker, () => operation(device)))
|
|
84
96
|
try { remainingBudget() } catch {
|
|
85
97
|
const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false))
|
|
86
|
-
return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() }
|
|
98
|
+
return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline(), device_readiness: tracker.deviceReadiness }
|
|
87
99
|
}
|
|
88
|
-
return { ...result, readiness: tracker.summary }
|
|
100
|
+
return { ...result, readiness: tracker.summary, device_readiness: tracker.deviceReadiness }
|
|
89
101
|
} catch (error) {
|
|
90
102
|
const expired = budgetExpired(error)
|
|
91
103
|
if (expired) tracker.deadline()
|
|
92
104
|
else tracker.unavailable()
|
|
93
|
-
return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary }
|
|
105
|
+
return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary, device_readiness: tracker.deviceReadiness }
|
|
94
106
|
}
|
|
95
107
|
})
|
|
96
108
|
}
|
package/src/observe/readiness.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DeviceReadiness } from '../contracts/device-readiness.js'
|
|
2
|
+
import { unknownDeviceReadiness, observeDeviceReadiness, finishDeviceReadiness } from './device-readiness.js'
|
|
1
3
|
import type { GetUITreeResponse } from '../types.js'
|
|
2
4
|
import { pausedReadiness } from '../debugger/guard.js'
|
|
3
5
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
@@ -12,7 +14,36 @@ export function isDeviceUnavailable(error: unknown) {
|
|
|
12
14
|
return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error))
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
function retainBlockedEvidence(previous: DeviceReadiness, next: DeviceReadiness) {
|
|
18
|
+
const retained: string[] = []
|
|
19
|
+
if (['unknown', 'unsupported'].includes(next.wakefulness) && ['asleep', 'transitioning'].includes(previous.wakefulness)) {
|
|
20
|
+
next.wakefulness = previous.wakefulness
|
|
21
|
+
retained.push('DEVICE_ASLEEP', 'DEVICE_TRANSITIONING')
|
|
22
|
+
}
|
|
23
|
+
if (['unknown', 'unsupported'].includes(next.lock_state) && previous.lock_state === 'locked') {
|
|
24
|
+
next.lock_state = previous.lock_state
|
|
25
|
+
retained.push('DEVICE_LOCKED')
|
|
26
|
+
}
|
|
27
|
+
if (retained.length) {
|
|
28
|
+
next.evidence = [...previous.evidence.filter(item => retained.includes(item.code)), ...next.evidence].slice(-8)
|
|
29
|
+
next.limitations = [...new Set([...next.limitations, 'DEVICE_BLOCKER_EVIDENCE_RETAINED'])]
|
|
30
|
+
}
|
|
31
|
+
return finishDeviceReadiness(next)
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
export class ReadinessTracker {
|
|
35
|
+
deviceReadiness = unknownDeviceReadiness()
|
|
36
|
+
get ready() { return this.summary.state === 'settled' && this.deviceReadiness.input_ready !== false }
|
|
37
|
+
async observeDevice() {
|
|
38
|
+
const next = await observeDeviceReadiness({ platform: this.platform, id: this.deviceId })
|
|
39
|
+
this.deviceReadiness = retainBlockedEvidence(this.deviceReadiness, next)
|
|
40
|
+
if (this.deviceReadiness.input_ready === false) {
|
|
41
|
+
this.signature = null
|
|
42
|
+
this.stableSince = 0
|
|
43
|
+
this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
44
|
+
limitations: [...new Set([...this.summary.limitations, 'DEVICE_BLOCKED'])] }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
16
47
|
private signature: string | null = null
|
|
17
48
|
private stableSince = 0
|
|
18
49
|
summary: ReadinessSummary
|
|
@@ -103,3 +134,5 @@ function readinessState(loading: boolean, previous: string | null, unchanged: bo
|
|
|
103
134
|
function readinessCode(nativeLoading: boolean, textLoading: boolean, state: ReadinessSummary['state']) {
|
|
104
135
|
return nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES'
|
|
105
136
|
}
|
|
137
|
+
|
|
138
|
+
export async function observeReadinessDeviceWait() { await waits.getStore()?.observeDevice() }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Metadata } from 'sharp'
|
|
2
|
+
import { benchmarkStage } from '../utils/benchmark-metrics.js'
|
|
2
3
|
interface EncodedScreenshot { screenshot: string; screenshot_mime: string; screenshot_fallback?: string | null; screenshot_fallback_mime?: string }
|
|
3
4
|
async function convert(buffer: Buffer, platform: 'android' | 'ios') {
|
|
4
5
|
const { default: sharp } = await import('sharp')
|
|
@@ -16,6 +17,9 @@ async function convert(buffer: Buffer, platform: 'android' | 'ios') {
|
|
|
16
17
|
return { hasAlpha, webp, jpeg }
|
|
17
18
|
}
|
|
18
19
|
export async function encodeScreenshot(buffer: Buffer, platform: 'android' | 'ios'): Promise<EncodedScreenshot> {
|
|
20
|
+
return benchmarkStage('screenshot_encoding_ms', () => encode(buffer, platform))
|
|
21
|
+
}
|
|
22
|
+
async function encode(buffer: Buffer, platform: 'android' | 'ios'): Promise<EncodedScreenshot> {
|
|
19
23
|
const png = buffer.toString('base64')
|
|
20
24
|
try {
|
|
21
25
|
const { hasAlpha, webp, jpeg } = await convert(buffer, platform)
|
|
@@ -5,6 +5,7 @@ import { measure } from '../utils/timing.js'
|
|
|
5
5
|
import { deriveSnapshotMetadata } from './snapshot-metadata.js'
|
|
6
6
|
import { deriveSnapshotSemantic } from './snapshot-semantic.js'
|
|
7
7
|
import { snapshotLog, streamLog, type SnapshotLogEntry } from './snapshot-logs.js'
|
|
8
|
+
import { collectGetScreenFingerprint } from './android/fingerprint.js'
|
|
8
9
|
export interface SnapshotInput { reason?: string; includeLogs?: boolean; includeScreenshot?: boolean; logLines?: number; platform?: 'android' | 'ios'; appId?: string; deviceId?: string; sessionId?: string }
|
|
9
10
|
type Dependencies = Pick<typeof ToolsObserve, 'captureScreenshotHandler' | 'getCurrentScreenHandler' | 'getScreenFingerprintHandler' | 'getUITreeHandler' | 'readLogStreamHandler' | 'getLogsHandler'>
|
|
10
11
|
async function settle<T extends Record<string, Promise<unknown>>>(tasks: T) {
|
|
@@ -17,11 +18,15 @@ function apply<T>(result: PromiseSettledResult<T>, success: (value: T) => void,
|
|
|
17
18
|
}
|
|
18
19
|
async function collect(raw: CaptureDebugSnapshotRawResponse, input: SnapshotInput, deps: Dependencies) {
|
|
19
20
|
const { platform, deviceId, includeScreenshot = true, includeLogs = true, sessionId, logLines = 200 } = input
|
|
21
|
+
const androidTree = platform === 'android' ? deps.getUITreeHandler({ platform, deviceId }) : undefined
|
|
20
22
|
const results = await settle({
|
|
21
23
|
screenshot: includeScreenshot ? measure('screenshot_ms', () => deps.captureScreenshotHandler({ platform, deviceId })) : Promise.resolve(null),
|
|
22
24
|
currentScreen: !platform || platform === 'android' ? deps.getCurrentScreenHandler({ deviceId }) : Promise.resolve(null),
|
|
23
|
-
fingerprint:
|
|
24
|
-
|
|
25
|
+
fingerprint: androidTree ? collectGetScreenFingerprint({
|
|
26
|
+
getUITree: () => androidTree,
|
|
27
|
+
getCurrentScreen: id => deps.getCurrentScreenHandler({ deviceId: id })
|
|
28
|
+
}, deviceId) : deps.getScreenFingerprintHandler({ platform, deviceId }),
|
|
29
|
+
uiTree: androidTree ?? deps.getUITreeHandler({ platform, deviceId }),
|
|
25
30
|
readLogStream: includeLogs ? deps.readLogStreamHandler({ platform, deviceId, sessionId: sessionId || 'default', limit: logLines }) : Promise.resolve({ entries: [] })
|
|
26
31
|
})
|
|
27
32
|
let entries: unknown[] = []
|
package/src/server/common.ts
CHANGED
|
@@ -13,9 +13,9 @@ export function wrapResponse<T>(data: T, includeSerializationTiming = true) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export type ToolCallArgs = Record<string, unknown>
|
|
16
|
-
export type ToolCallResult = Awaited<ReturnType<typeof wrapResponse>> | {
|
|
16
|
+
export type ToolCallResult = (Awaited<ReturnType<typeof wrapResponse>> | {
|
|
17
17
|
content: Array<{ type: 'text' | 'image'; text?: string; data?: string; mimeType?: string }>
|
|
18
|
-
}
|
|
18
|
+
}) & { isError?: boolean }
|
|
19
19
|
export type ToolHandler = (args: ToolCallArgs) => Promise<ToolCallResult>
|
|
20
20
|
|
|
21
21
|
export function getStringArg(args: ToolCallArgs, key: string): string | undefined {
|
|
@@ -185,11 +185,12 @@ Failure Handling:
|
|
|
185
185
|
},
|
|
186
186
|
{
|
|
187
187
|
name: 'list_devices',
|
|
188
|
-
description: 'List
|
|
188
|
+
description: 'List Android devices and iOS simulators. Supply appId for device-specific installation status; unknown checks do not imply absence.',
|
|
189
189
|
inputSchema: {
|
|
190
190
|
type: 'object',
|
|
191
191
|
properties: {
|
|
192
|
-
platform: { type: 'string', enum: ['android', 'ios'] }
|
|
192
|
+
platform: { type: 'string', enum: ['android', 'ios'] },
|
|
193
|
+
appId: { type: 'string', pattern: '^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)*$', description: 'Optional package or bundle identifier to check on each device.' }
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeDeviceReadiness, unknownDeviceReadiness } from '../../observe/device-readiness.js'
|
|
1
2
|
import type { ToolPayload } from './payload.js'
|
|
2
3
|
import { payloadOf } from './payload.js'
|
|
3
4
|
import { correlatedHandler } from './correlation.js'
|
|
@@ -8,7 +9,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
8
9
|
import { runWithBudget } from '../../utils/operation-budget.js'
|
|
9
10
|
import { validateExpectedState } from '../schemas/condition-schema.js'
|
|
10
11
|
import { conditionFailureCode, createConditionalAction, withConditionalAction } from '../../interact/conditional-action.js'
|
|
11
|
-
import { resolveTargetDevice, withResolvedDevice } from '../../utils/resolve-device.js'
|
|
12
|
+
import { resolveTargetDevice, withResolvedDevice, currentResolvedDevice } from '../../utils/resolve-device.js'
|
|
12
13
|
|
|
13
14
|
import { ToolsObserve } from '../../observe/index.js'
|
|
14
15
|
import { recordObservationEvent, withObservationContext } from '../../observe/performance-metrics.js'
|
|
@@ -24,6 +25,7 @@ import type { PlatformArg, VerificationMode } from './handler-types.js'
|
|
|
24
25
|
export function compactAction(result: ToolPayload, verificationMode: VerificationMode, started = true, stateDelta?: unknown, totalMs = 0) {
|
|
25
26
|
return {
|
|
26
27
|
action_id: result.action_id,
|
|
28
|
+
...(result.device_readiness ? { device_readiness: result.device_readiness } : {}),
|
|
27
29
|
action_type: result.action_type,
|
|
28
30
|
success: !!result.success,
|
|
29
31
|
lifecycle_state: result.success ? (verificationMode === 'full' ? result.lifecycle_state : 'pending_verification') : 'failed',
|
|
@@ -51,14 +53,25 @@ function createAction(type: string, args: ToolCallArgs, timing: Record<string, n
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
type Action = ReturnType<typeof createAction>
|
|
56
|
+
async function executeWithDeviceState(args: ToolCallArgs, execute: ToolHandler) {
|
|
57
|
+
const result = payloadOf(await execute(args))
|
|
58
|
+
result.device_readiness = await observeDeviceReadiness(result.device ?? currentResolvedDevice())
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
function actionError(type: string, error: unknown) {
|
|
62
|
+
const response = wrapToolError(type, error)
|
|
63
|
+
const item = response.content.find(item => item.type === 'text')
|
|
64
|
+
if (item?.text) item.text = JSON.stringify({ ...JSON.parse(item.text), device_readiness: unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE') })
|
|
65
|
+
return response
|
|
66
|
+
}
|
|
54
67
|
async function dispatchAction(s: Action, execute: ToolHandler) {
|
|
55
68
|
const { type, args, controls, condition, budgetMs } = s
|
|
56
69
|
const dispatchWithBudget = (timeoutMs: number) => runWithBudget(timeoutMs, async (budget) => {
|
|
57
70
|
try {
|
|
58
|
-
if (!condition) return
|
|
71
|
+
if (!condition) return await executeWithDeviceState(args, execute)
|
|
59
72
|
return await withConditionalAction(condition, async () => {
|
|
60
73
|
const device = await resolveTargetDevice({ platform: condition.platform, deviceId: condition.deviceId })
|
|
61
|
-
return withResolvedDevice(device, async () =>
|
|
74
|
+
return withResolvedDevice(device, async () => executeWithDeviceState(args, execute))
|
|
62
75
|
})
|
|
63
76
|
}
|
|
64
77
|
finally { s.dispatchStarted = budget.dispatchStarted }
|
|
@@ -69,8 +82,8 @@ async function dispatchAction(s: Action, execute: ToolHandler) {
|
|
|
69
82
|
? await dispatchWithBudget(Infinity) : await dispatchWithBudget(budgetMs)
|
|
70
83
|
if (s.result.success) s.dispatchStarted = true
|
|
71
84
|
} catch (error) {
|
|
72
|
-
if (!condition && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return
|
|
73
|
-
s.result = { success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
|
|
85
|
+
if (!condition && controls.verificationMode === 'full' && controls.responseMode === 'debug' && controls.actionTimeoutMs === undefined && !/TIMEOUT|timed out/i.test(String(error))) return actionError(type, error)
|
|
86
|
+
s.result = { device_readiness: unknownDeviceReadiness('DEVICE_OBSERVATION_UNAVAILABLE'), success: false, failure_code: /TIMEOUT|timed out/i.test(String(error)) ? 'ACTION_TIMEOUT' : 'UNKNOWN', error: String(error) }
|
|
74
87
|
}
|
|
75
88
|
}
|
|
76
89
|
function rejectCondition(s: Action) {
|
|
@@ -78,6 +91,7 @@ function rejectCondition(s: Action) {
|
|
|
78
91
|
s.blocked = !!condition && ['mismatch', 'unavailable'].includes(condition.result.status)
|
|
79
92
|
if (s.blocked && condition) {
|
|
80
93
|
s.dispatchStarted = false
|
|
94
|
+
const deviceReadiness = s.result.device_readiness
|
|
81
95
|
s.result = buildActionExecutionResult({
|
|
82
96
|
actionType: type, sourceModule: 'server', device: condition.result.observed?.device,
|
|
83
97
|
selector: s.result?.target?.selector ?? null, resolved: s.result?.target?.resolved,
|
|
@@ -85,6 +99,7 @@ function rejectCondition(s: Action) {
|
|
|
85
99
|
failure: { failureCode: conditionFailureCode(condition), retryable: false },
|
|
86
100
|
traceSteps: [{ stage: 'verify', timestamp: Date.now(), result: 'failure', attempt_index: 0, metadata: { phase: 'precondition', dispatch_started: false } }]
|
|
87
101
|
})
|
|
102
|
+
s.result.device_readiness = deviceReadiness
|
|
88
103
|
}
|
|
89
104
|
}
|
|
90
105
|
function normalizeAction(s: Action) {
|
|
@@ -130,19 +145,12 @@ function actionResponse(s: Action) {
|
|
|
130
145
|
const evidence = {
|
|
131
146
|
...compact,
|
|
132
147
|
...(condition ? {
|
|
133
|
-
condition:
|
|
134
|
-
? {
|
|
135
|
-
...condition.result, observed: condition.result.observed ? {
|
|
136
|
-
snapshot_session_id: condition.result.observed.snapshot_session_id,
|
|
137
|
-
snapshot_revision: condition.result.observed.snapshot_revision,
|
|
138
|
-
captured_at_ms: condition.result.observed.captured_at_ms
|
|
139
|
-
} : null
|
|
140
|
-
} : condition.result
|
|
148
|
+
condition: condition.result
|
|
141
149
|
} : {}),
|
|
142
150
|
...(controls.verificationMode === 'light' ? { state_delta_available: !!s.delta } : {}),
|
|
143
151
|
...(s.result.success && controls.verificationMode === 'light' && !s.observationAvailable ? { verification_diagnostic: { code: 'VERIFICATION_UNAVAILABLE', retryable: true } } : {})
|
|
144
152
|
}
|
|
145
|
-
return wrapResponse(
|
|
153
|
+
return wrapResponse({
|
|
146
154
|
...s.result, ...evidence, timing: { ...timing, total_ms: performance.now() - started },
|
|
147
155
|
effective_controls: { ...controls, actionTimeoutMs: s.unbounded ? null : budgetMs, timeout_bound: !(s.unbounded) }
|
|
148
156
|
}, controls.responseMode === 'debug')
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { evidenceSessionId } from '../../observe/evidence.js'
|
|
2
|
+
import { interactionDiagnostics } from './interaction-diagnostics.js'
|
|
3
|
+
import { object, pick, boundedValue, boundedState, observedState, deltaSummary, type Payload } from './compact-state.js'
|
|
4
|
+
import type { ToolCallArgs, ToolCallResult } from '../common.js'
|
|
5
|
+
|
|
6
|
+
export const compactInteractionTools = new Set(['start_app', 'restart_app', 'tap', 'tap_element', 'swipe', 'scroll_to_element', 'type_text', 'press_back', 'run_journey'])
|
|
7
|
+
const bytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value))
|
|
8
|
+
// IDs are preserved verbatim; their allowance counts the UTF-8 wire encoding.
|
|
9
|
+
const idBytes = (value: unknown) => bytes(String(value ?? '')) - 2
|
|
10
|
+
const deviceBytes = (evidence: unknown) => idBytes(object(object(evidence).device).id)
|
|
11
|
+
function compactEvidence(value: unknown) {
|
|
12
|
+
const e = object(value)
|
|
13
|
+
return { ...pick(e, ['session_id', 'correlation_id', 'action_id', 'device']),
|
|
14
|
+
...(e.association_reason ? pick(e, ['association', 'association_reason']) : {}),
|
|
15
|
+
...(object(e.trace_context).trace_id ? { trace_id: object(e.trace_context).trace_id } : {}) }
|
|
16
|
+
}
|
|
17
|
+
function conditionSummary(value: unknown) {
|
|
18
|
+
const c = object(value)
|
|
19
|
+
return { ...pick(c, ['status', 'reason']), observed: c.observed == null ? null : pick(c.observed, ['snapshot_session_id', 'snapshot_revision', 'captured_at_ms']) }
|
|
20
|
+
}
|
|
21
|
+
function addCompactError(result: Payload, error: unknown, name: string) {
|
|
22
|
+
const message = typeof error === 'string' ? error : object(error).message
|
|
23
|
+
result.error = { ...pick(error, ['code']), message: boundedValue(message) }
|
|
24
|
+
result.action_type ??= name
|
|
25
|
+
result.verification ??= { status: 'unavailable' }
|
|
26
|
+
if (object(result.error).message !== message) result.truncated = true
|
|
27
|
+
}
|
|
28
|
+
function compactResult(source: Payload, name: string, limit = 1280): Payload {
|
|
29
|
+
const evidence = object(source.evidence)
|
|
30
|
+
const result: Payload = {
|
|
31
|
+
...pick(source, ['action_id', 'action_type', 'success', 'lifecycle_state', 'failure_code', 'retryable', 'delivery_status', 'dispatch_started', 'verification', 'verification_diagnostic', 'state_delta_available']),
|
|
32
|
+
observed_state: !['wait', 'assert'].includes(name) && source.dispatch_started !== true
|
|
33
|
+
? { status: 'unknown', reason: 'NO_POST_ACTION_OBSERVATION' } : observedState(evidence, source.dispatch_started === true),
|
|
34
|
+
evidence: compactEvidence(evidence), diagnostics: interactionDiagnostics.retain(source)
|
|
35
|
+
}
|
|
36
|
+
result.success ??= false
|
|
37
|
+
result.verification ??= { status: source.dispatch_started === false ? 'not_requested' : 'unavailable' }
|
|
38
|
+
if (source.error) addCompactError(result, source.error, name)
|
|
39
|
+
if (source.device_readiness) result.device_readiness = pick(source.device_readiness, ['wakefulness', 'lock_state', 'input_ready', 'blockers', 'limitations'])
|
|
40
|
+
if (source.condition) result.condition = conditionSummary(source.condition)
|
|
41
|
+
if (source.state_delta) result.state_delta = deltaSummary(source.state_delta)
|
|
42
|
+
if (source.timing) result.timing = pick(source.timing, ['total_ms'])
|
|
43
|
+
if (object(source.observed).matched_count !== undefined) result.matched_count = object(source.observed).matched_count
|
|
44
|
+
if (source.observed_fingerprint !== undefined) result.matched = !!source.success
|
|
45
|
+
const state = source.actual_state !== undefined ? source.actual_state : source.observed_state
|
|
46
|
+
if (state !== undefined) {
|
|
47
|
+
result.observed = boundedState(state)
|
|
48
|
+
if (JSON.stringify(result.observed) !== JSON.stringify(state)) result.truncated = true
|
|
49
|
+
}
|
|
50
|
+
result.evidence = { session_id: evidenceSessionId(), ...object(result.evidence) }
|
|
51
|
+
enforceBudget(result, limit + deviceBytes(evidence))
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
function enforceBudget(result: Payload, limit: number) {
|
|
55
|
+
if (bytes(result) <= limit) return
|
|
56
|
+
result.truncated = true
|
|
57
|
+
for (const key of ['state_delta', 'observed', 'matched_count', 'matched', 'state_delta_available', 'timing']) {
|
|
58
|
+
delete result[key]
|
|
59
|
+
if (bytes(result) <= limit) return
|
|
60
|
+
}
|
|
61
|
+
if (result.condition) result.condition = pick(result.condition, ['status', 'reason'])
|
|
62
|
+
result.observed_state = { status: 'unknown', reason: 'OMITTED' }
|
|
63
|
+
if (result.failure_code) delete result.error
|
|
64
|
+
else if (result.error) result.error = pick(result.error, ['code'])
|
|
65
|
+
}
|
|
66
|
+
function compactJourney(source: Payload): Payload {
|
|
67
|
+
const steps = Array.isArray(source.steps) ? source.steps.map(object) : []
|
|
68
|
+
const result: Payload = { ...pick(source, ['stopped_at_step_id']), success: source.success === true,
|
|
69
|
+
evidence: compactEvidence(source.evidence), diagnostics: interactionDiagnostics.retain(source),
|
|
70
|
+
observed_state: observedState(source.evidence), timing: pick(source.timing, ['total_ms']),
|
|
71
|
+
steps: steps.map(step => ({ ...pick(step, ['id', 'type', 'status', 'success', 'reason']),
|
|
72
|
+
result: step.result ? compactResult(object(step.result), String(step.type), 1080) : null })) }
|
|
73
|
+
if (source.error) {
|
|
74
|
+
const message = object(source.error).message
|
|
75
|
+
const bounded = boundedValue(message)
|
|
76
|
+
result.error = { ...pick(source.error, ['code']), message: bounded }
|
|
77
|
+
if (bounded !== message) result.truncated = true
|
|
78
|
+
}
|
|
79
|
+
if (source.system_trace) result.system_trace = pick(source.system_trace, ['trace_id', 'state', 'coverage', 'clock_alignment'])
|
|
80
|
+
enforceBudget(result, 1280 + 1280 * steps.length + steps.reduce((sum, s) => sum + idBytes(s.id) + deviceBytes(object(s.result).evidence), 0) + idBytes(source.stopped_at_step_id) + deviceBytes(source.evidence))
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
export function finalizeCompactInteraction(name: string, args: ToolCallArgs, response: ToolCallResult) {
|
|
84
|
+
if (!compactInteractionTools.has(name) || (args.responseMode ?? (name === 'run_journey' ? 'compact' : 'debug')) !== 'compact') return response
|
|
85
|
+
const item = response.content.find(item => item.type === 'text')
|
|
86
|
+
if (item?.text) {
|
|
87
|
+
const source = object(JSON.parse(item.text))
|
|
88
|
+
item.text = JSON.stringify(name === 'run_journey' ? compactJourney(source) : compactResult(source, name))
|
|
89
|
+
}
|
|
90
|
+
return response
|
|
91
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type Payload = Record<string, unknown>
|
|
2
|
+
export function object(value: unknown): Payload {
|
|
3
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Payload : {}
|
|
4
|
+
}
|
|
5
|
+
export function pick(value: unknown, keys: string[]): Payload {
|
|
6
|
+
const source = object(value)
|
|
7
|
+
return Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]]))
|
|
8
|
+
}
|
|
9
|
+
export function boundedValue(value: unknown): unknown {
|
|
10
|
+
if (typeof value !== 'string') return ['number', 'boolean'].includes(typeof value) || value === null ? value : undefined
|
|
11
|
+
let text = ''
|
|
12
|
+
for (const char of value) {
|
|
13
|
+
if (Buffer.byteLength(text + char) > 128) break
|
|
14
|
+
text += char
|
|
15
|
+
}
|
|
16
|
+
return text
|
|
17
|
+
}
|
|
18
|
+
export function observedState(evidence: unknown, dispatchStarted = false): Payload {
|
|
19
|
+
const e = object(evidence), device = object(e.device)
|
|
20
|
+
const observations = Array.isArray(e.observations) ? e.observations.map(object) : []
|
|
21
|
+
const candidates = observations.filter(o => ['ui_tree', 'fingerprint', 'screenshot'].includes(String(o.kind)))
|
|
22
|
+
const last = candidates.at(-1)
|
|
23
|
+
if (!last) return { status: 'unknown', reason: 'NO_POST_ACTION_OBSERVATION' }
|
|
24
|
+
const summary = pick(last, ['kind', 'source_captured_at_ms', 'freshness', 'snapshot_session_id', 'snapshot_revision'])
|
|
25
|
+
summary.captured_at_ms = summary.source_captured_at_ms ?? null
|
|
26
|
+
delete summary.source_captured_at_ms
|
|
27
|
+
const same = object(last.device).id === device.id && object(last.device).platform === device.platform && !!device.id
|
|
28
|
+
const post = typeof e.dispatch_completed_at_ms === 'number' && typeof last.capture_started_at_ms === 'number' && last.capture_started_at_ms >= e.dispatch_completed_at_ms
|
|
29
|
+
if (typeof last.source_captured_at_ms !== 'number' || last.source_captured_at_ms <= 0) return { status: 'unknown', reason: 'CAPTURE_TIME_UNKNOWN', ...summary }
|
|
30
|
+
if (last.status === 'captured' && last.freshness === 'fresh' && same && (!dispatchStarted || post)) return { status: 'observed', ...summary }
|
|
31
|
+
return { status: 'unknown', reason: !same ? 'DEVICE_MISMATCH' : !post && dispatchStarted ? 'NO_POST_ACTION_OBSERVATION' : 'OBSERVATION_UNAVAILABLE', ...summary }
|
|
32
|
+
}
|
|
33
|
+
export function deltaSummary(value: unknown) {
|
|
34
|
+
const delta = object(value)
|
|
35
|
+
return { ...pick(delta, ['base_snapshot_revision', 'snapshot_revision', 'truncated', 'omitted_changes']),
|
|
36
|
+
...Object.fromEntries(['added', 'removed', 'changed'].map(key => [key, Array.isArray(delta[key]) ? delta[key].length : 0])) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function boundedState(value: unknown): unknown {
|
|
40
|
+
if (!value || typeof value !== 'object') return boundedValue(value)
|
|
41
|
+
const fields = pick(value, ['property', 'value', 'raw_value', 'checked', 'selected', 'expanded', 'enabled', 'text_value'])
|
|
42
|
+
return Object.fromEntries(Object.entries(fields).map(([key, item]) => [key, boundedValue(item)]).filter(([, item]) => item !== undefined))
|
|
43
|
+
}
|
|
@@ -34,6 +34,7 @@ export async function executeTap(args: ToolCallArgs) {
|
|
|
34
34
|
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
35
35
|
const result = buildActionExecutionResult({
|
|
36
36
|
actionType: 'tap',
|
|
37
|
+
device: res.device,
|
|
37
38
|
sourceModule: 'server',
|
|
38
39
|
selector: { x, y },
|
|
39
40
|
success: !!res.success,
|
|
@@ -84,6 +85,7 @@ export async function executeSwipe(args: ToolCallArgs) {
|
|
|
84
85
|
const uiFingerprintAfter = verificationMode === 'full' ? await measure('post_observation_ms', () => captureActionFingerprint(platform, deviceId)) : null
|
|
85
86
|
const result = buildActionExecutionResult({
|
|
86
87
|
actionType: 'swipe',
|
|
88
|
+
device: res.device,
|
|
87
89
|
sourceModule: 'server',
|
|
88
90
|
selector: { x1, y1, x2, y2, duration },
|
|
89
91
|
success: !!res.success,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeDeviceReadiness } from '../../observe/device-readiness.js'
|
|
1
2
|
import type { UiSelector as ExpectElementSelectorArg } from '../../contracts/selector.js'
|
|
2
3
|
import { markEvidenceVerification } from '../../observe/evidence.js'
|
|
3
4
|
import { withReadyWait } from '../../observe/readiness-service.js'
|
|
@@ -86,6 +87,7 @@ export async function handleAdjustControl(args: ToolCallArgs) {
|
|
|
86
87
|
platform,
|
|
87
88
|
deviceId
|
|
88
89
|
})
|
|
90
|
+
res.device_readiness = await observeDeviceReadiness(res.device)
|
|
89
91
|
markEvidenceVerification()
|
|
90
92
|
return wrapResponse(res)
|
|
91
93
|
}
|