Skip to content

fix(limrun): make Android provider text entry replace the field for fill - #3362

Merged
thymikee merged 4 commits into
mainfrom
t3/fix-issue-3358
Oct 11, 2026
Merged

thymikee merged 4 commits into
mainfrom
t3/fix-issue-3358

Conversation

@thymikee

@thymikee thymikee commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #3358. On a Limrun Android instance, fill concatenated the requested text over the field's old value: the provider-native injector was a one-line pass-through to the instance's setText, which inserts at the focused field's cursor and rejects empty text. fillAndroid delegates replacement to the provider (the AndroidTextInjectionRequest.target contract), so the pass-through silently produced the merge, and verification reported it as unconfirmed soft success.

The injector now owns the replacement, matching the contract it already declared and mirroring the Limrun iOS leg: focus the target, select-all and delete through the instance's own key events (the same input channel the typing rides), then setText only the replacing text. The empty fill stops at the clear, which is also the shape the SDK tolerates (setText('') is rejected). No platform routing change: atomically-replacing providers pay nothing extra, and the existing verify/retry stays the generic net.

Touched files: the new injector + its test, the provider wiring, the contract doc-comment, and the type re-export it needed.

Validation

  • Commit 2e3a458aa: pnpm check:affected --run — all runnable checks passed (provider-integration covered by GitHub CI).
  • Regression evidence: with the old pass-through restored, 3 of the 4 new android-text-entry.test.ts cases fail (all fill shapes); the type case pins that append stays untouched.
  • Key naming and modifier shape are pinned by the vendored SDK contract: @limrun/api@0.59.0 documents pressKey keys as case-insensitive plain names ('A', 'BACK', 'ENTER', …) and modifiers as exactly 'shift', 'ctrl'/'control', 'alt'/'option', 'meta'/'command'/'cmd', 'sym', 'fn'/'function'. The injector sends 'a' + ['ctrl'] and 'del', which are the documented forms of KEYCODE_A+META_CTRL_ON and KEYCODE_DEL; an unknown name is a server-side reject on the awaited request, so it fails loudly rather than silently skipping the clear. All injector steps are sequentially awaited on the single instance-client connection, so keys cannot precede the tap's ack.
  • No live Limrun run available in this environment; the clear primitive (Ctrl+A + Delete via the instance client, then setText) is the workaround the reporter verified 3/3 on the same instance. Residual risk: if an instance IME binds Ctrl+A differently, the old value stays and verification now fails hard (unchanged text → commit-dropped), never silently.

View guided diff Turn on auto-fix

The instance's setText inserts at the focused field's cursor and rejects
empty text, so the pass-through injector broke fill's replace contract
and concatenated the requested text over the old value (#3358). The
injector now owns the replacement: tap the target, select-all and delete
through the instance's own key events (the same channel the typing rides,
mirroring the iOS leg), then setText only the replacing text — and for
the empty fill, stop at the clear the SDK tolerates. Strengthen the
AndroidTextInjectionRequest target contract to state the replace-and-
clear obligation the platform now depends on.
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.16 MB 5.15 MB -10.0 kB
Package (unpacked) 5.16 MB 5.15 MB -10.0 kB
Package (download) 1.55 MB 1.55 MB -3.4 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 21.5 ms 21.0 ms -0.4 ms
CLI --help 61.9 ms 63.6 ms +1.7 ms

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

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

…rt closure

The Coverage lane's eager-closure budget refused the static edge from
android.ts into android-text-entry.ts; load the injector behind an
await import on the text path, the same deferral the iOS leg uses for
its own fill-path module.
@thymikee

Copy link
Copy Markdown
Member Author

I found two problems at c2d19ee. The Limrun Android fill change is not yet shown to work on a real instance, and no test covers the production routing. CI is green with 19 checks and none failing, and there are no conflicts.

The new fill path is device-facing: android-text-entry.ts:24 now runs tap, Ctrl+A, Del, then setText through the live instance client. The old setText({x,y}) tapped and typed in one call. Now the tap is a separate call, followed at once by key events, with no wait for focus or the keyboard. If the keys arrive before the field has focus or the IME has attached, Ctrl+A and Del hit the wrong view or nothing. Then fill leaves old text behind or drops its first character. The "first character lost after focus moved from another field" note in the issue is the same risk, and the reporter's 3/3 check covered Ctrl+A, Del and setText, not this sequence. Could you run this on a real Limrun Android instance with the repo CLI on this head and attach the snapshot -i output for each case? (1) In the Settings search field already holding jane.doe+12345@example.com, run fill @eN twice. The field must show exactly one copy. (2) Fill a field that was never focused, right after clicking a different field. The first character must survive. (3) Run fill @eN "" on a non-empty field. It must end empty. (4) Run type on a non-empty field. It must still append. If case 2 drops a character, please add a short focus settle after the tap.

All four tests in android-text-entry.test.ts call createLimrunAndroidTextInjector with a fake client. None goes through createLimrunAndroidSession().adbProvider.text, which android.ts:82-85 now routes to the injector. If that routing went back to client.setText(request.target, request.text), #3358 would return and every new test would stay green. runtime-dependencies.test.ts already mocks createInstanceClient, so this is cheap to cover. Could you add one test that builds the Android session with a mocked client and calls session.adbProvider.text({action:'fill', target, text})? It should assert the client saw tap, pressKey a+ctrl, pressKey del, then setText. A second case should check that type calls setText only.

I did not run the tests locally. I also did not read verifyAndroidFilledText, so I have not confirmed that a mismatch fails hard for a Limrun session, as the PR body says. Once the live run and the session-level test are in, I will re-review.

The injector's own tests call the factory directly; this pins that
adbProvider.text routes through it, so a regression to the raw setText
pass-through that caused #3358 now fails at the production seam.
@thymikee

Copy link
Copy Markdown
Member Author

@thymikee both findings addressed at e4e95f7:

Session-level routing test — added. runtime-dependencies.test.ts now has the session adb text seam replaces the field on fill and appends on type: it allocates the Android device through the runtime with the mocked createInstanceClient and calls deviceSession.adb.text(...) — the production seam fillAndroid resolves. fill asserts the client saw tap → pressKey:a+ctrl → pressKey:del → setText:<text>; type asserts setText alone. Regression-proved: with the pass-through restored in android.ts, this test fails while the injector unit tests stay green — exactly the mutation you named.

Hard-failure claim, with the path you asked about: in the provider branch, completeAndroidFillVerification(text, beforeTarget, verification) → on a not-ok verification, buildAndroidFillUnconfirmedVerification returns null when isAndroidFillCommitDropped (old text unchanged / hint showing) → it throws COMMAND_FAILED with the fill-failure details. A Ctrl+A bound to something else therefore leaves the old value and fails loudly; only an app-formatted changed value on the same node reaches the unconfirmed soft-success, unchanged from before this PR.

Live Limrun run — blocked on this host, recording the command as the docs require. This machine has no LIMRUN_API_KEY (checked env, interactive shell, keychain, the main checkout, config stores), so I cannot lease an instance from here; no local Android emulator can exercise the Limrun instance-client path. The sequence you listed is the reproduction to run on any host with the key:

agent-device connect limrun --platform android --run-id fill-3358 --session verify
agent-device open settings --platform android --session verify
agent-device snapshot -i --session verify            # note the search field ref + initial text
agent-device click @eN --session verify              # case 2: focus a different field first
agent-device fill @eM "jane.doe+12345@example.com" --session verify
agent-device fill @eM "jane.doe+12345@example.com" --session verify   # case 1: exactly one copy
agent-device fill @eM "" --session verify            # case 3: empty ends the field empty
agent-device type "+more" --session verify           # case 4: type still appends
agent-device snapshot -i --session verify
agent-device close && agent-device disconnect

On your point about focus timing after the separate tap: the reporter's own direct-client workaround (the 3/3 run) issued pressKey/setText as separate consecutive client calls after prior interaction, so the instance tolerates that cadence — but your case 2 is the honest unknown, and the +more-style first-character check is in the script above. If it drops a character on a live instance, the settle after the tap is a one-line follow-up.

@thymikee

Copy link
Copy Markdown
Member Author

At e4e95f7, the new session-level test covers the production routing: it reads adb.text from the allocated session, and it would fail if android.ts went back to the plain setText pass-through. That finding is resolved.

One thing is still missing. The fix depends on how a real Limrun instance handles pressKey('a', ['ctrl']) and pressKey('del') (android-text-entry.ts#L30). The tests use a recording mock of createInstanceClient, so a wrong key name or modifier shape would still pass. The empty fill sends no setText, so it relies fully on that clear. If the keys do not clear on a real instance, fill still concatenates (#3358 stays open) and fill @eN "" does nothing but exits 0. Could someone with a LIMRUN_API_KEY run the script from your last comment on this head and attach the snapshot -i output after each step? The field must hold exactly one copy after both fills, be empty after the empty fill, and keep the appended text after type. Output from a maintainer's host is fine.

Not blocking: the comment at android.ts#L83 says the dynamic import protects the import-time closure budget, but the injector is about 25 lines; a static import may be fine.

CI is green with 19 checks, and there are no conflicts. If the live run shows replacement and an empty field, no code change is needed.

@thymikee

Copy link
Copy Markdown
Member Author

@thymikee thanks — responding at 53dfef4 to what this host can still do, plus one correction:

fill @eN "" does not exit 0 if the keys clear nothing. Traced and now pinned: the provider branch runs verifyAndroidFilledText against the empty expectation; with the old value still held the verification is not-ok, and buildAndroidFillUnconfirmedVerification refuses the soft-success outright for requested.length === 0 (the #2063 rule — nothing formats the empty value), so completeAndroidFillVerification throws COMMAND_FAILED. A new test pins this against the provider seam: fillAndroid with an empty text fails, not unconfirmed, when a provider fill leaves the old value (injector declared but a deliberate no-op; fill asserts the throw). So a wrong key name is a loud failure on both fill shapes — non-empty fill keeps the old value → commit-dropped hard fail; empty fill → hard fail — never a silent success. Your remaining live-run question is then about which loud failure, not about #3358 silently surviving the clear.

Live run: this host is credential-less for Limrun (env, interactive shell, keychain, main checkout — no LIMRUN_API_KEY anywhere), so I'm one of the people the script needs a maintainer host from; the four-case script from my previous comment is the run, on this head.

Not-blocking, static import: tried and gate-refused, not judgment. The Coverage lane's eager-closure rule for packages/provider-limrun/src/index.ts is merge-base no-growth: the first CI run on this PR failed it verbatim ("evaluates 30 modules; the merge-base evaluated 29... a pure split does not"), and entries present at the merge-base have no over-ceiling approval row path — the gate's own printed remedy is "move the new edge behind a function-scoped await import," which is what the comment records. Module size is irrelevant to that rule, so the deferral stays; it also buys a marginally faster --help per the size report.

@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 1 file (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/platform-android/src/__tests__/text-input-fill.test.ts
The review asked whether a no-op key clear would let fill "" exit 0.
Verification already refuses it on the provider branch; this pins that
against the production seam so the soft-success cannot ever grow to
cover an un-cleared field.
@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. At f8f1196, the failure-path test now fails loudly when the field is not cleared, and the cubic-dev-ai thread on the text_mismatch assertion is fixed. CI is green on all 19 checks, but the new commit adds only a provider-seam unit test and no production change, so CI cannot show that the live Limrun route works.

The main gap is unchanged from my earlier review. The fix in packages/provider-limrun/src/android-text-entry.ts (https://github.com/callstack/agent-device/blob/f8f1196/packages/provider-limrun/src/android-text-entry.ts#L30) depends on how a real Limrun Android instance handles pressKey('a', ['ctrl']), pressKey('del') and a tap right before them. Every test uses a recording mock, so a wrong key name, a wrong modifier shape, or keys that arrive before focus would still pass. If the keys do not clear a real field, fill still concatenates and #3358 stays open. Please run the 4-case script on this head through the repo CLI (connect limrun --platform android, open Settings) with a host that has LIMRUN_API_KEY, and attach snapshot -i output after each step. The output should show one copy of the text in the Settings search field after two identical fills. It should show the first character kept when focus moves from another field. It should show an empty field after fill @en "". And it should show appended text after type on a non-empty field. If the first character drops, add a short settle after the tap.

The cubic-dev-ai thread (#3362 (comment)) is fixed at f8f1196, so you can resolve it. I did not run the tests locally, and I could not check real Limrun key naming, tap-to-key timing or IME behavior. There are no conflicts. The next step before merge is the live Limrun Android run on f8f1196.

@thymikee

Copy link
Copy Markdown
Member Author

Thanks — confirming the state and closing the two things that can be evidenced without the live run:

The cubic thread was already resolved (isResolved: true) after the f8f1196 fix reply; no open review threads remain.

Key naming and modifier shape are now pinned by the vendored SDK contract, not just the mock. @limrun/api@0.59.0 (the exact range this PR builds against, packages/provider-limrun/package.json) declares InstanceClient.pressKey in instance-client.ts with:

  • key strings that are "case-insensitive and may be plain names like 'BACK', 'ENTER', 'A', 'TAB', full Android constants like 'KEYCODE_TAB', or digit strings like '4'" — so 'a' and 'del' are the documented plain-name form of KEYCODE_A / KEYCODE_DEL;
  • modifiers explicitly listed as 'shift', 'ctrl'/'control', 'alt'/'option', 'meta'/'command'/'cmd', 'sym', 'fn'/'function' — so ['ctrl'] is the documented shape, and the wire payload the SDK builds is { keyName, modifiers }.

That removes "typo'd string" as a silent failure mode: an unknown key name is a server-side reject on a request we await, so a wrong name surfaces as COMMAND_FAILED, not a silent no-clear. The two shapes android-text-entry.ts sends are exactly the documented examples, and the same pressKey channel is what the live press command already drives successfully against Limrun Android on this repo's smoke tests.

Tap-to-key ordering: every step in the injector is a sequentially-awaited request on the single instance-client connection (tap → pressKey → pressKey → setText), so the keys cannot be dispatched before the tap is acked. What the SDK does not document is whether the instance acks the tap before the target field has taken IME focus. That is precisely the residual the live run measures, and your fallback stands: if case 2 drops the first character, we add a short settle after the tap in android-text-entry.ts — a one-line change; everything else is unchanged.

The live run: this PR head cannot be driven from my host — I've verified there is no LIMRUN_API_KEY in the environment, keychain, or any .env here (the T3 secret card I set up can only feed tool parameters, not a CLI process env, so the value is unusable for this). Someone with a key (you or @zaka7024, whose 3/3 verified workaround this mirrors) running the four cases on f8f1196 and attaching snapshot -i after each closes the last gate. Per the earlier discussion, every failure mode of the clear is now hard-fail (text_mismatch / commit-dropped → COMMAND_FAILED, test-pinned at this head), so a green run is expected and a red one is loud.

Everything else I can own is done: 19/19 checks green on f8f1196 (the one "skipping" row is the matrix-resolve placeholder), MERGEABLE/CLEAN, no conflicts, all threads resolved.

@thymikee

Copy link
Copy Markdown
Member Author

I ran the live Limrun Android check on f8f1196 through the repo CLI (connect limrun --platform android), and all four cases pass. The request log shows each fill went through the new provider text-entry path (backend: provider-native).

  • Two identical fills: fill "wifi" twice in the Settings search field shows wifi once.
  • Focus from another field: in the new-contact form, filling First name Alice, then Last name Smith, then refilling First name with Bob gives Bob and Smith. No first character dropped, so no settle after the tap is needed.
  • Clear: fill @eN "" on wifi leaves the field empty (it shows the Search… hint).
  • type appends: fill "blue" then type "tooth" gives bluetooth.

When a fill hit a non-text target, it failed with a verification error instead of reporting success, which is the right behaviour. I did not re-run the old behaviour on main; #3358 already shows it. This covers the gap from my earlier review, so this is ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 11, 2026
@thymikee
thymikee merged commit fd5380e into main Oct 11, 2026
19 checks passed
@thymikee
thymikee deleted the t3/fix-issue-3358 branch October 11, 2026 11:00
@github-actions

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

Limrun Android: fill inserts at the cursor instead of replacing the field's text

1 participant