From 0bfb59f63f740b79a213c90296cf635e0cb3011a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 18:26:29 +0200 Subject: [PATCH 1/5] fix: unify divergence screen capture with snapshot's full-window scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route captureDivergenceObservation through captureSnapshotData — the same function the snapshot command itself builds its capture with (Android's snapshot-helper full-window route with its graceful app-scoped fallback, iOS's bounded system-modal probe path, macOS/Linux surface-scoped branches) — instead of a parallel hand-rolled dispatchCommand call. The chrome filter and meaningful-target filter stay layered on top as filters over that full capture, never as a scoping. Amends ADR-0012 decision 4 to state the invariant: an agent must never see a healthier `screen` in a divergence report than a plain `snapshot` would show it, so a separate-window system overlay (volume dialog, quick-settings shade, permission dialog) must survive into `screen.refs` exactly as `snapshot` would present it. Also fixes the synthetic `volume_dialog_slider` id in snapshot-chrome-android-statusbar.test.ts to the real, live-verified `volume_new_ringer_active_icon_container` id and rewords the test comment to read as a filter-logic unit test rather than a live-capture-path claim, and adds unit coverage for the invariant itself. Fixes #1264 --- docs/adr/0012-interactive-replay.md | 19 ++++ .../snapshot-chrome-android-statusbar.test.ts | 29 +++--- .../session-replay-divergence.test.ts | 90 +++++++++++++++++++ .../handlers/session-replay-divergence.ts | 40 ++++----- 4 files changed, 146 insertions(+), 32 deletions(-) diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index 6d7180d37..cac9145fe 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -12,6 +12,9 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig `REPLAY_DIVERGENCE` repair-loop fix (#1223). - Decision 6, the base agent-supervised re-record repair — `replay --save-script` arming, the post-watermark healed slice, `repairHint`, and the writer's bare-`@ref` fail-loud guard (#1228). +- Decision 4 amendment, `screen`'s capture scope — the divergence `screen` capture is unified with + `snapshot`'s own full-window scope, with the chrome and meaningful-target filters layered on top as + filters, not scopings (#1264). **Accepted but NOT yet implemented** (this amendment; tracked by #1235 — repair-transaction lifecycle): the R7 repair-transaction keep-alive and its distinct `resume.repairSessionHeld` signal, the ARMED → @@ -438,6 +441,22 @@ healthy snapshot digest and the only form that issues actionable refs. `{ state: hint }` is returned when capture fails or is sparse; it has no refs or generation and must not fall back to the old session tree. Screen-capture failure never replaces or masks the original replay cause. +> **Amendment (#1264): `screen`'s capture scope.** `refs` is a filtered digest of the exact same tree a +> plain `snapshot` would return at that moment — the capture underneath `screen` is built with `snapshot`'s +> own full-window scope (Android: the snapshot-helper route, with the existing graceful app-scoped fallback +> only when the helper is unavailable; iOS: the same capture semantics as the bounded system-modal probe +> path; macOS/Linux: their surface-scoped branches), never a narrower, app-window-only capture assembled +> separately for divergence reporting. The chrome filter (#1233/#1256, `collectSettleChromeRefs`) and the +> meaningful-target filter (label/id or `hittable`) are layered ON TOP of that full capture as **filters**, +> not as a separate, narrower scoping — a filter may drop a node the full capture contains, but the capture +> itself must never omit content `snapshot` would show. This is a hard invariant: **an agent must never see +> a healthier `screen` in a divergence report than a plain `snapshot` would show it.** Concretely, a +> separate-window system overlay covering the app at the moment of capture — a held volume dialog, a +> persistent quick-settings shade, a permission dialog — must appear in `screen.refs` (its actionable/ +> hittable/labeled nodes surviving the filters) exactly as `snapshot` would present it, and `repairHint` +> (decision 6) is computed over that same full, correctly-scoped capture so it is never routed as if the +> app underneath a covering overlay were healthy. + Response levels bound the entire serialized UTF-8 `details.divergence` object, not merely its arrays: compact (`--level digest`) is at most **8 KiB**, default at most **24 KiB**, and full at most **64 KiB**. Compact carries at most **8** screen refs and no suggestion entries — it carries `suggestionCount` (the diff --git a/src/core/__tests__/snapshot-chrome-android-statusbar.test.ts b/src/core/__tests__/snapshot-chrome-android-statusbar.test.ts index 9b07479a7..9a5a568ba 100644 --- a/src/core/__tests__/snapshot-chrome-android-statusbar.test.ts +++ b/src/core/__tests__/snapshot-chrome-android-statusbar.test.ts @@ -104,14 +104,16 @@ test('Android non-raw capture: status-bar leaves are recognized as chrome once t } }); -test('Android actionable systemui overlay (volume dialog) still survives with the status-bar leak fix (#1251)', () => { - // Extend the RAW capture with a disjoint systemui run whose leaf ids look - // like an actionable overlay (volume dialog): no real capture of that - // surface was available, but the point of this test is exactly that the - // new leaf-id set must NOT broaden to "any systemui id". Appended to the - // RAW array (not the already-walked output) so the whole tree — status bar - // AND volume dialog — goes through the real walk together, exactly as a - // single device capture would. +test('Android actionable systemui overlay (volume dialog) still survives the chrome filter (#1251)', () => { + // Filter-logic unit test, NOT a live-capture-path claim (#1264 finding 2): this + // exercises `collectSettleChromeRefs` in isolation over a synthetic systemui + // run appended to an unrelated capture fixture. The leaf ids ARE real, + // live-verified volume-dialog ids (`volume_dialog_container`, + // `volume_new_ringer_active_icon_container`), but appending them to this + // fixture does not reproduce how a live capture reaches the filter — that is + // covered separately by the full-capture invariant test below. The point + // here is narrower: the status-bar/nav-bar leaf-id set (#1251) must NOT + // broaden to "any systemui id" and drop an actionable overlay it is handed. const rawWithVolumeDialog: RawSnapshotNode[] = [ ...ANDROID_IME_CAPTURE_RAW_NODES, { @@ -125,8 +127,8 @@ test('Android actionable systemui overlay (volume dialog) still survives with th parentIndex: 9000, type: 'android.widget.ImageButton', bundleId: 'com.android.systemui', - identifier: 'com.android.systemui:id/volume_dialog_slider', - label: 'Media volume', + identifier: 'com.android.systemui:id/volume_new_ringer_active_icon_container', + label: 'Ringer volume', hittable: true, }, ]; @@ -134,13 +136,16 @@ test('Android actionable systemui overlay (volume dialog) still survives with th const chromeRefs = collectSettleChromeRefs(nodes, 'com.callstack.agentdevicelab'); // The container is non-hittable with a generic id, but its hittable - // `volume_dialog_slider` child keeps it in the walked tree (descendantHittable). + // `volume_new_ringer_active_icon_container` child keeps it in the walked + // tree (descendantHittable). assert.equal( chromeRefs.has(refForIdentifier(nodes, 'com.android.systemui:id/volume_dialog_container')), false, ); assert.equal( - chromeRefs.has(refForIdentifier(nodes, 'com.android.systemui:id/volume_dialog_slider')), + chromeRefs.has( + refForIdentifier(nodes, 'com.android.systemui:id/volume_new_ringer_active_icon_container'), + ), false, ); // The status-bar leak fix stays active in the very same tree. diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 9da29bb5b..3ab5305f9 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -471,3 +471,93 @@ test('buildReplayFailureDivergence excludes Android status-bar/IME chrome from s expect(screen.refs.some((ref) => ref.label === '+48 555 010 010')).toBe(true); // field-phone expect(screen.truncated).toBeUndefined(); }); + +// #1264 invariant: an agent must never see a healthier `screen` in a +// divergence report than a plain `snapshot` would show it. A separate-window +// system overlay (e.g. the volume dialog) that a full `snapshot` capture would +// include must also survive into divergence `screen.refs` — its actionable +// (hittable) content passing the meaningful-target filter — while ordinary +// status-bar/nav-bar/IME chrome in the SAME capture is still excluded. This +// extends the real (walked) Android fixture from the test above with a +// systemui overlay run using real, live-verified volume-dialog leaf ids +// (`volume_dialog_container`, `volume_new_ringer_active_icon_container`, see +// `core/__tests__/snapshot-chrome-android-statusbar.test.ts`), appended to the +// RAW array so the whole tree — app content, status bar, AND the overlay — +// goes through the real non-raw walk together, exactly as a single full +// capture would. +test('buildReplayFailureDivergence: a system-overlay window in the full capture survives into screen.refs while status/nav chrome is filtered (#1264)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-overlay-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set( + sessionName, + makeAndroidSession(sessionName, { appBundleId: 'com.callstack.agentdevicelab' }), + ); + + const rawWithVolumeDialog = [ + ...ANDROID_IME_CAPTURE_RAW_NODES, + { + index: 9000, + type: 'android.widget.FrameLayout', + bundleId: 'com.android.systemui', + identifier: 'com.android.systemui:id/volume_dialog_container', + }, + { + index: 9001, + parentIndex: 9000, + type: 'android.widget.ImageButton', + bundleId: 'com.android.systemui', + identifier: 'com.android.systemui:id/volume_new_ringer_active_icon_container', + label: 'Ringer volume', + hittable: true, + }, + ]; + + mockDispatchCommand.mockResolvedValue({ + nodes: walkNonRawAndroidFixture(rawWithVolumeDialog), + truncated: false, + backend: 'android', + }); + + const action = { + ts: 0, + command: 'press', + positionals: ['label="Full name"'], + flags: {}, + result: { selectorChain: ['label="Full name"'] }, + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + session: sessionStore.get(sessionName), + sessionName, + sessionStore, + logPath: path.join(root, 'daemon.log'), + responseLevel: 'default', + planActions: [action], + planDigest: 'test-plan-digest', + }); + + expect(divergence.screen.state).toBe('available'); + const screen = divergence.screen as Extract; + + // The overlay's actionable (hittable) control survives the filters, exactly + // as a plain `snapshot` at the same moment would show it. + expect(screen.refs.some((ref) => ref.label === 'Ringer volume')).toBe(true); + + // Ordinary status-bar/mobile/wifi systemui chrome and IME keys, present in + // the SAME capture, are still filtered — the overlay fix does not broaden + // the chrome filter into a no-op. + expect(screen.refs.some((ref) => ref.label === '7:03')).toBe(false); // clock + expect(screen.refs.some((ref) => ref.label === 'T-Mobile, signal full.')).toBe(false); // mobile_combo + expect(screen.refs.some((ref) => ref.label === 'Wifi signal full.')).toBe(false); // wifi_signal + expect(screen.refs.some((ref) => ref.label === 'Shift')).toBe(false); // IME key + + // App content underneath, from the same capture, is unaffected by either + // the overlay or the chrome filter. + expect(screen.refs.some((ref) => ref.label === 'Checkout form')).toBe(true); + expect(screen.refs.some((ref) => ref.label === 'review name')).toBe(true); // field-name +}); diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index da88adf5f..30c9c3913 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -1,14 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; -import { dispatchCommand } from '../../core/dispatch.ts'; -import { contextFromFlags } from '../context.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from '../session-snapshot.ts'; import { isSparseSnapshotQualityVerdict } from '../../snapshot/snapshot-quality.ts'; import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; import { redactDiagnosticData } from '../../kernel/redaction.ts'; import type { DaemonError, ResponseLevel } from '../../kernel/contracts.ts'; -import type { RawSnapshotNode, SnapshotBackend, SnapshotNode } from '../../kernel/snapshot.ts'; -import { buildSnapshotState } from './snapshot-capture.ts'; +import type { SnapshotNode } from '../../kernel/snapshot.ts'; +import { buildSnapshotState, captureSnapshotData } from './snapshot-capture.ts'; import { buildSelectorChainForNode, resolveSelectorChain, @@ -203,6 +201,17 @@ export function toReplayRepairHintCapture( * sequence (setSessionSnapshot -> markSessionSnapshotRefsIssued -> store). * Sparse captures do not write back (selector-capture reliability contract), * so a sparse verdict degrades the whole observation. + * + * ADR 0012 decision 4 amendment (#1264): this reuses `captureSnapshotData` — + * the SAME function the `snapshot` command itself builds its capture with + * (Android: snapshot-helper full-window route with the graceful app-scoped + * fallback; iOS: the bounded system-modal probe path; macOS/Linux: their + * surface-scoped branches) — instead of a parallel hand-rolled dispatch. An + * agent must never see a healthier `screen` in a divergence report than a + * plain `snapshot` would show it, so `screen` cannot be built from a narrower + * capture than `snapshot`'s. The chrome filter (`collectSettleChromeRefs`) + * and the meaningful-target filter below stay layered ON TOP of this full + * capture as FILTERS, never as a narrower scoping. */ export async function captureDivergenceObservation(params: { session: SessionState; @@ -213,24 +222,15 @@ export async function captureDivergenceObservation(params: { }): Promise { const { session, sessionName, sessionStore, logPath, action } = params; const snapshotInteractiveOnly = divergenceCaptureInteractiveOnly(action); + const flags = { ...(action.flags ?? {}), snapshotInteractiveOnly }; try { - const data = (await dispatchCommand(session.device, 'snapshot', [], undefined, { - ...contextFromFlags( - logPath, - { ...(action.flags ?? {}), snapshotInteractiveOnly }, - session.appBundleId, - session.trace?.outPath, - ), - })) as { - nodes?: RawSnapshotNode[]; - truncated?: boolean; - backend?: SnapshotBackend; - quality?: unknown; - }; - const snapshot = buildSnapshotState(data, { - ...(action.flags ?? {}), - snapshotInteractiveOnly, + const data = await captureSnapshotData({ + device: session.device, + session, + flags, + logPath, }); + const snapshot = buildSnapshotState(data, flags); if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { return { state: 'unavailable', From 123b7fef0b5791a857a49f737b4a1705e8a0276f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 18:56:38 +0200 Subject: [PATCH 2/5] fix: rank divergence screen.refs within the cap so overlays are not buried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1264 root cause is cap burial, not capture scope: buildReplayDivergenceScreenRefs sliced candidates in document order, so a fully-captured separate-window overlay (volume dialog, QS shade, permission dialog) that enumerates after the app window's ~77 nodes lands past position 20 and is truncated away — the report shows a healthy-looking app under a covering overlay it cannot see (archived evidence: screen.truncated: true, zero volume refs). - Rank within the cap instead of document-order slicing: foreign-window (non-app-bundleId) hittable nodes — the dismiss targets for whatever covers the app — are promoted ahead of app content, otherwise stable (document order preserved within each tier; equal-priority app nodes never reshuffled). The 20-cap is a byte bound, not a first-20-in-tree-order policy. - Occlusion fallback: when a system overlay mass-covers the app (every app node annotated interactionBlocked: 'covered'), surface those covered nodes rather than emitting an empty screen.refs — a report whose capture holds meaningful nodes but whose refs is empty is broken by construction. - repairHint/suggestions consume the full captured node list, not the capped refs slice, so hint routing is unaffected; only screen.refs selection changes. Detection keys off node.bundleId (Android-only, from the a11y package); iOS/macOS leave per-node bundleId undefined, so ranking degrades to document order there (safe — those platforms surface modals via the probe path, not by cap-competing). Guarded on a known appBundleId so a sessionless capture never reorders. Tests: replaces the small-fixture #1264 test (which the overlay fit inside the cap regardless of order, so it did not prove the invariant) with a realistic full fixture (24 app controls + overlay dismiss-target captured LAST) that fails on document-order slicing and passes with ranking; plus occlusion tests (mass-covered app -> overlay surfaces, refs non-empty; bare-scrim fallback -> covered app nodes surfaced, refs non-empty). Both were verified to fail before the fix. ADR-0012 decision 4 amendment reworded to cover ref-selection ranking and the occlusion guarantee, not only capture scope. Refs #1264 --- docs/adr/0012-interactive-replay.md | 51 ++-- .../session-replay-divergence.test.ts | 280 +++++++++++++++++- .../handlers/session-replay-divergence.ts | 61 +++- 3 files changed, 353 insertions(+), 39 deletions(-) diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index cac9145fe..74c88d265 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -12,9 +12,11 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig `REPLAY_DIVERGENCE` repair-loop fix (#1223). - Decision 6, the base agent-supervised re-record repair — `replay --save-script` arming, the post-watermark healed slice, `repairHint`, and the writer's bare-`@ref` fail-loud guard (#1228). -- Decision 4 amendment, `screen`'s capture scope — the divergence `screen` capture is unified with - `snapshot`'s own full-window scope, with the chrome and meaningful-target filters layered on top as - filters, not scopings (#1264). +- Decision 4 amendment, `screen`'s capture scope and ref selection — the divergence `screen` capture is + unified with `snapshot`'s own full-window scope (chrome and meaningful-target filters layered on top as + filters, not scopings), and `screen.refs` is ranked within the byte cap (foreign-window dismiss targets + ahead of app content; mass-covered app nodes surfaced rather than emptied) instead of sliced in document + order, so a captured overlay is never buried past the cap (#1264). **Accepted but NOT yet implemented** (this amendment; tracked by #1235 — repair-transaction lifecycle): the R7 repair-transaction keep-alive and its distinct `resume.repairSessionHeld` signal, the ARMED → @@ -441,21 +443,34 @@ healthy snapshot digest and the only form that issues actionable refs. `{ state: hint }` is returned when capture fails or is sparse; it has no refs or generation and must not fall back to the old session tree. Screen-capture failure never replaces or masks the original replay cause. -> **Amendment (#1264): `screen`'s capture scope.** `refs` is a filtered digest of the exact same tree a -> plain `snapshot` would return at that moment — the capture underneath `screen` is built with `snapshot`'s -> own full-window scope (Android: the snapshot-helper route, with the existing graceful app-scoped fallback -> only when the helper is unavailable; iOS: the same capture semantics as the bounded system-modal probe -> path; macOS/Linux: their surface-scoped branches), never a narrower, app-window-only capture assembled -> separately for divergence reporting. The chrome filter (#1233/#1256, `collectSettleChromeRefs`) and the -> meaningful-target filter (label/id or `hittable`) are layered ON TOP of that full capture as **filters**, -> not as a separate, narrower scoping — a filter may drop a node the full capture contains, but the capture -> itself must never omit content `snapshot` would show. This is a hard invariant: **an agent must never see -> a healthier `screen` in a divergence report than a plain `snapshot` would show it.** Concretely, a -> separate-window system overlay covering the app at the moment of capture — a held volume dialog, a -> persistent quick-settings shade, a permission dialog — must appear in `screen.refs` (its actionable/ -> hittable/labeled nodes surviving the filters) exactly as `snapshot` would present it, and `repairHint` -> (decision 6) is computed over that same full, correctly-scoped capture so it is never routed as if the -> app underneath a covering overlay were healthy. +> **Amendment (#1264): `screen`'s capture scope and ref selection.** `refs` is a filtered, ranked digest +> of the exact same tree a plain `snapshot` would return at that moment. Two guarantees back the invariant: +> +> 1. **Capture scope.** The capture underneath `screen` is built with `snapshot`'s own full-window scope +> (Android: the snapshot-helper route, with the existing graceful app-scoped fallback only when the +> helper is unavailable; iOS: the same capture semantics as the bounded system-modal probe path; +> macOS/Linux: their surface-scoped branches), never a narrower, app-window-only capture assembled +> separately for divergence reporting. The chrome filter (#1233/#1256, `collectSettleChromeRefs`) and the +> meaningful-target filter (label/id or `hittable`) are layered ON TOP of that full capture as +> **filters**, not as a separate, narrower scoping — a filter may drop a node the full capture contains, +> but the capture itself must never omit content `snapshot` would show. +> 2. **Ref selection within the cap.** The `screen.refs` cap is a **byte bound**, not a "first N in document +> order" policy. A separate-window overlay enumerates AFTER the app window's nodes, so a document-order +> slice truncates a fully-captured overlay away (its dismiss target sits past the cap) — reporting a +> healthy-looking app even though the capture holds the covering window. So `refs` is RANKED before it is +> capped: foreign-window (non-app-`bundleId`) hittable nodes — the dismiss targets for whatever covers +> the app — are promoted ahead of app content, with document order otherwise preserved (stable within +> each tier). And when a system overlay MASS-COVERS the app so every app node is annotated `covered`, +> those covered nodes are surfaced rather than emitting an empty `refs`: a report whose capture holds +> meaningful nodes but whose `refs` is empty is broken by construction. +> +> This is a hard invariant: **an agent must never see a healthier `screen` in a divergence report than a +> plain `snapshot` would show it.** Concretely, a separate-window system overlay covering the app at the +> moment of capture — a held volume dialog, a persistent quick-settings shade, a permission dialog — must +> appear in `screen.refs` (its actionable/hittable/labeled nodes surviving the filters AND the cap) exactly +> as `snapshot` would present it. `repairHint` (decision 6) and `suggestions` (decision 1) consume the FULL +> captured node list, not the capped `refs` slice, so hint routing is computed over the same full, +> correctly-scoped capture and is never routed as if the app underneath a covering overlay were healthy. Response levels bound the entire serialized UTF-8 `details.divergence` object, not merely its arrays: compact (`--level digest`) is at most **8 KiB**, default at most **24 KiB**, and full at most **64 KiB**. diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 3ab5305f9..be160bed5 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -472,20 +472,17 @@ test('buildReplayFailureDivergence excludes Android status-bar/IME chrome from s expect(screen.truncated).toBeUndefined(); }); -// #1264 invariant: an agent must never see a healthier `screen` in a -// divergence report than a plain `snapshot` would show it. A separate-window -// system overlay (e.g. the volume dialog) that a full `snapshot` capture would -// include must also survive into divergence `screen.refs` — its actionable -// (hittable) content passing the meaningful-target filter — while ordinary +// #1264 coexistence (chrome filter + overlay, real walked fixture): a +// separate-window system overlay (volume dialog) that a full `snapshot` capture +// includes survives into divergence `screen.refs` — its actionable (hittable) +// content passing the meaningful-target filter — while ordinary // status-bar/nav-bar/IME chrome in the SAME capture is still excluded. This -// extends the real (walked) Android fixture from the test above with a -// systemui overlay run using real, live-verified volume-dialog leaf ids -// (`volume_dialog_container`, `volume_new_ringer_active_icon_container`, see -// `core/__tests__/snapshot-chrome-android-statusbar.test.ts`), appended to the -// RAW array so the whole tree — app content, status bar, AND the overlay — -// goes through the real non-raw walk together, exactly as a single full -// capture would. -test('buildReplayFailureDivergence: a system-overlay window in the full capture survives into screen.refs while status/nav chrome is filtered (#1264)', async () => { +// runs the real non-raw Android walk over app content + status bar + overlay +// together, proving the chrome filter and the overlay coexist. NOTE: this +// fixture is small enough that the overlay fits inside the 20-cap regardless of +// ordering, so it does NOT by itself prove cap-burial resistance — that is the +// dedicated realistic-tree test below. +test('buildReplayFailureDivergence: a system-overlay window survives into screen.refs while status/nav chrome is filtered (#1264)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-overlay-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; @@ -561,3 +558,260 @@ test('buildReplayFailureDivergence: a system-overlay window in the full capture expect(screen.refs.some((ref) => ref.label === 'Checkout form')).toBe(true); expect(screen.refs.some((ref) => ref.label === 'review name')).toBe(true); // field-name }); + +// Realistic full capture: 24 app controls enumerate in document order BEFORE a +// separate-window overlay, whose lone actionable dismiss target is captured +// LAST (index 24) — exactly the shape of a live tree where the app window's +// ~77 nodes precede a volume dialog / QS shade. 24 app + 1 overlay = 25 +// meaningful candidates > the 20-cap. Under the previous +// `candidates.slice(0, 20)` in document order, the overlay sits at position 24 +// and is TRUNCATED away (the archived #1264 evidence: `screen.truncated: true`, +// zero volume refs). Under within-cap ranking (foreign-bundle hittable dismiss +// targets promoted ahead of app content) it survives. This test fails on +// document-order slicing and passes with the ranking. +function appSwampWithTrailingOverlay(appBundleId: string) { + const appControls = Array.from({ length: 24 }, (_, i) => ({ + index: i, + depth: 1, + type: 'android.widget.Button', + bundleId: appBundleId, + label: `App control ${String(i + 1).padStart(2, '0')}`, + identifier: `${appBundleId}:id/control_${i + 1}`, + // Non-overlapping small rects down the app window: no occlusion fires. + rect: { x: 0, y: 10 * i, width: 120, height: 8 }, + hittable: true, + })); + return [ + ...appControls, + // The overlay's dismiss target, captured LAST — a foreign (systemui) window. + { + index: 24, + depth: 1, + type: 'android.widget.ImageButton', + bundleId: 'com.android.systemui', + identifier: 'com.android.systemui:id/volume_new_ringer_active_icon_container', + label: 'Ringer volume', + rect: { x: 300, y: 300, width: 44, height: 44 }, + hittable: true, + }, + ]; +} + +test('buildReplayFailureDivergence: a fully-captured overlay dismiss-target enumerating LAST still lands within the screen.refs cap (#1264 cap burial)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-burial-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + const appBundleId = 'com.callstack.agentdevicelab'; + sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + + mockDispatchCommand.mockResolvedValue({ + nodes: appSwampWithTrailingOverlay(appBundleId), + truncated: false, + backend: 'android', + }); + + const action = { + ts: 0, + command: 'press', + positionals: ['label="App control 01"'], + flags: {}, + result: { selectorChain: ['label="App control 01"'] }, + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + session: sessionStore.get(sessionName), + sessionName, + sessionStore, + logPath: path.join(root, 'daemon.log'), + responseLevel: 'default', + planActions: [action], + planDigest: 'test-plan-digest', + }); + + expect(divergence.screen.state).toBe('available'); + const screen = divergence.screen as Extract; + + // The overlay dismiss target, captured LAST (position 24, past the 20-cap in + // document order), survives because foreign-window hittable nodes are ranked + // ahead of app content within the cap. + expect(screen.refs.some((ref) => ref.label === 'Ringer volume')).toBe(true); + // It is ranked first, not merely present. + expect(screen.refs[0]?.label).toBe('Ringer volume'); + // Ranking is stable for equal-priority app content: the surviving app refs are + // still the earliest ones in document order (no reshuffle). + expect(screen.refs.some((ref) => ref.label === 'App control 01')).toBe(true); + // The report still honestly reports it dropped candidates beyond the cap. + expect(screen.truncated).toBe(true); +}); + +// Occlusion interaction (#1264): when a system overlay MASS-COVERS the app, +// every app node is annotated `interactionBlocked: 'covered'` (and +// `hittable: false`) by `annotateCoveredSnapshotNodes`. The covering overlay's +// own actionable node is NOT covered and IS the repair surface. A report whose +// capture holds these nodes but whose `refs` is empty is broken by +// construction. The overlay-like `Dialog` container (a later document node with +// a full-screen rect over the app buttons) triggers the occlusion annotation. +function overlayMassCoveringApp(appBundleId: string) { + const appButtons = Array.from({ length: 3 }, (_, i) => ({ + index: i, + depth: 1, + type: 'android.widget.Button', + bundleId: appBundleId, + label: `Field ${String.fromCharCode(65 + i)}`, + identifier: `${appBundleId}:id/field_${i + 1}`, + rect: { x: 20, y: 100 + 60 * i, width: 300, height: 44 }, + hittable: true, + })); + const dialogIndex = appButtons.length; + return [ + ...appButtons, + // Overlay-like container (`Dialog` type) covering the whole app area, later + // in document order — marks every app button `covered`. Non-hittable, so it + // is not itself a dismiss target. + { + index: dialogIndex, + depth: 0, + type: 'android.app.Dialog', + bundleId: 'com.android.systemui', + identifier: 'com.android.systemui:id/volume_dialog_container', + rect: { x: 0, y: 0, width: 402, height: 874 }, + }, + // The overlay's actionable dismiss target, a child of the dialog. + { + index: dialogIndex + 1, + parentIndex: dialogIndex, + depth: 1, + type: 'android.widget.ImageButton', + bundleId: 'com.android.systemui', + identifier: 'com.android.systemui:id/volume_new_ringer_active_icon_container', + label: 'Ringer volume', + rect: { x: 300, y: 300, width: 44, height: 44 }, + hittable: true, + }, + ]; +} + +test('buildReplayFailureDivergence: when a system overlay mass-covers the app, the overlay dismiss-target surfaces in screen.refs and refs is non-empty (#1264 occlusion)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-occlusion-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + const appBundleId = 'com.callstack.agentdevicelab'; + sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + + mockDispatchCommand.mockResolvedValue({ + nodes: overlayMassCoveringApp(appBundleId), + truncated: false, + backend: 'android', + }); + + const action = { + ts: 0, + command: 'press', + positionals: ['label="Field A"'], + flags: {}, + result: { selectorChain: ['label="Field A"'] }, + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + session: sessionStore.get(sessionName), + sessionName, + sessionStore, + logPath: path.join(root, 'daemon.log'), + responseLevel: 'default', + planActions: [action], + planDigest: 'test-plan-digest', + }); + + expect(divergence.screen.state).toBe('available'); + const screen = divergence.screen as Extract; + + // A capture with meaningful nodes must never yield an empty screen.refs. + expect(screen.refs.length).toBeGreaterThan(0); + // The overlay's non-covered actionable node is the repair surface and surfaces. + expect(screen.refs.some((ref) => ref.label === 'Ringer volume')).toBe(true); + // The mass-covered app buttons are correctly excluded while a non-covered + // dismiss target exists (they are not tappable under the overlay). + expect(screen.refs.some((ref) => ref.label === 'Field A')).toBe(false); +}); + +// Occlusion fallback (#1264): the covering overlay has NO actionable node of its +// own (a bare scrim), so once the app is mass-covered there is no non-covered +// meaningful candidate at all. Rather than emit an empty `screen.refs` — a +// report broken by construction — the covered app nodes are surfaced so the +// agent at least sees what is underneath. +function bareScrimMassCoveringApp(appBundleId: string) { + const appButtons = Array.from({ length: 3 }, (_, i) => ({ + index: i, + depth: 1, + type: 'android.widget.Button', + bundleId: appBundleId, + label: `Field ${String.fromCharCode(65 + i)}`, + identifier: `${appBundleId}:id/field_${i + 1}`, + rect: { x: 20, y: 100 + 60 * i, width: 300, height: 44 }, + hittable: true, + })); + return [ + ...appButtons, + // Bare overlay scrim: overlay-like `Dialog` type (covers the app) but no + // label, no identifier, non-hittable — not itself a meaningful candidate. + { + index: appButtons.length, + depth: 0, + type: 'android.app.Dialog', + bundleId: 'com.android.systemui', + rect: { x: 0, y: 0, width: 402, height: 874 }, + }, + ]; +} + +test('buildReplayFailureDivergence: a mass-covered app with no actionable overlay node still returns non-empty screen.refs (#1264 occlusion fallback)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-scrim-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + const appBundleId = 'com.callstack.agentdevicelab'; + sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + + mockDispatchCommand.mockResolvedValue({ + nodes: bareScrimMassCoveringApp(appBundleId), + truncated: false, + backend: 'android', + }); + + const action = { + ts: 0, + command: 'press', + positionals: ['label="Field A"'], + flags: {}, + result: { selectorChain: ['label="Field A"'] }, + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + session: sessionStore.get(sessionName), + sessionName, + sessionStore, + logPath: path.join(root, 'daemon.log'), + responseLevel: 'default', + planActions: [action], + planDigest: 'test-plan-digest', + }); + + expect(divergence.screen.state).toBe('available'); + const screen = divergence.screen as Extract; + + // No non-covered meaningful candidate exists, but the capture is not empty — + // the covered app content is surfaced rather than returning an empty report. + expect(screen.refs.length).toBeGreaterThan(0); + expect(screen.refs.some((ref) => ref.label === 'Field A')).toBe(true); +}); diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index 30c9c3913..07e51192f 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -324,6 +324,31 @@ function isMeaningfulDivergenceTarget(node: SnapshotNode): boolean { return Boolean(displayLabel(node, formatRole(node.type ?? 'Element'))) || node.hittable === true; } +/** + * ADR 0012 decision 4 amendment (#1264): a hittable node owned by a window + * OTHER than the app under test — a system-overlay window (volume dialog, + * quick-settings shade, permission dialog) whose actionable nodes are the + * dismiss targets for whatever is covering the app. Ownership is the node's own + * `bundleId`/package: Android sets it per node from the accessibility `package`, + * so a systemui/permission-controller/`android` node reads as foreign; iOS and + * macOS leave per-node `bundleId` undefined, so this is inert there (those + * platforms surface separate-window modals through the dedicated probe path, + * not by cap-competing with app content). Guarded on a known `appBundleId` so a + * sessionless capture never reorders — without an app identity there is no + * "foreign" to promote. + */ +function isForeignOverlayDismissTarget( + node: SnapshotNode, + appBundleId: string | undefined, +): boolean { + return ( + appBundleId !== undefined && + node.bundleId !== undefined && + node.bundleId !== appBundleId && + node.hittable === true + ); +} + function buildReplayDivergenceScreenRefs( nodes: SnapshotNode[], sanitize: DivergenceFieldSanitizer, @@ -336,14 +361,34 @@ function buildReplayDivergenceScreenRefs( // structural classifier `--settle`'s tail already relies on (#1198/#1200) // rather than a second keyboard/IME node-type list. const chromeRefs = collectSettleChromeRefs(nodes, appBundleId); - const candidates = nodes.filter( - (node) => - node.ref && - node.interactionBlocked !== 'covered' && - !chromeRefs.has(node.ref) && - isMeaningfulDivergenceTarget(node), + const meaningful = nodes.filter( + (node) => node.ref && !chromeRefs.has(node.ref) && isMeaningfulDivergenceTarget(node), ); - const refs = candidates.slice(0, SCREEN_REF_CAPTURE_LIMIT).map((node) => { + // Occlusion fallback (#1264): a `covered` node is normally dropped — an agent + // cannot tap what an overlay hides. But when a system overlay MASS-COVERS the + // app, EVERY app node is annotated `covered`; dropping them all would emit an + // empty `screen.refs` while the capture plainly holds meaningful nodes — a + // report broken by construction (the agent is shown nothing to act on). So + // `covered` nodes are excluded only while non-covered candidates remain; if + // the entire meaningful surface is covered, they are surfaced rather than + // returning empty. + const visible = meaningful.filter((node) => node.interactionBlocked !== 'covered'); + const pool = visible.length > 0 ? visible : meaningful; + // Rank within the cap instead of slicing document order (#1264 cap burial): + // `SCREEN_REF_CAPTURE_LIMIT` is a BYTE bound, NOT a "first 20 in tree order" + // policy. A separate-window overlay enumerates AFTER the app window's nodes, + // so on a realistic tree its dismiss target sits past position 20 and is + // truncated away even though it was captured. Foreign-bundle hittable overlay + // nodes (the dismiss targets) are promoted ahead of app content; ordering is + // otherwise STABLE — document order is preserved within each tier, so + // equal-priority app nodes are never reshuffled. `repairHint`/`suggestions` + // consume the FULL captured node list, not this slice, so hint routing is + // unaffected; only the agent-visible `screen.refs` selection changes. + const ranked = [ + ...pool.filter((node) => isForeignOverlayDismissTarget(node, appBundleId)), + ...pool.filter((node) => !isForeignOverlayDismissTarget(node, appBundleId)), + ]; + const refs = ranked.slice(0, SCREEN_REF_CAPTURE_LIMIT).map((node) => { const role = formatRole(node.type ?? 'Element'); const label = displayLabel(node, role); return { @@ -352,7 +397,7 @@ function buildReplayDivergenceScreenRefs( ...(label ? { label: sanitize(label) } : {}), }; }); - return { refs, truncated: candidates.length > refs.length }; + return { refs, truncated: ranked.length > refs.length }; } const BASIS_RANK: Record = { From f88fed4aa39633ec26e1317f1162210276af405f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 20:14:45 +0200 Subject: [PATCH 3/5] fix: route divergence capture through captureSnapshot wrapper + clean flags policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the #1264 capture unification. The prior round routed captureDivergenceObservation through captureSnapshotData (the inner single-shot capture), but plain `snapshot`'s backend calls the HIGHER captureSnapshot wrapper, which owns Android freshness + post-action retry. A divergence could therefore consume the first stale/app-scoped dump while a plain `snapshot` retries to the fresh full-window tree — a divergence staler/narrower than `snapshot`, violating the invariant. - Route the divergence capture through the same `captureSnapshot` wrapper as plain snapshot, so it inherits freshness/post-action retry parity. No fork: the wrapper's params (device, session, flags, logPath) are all suppliable from the divergence path. - Build the divergence capture's flags from a clean, fixed policy (`divergenceCaptureFlags`: full-window, non-raw, default depth) instead of spreading the failed action's flags — so a failed `snapshot --raw`/scoped/`-d` action can no longer narrow the diagnostic tree. Only the interactive-only policy is carried (extracted as a helper so captureDivergenceObservation stays within complexity budget). Tests: a freshness-retry regression (session carries an active Android freshness marker; capture-1 is a stale near-empty dump that trips sharp-drop, capture-2 holds the overlay — asserts the divergence uses the retried fresh tree and dispatched twice), and a clean-flags regression (a failed raw/scoped/depth action — asserts the snapshot dispatch context drops snapshotRaw/scope/depth while still applying interactive-only). Both verified to fail on the pre-fix code. ADR-0012 decision 4 amendment updated to state the same-wrapper (freshness parity) and clean-flags guarantees. Live overlay acceptance remains a maintainer device step (env down): unit fixtures prove ref SELECTION after nodes are supplied, not that the Android helper returns the separate-window overlay at divergence time. Refs #1264 --- docs/adr/0012-interactive-replay.md | 31 ++-- .../session-replay-divergence.test.ts | 174 ++++++++++++++++++ .../handlers/session-replay-divergence.ts | 53 ++++-- 3 files changed, 232 insertions(+), 26 deletions(-) diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index 74c88d265..d43bb3841 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -12,9 +12,11 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig `REPLAY_DIVERGENCE` repair-loop fix (#1223). - Decision 6, the base agent-supervised re-record repair — `replay --save-script` arming, the post-watermark healed slice, `repairHint`, and the writer's bare-`@ref` fail-loud guard (#1228). -- Decision 4 amendment, `screen`'s capture scope and ref selection — the divergence `screen` capture is - unified with `snapshot`'s own full-window scope (chrome and meaningful-target filters layered on top as - filters, not scopings), and `screen.refs` is ranked within the byte cap (foreign-window dismiss targets +- Decision 4 amendment, `screen`'s capture scope and ref selection — the divergence `screen` capture runs + through the same `captureSnapshot` wrapper as plain `snapshot` (full-window scope + Android freshness / + post-action retry parity) under a clean, fixed capture-flags policy (a failed raw/scoped/`-d` action can + no longer narrow the diagnostic tree), with the chrome and meaningful-target filters layered on top as + filters, not scopings; and `screen.refs` is ranked within the byte cap (foreign-window dismiss targets ahead of app content; mass-covered app nodes surfaced rather than emptied) instead of sliced in document order, so a captured overlay is never buried past the cap (#1264). @@ -446,14 +448,21 @@ the old session tree. Screen-capture failure never replaces or masks the origina > **Amendment (#1264): `screen`'s capture scope and ref selection.** `refs` is a filtered, ranked digest > of the exact same tree a plain `snapshot` would return at that moment. Two guarantees back the invariant: > -> 1. **Capture scope.** The capture underneath `screen` is built with `snapshot`'s own full-window scope -> (Android: the snapshot-helper route, with the existing graceful app-scoped fallback only when the -> helper is unavailable; iOS: the same capture semantics as the bounded system-modal probe path; -> macOS/Linux: their surface-scoped branches), never a narrower, app-window-only capture assembled -> separately for divergence reporting. The chrome filter (#1233/#1256, `collectSettleChromeRefs`) and the -> meaningful-target filter (label/id or `hittable`) are layered ON TOP of that full capture as -> **filters**, not as a separate, narrower scoping — a filter may drop a node the full capture contains, -> but the capture itself must never omit content `snapshot` would show. +> 1. **Capture scope.** The capture underneath `screen` runs through the **same `captureSnapshot` wrapper** +> the plain `snapshot` command's backend calls — not a parallel single-shot dispatch — so it inherits +> `snapshot`'s full-window scope (Android: the snapshot-helper route, with the existing graceful +> app-scoped fallback only when the helper is unavailable; iOS: the bounded system-modal probe path; +> macOS/Linux: their surface-scoped branches) AND its Android freshness / post-action retry policy. A +> divergence must not consume a first stale or app-scoped dump while a plain `snapshot` retries to the +> fresh full-window tree — that would make the divergence staler or narrower than `snapshot`, violating +> the invariant. The divergence capture's flags are a **clean, fixed policy** (full-window, non-raw, +> default depth), NOT the failed action's flags: a failed `snapshot --raw`/scoped/`-d` action must never +> narrow or reshape the diagnostic tree below what a plain `snapshot` shows, so `snapshotRaw` / +> `snapshotScope` / `snapshotDepth` are dropped; only the interactive-only policy (full for non-rect +> `get`/`is`/`wait` reads, interactive otherwise) is carried. The chrome filter (#1233/#1256, +> `collectSettleChromeRefs`) and the meaningful-target filter (label/id or `hittable`) are layered ON TOP +> of that full capture as **filters**, not as a separate, narrower scoping — a filter may drop a node the +> full capture contains, but the capture itself must never omit content `snapshot` would show. > 2. **Ref selection within the cap.** The `screen.refs` cap is a **byte bound**, not a "first N in document > order" policy. A separate-window overlay enumerates AFTER the app window's nodes, so a document-order > slice truncates a fully-captured overlay away (its dismiss target sits past the cap) — reporting a diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index be160bed5..9a330a919 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -815,3 +815,177 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla expect(screen.refs.length).toBeGreaterThan(0); expect(screen.refs.some((ref) => ref.label === 'Field A')).toBe(true); }); + +// #1264 (capture parity, point 1): the divergence capture must go through the +// SAME `captureSnapshot` wrapper as a plain `snapshot`, so it inherits Android +// freshness + post-action retry. Otherwise a divergence could consume the first +// stale / app-scoped dump while a plain `snapshot` retries to the fresh +// full-window tree — a divergence STALER than `snapshot`. Here the session +// carries an active Android freshness marker (baselineCount 20); the first +// on-device dump is a stale, near-empty tree (sharp node-count drop, no +// meaningful content → the `sharp-drop` retry trigger), and only the RETRIED +// second dump contains the system overlay. The divergence must reflect the +// retried tree. +test('buildReplayFailureDivergence: routes through the freshness-retry wrapper and uses the retried fresh tree, not the first stale dump (#1264 capture parity)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-fresh-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + const appBundleId = 'com.callstack.agentdevicelab'; + const session = makeAndroidSession(sessionName, { appBundleId }); + // Active freshness marker: a navigation-sensitive action just ran, and the + // pre-action baseline had 20 nodes, so a near-empty next dump is suspicious. + session.androidSnapshotFreshness = { + action: 'press', + markedAt: Date.now(), + baselineCount: 20, + routeComparable: false, + }; + sessionStore.set(sessionName, session); + + // Capture 1: stale, near-empty dump (a single bare view — no hittable/label/ + // id) → `sharp-drop` vs the 20-node baseline → triggers a retry. + const staleDump = { + nodes: [ + { + index: 0, + type: 'android.view.View', + bundleId: appBundleId, + rect: { x: 0, y: 0, width: 10, height: 10 }, + }, + ], + truncated: false, + backend: 'android', + }; + // Capture 2: the fresh full-window tree, holding the app control AND the + // separate-window system overlay's dismiss target. + const freshDump = { + nodes: [ + { + index: 0, + type: 'android.widget.Button', + bundleId: appBundleId, + label: 'App control', + identifier: `${appBundleId}:id/control`, + rect: { x: 20, y: 100, width: 200, height: 44 }, + hittable: true, + }, + { + index: 1, + type: 'android.widget.ImageButton', + bundleId: 'com.android.systemui', + identifier: 'com.android.systemui:id/volume_new_ringer_active_icon_container', + label: 'Ringer volume', + rect: { x: 300, y: 300, width: 44, height: 44 }, + hittable: true, + }, + ], + truncated: false, + backend: 'android', + }; + mockDispatchCommand.mockReset(); + mockDispatchCommand.mockResolvedValueOnce(staleDump).mockResolvedValueOnce(freshDump); + + const action = { + ts: 0, + command: 'press', + positionals: ['label="App control"'], + flags: {}, + result: { selectorChain: ['label="App control"'] }, + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + session: sessionStore.get(sessionName), + sessionName, + sessionStore, + logPath: path.join(root, 'daemon.log'), + responseLevel: 'default', + planActions: [action], + planDigest: 'test-plan-digest', + }); + + // The freshness wrapper retried past the stale dump (2 on-device captures). + expect(mockDispatchCommand).toHaveBeenCalledTimes(2); + + expect(divergence.screen.state).toBe('available'); + const screen = divergence.screen as Extract; + // The overlay only exists in the RETRIED capture, so its presence proves the + // divergence used the fresh tree — parity with what a plain `snapshot` sees. + expect(screen.refs.some((ref) => ref.label === 'Ringer volume')).toBe(true); + expect(screen.refs.some((ref) => ref.label === 'App control')).toBe(true); +}); + +// #1264 (clean flags policy, point 2): a failed `snapshot --raw`/scoped/`-d` +// action must never narrow the DIAGNOSTIC divergence tree. The divergence +// capture builds its flags from a fixed policy (full-window, non-raw, default +// depth), NOT from the failed action's flags — so `snapshotRaw`/`snapshotScope`/ +// `snapshotDepth` on the action do not reach the capture. This inspects the +// context handed to the snapshot dispatch and asserts those narrowing flags are +// dropped while the interactive-only policy is still applied. +test('buildReplayFailureDivergence: divergence capture drops the action snapshotRaw/scope/depth flags (#1264 clean flags policy)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-flags-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + const appBundleId = 'com.callstack.agentdevicelab'; + sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + + mockDispatchCommand.mockReset(); + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { + index: 0, + type: 'android.widget.Button', + bundleId: appBundleId, + label: 'Submit', + identifier: `${appBundleId}:id/submit`, + rect: { x: 20, y: 100, width: 200, height: 44 }, + hittable: true, + }, + ], + truncated: false, + backend: 'android', + }); + + // A failed action that itself requested a raw, ref-scoped, depth-limited + // snapshot — none of which may reshape the divergence diagnostic tree. + const action = { + ts: 0, + command: 'press', + positionals: ['label="Submit"'], + flags: { snapshotRaw: true, snapshotScope: '@e5', snapshotDepth: 2 }, + result: { selectorChain: ['label="Submit"'] }, + }; + await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + session: sessionStore.get(sessionName), + sessionName, + sessionStore, + logPath: path.join(root, 'daemon.log'), + responseLevel: 'default', + planActions: [action], + planDigest: 'test-plan-digest', + }); + + expect(mockDispatchCommand).toHaveBeenCalled(); + const context = mockDispatchCommand.mock.calls[0]?.[4] as + | { + snapshotRaw?: boolean; + snapshotScope?: string; + snapshotDepth?: number; + snapshotInteractiveOnly?: boolean; + } + | undefined; + // The action's narrowing flags are stripped by the fixed divergence policy. + expect(context?.snapshotRaw).not.toBe(true); + expect(context?.snapshotScope).toBeUndefined(); + expect(context?.snapshotDepth).toBeUndefined(); + // The interactive-only policy (press → interactive) is still applied. + expect(context?.snapshotInteractiveOnly).toBe(true); +}); diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index 07e51192f..a4b38cd83 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -4,9 +4,10 @@ import { markSessionPartialRefsIssued, setSessionSnapshot } from '../session-sna import { isSparseSnapshotQualityVerdict } from '../../snapshot/snapshot-quality.ts'; import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; import { redactDiagnosticData } from '../../kernel/redaction.ts'; +import type { CommandFlags } from '../../core/dispatch.ts'; import type { DaemonError, ResponseLevel } from '../../kernel/contracts.ts'; import type { SnapshotNode } from '../../kernel/snapshot.ts'; -import { buildSnapshotState, captureSnapshotData } from './snapshot-capture.ts'; +import { captureSnapshot } from './snapshot-capture.ts'; import { buildSelectorChainForNode, resolveSelectorChain, @@ -202,16 +203,28 @@ export function toReplayRepairHintCapture( * Sparse captures do not write back (selector-capture reliability contract), * so a sparse verdict degrades the whole observation. * - * ADR 0012 decision 4 amendment (#1264): this reuses `captureSnapshotData` — - * the SAME function the `snapshot` command itself builds its capture with - * (Android: snapshot-helper full-window route with the graceful app-scoped - * fallback; iOS: the bounded system-modal probe path; macOS/Linux: their - * surface-scoped branches) — instead of a parallel hand-rolled dispatch. An - * agent must never see a healthier `screen` in a divergence report than a - * plain `snapshot` would show it, so `screen` cannot be built from a narrower - * capture than `snapshot`'s. The chrome filter (`collectSettleChromeRefs`) - * and the meaningful-target filter below stay layered ON TOP of this full - * capture as FILTERS, never as a narrower scoping. + * ADR 0012 decision 4 amendment (#1264): this routes through `captureSnapshot` + * — the EXACT wrapper the `snapshot` command's backend calls + * (`dispatchSnapshotViaRuntime` -> `createDaemonSnapshotBackend`), which owns + * Android freshness + post-action retry (`capturePostActionAwareSnapshot`) on + * top of the per-platform capture (Android snapshot-helper full-window route + * with its graceful app-scoped fallback; iOS bounded system-modal probe path; + * macOS/Linux surface-scoped branches). Calling the inner single-shot + * `captureSnapshotData` instead would let a divergence consume a first stale / + * app-scoped dump while a plain `snapshot` retries to the fresh full-window + * tree — a divergence STALER or NARROWER than `snapshot`, which is exactly the + * invariant this amendment forbids: an agent must never see a healthier + * `screen` in a divergence report than a plain `snapshot` would show it. + * + * The capture flags are a CLEAN, fixed divergence-capture policy, NOT the + * failed action's flags: `snapshotRaw`/`snapshotScope`/`snapshotDepth` from a + * failed `snapshot --raw`/scoped/`-d` action would narrow or reshape the + * diagnostic tree below what a plain `snapshot` shows, so they are dropped. The + * only carried policy is interactive-only (`divergenceCaptureInteractiveOnly` — + * full for non-rect `get`/`is`/`wait` reads so static-text targets survive, + * interactive otherwise), matching heal's long-standing rule. The chrome filter + * (`collectSettleChromeRefs`) and the meaningful-target filter stay layered ON + * TOP of this full capture as FILTERS, never as a narrower scoping. */ export async function captureDivergenceObservation(params: { session: SessionState; @@ -221,16 +234,15 @@ export async function captureDivergenceObservation(params: { action: SessionAction; }): Promise { const { session, sessionName, sessionStore, logPath, action } = params; - const snapshotInteractiveOnly = divergenceCaptureInteractiveOnly(action); - const flags = { ...(action.flags ?? {}), snapshotInteractiveOnly }; + const flags = divergenceCaptureFlags(action); try { - const data = await captureSnapshotData({ + const capture = await captureSnapshot({ device: session.device, session, flags, logPath, }); - const snapshot = buildSnapshotState(data, flags); + const snapshot = capture.snapshot; if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { return { state: 'unavailable', @@ -269,6 +281,17 @@ export async function captureDivergenceObservation(params: { } } +/** + * The clean, fixed flags for a divergence capture (#1264): full-window + * (no `snapshotScope`), non-raw (no `snapshotRaw`), default depth (no + * `snapshotDepth`) — a failed scoped/raw/depth-limited action must never + * produce a narrowed divergence `screen`. Only the interactive-only policy is + * carried, since it governs whether static-text suggestion targets survive. + */ +function divergenceCaptureFlags(action: SessionAction): CommandFlags { + return { snapshotInteractiveOnly: divergenceCaptureInteractiveOnly(action) }; +} + /** * Interactive-only capture, except for non-rect selector reads * (`get`/`is`/`wait`) whose suggestion targets include static text — the From 76b0fbd303e3db0657f8a044e1c25263cc814a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 21:05:51 +0200 Subject: [PATCH 4/5] test: stub the freshness-retry sleep so the capture-parity test doesn't real-wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1264 capture-parity regression exercised the real Android sharp-drop retry, which awaited the real ~250 ms `sleep` delay — repo guidance forbids real-time waits in unit tests. Mock `sleep` (the delay the retry path in snapshot-capture.ts awaits) to a no-op at the module level, so the retry BRANCH still executes (loop runs, retries, re-captures) without a wall-clock wait. The test still proves the branch: two on-device dispatches and use of the retried fresh tree (overlay present). Verified it still fails on the pre-fix single-shot path (1 dispatch) with the delay stubbed, so the stub does not make it vacuous. No production change; the delay stub needs no DI seam since `sleep` is a plain module export. Refs #1264 --- .../__tests__/session-replay-divergence.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 9a330a919..4dd59f6b8 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -8,6 +8,18 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +// Stub the Android freshness-retry delay to a no-op so the capture-parity test +// exercises the retry BRANCH without a real wall-clock wait (repo guidance: +// no real-time sleeps in unit tests). This is the exact `sleep` the retry path +// in `snapshot-capture.ts` (`capturePostActionAwareSnapshot`) awaits; making it +// instant does not change control flow — the loop still runs, retries, and +// re-captures — so the test still proves two dispatches and use of the retried +// tree. No other test in this file triggers a sleep path. +vi.mock('../../../utils/timeouts.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sleep: vi.fn(async () => {}) }; +}); + import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession, @@ -825,7 +837,8 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla // on-device dump is a stale, near-empty tree (sharp node-count drop, no // meaningful content → the `sharp-drop` retry trigger), and only the RETRIED // second dump contains the system overlay. The divergence must reflect the -// retried tree. +// retried tree. The retry delay (`sleep`) is stubbed to a no-op at the top of +// this file, so the retry BRANCH runs without a real wall-clock wait. test('buildReplayFailureDivergence: routes through the freshness-retry wrapper and uses the retried fresh tree, not the first stale dump (#1264 capture parity)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-fresh-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); From 0f7a41bca6957d01a1d15cc4e3807a6f6c592ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 14 Jul 2026 21:38:06 +0200 Subject: [PATCH 5/5] fix: reconcile divergence ref selection with #1257 ADR-0014 partial ref frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase reconciliation. #1257 (ADR-0014 session ref-frame lifetime) landed on main and changed captureDivergenceObservation to activate a PARTIAL ref frame (markSessionPartialRefsIssued) authorizing exactly the divergence screen's emitted refs — computing that "digestBodies" set with its own document-order, non-covered-only filter. My #1264 change made buildReplayDivergenceScreenRefs emit a DIFFERENT set (ranked, occlusion-fallback, meaningful-filtered), so the authorized frame would no longer match the shown screen: in the mass-covered fallback the screen surfaces covered refs that #1257's non-covered-only frame filter excluded, leaving the agent a ref the screen advertised but the frame rejects. Extract selectDivergenceScreenRefNodes as the single source of truth for which nodes screen.refs publishes and in what order. Both the rendered digest (buildReplayDivergenceScreenRefs) and the partial-frame authorization (captureDivergenceObservation -> markSessionPartialRefsIssued) derive from it, so the frame authorizes exactly the emitted set — preserving BOTH #1257's ADR-0014 intent and #1264's ranking/occlusion intent. Also refresh the captureDivergenceObservation doc to the partial-frame sequence. Test: assert the partial ref frame scope (session.refFrameScope) equals the emitted screen.refs set in the mass-covered fallback (covered refs included) — verified to fail on #1257's original non-covered-only digestBodies. Refs #1264 #1257 --- .../session-replay-divergence.test.ts | 56 ++++++++++++++++ .../handlers/session-replay-divergence.ts | 65 ++++++++++++------- 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 4dd59f6b8..ec13a0144 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -828,6 +828,62 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla expect(screen.refs.some((ref) => ref.label === 'Field A')).toBe(true); }); +// #1257 + #1264 reconciliation: the ADR-0014 partial ref frame the divergence +// capture activates (`markSessionPartialRefsIssued`) must authorize EXACTLY the +// refs the `screen.refs` digest renders — both now derive from the shared +// `selectDivergenceScreenRefNodes`. This is load-bearing in the mass-covered +// fallback: the screen surfaces COVERED refs, which #1257's original +// non-covered-only `digestBodies` filter would have EXCLUDED from the frame — +// leaving the agent a ref the screen advertised but the frame rejects. Assert +// the frame scope equals the emitted screen ref set (covered refs included). +test('buildReplayFailureDivergence: the partial ref frame authorizes exactly the emitted screen.refs, including mass-covered fallback refs (#1257 + #1264)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-frame-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + const appBundleId = 'com.callstack.agentdevicelab'; + sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + + mockDispatchCommand.mockReset(); + mockDispatchCommand.mockResolvedValue({ + nodes: bareScrimMassCoveringApp(appBundleId), + truncated: false, + backend: 'android', + }); + + const action = { + ts: 0, + command: 'press', + positionals: ['label="Field A"'], + flags: {}, + result: { selectorChain: ['label="Field A"'] }, + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + session: sessionStore.get(sessionName), + sessionName, + sessionStore, + logPath: path.join(root, 'daemon.log'), + responseLevel: 'default', + planActions: [action], + planDigest: 'test-plan-digest', + }); + + const screen = divergence.screen as Extract; + const screenRefBodies = new Set(screen.refs.map((ref) => ref.ref)); + expect(screenRefBodies.size).toBeGreaterThan(0); + + // The partial ref frame the capture activated authorizes exactly the emitted + // screen refs — no more (no over-pin surface), no less (every advertised ref + // is usable), even though every one of them is a `covered` node here. + const stored = sessionStore.get(sessionName); + expect(stored?.refFrameState).toBe('active'); + expect(stored?.refFrameScope).toEqual(screenRefBodies); +}); + // #1264 (capture parity, point 1): the divergence capture must go through the // SAME `captureSnapshot` wrapper as a plain `snapshot`, so it inherits Android // freshness + post-action retry. Otherwise a divergence could consume the first diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index a4b38cd83..e1de6c6ad 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -198,8 +198,10 @@ export function toReplayRepairHintCapture( } /** - * The single post-failure capture, blessed via the standard ref-issuing - * sequence (setSessionSnapshot -> markSessionSnapshotRefsIssued -> store). + * The single post-failure capture, blessed via the ADR-0014 partial ref-issuing + * sequence (setSessionSnapshot -> markSessionPartialRefsIssued -> store): a + * divergence screen publishes only its bounded ref set, so it activates a + * PARTIAL frame authorizing exactly those bodies, not a complete namespace. * Sparse captures do not write back (selector-capture reliability contract), * so a sparse verdict degrades the whole observation. * @@ -251,19 +253,17 @@ export async function captureDivergenceObservation(params: { }; } setSessionSnapshot(session, snapshot); - // ADR 0014: the divergence screen digest publishes only its capped, - // non-covered, non-chrome refs — the same set `buildReplayDivergenceScreenRefs` - // renders. Activate a PARTIAL frame authorizing exactly those bodies. - const chromeRefs = collectSettleChromeRefs(snapshot.nodes, session.appBundleId); - const digestBodies = snapshot.nodes - .filter( - (node) => - node.ref !== undefined && - node.interactionBlocked !== 'covered' && - !chromeRefs.has(node.ref), - ) - .slice(0, SCREEN_REF_CAPTURE_LIMIT) - .map((node) => node.ref as string); + // ADR 0014 (#1257) + #1264: the divergence screen publishes exactly the + // ranked, occlusion-resolved, capped ref set `screen.refs` renders. Activate + // a PARTIAL frame authorizing precisely THOSE bodies — derived from the same + // `selectDivergenceScreenRefNodes` the digest uses, so the frame never + // authorizes a ref the screen hides (over-pin risk) nor rejects one the + // screen advertised (e.g. the mass-covered fallback surfaces covered refs + // that the old non-covered-only filter would have excluded here). + const digestBodies = selectDivergenceScreenRefNodes( + snapshot.nodes, + session.appBundleId, + ).nodes.map((node) => node.ref as string); markSessionPartialRefsIssued(session, digestBodies); sessionStore.set(sessionName, session); return { @@ -372,14 +372,20 @@ function isForeignOverlayDismissTarget( ); } -function buildReplayDivergenceScreenRefs( +/** + * The single source of truth for which nodes a divergence `screen.refs` + * publishes, and in what order. Both the rendered `screen.refs` digest + * (`buildReplayDivergenceScreenRefs`) AND the ADR-0014 partial ref frame the + * capture authorizes (`captureDivergenceObservation` → + * `markSessionPartialRefsIssued`) derive from THIS function, so the authorized + * ref set is exactly the set the agent is shown — never a superset it can pin + * refs outside of, nor a subset that rejects a ref the screen advertised. + * Returns the capped node list plus whether ranking overflowed the cap. + */ +function selectDivergenceScreenRefNodes( nodes: SnapshotNode[], - sanitize: DivergenceFieldSanitizer, appBundleId: string | undefined, -): { - refs: ReplayDivergenceScreenRef[]; - truncated: boolean; -} { +): { nodes: SnapshotNode[]; truncated: boolean } { // Keyboard/IME chrome must not consume the ref budget: it reuses the exact // structural classifier `--settle`'s tail already relies on (#1198/#1200) // rather than a second keyboard/IME node-type list. @@ -411,7 +417,20 @@ function buildReplayDivergenceScreenRefs( ...pool.filter((node) => isForeignOverlayDismissTarget(node, appBundleId)), ...pool.filter((node) => !isForeignOverlayDismissTarget(node, appBundleId)), ]; - const refs = ranked.slice(0, SCREEN_REF_CAPTURE_LIMIT).map((node) => { + const selected = ranked.slice(0, SCREEN_REF_CAPTURE_LIMIT); + return { nodes: selected, truncated: ranked.length > selected.length }; +} + +function buildReplayDivergenceScreenRefs( + nodes: SnapshotNode[], + sanitize: DivergenceFieldSanitizer, + appBundleId: string | undefined, +): { + refs: ReplayDivergenceScreenRef[]; + truncated: boolean; +} { + const { nodes: selected, truncated } = selectDivergenceScreenRefNodes(nodes, appBundleId); + const refs = selected.map((node) => { const role = formatRole(node.type ?? 'Element'); const label = displayLabel(node, role); return { @@ -420,7 +439,7 @@ function buildReplayDivergenceScreenRefs( ...(label ? { label: sanitize(label) } : {}), }; }); - return { refs, truncated: ranked.length > refs.length }; + return { refs, truncated }; } const BASIS_RANK: Record = {