Skip to content

refactor(ios-runner): charge the text-entry budget the plan the runner posts - #2977

Open
thymikee wants to merge 1 commit into
mainfrom
fix/2955-text-entry-plan-budget
Open

thymikee wants to merge 1 commit into
mainfrom
fix/2955-text-entry-plan-budget

Conversation

@thymikee

@thymikee thymikee commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #2955. The delivery budget from #2904 was a parallel projection: projectedSeconds re-derived the spaced split, the warmup peel and the warmup wait — all decided elsewhere — and under-charged even its own model, counting a select-and-type post as one synthesize call when the bridge runs two (Cmd-A record + text record).

SynthesizedTextPlan now carries the posts a command makes (character counts, whether a post selects first, the wait after it). The budget charges the sum over those steps, and both synthesized routes execute that same array: the loop sleeps the plan's own pauses instead of re-deriving delaySeconds > 0 (twice more, at that). The type route decides the budget once, outside the per-chunk closure, and its budget refusal and synthesizer .fallback arm share one application-wide typing path. The pace moved from the ObjC bridge (whose getter Swift read back through a second source of truth) into TextEntryTiming and is passed to the bridge, so no call site can type at a pace its own budget did not charge. synthesizedAcknowledgeWindowSeconds was production dead weight; it moved into the tests. The fixture's never-asserted edits= counter is gone.

Numbers: fill's admitted length at --delay-ms 0 moves 214 → 212 — honesty about the select-all record the old hint overpromised (at 80 ms both models say 57). The recovery hint now fits the host's 400-char diagnostic bound it previously overflowed by 60, silently losing its last sentence via redactDiagnosticData.

Validation

Commit 43ea03ca0. pnpm check:affected --run passed locally (xctest-selection: 0 dark tests; packaged-runner-swift ok). Local XCUITest evidence: full macOS host lane green (275 tests, TEST EXECUTE SUCCEEDED); iOS simulator lane — pace/budget/route tests all passed (testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow, testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget, testOverBudgetTypeWithoutResolvableElementTypesApplicationWide, testTypeTextReliablyPacesSynthesizedReplacementThroughProductionCaller, testBareSubmitKey…, testBareDelayedType…). tvOS build green (the fixture edits are deletions inside the existing iOS guard… verified by building tvOS).

Regression evidence (mutation discipline):

  • synthesizeCallCount → 1 → testSynthesizedPlanChargesAReplacingPostTwoSynthesizeCalls red at both asserts.
  • peelsWarmupCharacter: false (budget ignores the peel) → testOverBudgetTypeWithoutResolvableElementTypesApplicationWide red: route synthesized-first-responder ≠ xctest-application-fallback — proving the peel charge is what makes the over-budget branch reachable.
  • New pins are policy floors, not formula copies: spaced marginal cost ≥ pace+overhead+delay; plan steps cover the text exactly once (lengths 2/3/11/240 × delays 0/0.08); append chunks admitted at fill's own advertised budget; hint ≤ 400 chars.

Risk: admission tightened by 2 chars for fill at delay 0 (refuses 213–214 that previously shipped and could time out mid-command). type .none burst admission unchanged. Docs need no change; commands.md:491 describes the hint's shape, which is preserved.

Review in cubic

…r posts

The delivery budget added in #2904 was a parallel projection: projectedSeconds
re-derived the spaced split, the warmup peel and the warmup wait, all decided
elsewhere, and two pre-merge fix commits were drift this shape caused. It also
under-charged the plan it modeled: a post that replaces selects with its own
synthesize record (RunnerSynthesizedTextEntry.m), which the estimate counted as
one call.

SynthesizedTextPlan now carries the posts a command makes (characters, whether
the post selects first, the wait after it), the budget charges the sum over
those steps, and both synthesized routes execute that same array — the loop
sleeps the plan's own pauses instead of re-deriving delaySeconds > 0 twice more.
The type route decides the budget once, outside the per-chunk closure, and its
budget refusal and synthesizer .fallback arm share one application-wide typing
path. The pace moved from the ObjC bridge (whose getter Swift read back) into
TextEntryTiming and is passed to the bridge, so no call site can type at a pace
its own budget never charged. The test-only acknowledge window moved into the
tests, and the fixture's never-asserted edits= counter is gone.

fill's admitted length at --delay-ms 0 moves 214 -> 212 (honesty about the
select-all record, which the old hint overpromised); the recovery hint now fits
the host's 400-character diagnostic bound it previously overflowed by 60, losing
its last sentence on the wire.
@github-actions

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.85 MB 4.85 MB +1.1 kB
Package (unpacked) 4.85 MB 4.85 MB +1.1 kB
Package (download) 1.45 MB 1.45 MB -79 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 21.6 ms 21.4 ms -0.3 ms
CLI --help 64.3 ms 61.2 ms -3.1 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed 43ea03c. I found no blocking defect. Both synthesized routes now run the step array the budget charges, and the refusal shares the .fallback typing path.

Could one step executor serve both routes? It would take a plan and a post closure, slice the characters, and sleep pauseAfterSeconds for every step, including the warmup step. That would remove the stored isSpaced and warmupSplit and the two slice loops, and bring the change closer to the −80 lines estimated in #2955 (runner production code grows by about +90 now). The open question is whether the element-backed warmup read-back can be a per-step hook, or must stay outside the plan. If it must stay outside, please say why.

Related, not blocking: the warmup step's pause is charged from the plan, but waitForWarmupValue decides its own wait. They agree today only because the budgeted route has no expected warmup text. A few comments also narrate review history (for example TextEntry.swift#L39); keep the invariant and drop the history.

Smoke Tests, Repo Guards and Coverage were still running at review time, with no failures. Smoke Tests exercises this change directly.

Next step: answer the executor question above. For evidence, please attach the simulator log lines from the head commit for the pace test (route=synthesized-first-responder-replacement) and the over-budget test (route=xctest-application-fallback reason=delivery-budget).

@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 8 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/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift">

<violation number="1" location="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift:6">
P3: The comment's claim that `write-backs` is "the only counter this suite acts on" is inaccurate: `testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow` asserts on `after.burstEdits` (`XCTAssertGreaterThan(after.burstEdits, 1)`) and on `after.burstMilliseconds`, which are more than parse-only diagnostics, and `minimumGapMilliseconds` likewise (it appears only in a failure message, but burst counters do drive assertions). "Acts on" should be "branches on" (or the wording should distinguish the counters that gate an outcome from the ones that feed the pacing assertion).</violation>
</file>

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

<violation number="1" location="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift:340">
P3: The floor accepts `0.8 * delay`, so a plan undercharging up to 20% of each character's delay still passes even though the comment claims the cost "cannot come cheaper" than pace + call + delay. Use the full `delay` in the floor, or rename the tolerance so the 80% is deliberate.</violation>
</file>

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

<violation number="1" location="apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift:203">
P3: On the `type` route the delivery budget never refuses: when `synthesizedTypeCarriesTheCommand` is false, every post is merely routed to `typeApplicationWide`, and the spaced loop keeps posting each character and sleeping `delaySeconds` between them until the whole text is typed, reporting success (failure nil, verified nil). The `TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED` refusal exists only on the `fill` route. So for an over-budget spaced `type --delay-ms` command the text still runs to completion, one slow `app.typeText` call plus a paced sleep per character, on the same watchdogged main thread the ceiling was derived from — the exact "watchdog abandons the command with the runner still typing" failure `synthesizedDeliveryCeiling` documents it prevents. Worth confirming this is intended: if the ceiling should bound the type route too, return a budget failure here (or short-circuit the spaced loop) instead of switching channels.</violation>
</file>

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

Fix all with cubic | Re-trigger cubic

Comment on lines +6 to +8
/// field's contents never cross into the test. `write-backs` is the only counter this suite acts
/// on; the others are parsed so a fixture that stops reporting one fails here instead of silently
/// narrowing what the pacing assertion can see.

@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: The comment's claim that write-backs is "the only counter this suite acts on" is inaccurate: testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow asserts on after.burstEdits (XCTAssertGreaterThan(after.burstEdits, 1)) and on after.burstMilliseconds, which are more than parse-only diagnostics, and minimumGapMilliseconds likewise (it appears only in a failure message, but burst counters do drive assertions). "Acts on" should be "branches on" (or the wording should distinguish the counters that gate an outcome from the ones that feed the pacing assertion).

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

<comment>The comment's claim that `write-backs` is "the only counter this suite acts on" is inaccurate: `testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow` asserts on `after.burstEdits` (`XCTAssertGreaterThan(after.burstEdits, 1)`) and on `after.burstMilliseconds`, which are more than parse-only diagnostics, and `minimumGapMilliseconds` likewise (it appears only in a failure message, but burst counters do drive assertions). "Acts on" should be "branches on" (or the wording should distinguish the counters that gate an outcome from the ones that feed the pacing assertion).</comment>

<file context>
@@ -3,13 +3,16 @@ import XCTest
 #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
   /// What the app-owned-value fixture reports about the edits it saw. Counts and timings only: the
-  /// field's contents never cross into the test.
+  /// field's contents never cross into the test. `write-backs` is the only counter this suite acts
+  /// on; the others are parsed so a fixture that stops reporting one fails here instead of silently
+  /// narrowing what the pacing assertion can see.
</file context>
Suggested change
/// field's contents never cross into the test. `write-backs` is the only counter this suite acts
/// on; the others are parsed so a fixture that stops reporting one fails here instead of silently
/// narrowing what the pacing assertion can see.
/// field's contents never cross into the test. This suite's branch keys on `write-backs`; the
/// others feed the pacing assertion, and each is required so a fixture that stops reporting one
/// fails here instead of silently narrowing what the pacing assertion can see.
Fix with cubic

cost,
TextEntryTiming.synthesizedCharacterInterval
+ TextEntryTiming.synthesizeCallOverhead
+ 0.8 * delay

@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: The floor accepts 0.8 * delay, so a plan undercharging up to 20% of each character's delay still passes even though the comment claims the cost "cannot come cheaper" than pace + call + delay. Use the full delay in the floor, or rename the tolerance so the 80% is deliberate.

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

<comment>The floor accepts `0.8 * delay`, so a plan undercharging up to 20% of each character's delay still passes even though the comment claims the cost "cannot come cheaper" than pace + call + delay. Use the full `delay` in the floor, or rename the tolerance so the 80% is deliberate.</comment>

<file context>
@@ -259,59 +297,130 @@ extension RunnerTests {
+      cost,
+      TextEntryTiming.synthesizedCharacterInterval
+        + TextEntryTiming.synthesizeCallOverhead
+        + 0.8 * delay
+    )
+  }
</file context>
Suggested change
+ 0.8 * delay
+ delay
Fix with cubic

// application-wide typing: the command's own budget refused it, or XCTest's private synthesis
// is unavailable. This branch's target has no element to type into, so nothing can be read
// back afterwards: the value arrives unverified either way.
guard synthesizedTypeCarriesTheCommand else {

@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: On the type route the delivery budget never refuses: when synthesizedTypeCarriesTheCommand is false, every post is merely routed to typeApplicationWide, and the spaced loop keeps posting each character and sleeping delaySeconds between them until the whole text is typed, reporting success (failure nil, verified nil). The TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED refusal exists only on the fill route. So for an over-budget spaced type --delay-ms command the text still runs to completion, one slow app.typeText call plus a paced sleep per character, on the same watchdogged main thread the ceiling was derived from — the exact "watchdog abandons the command with the runner still typing" failure synthesizedDeliveryCeiling documents it prevents. Worth confirming this is intended: if the ceiling should bound the type route too, return a budget failure here (or short-circuit the spaced loop) instead of switching channels.

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 203:

<comment>On the `type` route the delivery budget never refuses: when `synthesizedTypeCarriesTheCommand` is false, every post is merely routed to `typeApplicationWide`, and the spaced loop keeps posting each character and sleeping `delaySeconds` between them until the whole text is typed, reporting success (failure nil, verified nil). The `TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED` refusal exists only on the `fill` route. So for an over-budget spaced `type --delay-ms` command the text still runs to completion, one slow `app.typeText` call plus a paced sleep per character, on the same watchdogged main thread the ceiling was derived from — the exact "watchdog abandons the command with the runner still typing" failure `synthesizedDeliveryCeiling` documents it prevents. Worth confirming this is intended: if the ceiling should bound the type route too, return a budget failure here (or short-circuit the spaced loop) instead of switching channels.</comment>

<file context>
@@ -163,39 +196,18 @@ extension RunnerTests {
+        // application-wide typing: the command's own budget refused it, or XCTest's private synthesis
+        // is unavailable. This branch's target has no element to type into, so nothing can be read
+        // back afterwards: the value arrives unverified either way.
+        guard synthesizedTypeCarriesTheCommand else {
+          return typeApplicationWide(value, reason: "delivery-budget")
         }
</file context>
Fix with cubic

@okwasniewski

Copy link
Copy Markdown
Contributor

Downstream report from a CI fleet pinning agent-device releases, before this lands.

On main (#2904) runSynthesizedReplacementRoute answers TEXT_INPUT_SYNTHESIS_BUDGET_EXCEEDED when SynthesizedDeliveryBudget.exceeds says the text does not fit: 214 chars at --delay-ms 0, 57 at 80 ms. This PR keeps that refusal and moves the admitted length to 212. For us any long fill in a user test on a channel-penalized simulator becomes an engine refusal, and the test author cannot tell from the test why a 250-char address failed.

Ask, since the budget model is being rewritten here anyway: make the budget a pace, never a refusal.

  • SynthesizedTextPlan already carries the posts a command makes. Let it carry as many posts as the text needs: type in chunks sized to the budget, and let the caller's command deadline be the only thing that ends a long entry early.
  • If a chunk cannot be posted before the deadline, fail with a typed reason that names how many characters landed (observedText prefix length), not a refusal before the first character.
  • The type route already does not refuse: it falls through to xctest-application-fallback and types the whole value. fill refusing what type accepts is the inconsistency callers hit.

Happy to take this as a follow-up PR on top of this one if you prefer to keep the scope here; wanted the ask on record before the admitted length is pinned again.

This branch has not been deployed

No deployments
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.

Synthesized text-entry budget: cost the executed plan instead of a parallel projection

2 participants