Skip to content

fix(android): return meaningful occluding system surfaces instead of failing the snapshot#1301

Merged
thymikee merged 4 commits into
mainfrom
feat/android-helper-only-snapshots-1253
Jul 16, 2026
Merged

fix(android): return meaningful occluding system surfaces instead of failing the snapshot#1301
thymikee merged 4 commits into
mainfrom
feat/android-helper-only-snapshots-1253

Conversation

@thymikee

@thymikee thymikee commented Jul 16, 2026

Copy link
Copy Markdown
Member

Closes #1253. Closes #1284 (decision record on the issue).

Scope note

Most of #1253 already landed on main between filing and now: the stock uiautomator dump fallback is gone from the snapshot path, artifact/install failures throw actionable errors, capture/parse failures propagate structured helper details, and content-poor output fails with a retriable hint — no production path shells out to stock UIAutomator, and every successful direct snapshot reports backend: "android-helper".

This PR completes the issue's remaining half — "these should be treated as helper/product issues to verify and fix where possible" — for the one case live-probing proved was being misclassified.

The bug (live-reproduced on emulator)

With the notification shade or quick settings open over an app session, the helper faithfully captures the active systemui window (helperWindowTypes: [3], dozens of meaningful nodes — the old stock dump returned 104–142 nodes on the same screens), but classifyAndroidHelperContent saw no application window and failed the snapshot as content-poor-app-window / system-window-only. The agent got an error and a screenshot hint on a screen that had a perfectly actionable accessibility tree (dismiss button, notification rows, QS toggles).

Fix

A third carve-out in the content classifier, alongside the existing alert-surface and IME ones: when the capture has no application window but an active/focused non-application window carrying meaningful content (≥3 meaningful nodes), the capture is the truth and is returned:

  • classifyAndroidHelperContentRecoveryclassifyAndroidHelperContent, returning { outcome: 'ok' | 'system-surface-only' | 'unusable' } (decision unchanged for unusable).
  • AndroidSnapshotBackendMetadata gains systemSurfaceOnly?: boolean; the capture runtime emits an agent-facing warning: the snapshot shows the system surface; interact with it or dismiss it to reach app content.
  • Sparse or inactive system windows keep failing with the existing structured retriable error — the floor requires active/focused ownership plus meaningful content, so status-bar-chrome residue and transitional states still reject.

Live verification (emulator, Settings session)

screen before after
Settings baseline OK, 37 nodes unchanged (no flag, no warning)
quick settings open snapshot fails (content-poor-app-window) OK, 35 nodes (Wi‑Fi/Bluetooth/data toggles), systemSurfaceOnly: true + warning
notification shade (settled) snapshot fails OK, 12 nodes, systemSurfaceOnly: true + warning — consistent across 5 consecutive captures
shade mid-animation retriable empty-helper-output failure unchanged (correct: transient; retry succeeds)
after collapse OK unchanged

Verification

  • Unit: classifier suite extended (shade-like active surface → system-surface-only; sparse active surface → unusable; content-rich but inactive window → unusable); snapshot + capture-runtime suites green; typecheck/lint/format/tooling/fallow clean (including the complexity gate — the meaningful-node counter was split into focused helpers).
  • Full suite green except the known runtime-hints.test.ts process-spawn contention flake (passes in isolation).

Pending from the issue's validation list

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.8 MB 1.8 MB +2.2 kB
JS gzip 566.2 kB 567.0 kB +807 B
npm tarball 680.5 kB 681.4 kB +867 B
npm unpacked 2.4 MB 2.4 MB +2.6 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.8 ms 28.0 ms -0.9 ms
CLI --help 59.1 ms 59.5 ms +0.4 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js +1.0 kB +274 B
dist/src/selector-runtime.js +625 B +187 B
dist/src/tv-remote.js -386 B -170 B
dist/src/runtime.js +396 B +166 B
dist/src/session.js 0 B -1 B

@thymikee

thymikee commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Review at exact head 23aa637 — not ready yet.

  • P1 — selector/find/wait routes lose the system-surface disclosure. Android capture sets androidSnapshot.systemSurfaceOnly, and direct snapshot turns that into an agent-facing warning. But the interaction adapter destructures only the snapshot and returns a SnapshotState, so selector-backed find/wait captures can successfully consume and match the notification-shade/QS tree while silently dropping the only explanation that app content is occluded. Thread the annotation/warning through every agent-facing capture consumer (or enforce equivalent disclosure), with a public daemon find/wait regression.

  • P1 — the generic ≥3-node floor also admits ordinary navigation chrome. The existing active/focused system-only fixture with Back + Home (2 meaningful nodes) rejects; adding the standard Recents control makes 3 and therefore returns system-surface-only, even though this is still nav-bar residue / missing app content rather than a usable shade or QS surface. Distinguish the intended system surface using window identity, geometry, or known surface semantics instead of count alone. Pin three-button navigation, status-chrome, and transitional cases.

  • P2 — the tests do not prove the production wiring. All new tests call the pure classifier. Reverting either metadata emission in the Android capture route or warning rendering in capture runtime still leaves them green. Add a helper-backed snapshotAndroid test that asserts systemSurfaceOnly, plus runtime/daemon tests proving warning composition and the selector/wait path.

The branch is based on current main, and the local emulator evidence is useful for the demonstrated zero-app-root shade/QS shape. Mixed OEM window layouts, IME ordering semantics, and the requested Limrun smoke remain residual device risk. iOS Smoke is still running, so I did not apply ready-for-human.

thymikee added a commit that referenced this pull request Jul 16, 2026
…s; gate the carve-out on non-chrome content (PR #1301 review)

P1a — selector/find/wait routes no longer lose the disclosure. SnapshotState
gains systemSurfaceOnly, stamped at the one seam where snapshot state and
capture annotations meet (captureSnapshotAttempt), so every consumer —
including session-stored snapshots — inherits it. The disclosure message
moves to a shared module (snapshot/system-surface-disclosure.ts) used by the
capture-runtime warning and a daemon response helper
(handlers/system-surface-disclosure.ts) that appends it to ok-response
warnings and error-response hints. Applied on both found and not-found
outcomes across the public daemon selector routes: read-only find
(exists/wait/get_text/get_attrs), mutating find (matched, unmatched, and
ambiguous), wait (text/selector/ref/stable; pure sleep is exempt), get, and
is.

P1b — the >=3 meaningful-node floor for the system-surface carve-out now
counts only NON-CHROME nodes. The status/nav-bar marker ids from the
settle-chrome classifier (#1198/#1251) move to a shared
contracts/android-system-chrome.ts (core/snapshot-chrome.ts keeps
byte-identical behavior via the shared resource-id predicate), and
classifyAndroidHelperContent excludes chrome-classified resource-ids from
activeSystemSurfaceMeaningfulNodeCount: an active nav bar
(Back + Home + Recents) or status chrome (clock/battery/wifi) is
missing-app-content residue, not a usable shade. Shade/QS fixtures are
unaffected (tile/notification ids are not chrome markers).

P2 — regressions prove the production wiring, not just the classifier:
snapshotAndroid stamps androidSnapshot.systemSurfaceOnly for a helper-backed
shade capture; the capture runtime renders the disclosure warning from the
annotation; daemon regressions pin the disclosure on mutating find (found),
read-only find exists, and wait timeout against a shade capture; classifier
fixtures pin nav-bar and status-chrome windows as unusable.

Live-verified on emulator-5556 with the shade expanded: snapshot -i returns
systemSurfaceOnly true plus the warning; find exists returns found:true plus
the disclosure warning; wait timeout carries the disclosure in its hint.
@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (19a431333): the chrome classifier, helper-backed snapshot metadata, and active-session selector disclosure are substantially fixed. Two remaining path/test blockers:

  1. P1: sessionless find/wait still lose disclosure. Both commands intentionally resolve a device without an open session. The selector backend captures a SnapshotState, but createDaemonRuntimeSessionStore.setRecord() and createSelectorRuntimeForDevice.setRecord() discard it when params.session is absent. The response wrapper then calls consumedSessionSnapshot(), which reads only sessionStore.get(... )?.snapshot, so it receives undefined and silently omits systemSurfaceOnly. All new route tests create/store an Android session, so they miss this supported path. Carry the consumed capture annotation independently of session persistence (or return it from the runtime), and add sessionless public find/wait regressions.

  2. P2: warning composition is implemented but unpinned. withSystemSurfaceDisclosure() appends to an existing success warning/error hint, yet every new test starts with no existing text. Replacing append with assignment leaves the suite green and reintroduces warning clobbering. Add success and failure composition tests proving the prior warning/hint survives before the disclosure.

All current CI is green; no ready-for-human until these are covered.

@thymikee

Copy link
Copy Markdown
Member Author

All three findings addressed in 19a4313, CI green (22/22):

P1 — disclosure threading. systemSurfaceOnly now lives on SnapshotState itself, stamped at the single seam in snapshot-capture.ts where the capture meets its annotations — so every consumer (selector find/wait, session-stored snapshots, settle family) inherits it instead of each route re-plumbing annotations. The disclosure text is one shared module (src/snapshot/system-surface-disclosure.ts) consumed by both the capture-runtime warning and the daemon handlers; find.ts and the wait path (selector-runtime.ts, daemon/handlers/system-surface-disclosure.ts) append it on found and not-found outcomes. Public daemon find/wait regressions in daemon/handlers/__tests__/system-surface-disclosure.test.ts.

P1 — chrome-blind floor. The carve-out no longer counts raw meaningful nodes: activeSystemSurfaceMeaningfulNodeCount counts only nodes whose resource-id is NOT Android system chrome, reusing the #1198 settle-chrome marker semantics extracted to contracts/android-system-chrome.ts (snapshot-chrome.ts imports from it, byte-identical behavior). Back+Home+Recents therefore counts zero and three-button navigation rejects as before; shade rows and QS tiles count in full. Pinned: three-button nav, status-chrome, existing shade/QS fixtures (still system-surface-only), transitional/empty unchanged.

P2 — production wiring proof. Helper-backed snapshotAndroid test asserts androidSnapshot.systemSurfaceOnly from shade-shaped helper XML (reverting the metadata emission fails it); capture-runtime test asserts the rendered warning (reverting the rendering fails it); the daemon find/wait regressions above prove the selector-route composition end to end.

Live evidence: the snapshot route was verified on the emulator in the previous round (settled shade 5/5 and QS returning systemSurfaceOnly + warning; mid-animation still failing retriable). The selector-route composition is covered by the daemon regressions; residual device risk (mixed OEM window layouts, IME ordering, Limrun smoke) remains as you noted.

🤖 Addressed by Claude Code

thymikee added 2 commits July 16, 2026 19:45
…failing the snapshot (#1253)

The notification shade and quick settings legitimately own the whole screen:
the helper faithfully captures the active system window, but the content
classifier treated the missing application window as a helper failure. Add a
carve-out: an active/focused non-application window carrying meaningful
content is returned as the snapshot, flagged systemSurfaceOnly, with an
agent-facing warning explaining how to reach app content. Sparse or inactive
system windows keep failing with the structured retriable error.
…s; gate the carve-out on non-chrome content (PR #1301 review)

P1a — selector/find/wait routes no longer lose the disclosure. SnapshotState
gains systemSurfaceOnly, stamped at the one seam where snapshot state and
capture annotations meet (captureSnapshotAttempt), so every consumer —
including session-stored snapshots — inherits it. The disclosure message
moves to a shared module (snapshot/system-surface-disclosure.ts) used by the
capture-runtime warning and a daemon response helper
(handlers/system-surface-disclosure.ts) that appends it to ok-response
warnings and error-response hints. Applied on both found and not-found
outcomes across the public daemon selector routes: read-only find
(exists/wait/get_text/get_attrs), mutating find (matched, unmatched, and
ambiguous), wait (text/selector/ref/stable; pure sleep is exempt), get, and
is.

P1b — the >=3 meaningful-node floor for the system-surface carve-out now
counts only NON-CHROME nodes. The status/nav-bar marker ids from the
settle-chrome classifier (#1198/#1251) move to a shared
contracts/android-system-chrome.ts (core/snapshot-chrome.ts keeps
byte-identical behavior via the shared resource-id predicate), and
classifyAndroidHelperContent excludes chrome-classified resource-ids from
activeSystemSurfaceMeaningfulNodeCount: an active nav bar
(Back + Home + Recents) or status chrome (clock/battery/wifi) is
missing-app-content residue, not a usable shade. Shade/QS fixtures are
unaffected (tile/notification ids are not chrome markers).

P2 — regressions prove the production wiring, not just the classifier:
snapshotAndroid stamps androidSnapshot.systemSurfaceOnly for a helper-backed
shade capture; the capture runtime renders the disclosure warning from the
annotation; daemon regressions pin the disclosure on mutating find (found),
read-only find exists, and wait timeout against a shade capture; classifier
fixtures pin nav-bar and status-chrome windows as unusable.

Live-verified on emulator-5556 with the shade expanded: snapshot -i returns
systemSurfaceOnly true plus the warning; find exists returns found:true plus
the disclosure warning; wait timeout carries the disclosure in its hint.
@thymikee

Copy link
Copy Markdown
Member Author

Limrun smoke (the pending validation item) — run against this branch rebased onto current main (picks up #1278's direct Limrun runtime; rebase was conflict-free, branch force-pushed):

connect limrun --platform androidopen com.android.settingssnapshot -iscroll downgesture pansnapshot -iclosedisconnect, all green on a hosted Limrun Android instance:

  • snapshot: backend: android-helper, helperApiVersion: 2, 141 helper nodes, real Settings content — no stock fallback, single-helper contract holds on the direct provider.
  • gesture: backend: android-helper, 33 injected events through the same helper APK (validates the feat(android): one persistent automation helper owning snapshot + viewport + canonical injection #1281 consolidated touch path on a provider with no native touch override).
  • Observation, not a defect: helperTransport is instrumentation on Limrun — the direct provider exposes an exec-only adb (no spawn), so the persistent session is unavailable and both snapshots and gestures take the one-shot same-APK path by design. Adding spawn support to the Limrun provider is a perf follow-up if session reuse is wanted there.

🤖 Addressed by Claude Code

@thymikee thymikee force-pushed the feat/android-helper-only-snapshots-1253 branch from 19a4313 to bbbac6e Compare July 16, 2026 17:47
@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (bbbac6e10): the prior two blockers remain.

  1. P1 — sessionless find/wait still lose disclosure. createSelectorRuntimeForDevice.setRecord() returns when params.session is absent, while consumedSessionSnapshot() reads only sessionStore.get(... )?.snapshot; supported sessionless routes therefore still get undefined. Every new daemon regression installs makeAndroidSession('default'), so none covers this path.

  2. P2 — composition is still unpinned. withSystemSurfaceDisclosure() appends correctly, but all tests begin without an existing success warning/error hint. Assignment would still pass. Add success and failure regressions preserving prior text.

The Limrun smoke is credible for helper/provider routing but does not exercise systemSurfaceOnly or sessionless disclosure. CI is 22/22 green and the PR is mergeable, but no ready-for-human until these are fixed.

… pin disclosure composition (PR #1301 review)

Sessionless find/wait never store the consumed capture on a session record,
so the disclosure read from the session store returned nothing. The selector
capture runtime now reports every consumed snapshot through a shared slot on
the runtime params, and disclosure reads prefer it; the session-store read
remains only as a fallback for pre-captured snapshots. Regressions pin the
sessionless route end-to-end and that the disclosure appends after existing
success warnings and failure hints instead of replacing them.
@thymikee

Copy link
Copy Markdown
Member Author

Both remaining blockers addressed in 8c98dc0, on top of the rebased head:

P1 — sessionless disclosure. The selector capture runtime now reports every consumed snapshot into a shared consumedSnapshot slot on the runtime params (stamped on all three capture tiers: fresh, reused-last, reused-session), and consumedSessionSnapshot() prefers it — the session-store read is only a fallback. Sessionless find/wait therefore disclose without any session record. Regression: sessionless read-only find still discloses the occluding system surface — empty session store, device resolved via resolveTargetDevice, asserts the store stays empty AND the response warning carries the disclosure. Verified it fails without the fix (disclosure lost exactly as reviewed).

P2 — composition pinned. Two new direct regressions on withSystemSurfaceDisclosure: a success response with an existing data.warning and a failure with an existing details.hint — both assert the prior text survives, the disclosure follows it, and the ordering (prior first). Assignment instead of append now fails both.

CI-relevant: unit suite green (the handful of full-suite failures are the known process-spawn contention flakes — runtime-hints, apple runner-client/session, client-metro, daemon-client — all pass in isolation, 199/199).

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (8c98dc093): composition is now correctly pinned, and sessionless read-only find is fixed. One P1 sessionless-wait blocker remains.

The consumed-snapshot slot is initialized only in createSelectorRuntime(), which read-only find uses. dispatchWaitViaRuntime() bypasses that helper and calls createSelectorRuntimeForDevice() directly; the normal sessionless wait caller supplies no slot, so capture runtime has nowhere to report the consumed snapshot, and there is no session-store fallback. The new regression covers sessionless find only; the wait regression still installs a session.

Initialize/pass the shared slot on the wait route and add a sessionless wait success/timeout regression. CI is still running and no ready-for-human yet.

…(PR #1301 review)

dispatchWaitViaRuntime builds its selector runtime directly rather than via
createSelectorRuntime, so sessionless waits had no slot for the capture
runtime to report the consumed snapshot into and lost the system-surface
disclosure. Regressions pin sessionless wait success and timeout, both
asserting no session record exists and the disclosure is present.
@thymikee

Copy link
Copy Markdown
Member Author

Sessionless-wait blocker addressed in 76a180c: dispatchWaitViaRuntime now initializes the shared consumedSnapshot slot before building its runtime (it bypasses createSelectorRuntime, which is where find gets it). Two regressions pin the route with an empty session store — sessionless wait success on shade content still discloses the occluding system surface (ok:true, warning carries the disclosure) and sessionless wait timeout still discloses the occluding system surface (error hint carries it) — both assert the store stays empty, and both fail without the one-line initialization (verified by reverting it: 2 failed / 6 passed, restored: 8/8).

Daemon suite 1354/1354; typecheck/lint/format/fallow clean.

🤖 Addressed by Claude Code

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 16, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (76a180c63): code-review clean. dispatchWaitViaRuntime() now initializes the shared consumed-snapshot slot before directly constructing its selector runtime, so sessionless wait success and timeout can disclose from the capture itself without a session-store record. Both new regressions exercise the public wait dispatcher with an empty store/resolved Android device, assert no session persistence, and fail if the initialization is removed. Prior composition and sessionless-find fixes remain sound. The PR is mergeable; CI is still in progress. Applying ready-for-human under coordinator policy.

@thymikee thymikee merged commit 11a4212 into main Jul 16, 2026
23 checks passed
@thymikee thymikee deleted the feat/android-helper-only-snapshots-1253 branch July 16, 2026 19:08
@github-actions

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

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

1 participant