Skip to content

fix: unify divergence screen capture with snapshot's full-window scope#1265

Merged
thymikee merged 5 commits into
mainfrom
feat/unify-divergence-capture-scope
Jul 14, 2026
Merged

fix: unify divergence screen capture with snapshot's full-window scope#1265
thymikee merged 5 commits into
mainfrom
feat/unify-divergence-capture-scope

Conversation

@thymikee

@thymikee thymikee commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Implements #1264's decided direction: unify the replay-divergence screen capture with snapshot's
full-window scope, so an agent never sees a healthier screen in a divergence report than a plain
snapshot would show — separate-window system overlays (volume dialog, quick-settings shade, permission
dialogs) must appear in divergence screen.refs exactly as snapshot shows them, instead of being
invisible to both the dismiss target and repairHint.

  • Capture unification: captureDivergenceObservation (src/daemon/handlers/session-replay-divergence.ts)
    now calls captureSnapshotData — the same function snapshot itself builds its capture with
    (src/daemon/handlers/snapshot-capture.ts) — instead of a parallel hand-rolled dispatchCommand call.
    This picks up every scope branch snapshot supports (Android's snapshot-helper full-window route with
    its graceful app-scoped fallback, iOS's bounded system-modal probe path, macOS/Linux surface-scoped
    captures) instead of duplicating only the generic branch.
  • Filters stay filters: the chrome filter (collectSettleChromeRefs /
    ANDROID_SYSTEM_CHROME_MARKER_LEAF_IDS, src/core/snapshot-chrome.ts) and the meaningful-target filter
    (isMeaningfulDivergenceTarget) are unchanged — they already operate as filters over whatever nodes
    they're handed, never as a scoping. The fix was ensuring the capture itself is full-window, not narrowing
    or widening these filters.
  • ADR-0012 amendment: docs/adr/0012-interactive-replay.md decision 4's screen semantics gain an
    explicit amendment paragraph (plus a Status-section entry) stating the invariant: screen.refs is a
    filtered digest of the exact same tree snapshot would return, and an agent must never see a healthier
    screen in a divergence report than a plain snapshot would show it.
  • Test-validity sub-tasks (from the issue's live investigation, finding 2):
    src/core/__tests__/snapshot-chrome-android-statusbar.test.ts's synthetic volume-dialog test used a
    non-existent volume_dialog_slider id — replaced with the real, live-verified
    volume_new_ringer_active_icon_container id, and the test's comment now reads explicitly as a
    filter-logic unit test (isolated collectSettleChromeRefs exercise), not a live-capture-path claim.
  • New invariant coverage: src/daemon/handlers/__tests__/session-replay-divergence.test.ts gains a
    test that feeds buildReplayFailureDivergence a full Android capture (real non-raw walk) containing a
    systemui overlay window (volume dialog, real ids) alongside status-bar/IME chrome and app content —
    asserting the overlay's actionable ref survives into screen.refs while status/nav/IME chrome is still
    filtered and app content is untouched.

Why captureSnapshotData and not a deeper native change

captureDivergenceObservation's previous hand-rolled dispatchCommand('snapshot', ...) call and
snapshot's own capture path already converged on the identical low-level per-platform capture
(Android/iOS both ultimately call the same dispatchCommand('snapshot', ...) under the hood), so this is
not a "genuinely different code path requiring a larger refactor" per the task's stop condition. But the
hand-rolled call bypassed captureSnapshotData's platform branching entirely for macOS surface-scoped
sessions (isMacOs(device) && session.surface !== 'app'runMacOsSnapshotAction) and Linux AT-SPI
sessions (snapshotLinux), always falling through to the generic XCTest/Android branch instead — a
concrete, verifiable full-scope gap for those platforms. Routing through captureSnapshotData closes that
gap and removes any possibility of future incidental drift between the two call sites, which is the
literal ask in the issue ("find where snapshot builds its full capture and reuse that scope").

I could not reproduce the Android live-investigation numbers from #1264 on a physical/emulator device in
this environment (no Android/iOS runtime available here), so I can't independently confirm the exact
native-layer mechanism behind the "0 systemui/volume nodes" finding. The unification above is the
maintainer-directed fix and is verified at the unit level (new test above); the live acceptance recipe
below re-runs the maintainer's own experiment for confirmation on a real device.

Live acceptance recipe (for the maintainer's device setup — not run in this PR)

  1. Boot an Android emulator with the snapshot-helper installed, open the test-app, and start a replay --save-script (or any) run that fails while a system overlay is up.
  2. Held volume dialog: trigger the hardware volume key (or adb shell input keyevent KEYCODE_VOLUME_UP) repeatedly across the failing step so VolumeDialog stays up through the capture.
    The resulting REPLAY_DIVERGENCE's details.divergence.screen.refs should include the volume dialog's
    hittable control(s) (e.g. volume_new_ringer_active_icon_container), not just app-content refs.
  3. Persistent quick-settings shade: expand QS (two-finger swipe down / adb shell cmd statusbar expand-settings) before and keep it up after the failing step. screen.refs should include QS's
    actionable tiles, not be empty.
  4. Chrome still filtered: in both captures above, confirm clock/battery/status-bar and nav-bar
    entries are absent from screen.refs (the fix: exclude keyboard/IME chrome from replay divergence screen.refs #1233/fix: Android status/nav-bar systemui chrome leaks into non-raw captures (#1251) #1256 filter still applies on top of the now-full
    capture).
  5. Permission dialog divergence: reproduce a permission-prompt-caused divergence (e.g. the
    springboard-system-alert-repro recipe's Android analogue, or any runtime permission dialog covering
    the app) and confirm the dialog's own refs appear in screen.refs and repairHint does not read the
    app underneath as healthy.

This recipe is the same one documented live in #1264's investigation; it requires the maintainer's device
setup (emulator/simulator + snapshot-helper) and is not part of this PR's automated verification.

Test plan

  • npx tsc --noEmit
  • npx oxlint --deny-warnings
  • npx oxfmt --check (no new formatting issues introduced; the 38 files flagged pre-exist on
    origin/main)
  • npx vitest run over snapshot-chrome*, session-replay-divergence*, snapshot-capture*,
    settle-related, session-replay-target-verification-runtime, replay/__tests__/divergence, and
    the full src/daemon/handlers/__tests__ + src/replay + src/core/__tests__ suites (105 files /
    1021 tests, all passing)
  • npx fallow audit --base origin/main — no issues in the 4 changed files
  • Live acceptance recipe above — for the maintainer's device setup, not run in this PR

Status update (head 9cb3188ab) — the real fix evolved during review

The summary above describes the round-1 framing (capture scope). Maintainer review established that #1264's
root cause is cap burial in screen.refs selection, not capture scope, and flagged two remaining
capture-parity gaps. The branch now carries three layered changes; see the PR comment thread for the full
point-by-point mapping. Net state:

  1. Capture unification — the divergence capture routes through the same captureSnapshot wrapper as
    plain snapshot (full-window scope + Android freshness / post-action retry parity), under a clean,
    fixed capture-flags policy
    (full-window, non-raw, default depth) so a failed --raw/scoped/-d action
    can't narrow the diagnostic tree. (Supersedes the round-1 captureSnapshotData routing described above.)
  2. Ranking within the cap — the 20-ref cap is a byte bound, not a first-20-in-document-order policy;
    foreign-window (non-app-bundleId) hittable dismiss targets are promoted ahead of app content (stable
    within tiers), so a fully-captured overlay is never buried past the cap. Plus an occlusion fallback: a
    mass-covered app surfaces its covered nodes rather than emitting an empty refs.
  3. Filters unchanged — chrome (fix: exclude keyboard/IME chrome from replay divergence screen.refs #1233/fix: Android status/nav-bar systemui chrome leaks into non-raw captures (#1251) #1256) and meaningful-target filters stay layered on top.

repairHint/suggestions consume the full captured node list, not the capped slice, so hint routing is
unaffected. ADR-0012 decision 4's amendment reflects all of the above.

Live overlay acceptance remains a maintainer device step (env down): the unit fixtures prove ref
selection and capture-wrapper parity, not that the Android helper returns the separate-window overlay at
divergence time — see the recipe above.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB +75 B
JS gzip 550.4 kB 550.4 kB -2 B
npm tarball 663.7 kB 663.7 kB +6 B
npm unpacked 2.3 MB 2.3 MB +75 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.4 ms 25.9 ms +0.5 ms
CLI --help 55.8 ms 57.0 ms +1.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js -12 B -31 B
dist/src/session.js +87 B +30 B

@thymikee

Copy link
Copy Markdown
Member Author

Verified this diff against origin/main (tracing, not trusting the self-report): sound. Confirmed the capture unification is a DRY refactor for Android/iOS — both already used captureSnapshotData, and appBundleId is a content-recovery hint (classifyAndroidHelperContentRecovery), not a scope; the real behavioral delta is closing the macOS-menubar/Linux surface gap. The volume_dialog_slidervolume_new_ringer_active_icon_container fix is correct (verified the real id from a raw capture).

One framing note (correction posted on #1264): the Android divergence-scope "bug" that partly motivated this was a measurement (timing) confound on my end — the code has no app-scoping in the divergence path. This PR's genuine value is unification + macOS/Linux + the ADR invariant (now provably true) + the test-id fix. Merge on those merits; the live acceptance (held overlay in screen.refs) confirms once devices are back.

@thymikee

Copy link
Copy Markdown
Member Author

I found two issues and would request changes before merging:

  1. Android capture behavior is unchanged. The motivating Unify divergence screen capture with snapshot's full-window scope (separate-window overlays invisible to repair; #1256 follow-up) #1264 failure is Android, but captureSnapshotData only introduces distinct branches for Linux and non-app macOS surfaces. Android and iOS still execute the same dispatchCommand(device, "snapshot", …) call with effectively the same context as the removed code. No new scope, backend option, or full-window signal reaches snapshotAndroid, so this does not explain how volume/QS overlay windows would become visible. Please identify the actual difference between plain snapshot and divergence capture and route that distinction explicitly.

  2. The regression test passes before the fix. The new test injects an already-full tree containing the synthetic overlay through the mocked dispatchCommand. The previous implementation receives and filters that exact tree too, so reverting the production change would still leave this test green. Please add coverage that distinguishes the app-scoped and full-window capture routes—such as asserting the routing/backend request—or provide live/integration evidence reproducing Unify divergence screen capture with snapshot's full-window scope (separate-window overlays invisible to repair; #1256 follow-up) #1264.

All completed CI checks are green; the iOS smoke test was still pending when reviewed.

@thymikee

Copy link
Copy Markdown
Member Author

Review: keep the refactor, but it does not fix #1264 — the archived live evidence points at divergence-side ref selection, not capture scope.

Verified against origin/main:

  1. The unification is a no-op on Android/iOS (as this issue itself notes): the pre-fix captureDivergenceObservation and captureSnapshotData's generic branch make the identical call — dispatchCommand(device, 'snapshot', [], …, contextFromFlags(logPath, flags, session.appBundleId, …)). The only real behavior changes are the macOS surface-scoped and Linux AT-SPI branches. Worth keeping for that and for drift removal — but it cannot explain or fix the Android finding.
  2. Cap burial explains the volume-dialog result. The archived evidence (~/.agent-device-bench/wave3/E4/volume-dialog-held-up-divergence-0-systemui.json) has screen.truncated: true — the report itself declared it dropped candidates beyond 20. buildReplayDivergenceScreenRefs (session-replay-divergence.ts) is candidates.slice(0, SCREEN_REF_CAPTURE_LIMIT) in document order; overlay windows enumerate after the app window's ~77 nodes, so a fully-captured volume dialog sits at position ~78 and is truncated away. "20 app refs, zero volume refs, truncated" is exactly what a full capture produces under this code — no scoping difference required. The "Unify divergence screen capture with snapshot's full-window scope (separate-window overlays invisible to repair; #1256 follow-up) #1264 = app-window-scoped capture" diagnosis should be treated as unproven.
  3. The QS-shade refs: 0 result fits the occlusion filter: interactionBlocked !== 'covered' removes every app node when the shade covers them. Whether the shade's own nodes were captured (and why they didn't surface) is the one genuinely open live question.
  4. The new unit test doesn't prove its claimed invariant: its fixture is small enough that the overlay fits inside the 20-cap. On a realistic tree the overlay is buried. That repeats the category error Unify divergence screen capture with snapshot's full-window scope (separate-window overlays invisible to repair; #1256 follow-up) #1264 called out (filter-level evidence presented as capture-path evidence), one level up.

Actionable changes for this fix:

  • Keep the captureSnapshotData unification + test-id/comment fixes as-is.
  • Add the real fix: rank within the cap instead of document-order slicing. Non-app-bundle hittable nodes (the dismiss targets for whatever blocks the app) get priority or reserved slots ahead of app content. The 20-cap exists for byte bounds, not as a "first 20 in tree order" policy.
  • Handle the occlusion interaction: when meaningful app nodes are mass-covered, the covering window's nodes ARE the repair surface — a report whose capture contains nodes but whose refs is empty is broken by construction.
  • Fix the unit test: use the realistic full fixture with the overlay appended last in document order and assert it still lands within the 20 refs — that is the actual regression test for cap burial.
  • Then run the live recipe to settle the remaining unknown (do shade/volume windows appear in the raw capture at divergence time at all).

Mitigating nuance worth recording: repairHint and suggestions consume the full captured node list, not the 20-ref slice — so hint routing may already behave correctly once the overlay is captured; it is the agent-visible screen.refs that lies.

@thymikee

Copy link
Copy Markdown
Member Author

Verified your review — cap burial is correct. The archived JSON has screen.truncated: true and buildReplayDivergenceScreenRefs slices candidates.slice(0, 20) in document order, so a full-captured overlay at ~position 78 is truncated. Both my earlier reads (capture-scope, then timing) were wrong — posted a correction on #1264.

Implementing your actionable list on this branch now:

  1. Keep the captureSnapshotData unification + test-id/comment fixes.
  2. Rank within the cap instead of document-order slicing — non-app-bundle hittable nodes (dismiss targets) get priority/reserved slots ahead of app content.
  3. Occlusion handling — when meaningful app nodes are mass-covered, surface the covering window's nodes; a report with captured nodes but empty refs is broken by construction.
  4. Fix the unit test — realistic full fixture, overlay appended last in document order, assert it lands within the 20 refs (the actual cap-burial regression).
  5. Live recipe noted for the maintainer's device (do shade/volume windows appear in the raw capture at divergence time).

Recording your nuance: repairHint/suggestions consume the full node list, not the 20-slice, so hint routing may already be correct once captured — it's screen.refs that lies. Fix scoped accordingly.

@thymikee

Copy link
Copy Markdown
Member Author

Thanks — the cap-burial diagnosis is correct, and I've verified it against the code and reproduced it in a unit test. Pushed 7afa75809 on top of the unification commit. Mapping your actionable list to the change:

1. Keep the captureSnapshotData unification + test-id/comment fixes as-is. Kept (commit 0c1f9a810, unchanged). The unification stays for the macOS-menubar/Linux-surface gap it closes and for drift removal — not as the #1264 fix, which it is not.

2. Rank within the cap instead of document-order slicing. Done in buildReplayDivergenceScreenRefs (session-replay-divergence.ts). The .slice(0, 20) now runs over a ranked list: foreign-window (node.bundleId !== appBundleId) hittable nodes — the dismiss targets for whatever covers the app — 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. New helper isForeignOverlayDismissTarget encodes the ownership test; it keys off per-node bundleId (Android sets it from the a11y package; iOS/macOS leave it undefined, so ranking degrades to document order there — which is correct, those platforms surface separate-window modals through the probe path, not by cap-competing). Guarded on a known appBundleId so a sessionless capture never reorders.

3. Occlusion interaction. Done. When a system overlay mass-covers the app, annotateCoveredSnapshotNodes marks every app node interactionBlocked: 'covered'. The filter now excludes covered nodes only while non-covered candidates remain; if the entire meaningful surface is covered, the 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, exactly as you said. (The covering overlay's own actionable nodes are not covered, so in the normal overlay case they rank first and surface directly.)

4. Fix the unit test to actually catch cap burial. Done. I replaced the small-fixture #1264 test (which — as you flagged — let the overlay fit inside the cap regardless of order, so it proved nothing) with a realistic full fixture: 24 app controls enumerate in document order, the overlay's lone dismiss-target is captured last (position 24), 25 meaningful > the 20-cap. It asserts the overlay still lands within screen.refs (and is ranked first). I verified it fails on document-order slicing (overlay truncated away, screen.truncated: true) and passes with the ranking. Added two occlusion tests: mass-covered app → overlay dismiss-target surfaces and refs is non-empty; bare-scrim mass-cover with no actionable overlay node → covered app content surfaced rather than empty refs (also verified to fail without the fallback). The retained real-walked-fixture test is retitled to state it proves chrome-filter/overlay coexistence, not cap-burial resistance.

5. Nuance preserved. repairHint (decision 6) and suggestions (decision 1) still consume the full captured node list (observation.nodes), not the capped refs slice — the ranking/occlusion change is scoped to screen.refs selection only, so hint routing is unaffected. Called out in code comments and in the ADR amendment.

ADR-0012 decision 4's amendment is reworded from "capture scope" to two guarantees — capture scope and within-cap ref selection (ranking + occlusion) — so it no longer overclaims that scope alone upholds the invariant.

Live acceptance (for your device setup — not run here; device env is down): re-run the archived experiment — held volume dialog and persistent QS shade must appear in screen.refs (hittable entries retained, clock/battery excluded), and a permission-dialog-caused divergence must carry the dialog's refs. The remaining open question your review named — whether the shade/volume windows appear in the raw capture at divergence time at all — is answerable only on-device; the code now guarantees that if they are captured, they are neither buried past the cap nor dropped by occlusion.

Verify (in the worktree): npx tsc --noEmit clean; npx oxlint --deny-warnings clean; npx oxfmt --check clean on the changed .ts files (the ADR is flagged but was already oxfmt-nonconforming on origin/main, unrelated to this change); full src/daemon/handlers/__tests__ + src/replay + src/core/__tests__ = 105 files / 1024 tests green; fallow audit --base origin/main no issues in the 4 changed files.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of exact head 7afa758: the cap ranking and occlusion regression is improved, but the new ADR invariant is still not proven by the production route.

  1. captureDivergenceObservation calls captureSnapshotData plus buildSnapshotState, while plain snapshot goes through the higher captureSnapshot wrapper. That wrapper owns Android freshness and post-action retry. A divergence can therefore consume the first stale or app-scoped dump while plain snapshot retries to the fresh full-window tree. Route both through the same wrapper, or extract the shared freshness policy, and add a regression where capture 1 is stale and capture 2 contains the overlay.
  2. The divergence capture spreads action.flags; snapshotRaw, snapshotScope, and snapshotDepth can narrow or change this diagnostic tree. Build flags from a clean divergence-capture policy so a failed scoped or raw action cannot violate the full-tree invariant.
  3. Live overlay acceptance remains unchecked. The unit fixtures prove selection after nodes are supplied, not that the Android helper returns the separate-window overlay at divergence time.

All checks are green, but this head is not merge-ready.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed the re-review of 7afa75809. Pushed 9cb3188ab. Mapping each point:

1. Complete the capture unification (freshness/retry parity). You're right — captureDivergenceObservation called captureSnapshotData (the inner single-shot), while plain snapshot's backend (dispatchSnapshotViaRuntimecreateDaemonSnapshotBackend, snapshot-runtime.ts:307) calls the higher captureSnapshot wrapper that owns Android freshness + post-action retry (capturePostActionAwareSnapshot). So a divergence could consume the first stale/app-scoped dump while a plain snapshot retries to the fresh full-window tree. Fixed: the divergence capture now routes through the same captureSnapshot wrapper. No fork — the wrapper's params (device, session, flags, logPath) are all suppliable from the divergence path; it returns the built SnapshotState directly, so I dropped the separate buildSnapshotState call too.

Regression added (#1264 capture parity): the session carries an active Android freshness marker (baselineCount: 20); the mocked first on-device dump is a stale near-empty tree (single bare view → sharp-drop vs the 20-node baseline → retry), and only the retried second dump holds the system overlay. It asserts the divergence dispatched twice and that screen.refs contains the overlay — i.e. it used the retried fresh tree. Verified this test fails on the pre-fix single-shot path (1 dispatch, no overlay).

2. Clean divergence-capture flags policy. Fixed. The capture no longer spreads action.flags; it builds flags from a fixed policy via a new divergenceCaptureFlags helper — full-window (no snapshotScope), non-raw (no snapshotRaw), default depth (no snapshotDepth). Only the interactive-only policy is carried (full for non-rect get/is/wait, interactive otherwise). So a failed snapshot --raw/scoped/-d action can no longer narrow or reshape the diagnostic screen.

Regression added (#1264 clean flags policy): a failed action with { snapshotRaw: true, snapshotScope: '@e5', snapshotDepth: 2 } — asserts the snapshot dispatch context drops snapshotRaw/snapshotScope/snapshotDepth while still applying snapshotInteractiveOnly. Verified it fails on the pre-fix flag-spread (raw leaks through as true).

(The helper extraction keeps captureDivergenceObservation within the complexity budget — fallow audit --base origin/main is clean, no waiver.)

3. Live overlay acceptance — stays a maintainer step. Kept documented in the PR body, not faked. The unit fixtures prove ref selection after nodes are supplied and now also prove the divergence uses the same fresh-capture wrapper as snapshot; they do not prove the Android helper returns the separate-window overlay at divergence time — that is answerable only on-device. Recipe unchanged: held volume dialog + persistent QS shade must appear in screen.refs (hittable retained, clock/battery excluded); a permission-dialog divergence must carry the dialog's refs.

Everything from the prior rounds is kept (cap-ranking + occlusion + test-id/comment fixes + unification).

Verify (in the worktree): tsc --noEmit clean; oxlint --deny-warnings clean; oxfmt --check clean on the changed .ts files (the ADR remains oxfmt-nonconforming as on origin/main — pre-existing, unrelated); full src/daemon/handlers/__tests__ + src/replay + src/core/__tests__ = 105 files / 1026 tests green (settle suite green too); fallow audit --base origin/main no issues in the 4 changed files.

Head: 9cb3188ab.

@thymikee

Copy link
Copy Markdown
Member Author

Re-review of exact head 9cb3188: the two functional findings are fixed. Divergence now uses the full captureSnapshot freshness wrapper, the narrowing action flags are stripped, and the regressions fail on the prior behavior.

One small code issue remains: the new sharp-drop freshness regression waits on the real 250 ms Android retry delay. Repo guidance forbids real-time waits in unit tests; mock the delay or use fake timers while retaining proof that the retry branch executes.

Separately, merge readiness still requires the documented live Android overlay run. The current mocked fixtures do not prove that the helper returns separate-window overlay nodes at divergence time.

Not merge-ready yet.

@thymikee

Copy link
Copy Markdown
Member Author

Fixed the real-time-wait in the freshness regression. Pushed 340b66735 (test-only; the production files are byte-identical to 9cb3188ab).

How: the retry delay is sleep() from src/utils/timeouts.ts, which snapshot-capture.ts's retry path (capturePostActionAwareSnapshot) awaits — a plain module export, so it's mockable without any production seam. The test file now vi.mocks ../../../utils/timeouts.ts to stub sleep to a no-op (partial mock via importOriginal, so nothing else changes). Making the delay instant doesn't alter control flow — the retry loop still runs, retries, and re-captures.

Retained proof of the retry branch: the capture-parity test still asserts (a) dispatchCommand was called twice (stale dump → retry → fresh dump) and (b) screen.refs contains the overlay that exists only in the retried second capture. I re-verified it still fails on the pre-fix single-shot path (1 dispatch) with the delay stubbed — so the stub removes the wall-clock wait without making the assertion vacuous.

Measured effect: the divergence suite's tests time dropped from ~270 ms (dominated by the real ~250 ms sleep) to ~19–22 ms; no test in the file now real-waits.

Live Android overlay acceptance stays the maintainer's on-device step (env down) — documented in the PR body, not faked: held volume dialog + persistent QS shade must appear in screen.refs (hittable retained, clock/battery excluded); a permission-dialog divergence must carry the dialog's refs.

Verify (worktree): tsc --noEmit clean; oxlint --deny-warnings clean; oxfmt --check clean on the changed .ts files; fallow audit --base origin/main no issues (no waiver); divergence suite 11/11 green (parity regression passes, no longer real-waits).

Head: 340b66735.

@thymikee thymikee force-pushed the feat/unify-divergence-capture-scope branch from 340b667 to d7046b0 Compare July 14, 2026 19:20
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto the new origin/main (0436793c2, #1267 — dual-path resume.from/alternateFrom). New head: d7046b052.

Conflicts: none — the rebase applied all four of my commits cleanly with no conflict markers. I still hand-verified the two overlap areas #1267 touched:

  • ADR-0012 decision 4 composes coherently. fix(replay): extend resume.from record-and-heal shape to caution/manual #1267's amendments live in the resume block (resume: { …, alternateFrom?, … } wording, the caution/manual dual-path, empty-tail semantics) — all 8 of its markers are present and intact, just shifted down by the size of my block. My #1264 amendment sits in the screen subsection (capture scope + within-cap ref selection), immediately after the screen discriminated-union paragraph. Different concerns, one consistent decision-4 section — no duplicated or contradictory amendment blocks. fix(replay): extend resume.from record-and-heal shape to caution/manual #1267 added no Status-section entry, so no overlap there either.
  • session-replay-divergence.ts — my changes (captureDivergenceObservationcaptureSnapshot wrapper + clean flags; buildReplayDivergenceScreenRefs ranking/occlusion) and fix(replay): extend resume.from record-and-heal shape to caution/manual #1267's plumbing (sessionExists: session !== undefined feeding the resume block) are in different functions and coexist. No touch to session-replay-resume.ts / session-replay-target-verification.ts from my side.

Re-verified against the merged base: tsc --noEmit clean; oxlint --deny-warnings clean; oxfmt --check clean on the changed .ts files; fallow audit --base origin/main no issues (no waiver); my divergence suite 11/11 green (including the freshness-parity regression, still no real-wait); #1267's expanded suites green too (replay/__tests__/divergence 28, session-replay-resume 27); full src/daemon/handlers/__tests__ + src/replay + src/core/__tests__ = 105 files / 1047 tests green.

Head: d7046b052.

thymikee added 5 commits July 14, 2026 21:31
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
…uried

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
… flags policy

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
…'t real-wait

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
…ef frame

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
@thymikee thymikee force-pushed the feat/unify-divergence-capture-scope branch from d7046b0 to 0f7a41b Compare July 14, 2026 19:38
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto the new origin/main (54977f3b8, #1257 — ADR-0014 session ref-frame lifetime). New head: 0f7a41bca.

Unlike the #1267 rebase, this one had real conflicts on both overlapping files. Resolved preserving both intents:

src/daemon/handlers/session-replay-divergence.ts — conflict + a semantic reconciliation.

docs/adr/0012-interactive-replay.md — auto-merged, verified coherent. Decision 4 now carries three composed amendments, non-overlapping: my #1264 block (capture scope + within-cap ref selection) in the screen subsection; #1257's "ADR 0014 amendment (partial ref publication)" after the MCP ref-pinning paragraph (it correctly replaced the old Proposed ADR-0014 note); and #1267's alternateFrom wording in the resume block. Different concerns, one consistent section — no duplicated or contradictory blocks. #1257's amendment already asserts the frame authorizes "exactly the divergence screen's refs"; my code reconciliation makes that literally true.

Re-verified against the merged base: tsc --noEmit clean; oxlint --deny-warnings clean; oxfmt --check clean on the changed .ts files; fallow audit --base origin/main no issues (no waiver — the shared-selector extraction keeps each function within budget); my divergence suite 12/12 (including the new frame-coherence regression); #1257's ref-frame suites green (ref-frame 11, session-snapshot 5, android-system-dialog-ref-frame 1); broad src/daemon + src/replay + src/core/__tests__ = 178 files / 1569 tests green.

Head: 0f7a41bca.

@thymikee thymikee merged commit 6efe544 into main Jul 14, 2026
22 checks passed
@thymikee thymikee deleted the feat/unify-divergence-capture-scope branch July 14, 2026 19:46
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-14 19:47 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant