fix(daemon): distrust post-gesture stability that matches the pre-gesture baseline - #1563
Conversation
…ture baseline #1542 defect 2: post-gesture-stabilization.ts treated two consecutive matching AX-signature polls as proof the screen settled. On iOS's AX-free synthesized gesture lane, XCTest's tree can serve a stale-but-internally- consistent read for a window after a scroll/swipe, so that "match" can be false: the daemon then evaluates pre-gesture node positions on the very next interaction. Fix: capture the interaction-surface signature before the gesture dispatches (reusing session.snapshot, no extra capture), and when a quiet poll-to-poll match still equals that baseline, don't trust it — keep polling past the normal 1.5s deadline up to a bounded 3.5s cap. On cap expiry with the signature still identical, accept the result (a genuine no-op gesture is the honest answer) but flag it via a new post_gesture_snapshot_stale_accept diagnostic so a stale-accept is observable in ndjson. Baseline comparison is subset-tolerant (interactionSurfaceMatchesBaseline) rather than whole-array equality: the pre-gesture baseline and the post-gesture capture are routinely fetched with different snapshot scopes, so naive equality reported "changed" from scope drift alone and never caught the real staleness on first implementation — live-verified and fixed before shipping. Platform-scoped to Apple only (requiresPostGestureBaselineDistrust): Android's persistent helper clears its accessibility-node cache before every capture (AccessibilityTreeCapture.capture, #1254/#1259), so an Android post-gesture read is fresh by construction and never computes a baseline signature — latency and semantics unchanged, confirmed live (checkout-form-android.ad + gesture-lab-android.ad 2/2 on Pixel_7_CI). Does not close #1542: live validation on checkout-form.ad still fails at step 11, but now for a distinct reason this fix correctly surfaces rather than causes — a corrupted ScrollView-ancestor viewport frame ((18,381,366,109) vs the true (18,62,366,729)) that the off-screen guard's findNearestScrollableAncestorRect trusts, independent of whether the signature matches the baseline. gesture-lab.ad (iOS) remains 2/2 clean, confirming no regression on the passing scenario. _Generated by Claude Code_
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
…capture pair The distrust integration pushed capturePostGestureStabilizedResult over the complexity gate (cyclomatic 15, cognitive 24); extracting the settle-diagnostic branching and the capture+signature pair restores a clean fallow pass with no behavior change.
P1 — Do not treat a root-only overlap as stale-baseline evidence
The new scope-drift tests keep |
…ne match PR review on #1563 (P1): interactionSurfaceMatchesBaseline returned true whenever ANY shared entry was frozen, including the application/window viewport root, whose rect is invariant under any gesture. In the exact scope-drift case this PR supports, a broad pre-gesture baseline and a narrow post-gesture selector capture can share only that root after a real, successful scroll — the boolean predicate called that a baseline match and extended the interaction to the 3.5s stale-read cap on zero real evidence. Fix: replace the boolean with classifyBaselineSurfaceEvidence, a subset-tolerant classifier reusing this module's existing InteractionSurfaceChange vocabulary ('changed' | 'unchanged' | 'ambiguous') instead of a bespoke boolean or an Application-only special case. An entry only counts as evidence when it is `discriminating` — excludes the viewport root (minimal local equivalent of snapshot-occlusion.ts's isViewportRoot) and keyboard chrome (minimal local equivalent of snapshot-chrome.ts's keyboard-container check), both computed once at signature-build time since the flat signature-entry representation has no ref/parentIndex to reuse those modules' full ancestor-walk classifiers directly. Zero discriminating overlap is now 'ambiguous' (insufficient evidence) rather than a match, and decidePostGestureStabilityVerdict falls through 'ambiguous' to 'trust' — the safe default, same as 'changed'. Tests: the reviewer's exact shape (signatures sharing only the Application root, with the real content swapped) at three layers — classifyBaselineSurfaceEvidence directly, decidePostGestureStabilityVerdict, and the full capturePostGestureStabilizedResult async loop (proving no cap-tax: settles in 2 capture attempts, not 3.5s). Also: root+one real element both frozen still matches (guards against over-excluding), and keyboard chrome excluded from discriminating overlap. All prior tests kept green unchanged. Counterfactual: reverted to the old boolean predicate and reran — 5 tests went red, including the async regression test, which didn't just fail an assertion but timed out after 5s because the boolean predicate extended the interaction to the 3.5s distrust cap the test's 1s timer advance never covered — exactly the "extends to cap" failure mode the review predicted. Restored, 37/37 green. _Generated by Claude Code_
|
Fixed in 191c351, replacing the previous fix (295367b) rather than patching it — the review's design correction (three-valued classifier, not a boolean with an Application special-case) landed as specified. What changed
if (!needsBaselineDistrust || !baselineSignature?.length) return 'trust';
if (classifyBaselineSurfaceEvidence(baselineSignature, quietSignature) !== 'unchanged') return 'trust';
return elapsedMs < distrustCapMs ? 'distrust' : 'accept-stale';Discriminating is computed once per signature entry at build time (
Tests37 tests (interaction-outcome-policy.test.ts 17, post-gesture-stabilization.test.ts 20), including the reviewer's exact shape at three layers:
All prior tests (whole-array scope-drift, tiny-drift tolerance, genuine movement, frozen-target regression) kept green unchanged. CounterfactualReverted The async test didn't just fail an assertion — it timed out, because the boolean predicate genuinely extended the interaction to the 3.5s distrust cap that the test's 1s fake-timer advance never covers. That's the concrete "extends to cap" failure the review predicted. Restored, 37/37 green. Gates
Generated by Claude Code |
|
Reviewed exact head
The intended frozen-baseline route and counterfactual are otherwise meaningful, and the supplied device evidence supports that narrower fix. |
…om baseline evidence PR review on #1563 (two findings, blocking merge): 1. isKeyboardChromeKind excluded only the [Keyboard] container node itself. collectKeyboardChrome (src/core/snapshot-chrome.ts, the established source of truth) classifies the WHOLE keyboard window/subtree — keys, AND the "Next keyboard"/"Dictate" assistant buttons, which are documented siblings of the container, not descendants, so a container-descendant walk alone provably misses them. In the scope-drift case this PR supports, a successful scroll can leave only those keyboard descendants shared between a baseline and a later capture, and the narrower check called that a baseline match — extending a fresh result to the 3.5s stale-read cap. Fixed by exporting a narrow predicate, collectKeyboardChromeRefs(nodes), from snapshot-chrome.ts (returns collectKeyboardChrome(nodes).refs, no Android union — this caller has no appBundleId in scope and only needs the iOS half). buildInteractionSurfaceSignature computes it once per signature build and threads it into buildInteractionSurfaceEntry, so discriminating is now `!isViewportRootKind(node) && !keyboardChromeRefs .has(node.ref)` — reusing the real ancestor-walk classification instead of a per-node type check, no ancestry needed in the signature entries themselves. 2. post-gesture-stabilization.test.ts had grown to 550 LOC, past the repository's 500-line extraction tripwire (AGENTS.md: "past 500, extract before adding behavior... Tests are not exempt"). Split along subject lines: the pure decidePostGestureStabilityVerdict coverage moved to a new sibling post-gesture-stabilization-verdict.test.ts, and shared fixtures (pickupSnapshot, deliverySnapshot, applicationRootNode, keyboardWindowNodes, makeSession) moved to a new non-test post-gesture-stabilization-fixtures.ts. The async capturePostGesture- StabilizedResult loop tests stay in the original file. Assertions unchanged, only relocation, plus the new regression tests below. Resulting LOC: post-gesture-stabilization.test.ts 381, -verdict.test.ts 208, -fixtures.ts 129 (interaction-outcome-policy.test.ts grew to 413, still under the tripwire). Tests: the reviewer's exact regression — a shared overlap consisting only of keyboard descendants (a key + the "Next keyboard" sibling button, NOT the container) plus real content that changed (Pickup -> Delivery) — at three layers: classifyBaselineSurfaceEvidence directly (ambiguous), the verdict function (trust, elapsedMs: 0), and the full async capture loop (settles in 2 attempts, no cap tax). Counterfactual: reverted isNonDiscriminatingSurfaceNode to a container-only check (normalizeType(node.type) === 'keyboard') and reran — 3 of the new tests went red across all three layers, including the async test, which timed out after 5s (not just a failed assertion) because the container-only exclusion genuinely extended the interaction to the 3.5s distrust cap the test's 1s timer advance never covers — the same "extends to cap" failure shape as the review's finding 1. Restored, 40/40 green. _Generated by [Claude Code](https://claude.ai/code)_
|
Both findings fixed in 12a7cae. Finding 1: keyboard descendants, not just the container
Fixed by reusing the real source of truth rather than retaining ancestry in the signature: exported discriminating: !isViewportRootKind(node) && !(node.ref !== undefined && keyboardChromeRefs.has(node.ref))No new export needed beyond the one narrow predicate; picked the smaller of the two options since Finding 2: test file split
Assertions unchanged, only relocation (plus the new tests below). TestsThe reviewer's exact regression at three layers — a shared overlap consisting only of keyboard descendants (a key + the "Next keyboard" sibling button, not the container) plus real content that changed (Pickup → Delivery, a genuine successful scroll):
CounterfactualReverted Same shape as the first review's counterfactual: the async test didn't just fail an assertion, it timed out, because the container-only exclusion genuinely extended the interaction to the 3.5s distrust cap that the test's 1s fake-timer advance never covers. Restored, 40/40 green. Gates
Generated by Claude Code |
|
Re-reviewed exact head One readiness item remains: the new keyboard-visible scope-drift path is fixture-only; the cited iPhone run had the hardware keyboard off. Please run one iOS software-keyboard-visible scroll that changes app content and confirm it settles in two captures without |
Keyboard-visible live evidence (finding requested in re-review at 12a7cae)Live run on iPhone 17 simulator (D74E0B66-57EB-4EC1-92DC-DA0A30581FE7, same-generation substitute — iPhone 17 Pro was shut down/not in this session), 1. Software keyboard visible before the gestureTapped
2. Scroll while the keyboard stayed up, changing real app content
Node-level proof the content genuinely moved (not just visually — same elements, real rect shift, uniform ~920px scroll offset):
The post-scroll 3. Pass condition — verbatim ndjsonThe very next snapshot capture after the scroll ( {"ts":"2026-08-03T12:18:47.663Z","level":"debug","phase":"post_gesture_snapshot_stabilized","session":"cwd:2b4bc58ea6d67612:default","requestId":"11de0a88a23067bd","command":"snapshot","data":{"action":"scroll","attempts":2,"durationMs":505}}
No cap tax, no stale-accept, on the exact keyboard-visible scope-drift path the fix targets. ArtifactsPreserved outside the worktree at Generated by Claude Code |
|
Summary
#1542 defect 2 (documented in #1559's body, not implemented there pending sign-off):
src/daemon/post-gesture-stabilization.tsmarks scroll/swipe actions for stabilization, then treats two consecutive matching AX-signature polls as proof the screen settled. On iOS's AX-free synthesized gesture lane, XCTest's tree can serve a stale-but-internally-consistent read for a window after the gesture — every poll matches the previous one, "stabilization" declares victory almost immediately, and the next interaction evaluates pre-gesture node positions.Mechanism
markPostGestureStabilizationnow captures the interaction-surface signature fromsession.snapshot— the last-known pre-gesture capture, already available at the call site (this fires post-dispatch, pre-capture) — no extra capture added to the hot path.decidePostGestureStabilityVerdict(pure:{needsBaselineDistrust, baselineSignature, quietSignature, elapsedMs, distrustCapMs} → 'trust' | 'distrust' | 'accept-stale') fires only once a quiet poll-to-poll match is already observed. If that quiet signature still matches the pre-gesture baseline, it is not trusted — polling continues past the normal 1.5s deadline up to a bounded 3.5s cap (STABILIZATION_DEADLINE_MS + 2_000, real margin over both the 200ms poll interval and the normal deadline — see the repo's own zero-margin flake history before touching these numbers). On cap expiry with the signature still identical, the result is accepted (a genuine no-op gesture, e.g. scroll at an edge, is the honest answer) but flagged via a newpost_gesture_snapshot_stale_acceptdiagnostic (matchedPreGestureBaseline: true) so a stale-accept is observable in ndjson.interactionSurfaceMatchesBaselineinsrc/daemon/interaction-outcome-policy.ts): the pre-gesture baseline and the post-gesture capture are routinely fetched with different snapshot scopes (e.g. a broadwait-driven capture vs. an interactive-only selector-resolution capture), so naive whole-array equality reports "changed" from scope drift alone. My first implementation used whole-array equality and it failed on the very first live run for exactly this reason (verdict was alwaystruston the first quiet match, never once catching the real staleness) — root-caused and fixed before shipping, see Adjudication below.Platform scoping
Gated to Apple only via
requiresPostGestureBaselineDistrust(isApplePlatform(device.platform)). Android's persistent helper clears its accessibility-node cache before every capture —android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java'scapture()callsclearAccessibilityCache()(UiAutomation.clearCache()on API 34+,setServiceInfo(getServiceInfo())fallback below it) unconditionally before every traversal, for both one-shot and persistent-session capture — so an Android post-gesture read is fresh by construction and never needs this check (#1254/#1259). Android never computes abaselineSignatureat all (markPostGestureStabilizationskips it), so this fix costs Android nothing — latency and semantics unchanged, live-confirmed below.Counterfactual proof (docs/agents/testing.md)
Neutralized
interactionSurfaceMatchesBaselinetoreturn falseunconditionally (reproducing "no comparable baseline evidence, ever") and reran the owning test files. 8 of 31 tests went red, including the async integration test that pins the exact live regression:Restored the fix, reran — 31/31 green.
Live validation and adjudication
Ran on iPhone 17 (D74E0B66 — iPhone 17 Pro was lease-held; same-generation substitute, precedent from prior sessions), hardware keyboard off,
pnpm build && pnpm clean:daemon, explicit--state-dir.gesture-lab.ad(iOS, fresh-boot)checkout-form-android.ad+gesture-lab-android.ad(Pixel_7_CI, Release APK, freezer disabled)checkout-form.ad(iOS, fresh-boot)The fix demonstrably catches the literal defect described in #1559: one fresh-boot run of
checkout-form.adshowed the entire post-gesture signature frozen at the exact pre-gesture baseline for the full distrust window, correctly flagged rather than silently trusted:{"level":"warn","phase":"post_gesture_snapshot_stale_accept","command":"click","data":{"action":"scroll","attempts":45,"durationMs":21542,"matchedPreGestureBaseline":true}}(That run used a temporarily-widened 22s cap purely to test whether more time ever resolves it — it didn't, which is why the shipped cap stays at the reasoned 3.5s: a bigger bound doesn't help this specific staleness, it only costs latency on every genuinely inert gesture.)
But
checkout-form.adstill fails, for a different reason this fix correctly stops masking. A second fresh-boot repro resynced within 1.5s and differed from the pre-gesture baseline (post_gesture_snapshot_stabilized, attempts:3, durationMs:1566— correctlytrust, not distrusted) — yet the click still failed "off-screen". The raw errordetailsshow why:rect(the "Pickup" button) is correct — it matches the screenshot and the button's own post-scroll position.viewport— the nearest scrollable ancestor's own frame, read byfindNearestScrollableAncestorRect/resolveEffectiveViewportRectinsrc/snapshot/mobile-snapshot-semantics.tsand treated as authoritative by the off-screen guard — is corrupted: the "Checkout form" ScrollView's frame reads(18, 381.0, 366, 109.33)post-gesture, when its true, pre-gesture frame (captured moments earlier via a cleansnapshot -i) was(18, 62, 366, 729). A ScrollView's own frame does not change size when its content scrolls — this is neither the correct value nor the pre-gesture baseline value, so my distrust check (which only ever compares against the pre-gesture baseline) cannot catch it by construction: it's a third, wrong value. A same-selector retry immediately after (oncepostGestureStabilizationclears and the daemon takes the direct-XCUIElement fast path indirect-ios-selector.ts, which bypasses the bulk snapshot walk entirely) resolves the tap instantly and correctly, confirming per-element direct reads are reliable and the corruption is specific to the bulk-snapshot-derived ScrollView ancestor frame.Evidence preserved outside the worktree at
/private/tmp/ad-defect2-artifacts/:manual-probe/before-scroll.png,after-scroll-0ms.png— screenshots proving the gesture visibly works (refutes an inert-gesture explanation)manual-probe/pre-scroll-tree.json,post-scroll-tree.json— full snapshot pairs showing the ScrollView ancestor's frame corruption alongside correct child positionsios-diag/— the 45-attempt/21.8s frozen-baseline capture (quoted above)ios-run1/,ios-run2/— checkout-form.ad fresh-boot failures with this fix appliedios-gesture-lab-run1/,ios-gesture-lab-run2/— gesture-lab.ad 2/2android-run2/— Android suite 2/2Related: #1542 — deliberately not closed here. This fix is correct and safe as far as it goes (catches the literal frozen-tree case, zero Android cost, no regression on the passing scenario), but
checkout-form.adstill does not pass, so #1542 stays open on its own merits. The residual blocker — the corrupted ScrollView-ancestor-frame readfindNearestScrollableAncestorRecttrusts — is a distinct defect from the one this PR addresses; a separate refusal-double-check fix (re-verifying an off-screen rejection through the direct-selector path before failing, or an equivalent) is being scoped separately.Follow-ups
src/daemon/post-gesture-stabilization.tsnorsrc/daemon/interaction-outcome-policy.tsis inscripts/mutation/modules.ts'sKERNEL_MODULES, sopnpm mutation:runwas not run against them per the task's own instruction not to add lane membership unilaterally. Worth considering given both now house pure agent-facing decision functions.Gates run
pnpm typecheck && pnpm lint && pnpm format:check && pnpm check:layering && npx vitest run src/daemon/__tests__/post-gesture-stabilization.test.ts src/daemon/__tests__/interaction-outcome-policy.test.ts— all clean, chained in one run before pushing. Fullnpx vitest run(5363 tests) also run earlier in this branch's history: green except the pre-existingsrc/platforms/apple/core/__tests__/apps.test.tscontention-retry file (isolated rerun 54/54 green, unrelated to this diff).Generated by Claude Code