Skip to content

fix(ios): keep penalized-route fill on the field it focused - #3201

Merged
thymikee merged 3 commits into
callstack:mainfrom
pvedula7:fix/text-entry
Oct 5, 2026
Merged

thymikee merged 3 commits into
callstack:mainfrom
pvedula7:fix/text-entry

Conversation

@pvedula7

@pvedula7 pvedula7 commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On the penalized-XCTest route, iOS fill taps the daemon's point and types through synthesized input. Three problems there (RN 0.86 app, iOS 26.5 simulator):

  • The field was named only by the tap point, and focus moves the layout: landed fills failed with TEXT_INPUT_COMMIT_NOT_OBSERVED, and fill "" cleared the neighbouring field and reported success. The input under the point is now found before the tap and binds the target to its identity, as fix(ios): fill succeeds when app removes input after last character #3161 does on the element route. Reads use that handle, or the focused or identifier match with the same identity, never the point; otherwise the fill fails closed.
  • Secure fields never read back, so password fills always failed; they're now unverified, as on the element route.
  • In a freshly launched RN app select-alls get dropped, so a clear left text behind ("Client" → "ClieColdfour"). Each clear pass now reads the field back and repeats until empty (at most 4). Otherwise the fill fails with the new TEXT_INPUT_CLEAR_NOT_OBSERVED and types nothing.

Runner-only, plus a docs line for the new error (10 files).

Validation

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

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

Re-trigger cubic

Comment thread apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m Outdated
@pvedula7

pvedula7 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up to the Cubic review in 97a0d43, 045d0ce and 79735a1. The pre-tap lookup now runs inside the text-input probe's issue containment, so a timed-out read no longer fails the fill or ends the runner. The captured input keeps its identifier, and a handle that later resolves to another field, or to nothing, fails closed instead of using the stale point. A secure field whose text equals its placeholder is now left unverified, and the fixture uses named constants. The new XCTests fail with the fixes reverted. 104 runner tests pass locally on iOS 26.5, and pnpm check:affected --run passes on 79735a1. Live login-field fills still pass 4/4, at about 100 ms more per fill.

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

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

Re-trigger cubic

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member

The fix at 79735a1 looks right to me, but the live evidence in the PR body does not cover this commit yet. The one check reported on this PR passed.

The live runs in the PR body are on a0a2db4. Commit 97a0d43 then changed the same penalized fill route. A stale handle now resolves to nil instead of re-reading the point, and the pre-tap lookup now goes through probe containment (TextEntry.swift#L238). If the RN field's index-bound handle does not survive the layout move (exists is false, or its identifier changes), the head now returns TEXT_INPUT not-focused or commit-not-observed where a0a2db4 succeeded. Fixtures cannot show this. Could you re-run at 79735a1 (or 045d0ce or later) on the iOS 26.5 RN app with the XCTest channel penalized? The run should cover two cases: fill-replace into the bottom-sheet field on a fresh launch, and fill "" on the login email field. Please paste the CLI JSON responses (ok:true and the resulting snapshot value, with the neighbour unchanged). Please also paste the runner.log lines that show the coordinate-tap synthesized route was taken, with no AGENT_DEVICE_RUNNER_TEXT_INPUT_PROBE_UNAVAILABLE.

The cubic-dev-ai P2 thread on the unprotected stale check still applies: #3201 (comment). The stale check reads .exists and .identifier raw, so it lost the ObjC-exception catch the old read had. Wrapping it in the existing containment and returning false on failure restores parity.

I did not run XCTest or a device in this review. I also could not tell whether an index-bound handle survives an RN bottom-sheet relayout, or whether two inputs without testIDs could pass the identifier-only check after a re-bind. The focus allowance going from 2 s to 4 s lowers the longest undelayed fill from about 212 to about 181 characters, and I did not see a CHANGELOG entry for that.

The next step before merge is the live run at 79735a1 with the responses and runner.log attached.

@pvedula7

pvedula7 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Two commits since 79735a1:

  • 002bb98 runs the still-resolves check inside the probe containment (the Cubic thread).
  • 3276a3e verifies the replacement's clear. At 002bb98, 3 of 5 forced bottom-sheet fills left "Clie" + the new text and returned TEXT_INPUT_COMMIT_NOT_OBSERVED. a0a2db4 also passed only 2 of 5, so this isn't a regression; my earlier 5/5 was luck. Each pass now reads the field back inside the probe containment and repeats until it is empty, at most 4 passes. A field it can't clear or read fails with TEXT_INPUT_CLEAR_NOT_OBSERVED, and nothing is typed. Secure fields keep two unread passes. The extra passes are charged to the budget, so the longest undelayed fill drops from 181 to 138 characters (the PR body's Risk line is updated).

At 3276a3e: (a) bottom-sheet fill-replace 10/10, needing 2–3 passes each; (b) login fill "" 3/3. All runs took the coordinate-tap route with no TEXT_INPUT_PROBE_UNAVAILABLE. A local, uncommitted patch forced the penalty (details below). The CI-selected XCTests and pnpm check:affected --run pass.

Tallies by commit
Runner commit (a) bottom-sheet fill-replace, fresh launch (b) login fill ""
a0a2db4 2/5 (forced) not run
002bb98 3/6 (1 natural pass, 2/5 forced) 5/5 (1 natural, 4 forced)
3276a3e 10/10 (forced) 3/3 (forced; 2 also tripped naturally)

Failures at a0a2db4 and 002bb98 all returned TEXT_INPUT_COMMIT_NOT_OBSERVED. The field held "ColdtwoClien", "ColdthreeClien", "ClieColdfour", "Cliex", "ClieForcedtwo" and "ClieForcedfour". In every run at every commit, first name stayed "Solo", Email stayed unchanged, and the password field stayed unchanged. At 3276a3e, a fill took about 4.0–5.6 s end to end. At 002bb98 it took 2.9–6.7 s.

On how the penalty was forced: a natural trip came on 1 of 13 fresh launches, so I used a local patch that isn't committed. When /private/tmp/ad-force-penalty exists, executeTypeCommand calls penalizeSnapshotXCTestChannel(reason: "LOCAL_EVIDENCE_FORCED") before it reads the penalty, and the flag file exists only during the fill under test. Everything after that point is the commit's own code. The runner was rebuilt without the patch afterwards.

(a) at 3276a3e: CLI response, snapshot values, runner.log
$ agent-device fill @e14 "Coldtwo" --json
{ "success": true, "data": { "x": 313, "y": 427, "text": "Coldtwo", "message": "Filled 7 chars",
  "targetKind": "ref", "ref": "e14", "refLabel": "Client",
  "resolution": { "source": "ref", "phase": "pre-action", "kind": "exact" } } }
before: First "Solo" y=404   Last "Client"  y=404   Email y=483
after:  First "Solo" y=333   Last "Coldtwo" y=333   Email y=412, unchanged
23:55:07.151 AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=type commandId=runner-1c6ad6e1-…
23:55:07.356 AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALIZED bundle=com.ios.demotu.dev reason=LOCAL_EVIDENCE_FORCED
23:55:09.975 AGENT_DEVICE_RUNNER_SYNTHESIZED_DISPATCH kind=tap point=(313.0,427.0) reference=(0.0,0.0,440.0,956.0) orientation=1
23:55:10.339 AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE phase=focus durationMs=2983.1 chars=7 mode=replacement
23:55:10.339 AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder-replacement
23:55:13.892 AGENT_DEVICE_RUNNER_TEXT_ENTRY_CLEAR passes=3 outcome=cleared
23:55:15.515 AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE phase=total durationMs=5175.7 chars=7 mode=replacement
23:55:15.515 AGENT_DEVICE_RUNNER_COMMAND_COMPLETED command=type commandId=runner-1c6ad6e1-… ok=1

The 10 runs took passes=2 eight times and passes=3 twice, all outcome=cleared.

(b) at 3276a3e: CLI responses, runner.log
$ agent-device fill 'id=email-input' "" --json
{ "success": true, "data": { "x": 234, "y": 300, "text": "", "message": "Filled 0 chars",
  "targetKind": "selector", "selector": "id=email-input",
  "resolution": { "source": "runtime", "phase": "pre-action", "kind": "unique" } } }

$ agent-device get attrs 'id=email-input' --json
{ "success": true, "data": { "selector": "id=email-input", "node": { "type": "TextField",
  "identifier": "email-input", "placeholder": "Email Address", ... } } }   # no value: empty
snapshot before: email-input "solo-client@test.demotu.com"   password-input "••••••••••••"
snapshot after:  email-input (no value)                        password-input "••••••••••••"
00:02:03.855 AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=type commandId=runner-02a3d5b0-…
00:02:04.057 AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALIZED bundle=com.ios.demotu.dev reason=LOCAL_EVIDENCE_FORCED
             Find the "email-input" TextField                   <- pre-tap lookup
00:02:04.438 AGENT_DEVICE_RUNNER_SYNTHESIZED_DISPATCH kind=tap point=(234.0,300.0) reference=(0.0,0.0,440.0,956.0) orientation=1
00:02:04.754 AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE phase=focus durationMs=696.9 chars=0 mode=replacement
             Checking existence of `"email-input" TextField`    <- handle still resolves
             Tap / Type '...' into "email-input" TextField      <- clear lands on the email field
00:02:06.399 AGENT_DEVICE_RUNNER_COMMAND_COMPLETED command=type commandId=runner-02a3d5b0-… ok=1

In runs 1 and 3 the cold runner also tripped the breaker naturally (queries_backend_timeout), so that run's email and password fills went through this route too. Email cleared in 1 pass. Password, a secure field, logged TEXT_ENTRY_CLEAR passes=2 outcome=unverified and returned ok.

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

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member

The code in 3276a3e looks good to me, and the earlier evidence request is now met. Fill on the penalized route now stays on the field it focused, and the text-input probe check at TextEntry.swift:243 now fails closed, so that earlier thread is fixed at this head: #3201 (comment). The slow-fill thread does not apply, so please resolve it: the 4.0 to 5.6 s is the whole fill (focus, clear, commit), and the measured clear phase fits inside the charged budget, so the 30 s watchdog is not reachable on the data shown: #3201 (comment). One limit to know about: the live runs forced the XCTest penalty with a local patch, so the natural bottom-sheet route is shown only by inference, and the wrong-field read path when the pre-tap lookup finds nothing is bounded but not exercised. CI is still queued or running, so I can't say yet whether the iOS Smoke Tests route that drives fill through the runner passes. There are no conflicts. Merge once Smoke Tests and the repo gates report green.

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

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member

The new change at c05aba1 looks good, and I have no findings on it. The four clear reads are now charged 3.6 s (4 x 0.9 s) in RunnerTests+TextEntry.swift, which covers the 0.80-0.88 s per read. The route now refuses texts of 129-138 characters before it types the first character. That is the gap the earlier review at 3276a3e left open, and the derived-value tests in SynthesizedTextEntryTests.swift cover that refusal.

The live evidence from the author's earlier runs still applies, because the clear loop and the focus logic did not change. I did not see the raw per-read timings, and I did not run the Swift unit tests. I checked the arithmetic by hand and with a small script.

The cubic-dev-ai P2 thread on the read cost no longer applies at this head. The 4-5.6 s figure in it was for a whole replacement, not one read, so you can resolve it: #3201 (comment)

Smoke Tests is still running and has shown no failure so far. The change only touches the 129-138 character band on the penalized fill route, and smoke fills are short, so I expect no overlap. I can't attribute the result until the job finishes. Please wait for it to finish before merging.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 4, 2026
@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member

At c05aba1 this branch now conflicts with main after today's merges. The code verdict from the earlier review is unchanged, but I removed ready-for-human until the conflict is resolved. Please rebase onto main; I will recheck the new head.

When the XCTest channel is penalized, iOS `fill` taps the daemon's point and
types through private synthesis. That route named the field only by the
point, but focusing can move the layout (keyboard avoidance, a bottom sheet
extending above the keyboard). The commit wait then re-read whatever sat at
the point: a fill that landed failed with TEXT_INPUT_COMMIT_NOT_OBSERVED, and
`fill ""` cleared the neighbouring field and reported success.

The input under the point is now found before the tap, and the target is
bound to its identity, the way callstack#3161 binds an element-route target. Reads and
clears take that input's handle while it still carries the identity, and
otherwise only the focused input or the app-wide identifier match that
carries it; a target bound before its tap never re-reads the point. The
lookup and the handle check run inside the text-input probe's issue
containment, so a read that cannot answer fails the fill closed instead of
ending the runner.

Finding the input took up to 2 s more on a React Native bottom sheet, so the
replacement's focus allowance rises from 2 s to 4 s.
A secure field never exposes its value, so the synthesized replacement's
commit wait read nil on every poll and could only expire: every password
`fill` on this route failed with TEXT_INPUT_COMMIT_NOT_OBSERVED. The element
route leaves such a field unverified, and so does this route now, also when
the text equals the field's placeholder. A readable field still needs an
exact match here.
In a React Native app launched moments earlier (iOS 26.5 simulator), the
synthesized select-all is often dropped, so the replacement's clear deleted
only the last character and the fill typed after what was left ("Client"
filled with "Coldfour" became "ClieColdfour"). Each clear pass, a select-all
and a delete key, is now followed by a read of the field, and passes repeat
until it reads empty, at most four. A field that still holds text, or whose
read cannot answer, fails with the new TEXT_INPUT_CLEAR_NOT_OBSERVED and
nothing is typed. A secure field gets two unread passes and is typed into
unverified.

The delivery budget charges every pass, and each read 0.9 s (0.80-0.88 s
measured on a penalized React Native bottom sheet), so the route refuses an
undelayed replacement longer than 128 characters before typing any of it.
@pvedula7

pvedula7 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 8c9776a. I squashed the review follow-ups (002bb98, 3276a3e, c05aba1) into three commits matching the three bullets in the description, since the follow-ups mostly rewrote the first commit.

The one real conflict was #3161. Instead of keeping a parallel identifier check, the pre-tap lookup now binds the target to #3161's TextEntryInputIdentity. Reads and clears use the pre-tap handle while it carries that identity, then the focused input or an identifier match with the same identity, never the point. One behavior differs from c05aba1: a re-bound handle no longer falls back to whatever input has focus, so an unidentified field fails closed (new test for the identifier recovery). #3171 didn't overlap: its unconfirmed result stays on the accessibility route, and secure fields stay unverified on both routes.

At 8c9776a: pnpm check:affected --run passes, 127 runner XCTests pass (the text-entry files, including #3161's and #3171's tests), and forced-penalty live runs on iOS 26.5 gave bottom-sheet fill-replace 5/5 and login fill "" 3/3, all on the coordinate-tap route with no TEXT_INPUT_PROBE_UNAVAILABLE.

@thymikee

thymikee commented Oct 4, 2026

Copy link
Copy Markdown
Member

I found no blocking problems at 8c9776a. The conflict from the earlier review (#3201 (comment)) is gone, and no conflicts remain. The index-bound handle, the secure-field and placeholder order, and the probe failure path now hold at this head.

Not blocking: the comment above awaitSynthesizedReplacementCommit in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift (https://github.com/callstack/agent-device/blob/8c9776a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift#L433) still says each poll re-resolves from the refresh point. After this PR the target carries inputAtRefreshPoint, and resolveTextEntryElement trusts that handle first. The comment could say that polls use the input found under the point before the focus tap, and the point is used only when none was found. You can take this or leave it.

The four cubic-dev-ai threads are fixed at this head, so please resolve them: the clear read allowance (#3201 (comment)), the index-bound handle (#3201 (comment)), the secure-field check order (#3201 (comment)), and the probe read failure (#3201 (comment)).

I did not run XCTest or the live runs. The 127-test pass and the forced-penalty live results at 8c9776a are your report. They name the coordinate-tap replacement route and show no TEXT_INPUT_PROBE_UNAVAILABLE, which covers the re-binding path in this change. Two inputs that are both unidentified and of the same type share one identity, so a handle that re-binds between them can still pass the resolve check. That limit is unchanged from the head already reviewed, and it is not a regression here.

Smoke Tests, Repo Guards, Coverage and Integration Tests are still running, and none has failed. The diff touches the runner Swift/ObjC and one docs line, so the iOS smoke lanes are the checks that exercise it. Please make sure CI is green on 8c9776a before it is merged.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 4, 2026
@thymikee
thymikee merged commit d96754e into callstack:main Oct 5, 2026
16 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.

2 participants