Skip to content

fix(ios-runner): slow synthesized replacement so a value-owning field loses fewer characters - #2904

Merged
thymikee merged 8 commits into
mainfrom
t3code/8f114750
Sep 25, 2026
Merged

thymikee merged 8 commits into
mainfrom
t3code/8f114750

Conversation

@thymikee

@thymikee thymikee commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

fill's synthesized replacement typed at XCTest's default 60 characters/second. An app that owns its field's value renders it after the edit that produced it — a controlled React Native TextInput — and that render erases whatever the burst typed while it was in flight, then reads its own erasure back into its model. The field sits stable short of the request: the ada@example -> aexample signature in #2080.

Both routes that type without resolving an XCUIElement are now paced at 12 characters/second, declared once on RunnerSynthesizedTextEntry. This reduces the loss; it does not prevent it. Against a fixture app that acknowledges each edit within 40 ms (20 bursts of ada@example each, iPhone 17 Pro simulator, iOS 26.2):

pace field after the burst bursts with a write-back
60 cps (before) 1 of 11 characters, 20/20 20/20, 10 write-backs each
12 cps (this PR) 11 of 11 in 7/20, 10 of 11 in 13/20 13/20, 1 write-back each

XCTest does not space typingSpeed: characters evenly: at 12 cps a burst averages ~78 ms per gap, but its closest pair was 11–44 ms. So no fixed pace can promise that an app with a 40 ms window keeps up. Every short value was refused with TEXT_INPUT_COMMIT_NOT_OBSERVED (the commit wait main already has). Preventing the loss is #2906 (back-pressure from the field).

Because the pace makes length the burst's wall clock, text that cannot be posted inside one command is refused before the first character: fill answers TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED, and the element-less type fallback keeps its application-wide route, which cannot read the field back and so reports the value unverified. The budget is derived from the runner's 30 s main-thread watchdog (mainThreadExecutionTimeout, which bounds the whole command), minus a 2 s focus allowance and the 10 s commit-wait ceiling: 18 s of delivery. Each synthesize call is charged its characters at the pace plus a measured 150 ms call overhead, and a --delay-ms plan pays one call and the delay per character. That admits 214 characters without --delay-ms and 57 at --delay-ms 80. The refusal hint names both.

Follow-ups: #2903 (verified element route), #2906 (back-pressure; owns the "field keeps its value" guarantee).

User-facing note (release notes; CHANGELOG.md is gone on main): on iOS, synthesized text entry types about 83 ms apart instead of 13 ms, so a field whose app writes its own value back loses far fewer characters. The command still refuses a field left short. A fill too long for one runner command (about 214 characters, fewer with --delay-ms) now fails with TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED before typing anything.

Refs #2080

Validation

At 80bf831fe4 (pnpm check:affected --run exit 0):

  • Pace regression, red/green: testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow asserts that a burst's characters reach the app at least one acknowledge window (40 ms) apart on average. With RunnerTextEntryTypingSpeedCharactersPerSecond flipped to 60 it failed on both fills: "11 edits reached the app in 111 ms (closest pair 2 ms)" and 125 ms, and the host policy test failed too. Flipped back to 12: green (bursts 753–801 ms).
  • Through the daemon CLI, own simulator, Release AgentDeviceTester Form tab. The synthesized route only runs while the XCTest channel is penalized, so a temporary, uncommitted build forced isSnapshotXCTestChannelPenalized to true. runner.log shows route=synthesized-first-responder-replacement for every fill below:
    • Before this commit: fill @e10 <358 chars> failed at 30.2 s with COMMAND_FAILED "main thread execution timed out". Delivery alone took 30.3 s, so the watchdog abandoned the command mid-burst. The in-process harness behind the earlier 358-character "32.6 s ok" never ran under that watchdog.
    • fill @e10 <215 chars>: TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED in 0.9 s, budgetChars=214.
    • fill @e10 <214 chars>: ok, 20.1 s wall. Focus 491 ms, delivery 18.3 s, commit wait 1.1 s. Then snapshot -i: ok, 0.24 s.
    • fill @e10 <57 chars> --delay-ms 80: ok, 19.2 s wall. Focus 503 ms, 57 synthesize calls of 213–556 ms each, commit wait 1.1 s. Then snapshot -i: ok, 0.54 s.
  • The iOS PR lane (check:xctest-selection --ios-pr-tests, 66 tests, including both lane tests) plus 7 touched host tests: 73 tests, 0 failures on my simulator.

At a58713b50 (check:xctest-selection: 323 declared methods, iOS PR lane 67, 0 unreachable):

  • The over-budget application-wide branch (TextTyping.swift) had no coverage, and charging the budget per dispatch left it unreachable in practice: an append peels its first character for warmup, so 215 characters arrived as 1 + 214 and both pieces fit. testOverBudgetTypeWithoutResolvableElementTypesApplicationWide failed on route=synthesized-first-responder before the charge moved to the command's length and delay; it passes after, asserting route=xctest-application-fallback, no failure, verified == nil (the branch has no element to read), and all 215 characters in the field. --agent-device-text-entry-soft-keyboard gives the fixture field a real input view; without it no keyboard-visible branch is reachable.
  • 11 tests of the text-entry family (both budget lane tests, the pace lane test, the 4 policy tests, and the 4 neighbouring type routes): 0 failures, 71.6 s; the new test is 14.8 s. format:check, lint, check:xctest-selection, check:gate-manifest, check:packaged-runner-swift green.

Risk: the 2 s focus allowance and the 150 ms call overhead come from one host. A slower host has less watchdog left for the commit wait. The pace's cost on long text is bounded only by the refusal (#2906).

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.81 MB 4.82 MB +11.5 kB
Package (unpacked) 4.81 MB 4.82 MB +11.5 kB
Package (download) 1.44 MB 1.44 MB +3.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 20.7 ms 20.4 ms -0.3 ms
CLI --help 59.3 ms 57.6 ms -1.6 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 97d03fb. The typing pace is now a fixed 12 characters per second with no limit tied to text length (RunnerSynthesizedTextEntry.m#L17). The characters are sent inside the synthesize call, so a fill or type of more than about 500 characters now runs past the 45 s runner command budget (runner-transport.ts#L9); the old 60 cps pace allowed about 2700. When that timeout fires the runner is still typing, so the fill fails with a transport timeout and the next command finds a busy runner. Please add a delivery ceiling next to TextEntryTiming.synthesizedCommitCeiling, below the command budget minus focus, clear, and commit time, and then either raise the speed to max(12, ceil(length / ceiling)) or refuse longer text with a typed reason. A unit test at the boundary would pin it.

Small note, not blocking: the same constant also paces the plain type route through PrivateXCTestTextEntrySynthesizer.enterText, so the CHANGELOG could name both routes.

The iOS Smoke Tests jobs were still running; their fill steps reach this route, so a failure there would be relevant to this change.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed at de01c967c9.

Delivery ceiling. TextEntryTiming.synthesizedDeliveryCeiling (30s) sits next to synthesizedCommitCeiling, and the two together stay inside RUNNER_COMMAND_TIMEOUT_MS — pinned by testSynthesizedDeliveryBudgetRefusesTextThatOutrunsTheCommand, which also asserts the boundary itself: maxTextLength characters fits, one more refuses. I took the refuse option rather than max(12, ceil(length / ceiling)): an escalating pace deletes the exact guarantee this PR adds (the burst is only safe because it is slower than an app render), and it would do that silently for precisely the long texts nobody tests. The type route does not refuse — over budget it takes the verified xctest-application-fallback it already had, so the pace costs throughput there, not the command.

Pace ownership. The constant stays declared where the typing happens, on RunnerSynthesizedTextEntry.typingSpeedCharactersPerSecond, and Swift reads it, so SynthesizedDeliveryBudget.characterInterval and the window policy cannot drift from the pace. That also let the boundary tests be pure: they run on the host lane, every PR, no simulator.

CHANGELOG now names both routes and the new refusal.

Your smoke-lane note was right, and it caught this PR's weaker half. testSynthesizedReplacementSurvivesFieldValueWrittenBackByTheApp failed on iOS (ad@example, TEXT_INPUT_COMMIT_NOT_OBSERVED): the fixture scheduled a write 25 ms after an edit, and whether that landed before the next character is a question about how loaded the host is, not about the mechanism. The fixture now decides at the edit whether a character overtook a render — no timer — so a loaded host, which stretches the gaps between characters, can only make the app keep up better. Verified: green at the shipped pace under 24-way CPU load; red at the pre-fix pace against the 40 ms policy window (a after 20 write-backs). The lane run watches a 5 ms window, an app no pace this runner could ship outruns; the 40 ms budget the pace is sized for is pinned by testSynthesizedPaceLeavesRoomForAnAppToAcknowledgeEachEdit instead of by a race.

@thymikee

Copy link
Copy Markdown
Member Author

Following the earlier review at 97d03fb, the delta still doesn't bound the delayed route that fix was meant to close.

SynthesizedDeliveryBudget.projectedSeconds charges each character max(delaySeconds, characterInterval), but a plan with delay > 0 pays a synthesize call per character at 12 cps plus a sleepFor(delaySeconds), so the real per-step cost is closer to delay + interval + round trip (https://github.com/callstack/agent-device/blob/de01c96/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift#L110). For example, fill --delay-ms 80, the recovery hint TEXT_INPUT_COMMIT_NOT_OBSERVED prints, projects 0.083 s per character and admits up to 360 characters, but at roughly 0.16 s per character that is about 58 s of delivery, past the 45 s transport timeout, so the runner is still typing when the next command arrives and finds it busy.

The check needs to guarantee that every burst it admits finishes within synthesizedDeliveryCeiling at its real per-step cost: for a delayed plan that means counting n × (delay + characterInterval) plus a per-synthesize round-trip allowance, and using that same formula in maxTextLength and in the hint text it prints. SynthesizedDeliveryBudget is the single definition both callers read from, so fixing it there covers both without touching other sites. Could a boundary assertion at delay 0.08 s, next to the existing delay-0 one, pin this down going forward?

Before this can merge, the iOS runner lane on ios.yml needs to go green on de01c96 running testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget and testSynthesizedReplacementSurvivesFieldValueWrittenBackByTheApp. Once the delay-cost fix lands, a live fill --delay-ms 80 run through the XCTest channel at the admitted boundary length should also finish inside 45 s with an ok or typed response and leave the next command free, not busy.

There are no known conflicts with main. All five checks were still queued or in progress with no logs yet, so none of them speak to this fix yet; the iOS Smoke Tests exercise the paced synthesized route this PR touches and would be the relevant signal, while Repo Guards and Coverage run the TS/repo-shape gates the delta doesn't touch, so a failure there would most likely be unrelated.

The 58 s figure above is inferred from reading the code path, not timed, and the delay-0.08 device runs from the earlier review weren't reproduced here, so is that figure worth confirming with an actual timed run before relying on it?

A few smaller things worth a look separately: the CI lane's 5 ms window is also cleared by the pre-fix 60 cps pace, so it only proves the fixture runs, not that the fix holds; testSynthesizedPaceLeavesRoomForAnAppToAcknowledgeEachEdit looks like the only guard left that would go red without the fix; TextTyping.swift:170's type-route budget fallback still has no test running through the production route; and TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED is missing from website/docs/docs/commands.md next to the other TEXT_INPUT_* codes.

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed at de01c96. The pace/budget split looks right. I checked the delaySeconds: 0 guard in typeIntoCurrentTarget and concluded it is intentional: delayed routes enter that helper one character at a time and the inter-character sleep happens outside the helper. The user-facing hint is the remaining place where the default-pace ceiling can be mistaken for the actual request budget.

case .commitNotObserved:
return "The field may hold none, part, or all of the text. Run snapshot -i and inspect the field: if it already matches, continue; otherwise retry fill with the full text quoted and --delay-ms 80. Do not use type, which appends to whatever committed."
case .synthesisBudgetExceeded:
return "Fill about \(SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0)) characters at a time and append the rest with separate type commands, keeping each command inside that budget. A lower --delay-ms does not help: this route is chosen when the accessibility channel is already degraded, and every character interval counts against the same budget."

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The hint hardcodes maxTextLength(delaySeconds: 0), but a synthesis-budget refusal can be produced by a request with a nondefault delay. Above the synthesized character interval, a higher delay lowers the per-command ceiling, so this hint can over-promise and lead the operator to retry a fill that is still too large. Carry the actual maxTextLength(delaySeconds:) for the failing request into the response, or phrase the number as the default-pace ceiling and explain that higher delays lower it.

@thymikee

Copy link
Copy Markdown
Member Author

The iOS lane went red twice more on my fixture, and the second failure changed its design — worth recording here because it is a fact about the pace this PR adds.

  1. 13:31 UTC: the lane test failed holding ad@example. Cause: the fixture scheduled a write 25 ms after an edit, so whether it landed before the next character was a question about host load. Fixed in de01c967 by having the app decide at the edit whether a character overtook its render — no timer, and load can only make the app keep up better.
  2. 15:21 UTC: failed holding ada@exampl with exactly one write-back. The runner behaved correctly — it refused with TEXT_INPUT_COMMIT_NOT_OBSERVED — but the test had asserted the app would keep up. Two characters of a 12-characters/second burst reached the field 4 ms apart, so typingSpeed: does not space characters evenly and no acknowledge window can be a promise about delivery microstructure.

b3a304df moves the assertion onto the runner's own guarantee, which is the one #2080 was about: the fixture reports whether it overtook the burst, an app that kept up must show the exact value with an ok, and an app that overtook it must show the refusal. Both branches are verified locally — strict at the 5 ms lane window, refusal at 150 ms — so the lane no longer depends on who wins a race it does not control.

Also from your smoke note: the lane's failure was the targeted XCTest list, not the smoke lane's fill steps, so the route's CLI fills held the value at the new pace on CI. The remaining pace facts are pinned without a race: testSynthesizedPaceLeavesRoomForAnAppToAcknowledgeEachEdit (one character interval ≥ two acknowledge windows) and testSynthesizedDeliveryBudgetRefusesTextThatOutrunsTheCommand (the boundary, and the ceiling plus commit wait inside the 45 s budget) — both pure, host lane, every PR.

pnpm check:affected --run passed at b3a304df. 11 text-entry lane tests green locally, and the app-owned-value test green under 24-way CPU load.

@thymikee

Copy link
Copy Markdown
Member Author

This is a follow-up on the earlier review at de01c96. The budget undercount is still open, the rewritten lane test no longer reproduces #2080, and no live run backs the new pace or budget at b3a304d.

The delay/length budget still charges max(delay, 1/12 s) per character while the real cost of a delayed plan is one synthesize call plus sleepFor(delay) per character (apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift#L109). fill --delay-ms 80, the recovery the TEXT_INPUT_COMMIT_NOT_OBSERVED hint prints, projects 0.083 s per character and admits 360 characters, but the real per-step cost is about delay plus round trip, so an admitted burst can run past the 45 s RUNNER_COMMAND_TIMEOUT_MS and end in a transport timeout while the runner keeps typing, leaving the next command busy. The doc comment now admits the undercount but doesn't fix it. projectedSeconds, maxTextLength, the refusal log, and the hint all need to charge from one formula in SynthesizedDeliveryBudget, n*(delay + a per-synthesize round-trip allowance), and the boundary needs a test at delay 0.08 next to the existing delay-0 one.

The lane test at apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift#L78 now accepts either an ok with the exact value or a TEXT_INPUT_COMMIT_NOT_OBSERVED refusal after a write-back. Main at f47e9c9 already produces that refusal through awaitSynthesizedReplacementCommit, and #2080 recorded exactly that refusal, so at the pre-fix 60 cps pace this test passes through the refusal branch and no longer catches a reverted fix. testSynthesizedPaceLeavesRoomForAnAppToAcknowledgeEachEdit only compares a constant, and the author reports XCTest doesn't honor that constant's spacing. Can this get a deterministic, non-racy assertion that the 12 cps route keeps a field whose render window is well inside the pace, shown red at 60 cps? If XCTest spacing can't be bounded, the PR's claim should narrow to "reduces" rather than "keeps," said in the changelog, with the guarantee moved to #2906, and the doc comment at line 18 corrected either way.

This changes a device-facing iOS fill route: the pace, the delivery-budget refusal, and the TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED code (apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift#L126). No live run on this head is attached, including the fill --delay-ms 80 run the earlier review asked for. After the budget formula is fixed, this needs a simulator run on the synthesized-first-responder-replacement route for two cases: a default-pace fill at maxTextLength(0) returning ok, and fill --delay-ms 80 at the corrected maxTextLength(0.08) finishing under 45 s with an ok or typed response, followed by an immediate snapshot -i that isn't busy, plus the ios.yml lane result for both new tests at this head.

Not blocking: the NSLog marker at RunnerTests+SynthesizedTextEntryTests.swift#L93 has no consumer at head and the sawKeepUp || sawRewrite assertion is always true since each iteration sets one of the two flags, so either delete both or make the branch outcome part of a real assertion, and the still-unaddressed ask to have the TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED hint use the request's delay folds into the budget formula fix above.

I read the code rather than timing it, so the ~0.16 s per character behind this comes from the formula, not a measurement; I didn't see the CI log for the 4 ms inter-character observation or the 15:21 UTC lane failure; and I didn't run the lane tests myself, so the refusal-branch conclusion rests on main's existing commit wait plus the author's reported result after 20 write-backs at 40 ms.

Smoke Tests is queued at b3a304d, and its iOS fill steps reach the synthesized-first-responder-replacement route this PR changes; the ios.yml runner lane now also runs both new lane tests, and a failure in either matters here. The budget formula needs to charge the real per-step cost so an admitted delayed fill finishes inside 45 s, and then #2080 needs a regression that actually fails at the pre-fix pace.

…ning field keeps it

A replacement burst typed at XCTest's default 60 characters/second outruns an app
that owns its field's value and re-applies it after the edit that produced it, which
is how a controlled React Native `TextInput` behaves. That write erases whatever the
burst typed while it was in flight and the field then sits stable short of the
request: first characters and tail survive, a middle run is gone, which is the
`fill id="field-email" ada@example` -> `aexample` CI signature. The commit wait can
refuse such a value but cannot repair it, because a retype races the same write.

12 characters/second spaces characters ~83 ms apart. The new
`--agent-device-text-entry-async-value-write` fixture owns its field's value and the
iOS lane test pins the pace against it: raising the pace re-opens the race and turns
that test red. A write-back still in flight 150 ms after an edit corrupts a burst at
this pace too, so this narrows the window rather than closing it, and the measured
wall-clock cost of the pace is recorded next to the commit ceiling it sits upstream
of.

Closes #2080
…r, not a timer

Review asked for a delivery ceiling tied to text length, and the iOS lane showed the
lane test itself was the weaker half of this change: a fixture that schedules a write
lands differently depending on how loaded the host is, and it failed on CI holding
`ad@example` at the shipped pace.

`TextEntryTiming.synthesizedDeliveryCeiling` bounds how long a burst may spend
posting. The private synthesize call delivers as it returns, so text beyond the
ceiling would still have been arriving when the transport gave up on the command,
leaving the runner typing into a field nobody waits for and the next command finding it
busy. `fill` now answers `TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED` before posting the
first character, and the element-less `type` fallback keeps its long text on the
verified application-wide route it already had.

The pace stays owned by `RunnerSynthesizedTextEntry` and is read from Swift, so the
delivery budget and the acknowledge-window policy derive from one declaration.
`testSynthesizedPaceLeavesRoomForAnAppToAcknowledgeEachEdit` pins the pace at two
windows per character or slower, and the budget test pins the refusal boundary; both
are pure and run on the host lane.

The fixture now decides at the edit whether a character overtook a render instead of
scheduling one against a timer. A loaded host stretches the gaps between characters,
which can only make that app keep up better, so the safe half no longer depends on how
busy the machine is. The lane run watches a 5ms window, an app no pace this runner
could ship outruns; the red half was verified at the 40ms policy window, where the
pre-fix pace leaves the field holding `a` against 20 write-backs.
The lane test failed on CI twice: first holding `ad@example`, then `ada@exampl` with a
single write-back. Both were honest `TEXT_INPUT_COMMIT_NOT_OBSERVED` refusals over a
field the app had rewritten, and both were the test assuming it knew whether the app had
won a round trip. XCTest does not deliver `typingSpeed:` characters evenly — CI saw two
characters of a paced burst 4 ms apart — so no acknowledge window a fixture watches is a
promise about the host.

The test now follows the fixture's own counter: an app that kept up has to show the exact
value and an ok, and an app that overtook the burst has to show the refusal. Verified on
both branches, the strict one at the 5 ms lane window and the refusal one at 150 ms. That
is the invariant the runner owns, and the one #2080 was about: never an ok over a field
the app rewrote.
…all and delay

A delayed plan posts each character in its own synthesize call and sleeps
between calls, but the delivery budget charged it max(delay, interval) per
character. At --delay-ms 80 it admitted 360 characters; measured on a
simulator, one call costs about 222 ms, so a 155-character fill already
took 48 s, past the 45 s command budget.

The budget now charges every call the pace for its characters plus a
measured 150 ms overhead, and the delay between calls. maxTextLength is
derived from the same projection, and the refusal hint names both the
undelayed budget and the one at --delay-ms 80.
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at a27335ef41, rebased on 622435ecc5.

Delayed plan cost (14:54 note): fixed. You were right, and the real cost was higher than 58 s. On an iPhone 17 Pro simulator, one one-character synthesize call took 222 ms on average (212–617 ms over 235 calls), not 83 ms. Under the old rule, --delay-ms 80 admitted 360 characters, which is about 110 s. SynthesizedDeliveryBudget.projectedSeconds now charges each synthesize call its characters at the pace plus a measured 150 ms call overhead. A spaced plan also pays the delay between calls. maxTextLength is the longest length that exceeds admits, so the refusal, the log, and the hint all use one projection. Result: 358 characters without --delay-ms, 96 at --delay-ms 80. testSpacedDeliveryBudgetChargesEachCharacterItsCallAndDelay is the delay-0.08 boundary test next to the delay-0 one. It also pins the per-character increment at interval + overhead + delay.

Timed boundary runs (your live-run ask): on my own simulator, through executeTypeCommand with the XCTest channel penalized. A 358-character fill took 32.6 s and a 96-character fill --delay-ms 80 took 31.2 s. Both returned ok, and the next command on the same runner succeeded. For contrast, an intermediate 0.03 s allowance admitted 155 characters at --delay-ms 80, and that fill took 48.1 s. I ran these with a temporary test that is not committed, and the runner was driven in-process, not through the daemon CLI.

Hint used the delay-0 ceiling (15:22 inline): fixed. The hint now names both numbers: the undelayed budget and the budget at --delay-ms 80, the retry that TEXT_INPUT_COMMIT_NOT_OBSERVED recommends. It says that --delay-ms lowers the budget. The removed sentence "a lower --delay-ms does not help" was not true under the new model.

TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED missing from commands.md: added, next to the other TEXT_INPUT_* codes.

TextTyping.swift:170 type-route budget fallback has no production-route test: deferred. To reach that branch, a lane test needs a visible software keyboard and more than 358 characters through app.typeText. That is not a cheap addition, and I found no defect in the code there.

Rebase: main removed CHANGELOG.md (#2922), so I dropped the changelog commit and the entry. The user-facing wording is now in the PR body. Main also derives the iOS PR lane from Swift guards (#2896). Both lane tests are selected automatically (check:xctest-selection --ios-pr-tests), so I dropped the two chore(gates) ios.yml commits.

Validation: pnpm check:affected --run exit 0 at a27335ef41. Runner built with unit tests; 32 text-entry, submit-key, and budget tests green on a fresh iPhone 17 Pro iOS 26.2 simulator. Before the rebase, iOS Smoke attempt 1 failed only in Apple toolchain probe failed: xcrun ... ETIMEDOUT after TEST BUILD SUCCEEDED. That is the known cold-toolchain probe flake, not this change. The new push starts fresh CI.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-25 07:18 UTC

@thymikee

Copy link
Copy Markdown
Member Author

This is a follow-up on b3a304d (#2904 (comment)) at a27335e.

The second blocking finding from that review is still open, and the reply doesn't address it: c62366e is byte-identical to b3a304d. RunnerTests+SynthesizedTextEntryTests.swift:78 (https://github.com/callstack/agent-device/blob/a27335e/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift#L78) still accepts either ok with the exact value or TEXT_INPUT_COMMIT_NOT_OBSERVED after a write-back. At the pre-fix 60 cps pace, main already returns that refusal through awaitSynthesizedReplacementCommit, and #2080 recorded the same refusal, so a reverted pace still goes through the rewrite branch and the test stays green; the 5 ms acknowledge window makes the fixture's race unlikely at either pace. XCTAssertTrue(sawKeepUp || sawRewrite) at line 96 is always true, and the NSLog marker at line 94 has no consumer, so no test goes red if the 12 cps pace reverts to 60 cps. The PR body claims an app acknowledging each edit inside 40 ms survives the pace, but nothing in the suite proves that. What test would fail if typingSpeedCharactersPerSecond went back to 60 and pass at 12? One option is a deterministic assertion at the 40 ms policy window — strict ok plus the exact value, no refusal branch — with the 60 cps red run named in the PR; if XCTest spacing is too uneven for that, the title and body could narrow from "keeps" to "reduces" and the guarantee could move to #2906. Either way, the always-true assertion and the NSLog marker should go.

The author's timed boundary runs cover the budget question — 358 chars at 32.6 s and 96 chars at --delay-ms 80 at 31.2 s, both ok — but two things are still missing before this counts as validated. First, that run was in-process and uncommitted: could you attach its output showing textEntryRoute=synthesized-first-responder-replacement for both boundary fills plus the follow-up command's ok response, or rerun it through the daemon CLI (fill at 358 chars, then fill --delay-ms 80 at 96 chars, each followed immediately by a snapshot -i that isn't busy)? Second, the ios.yml lane result at a27335e for testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget and for the corrected #2080 regression test is still needed.

Both Smoke Tests jobs are queued at a27335e. The iOS smoke fill steps and the ios.yml runner lane reach the synthesized-first-responder-replacement route this PR changes — the pace, the delivery budget, and the refusal — so a failure there would be attributable to this PR until shown otherwise. The earlier ETIMEDOUT failure was in the toolchain probe after TEST BUILD SUCCEEDED, and that doesn't overlap this diff.

A couple of limits on what backs this: the claim that main already refuses at 60 cps rests on the previous review's reading of awaitSynthesizedReplacementCommit and on the #2080 report, not a fresh run; the 222 ms per-call figure and the 32.6 s / 31.2 s timings are author-reported with no attached logs, and I can't tell how the 32.6 s splits between delivery and the 10 s commit wait — the worst case of roughly 30 s delivery plus 10 s commit plus focus looks like it fits inside 45 s, but that's not measured. I also didn't verify that check:xctest-selection picks up both lane tests at this head, and I reviewed the logical delta from the range-diff and the two new commits rather than the full diff, since it's dominated by upstream rebase commits.

Before this can merge: add a #2080 regression test that goes red at 60 cps (strict ok plus the exact value, no refusal branch) or narrow the "keeps" claim to "reduces," then attach route-confirmed live output for the boundary fills and the ios.yml lane results at a27335e.

…og and pin the pace the app sees

A 358-character fill through the daemon was abandoned by the runner's
30 s main-thread watchdog with the burst still typing. The delivery
ceiling was a free-standing 30 s sized against the 45 s transport budget,
while the whole command runs under mainThreadExecutionTimeout. The
ceiling is now derived from that watchdog minus a focus allowance and the
commit wait's ceiling (18 s), so the admitted text fits with the longest
commit wait: 214 characters undelayed, 57 at --delay-ms 80.

The app-owned-value lane test accepted either outcome and stayed green at
60 characters per second. It now asserts the spacing the fixture app
receives: a burst's characters average at least one acknowledge window
apart. That is red at 60 (11 edits in about 110 ms) and green at 12
(about 780 ms). A strict "the app keeps up" assertion is not possible:
at a 40 ms window, 12 characters per second still lost one character in
13 of 20 bursts, because XCTest spaces the characters unevenly. The
comments that claimed survival are narrowed to match.

The call overhead and the --delay-ms 80 recovery delay move into
TextEntryTiming beside the other text-entry timings, and both hints read
the recovery delay from there.
@thymikee thymikee changed the title fix(ios-runner): bound the synthesized replacement pace so a value-owning field keeps it fix(ios-runner): slow synthesized replacement so a value-owning field loses fewer characters Sep 24, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at 80bf831fe4. Point by point:

1. No test went red at 60 cps: fixed, and the claim is narrowed. I measured before choosing. 20 bursts of ada@example at the 40 ms policy window:

  • 60 cps: 20/20 left 1 of 11 characters.
  • 12 cps: 7/20 exact, 13/20 left 10 of 11.

The closest pair of characters at 12 cps was 11–44 ms, although the burst averages ~78 ms per gap. So a strict "ok + exact value at 40 ms" test would fail most runs at the shipped pace. That is option (b): the title and body now say the pace reduces the loss, and #2906 owns the guarantee (body says Refs #2080, not Closes). The same test would also be a race again.

What goes red instead: testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow (the renamed lane test) now asserts the spacing the fixture app actually receives. A burst's edits must average at least TextEntryTiming.synthesizedAcknowledgeWindowSeconds apart. The fixture reports burst-edits, burst-ms and min-gap-ms and now takes its window from the test, so the fixture has no default constant of its own.

  • Flipped to 60: red, "11 edits reached the app in 111 ms (closest pair 2 ms)" and 125 ms. testSynthesizedPaceLeavesRoomForAnAppToAcknowledgeEachEdit was also red.
  • Back at 12: green, bursts 753–801 ms.

The write-back branch stays, but only as the runner's half (a rewritten field never reports ok). XCTAssertTrue(sawKeepUp || sawRewrite) and the NSLog marker are deleted. The comments that claimed survival (RunnerSynthesizedTextEntry.m, synthesizedAcknowledgeWindowSeconds, the policy test, TextTyping.swift) are narrowed.

2. Daemon CLI evidence: this found a real bug. My earlier 358-character "32.6 s ok" was in-process, and that harness never runs under the runner's 30 s main-thread watchdog. Through the CLI, fill @e10 <358 chars> failed at 30.2 s with main thread execution timed out. Delivery alone was 30.3 s. The delivery ceiling was a free-standing 30 s sized against the 45 s transport budget, but mainThreadExecutionTimeout bounds the whole command.

It is now derived: synthesizedDeliveryCeiling = mainThreadExecutionTimeout − focus allowance (2 s) − synthesizedCommitCeiling (10 s) = 18 s, which admits 214 characters undelayed and 57 at --delay-ms 80. After the fix, on my own simulator, through the daemon CLI (Release AgentDeviceTester, Form, Full name):

  • fill <215> → TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED (0.9 s, runner.log budgetChars=214).
  • fill <214> → ok, 20.1 s: focus 491 ms, delivery 18.3 s, commit wait 1.1 s. snapshot -i right after: ok, 0.24 s.
  • fill <57> --delay-ms 80 → ok, 19.2 s: focus 503 ms, 57 synthesize calls of 213–556 ms, commit wait 1.1 s. snapshot -i right after: ok, 0.54 s.

runner.log shows route=synthesized-first-responder-replacement for all four. Caveat: that route only runs while the XCTest channel is penalized, so this used a temporary, uncommitted build that forced isSnapshotXCTestChannelPenalized to true. The daemon, CLI, transport and watchdog were the shipped code.

3. Lane results. check:xctest-selection --ios-pr-tests at 80bf831fe4 selects both testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget and testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow. At a27335e, "Run targeted iOS runner XCTest regressions" passed (https://github.com/callstack/agent-device/actions/runs/36033235260/job/107747281633); that run was still in the fixture E2E step when I read it, so the per-test log was not available yet. The new head's iOS run is https://github.com/callstack/agent-device/actions/runs/36039916759. Locally at 80bf831fe4: the 66-test PR selection plus 7 touched host tests, 73 tests, 0 failures.

4. Reuse pass.

  • SynthesizedCommitDeadline is the runtime, progress-based wait, not a projection, so I did not merge it. It does own the ceiling that the budget now subtracts.
  • The duplicated bound was the delivery ceiling itself. It now derives from mainThreadExecutionTimeout, which became static so a static timing can read it (9 mechanical Self. references). The literal 45 in the host test is gone.
  • The call overhead and the --delay-ms 80 recovery delay now live in TextEntryTiming beside the other text-entry timings. Both hints read the delay from there; before, the budget hint and the commit-not-observed hint each carried their own 80.
  • The refusal, the log, and the hint still share one projection (SynthesizedDeliveryBudget).
  • The lane tests now share one launch/focus/penalize helper instead of two copies.

pnpm check:affected --run exit 0 at 80bf831fe4.

@thymikee

Copy link
Copy Markdown
Member Author

The delivery-budget fix looks correct at 80bf831, and the issues from the earlier pass are resolved. Not blocking: a policy or lane test that sends append-mode text past maxTextLength(delaySeconds: 0) through the unresolved-element, keyboard-visible branch (asserting textEntryRoute == "xctest-application-fallback" and value verification) is still missing, but it can be taken or left, https://github.com/callstack/agent-device/blob/80bf831/apple/runner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift#L170.

The CLI-reported timing runs and the 60 cps run are author-reported without attached raw output, and those runs used an uncommitted build that forced isSnapshotXCTestChannelPenalized to true, so they don't stand as evidence for this commit. The earlier lane pass at a27335e was job-level with no per-test logs, and Smoke Tests at 80bf831 is still queued, so CI has not yet spoken for this commit. Smoke Tests and the ios.yml targeted-runner lane need to pass at 80bf831, specifically testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow and testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget, before this is ready to merge.

@thymikee

Copy link
Copy Markdown
Member Author

CI at 80bf831: Smoke Tests passed (run 36039916759). The targeted-runner lane ran both tests the review named:

  • testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow passed (25.1 s)
  • testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget passed (14.2 s)

The non-blocking note, a lane test for the keyboard-visible append fallback at TextTyping.swift:170, is left open.

The element-less, keyboard-visible branch tested the budget against the
chunk it was about to post. An append peels its first character for
warmup and a `--delay-ms` plan posts one character per call, so no chunk
ever looked long and the paced synthesizer kept a text the command
cannot carry: 215 characters arrived as 1 + 214, both inside the budget.
The branch is now charged the command's own length and delay.

That branch had no test, so it gained one: 215 characters through an
unresolved target type application-wide, and the value arrives unverified
because the branch has no element to read back. The fixture field keeps a
real input view under `--agent-device-text-entry-soft-keyboard`, without
which no branch requiring a visible keyboard is reachable.
@thymikee

Copy link
Copy Markdown
Member Author

Took the non-blocking note at 80bf831 and it paid for itself: the branch had no coverage because it was unreachable.

fix(ios-runner): charge the delivery budget the whole type command (a58713b)

  • The over-budget check tested the chunk about to be posted. An append peels its first character for warmup, so a 215-character type dispatches 1 + 214 and both pieces fit inside maxTextLength(delaySeconds: 0); a --delay-ms plan posts one character per call, so nothing ever exceeded it. The paced synthesizer kept text the command cannot carry, and xctest-application-fallback was dead code on this route. The check is now charged the command's own length and delay.
  • testOverBudgetTypeWithoutResolvableElementTypesApplicationWide asserts route=xctest-application-fallback, no failure, verified == nil and the full 215 characters in the field. It fails on route=synthesized-first-responder without the charge fix. The fixture field keeps a real input view under --agent-device-text-entry-soft-keyboard — without one, no branch that requires isKeyboardVisible is reachable, and the hidden-keyboard precondition helper gets its mirror.
  • Correction to a claim in the Summary: on this route the value is not verified. The target has no element, so verifyTextEntry has nothing to read and returns verified: nil, which TypeExecution reports as ok. Same for this branch's older synthesizer-unavailable fallback. Body updated; the TextTyping.swift comment now says so.

Local at a58713b: 11 tests of the text-entry family, 0 failures (71.6 s; the new test 14.8 s). format:check, lint, check:xctest-selection (323 methods, iOS PR lane 67, 0 unreachable), check:gate-manifest, check:packaged-runner-swift green.

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

Copy link
Copy Markdown

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

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

Fix all with cubic | Re-trigger cubic

Comment thread website/docs/docs/commands.md Outdated
@thymikee

Copy link
Copy Markdown
Member Author

This is ready for human review at a58713b; the replacement path now trims the smaller share of characters instead of the larger one, and nothing is left open from the earlier evidence-pending review. Not blocking: projectedSeconds in apple/runner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift charges one synthesize call for an append-mode type, but the route actually makes two calls (a warmup character, then the remainder) with a wait between them, so the projection undercounts by one call overhead plus the warmup wait — worth lining up if you want the estimate to match, but it can wait.

No conflicts. All 21 checks pass at a58713b, including the ios.yml targeted-runner lane, which is the job that exercises the keyboard-visible, no-element branch this delta touches.

A couple of things worth flagging that I couldn't confirm myself: the new lane test starts with XCTSkipIf when the software keyboard isn't visible, so a CI simulator with a hardware keyboard attached would show a skip, not a pass — did the per-test log confirm it ran rather than skipped? I also didn't rerun check:xctest-selection, so the test's inclusion in the iOS PR lane is as reported. The 12 cps synthesized-typing pace, and this delta's use of it, were already covered in the earlier review and are unchanged here.

Can you confirm from the ios.yml per-test log that testOverBudgetTypeWithoutResolvableElementTypesApplicationWide actually ran and passed, rather than being skipped?

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 24, 2026
The review found `projectedSeconds` charging one synthesize call for an
append-mode `type` while the route posts a warmup character and then the
rest, so the estimate undercounted one call and the wait between them.

The over-budget branch that asks is the one with no element, so its
warmup wait is one poll: `waitForWarmupValue` has no expected value to
wait for. That branch now charges 2 calls and the poll, which is one
character less than a replacement of the same length.

Also: the budget hint and the docs named one limit and said "the number"
for a delayed retry, which reads as the larger one; both now name the
limit for the delay in play and say the delay falls between characters.
The pace's header comment claimed the acknowledge window is derived from
it, which it is not. The synthesized-replacement helper builds its
command with JSONSerialization, and the over-budget lane test polls the
field under its own deadline instead of reading `value` once on a route
that verifies nothing.
@thymikee

Copy link
Copy Markdown
Member Author

Can you confirm from the ios.yml per-test log that testOverBudgetTypeWithoutResolvableElementTypesApplicationWide actually ran and passed, rather than being skipped?

Confirmed from the iOS job log at a58713b (run 36045558970):

.RunnerTests testOverBudgetTypeWithoutResolvableElementTypesApplicationWide]' passed (28.986 seconds).
.RunnerTests testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow]' passed (26.985 seconds).
.RunnerTests testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget]' passed (13.478 seconds).

It ran rather than skipped, so the CI simulator brings the software keyboard up for a field with a real input view. I will post the same three lines for the head this comment names once its iOS lane finishes.

fix(ios-runner): charge the warmup split the type plan really makes (7baa118) — the non-blocking estimate note, plus the two P2 and two P3 findings from the bot review:

  • projectedSeconds charged one call for a type plan that posts a warmup character and then the rest. It now takes a typeWarmup flag and charges 2 calls plus the wait between them. The wait is pollInterval, not warmupValueTimeout, because the only caller that asks is the branch with no element: waitForWarmupValue gets no expected value there and returns after one poll. A spaced plan and a one-character plan are unchanged, since neither splits.
  • New host policy test testTypeWarmupSplitCostsOneMoreCallThanASingleBurst: red when the split charge is removed ("1.8166 is not greater than 1.8166", delta 0 vs 0.17), green at 7baa118. That split is also why the lane test's text is maxTextLength + 1: with the warmup charged, a type of that length is one character over its own budget.
  • The docs sentence and the refusal hint both named one limit ("the number the hint names"), which reads as the larger undelayed one for a caller retrying with --delay-ms. Both now name the limit for the delay in play and say the delay falls between characters, not after every character.
  • The over-budget lane test polled the field under its own deadline instead of reading value once, which matters on a route that verifies nothing; and the synthesized-replacement helper assembles its command with JSONSerialization rather than interpolating text into JSON.
  • RunnerSynthesizedTextEntry.h claimed the acknowledge window is derived from the typing speed. It is not; the comment now says so.

Rebase not done: main's 22 commits touch none of these files (its commands.md edits are the Android checked and wait readiness sections), and git merge-tree against origin/main reports no conflict.

Local at 7baa118: the 10-test text-entry family 0 failures; format:check, lint, check:xctest-selection (324 methods, iOS PR lane 67, 0 unreachable), check:gate-manifest, check:packaged-runner-swift, pnpm build green.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 15 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift">

<violation number="1" location="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift:182">
P3: This log reports `value.count`, but the budget decision above it was made on `text.count` (the full command text). In the warmup split and the `--delay-ms` per-character path, `typeIntoCurrentTarget` is called with a single character, so this prints `chars=1` (or `chars=N-1`) for a command that actually overflowed the budget on the full text, which is misleading when triaging `reason=delivery-budget` logs. Log `text.count` instead to match the decision.</violation>
</file>

<file name="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift">

<violation number="1" location="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift:71">
P1: The delivery budget omits the Command-A synthesis call that every replacement performs. A boundary-sized fill therefore passes the refusal check with only about 17 ms of slack before delivery, then can exceed the 30-second watchdog and leave partial input; charge that selection call or reserve its overhead before calculating the maximum length.</violation>
</file>

<file name="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift">

<violation number="1" location="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift:128">
P2: The warmup wait is charged as one pollInterval (0.02s), but for `repairMode == .replacement` the element-less type path actually spends the full `warmupValueTimeout` (0.4s). In that mode `warmupExpectedText` is the first character (non-nil), so `waitForWarmupValue` skips its one-poll guard and loops until the deadline because `editableTextValue(for: nil)` never equals the expected string. The budget check in `typeIntoCurrentTarget` passes `typeWarmup: repairMode != .none`, so this undercounts a replacement-mode warmup by ~0.38s and lets text slightly past the 18s delivery ceiling through the synthesized route instead of the app-wide fallback — the watchdog-abandonment case this change exists to prevent.</violation>
</file>

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

Fix all with cubic | Re-trigger cubic

/// main-thread watchdog leaves after focus and the longest commit wait. The private synthesize
/// call delivers as it returns, so text that does not fit is refused before the first character
/// is posted; otherwise the watchdog abandons the command with the runner still typing.
static let synthesizedDeliveryCeiling: TimeInterval = RunnerTests.mainThreadExecutionTimeout

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The delivery budget omits the Command-A synthesis call that every replacement performs. A boundary-sized fill therefore passes the refusal check with only about 17 ms of slack before delivery, then can exceed the 30-second watchdog and leave partial input; charge that selection call or reserve its overhead before calculating the maximum length.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift, line 71:

<comment>The delivery budget omits the Command-A synthesis call that every replacement performs. A boundary-sized fill therefore passes the refusal check with only about 17 ms of slack before delivery, then can exceed the 30-second watchdog and leave partial input; charge that selection call or reserve its overhead before calculating the maximum length.</comment>

<file context>
@@ -49,10 +58,31 @@ extension RunnerTests {
+    /// main-thread watchdog leaves after focus and the longest commit wait. The private synthesize
+    /// call delivers as it returns, so text that does not fit is refused before the first character
+    /// is posted; otherwise the watchdog abandons the command with the runner still typing.
+    static let synthesizedDeliveryCeiling: TimeInterval = RunnerTests.mainThreadExecutionTimeout
+      - synthesizedReplacementFocusAllowance
+      - synthesizedCommitCeiling
</file context>
Fix with cubic

return Double(textLength) * characterInterval
+ Double(calls) * TextEntryTiming.synthesizeCallOverhead
+ Double(calls - 1) * delaySeconds
+ (warmupSplit ? TextEntryTiming.pollInterval : 0)

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The warmup wait is charged as one pollInterval (0.02s), but for repairMode == .replacement the element-less type path actually spends the full warmupValueTimeout (0.4s). In that mode warmupExpectedText is the first character (non-nil), so waitForWarmupValue skips its one-poll guard and loops until the deadline because editableTextValue(for: nil) never equals the expected string. The budget check in typeIntoCurrentTarget passes typeWarmup: repairMode != .none, so this undercounts a replacement-mode warmup by ~0.38s and lets text slightly past the 18s delivery ceiling through the synthesized route instead of the app-wide fallback — the watchdog-abandonment case this change exists to prevent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift, line 128:

<comment>The warmup wait is charged as one pollInterval (0.02s), but for `repairMode == .replacement` the element-less type path actually spends the full `warmupValueTimeout` (0.4s). In that mode `warmupExpectedText` is the first character (non-nil), so `waitForWarmupValue` skips its one-poll guard and loops until the deadline because `editableTextValue(for: nil)` never equals the expected string. The budget check in `typeIntoCurrentTarget` passes `typeWarmup: repairMode != .none`, so this undercounts a replacement-mode warmup by ~0.38s and lets text slightly past the 18s delivery ceiling through the synthesized route instead of the app-wide fallback — the watchdog-abandonment case this change exists to prevent.</comment>

<file context>
@@ -82,23 +82,97 @@ extension RunnerTests {
+      return Double(textLength) * characterInterval
+        + Double(calls) * TextEntryTiming.synthesizeCallOverhead
+        + Double(calls - 1) * delaySeconds
+        + (warmupSplit ? TextEntryTiming.pollInterval : 0)
+    }
+
</file context>
Fix with cubic

NSLog(
"AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=xctest-application-fallback "
+ "reason=delivery-budget chars=%d",
value.count

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This log reports value.count, but the budget decision above it was made on text.count (the full command text). In the warmup split and the --delay-ms per-character path, typeIntoCurrentTarget is called with a single character, so this prints chars=1 (or chars=N-1) for a command that actually overflowed the budget on the full text, which is misleading when triaging reason=delivery-budget logs. Log text.count instead to match the decision.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift, line 182:

<comment>This log reports `value.count`, but the budget decision above it was made on `text.count` (the full command text). In the warmup split and the `--delay-ms` per-character path, `typeIntoCurrentTarget` is called with a single character, so this prints `chars=1` (or `chars=N-1`) for a command that actually overflowed the budget on the full text, which is misleading when triaging `reason=delivery-budget` logs. Log `text.count` instead to match the decision.</comment>

<file context>
@@ -162,6 +162,28 @@ extension RunnerTests {
+          NSLog(
+            "AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=xctest-application-fallback "
+              + "reason=delivery-budget chars=%d",
+            value.count
+          )
+          app.typeText(value)
</file context>
Fix with cubic

@thymikee

Copy link
Copy Markdown
Member Author

CI green at 7baa118 (all 20 required checks pass). Per-test confirmation for the head this delta touches — iOS targeted lane (run 36101110733):

.RunnerTests testOverBudgetTypeWithoutResolvableElementTypesApplicationWide]' passed (16.798 seconds).
.RunnerTests testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow]' passed (23.909 seconds).
.RunnerTests testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget]' passed (11.640 seconds).

The new testTypeWarmupSplitCostsOneMoreCallThanASingleBurst runs on the macOS host lane (host-runnable runner XCTests, passed 0.010 s) — it is not in the iOS PR lane because it compiles on macOS and has no iOS-only branch, which is why check:xctest-selection counts it in the 259-test host reach rather than the 67-test iOS reach.

@thymikee

Copy link
Copy Markdown
Member Author

This is ready for human review at 7baa118. All checks pass at this commit, and the iOS targeted lane runs the changed no-element keyboard-visible branch (https://github.com/callstack/agent-device/blob/7baa118/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift#L173), which the author's quoted output from run 36101110733 shows passing. I did not open the per-test iOS logs for runs 36045558970 and 36101110733, so the ran-not-skipped confirmation rests on the lines the author quotes, and I did not re-run check:xctest-selection to confirm the new policy test sits on the macOS host lane. Not blocking: the replacement-mode lane test could restore the XCTAssertNil(result.verified) assertion the comment still implies and pick a length that only exceeds budget with warmup charged, the policy test's comment could describe the current per-call-plus-poll charging invariant instead of an older budget round, and the warmup wait charged in replacement mode could be derived from the same expected-value rule waitForWarmupValue uses (or documented as append-only) — all take-it-or-leave-it.

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.

1 participant