Skip to content

fix(find): refuse a focus match behind the visible keyboard (#2622) - #3727

Merged
thymikee merged 8 commits into
mainfrom
maint/2622-find-focus-keyboard-guard
Oct 16, 2026
Merged

thymikee merged 8 commits into
mainfrom
maint/2622-find-focus-keyboard-guard

Conversation

@thymikee

@thymikee thymikee commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Summary

find <locator> focus and the focus leg of find <locator> type aimed a point themselves, so they never passed the pipeline door where the #2589 keyboard guard runs: a match behind the visible keyboard reported Focused (x, y) while the key ate the touch. Closes #2622.

Landed as the issue's option 2: the guard plus the vocabulary it needs (InteractionAction, interactionVerb) move to @agent-device/selectors, which both zones already import, so R2 is satisfied by placement, not exemption. The seam calls that one guard against the tree find matched, at the center it dispatches, ahead of expireRefFrame — a refusal leaves the frame pinnable.

find label Form focus   # -> Ref @e2 is behind the visible keyboard and cannot be focused safely

25 files: the move, the seam fix, an ADR 0011 path row (find-focus-dispatch, 14 cells classified) with daemon-harness scenarios, one shared #2589 fixture in @agent-device/test-kit. The row declares four gaps that are this seam's pre-existing behavior, newly visible because the row exists: offscreen (findAct says ignore), parentOwnedTouchPoint (taps the rect center), nonHittable (promotion runs, annotation never does), targetStability (no #3471 gate). All four cite #3734, which owns closing them; outcomeObservation stays on #3335.

Stale in #2622: paths are now packages/daemon/..., and option 2's cost was overstated — the pipeline-level half of the moved test still drives the guard through createInteractionDevice, so the split was a rename plus a trimmed unit suite.

Live evidence

Devices are mine, not shared: iOS simulator 2622-keyboard-guard (iPhone 17 Pro, iOS 27.0, created for this run) and AVD kb2622_api35 (emulator-5580, started for this run). Both deleted afterwards. Agent Device Tester via Metro, Form tab, Gboard on Android. Transcripts at head a0135d0d6, the gate-tested SHA.

iOS — refusal on both legs, positive control above the band

Keyboard raised by focusing the email field, seeded with pre@live.test. Settings is the tab-bar button at y 795, centered inside the band.

$ agent-device snapshot -i --json | jq '.data'
keys: 34   topmost key y: 597   viewport: 402x874
covered app node: e25 button Settings {x: 286.8, y: 795, w: 90.2, h: 54}

$ agent-device get text @e12
pre@live.test

$ agent-device find label Settings focus --json
{
 "success": false,
 "code": "COMMAND_FAILED",
 "message": "Ref @e25 is behind the visible keyboard and cannot be focused safely",
 "reason": "tap_keyboard_occludes_target",
 "ref": "@e25",
 "rect": {"x": 286.8, "y": 795, "width": 90.2, "height": 54},
 "keyboardFrame": {"x": 0, "y": 590, "width": 402, "height": 284},
 "dispatched": "no"
}

$ agent-device find label Settings type x --json
{
 "success": false,
 "code": "COMMAND_FAILED",
 "message": "Ref @e25 is behind the visible keyboard and cannot be focused safely",
 "reason": "tap_keyboard_occludes_target",
 "ref": "@e25",
 "keyboardFrame": {"x": 0, "y": 590, "width": 402, "height": 284},
 "dispatched": "no"
}

$ agent-device get text @e12                                  # unchanged, no keystroke landed
pre@live.test

$ agent-device snapshot -i | grep -c '\[key\]'                # keyboard still up after both refusals
40

$ agent-device find label "Contact details" focus --json      # above the band
{ "success": true, "data": { "x": 201, "y": 247, "message": "Focused (201, 247)" } }

On this producer data.keyboard is null, so the refusal above came from the guard's tree-derived rule reading the key nodes in find's own capture. That is the direct answer to the fail-open question: with no measured band, find's tree still carries the keys and the guard refuses.

Android — both legs refuse on the measured-band producer

Landscape, Gboard. This is the producer the review asked about — the helper's measured IME window, not the tree rule:

$ agent-device snapshot --json
windows: 3   keyNodes: 0
keyboard fact carried into the capture: {"kind":"visible","frame":{"x":0,"y":74,"width":2264,"height":1006}}
in-band app node: e165 group "Settings" {x: 1863, y: 806, w: 442, h: 211}  bundle com.callstack.agentdevicelab

$ agent-device find label Settings focus --raw --last --json
{
 "success": false,
 "code": "COMMAND_FAILED",
 "message": "Ref @e300 is behind the visible keyboard and cannot be focused safely",
 "reason": "tap_keyboard_occludes_target",
 "ref": "@e300",
 "keyboardFrame": {"x": 0, "y": 74, "width": 2264, "height": 1006},
 "dispatched": "no"
}

$ agent-device find label Settings type x --raw --last --json
{
 "success": false,
 "code": "COMMAND_FAILED",
 "message": "Ref @e300 is behind the visible keyboard and cannot be focused safely",
 "reason": "tap_keyboard_occludes_target",
 "ref": "@e300",
 "keyboardFrame": {"x": 0, "y": 74, "width": 2264, "height": 1006},
 "dispatched": "no"
}

keyNodes: 0 is the point of that pair: with no keys in the tree, the tree-derived rule cannot fire, so those refusals can only be the measured band reaching the guard through match.keyboard.

Two notes on the Android surface, both reproducible:

  • The refusal needs --raw. In the interactive projection the same app node is marked interactionBlocked: covered by the helper's cross-window scan, so the projected route stops it one stage earlier with a different reason: find label Settings focus answers AMBIGUOUS_MATCH (the app node plus Gboard's own Settings key) and find label Settings focus --last answers reason: "target_covered". So on Android with this app the misfire is blocked by occlusion when the node is projected as covered, and by this guard when it survives as a match. In portrait no app node is centered in the band at all (adjustPan moves the window), so landscape is the reachable case.
  • ime set is required, settings put secure default_input_method is not enough. My first Android passes reported keyboard: {"kind":"absent"} with dumpsys input_method saying mInputShown=true, and the guard dispatched behind what looked like a keyboard. That was my setup, not the producer: the bound IME was the app's bundled headless com.callstack.agentdevice.imehelper, which reports itself shown while drawing no window — the screenshots from those runs have no keyboard in them, so there was nothing to occlude and the dispatch was correct. After adb shell ime set com.google.android.inputmethod.latin/... the band measures on every capture. No producer bug to report.

Validation

🤖 Generated with Claude Code

The #2589 guard and the action vocabulary it needs (`InteractionAction`,
`interactionVerb`) lived in the command surface, so the daemon's mutating-`find`
focus seam could not reach either. Both move to @agent-device/selectors, the
package the selector pipeline and the daemon dispatch seams already share, and
the guard's call sites now import it from there.

`keyboardCoveredTabBarSnapshot` moves to @agent-device/test-kit with the guard:
the daemon seam test, the guard test, and the ADR 0011 contract scenarios need
one copy of the #2589 capture. Its variants (a moved app target, a keyboard
lifted off the bottom edge) replace the copies those suites were about to grow.
`find <locator> focus` and the focus leg of `find <locator> type` aim a point
themselves instead of re-entering the interaction leaf, so the #2589 guard never
ran: a match behind the visible keyboard reported `Focused (x, y)` while the key
ate the touch (#2622). The seam now runs the same guard the acting paths run,
against the tree find matched, at the center it dispatches, ahead of the ref-frame
expiry so a refusal leaves the frame pinnable.

ADR 0011 gains the classified `find-focus-dispatch` row for this seam, with its
enforcement proven by contract scenarios over the daemon harness and its four
divergences from the acting rows recorded as owned gaps: no off-screen refusal,
no parent-owned touch point, no moving-target gate, and a dispatch-only success
claim.
…dispatch row

The selectors export enumeration gains `./interaction-action` and
`./keyboard-occlusion`, the guarantee matrix's three keyboard cells now name the
guard's new module, and the acknowledged-gap list grows by the four cells the new
row declares, each linked to its tracking issue.

@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 25 files

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

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/selectors/src/keyboard-occlusion.test.ts
Comment thread packages/contracts/src/interaction-guarantees.ts
Comment thread packages/selectors/src/interaction-action.ts Outdated
Comment thread packages/contracts/src/interaction-guarantees.ts Outdated
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.30 MB 5.30 MB -4.4 kB
Package (unpacked) 5.30 MB 5.30 MB -4.4 kB
Package (download) 1.60 MB 1.59 MB -858 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.3 ms 29.7 ms +2.4 ms
CLI --help 78.2 ms 81.8 ms +3.6 ms

@thymikee

Copy link
Copy Markdown
Member Author

The code in 8fe670b needs live evidence before merge. Smoke Tests are still queued, so CI has not shown a result yet.

find <locator> focus and find <locator> type now refuse instead of dispatching focusPoint. That changes what a device command does. The PR body says there is no live-simulator evidence, and the only proof is the daemon harness with the captured iPhone 17 Pro fixture tree. docs/agents/pull-requests.md:15 requires the changed route to be proven live. No one has shown that a real iOS or Android capture taken through find's own snapshot (surfaceScopedNodes plus the measured snapshotResult.keyboard band) carries the keyboard into match.tree and match.keyboard. If it does not, the guard fails open on device, the misfire from #2622 continues, and every test still passes. Please run it on an iOS simulator with the keyboard up (focus a text field). Run find label <element whose center is inside the keyboard band> focus and find ... type x. Both must return COMMAND_FAILED with details.reason=tap_keyboard_occludes_target, keyboardFrame and dispatched:'no', and the field must not change or receive keystrokes. Then show that find on an element above the band still focuses. Please repeat the refusal on an Android emulator, because the measured-band path is a different producer.

On the open threads from the other reviewer, the nonHittable cell marked 'runtime' and the find.ts:430 fallback that never runs still apply, and one lower-priority thread on the 'be tapped' default for drag also still applies. The keyboard-occlusion.test.ts:91-101 thread does not apply: the 'cannot safely' message can only come from keyboard-occlusion.ts:120, and the test just before it asserts the typed details. Please resolve it.

Not blocking, and fine to take or leave: rejectCoveredFindMatch(match, 'be focused') at find.ts:394 hard-codes the verb while the keyboard refusal uses interactionVerb('focus'), so it could take an InteractionAction and call interactionVerb; and the new gap waivers on find-focus-dispatch at interaction-guarantees.ts:577 cite the closed #1081, so sub-issues should be opened and cited as trackingIssue before merge.

I did not run the seam, unit, contract or layering suites. I read the diff and the base find.ts, so fail-before is inferred from the test assertions. Smoke Tests may exercise the find route, so a later failure there would need a look before it is called unrelated.

Before merge, the live iOS and Android runs above must show the refusal and the focus above the band.

interactionVerb fell through to 'be tapped' for every action it did not
list, so a keyboard- or coverage-blocked drag, swipe, or scroll endpoint
was refused in the words of an action nobody asked for. Each action now
names itself, and with no default arm a future InteractionAction fails to
compile until it chooses the verb its refusal will use.
The action-loop assertion checked only that the message contained the
verb, so it would stay green on an unrelated rejection that happened to
mention the action. Each iteration now runs the full typed refusal —
COMMAND_FAILED, reason tap_keyboard_occludes_target, dispatched: 'no' —
and matches the verb on the message of that same refusal.
nonHittable claimed runtime for a guarantee whose second half never
runs: the seam promotes a non-hittable match to its hittable ancestor
but never annotates the one promotion declines, so it cannot claim the
whole cell. It is an owned gap on #1081 now, and the Layer-3 scenario
that would have overclaimed the waived cell is gone — the promotion half
stays proven by the seam's unit test named in the reason.

The responseIdentity rationale named a data.ref the focus and type
payloads never emit; it describes the omission instead.
…verb

Both mutating focus legs ended in `response ?? { ref: match.ref }`, but
executeFocusPoint and executeBoundTypeText always return a payload, so
the branch never ran and it was the only place this route could have
issued a data.ref. Removing it makes the responseIdentity waiver a
structural fact rather than a claim about a branch.

rejectCoveredFindMatch now takes the InteractionAction and names itself
through interactionVerb, so the covered and keyboard refusals on this
route draw their verbs from one source.

@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 2 files (changes from recent commits).

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

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/contracts/src/interaction-guarantees.ts Outdated
The four waivers this seam introduces (offscreen, parentOwnedTouchPoint,
nonHittable, targetStability) now cite #3734, which owns closing them.
outcomeObservation stays on #3335 and the pre-existing rows are untouched.
@thymikee
thymikee force-pushed the maint/2622-find-focus-keyboard-guard branch from ef82156 to a0135d0 Compare October 16, 2026 17:08
@thymikee

Copy link
Copy Markdown
Member Author

Live evidence is in the body now, on a simulator I created and an emulator instance I started (both deleted afterwards). Point by point:

iOS refusal, both legs. Done at a0135d0d6 on simulator 2622-keyboard-guard (iPhone 17 Pro / iOS 27.0), Agent Device Tester via Metro, Form tab. The email field was seeded with pre@live.test and focused to raise the keyboard, and Settings (tab-bar button, center y 795) sits inside the band at y 590..874. find label Settings focus and find label Settings type x both answer COMMAND_FAILED with details.reason: "tap_keyboard_occludes_target", keyboardFrame {0, 590, 402, 284}, dispatched: "no"; the field still reads pre@live.test, the keyboard is still up (40 key nodes) after both refusals, and find label "Contact details" focus still reports Focused (201, 247). Transcripts in the body.

Does a real capture carry the keyboard into match.tree / match.keyboard — and does the guard fail open when it doesn't. Both halves measured, one per platform:

  • iOS: data.keyboard is literally null on this producer while the keyboard is up (verified directly), so the refusal above is the tree-derived rule reading the key nodes find captured. No fail-open — the band absence is expected there and the tree carries the keyboard.
  • Android: landscape + Gboard, snapshot --json inside the same session shows keyboard: {"kind":"visible","frame":{"x":0,"y":74,"width":2264,"height":1006}} with zero key-role nodes in the capture. find label Settings focus --raw --last and find label Settings type x --raw --last both refuse with reason: "tap_keyboard_occludes_target", that same keyboardFrame, dispatched: "no". With zero keys in the tree the tree rule cannot fire, so those refusals can only come from the measured band reaching the guard through match.keyboard — which is the different producer you asked about.

Two Android-surface facts, in the body too: the refusal needs --raw, because the interactive projection marks the same app node interactionBlocked: covered and the projected route stops it one stage earlier (AMBIGUOUS_MATCH against Gboard's own Settings key, or target_covered with --last); and in portrait no app node is ever centered in the band because adjustPan moves the window, so landscape is the reachable shape.

One correction I owe you about my own setup: my first Android passes showed keyboard: {"kind":"absent"} with mInputShown=true and a dispatch behind an apparently-visible keyboard. That was me, not the producer — the bound IME was the app's bundled headless imehelper, which reports itself shown while drawing no window; the screenshots from those runs have no keyboard, so nothing was occluded and dispatching was correct. After adb shell ime set com.google.android.inputmethod.latin/... (a plain settings put secure default_input_method does not bind it) the band measures on every capture. No producer bug.

The two threads you said still apply (nonHittable marked runtime, the find.ts:430 fallback). Fixed before your review landed, in 0f4285661 and ebb95613a — nonHittable is now an owned gap: waiver on #3734 that says what actually runs (promotion yes, annotation never), and the dead ?? { ref: match.ref } fallbacks are deleted from both focus legs. The lower-priority be tapped default for drag is fixed in 72c771fc1 (interactionVerb is exhaustive with a pinning test). All four of those threads are answered and resolved.

The keyboard-occlusion.test.ts:91-101 thread. Resolved as you asked.

Non-blocking suggestions. Both taken: rejectCoveredFindMatch now takes an InteractionAction and derives the message through interactionVerb (ebb95613a), and the gap waivers no longer cite the closed #1081 — #3734 is open and owns exactly those four cells, with outcomeObservation left on #3335 (0f4285661, and the follow-up comment-wording fix in a0135d0d6).

Gate. check:affected --run --head ef8215636 passed with exactCommit=true (receipt run-CKC9HI). Head a0135d0d6 is that tree plus one code-comment line in interaction-guarantees.ts with no behavior change, so per the instruction to not start a separate gate run for a comment, the receipt is recorded at ef8215636 and the delta is stated in the body.

Your last line ("I did not run the seam, unit, contract or layering suites"). To close that gap cheaply: the gate covers all of them and passed at ef8215636 (exactCommit=true). Focused at the current head a0135d0d6: npx vitest run --project unit-core test/repo-guards/interaction-guarantees.test.ts → 9/9, and npx vitest run --project interaction-contract test/integration/interaction-contract/find-focus-dispatch.contract.test.ts → 6/6.

@thymikee

Copy link
Copy Markdown
Member Author

Gate re-run on the exact pushed head: check:affected --run --head a0135d0d6 passed with exactCommit=true (receipt run-tQKoZm), so the receipt now matches the head and supersedes the run-CKC9HI line in my previous comment. No code changes since.

@thymikee

Copy link
Copy Markdown
Member Author

The code at a0135d0 looks good to me, and the earlier findings from 8fe670b are fixed. The typed-reason assertions in the keyboard occlusion test now cover every action, the nonHittable guarantee is a waived gap that cites #3734, the action switch is exhaustive, and the response identity reason matches the payload. I found nothing new to ask for.

The five inline threads from the other review are fixed at this head, so you can resolve them: #3727 (comment), #3727 (comment), #3727 (comment), #3727 (comment) and #3727 (comment).

I took the live transcripts from the PR body and did not rerun them. I did not run the unit, contract or repo-guard suites. The gate receipt is at ef82156, which differs from this head only by a comment.

Both Smoke Tests jobs are still queued, so CI has no result yet. Smoke may exercise find, but there is no failure to attribute. They need to finish green at a0135d0 before merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 16, 2026
@thymikee
thymikee merged commit 0149438 into main Oct 16, 2026
20 checks passed
@thymikee
thymikee deleted the maint/2622-find-focus-keyboard-guard branch October 16, 2026 17:48
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-16 18:03 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

Development

Successfully merging this pull request may close these issues.

find focus/type dispatch the focus without the #2589 keyboard guard

1 participant