From 8d8b384f82f2dc5130aaa0595611f1da45300601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 16:12:01 +0200 Subject: [PATCH 1/7] refactor(interaction): migrate post-gesture stability and scroll movement onto observeUntil Restores the two loop migrations, their scroll-movement provider scenario, and the outcomeObservation guarantee cell on top of the engine and readiness wait. Both schedules declare captureDeadline 'cancel', the engine's behavior before the mode existed. --- .../capture-kit/src/post-gesture-stability.ts | 114 ++++++---- .../contracts/src/interaction-guarantees.ts | 51 +++++ .../contracts/interaction-guarantees.test.ts | 6 + src/daemon/scroll-movement.ts | 79 +++---- .../scroll-movement-observation.test.ts | 215 ++++++++++++++++++ 5 files changed, 378 insertions(+), 87 deletions(-) create mode 100644 test/integration/provider-scenarios/scroll-movement-observation.test.ts diff --git a/packages/capture-kit/src/post-gesture-stability.ts b/packages/capture-kit/src/post-gesture-stability.ts index 5ce237f1ce..dd1adac6ac 100644 --- a/packages/capture-kit/src/post-gesture-stability.ts +++ b/packages/capture-kit/src/post-gesture-stability.ts @@ -1,21 +1,30 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { sleep } from '@agent-device/host-kit/retry'; import type { PostGestureAction, PostGestureOutcome } from '@agent-device/kernel/snapshot'; +import { observeUntil, type ObservationSchedule } from './observe-until.ts'; /** - * Pure post-gesture stability mechanics: the quiet-window polling loop and the - * baseline-distrust verdict, parameterized over the capture value and the - * signature comparators. Deliberately a leaf — it imports no cycle owners and - * no `SessionState`, so it stays outside the R9 type cycle while the - * deferred-interaction-outcome owner (which holds the pending record and the - * session mutation) stays the one seam callers see. The owner supplies the - * comparators from interaction-outcome-policy; their semantics (subset - * tolerance, identity keying, discriminating entries) are documented there. + * Pure post-gesture stability mechanics: the quiet-window verdict over the shared + * `observeUntil` engine, plus the baseline-distrust decision, parameterized over + * the capture value and the signature comparators. Deliberately a leaf — it + * imports no cycle owners and no `SessionState`, so it stays outside the R9 + * type cycle while the deferred-interaction-outcome owner (which holds the + * pending record and the session mutation) stays the one seam callers see. The + * owner supplies the comparators from interaction-outcome-policy; their + * semantics (subset tolerance, identity keying, discriminating entries) are + * documented there. */ -const STABILIZATION_DEADLINE_MS = 1_500; -const STABILIZATION_INTERVAL_MS = 200; -const STABILIZATION_MIN_ATTEMPTS = 2; +/** + * Cadence and budget for the quiet-window loop: poll every 200ms, allow 1.5s + * before an unsettled surface times out, and always complete two observations + * (an `initial` counts) so a quiet pair can form even under a tight budget. + */ +const POST_GESTURE_STABILITY_SCHEDULE: ObservationSchedule = { + intervalMs: 200, + budgetMs: 1_500, + minPolls: 2, + captureDeadline: 'cancel', +}; /** * Defect 2 (#1542): a bounded extra budget used ONLY when a quiet signature @@ -30,7 +39,7 @@ const STABILIZATION_MIN_ATTEMPTS = 2; * settle-zero-margin-flake, a week-long contention-flake root cause), so this * cap is sized to never come close to that trap. */ -const STABILIZATION_DISTRUST_DEADLINE_MS = STABILIZATION_DEADLINE_MS + 2_000; +const STABILIZATION_DISTRUST_DEADLINE_MS = POST_GESTURE_STABILITY_SCHEDULE.budgetMs + 2_000; export type BaselineSurfaceEvidence = 'changed' | 'unchanged' | 'ambiguous'; @@ -116,10 +125,11 @@ export function decidePostGestureStabilityVerdict( } /** - * The quiet-window stability loop: poll until two consecutive captures agree - * and the verdict accepts the agreement, or the (possibly distrust-extended) - * deadline expires. Session state never enters here — the caller owns the - * pending record's lifecycle and clears it when this returns. + * The quiet-window stability verdict over the shared observation engine: keep + * polling until two consecutive captures agree and the decision accepts the + * agreement, or the (possibly distrust-extended) budget expires. Session + * state never enters here — the caller owns the pending record's lifecycle + * and clears it when this returns. */ export async function runPostGestureStabilityLoop(params: { pending: PostGestureStabilityPending; @@ -129,24 +139,34 @@ export async function runPostGestureStabilityLoop> { const { pending, needsBaselineDistrust, hooks } = params; const startedAt = Date.now(); - let attempts = 1; - let previous = await captureSurface(hooks, params.initial); + let attempts = 0; let baselineSignature = pending.baselineSignature; let baselineBackend = pending.baselineBackend; let baselineRebased = false; - // Extended past STABILIZATION_DEADLINE_MS only when the distrust verdict - // fires below; the ordinary (non-distrust) timeout path is unaffected. - let effectiveDeadlineMs = STABILIZATION_DEADLINE_MS; // A rebase or a distrust verdict keeps polling on a pair that DID agree, so - // the deadline can expire on a surface that is already at rest. + // the budget can expire on a surface that is already at rest. let lastPairAgreed = false; + let surfaceCache: CapturedSurface | undefined; + + const surfaceOf = (value: T): CapturedSurface => { + if (surfaceCache?.value === value) return surfaceCache; + surfaceCache = { value, ...hooks.readSurface(value) }; + return surfaceCache; + }; + + const observed = await observeUntil>({ + ...(params.initial !== undefined ? { initial: params.initial } : {}), + capture: () => hooks.capture(), + schedule: POST_GESTURE_STABILITY_SCHEDULE, + verdict: (latest, previousValue) => { + attempts += 1; + const current = surfaceOf(latest); + if (previousValue === undefined) return { kind: 'continue' }; + + const previous = surfaceOf(previousValue); + lastPairAgreed = hooks.signaturesStable(previous.signature, current.signature); + if (!lastPairAgreed) return { kind: 'continue' }; - while (attempts < STABILIZATION_MIN_ATTEMPTS || Date.now() - startedAt < effectiveDeadlineMs) { - await sleep(STABILIZATION_INTERVAL_MS); - attempts += 1; - const current = await captureSurface(hooks); - lastPairAgreed = hooks.signaturesStable(previous.signature, current.signature); - if (lastPairAgreed) { const elapsedMs = Date.now() - startedAt; // A capture plan may fall back or be pre-empted by the XCTest-channel // penalty at any time, so the backend can change mid-poll. Backends do @@ -162,8 +182,7 @@ export async function runPostGestureStabilityLoop = { @@ -204,14 +228,6 @@ type CapturedSurface = { backend: string | undefined; }; -async function captureSurface( - hooks: PostGestureStabilityHooks, - initial?: T, -): Promise> { - const value = initial ?? (await hooks.capture()); - return { value, ...hooks.readSurface(value) }; -} - function emitSettleDiagnostic( verdict: 'trust' | 'accept-stale', action: string, diff --git a/packages/contracts/src/interaction-guarantees.ts b/packages/contracts/src/interaction-guarantees.ts index 57b56a54a9..0410b9eae4 100644 --- a/packages/contracts/src/interaction-guarantees.ts +++ b/packages/contracts/src/interaction-guarantees.ts @@ -78,6 +78,11 @@ export const INTERACTION_GUARANTEES = [ // dispatch time. Distinct from occlusion/offscreen/nonHittable, which judge a target already // found; this is about whether one is found at all. 'targetReadiness', + // What the path observes after dispatch to back its success claim, and whether that observation + // is advisory (a hint the caller may ignore) or can change the response (a corroborated failure, + // a settled diff). Distinct from the opt-in verifyEvidence/settleObservation features; this is the + // path's baseline, always-on claim. + 'outcomeObservation', ] as const; export type InteractionGuarantee = (typeof INTERACTION_GUARANTEES)[number]; @@ -154,6 +159,18 @@ const SHARED_RESPONSE_CONSTRUCTION: GuaranteeEnforcement = { via: 'src/daemon/interaction/internal/interaction-touch-response.ts#buildInteractionResponseData', }; +// runtime-selector and runtime-ref share this waiver by construction: neither path observes the tap +// outcome by default. The deferred/corroborated-failure outcome mark (a recorded XCTest failure +// after a tap is an ambiguous outcome, per docs/agents/selector-capture.md) is scoped to +// navigation-sensitive Android actions and target-authored gestures, not the tap-shaped runtime +// paths; --settle/--verify are the opt-in ways to observe one. +const TAP_OUTCOME_NOT_OBSERVED_GAP: GuaranteeEnforcement = { + kind: 'waived', + reason: + 'gap: neither path observes the tap outcome by default; only --settle/--verify capture post-action evidence, and the deferred/corroborated-failure outcome mark applies only to navigation-sensitive Android actions and target-authored gestures.', + trackingIssue: GAPS_UMBRELLA_ISSUE, +}; + // Both Maestro-compatible fast paths (src/daemon/interaction/internal/interaction-touch-direct-ios.ts) // dispatch ONE fused XCTest runner request (`command: 'tap'` with a selectorKey/selectorValue) built // in packages/platform-apple/src/interactions.ts#tapElementSelector — not the separate @@ -165,6 +182,19 @@ const DIRECT_IOS_SINGLE_QUERY_READINESS: GuaranteeEnforcement = { "Intentional: the fused runner request performs a single XCTest selector/frame query per dispatch and does not itself retry for an as-yet-nonexistent target; a miss is a runner failure (see errorTaxonomy) rather than a wait, and delegation-on-error (ADR 0011) is a different path's guarantee, not a retry of this one.", }; +// Shares the SAME reasoning as TAP_OUTCOME_NOT_OBSERVED_GAP: the tap outcome is not observed on the +// success path here either. The shared ambiguous-XCTest-failure corroboration +// (src/daemon/interaction/internal/interaction-ios-tap-outcome.ts#corroborateIosTapFailure) is +// invoked identically from this fast path and from the tree-based runtime dispatch, but only ever +// reconsiders a THROWN error — it never validates a call that returned success, so it is not a +// baseline outcome claim for either path. +const DIRECT_IOS_OUTCOME_NOT_OBSERVED_GAP: GuaranteeEnforcement = { + kind: 'waived', + reason: + "gap: this replay-only route observes no outcome beyond the runner's own report; --verify/--settle do not apply here (see the inapplicable cells on this row), and the shared ambiguous-failure corroboration only reconsiders a thrown error, never a successful dispatch.", + trackingIssue: GAPS_UMBRELLA_ISSUE, +}; + // The two runtime tree paths (selector and ref resolution) run the SAME shared // guard/observation implementations; only how the target is found // (disambiguation) and how failures are described (errorTaxonomy) differ. @@ -262,6 +292,7 @@ export const INTERACTION_DISPATCH_PATHS: Record { // (umbrella: https://github.com/callstack/agent-device/issues/1081). assert.deepEqual(gaps.sort(), [ 'maestro-direct-selector/errorTaxonomy', + 'maestro-direct-selector/outcomeObservation', 'maestro-direct-selector/parentOwnedTouchPoint', 'maestro-direct-selector/responseIdentity', 'maestro-non-hittable-fallback/errorTaxonomy', + 'maestro-non-hittable-fallback/outcomeObservation', 'maestro-non-hittable-fallback/parentOwnedTouchPoint', + 'native-ref/outcomeObservation', + 'runtime-ref/outcomeObservation', + 'runtime-selector/outcomeObservation', + 'target-drag/outcomeObservation', ]); }); diff --git a/src/daemon/scroll-movement.ts b/src/daemon/scroll-movement.ts index 7bc96f6ca0..27caca2a30 100644 --- a/src/daemon/scroll-movement.ts +++ b/src/daemon/scroll-movement.ts @@ -14,7 +14,7 @@ import { containsPoint } from '@agent-device/kernel/rect'; import { AppError } from '@agent-device/kernel/errors'; import type { Point, Rect, SnapshotState } from '@agent-device/kernel/snapshot'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { sleep } from '@agent-device/host-kit/retry'; +import { observeUntil, type ObservationSchedule } from '@agent-device/capture-kit/observe-until'; import { areInteractionSurfaceSignaturesStable, buildInteractionSurfaceSignature, @@ -62,8 +62,11 @@ import type { SessionState } from './session-state.ts'; */ /** How long a scroll keeps asking whether an untouched surface is really untouched (#1542's window). */ -const MOVEMENT_VERDICT_BUDGET_MS = 1_500; -const MOVEMENT_POLL_MS = 200; +const SCROLL_MOVEMENT_SCHEDULE: ObservationSchedule = { + intervalMs: 200, + budgetMs: 1_500, + captureDeadline: 'cancel', +}; /** The pre-gesture surface, already in hand: no capture is spent to produce it. */ export type ScrollSurfaceBaseline = Readonly<{ @@ -217,6 +220,12 @@ type SurfaceVerdict = startedAt: number; }>; +/** What one capture's verdict decides, before the loop's own timing is stitched back on. */ +type SurfaceJudgement = + | Readonly<{ kind: 'blind'; reason: SurfaceBlindReason }> + | Readonly<{ kind: 'moved'; observed: ObservedSurface }> + | Readonly<{ kind: 'settled'; observed: ObservedSurface; evidence: InteractionSurfaceChange }>; + /** Why this capture cannot be compared against the pre-gesture tree at all, movement included. */ type SurfaceBlindReason = 'capture-unreadable' | 'surface-unsettled' | ScrollSurfacePairDrift; @@ -257,30 +266,33 @@ async function pollForSurfaceVerdict( swipe: ScrollSwipeEvidence; }, ): Promise { - const startedAt = Date.now(); - const deadline = startedAt + (params.budgetMs ?? MOVEMENT_VERDICT_BUDGET_MS); + // The edge question is asked only of the pre-gesture (baseline) tree, so it never depends on a + // poll's outcome and can be answered once, before the loop starts, rather than lazily on the + // first `changed` reading. + const changeNeedsRest = await baselineEndsInDirection(baseline, params.direction, params.swipe); let previous: InteractionSurfaceSignature | undefined; - let attempts = 0; - let changeNeedsRest: boolean | undefined; - - while (true) { - const reading = await readOneCapture(baseline, params.capture); - attempts += 1; - if (reading.kind === 'blind') return { kind: 'blind', reason: reading.reason }; - if (reading.kind === 'changed') { - changeNeedsRest ??= await baselineEndsInDirection(baseline, params.direction, params.swipe); - } - const verdict = settledVerdict(reading, { - previous, - changeNeedsRest: changeNeedsRest === true, - attempts, - startedAt, - }); - if (verdict) return verdict; - if (Date.now() >= deadline) return budgetExpiredVerdict(params, attempts, startedAt); - previous = reading.observed.signature; - await sleep(params.pollMs ?? MOVEMENT_POLL_MS); - } + const observed = await observeUntil({ + capture: () => readOneCapture(baseline, params.capture), + schedule: { + intervalMs: params.pollMs ?? SCROLL_MOVEMENT_SCHEDULE.intervalMs, + budgetMs: params.budgetMs ?? SCROLL_MOVEMENT_SCHEDULE.budgetMs, + captureDeadline: SCROLL_MOVEMENT_SCHEDULE.captureDeadline, + }, + verdict: (latest) => { + if (latest.kind === 'blind') + return { kind: 'done', result: { kind: 'blind', reason: latest.reason } }; + const judged = settledVerdict(latest, { previous, changeNeedsRest }); + previous = latest.observed.signature; + return judged ? { kind: 'done', result: judged } : { kind: 'continue' }; + }, + }); + + const attempts = observed.polls.length; + const startedAt = Date.now() - observed.waitedMs; + if (observed.kind !== 'done') return budgetExpiredVerdict(params, attempts, startedAt); + return observed.result.kind === 'blind' + ? observed.result + : { ...observed.result, attempts, startedAt }; } /** @@ -293,26 +305,17 @@ function settledVerdict( poll: { previous: InteractionSurfaceSignature | undefined; changeNeedsRest: boolean; - attempts: number; - startedAt: number; }, -): SurfaceVerdict | undefined { +): SurfaceJudgement | undefined { const atRest = surfaceIsAtRest(poll.previous, reading.observed.signature); - const { attempts, startedAt } = poll; if (reading.kind === 'changed') { if (!poll.changeNeedsRest || atRest) { - return { kind: 'moved', observed: reading.observed, attempts, startedAt }; + return { kind: 'moved', observed: reading.observed }; } return undefined; } if (!atRest) return undefined; - return { - kind: 'settled', - observed: reading.observed, - evidence: reading.evidence, - attempts, - startedAt, - }; + return { kind: 'settled', observed: reading.observed, evidence: reading.evidence }; } /** diff --git a/test/integration/provider-scenarios/scroll-movement-observation.test.ts b/test/integration/provider-scenarios/scroll-movement-observation.test.ts new file mode 100644 index 0000000000..e56e2808a6 --- /dev/null +++ b/test/integration/provider-scenarios/scroll-movement-observation.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; +import { assertRpcError, assertRpcOk } from './assertions.ts'; +import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { + createAppleRunnerProviderFromTranscript, + createRecordingAppleToolProvider, + simctlDeviceLifecycleHandler, +} from './providers.ts'; +import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; + +const APP = 'com.example.app'; +const DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id; + +// Directional-scroll observation on `observeUntil` (packages/capture-kit/src/observe-until.ts): +// `scroll down` gates its own `movement` claim on the tree it held before the gesture against one +// (or more, while the surface still looks untouched) post-gesture capture. The unit-level coverage +// in src/daemon/__tests__/scroll-movement.test.ts pins the verdict against a scripted capture +// function directly; these two scenarios drive the SAME loop end to end through the daemon's real +// command routing and a scripted Apple runner, the way `settle-observation.test.ts` drives `--settle`. + +// Fixed tab-bar-free list: a ScrollView reporting hidden content below, holding two rows. `rowOffset` +// moves the rows to model a scroll that actually shifted content; `hiddenBelow` toggles whether the +// container still reports more to reveal, which is what the no-progress refusal keys on. +const CONTAINER = { x: 18, y: 178, width: 366, height: 662 }; + +function screen(rowOffset: number, hiddenBelow: boolean) { + return [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 393, height: 852 }, + }, + { + index: 1, + parentIndex: 0, + type: 'ScrollView', + identifier: 'lab-list', + rect: CONTAINER, + ...(hiddenBelow ? { hiddenContentBelow: true } : {}), + }, + { + index: 2, + parentIndex: 1, + type: 'StaticText', + label: 'Row one', + // 300 (+ up to -100 offset) stays inside CONTAINER's clip (y 178..840): the presentation + // validator refuses a child frame that escapes its container's cumulative clip. + rect: { x: 24, y: 300 + rowOffset, width: 300, height: 20 }, + }, + { + index: 3, + parentIndex: 1, + type: 'StaticText', + label: 'Row two', + rect: { x: 24, y: 360 + rowOffset, width: 300, height: 20 }, + }, + ]; +} + +function snapshotEntry(nodes: readonly unknown[]): ProviderScenarioProviderEntry { + return { + command: 'ios.runner.snapshot', + deviceId: DEVICE_ID, + platform: 'apple', + result: { nodes, truncated: false }, + }; +} + +// A UI that never moves: every post-gesture capture returns the same tree, so the loop can never +// see a quiet-then-still-hidden pair on a fixed count. How many captures it takes to prove that is +// wall-clock (200ms poll floor), so the count is not scripted — a repeat entry serves every call. +function repeatSnapshotEntry(nodes: readonly unknown[]): ProviderScenarioProviderEntry { + return { ...snapshotEntry(nodes), repeat: true }; +} + +// Midpoint (201, 437) sits inside CONTAINER, matching the swipe fixture +// `src/daemon/__tests__/scroll-movement.test.ts` uses for the same geometry. +function scrollEntry(): ProviderScenarioProviderEntry { + return { + command: 'ios.runner.scroll', + deviceId: DEVICE_ID, + platform: 'apple', + result: { x: 201, y: 487, x2: 201, y2: 387 }, + }; +} + +/** + * The route ahead of a directional scroll's own capture resolves a live simulator target (a real + * host process lookup) before it ever reaches the scripted runner, so a bare `simctlDeviceLifecycleHandler` + * leaves every capture with a fresh, per-call "unknown generation" identity — comparable to nothing, + * which is `capture-lineage-drift` on the very first post-gesture read. Naming one fixed, stable + * "running app" job (a `launchctl list` line) and a fixed process start time (`ps -p -o lstart=`) + * lets the route resolve ONE identity and reuse it every capture, exactly like a real simulator whose + * app process never restarts mid-scroll — the AX bridge itself still isn't modeled, so every capture + * still falls back to the scripted runner, carrying that one stable identity instead of a random one. + */ +function iosSimulatorTool(): { provider: AppleToolProvider } { + const baseSimctl = simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, + ]); + const recorded = createRecordingAppleToolProvider({ + simctl: async (args, options) => { + if (args[0] === 'spawn' && args[1] === DEVICE_ID && args[2] === 'launchctl') { + return { stdout: `4242\t0\tUIKitApplication:${APP}[0x1]\n`, stderr: '', exitCode: 0 }; + } + return await baseSimctl(args, options); + }, + }); + const runCommand = async ( + cmd: string, + args: string[], + options?: ExecOptions, + ): Promise => { + // The system-surface presence probe runs before target resolution: report every + // registered host absent so the route proceeds to resolve the (scripted) app target below, + // instead of reading the probe as 'unknown' and falling back with a fresh random identity. + if (cmd === 'pgrep') return { stdout: '', stderr: '', exitCode: 1 }; + if (cmd === 'ps' && args[0] === '-p') { + return { stdout: 'Thu Jan 1 00:00:00 1970\n', stderr: '', exitCode: 0 }; + } + return await recorded.provider.runCommand(cmd, args, options); + }; + return { provider: { ...recorded.provider, runCommand } }; +} + +test('Provider-backed integration scroll down answers moved on the first post-gesture capture', async () => { + const runnerTranscript = createProviderTranscript([ + snapshotEntry(screen(0, true)), // pre-scroll baseline + scrollEntry(), + snapshotEntry(screen(-100, true)), // post-gesture: rows shifted into view on the first read + ]); + const appleRunnerProvider = createAppleRunnerProviderFromTranscript( + runnerTranscript, + 'ios.runner', + ); + const appleTool = iosSimulatorTool(); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + appleRunnerProvider: () => appleRunnerProvider, + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }), + async (daemon) => { + assertRpcOk(await daemon.callCommand('open', [APP], { platform: 'ios', udid: DEVICE_ID })); + assertRpcOk(await daemon.callCommand('snapshot')); + + const callsBeforeScroll = runnerTranscript.calls.length; + const scroll = await daemon.callCommand('scroll', ['down']); + const scrollData = assertRpcOk<{ movement?: string }>(scroll); + + assert.equal(scrollData.movement, 'moved'); + // Gesture first, then exactly one post-gesture snapshot: a scroll that worked is confirmed by + // the cheapest possible read, and the order proves the read followed the gesture rather than + // racing it. + assert.deepEqual( + runnerTranscript.calls.slice(callsBeforeScroll).map((call) => call.command), + ['ios.runner.scroll', 'ios.runner.snapshot'], + ); + + runnerTranscript.assertComplete(); + }, + ); +}); + +test('Provider-backed integration scroll down that never moves a hidden-content container refuses with scroll_no_progress', async () => { + const runnerTranscript = createProviderTranscript([ + snapshotEntry(screen(0, true)), // pre-scroll baseline + scrollEntry(), + // Every post-gesture read is byte-identical to the baseline and the container still reports + // hidden content below: the gesture never reached it. + repeatSnapshotEntry(screen(0, true)), + ]); + const appleRunnerProvider = createAppleRunnerProviderFromTranscript( + runnerTranscript, + 'ios.runner', + ); + const appleTool = iosSimulatorTool(); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + appleRunnerProvider: () => appleRunnerProvider, + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }), + async (daemon) => { + assertRpcOk(await daemon.callCommand('open', [APP], { platform: 'ios', udid: DEVICE_ID })); + assertRpcOk(await daemon.callCommand('snapshot')); + + const callsBeforeScroll = runnerTranscript.calls.length; + const scroll = await daemon.callCommand('scroll', ['down']); + const errorData = assertRpcError(scroll, 'COMMAND_FAILED', /scroll down moved nothing/); + assert.equal( + errorData.details && (errorData.details as Record).reason, + 'scroll_no_progress', + ); + + const callsDuringScroll = runnerTranscript.calls.slice(callsBeforeScroll); + // Gesture first, then at least the quiet-pair minimum of post-gesture snapshots — an exact + // count would assert the loop's polling speed rather than the refusal itself. + assert.equal(callsDuringScroll[0]?.command, 'ios.runner.scroll'); + assert.ok( + callsDuringScroll.filter((call) => call.command === 'ios.runner.snapshot').length >= 2, + `expected at least two post-gesture captures, saw ${callsDuringScroll.length - 1}`, + ); + }, + ); +}); From 4f4793e627d3275935b712f00a96d345648e7d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:09:11 +0200 Subject: [PATCH 2/7] fix(capture-kit): judge a late post-gesture capture instead of ending stalled The post-gesture capture hook takes no signal, so declaring 'cancel' let the loop end stalled on a capture that finished past its deadline and rethrow it, where main judged the late capture. The schedule now uses the default 'none'. Every non-done end maps to main's outcome: the stabilization-timeout warning and the last value, marked unsettled unless its pair agreed. A capture error is rethrown as itself. The loop takes an optional clock for its elapsed-time reads and the engine. Tests with an injected clock pin a capture that overruns the remaining budget (judged, unsettled, one warning) and a first capture slower than the budget (still forms the quiet pair); both fail with the schedule switched to 'cancel'. --- .../src/post-gesture-stability.test.ts | 119 ++++++++++++++++++ .../capture-kit/src/post-gesture-stability.ts | 16 ++- 2 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 packages/capture-kit/src/post-gesture-stability.test.ts diff --git a/packages/capture-kit/src/post-gesture-stability.test.ts b/packages/capture-kit/src/post-gesture-stability.test.ts new file mode 100644 index 0000000000..ffb2bbe3d6 --- /dev/null +++ b/packages/capture-kit/src/post-gesture-stability.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { ObservationClock } from './observe-until.ts'; +import { + runPostGestureStabilityLoop, + type PostGestureStabilityHooks, +} from './post-gesture-stability.ts'; + +vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, emitDiagnostic: vi.fn() }; +}); + +beforeEach(() => { + vi.mocked(emitDiagnostic).mockClear(); +}); + +type Surface = Readonly<{ signature: readonly string[]; costMs: number }>; + +function fakeClock(): ObservationClock & { advance(ms: number): void } { + let nowMs = 0; + return { + now: () => nowMs, + advance: (ms) => { + nowMs += ms; + }, + sleep: async (ms) => { + nowMs += ms; + }, + }; +} + +/** Captures that each cost their own `costMs` of clock time and ignore any signal. */ +function hooksFor( + clock: ReturnType, + surfaces: readonly Surface[], +): PostGestureStabilityHooks { + let calls = 0; + const same = (a: readonly string[], b: readonly string[]) => a.join() === b.join(); + return { + capture: async () => { + const surface = surfaces[calls]; + calls += 1; + if (!surface) throw new Error('the loop captured more surfaces than the case supplied'); + clock.advance(surface.costMs); + return surface; + }, + readSurface: (value) => ({ signature: value.signature, backend: 'xctest' }), + signaturesStable: same, + classifyBaselineEvidence: (baseline, quiet) => (same(baseline, quiet) ? 'unchanged' : 'changed'), + surfacesIdentical: same, + summarizeDivergence: () => ({}), + }; +} + +const PENDING = { action: 'scroll', positionals: ['down'] }; + +function timeoutWarnings(): unknown[] { + return vi + .mocked(emitDiagnostic) + .mock.calls.filter(([event]) => event.phase === 'post_gesture_snapshot_stabilization_timeout'); +} + +test('a capture that runs past the remaining budget is judged and ends unsettled, not thrown', async () => { + const clock = fakeClock(); + const late: Surface = { signature: ['b'], costMs: 2_000 }; + + const outcome = await runPostGestureStabilityLoop({ + pending: PENDING, + needsBaselineDistrust: false, + hooks: hooksFor(clock, [{ signature: ['a'], costMs: 0 }, late]), + clock, + }); + + assert.equal(outcome.value, late); + assert.deepEqual(outcome.postGestureOutcome, { + kind: 'unsettled', + gesture: { action: 'scroll', positionals: ['down'] }, + }); + assert.equal(timeoutWarnings().length, 1); +}); + +test('a first capture slower than the whole budget still forms a quiet pair', async () => { + const clock = fakeClock(); + const quiet: Surface = { signature: ['a'], costMs: 0 }; + + const outcome = await runPostGestureStabilityLoop({ + pending: PENDING, + needsBaselineDistrust: false, + hooks: hooksFor(clock, [{ signature: ['a'], costMs: 2_000 }, quiet]), + clock, + }); + + assert.equal(outcome.value, quiet); + assert.equal(outcome.postGestureOutcome, undefined); + assert.equal(timeoutWarnings().length, 0); +}); + +test('a capture error ends the loop by rethrowing that error', async () => { + const clock = fakeClock(); + const failure = new Error('capture failed'); + const hooks = hooksFor(clock, []); + + await assert.rejects( + runPostGestureStabilityLoop({ + pending: PENDING, + needsBaselineDistrust: false, + hooks: { + ...hooks, + capture: async () => { + throw failure; + }, + }, + clock, + }), + (error) => error === failure, + ); +}); diff --git a/packages/capture-kit/src/post-gesture-stability.ts b/packages/capture-kit/src/post-gesture-stability.ts index dd1adac6ac..3204a84e7f 100644 --- a/packages/capture-kit/src/post-gesture-stability.ts +++ b/packages/capture-kit/src/post-gesture-stability.ts @@ -1,6 +1,6 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { PostGestureAction, PostGestureOutcome } from '@agent-device/kernel/snapshot'; -import { observeUntil, type ObservationSchedule } from './observe-until.ts'; +import { observeUntil, type ObservationClock, type ObservationSchedule } from './observe-until.ts'; /** * Pure post-gesture stability mechanics: the quiet-window verdict over the shared @@ -18,12 +18,13 @@ import { observeUntil, type ObservationSchedule } from './observe-until.ts'; * Cadence and budget for the quiet-window loop: poll every 200ms, allow 1.5s * before an unsettled surface times out, and always complete two observations * (an `initial` counts) so a quiet pair can form even under a tight budget. + * No per-capture deadline: `hooks.capture` takes no signal, so a late capture + * is judged when it returns. */ const POST_GESTURE_STABILITY_SCHEDULE: ObservationSchedule = { intervalMs: 200, budgetMs: 1_500, minPolls: 2, - captureDeadline: 'cancel', }; /** @@ -136,9 +137,11 @@ export async function runPostGestureStabilityLoop; + clock?: ObservationClock; }): Promise> { - const { pending, needsBaselineDistrust, hooks } = params; - const startedAt = Date.now(); + const { pending, needsBaselineDistrust, hooks, clock } = params; + const now = () => clock?.now() ?? Date.now(); + const startedAt = now(); let attempts = 0; let baselineSignature = pending.baselineSignature; let baselineBackend = pending.baselineBackend; @@ -158,6 +161,7 @@ export async function runPostGestureStabilityLoop hooks.capture(), schedule: POST_GESTURE_STABILITY_SCHEDULE, + ...(clock ? { clock } : {}), verdict: (latest, previousValue) => { attempts += 1; const current = surfaceOf(latest); @@ -167,7 +171,7 @@ export async function runPostGestureStabilityLoop Date: Wed, 30 Sep 2026 20:09:12 +0200 Subject: [PATCH 3/7] fix(daemon): judge a late post-scroll capture; keep the edge-rest analysis lazy The scroll capture takes no signal, so the movement schedule now uses the default 'none': a capture that finishes past the budget is judged, and only a budget that ends while the verdict still continues maps to budgetExpiredVerdict. A capture error is rethrown. The migration asked baselineEndsInDirection before the first capture. It is lazy again: the edge analysis and its dynamic import run only after a changed reading, as on main. Tests with an injected clock pin a first capture slower than the budget that shows movement (moved) and a later poll that outlives the budget (judged, moved); both fail with the schedule switched to 'cancel'. A third pins that an untouched surface never asks the edge question of the baseline. --- src/daemon/__tests__/scroll-movement.test.ts | 73 ++++++++++++++++++++ src/daemon/scroll-movement.ts | 41 +++++++---- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/daemon/__tests__/scroll-movement.test.ts b/src/daemon/__tests__/scroll-movement.test.ts index 5756f614d0..955a2019ed 100644 --- a/src/daemon/__tests__/scroll-movement.test.ts +++ b/src/daemon/__tests__/scroll-movement.test.ts @@ -6,6 +6,7 @@ import { AppError } from '@agent-device/kernel/errors'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { readScrollEdgeState } from '@agent-device/capture-kit/scroll-edge-state'; import { IOS_SIMULATOR, MACOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { expireRefFrame } from '../ref-frame.ts'; @@ -26,6 +27,11 @@ vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { return { ...actual, emitDiagnostic: vi.fn() }; }); +vi.mock('@agent-device/capture-kit/scroll-edge-state', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readScrollEdgeState: vi.fn(actual.readScrollEdgeState) }; +}); + const loggedDiagnostics = vi.mocked(emitDiagnostic); function movementDiagnostic(): { phase: string; level: string; data: Record } { @@ -177,6 +183,58 @@ test('a surface that no longer holds the pre-gesture content answers moved on th assert.equal(spy.calls(), 1); }); +/** A clock that moves only when the loop sleeps or a capture spends its own time. */ +function steppedClock() { + let nowMs = 0; + return { + now: () => nowMs, + advance: (ms: number) => { + nowMs += ms; + }, + sleep: async (ms: number) => { + nowMs += ms; + }, + }; +} + +/** Observes under the real 1.5 s budget and 200 ms cadence, with captures that cost clock time. */ +function observeTimed(frames: ReadonlyArray<{ nodes: SnapshotNode[]; costMs: number }>) { + const clock = steppedClock(); + let calls = 0; + loggedDiagnostics.mockClear(); + const observation = observeScrollMovement({ + direction: 'down', + baseline: baselineOf(screen(0)), + swipe: { midpoint: SWIPE_MIDPOINT, pixels: REQUESTED_PIXELS }, + capture: async (): Promise => { + const frame = frames[calls]; + calls += 1; + if (!frame) throw new Error('the observation captured more times than the case supplied'); + clock.advance(frame.costMs); + return { nodes: frame.nodes, backend: 'xctest', producer: 'apple-runner' }; + }, + clock, + }); + return { observation, calls: () => calls }; +} + +test('a post-scroll capture slower than the whole budget that shows movement answers moved', async () => { + const { observation, calls } = observeTimed([{ nodes: screen(-300), costMs: 2_000 }]); + + assert.equal(await observation, 'moved'); + assert.equal(calls(), 1); +}); + +test('a later poll that outlives the remaining budget is judged, not dropped', async () => { + const { observation, calls } = observeTimed([ + { nodes: screen(0), costMs: 0 }, + { nodes: screen(-300), costMs: 2_000 }, + ]); + + assert.equal(await observation, 'moved'); + assert.equal(calls(), 2); +}); + /** * At the end of a list iOS rubber-bands past the edge: the first capture lands mid-bounce with every row * shifted, then the content springs back to exactly the pre-gesture tree (#2884). A baseline that already @@ -309,6 +367,21 @@ test('a refusal without gesture coordinates does not recommend a swipe', async ( ); }); +test('an untouched surface never asks whether the baseline ended in the scrolled direction', async () => { + const baseline = baselineOf(screen(0, false)); + vi.mocked(readScrollEdgeState).mockClear(); + const { observation } = observe({ + baseline, + screens: [screen(0, false), screen(0, false)], + }); + + assert.equal(await observation, 'at-edge'); + const askedOfBaseline = vi + .mocked(readScrollEdgeState) + .mock.calls.filter(([nodes]) => nodes === baseline.nodes); + assert.deepEqual(askedOfBaseline, []); +}); + test('a surface that never shifted with nothing left to reveal answers at-edge, not a refusal', async () => { const { observation } = observe({ baseline: baselineOf(screen(0)), diff --git a/src/daemon/scroll-movement.ts b/src/daemon/scroll-movement.ts index 27caca2a30..cd70a3d3d2 100644 --- a/src/daemon/scroll-movement.ts +++ b/src/daemon/scroll-movement.ts @@ -14,7 +14,11 @@ import { containsPoint } from '@agent-device/kernel/rect'; import { AppError } from '@agent-device/kernel/errors'; import type { Point, Rect, SnapshotState } from '@agent-device/kernel/snapshot'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { observeUntil, type ObservationSchedule } from '@agent-device/capture-kit/observe-until'; +import { + observeUntil, + type ObservationClock, + type ObservationSchedule, +} from '@agent-device/capture-kit/observe-until'; import { areInteractionSurfaceSignaturesStable, buildInteractionSurfaceSignature, @@ -61,11 +65,13 @@ import type { SessionState } from './session-state.ts'; * lineages can never answer either way and the claim is withheld rather than re-based. */ -/** How long a scroll keeps asking whether an untouched surface is really untouched (#1542's window). */ +/** + * How long a scroll keeps asking whether an untouched surface is really untouched (#1542's window). + * No per-capture deadline: the scroll capture takes no signal, so a late capture is judged. + */ const SCROLL_MOVEMENT_SCHEDULE: ObservationSchedule = { intervalMs: 200, budgetMs: 1_500, - captureDeadline: 'cancel', }; /** The pre-gesture surface, already in hand: no capture is spent to produce it. */ @@ -190,6 +196,7 @@ export async function observeScrollMovement(params: { /** The same two overrides `pollForScrollRest` takes: how long to ask, and how often. */ budgetMs?: number; pollMs?: number; + clock?: ObservationClock; }): Promise { const { direction, baseline, swipe } = params; const verdict = await pollForSurfaceVerdict(baseline, params); @@ -264,32 +271,40 @@ async function pollForSurfaceVerdict( budgetMs?: number; pollMs?: number; swipe: ScrollSwipeEvidence; + clock?: ObservationClock; }, ): Promise { - // The edge question is asked only of the pre-gesture (baseline) tree, so it never depends on a - // poll's outcome and can be answered once, before the loop starts, rather than lazily on the - // first `changed` reading. - const changeNeedsRest = await baselineEndsInDirection(baseline, params.direction, params.swipe); let previous: InteractionSurfaceSignature | undefined; + let changeNeedsRest: boolean | undefined; const observed = await observeUntil({ - capture: () => readOneCapture(baseline, params.capture), + capture: async () => { + const reading = await readOneCapture(baseline, params.capture); + if (reading.kind === 'changed') { + changeNeedsRest ??= await baselineEndsInDirection(baseline, params.direction, params.swipe); + } + return reading; + }, schedule: { intervalMs: params.pollMs ?? SCROLL_MOVEMENT_SCHEDULE.intervalMs, budgetMs: params.budgetMs ?? SCROLL_MOVEMENT_SCHEDULE.budgetMs, - captureDeadline: SCROLL_MOVEMENT_SCHEDULE.captureDeadline, }, verdict: (latest) => { if (latest.kind === 'blind') return { kind: 'done', result: { kind: 'blind', reason: latest.reason } }; - const judged = settledVerdict(latest, { previous, changeNeedsRest }); + const judged = settledVerdict(latest, { + previous, + changeNeedsRest: changeNeedsRest === true, + }); previous = latest.observed.signature; return judged ? { kind: 'done', result: judged } : { kind: 'continue' }; }, + ...(params.clock ? { clock: params.clock } : {}), }); const attempts = observed.polls.length; + if (observed.kind === 'failed') throw observed.error; + if (observed.kind !== 'done') return budgetExpiredVerdict(params, attempts, observed.waitedMs); const startedAt = Date.now() - observed.waitedMs; - if (observed.kind !== 'done') return budgetExpiredVerdict(params, attempts, startedAt); return observed.result.kind === 'blind' ? observed.result : { ...observed.result, attempts, startedAt }; @@ -382,7 +397,7 @@ function budgetExpiredVerdict( swipe: ScrollSwipeEvidence; }, attempts: number, - startedAt: number, + durationMs: number, ): SurfaceVerdict { emitDiagnostic({ level: 'warn', @@ -390,7 +405,7 @@ function budgetExpiredVerdict( data: { direction: params.direction, attempts, - durationMs: Date.now() - startedAt, + durationMs, ...(params.swipe.pixels === undefined ? {} : { requestedPixels: params.swipe.pixels }), }, }); From ce509a9e0d89c4ebb6b34a7657e003482260b6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:09:21 +0200 Subject: [PATCH 4/7] test(provider-scenarios): keep the Simulator AX bridge off the host in the scroll scenario The scroll scenario stubs launchctl and ps so the snapshot route resolves one stable app target. That also makes every capture eligible for the Simulator AX bridge, which no scenario provider scripts: the bridge source runs xcrun on the host, builds or locates its binary, spawns it with simctl, and retries its socket until the bridge deadline before the route falls back to the scripted runner. On a host with Xcode that cost more than a second per test, and under the full provider-integration run both tests timed out at 5 s; the scroll loop itself took 8 ms and 204 ms. The scenario now mocks the bridge source to report unsupported at once, as it does on a host without Xcode, so every capture takes the scripted runner. The file runs in 1.5 to 1.8 s in the full project run. --- .../scroll-movement-observation.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/integration/provider-scenarios/scroll-movement-observation.test.ts b/test/integration/provider-scenarios/scroll-movement-observation.test.ts index e56e2808a6..b5b2c92211 100644 --- a/test/integration/provider-scenarios/scroll-movement-observation.test.ts +++ b/test/integration/provider-scenarios/scroll-movement-observation.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import type { SimulatorSnapshotSource } from '../../../packages/platform-apple/src/snapshot-source-facade.ts'; import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; import { assertRpcError, assertRpcOk } from './assertions.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; @@ -12,6 +13,23 @@ import { } from './providers.ts'; import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; +// The Simulator AX bridge runs on the host toolchain, outside every provider this scenario scripts: +// on a host with Xcode it builds, spawns, and connects for real before failing, which costs each +// test seconds of wall time. Here it reports unavailable at once, so every capture takes the +// scripted runner, as it does on a host without Xcode. +vi.mock( + '../../../packages/platform-apple/src/snapshot-source-facade.ts', + (): { createSimulatorSnapshotSource: () => SimulatorSnapshotSource } => ({ + createSimulatorSnapshotSource: () => ({ + acquire: async () => ({ + stage: 'failed', + failure: { kind: 'unsupported', code: 'provider-scenario-no-bridge' }, + }), + close: async () => {}, + }), + }), +); + const APP = 'com.example.app'; const DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id; From a764d758232f2cfc0893d1d20c3f310c7a88d0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:13:00 +0200 Subject: [PATCH 5/7] test(provider-scenarios): mock the Simulator AX bridge through its package subpath --- .../provider-scenarios/scroll-movement-observation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/provider-scenarios/scroll-movement-observation.test.ts b/test/integration/provider-scenarios/scroll-movement-observation.test.ts index b5b2c92211..1a71568b22 100644 --- a/test/integration/provider-scenarios/scroll-movement-observation.test.ts +++ b/test/integration/provider-scenarios/scroll-movement-observation.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; -import type { SimulatorSnapshotSource } from '../../../packages/platform-apple/src/snapshot-source-facade.ts'; +import type { SimulatorSnapshotSource } from '@agent-device/platform-apple/snapshot-source'; import type { ExecOptions, ExecResult } from '@agent-device/host-kit/command'; import { assertRpcError, assertRpcOk } from './assertions.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; @@ -18,7 +18,7 @@ import { createProviderTranscript, type ProviderScenarioProviderEntry } from './ // test seconds of wall time. Here it reports unavailable at once, so every capture takes the // scripted runner, as it does on a host without Xcode. vi.mock( - '../../../packages/platform-apple/src/snapshot-source-facade.ts', + '@agent-device/platform-apple/snapshot-source', (): { createSimulatorSnapshotSource: () => SimulatorSnapshotSource } => ({ createSimulatorSnapshotSource: () => ({ acquire: async () => ({ From 249fb956f9d5552a92e8efb8d7d98ee7bd9ca989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:15:35 +0200 Subject: [PATCH 6/7] docs(contracts): state which deferred outcome marks the tap paths set The outcomeObservation waiver said the deferred-outcome mark applied only to navigation-sensitive Android actions and target-authored gestures. The tap paths finalize through finalizeTouchInteraction, which calls markDeferredInteractionOutcome with the tap point: press/click get the Android snapshot-freshness mark, the no-change tap retry when the request sets interactionOutcome.retryOnNoChange, and post-gesture stabilization when the request sets postGestureStabilization. Target drag (gesture drag) gets none. The waiver now names these marks and says the next capture judges them, never the tap's own response. The coordinate cell said inapplicable, but coordinate press/click reach the same finalize call with the same marks and accept --verify/--settle. It now shares the runtime paths' gap waiver, and the bounded gap list gains coordinate/outcomeObservation. --- packages/contracts/src/interaction-guarantees.ts | 16 +++++----------- .../contracts/interaction-guarantees.test.ts | 1 + 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/contracts/src/interaction-guarantees.ts b/packages/contracts/src/interaction-guarantees.ts index 0410b9eae4..e80127d94b 100644 --- a/packages/contracts/src/interaction-guarantees.ts +++ b/packages/contracts/src/interaction-guarantees.ts @@ -159,15 +159,13 @@ const SHARED_RESPONSE_CONSTRUCTION: GuaranteeEnforcement = { via: 'src/daemon/interaction/internal/interaction-touch-response.ts#buildInteractionResponseData', }; -// runtime-selector and runtime-ref share this waiver by construction: neither path observes the tap -// outcome by default. The deferred/corroborated-failure outcome mark (a recorded XCTest failure -// after a tap is an ambiguous outcome, per docs/agents/selector-capture.md) is scoped to -// navigation-sensitive Android actions and target-authored gestures, not the tap-shaped runtime -// paths; --settle/--verify are the opt-in ways to observe one. +// runtime-selector, runtime-ref, and coordinate share this waiver: all three finalize through +// finalizeTouchInteraction, which calls markDeferredInteractionOutcome with the tap point, and none +// observes the outcome in its own response. const TAP_OUTCOME_NOT_OBSERVED_GAP: GuaranteeEnforcement = { kind: 'waived', reason: - 'gap: neither path observes the tap outcome by default; only --settle/--verify capture post-action evidence, and the deferred/corroborated-failure outcome mark applies only to navigation-sensitive Android actions and target-authored gestures.', + 'gap: the response reports the dispatch only; only opt-in --verify/--settle capture post-action evidence into it. The deferred marks set after dispatch (Android snapshot freshness after press/click, the no-change tap retry when the request sets interactionOutcome.retryOnNoChange, post-gesture stabilization when the request sets postGestureStabilization) are judged by the next capture, never in this response, and the iOS ambiguous-failure corroboration reconsiders only a thrown runner error.', trackingIssue: GAPS_UMBRELLA_ISSUE, }; @@ -539,11 +537,7 @@ export const INTERACTION_DISPATCH_PATHS: Record { // updates it here with a linked issue. It is the diffable debt list // (umbrella: https://github.com/callstack/agent-device/issues/1081). assert.deepEqual(gaps.sort(), [ + 'coordinate/outcomeObservation', 'maestro-direct-selector/errorTaxonomy', 'maestro-direct-selector/outcomeObservation', 'maestro-direct-selector/parentOwnedTouchPoint', From dfe962938346b912c67a11f7cc16c83fff7c06d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 22:09:25 +0200 Subject: [PATCH 7/7] chore(gates): format the two adapter test files --- packages/capture-kit/src/post-gesture-stability.test.ts | 3 ++- src/daemon/__tests__/scroll-movement.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/capture-kit/src/post-gesture-stability.test.ts b/packages/capture-kit/src/post-gesture-stability.test.ts index ffb2bbe3d6..03ee9dc592 100644 --- a/packages/capture-kit/src/post-gesture-stability.test.ts +++ b/packages/capture-kit/src/post-gesture-stability.test.ts @@ -48,7 +48,8 @@ function hooksFor( }, readSurface: (value) => ({ signature: value.signature, backend: 'xctest' }), signaturesStable: same, - classifyBaselineEvidence: (baseline, quiet) => (same(baseline, quiet) ? 'unchanged' : 'changed'), + classifyBaselineEvidence: (baseline, quiet) => + same(baseline, quiet) ? 'unchanged' : 'changed', surfacesIdentical: same, summarizeDivergence: () => ({}), }; diff --git a/src/daemon/__tests__/scroll-movement.test.ts b/src/daemon/__tests__/scroll-movement.test.ts index 955a2019ed..5eb444ac58 100644 --- a/src/daemon/__tests__/scroll-movement.test.ts +++ b/src/daemon/__tests__/scroll-movement.test.ts @@ -28,7 +28,8 @@ vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { }); vi.mock('@agent-device/capture-kit/scroll-edge-state', async (importOriginal) => { - const actual = await importOriginal(); + const actual = + await importOriginal(); return { ...actual, readScrollEdgeState: vi.fn(actual.readScrollEdgeState) }; });