Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ extension RunnerTests {
invalidateCachedTarget(reason: "xctest_recorded_failure")
return failureResponse
}
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
if !hasRetried, command.traits.retryOnSessionLoss, shouldRetryResponse(response) {
NSLog(
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
command.command.rawValue
Expand All @@ -236,7 +236,7 @@ extension RunnerTests {
}
}

/// The dispatched snapshot recovery loop: read-only retry + XCTest-recorded-failure invalidation,
/// The dispatched snapshot recovery loop: session-loss retry + XCTest-recorded-failure invalidation,
/// matching what `executeOnMainSafely` gives the generic path. `perform` runs the capture and its
/// own bounded main-thread work.
func executeDispatchedWithRecovery(
Expand Down Expand Up @@ -281,7 +281,7 @@ extension RunnerTests {
}
return recordedFailureResponse
}
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
if !hasRetried, command.traits.retryOnSessionLoss, shouldRetryResponse(response) {
NSLog(
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
command.command.rawValue
Expand Down Expand Up @@ -418,88 +418,121 @@ extension RunnerTests {
)
}

/// The target this command runs against, decided by its `launchPolicy` (#2890). Exhaustive over the
/// policy so a new case is a compile error here rather than a fall-through that quietly launches or
/// quietly refuses.
func prepareActiveCommandContext(
command: Command,
routeToSpringboard: Bool = false
) -> ActiveCommandPreparation {
var activeApp = currentApp ?? app
var systemSurface: SystemSurfaceHost? = nil
if routeToSpringboard {
activeApp = springboard
} else if shouldSkipAppActivationPreflight(command) {
activeApp = resolveAppWithoutActivation(command: command)
} else if let presented = presentedSystemSurfaceHost() {
return .context(ActiveCommandContext(app: springboard))
}
switch command.traits.launchPolicy {
case .noApp:
// Answers from the runner's own capture and state, so the target is resolved exactly as it
// stands.
return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 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: A screenshot taken while a registered system surface is foregrounded now resolves the cached/requested app instead of the presented host. On foldable devices this can select the wrong display (or fall back to SpringBoard), so preserve the presented system-surface target before resolving the no-app target.

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+CommandDispatch.swift, line 435:

<comment>A screenshot taken while a registered system surface is foregrounded now resolves the cached/requested app instead of the presented host. On foldable devices this can select the wrong display (or fall back to SpringBoard), so preserve the presented system-surface target before resolving the no-app target.</comment>

<file context>
@@ -418,88 +418,121 @@ extension RunnerTests {
+    case .noApp:
+      // Answers from the runner's own capture and state, so the target is resolved exactly as it
+      // stands.
+      return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))
+    case .presentedSurface:
+      // The command is about the surface that already has focus; activating an app under it would
</file context>
Suggested change
return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))
if let presented = presentedSystemSurfaceHost() {
return .context(ActiveCommandContext(app: presented.app, systemSurface: presented.host))
}
return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))
Fix with cubic

case .presentedSurface:
// The command is about the surface that already has focus; activating an app under it would
// cancel exactly what the command is about.
#if os(iOS)
return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))
#else
// The platform exception, written once: `SystemSurfaceHostRegistry` registers no hosts off iOS,
// so nothing is ever served in place there and such a command keeps the activation route this
// axis found it on.
return prepareActivatedTarget(command: command)
#endif
case .existingApp, .mayLaunch:
// Asked only where activation is on the table: the bypass decides by querying the cached
// target's state, and a command that may bring nothing forward has nothing for it to settle.
if shouldSkipAppActivationPreflight(command) {

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 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: Inside the combined case .existingApp, .mayLaunch: arm, the shouldSkipAppActivationPreflight bypass is reachable only for .mayLaunch: the bypass requires isCoordinateOnlyTap (command == .tap with x/y and no text/selector), and no .existingApp command (.findText/.readText/.snapshot/.gestureViewport/.querySelector) can ever satisfy it. The branch is dead code for half the case label, which reads as if reads could skip the preflight. Split the case so the bypass sits only under .mayLaunch (or drop it from the .existingApp arm).

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+CommandDispatch.swift, line 450:

<comment>Inside the combined `case .existingApp, .mayLaunch:` arm, the `shouldSkipAppActivationPreflight` bypass is reachable only for `.mayLaunch`: the bypass requires `isCoordinateOnlyTap` (command == `.tap` with x/y and no text/selector), and no `.existingApp` command (.findText/.readText/.snapshot/.gestureViewport/.querySelector) can ever satisfy it. The branch is dead code for half the case label, which reads as if reads could skip the preflight. Split the case so the bypass sits only under `.mayLaunch` (or drop it from the `.existingApp` arm).</comment>

<file context>
@@ -418,88 +418,121 @@ extension RunnerTests {
+    case .existingApp, .mayLaunch:
+      // Asked only where activation is on the table: the bypass decides by querying the cached
+      // target's state, and a command that may bring nothing forward has nothing for it to settle.
+      if shouldSkipAppActivationPreflight(command) {
+        // The one request-dependent bypass: a coordinate-only synthesized tap whose cached target is
+        // already foreground needs nothing brought forward.
</file context>
Fix with cubic

// The one request-dependent bypass: a coordinate-only synthesized tap whose cached target is
// already foreground needs nothing brought forward.
return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))
}
return prepareActivatedTarget(command: command)
}
}

/// The route that may bring something forward: a system surface genuinely on screen is served in
/// place, and otherwise the requested session app is resolved and activated. What happens to a
/// stopped app is the caller's `launchPolicy`; the `.existingApp` refusal belongs to
/// `notRunningRefusal` because it is only meaningful once nothing is presented (#2890).
private func prepareActivatedTarget(command: Command) -> ActiveCommandPreparation {
if let presented = presentedSystemSurfaceHost() {
// Serve and drive the presented surface IN PLACE: never activate it (that cancels what it
// presents) and never adopt it as the cached session target, so once it is gone the next
// command resolves back to the still-bound session app (#2438).
activeApp = presented.app
systemSurface = presented.host
if isInteractionCommand(command.command) {
if command.traits.isInteraction {
applyInteractionStabilizationIfNeeded()
}
} else if !isRunnerLifecycleCommand(command.command) {
let normalizedBundleId = command.appBundleId?
.trimmingCharacters(in: .whitespacesAndNewlines)
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
if let bundleId = requestedBundleId,
let notRunning = notRunningReadResponse(command: command, bundleId: bundleId)
{
return .response(notRunning)
return .context(ActiveCommandContext(app: presented.app, systemSurface: presented.host))
}

let normalizedBundleId = command.appBundleId?
.trimmingCharacters(in: .whitespacesAndNewlines)
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
if let bundleId = requestedBundleId,
let notRunning = notRunningRefusal(command: command, bundleId: bundleId)
{
return .response(notRunning)
}
if let bundleId = requestedBundleId {
if currentBundleId != bundleId || currentApp == nil {
_ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
} else {
refreshCachedTargetIfProcessChanged(bundleId: bundleId)
}
} else {
// Do not reuse stale bundle targets when the caller does not explicitly request one.
invalidateCachedTarget(reason: "missing_app_bundle")
}

// Read back after the bundle resolution above, which is what may have just bound a target.
var activeApp = currentApp ?? app
if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
} else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
ensureRunnerHostAppActive(reason: "missing_app_bundle")
activeApp = app
}

let skipExistenceWait = canUseFastForegroundAppGuard(
activeApp: activeApp,
requestedBundleId: requestedBundleId
)
if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
if let bundleId = requestedBundleId {
if currentBundleId != bundleId || currentApp == nil {
_ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
} else {
refreshCachedTargetIfProcessChanged(bundleId: bundleId)
activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: bundleId)))
}
} else {
// Do not reuse stale bundle targets when the caller does not explicitly request one.
invalidateCachedTarget(reason: "missing_app_bundle")
return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: nil)))
}
}

activeApp = currentApp ?? app
if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
} else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
ensureRunnerHostAppActive(reason: "missing_app_bundle")
if command.traits.isInteraction {
if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
} else if requestedBundleId == nil, activeApp.state != .runningForeground {
ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
activeApp = app
}

let skipExistenceWait = canUseFastForegroundAppGuard(
let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
activeApp: activeApp,
requestedBundleId: requestedBundleId
)
if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
if let bundleId = requestedBundleId {
activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: bundleId)))
}
} else {
return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: nil)))
}
}

if isInteractionCommand(command.command) {
if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
} else if requestedBundleId == nil, activeApp.state != .runningForeground {
ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
activeApp = app
}
let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
activeApp: activeApp,
requestedBundleId: requestedBundleId
if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
return .response(
Response(ok: false, error: .targetAppUnavailable(bundleId: requestedBundleId))
)
if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
return .response(
Response(ok: false, error: .targetAppUnavailable(bundleId: requestedBundleId))
)
}
applyInteractionStabilizationIfNeeded()
}
applyInteractionStabilizationIfNeeded()
}
return .context(ActiveCommandContext(app: activeApp, systemSurface: systemSurface))
return .context(ActiveCommandContext(app: activeApp))
}

/// A registered system surface host that is genuinely on screen, or nil. Presence is foreground
Expand Down Expand Up @@ -533,7 +566,7 @@ extension RunnerTests {
if response.data?.runnerFatal == true {
return nil
}
guard !isReadOnlyCommand(command), !isRunnerLifecycleCommand(command.command) else {
guard command.traits.convertsRecordedFailure else {
return nil
}
return Response(
Expand All @@ -546,19 +579,11 @@ extension RunnerTests {
)
}

/// The one activation bypass that depends on the request rather than on the command: a tap that
/// needs nothing the preflight would bring forward. Commands whose own classification answers
/// without the session app's foreground state are handled by their `launchPolicy` (#2890).
func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
#if os(iOS)
if command.command == .alert {
return true
}
// A hardware Action Button press belongs to the system, not to the session app: the Shortcut or
// App Intent behind it is expected to run whether that app is foregrounded, backgrounded, or
// terminated, and activating first would foreground exactly what the press should leave alone.
// The press keeps its recorded-failure conversion, which `isLifecycle` would have removed
// (#2699, #2702 review).
if command.command == .actionButton {
return true
}
// Coordinate-only synthesized taps can run after an AX-fatal foreground screen because they do not
// need app activation, window lookup, keyboard lookup, or element resolution. Selector/text
// interactions intentionally stay on the normal AX path because they need an element query.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ extension RunnerTests {
alertDeadline: Date? = nil
) throws -> Response {
var activeApp = activeApp
if command.command != .tap && command.command != .type && !isReadOnlyCommand(command) {
// Every command that reaches here with a mutation to prove makes a remembered text-entry tap
// stale; the two commands that own that witness decide for themselves in their own cases below.
if command.traits.convertsRecordedFailure,

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 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: Do not use convertsRecordedFailure as the text-entry-witness invalidation predicate: it makes the non-mutating querySelector clear a valid tap→bare-type witness. Give witness invalidation its own trait, or classify selector resolution consistently with the other read commands.

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+CommandExecution.swift, line 13:

<comment>Do not use `convertsRecordedFailure` as the text-entry-witness invalidation predicate: it makes the non-mutating `querySelector` clear a valid tap→bare-type witness. Give witness invalidation its own trait, or classify selector resolution consistently with the other read commands.</comment>

<file context>
@@ -8,7 +8,11 @@ extension RunnerTests {
-    if command.command != .tap && command.command != .type && !isReadOnlyCommand(command) {
+    // Every command that reaches here with a mutation to prove makes a remembered text-entry tap
+    // stale; the two commands that own that witness decide for themselves in their own cases below.
+    if command.traits.convertsRecordedFailure,
+      !CommandTraits.textEntryWitnessOwners.contains(command.command)
+    {
</file context>
Fix with cubic

!CommandTraits.textEntryWitnessOwners.contains(command.command)
{
clearRememberedTextEntryTap()
}
switch command.command {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,12 @@ extension RunnerTests {
return foreign.count == 1 ? foreign.first : nil
}

/// `activate()` on a not-running app is a bare launch, which would drop the URL of a launch
/// SpringBoard still holds behind its "Open in …?" confirmation; see `APP_NOT_RUNNING_RUNNER_CODE`.
func notRunningReadResponse(command: Command, bundleId: String) -> Response? {
/// The `.existingApp` refusal: `activate()` on a not-running app is a bare launch, which would drop
/// the URL of a launch SpringBoard still holds behind its "Open in …?" confirmation; see
/// `APP_NOT_RUNNING_RUNNER_CODE` (#2852).
func notRunningRefusal(command: Command, bundleId: String) -> Response? {
#if os(iOS)
guard isReadOnlyCommand(command),
guard command.traits.launchPolicy == .existingApp,

@cubic-dev-ai cubic-dev-ai Bot Sep 24, 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 .existingApp refusal is compiled in only under #if os(iOS), but Command.traits declares .existingApp (and the new querySelector refusal that fixes #2890) for every platform. On tvOS/macOS, prepareActivatedTarget therefore still reaches activateTarget for a stopped app and bare-launches it — on macOS the same activation route has always applied, but querySelector now joins it through .existingApp with documented "refuses rather than launching" semantics that cannot occur there. State this scope in the existingApp case documentation or enforce the refusal off iOS too.

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+Lifecycle.swift, line 324:

<comment>The `.existingApp` refusal is compiled in only under `#if os(iOS)`, but `Command.traits` declares `.existingApp` (and the new `querySelector` refusal that fixes #2890) for every platform. On tvOS/macOS, `prepareActivatedTarget` therefore still reaches `activateTarget` for a stopped app and bare-launches it — on macOS the same activation route has always applied, but `querySelector` now joins it through `.existingApp` with documented "refuses rather than launching" semantics that cannot occur there. State this scope in the `existingApp` case documentation or enforce the refusal off iOS too.</comment>

<file context>
@@ -316,11 +316,12 @@ extension RunnerTests {
+  func notRunningRefusal(command: Command, bundleId: String) -> Response? {
 #if os(iOS)
-    guard isReadOnlyCommand(command),
+    guard command.traits.launchPolicy == .existingApp,
       XCUIApplication(bundleIdentifier: bundleId).state == .notRunning
     else { return nil }
</file context>
Fix with cubic

XCUIApplication(bundleIdentifier: bundleId).state == .notRunning
else { return nil }
NSLog(
Expand Down Expand Up @@ -450,43 +451,19 @@ extension RunnerTests {
}
}

func shouldRetryCommand(_ command: Command) -> Bool {
isReadOnlyCommand(command)
}
// MARK: - Session-Loss Retry

func shouldRetryException(_ command: Command, message: String) -> Bool {
guard shouldRetryCommand(command) else { return false }
guard command.traits.retryOnSessionLoss else { return false }
// XCTest raises this AX error as an ObjC exception whose reason is the only handle on it.
return message.lowercased().contains("kaxerrorservernotfound")
}

// MARK: - Command Classification

func isReadOnlyCommand(_ command: Command) -> Bool {
switch command.command.traits.readOnly {
case .always:
return true
case .never:
return false
case .conditional:
// Today only `alert` is conditional: read-only when getting, mutating otherwise.
return (command.action ?? "get").lowercased() == "get"
}
}

func shouldRetryResponse(_ response: Response) -> Bool {
guard response.ok == false else { return false }
return response.error?.retryableFailure != nil
}

func isInteractionCommand(_ command: CommandType) -> Bool {
return command.traits.isInteraction
}

func isRunnerLifecycleCommand(_ command: CommandType) -> Bool {
return command.traits.isLifecycle
}

// MARK: - Interaction Stabilization

func applyInteractionStabilizationIfNeeded() {
Expand Down
Loading
Loading