Repository navigation
fix: recover cross-process UI when the frontmost tree is an empty shell - #75
Conversation
A system document picker (or any remote-process presentation) leaves
the frontmost application's accessibility tree an empty shell while
the host app is still frontmost - observed live both as a bare
{pid, role} root (0.10.0-era reports) and as a full-screen-framed
AXApplication with zero children (current runtimes). describe-ui
produced zero entries in that state even though point queries could
hit the visible picker from another process (issue #64).
Upstream idb already ships the missing capability: request options
carry remoteContentOptions, which grid-hit-tests the screen regions
the frontmost tree does not cover and returns other processes'
elements. sim-use simply never enabled it. The fetch now detects the
shell (isEmptyShellTree: no non-application node carries a
positive-area frame; the application container's own frame is just
the screen rectangle and proves nothing) and refetches once with
discovery enabled, attaching a remote_content_recovery advisory that
warns the recovered hierarchy is flat.
Two-phase by design: the plain first fetch keeps the hot path
untouched. Spike measurements - healthy native screens report full
coverage and the discovery grid probes nothing; sparse-but-valid
WebView pages must NOT trigger the retry (their coverage is low while
their tree is fine), which is why the trigger is the shell predicate
and not a coverage threshold.
TDD: EmptyShellTreeTests written first against a never-retry stub;
the current-runtime shell fixture was captured from the live failing
scene after the first predicate draft (based on the issue's 0.10.0
output shape) missed it. E2E RemoteContentRecoveryTests drives the
full Safari file-input -> action sheet -> picker scene against a
booted simulator and asserts both the recovered entries and the
advisory kind; it restarts the per-UDID daemon first so a stale
pre-fix daemon binary cannot serve the old fetch path.
Fixes #64
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rir4WwrPShf5DYzziJ8RyC
Signed-off-by: onevcat <onevcat@gmail.com>
Review follow-up (P1): upstream's default discovery region is the root element's UI-space frame, but the grid points feed the framebuffer-space point hit-test (issue #34). Under rotation the UI-space region samples the wrong band - points past the native width hit nothing and a whole native band is never sampled - so a landscape picker stayed an empty shell or recovered only partially. Portrait is the identity mapping, which is why the original verification and the E2E (both portrait) passed. Pass an explicit sampling region in native-portrait bounds through the new remoteSamplingRegion parameter: a full native grid covers every visible pixel regardless of orientation, with no upstream patch. Unknown screen size falls back to upstream's default region (correct in portrait, best-effort elsewhere). The coverage grid's UI-space bookkeeping is unaffected in practice: the retry only runs on empty shells, where the grid has nothing filled. Verified live on a landscape-left picker: empty-shell retry fires and the recovered elements span the full landscape width (cancel / more at x~741-794, 718pt search field) with correct UI-space frames, and the header reports the orientation. Automated rotation is not scriptable (Simulator menu needs accessibility permission), so the landscape scene stays a live check; the portrait E2E still pins the end-to-end flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rir4WwrPShf5DYzziJ8RyC Signed-off-by: onevcat <onevcat@gmail.com>
Review follow-up: P1 (landscape sampling space) — confirmed and fixed in
|
Review follow-up (P1, second round): discoverRemoteElements marks each discovered element's UI-space frame into the coverage grid and gates later sample points on isFilled - but the retry's sample points are framebuffer-space. Under rotation a discovered element's UI frame shadows a numerically overlapping but visually unrelated framebuffer band, so later sample points there are skipped. Measured before changing anything: portrait and landscape A/B (grid on vs off) over the live picker scene were identical across runs - the picker's large controls are hit by multiple grid points and upstream's frame-key dedup absorbs the overlap, so no element was actually lost in this scene. The mechanism is still real for elements smaller than the grid step sitting inside a shadowed band, and on the only path that runs discovery (an empty shell) the gate's upside is exactly zero: the grid starts empty, so it can never save a probe - it can only mis-skip one. Disabling collectFrameCoverage is zero-cost correctness; duplicate hits remain collapsed by the UI-space-vs-UI-space frame dedup. Options assembly moves into LegacyAccessibilityRequestBuilder so the request contract (no coverage grid, explicit native-portrait sampling region) is pinned by unit tests. Also hardens the E2E: the scene's action-sheet tap resolves against a symmetric, sparse layout that gives orientation calibration nothing to discriminate with - on a rotated simulator an ambiguous guess mapped the tap one row off (into the camera option), producing a misleading late failure. The test now requires portrait up front. That miscalibration itself is a pre-existing issue (the known landscape wobble, now observed causing a real mis-tap) and will be tracked separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rir4WwrPShf5DYzziJ8RyC Signed-off-by: onevcat <onevcat@gmail.com>
Review follow-up: P1 round 2 (coverage-grid cross-space shadowing) — mechanism confirmed, empirically bounded, fixed in
|
Fixes #64.
Problem
A system document picker (or any remote-process presentation) leaves the frontmost app's accessibility tree an empty shell while the host app stays frontmost.
describe-uiproduced zero entries in that state — the agent goes blind the moment an app opens a file picker — even though--pointcould hit the visible picker elements from another process. Reproduced live on current main (Safari +<input type="file">→ cross-process picker →App:header only,0 probe(s)calibration advisory, zero entries).Root cause & fix
The tree fetch rides
accessibilityElementForFrontmostApplication()and serialized with bare options. Upstream idb already ships the missing capability:FBAccessibilityRequestOptions.remoteContentOptionsgrid-hit-tests the screen regions the frontmost tree doesn't cover and returns other processes' elements (tagged, deduped by pid/frame) — sim-use simply never enabled it.The fetch now detects the shell and retries once with discovery enabled:
isEmptyShellTree(pure, unit-tested): a payload is a shell iff no non-application node carries a positive-area frame. The application container's own frame is just the screen rectangle and proves nothing — observed live both as a bare{pid, role}root (the issue's 0.10.0-era shape) and as a full-screen-framedAXApplicationwith zero children (current runtimes). Unrecognized shapes and oversized trees fail closed to the no-retry path.remote_content_recoveryadvisory (envelopeadvisorykey, merged with any calibration advisory) warning that the hierarchy is flat and may not cover every element.coverage=1.0and the discovery grid probes nothing; sparse-but-valid WebView pages report coverage as low as 0.03 while their tree is fine, which is why the trigger is the shell predicate and not a coverage threshold; the empty scene reportscoverage=0.0and discovery restores ~20% coverage of real elements.Testing
EmptyShellTreeTestswritten first against a never-retry stub (4 red / 3 green). Honest note: the first predicate draft keyed on the issue's 0.10.0 output shape (frameless root) and missed the current runtime's shell (full-screen-framed, childless) — the live failing scene supplied the second fixture, red again, then green. The shipped suite pins both shapes.RemoteContentRecoveryTests(SIM_USE_E2E-gated, registered intest-runner.sh) drives the full scene — local HTTP page with a file input, Safari, action sheet, cross-process picker — and asserts recovered entries and theremote_content_recoveryadvisory kind. It restarts the per-UDID daemon first: a daemon spawned by an older binary survives across builds and would otherwise serve the pre-fix fetch path (this bit us during development; the runner has no daemon hygiene of its own).🤖 Generated with Claude Code
https://claude.ai/code/session_01Rir4WwrPShf5DYzziJ8RyC