Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ @interface AgentDeviceRunnerViewController : UIViewController
@property(nonatomic, strong) UILabel *alertActivationBusyAnswer;
@property(nonatomic, assign) NSUInteger firstAlertActions;
@property(nonatomic, assign) NSUInteger replacementAlertActions;
@property(nonatomic, strong) UILabel *textEntryWriteBackStatus;
@property(nonatomic, assign) NSUInteger textEntryRenderedEdits;
@property(nonatomic, assign) NSUInteger textEntryWriteBacks;
@property(nonatomic, copy, nullable) NSString *textEntryRenderedValue;
@property(nonatomic, assign) NSTimeInterval textEntryLastEditTime;
@property(nonatomic, assign) NSTimeInterval textEntryBurstStartTime;
@property(nonatomic, assign) NSUInteger textEntryBurstEdits;
@property(nonatomic, assign) NSTimeInterval textEntryBurstMinGap;
@property(nonatomic, assign) NSTimeInterval textEntryAcknowledgeWindowSeconds;
@property(nonatomic, assign) BOOL alertFixtureStarted;
@property(nonatomic, strong) NSTimer *alertActivationBusyBackstop;
@property(nonatomic, strong) NSTimer *alertBannerRepost;
Expand Down Expand Up @@ -175,6 +184,17 @@ - (void)updateAlertActionStatus {
(unsigned long)self.replacementAlertActions];
}

- (void)updateTextEntryWriteBackStatus {
NSTimeInterval burstSpan = self.textEntryLastEditTime - self.textEntryBurstStartTime;
self.textEntryWriteBackStatus.text = [NSString
stringWithFormat:@"edits=%lu write-backs=%lu burst-edits=%lu burst-ms=%lu min-gap-ms=%lu",
(unsigned long)self.textEntryRenderedEdits,
(unsigned long)self.textEntryWriteBacks,
(unsigned long)self.textEntryBurstEdits,
(unsigned long)llround(burstSpan * 1000),
(unsigned long)llround(self.textEntryBurstMinGap * 1000)];
}

- (void)presentAlertFixtureReplacement:(BOOL)replacement {
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
BOOL sameTitle = [arguments containsObject:@"--agent-device-alert-same-title"];
Expand Down Expand Up @@ -232,7 +252,51 @@ - (void)viewDidAppear:(BOOL)animated {
}
#endif

// How fast an app that owns this field's value can acknowledge edits: one render per window, passed
// by the test as `--agent-device-text-entry-acknowledge-window <seconds>`. An edit that arrives
// inside that window overtook the render still in flight, so the value that render commits predates
// it and writing it erases the characters that got ahead of the app. The app then reads its own
// erasure back into its model, which is why the field stays wrong instead of healing when the burst
// finishes. The window is decided at the edit rather than scheduled, so a loaded host, which
// stretches the gaps between characters, can only make this app keep up better.
static NSTimeInterval AgentDeviceTextEntryAcknowledgeWindow(void) {
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
NSUInteger index = [arguments indexOfObject:@"--agent-device-text-entry-acknowledge-window"];
return index == NSNotFound || index + 1 >= arguments.count ? 0 : [arguments[index + 1] doubleValue];
}

// Edits further apart than this belong to different bursts: one runner command's characters arrive
// well inside it, and two commands are separated by at least a commit-wait poll and a status read.
static const NSTimeInterval AgentDeviceTextEntryBurstBreakSeconds = 1.0;

- (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
// A field whose app owns its value, the way a controlled React Native `TextInput` does. A burst
// typed faster than the app renders loses the characters that arrived while a render was in
// flight, and the field settles stable short of the request.
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-app-owned-value"]) {
NSTimeInterval now = NSProcessInfo.processInfo.systemUptime;
NSTimeInterval gap = now - self.textEntryLastEditTime;
BOOL overtookARender = self.textEntryRenderedValue != nil && gap < self.textEntryAcknowledgeWindowSeconds;
if (self.textEntryBurstEdits == 0 || gap > AgentDeviceTextEntryBurstBreakSeconds) {
self.textEntryBurstStartTime = now;
self.textEntryBurstEdits = 0;
self.textEntryBurstMinGap = 0;
} else if (self.textEntryBurstEdits == 1 || gap < self.textEntryBurstMinGap) {
self.textEntryBurstMinGap = gap;
}
self.textEntryBurstEdits += 1;
self.textEntryLastEditTime = now;
if (overtookARender) {
if (![textField.text isEqualToString:self.textEntryRenderedValue]) {
textField.text = self.textEntryRenderedValue;
self.textEntryWriteBacks += 1;
}
} else {
self.textEntryRenderedValue = [textField.text copy];
self.textEntryRenderedEdits += 1;
}
[self updateTextEntryWriteBackStatus];
}
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-disappear-after-input"] &&
textField.text.length > 0) {
[textField removeFromSuperview];
Expand Down Expand Up @@ -281,7 +345,13 @@ - (void)viewDidLoad {
UITextField *textField = [[UITextField alloc] init];
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
textField.borderStyle = UITextBorderStyleRoundedRect;
textField.inputView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 1)];
// An empty input view keeps the software keyboard down, which is the hardware-keyboard responder
// these routes are addressed to. `--agent-device-text-entry-soft-keyboard` leaves the real input
// view in place, so a lane test can reach the branch that requires a visible keyboard.
if (![NSProcessInfo.processInfo.arguments
containsObject:@"--agent-device-text-entry-soft-keyboard"]) {
textField.inputView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 1, 1)];
}
[textField addTarget:self
action:@selector(agentDeviceTextEntryDidChange:)
forControlEvents:UIControlEventEditingChanged];
Expand All @@ -293,6 +363,21 @@ - (void)viewDidLoad {
[textField.widthAnchor constraintEqualToConstant:240],
[textField.heightAnchor constraintEqualToConstant:44],
]];
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-app-owned-value"]) {
self.textEntryAcknowledgeWindowSeconds = AgentDeviceTextEntryAcknowledgeWindow();
// Reports how many edits this app rendered and how many writes it had to make because a
// character overtook one, so a lane test can tell a burst the app kept up with from an inert
// fixture. Counts only: no field content crosses into the test.
self.textEntryWriteBackStatus = [[UILabel alloc] init];
self.textEntryWriteBackStatus.accessibilityIdentifier = @"agent-device-text-entry-write-backs";
self.textEntryWriteBackStatus.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.textEntryWriteBackStatus];
[NSLayoutConstraint activateConstraints:@[
[self.textEntryWriteBackStatus.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[self.textEntryWriteBackStatus.topAnchor constraintEqualToAnchor:textField.bottomAnchor constant:12],
]];
[self updateTextEntryWriteBackStatus];
}
}

if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-crowded-screen"]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ typedef NS_ENUM(NSInteger, RunnerSynthesizedTextEntryStatus) {

@interface RunnerSynthesizedTextEntry : NSObject

// Characters per second the synthesized text-input records are typed at. Declared here, where the
// typing happens, so the delivery budget that bounds a burst is charged the same pace the app sees.
// The edit-acknowledge window that pace is sized for is a separate assumption about the app
// (TextEntryTiming.synthesizedAcknowledgeWindowSeconds), not a value derived from this one.
+ (NSUInteger)typingSpeedCharactersPerSecond;

// Synthesizes keyboard input for the current first responder without resolving an
// XCUIElement or serializing the application's accessibility tree.
+ (RunnerSynthesizedTextEntryResult *)synthesizeTextWithApplication:(id)application
text:(NSString *)text;

// Replaces the current first responder's contents using one synthesized
// Command-A, Delete, and text-input event sequence.
// Replaces the current first responder's contents with one synthesized Command-A record
// followed by a text-input record, typed at the bounded pace declared in the implementation.
+ (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
text:(NSString *)text;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@

static NSString *const RunnerTextSynthesisSurface = @"text";

// XCTest's `typingSpeed:` argument is characters per second. At 60 the 11 characters of a `fill`
// arrived at a fixture field across 131 ms (~13 ms per gap), which is faster than an app that owns
// its field's value and re-applies it after the edit (a controlled React Native `TextInput`, an
// async validator) can acknowledge: such a write lands between two characters of the burst and
// erases what was typed while it was in flight, leaving a value that is stable short of the
// request. 12 characters/second spaces them ~83 ms apart on average, which reduces that loss but
// does not remove it: XCTest does not space the characters evenly, and two of them can reach the
// app a few milliseconds apart. Against a fixture app that acknowledges each edit within 40 ms, 60
// characters/second left 1 of 11 characters in 20 of 20 bursts, and this pace left 10 or 11. The
// command refuses a field left short; back-pressure from the field (#2906) is what would prevent
// it. The app-owned-value lane test pins the average spacing the app sees, and the delivery budget
// in TextEntryTiming bounds what the pace costs a long text.
static const NSUInteger RunnerTextEntryTypingSpeedCharactersPerSecond = 12;

typedef id (*RunnerTextMsgSendInit)(id, SEL, NSString *);
typedef id (*RunnerTextMsgSendInitPath)(id, SEL);
typedef void (*RunnerTextMsgSendType)(id, SEL, NSString *, NSTimeInterval, NSUInteger, BOOL);
Expand Down Expand Up @@ -51,6 +65,10 @@ @implementation RunnerSynthesizedTextEntryResult

@implementation RunnerSynthesizedTextEntry

+ (NSUInteger)typingSpeedCharactersPerSecond {
return RunnerTextEntryTypingSpeedCharactersPerSecond;
}

+ (RunnerSynthesizedTextEntryResult *)synthesizeTextWithApplication:(id)application
text:(NSString *)text {
return RunnerSynthesizeTextWithMode(application, text, NO);
Expand Down Expand Up @@ -128,7 +146,14 @@ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
);
}
((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.core.setTargetProcessIDSelector, targetProcessID);
((RunnerTextMsgSendType)objc_msgSend)(path, bridge.typeTextSelector, text, 0.0, 60, YES);
((RunnerTextMsgSendType)objc_msgSend)(
path,
bridge.typeTextSelector,
text,
0.0,
RunnerTextEntryTypingSpeedCharactersPerSecond,
YES
);
((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.core.addPathSelector, path);

NSError *error = nil;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ extension RunnerTests {
}
return try runMainThreadWork(
"command_execution",
timeout: mainThreadExecutionTimeout,
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard)
Expand Down Expand Up @@ -247,7 +247,7 @@ extension RunnerTests {
while true {
let failureCountBefore = try runMainThreadWork(
"recorded_failure_count",
timeout: mainThreadExecutionTimeout,
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.currentXCTestFailureCount()
Expand All @@ -264,7 +264,7 @@ extension RunnerTests {
}
let recordedFailureResponse = try runMainThreadWork(
"recorded_failure_count",
timeout: mainThreadExecutionTimeout,
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.didRecordXCTestFailure(since: failureCountBefore)
Expand All @@ -274,7 +274,7 @@ extension RunnerTests {
if let recordedFailureResponse {
try runMainThreadWork(
"target_invalidation",
timeout: mainThreadExecutionTimeout,
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.invalidateCachedTarget(reason: "xctest_recorded_failure")
Expand All @@ -289,7 +289,7 @@ extension RunnerTests {
hasRetried = true
try runMainThreadWork(
"target_invalidation",
timeout: mainThreadExecutionTimeout,
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.invalidateCachedTarget(reason: "response_unavailable")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ extension RunnerTests {
private func executeSnapshotDispatchedOnce(command: Command) throws -> Response {
let preparation: SnapshotCommandPreparation = try runMainThreadWork(
"command_preparation",
timeout: mainThreadExecutionTimeout,
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) { () -> SnapshotCommandPreparation in
switch try self.prepareActiveCommandContextSafely(command: command, routeToSpringboard: false) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,23 +82,97 @@ extension RunnerTests {
text: String,
delaySeconds: Double
) -> [SynthesizedReplacementStep] {
let characters = Array(text)
guard delaySeconds > 0, characters.count > 1 else {
guard synthesizedReplacementIsSpaced(characterCount: text.count, delaySeconds: delaySeconds)
else {
return [SynthesizedReplacementStep(text: text, replacesExistingText: true)]
}
return characters.enumerated().map { index, character in
return Array(text).enumerated().map { index, character in
SynthesizedReplacementStep(
text: String(character),
replacesExistingText: index == 0
)
}
}

/// Whether a replacement is posted one character per synthesize call, `delaySeconds` apart,
/// rather than as one burst.
static func synthesizedReplacementIsSpaced(characterCount: Int, delaySeconds: Double) -> Bool {
delaySeconds > 0 && characterCount > 1
}

/// What a synthesized burst costs in wall clock, and the ceiling it has to fit inside before the
/// first character is posted. `synthesizedReplacementSteps` decides how a text is posted; this
/// decides whether the runner may start posting it at all.
enum SynthesizedDeliveryBudget {
/// Seconds between two characters of one synthesized burst.
static var characterInterval: TimeInterval {
1.0 / Double(RunnerSynthesizedTextEntry.typingSpeedCharactersPerSecond())
}

/// Seconds the plan spends posting: each synthesize call types its characters at the pace and
/// pays its overhead, a spaced plan sleeps `delaySeconds` between two calls, and a plan that
/// peels one character as a warmup (`typeWarmup`) pays one more call and the wait before the
/// rest is posted. That wait is one poll here because the caller that asks has no element to
/// read the warmup character back from, so `waitForWarmupValue` has no value to wait for.
static func projectedSeconds(
textLength: Int,
delaySeconds: TimeInterval,
typeWarmup: Bool = false
) -> TimeInterval {
let spaced = synthesizedReplacementIsSpaced(characterCount: textLength, delaySeconds: delaySeconds)
let warmupSplit = typeWarmup && textLength > 1 && !spaced
let calls = spaced ? textLength : (warmupSplit ? 2 : 1)
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

}

static func exceeds(
textLength: Int,
delaySeconds: TimeInterval,
typeWarmup: Bool = false
) -> Bool {
projectedSeconds(textLength: textLength, delaySeconds: delaySeconds, typeWarmup: typeWarmup)
> TextEntryTiming.synthesizedDeliveryCeiling
}

/// Longest text `exceeds` admits at `delaySeconds`, which is what the refusal tells the caller.
static func maxTextLength(delaySeconds: TimeInterval) -> Int {
var length = 1
while !exceeds(textLength: length + 1, delaySeconds: delaySeconds) {
length += 1
}
return length
}
}

func runSynthesizedReplacementRoute(
_ request: SynthesizedReplacementRequest
) -> SynthesizedReplacementRouteOutcome {
#if os(iOS)
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder-replacement")
if SynthesizedDeliveryBudget.exceeds(
textLength: request.text.count,
delaySeconds: request.delaySeconds
) {
NSLog(
"AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder-replacement "
+ "reason=delivery-budget-refused chars=%d budgetChars=%d",
request.text.count,
SynthesizedDeliveryBudget.maxTextLength(delaySeconds: request.delaySeconds)
)
return .completed(
TextEntryResult(
verified: nil,
repaired: false,
expectedText: request.text,
observedText: nil,
textEntryRoute: "synthesized-first-responder-replacement",
failure: .synthesisBudgetExceeded
)
)
}
let steps = Self.synthesizedReplacementSteps(
text: request.text,
delaySeconds: request.delaySeconds
Expand Down
Loading
Loading