Skip to content

fix(scroll): refuse an unreadable capture before scroll top/bottom decides - #3736

Merged
thymikee merged 4 commits into
callstack:mainfrom
airowe:fix/scroll-edge-unreadable-capture
Oct 16, 2026
Merged

thymikee merged 4 commits into
callstack:mainfrom
airowe:fix/scroll-edge-unreadable-capture

Conversation

@airowe

@airowe airowe commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

scroll top/scroll bottom no longer report "Already at bottom" on a capture they could not read. A missing, empty or sparse tree now fails with scroll_edge_capture_unreadable (with captureRefusal: no-capture | sparse-tree) before any edge verdict or gesture.

Following the shape in the issue, both scroll loops now run on one runScrollPasses({ observe, refuse, shouldStop, passLimit, ... }) in capture-kit. selectors depends on capture-kit, so capture-kit can't import sparseCaptureQuality. The loop therefore takes a refuse hook, and the daemon defines the refusal once (scroll-capture-refusal.ts) for both scroll --until and the edge scrolls.

One ordering change: the edge loop now checks its 40-pass budget before the stuck window, as --until already did. This only differs if a surface first reads as stuck on exactly pass 40.

6 files, no scope expansion. Fixes #2468.

Validation

  • pnpm check:affected --run --head 62eb122: format, lint, typecheck, layering, fallow, build and vitest-related all pass (exactCommit=true).
  • New tests: runScrollPasses unit cases (refusal before the stop condition, budget, stuck window, budget wins ties) and scroll bottom daemon cases (no tree, empty tree, sparse tree, unreadable mid-loop). They failed before the change.
  • Live, Pixel_7_API_36 emulator, Settings: scroll bottom takes 1 pass to the bottom on both origin/main (4db1b7d) and this branch, in two runs each.
  • Not live-tested: the unreadable-capture path itself, which isn't reproducible on demand.
  • Seen on both main and this branch: scroll top right after scroll bottom on Settings fails with scroll_edge_no_progress. Out of scope here; I can file it separately.

View guided diff

…cides

`scroll bottom` coerced a failed or sparse capture to an empty tree, which
the edge analyzer reads as "no hidden content below", so it reported
"Already at bottom" about a screen it never saw.

Both scroll loops now run on one `runScrollPasses` loop in capture-kit:
observe, refuse an unreadable observation, ask the caller's stop
condition, then spend a pass. The daemon defines the refusal once
(`scrollCaptureRefusal`, built on `sparseCaptureQuality`) and both
`scroll top`/`scroll bottom` and `scroll --until` pass it in, so an
edge scroll now fails with `scroll_edge_capture_unreadable` instead of
an edge verdict.

Fixes callstack#2468

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

View guided diff | Re-trigger cubic

@thymikee

Copy link
Copy Markdown
Member

The PR is ready at 62eb122. The refusal check now runs before scroll top/bottom decides, and I found no problems in the code. CI is green: 1 check, 0 not passing, and the author's receipt covers format, lint, typecheck, layering, fallow, build and vitest-related. There are no conflicts. Nothing else must happen before merge.

Not blocking, and you can take or leave these: in scrollCaptureRefusal (https://github.com/callstack/agent-device/blob/62eb122/packages/daemon/src/execution/scroll-capture-refusal.ts#L400), the empty-tree check runs before the sparse quality check, so the Android helper's presentation-failure capture (empty nodes, quality sparse, with a reason) reports a generic no-capture and loses the backend reason, and checking quality first would keep it; also, the 'refused' in observation test repeats about five times across scroll-edge-state.ts and scroll-runtime.ts (https://github.com/callstack/agent-device/blob/62eb122/packages/capture-kit/src/snapshot/scroll-edge-state.ts#L223) and the loop scope is tracked in two places, so one isRefused guard and one exposed scope would be simpler.

I did not run the tests or the gate. I judged the regression tests by reading the pre-change path. I could not check the live Android run, which comes from the PR body. I did not check whether backends other than Android and iOS, such as Appium, ever declare quality sparse, so for them the refusal only fires on a missing or empty tree. I did not trace the note that scroll top after scroll bottom hits scroll_edge_no_progress on both main and the branch. One live iOS scroll bottom and top run would confirm the shared loop on xctest, if you have a device handy.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 16, 2026
Check sparse quality before the empty-tree refusal, so a backend that
ships no nodes with a sparse verdict (the Android helper's presentation
failure) reports its own reason rather than a generic no-capture.

Read refused observations through one `isScrollEdgeRefused` guard, and
hand the edge loop's scope to `settleAfterPass` so the daemon no longer
tracks a second copy.
@airowe

airowe commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! I took both suggestions in d6eb2a9:

  • scrollCaptureRefusal now checks sparse quality before the empty-tree case, so an empty tree with a sparse verdict reports the backend's reason. A new scroll bottom case covers that (no nodes, sparse with a reason → sparse-tree), and it failed before the reorder.
  • Refused observations go through one isScrollEdgeRefused guard. runScrollEdgePasses passes its scope to settleAfterPass, so the daemon no longer tracks a second copy. One small change: the rest-wait now follows the scope the loop is deciding on. Before, it kept the last non-empty scope.

pnpm check:affected --run --head d6eb2a9 passes every stage (exactCommit=true). I haven't done the iOS xctest run yet; I can add one if you want it before merge.

The separate scroll-capture-refusal module added one eagerly evaluated
module to every daemon entry, which the eager-closure budget rejects.
Define the refusal in scroll-until.ts, which scroll-runtime already
imports, so both loops share it at no extra load cost.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/daemon/src/execution/scroll-until.ts Outdated
A capture with no nodes field and a sparse verdict reported a generic no-capture. Check sparse quality against an empty tree first, so the backend's reason survives whether the tree is empty or missing.
@thymikee
thymikee merged commit 5b97e14 into callstack:main Oct 16, 2026
6 of 12 checks passed
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

Development

Successfully merging this pull request may close these issues.

scroll top/bottom reports "Already at bottom" when the capture is unreadable

2 participants