Skip to content

fix(ios): wait for hidden-keyboard synthesized text to commit before responding - #1673

Closed
thymikee wants to merge 1 commit into
mainfrom
claude/fervent-tereshkova-0fa250
Closed

fix(ios): wait for hidden-keyboard synthesized text to commit before responding#1673
thymikee wants to merge 1 commit into
mainfrom
claude/fervent-tereshkova-0fa250

Conversation

@thymikee

@thymikee thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member

What

The synthesized-first-responder bare-type route (added in #1657) returned ok as soon as the private XCTest event record was posted, while the target app was still committing characters. On slow CI simulators the trailing characters land after the response, so anything reading the field right after — the smoke-lane assert, or a real agent doing type → submit — observes a truncated value through the public type path. This is the root cause of the testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden flake ("hardware-keyboa" != "hardware-keyboard") that hit #1670, #1671, and claude/agent-device-issue-1658 on TypeScript-only diffs.

Fix is in the production route, not the test: after a successful dispatch, awaitSynthesizedFirstResponderCommit polls the tapped element until its value reaches textBefore + typedText (3s ceiling), and:

  • exits immediately when the observed value leaves the expected prefix path — a formatter, mid-text caret, or autocomplete transformed the input, and the runner must not second-guess the app;
  • if progress stalls as a strict prefix for 0.6s, re-synthesizes the missing tail exactly once (defense against true drops);
  • skips entirely for newline/CR-suffixed text so a repair can never double-submit, and for secure fields (unreadable value);
  • carries no refresh point, so like the tap witness itself it can never rediscover a different field.

Prefix/tail decisions are pure policy functions with unit tests. Bare type keeps its verified: nil contract — no new failure modes, the response just can't outrun the field anymore.

Validation

On a booted iPhone 17 Pro simulator (iOS 26.2):

  • Baseline: the flaky test + both sibling type regressions + the two new policy tests, 5/5 pass.
  • 5 runs of the flaky test under full-core CPU load (yes burner per core): all pass. type-all phase ran 563–884ms vs a uniform ~494ms dispatch-only on unpatched code — the commit wait absorbed up to ~390ms of real post-dispatch lag per run.
  • A/B: unpatched code also passes 5/5 locally under load (an M-series host can't get slow enough to reproduce the CI failure), but the absorbed-lag timings plus the CI failures pin the mechanism.
  • The tail repair never fired in any local run — consistent with commit lag rather than true keystroke drops. If it ever fires in CI it logs AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY route=synthesized-first-responder.

Reviewer notes

…responding

The synthesized-first-responder bare-type route returned ok as soon as the
private XCTest event record was posted, while the target app was still
committing characters. On slow CI simulators the trailing characters landed
after the response, so agents (and the smoke test) observed a truncated
field value through the public type path.

After dispatch, poll the tapped element until its value reaches
textBefore + typedText, exit immediately when the app transforms the input
(formatter, mid-text caret, autocomplete), and if progress stalls as a
strict prefix, re-synthesize the missing tail once. Submit-suffixed text
keeps the old immediate return so a repair can never double-submit.

Validated on a booted iPhone 17 Pro simulator: 5/5 passes of
testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden under full-core CPU
load, with the commit wait absorbing up to ~390ms of post-dispatch lag that
the previous code ignored (uniform ~494ms dispatch-only before); the tail
repair never had to fire, consistent with commit lag rather than true drops.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.98 MB 1.98 MB -83 B
JS gzip 641.4 kB 641.3 kB -48 B
npm tarball 769.3 kB 770.1 kB +857 B
npm unpacked 2.69 MB 2.70 MB +4.2 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.3 ms 28.4 ms +0.1 ms
CLI --help 67.9 ms 68.9 ms +1.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/runner-disposal.js +24 B -4 B

@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

[P1] Do not re-synthesize a suffix merely because the observed value stayed a strict prefix for 600 ms.

The motivating failure is delayed app-side commit after the private synthesize call returns. A 600 ms quiet prefix cannot distinguish that delay from a genuinely dropped suffix: the original tail may still be queued. awaitSynthesizedFirstResponderCommit then posts the same tail again, returns when one copy reaches the expected value, and the original queued tail can commit afterward, producing duplicated text after the command has already reported success. The pure policy tests cannot expose this because they do not model an in-flight original event stream.

Keep this change as an observation-only commit wait, or require evidence that the original event stream is finished before repairing. Add a production-shaped regression where the value stalls past the quiet window while the original suffix remains queued, then commits; prove the command never posts a second suffix and the final value is exactly the requested text.

@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Closing — this work is continuing elsewhere.

@thymikee thymikee closed this Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-07 14:49 UTC

thymikee added a commit that referenced this pull request Aug 7, 2026
Addresses the P1 on #1673: a 600 ms quiet prefix cannot distinguish delayed
app-side commit from a genuinely dropped suffix, so re-synthesizing the tail
could post it while the original was still queued and commit the text twice
after the command had already reported ok.

Drop the repair path entirely — the tail builder, the quiet-window constant,
the stall tracking, and the synthesizer dependency the wait only needed in
order to repair. What remains is a bounded observation: poll the tapped
element until the value commits, the app transforms it, the value becomes
unreadable, or the 3s ceiling expires. A dropped suffix still reports ok,
exactly as before this change; only the false truncation from commit lag is
removed.

The submit-key skip stays, for its own reason: the app may clear or rewrite
the field on submit, so textBefore + typedText is not the value to wait for.

Also wire testSynthesizedTextCommitProgressWalksExpectedPrefixOnly into the
iOS smoke lane — that workflow enumerates its tests by hand with
-only-testing, so a new test that is not listed never runs.
@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Picking this up in #1676 — the commit wait is folded in there, minus the repair path.

Your P1 stands, so the tail re-synthesis is gone rather than defended: synthesizedTextCommitRepairTail, the 600 ms quiet window, the stall tracking and its unit test are deleted, and with no repair the wait no longer needs the TextEntrySynthesizing dependency at all. What is left is a bounded observation — poll until the value commits, the app transforms it, the value becomes unreadable, or the 3s ceiling expires. A dropped suffix still reports ok, unchanged; only the false truncation from commit lag is removed.

Also wired testSynthesizedTextCommitProgressWalksExpectedPrefixOnly into ios.yml — that lane enumerates tests by hand with -only-testing, so the unit tests added here would not have run in CI.

thymikee added a commit that referenced this pull request Aug 7, 2026
…responding (#1676)

* fix(test): wait for typed text to settle in the hidden-keyboard runner test

testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden read textField.value
in one shot right after executeTypeCommand returned. The simulator commits
synthesized keystrokes after the command responds, so on a loaded CI machine
the read landed mid-word — observed failures reported ("h") and
("hardware-ke"). It failed on 3 of 5 runs of a branch carrying zero Swift
changes and passed on re-run.

Poll the value until it holds the expected text (10s ceiling) and assert on
the last value read, so a real regression still fails with what the field
actually held. Both reads in the test use the same helper; the assertions are
unchanged.

* Revert "fix(test): wait for typed text to settle in the hidden-keyboard runner test"

This reverts commit caecdc6.

* fix(ios): wait for hidden-keyboard synthesized text to commit before responding

The synthesized-first-responder bare-type route returned ok as soon as the
private XCTest event record was posted, while the target app was still
committing characters. On slow CI simulators the trailing characters landed
after the response, so agents (and the smoke test) observed a truncated
field value through the public type path.

After dispatch, poll the tapped element until its value reaches
textBefore + typedText, exit immediately when the app transforms the input
(formatter, mid-text caret, autocomplete), and if progress stalls as a
strict prefix, re-synthesize the missing tail once. Submit-suffixed text
keeps the old immediate return so a repair can never double-submit.

Validated on a booted iPhone 17 Pro simulator: 5/5 passes of
testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden under full-core CPU
load, with the commit wait absorbing up to ~390ms of post-dispatch lag that
the previous code ignored (uniform ~494ms dispatch-only before); the tail
repair never had to fire, consistent with commit lag rather than true drops.

* fix(ios): make the synthesized commit wait observation-only

Addresses the P1 on #1673: a 600 ms quiet prefix cannot distinguish delayed
app-side commit from a genuinely dropped suffix, so re-synthesizing the tail
could post it while the original was still queued and commit the text twice
after the command had already reported ok.

Drop the repair path entirely — the tail builder, the quiet-window constant,
the stall tracking, and the synthesizer dependency the wait only needed in
order to repair. What remains is a bounded observation: poll the tapped
element until the value commits, the app transforms it, the value becomes
unreadable, or the 3s ceiling expires. A dropped suffix still reports ok,
exactly as before this change; only the false truncation from commit lag is
removed.

The submit-key skip stays, for its own reason: the app may clear or rewrite
the field on submit, so textBefore + typedText is not the value to wait for.

Also wire testSynthesizedTextCommitProgressWalksExpectedPrefixOnly into the
iOS smoke lane — that workflow enumerates its tests by hand with
-only-testing, so a new test that is not listed never runs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant