diff --git a/packages/contracts/src/wait.ts b/packages/contracts/src/wait.ts index 2389a60f2..31722408e 100644 --- a/packages/contracts/src/wait.ts +++ b/packages/contracts/src/wait.ts @@ -1,3 +1,25 @@ +/** + * Machine-readable wait failure taxonomy. These values are carried in + * `error.details.reason`; callers should branch on them instead of parsing + * wait error messages. + * + * `wait_capture_stalled` means no readable capture established an observation + * before the deadline and is retriable. `wait_deadline_exceeded` means a later + * capture consumed the remaining budget after at least one readable capture. + * `wait_target_absent` is the only ordinary absence verdict and therefore + * always carries readable-capture evidence. The remaining reasons describe + * stability and replay-landmark refusals. + */ +export const WAIT_REASONS = { + captureStalled: 'wait_capture_stalled', + deadlineExceeded: 'wait_deadline_exceeded', + targetAbsent: 'wait_target_absent', + stableTimeout: 'wait_stable_timeout', + landmarkIdentityMismatch: 'wait_landmark_identity_mismatch', +} as const; + +export type WaitReason = (typeof WAIT_REASONS)[keyof typeof WAIT_REASONS]; + /** * Public daemon result for `wait`. The runtime-local result carries a `kind` * discriminant, but `toDaemonWaitData` intentionally projects the normal daemon diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index 6ffc7392d..9fe7bc407 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -245,6 +245,11 @@ test('usageForCommand resolves workflow help topic', async () => { assert.match(help, /published script contain only \$\{PASSWORD\}/); assert.match(help, /Do not record passwords, tokens, or other secrets without --record-as/); assert.match(help, /Read-only visible\/state question: use snapshot\/get\/is\/find/); + assert.match(help, /wait_target_absent means at least one readable capture/); + assert.match(help, /wait_capture_stalled means no readable capture/); + assert.match(help, /wait_deadline_exceeded means a later capture/); + assert.match(help, /wait_landmark_identity_mismatch means a replay destination guard/); + assert.match(help, /wait_stable_timeout means wait stable/); assert.match(help, /Use snapshot -i only when refs are needed/); assert.match(help, /install-from-source --github-actions-artifact org\/repo:app-debug/); assert.match(help, /Discovery is not enough when the task asks to open\/start/); @@ -528,6 +533,8 @@ test('usageForCommand resolves manual QA help topic', async () => { assert.match(help, /use fill --settle to replace/); assert.match(help, /use type only to append to an already-focused field/); assert.match(help, /Do not use placeholders such as @ref/); + assert.match(help, /wait_target_absent means at least one readable capture/); + assert.match(help, /wait_capture_stalled means no readable capture/); }); test('usageForCommand resolves validate help topic', async () => { diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 072e49398..e3fbbb468 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -139,6 +139,15 @@ const EXAMPLE_LINES = [ 'agent-device test ./suite --platform android', ] as const; +const WAIT_FAILURE_CONTRACT = `Wait failure contract: + Read wait failures from error.details.reason in --json output; do not infer the verdict from the message. + wait_target_absent means at least one readable capture saw no matching target. It includes readableCaptures and waitedMs, and may include currentSurface details. + wait_capture_stalled means no readable capture established an observation before the deadline. It is retriable; retry or use screenshot to inspect the surface. + wait_deadline_exceeded means a later capture consumed the remaining budget after an earlier readable capture; it includes captureTruncated and readableCaptures. + wait_landmark_identity_mismatch means a replay destination guard found the selector but not the recorded target identity. + wait_stable_timeout means wait stable did not observe a stable UI; it is not an element-absence verdict. +`; + const HELP_TOPICS = { 'manual-qa': { summary: 'Follow manual test scripts with exact interactions and verification', @@ -180,7 +189,9 @@ Recovery: Network/typeahead result missing: wait text "Expected result" or wait . Keyboard visible over the next target: the on-screen keyboard usually does not block presses, so press the target directly instead of dismissing. If the press fails or reports no visible effect, scroll the target into view or use keyboard enter when submission is wanted. Sparse or recovered accessibility snapshot: use screenshot as visual truth, leave the bad screen if needed, then retry snapshot -i. - Non-hittable success hint: verify with the settled diff or snapshot; retarget by a better ref/selector if the UI did not change.`, + Non-hittable success hint: verify with the settled diff or snapshot; retarget by a better ref/selector if the UI did not change. + +${WAIT_FAILURE_CONTRACT}`, }, maestro: { summary: 'Supported Maestro YAML commands, grammar, and runtime boundaries', @@ -298,6 +309,7 @@ Session ordering: It is fine to parallelize independent read-only collection or commands that use different sessions/devices. Read-only and waits: +${WAIT_FAILURE_CONTRACT} Read-only visible/state question: use snapshot/get/is/find. agent-device snapshot agent-device get text 'id="product-title"' diff --git a/src/commands/interaction/runtime/selector-read.test.ts b/src/commands/interaction/runtime/selector-read.test.ts index 00974c5a1..a36ba1310 100644 --- a/src/commands/interaction/runtime/selector-read.test.ts +++ b/src/commands/interaction/runtime/selector-read.test.ts @@ -581,7 +581,7 @@ test('runtime wait rethrows the capture verdict when the screen never became rea ); }); -test('runtime wait keeps the plain timeout when readable polls simply never matched', async () => { +test('runtime wait classifies readable no-match polls as target absent', async () => { const empty = () => makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]); const device = waitDeviceWithCaptures([ empty, @@ -595,11 +595,18 @@ test('runtime wait keeps the plain timeout when readable polls simply never matc session: 'default', target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 1000 }, }), - /wait timed out for selector/, + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, 'wait timed out for selector: label="Screen X"'); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'wait_target_absent'); + assert.equal((details?.readableCaptures as number) > 0, true); + return true; + }, ); }); -test('runtime wait reports a deadline-truncated final capture over an earlier unreadable verdict', async () => { +test('runtime wait reports a stalled final capture after earlier unreadable verdicts', async () => { let captureCount = 0; const initial = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Initial' }]); const sessions = createMemorySessionStore([{ name: 'default', snapshot: initial }]); @@ -642,10 +649,11 @@ test('runtime wait reports a deadline-truncated final capture over an earlier un (error: unknown) => { assert.ok(error instanceof Error); assert.equal(error.message, 'wait timed out for selector: label="Screen X"'); - assert.equal( - (error as { details?: Record }).details?.reason, - 'wait_deadline_exceeded', - ); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'wait_capture_stalled'); + assert.equal(details?.retriable, true); + assert.equal(details?.readableCaptures, 0); + assert.equal(typeof details?.waitedMs, 'number'); return true; }, ); diff --git a/src/commands/interaction/runtime/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts index 59ed5bfb4..45d598ebe 100644 --- a/src/commands/interaction/runtime/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -36,12 +36,7 @@ import { } from './selector-read-shared.ts'; import { findSnapshotScope, sparseSelectorSnapshotError } from './selector-read-utils.ts'; import { deriveSelectorCapturePolicy } from './selector-capture-policy.ts'; -import { - createWaitPolling, - type WaitPollDeadline, - waitCaptureStalledError, - waitDeadlineExceededError, -} from './wait-polling.ts'; +import { createWaitPolling, type WaitPollDeadline, waitTimeoutError } from './wait-polling.ts'; import { createSelectorWaitCommands, type WaitCommandOptions, @@ -413,14 +408,7 @@ async function waitForFindMatch( } await polling.sleepUntilNextPoll(); } - if (deadline === 'capture-stalled') { - throw waitCaptureStalledError('find wait timed out', polling.timeoutMs); - } - if (deadline === 'capture-truncated') { - throw waitDeadlineExceededError('find wait timed out', polling.timeoutMs, true); - } - polling.rethrowIfNeverReadable(); - throw waitDeadlineExceededError('find wait timed out', polling.timeoutMs, false); + throw waitTimeoutError('find wait timed out', polling, deadline); } async function findFirstLocatorMatch( diff --git a/src/commands/interaction/runtime/selector-wait.test.ts b/src/commands/interaction/runtime/selector-wait.test.ts index 052bcb233..31d77b9d9 100644 --- a/src/commands/interaction/runtime/selector-wait.test.ts +++ b/src/commands/interaction/runtime/selector-wait.test.ts @@ -194,7 +194,27 @@ test('runtime wait with a recorded landmark keeps the plain timeout when the sel (thrown: unknown) => { assert.ok(thrown instanceof AppError); assert.match(thrown.message, /wait timed out for selector/); - assert.equal(thrown.details?.reason, undefined); + assert.equal(thrown.details?.reason, 'wait_target_absent'); + assert.equal((thrown.details?.readableCaptures as number) > 0, true); + assert.equal(typeof thrown.details?.waitedMs, 'number'); + return true; + }, + ); +}); + +test('runtime wait with no capture evidence never reports target absence', async () => { + const device = landmarkWaitDevice([landmarkScreen('Detail Screen')]); + + await assert.rejects( + device.selectors.wait({ + session: 'default', + target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 0 }, + }), + (thrown: unknown) => { + assert.ok(thrown instanceof AppError); + assert.equal(thrown.details?.reason, 'wait_capture_stalled'); + assert.equal(thrown.details?.retriable, true); + assert.equal(thrown.details?.readableCaptures, 0); return true; }, ); diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index f22a65962..b5d7f2b47 100644 --- a/src/commands/interaction/runtime/selector-wait.ts +++ b/src/commands/interaction/runtime/selector-wait.ts @@ -1,4 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS } from '@agent-device/contracts/interaction'; import { findNodeByRef, normalizeRef, type SnapshotNode } from '@agent-device/kernel/snapshot'; import { readNodeLocalIdentity, @@ -23,8 +24,7 @@ import { createWaitPolling, DEFAULT_WAIT_TIMEOUT_MS, type WaitPollDeadline, - waitCaptureStalledError, - waitDeadlineExceededError, + waitTimeoutError, } from './wait-polling.ts'; type WaitCommandContext = { @@ -268,32 +268,14 @@ async function waitForSelector( } await polling.sleepUntilNextPoll(); } - if (deadline === 'capture-stalled') { - throw waitCaptureStalledError( - `wait timed out for selector: ${selectorExpression}`, - polling.timeoutMs, - ); - } - if (landmarkMismatch) { + if (deadline !== 'capture-stalled' && landmarkMismatch) { throw new AppError( 'COMMAND_FAILED', `wait matched selector ${selectorExpression} but no candidate carried the recorded landmark identity`, { reason: WAIT_LANDMARK_MISMATCH_REASON, ...landmarkMismatch }, ); } - if (deadline === 'capture-truncated') { - throw waitDeadlineExceededError( - `wait timed out for selector: ${selectorExpression}`, - polling.timeoutMs, - true, - ); - } - polling.rethrowIfNeverReadable(); - throw waitDeadlineExceededError( - `wait timed out for selector: ${selectorExpression}`, - polling.timeoutMs, - false, - ); + throw waitTimeoutError(`wait timed out for selector: ${selectorExpression}`, polling, deadline); } type LandmarkMatchOutcome = @@ -361,14 +343,7 @@ async function waitForText( if (found) return { kind: 'text', text, waitedMs: polling.waitedMs() }; await polling.sleepUntilNextPoll(); } - if (deadline === 'capture-stalled') { - throw waitCaptureStalledError(`wait timed out for text: ${text}`, polling.timeoutMs); - } - if (deadline === 'capture-truncated') { - throw waitDeadlineExceededError(`wait timed out for text: ${text}`, polling.timeoutMs, true); - } - polling.rethrowIfNeverReadable(); - throw waitDeadlineExceededError(`wait timed out for text: ${text}`, polling.timeoutMs, false); + throw waitTimeoutError(`wait timed out for text: ${text}`, polling, deadline); } async function snapshotContainsText( @@ -403,7 +378,7 @@ async function waitForStable( }); if (!outcome.settled) { throw new AppError('COMMAND_FAILED', 'wait timed out waiting for a stable UI', { - reason: 'wait_stable_timeout', + reason: WAIT_REASONS.stableTimeout, ...(outcome.stalled ? { captureStalled: true } : {}), quietMs: quiet, timeoutMs: timeout, diff --git a/src/commands/interaction/runtime/wait-polling.ts b/src/commands/interaction/runtime/wait-polling.ts index 14b3be539..1f1bceda0 100644 --- a/src/commands/interaction/runtime/wait-polling.ts +++ b/src/commands/interaction/runtime/wait-polling.ts @@ -1,4 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS } from '@agent-device/contracts/interaction'; import { isUnreadableCaptureContentError } from '../../../snapshot/snapshot-quality.ts'; import { runWithinWaitDeadline } from './wait-deadline.ts'; @@ -7,6 +8,12 @@ const WAIT_POLL_INTERVAL_MS = 300; export type WaitPollDeadline = 'capture-stalled' | 'capture-truncated'; +export type WaitFailureEvidence = { + timeoutMs: number; + readableCaptures: number; + waitedMs: number; +}; + type WaitPollingRuntime = { clock?: { now(): number; @@ -21,6 +28,13 @@ type WaitPollingOptions = { type UnreadablePollTracker = { attempt: (capture: () => Promise) => Promise; + recordReadableCapture: () => void; + readableCaptures: () => number; + rethrowIfNeverReadable: () => void; +}; + +type WaitFailurePolling = { + failureEvidence: () => WaitFailureEvidence; rethrowIfNeverReadable: () => void; }; @@ -32,30 +46,45 @@ export function createWaitPolling( const timeoutMs = requestedTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS; const startedAtMs = now(runtime); const unreadable = createUnreadablePollTracker(); - let capturesStarted = 0; const remainingMs = () => Math.max(0, timeoutMs - (now(runtime) - startedAtMs)); return { capture: async (capture: (signal: AbortSignal) => Promise) => { - const receivedWholeWaitBudget = capturesStarted === 0; - capturesStarted += 1; + let captureWasReadable = false; const result = await runWithinWaitDeadline( runtime, options, remainingMs(), - async (signal) => await unreadable.attempt(() => capture(signal)), + async (signal) => + await unreadable.attempt(async () => { + const value = await capture(signal); + captureWasReadable = true; + return value; + }), ); - if (!result.timedOut) return result; + if (!result.timedOut) { + if (captureWasReadable) unreadable.recordReadableCapture(); + return result; + } + // A capture that only becomes readable after its deadline is not evidence for this wait. + // Count only captures that completed before runWithinWaitDeadline returned a timeout. return { timedOut: true as const, - // Only the first capture receives the wait's entire budget. A later poll is canceled by - // the enclosing deadline, so classifying it as a backend stall would overstate the evidence. - deadline: receivedWholeWaitBudget - ? ('capture-stalled' as const) - : ('capture-truncated' as const), + // A poll is a backend stall when no completed capture established a readable observation; + // the poll index is not evidence. This remains true after one or more unreadable content + // verdicts followed by a capture that consumes the remaining budget. + deadline: + unreadable.readableCaptures() === 0 + ? ('capture-stalled' as const) + : ('capture-truncated' as const), }; }, hasTimeRemaining: () => remainingMs() > 0, + failureEvidence: (): WaitFailureEvidence => ({ + timeoutMs, + readableCaptures: unreadable.readableCaptures(), + waitedMs: now(runtime) - startedAtMs, + }), rethrowIfNeverReadable: unreadable.rethrowIfNeverReadable, sleepUntilNextPoll: async () => await sleepWithinWait(runtime, options, Math.min(WAIT_POLL_INTERVAL_MS, remainingMs())), @@ -64,50 +93,67 @@ export function createWaitPolling( }; } -export function waitCaptureStalledError(message: string, timeoutMs: number): AppError { +function waitCaptureStalledError(message: string, evidence: WaitFailureEvidence): AppError { return new AppError('COMMAND_FAILED', message, { - reason: 'wait_capture_stalled', + reason: WAIT_REASONS.captureStalled, captureStalled: true, - timeoutMs, - hint: 'A snapshot capture stalled past the wait timeout. Retry, or use screenshot to inspect the current surface.', + ...evidence, + retriable: true, + hint: 'No readable snapshot capture completed before the wait timeout. Retry, or use screenshot to inspect the current surface.', }); } -export function waitDeadlineExceededError( +function waitDeadlineExceededError(message: string, evidence: WaitFailureEvidence): AppError { + return new AppError('COMMAND_FAILED', message, { + reason: WAIT_REASONS.deadlineExceeded, + captureTruncated: true, + ...evidence, + }); +} + +function waitTargetAbsentError(message: string, evidence: WaitFailureEvidence): AppError { + return new AppError('COMMAND_FAILED', message, { + reason: WAIT_REASONS.targetAbsent, + ...evidence, + }); +} + +export function waitTimeoutError( message: string, - timeoutMs: number, - captureTruncated: boolean, + polling: WaitFailurePolling, + deadline: WaitPollDeadline | undefined, ): AppError { - return new AppError( - 'COMMAND_FAILED', - message, - captureTruncated - ? { - reason: 'wait_deadline_exceeded', - captureTruncated: true, - timeoutMs, - } - : undefined, - ); + const evidence = polling.failureEvidence(); + if (deadline === 'capture-stalled') return waitCaptureStalledError(message, evidence); + if (deadline === 'capture-truncated') return waitDeadlineExceededError(message, evidence); + + polling.rethrowIfNeverReadable(); + return evidence.readableCaptures === 0 + ? waitCaptureStalledError(message, evidence) + : waitTargetAbsentError(message, evidence); } function createUnreadablePollTracker(): UnreadablePollTracker { - let sawReadableCapture = false; + let readableCaptureCount = 0; let lastUnreadableError: unknown; return { attempt: async (capture: () => Promise): Promise => { try { - const result = await capture(); - sawReadableCapture = true; - return result; + return await capture(); } catch (error) { if (!isUnreadableCaptureContentError(error)) throw error; lastUnreadableError = error; return undefined; } }, + recordReadableCapture: () => { + readableCaptureCount += 1; + }, + readableCaptures: () => readableCaptureCount, rethrowIfNeverReadable: () => { - if (!sawReadableCapture && lastUnreadableError !== undefined) throw lastUnreadableError; + if (readableCaptureCount === 0 && lastUnreadableError !== undefined) { + throw lastUnreadableError; + } }, }; } diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 9af140ed2..304401c2c 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -1560,7 +1560,7 @@ test('wait text timeout includes compact current-surface labels and buttons', as analysis: { rawNodeCount: 4, maxDepth: 1 }, }); - const response = await runWaitCommand(sessionName, androidDevice, ['Receipt uploaded', '0']); + const response = await runWaitCommand(sessionName, androidDevice, ['Receipt uploaded', '50']); expect(response?.ok).toBe(false); if (response && !response.ok) { @@ -1583,7 +1583,7 @@ test('wait selector timeout includes compact current-surface details', async () analysis: { rawNodeCount: 2, maxDepth: 0 }, }); - const response = await runWaitCommand(sessionName, androidDevice, ['id=receipt-uploaded', '0']); + const response = await runWaitCommand(sessionName, androidDevice, ['id=receipt-uploaded', '50']); expect(response?.ok).toBe(false); if (response && !response.ok) { @@ -1681,7 +1681,7 @@ test('wait timeout summary prefers content labels over chrome and identifier noi const response = await runWaitCommand(sessionName, iosSimulatorDevice, [ 'Impossible success text', - '0', + '50', ]); expect(response?.ok).toBe(false); @@ -1703,7 +1703,7 @@ test('wait timeout summary prefers content labels over chrome and identifier noi } }); -test('wait timeout preserves current behavior when current-surface inspection fails', async () => { +test('wait timeout without readable capture does not inspect the current surface', async () => { const sessionName = 'android-wait-timeout-surface-fails'; mockDispatch.mockRejectedValue(new Error('snapshot unavailable')); @@ -1712,8 +1712,11 @@ test('wait timeout preserves current behavior when current-surface inspection fa expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.message).toBe('wait timed out for text: Receipt uploaded'); - expect(response.error.details).toBeUndefined(); + expect(response.error.details?.reason).toBe('wait_capture_stalled'); + expect(response.error.details?.retriable).toBe(true); + expect(response.error.details?.readableCaptures).toBe(0); } + expect(mockDispatch).not.toHaveBeenCalled(); }); test('settings rejects unsupported iOS physical devices', async () => { diff --git a/src/daemon/wait-current-surface.test.ts b/src/daemon/wait-current-surface.test.ts index b30bb5ac8..be6a6b20f 100644 --- a/src/daemon/wait-current-surface.test.ts +++ b/src/daemon/wait-current-surface.test.ts @@ -42,3 +42,30 @@ test('deadline-truncated wait does not start a post-deadline diagnostic capture' expect(result).toBe(response); expect(captureSnapshot).not.toHaveBeenCalled(); }); + +test('wait surface decoration requires a structured wait timeout reason', async () => { + const response = { + ok: false as const, + error: { + code: 'COMMAND_FAILED' as const, + message: 'wait timed out for text: Agent Device Tester', + }, + }; + + const result = await maybeWaitTimeoutSurfaceResponse( + { + req: { + command: 'wait', + positionals: ['Agent Device Tester', '10000'], + session: 'android-e2e', + token: 'test-token', + }, + session: undefined, + device: ANDROID_EMULATOR, + }, + response, + ); + + expect(result).toBe(response); + expect(captureSnapshot).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/wait-current-surface.ts b/src/daemon/wait-current-surface.ts index 2c7b3e900..3edbf5496 100644 --- a/src/daemon/wait-current-surface.ts +++ b/src/daemon/wait-current-surface.ts @@ -1,3 +1,4 @@ +import { WAIT_REASONS } from '@agent-device/contracts/interaction'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { captureSnapshot } from './handlers/snapshot-capture.ts'; @@ -23,7 +24,7 @@ export async function maybeWaitTimeoutSurfaceResponse( params: WaitCurrentSurfaceParams, response: DaemonResponse, ): Promise { - if (response.ok || !isWaitTimeoutMessage(response.error.message)) return response; + if (response.ok || !canInspectWaitSurface(response.error.details?.reason)) return response; // A wait whose final capture consumed the remaining budget must not fire another capture for // decoration. A genuinely stalled capture would repeat the hang; an ordinary deadline truncation // would still push the response further past the user-supplied timeout. @@ -45,8 +46,8 @@ export async function maybeWaitTimeoutSurfaceResponse( ); } -function isWaitTimeoutMessage(message: string): boolean { - return /^wait timed out (?:for (?:selector|text): |waiting for a stable UI)/i.test(message); +function canInspectWaitSurface(reason: unknown): boolean { + return reason === WAIT_REASONS.targetAbsent || reason === WAIT_REASONS.stableTimeout; } async function inspectCurrentSurface( diff --git a/src/replay/target-identity-node.ts b/src/replay/target-identity-node.ts index cf422dd60..1f8c144b0 100644 --- a/src/replay/target-identity-node.ts +++ b/src/replay/target-identity-node.ts @@ -11,6 +11,7 @@ */ import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import { WAIT_REASONS } from '@agent-device/contracts/interaction'; import { normalizeType } from '../snapshot/snapshot-processing.ts'; import { normalizeIdentifierField, @@ -171,7 +172,7 @@ export const REPLAY_TARGET_GUARD_MISMATCH_REASON = 'replay_target_guard_mismatch * success. Lives here (replay zone) for the same layering reason as the * guard-mismatch marker above. */ -export const WAIT_LANDMARK_MISMATCH_REASON = 'wait_landmark_identity_mismatch'; +export const WAIT_LANDMARK_MISMATCH_REASON = WAIT_REASONS.landmarkIdentityMismatch; /** * The compact evidence `wait` retains from its LAST poll whose capture diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d6db91d6e..1c162b369 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -347,6 +347,7 @@ agent-device alert dismiss - `wait @ref` resolves the ref to its label/text from that stored snapshot, then polls for that text; it does not track the original node identity. - Because `wait @ref` is text-based after resolution, duplicate labels can match a different element than the original ref target. - `wait` shares the selector/snapshot resolution flow used by `click`, `fill`, `get`, and `is`. +- Wait failures carry a structured `error.details.reason` in `--json` output: `wait_target_absent` proves at least one readable capture saw no match; `wait_capture_stalled` means no readable capture arrived and is retriable; `wait_deadline_exceeded` means a later capture consumed the remaining budget after an earlier readable capture; `wait_landmark_identity_mismatch` is a replay destination-guard refusal; and `wait_stable_timeout` means the UI did not settle. Use `readableCaptures` and `waitedMs` instead of parsing error text. - `alert` inspects or handles system alerts on iOS simulator, macOS desktop, and Android native/runtime permission dialogs. - `alert` without an action is equivalent to `alert get`. - Use `alert get` for an immediate cheap check. Use `alert wait ` only when a prompt may appear after async work.