From 1e2a1545005908bb098cabdf77e7c5c6fbc39d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 20:50:13 +0100 Subject: [PATCH 1/5] refactor(apple): declare and cross-check runner surface policies --- .../RunnerSurfacePolicy.swift | 39 +++++ .../RunnerTests+CommandExecution.swift | 2 +- .../RunnerTests+SnapshotExecution.swift | 2 +- .../UnitTests/RunnerTests+ModelsTests.swift | 12 ++ contracts/fixtures/runner-surface-policy.json | 158 ++++++++++++++++++ docs/adr/0025-foldable-apple-panels.md | 27 ++- .../__tests__/runner-surface-policy.test.ts | 25 +++ packages/apple-runner/src/index.ts | 2 +- .../apple-runner/src/runner-command-traits.ts | 95 +++++------ .../platform-apple/src/core/runner-client.ts | 11 +- .../src/foldable/orientation.ts | 17 +- .../src/foldable/runner-panel.test.ts | 23 ++- .../src/foldable/runner-panel.ts | 3 +- 13 files changed, 347 insertions(+), 69 deletions(-) create mode 100644 apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSurfacePolicy.swift create mode 100644 contracts/fixtures/runner-surface-policy.json create mode 100644 packages/apple-runner/src/__tests__/runner-surface-policy.test.ts diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSurfacePolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSurfacePolicy.swift new file mode 100644 index 0000000000..cf5cf752f0 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSurfacePolicy.swift @@ -0,0 +1,39 @@ +import XCTest + +enum RunnerSurfacePolicy: String { + case none + case appWindows = "app-windows" + case observedScreen = "observed-screen" + case resolvedModal = "resolved-modal" +} + +extension CommandType { + var surfacePolicy: RunnerSurfacePolicy { + switch self { + case .mouseClick, .remotePress, .desktopScroll, .snapshotBridge, .home, .rotate, + .actionButton, .volumeUp, .volumeDown, .keyboardReturn, .recordStop, .status, .uptime, + .appState, .pasteboardWrite, .activate, .terminate, .targetReset, .shutdown: + return .none + case .tap, .longPress, .drag, .type, .swipe, .scroll, .findText, .querySelector, .readText, + .snapshot, .backInApp, .backSystem, .gesture, .gestureViewport, .appSwitcher, + .keyboardDismiss, .sequence: + return .appWindows + case .screenshot, .recordStart: + return .observedScreen + case .alert: + return .resolvedModal + } + } +} + +extension RunnerTests { + @MainActor + func prepareCommandSurface(command: Command, app: XCUIApplication) -> RunnerAppScreenCaptureFailure? { + switch command.command.surfacePolicy { + case .appWindows: + return bookRequestLitPanelSurface(app: app) + case .none, .observedScreen, .resolvedModal: + return nil + } + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index c5d3f55785..0f6065ae7b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -40,7 +40,7 @@ extension RunnerTests { clearRememberedTextEntryTap() } // A screenshot resolves its own surface, the target app and then the system surface. - if command.command != .screenshot, let refusal = bookRequestLitPanelSurface(app: activeApp) { + if let refusal = prepareCommandSurface(command: command, app: activeApp) { return refusal.response } switch command.command { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift index 1f2e9866d3..10496e5e94 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift @@ -18,7 +18,7 @@ extension RunnerTests { case .response(let response): return .response(response) case .context(let context): - if let refusal = self.bookRequestLitPanelSurface(app: context.app) { + if let refusal = self.prepareCommandSurface(command: command, app: context.app) { return .response(refusal.response) } if self.requestLitPanel != nil, let refusal = Self.litPanelBackendPinRefusal(command) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index 8bba410ff5..f133fc8c7b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -48,6 +48,18 @@ extension RunnerTests { XCTAssertEqual(orphaned, [], "CommandType cases with no production request") } + func testRunnerSurfacePreparationMatchesTheSharedPolicyFixture() throws { + let rows = try XCTUnwrap(runnerContractFixture("runner-surface-policy.json") as? [[String: String]]) + let names = rows.compactMap { $0["command"] } + XCTAssertEqual(names.count, Set(names).count) + XCTAssertEqual(Set(names), Set(CommandType.allCases.map(\.rawValue))) + for row in rows { + let name = try XCTUnwrap(row["command"]) + let command = try XCTUnwrap(CommandType(rawValue: name)) + XCTAssertEqual(command.surfacePolicy.rawValue, row["policy"], name) + } + } + func testQueuedReadOnlyTraitsMatchTheDaemonParityFixture() throws { let rows = try XCTUnwrap( runnerContractFixture("runner-command-read-only.json") as? [[String: Any]] diff --git a/contracts/fixtures/runner-surface-policy.json b/contracts/fixtures/runner-surface-policy.json new file mode 100644 index 0000000000..eb655b4e9f --- /dev/null +++ b/contracts/fixtures/runner-surface-policy.json @@ -0,0 +1,158 @@ +[ + { + "command": "tap", + "policy": "app-windows" + }, + { + "command": "mouseClick", + "policy": "none" + }, + { + "command": "longPress", + "policy": "app-windows" + }, + { + "command": "drag", + "policy": "app-windows" + }, + { + "command": "remotePress", + "policy": "none" + }, + { + "command": "type", + "policy": "app-windows" + }, + { + "command": "swipe", + "policy": "app-windows" + }, + { + "command": "scroll", + "policy": "app-windows" + }, + { + "command": "desktopScroll", + "policy": "none" + }, + { + "command": "findText", + "policy": "app-windows" + }, + { + "command": "querySelector", + "policy": "app-windows" + }, + { + "command": "readText", + "policy": "app-windows" + }, + { + "command": "snapshot", + "policy": "app-windows" + }, + { + "command": "snapshotBridge", + "policy": "none" + }, + { + "command": "screenshot", + "policy": "observed-screen" + }, + { + "command": "backInApp", + "policy": "app-windows" + }, + { + "command": "backSystem", + "policy": "app-windows" + }, + { + "command": "home", + "policy": "none" + }, + { + "command": "rotate", + "policy": "none" + }, + { + "command": "gesture", + "policy": "app-windows" + }, + { + "command": "gestureViewport", + "policy": "app-windows" + }, + { + "command": "appSwitcher", + "policy": "app-windows" + }, + { + "command": "actionButton", + "policy": "none" + }, + { + "command": "volumeUp", + "policy": "none" + }, + { + "command": "volumeDown", + "policy": "none" + }, + { + "command": "keyboardDismiss", + "policy": "app-windows" + }, + { + "command": "keyboardReturn", + "policy": "none" + }, + { + "command": "alert", + "policy": "resolved-modal" + }, + { + "command": "sequence", + "policy": "app-windows" + }, + { + "command": "recordStart", + "policy": "observed-screen" + }, + { + "command": "recordStop", + "policy": "none" + }, + { + "command": "status", + "policy": "none" + }, + { + "command": "uptime", + "policy": "none" + }, + { + "command": "appState", + "policy": "none" + }, + { + "command": "pasteboardWrite", + "policy": "none" + }, + { + "command": "activate", + "policy": "none" + }, + { + "command": "terminate", + "policy": "none" + }, + { + "command": "targetReset", + "policy": "none" + }, + { + "command": "shutdown", + "policy": "none" + } +] diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index 297c18dc5e..f7af00b6e0 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -720,13 +720,26 @@ Android reports the app window on the next snapshot instead of a panel. SpringBoard keeps windows on both panels. Its first window is the outer panel's status bar, whichever panel is lit. The runner booked gestures, the synthesized reference frame, the snapshot viewport, and the capture display against that window. A home-screen gesture could therefore land on a dark panel. -Decision: the host names the lit panel in each runner request that resolves a screen. `RUNNER_COMMAND_RESOLVES_SURFACE` in `packages/apple-runner/src/runner-command-traits.ts` is the one list of these commands: `tap`, `longPress`, `drag`, `type`, `swipe`, `scroll`, `findText`, `querySelector`, `readText`, `snapshot`, `screenshot`, `backInApp`, `backSystem`, `gesture`, `gestureViewport`, `appSwitcher`, `keyboardDismiss`, `sequence`, and `recordStart`. The request field is `litPanel` (`displayId`, `widthPt`, `heightPt`). The panel table is read once per request because a fold changes the answer. The read never outlasts the request's `timeoutMs`. A multi-panel table read that names the lit panel but spends the request budget refuses with `COMMAND_FAILED`, `reason: panel-probe-budget-exhausted` and `dispatched: no`, without a runner error code. The host sends the field only for a multi-panel simulator whose panel power decides exactly one panel. The runner gets what the read left of `timeoutMs`. A device known to be multi-panel whose table does not answer is refused with `COMMAND_FAILED`, `reason: panel-table-unreadable` and `runnerErrorCode: APP_SCREEN_UNRESOLVED`, before anything is sent. Only a device whose panel count was never read keeps the historic first-window route unchanged. - -With a named panel, the runner walks at most eight windows to book one; element search reads every window that reports the lit display. It books the first window on the lit display whose frame is the panel's native box, upright or turned. If no window fills the panel, it books the largest window on the lit display. A window that the walk cannot place refuses the request with `APP_SCREEN_UNRESOLVED`, even if a full-panel window follows it. A list past the cap with no full-panel window refuses the same way. An app whose windows are all on another display is `APP_SCREEN_OFF_LIT_PANEL`. An app with no window at all is `APP_SCREEN_WINDOW_UNRESOLVED`. A window list that cannot be read is `APP_SCREEN_UNRESOLVED`, so it never reaches the system-surface fallback. - -The request books the window by bundle identity and request identity before it acts, and releases the booking when the request ends. A refusal is returned before any gesture or snapshot. Dispatch, the coordinate fallback, and the snapshot viewport reuse the booked window of the same app, so they never fall back to the application's own frame. Every element read of such a request (`findText`, `querySelector`, `readText`, a selector `tap`, and the text-input lookup of `tap` and `type`) searches only under the application's windows that report the lit display. The element search rereads the complete window list for each read, including windows beyond the booking cap, because mutations can open another window. If the list cannot be read or contains no proven lit windows, the search throws `APP_SCREEN_UNRESOLVED` instead of answering no match. A lit-panel refusal during text-entry confirmation propagates with its typed code. The panel is written onto the main queue behind work already queued there, so work that a previous command abandoned still reads that command's panel. Capture resolves its own window with the same rule. It asks the system surface only when the app has no window, and it refuses an app on the dark panel. - -A snapshot runs only the tree tier, because only that tier can prove which display a window is on. A pin to another backend is refused with `UNSUPPORTED_OPERATION`. The snapshot traverses the application's tree, not only the booked window, and keeps every window whose element snapshot reports the lit display. A window that reports no display is dropped. If no window is proven, the snapshot refuses with `APP_SCREEN_UNRESOLVED`. The root takes the booked window's frame. `--raw` applies the same window filter and the same root frame, so raw and regular coordinates share one space. The snapshot reads the booked window only from its capture target. Collapsed-tab fallback candidates use the shared proven-lit element-query owner with that capture target's pinned display ID. A failed root walk propagates `APP_SCREEN_UNRESOLVED`. A `customActions` request with no explicit backend pin implies the private AX pin, so its refusal names `customActions`. +Decision: requests that search app windows or observe a screen name the lit panel before dispatch. +The [TypeScript policy](../../packages/apple-runner/src/runner-command-traits.ts), [Swift policy](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSurfacePolicy.swift), and [shared fixture](../../contracts/fixtures/runner-surface-policy.json) own command classification. +Modal commands resolve their prompt through the modal owner instead of booking an app window in advance. + +Omitting `litPanel` preserves the historic route. +This includes single-panel devices, unsupported devices, unknown or backed-off inventory, and multi-panel devices whose power is ambiguous or whose inventory is incomplete. +A known multi-panel device whose table does not answer still refuses before dispatch. +The [panel admission owner](../../packages/platform-apple/src/foldable/runner-panel.ts) and [its tests](../../packages/platform-apple/src/foldable/runner-panel.test.ts) own those cases and the request-budget rule. + +Anchor selection and element search answer different questions. +The anchor walk must be bounded, while search must include later windows such as the keyboard. +The [window resolver](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m), [request booking owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerRequestPanel.swift), and [booking tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerRequestPanelTests.swift) own selection, scope, and typed refusals. +Search refreshes lit windows because an interaction can create another window. +Request-scoped booking keeps delayed work on its own panel instead of the next command's panel. + +A snapshot must retain every window proven on the lit display instead of taking only the gesture anchor. +Only the tree backend proves display identity, so a backend pin must not bypass that proof. +The [snapshot preparation owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift), [snapshot owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift) and booking tests own filtering and geometry. +A capture may ask the system surface only when the app has no window; a resolved window's identity refusal must remain visible. +The [capture tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AppScreenCaptureTests.swift) own that distinction. Rejected alternatives: diff --git a/packages/apple-runner/src/__tests__/runner-surface-policy.test.ts b/packages/apple-runner/src/__tests__/runner-surface-policy.test.ts new file mode 100644 index 0000000000..211aa6ffe7 --- /dev/null +++ b/packages/apple-runner/src/__tests__/runner-surface-policy.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'vitest'; +import type { RunnerCommand } from '../runner-contract.ts'; +import { + RUNNER_COMMAND_TRAITS, + runnerCommandSurfacePolicy, + type RunnerSurfacePolicy, +} from '../runner-command-traits.ts'; + +test('runner surface preparation matches the cross-language policy fixture', () => { + const rows = JSON.parse( + fs.readFileSync( + new URL('../../../../contracts/fixtures/runner-surface-policy.json', import.meta.url), + 'utf8', + ), + ) as Array<{ command: RunnerCommand['command']; policy: RunnerSurfacePolicy }>; + assert.deepEqual( + rows.map(({ command }) => command).sort(), + Object.keys(RUNNER_COMMAND_TRAITS).sort(), + ); + for (const { command, policy } of rows) { + assert.equal(runnerCommandSurfacePolicy({ command }), policy, command); + } +}); diff --git a/packages/apple-runner/src/index.ts b/packages/apple-runner/src/index.ts index bdd464e2a9..d8e9e487e3 100644 --- a/packages/apple-runner/src/index.ts +++ b/packages/apple-runner/src/index.ts @@ -41,7 +41,7 @@ export { type RunnerCommand, type RunnerSnapshotBridgeRequest, } from './runner-contract.ts'; -export { runnerCommandResolvesSurface } from './runner-command-traits.ts'; +export { runnerCommandSurfacePolicy, type RunnerSurfacePolicy } from './runner-command-traits.ts'; export { isRunnerMainThreadTimeoutError } from './runner-error-classification.ts'; export { withAppleRunnerProvider, diff --git a/packages/apple-runner/src/runner-command-traits.ts b/packages/apple-runner/src/runner-command-traits.ts index 327f9c1bb9..a6dea8dcce 100644 --- a/packages/apple-runner/src/runner-command-traits.ts +++ b/packages/apple-runner/src/runner-command-traits.ts @@ -109,55 +109,52 @@ export const RUNNER_COMMAND_TRAITS = { shutdown: DEFAULT_TRAITS, } as const satisfies Record; -/** - * Whether the runner resolves a screen for the command: a window to book a gesture on, a viewport, - * a display to sample, or windows to search for an element. Such a request for a multi-panel device - * names the lit panel (ADR 0025). This table is the one list of lit-panel-scoped commands. - */ -const RUNNER_COMMAND_RESOLVES_SURFACE = { - tap: true, - mouseClick: false, - longPress: true, - drag: true, - remotePress: false, - type: true, - swipe: true, - scroll: true, - desktopScroll: false, - findText: true, - querySelector: true, - readText: true, - snapshot: true, - screenshot: true, - backInApp: true, - backSystem: true, - home: false, - rotate: false, - gesture: true, - gestureViewport: true, - appSwitcher: true, - actionButton: false, - volumeUp: false, - volumeDown: false, - keyboardDismiss: true, - keyboardReturn: false, - alert: false, - sequence: true, - recordStart: true, - recordStop: false, - status: false, - uptime: false, - appState: false, - snapshotBridge: false, - pasteboardWrite: false, - activate: false, - terminate: false, - targetReset: false, - shutdown: false, -} as const satisfies Record; - -export function runnerCommandResolvesSurface(command: RunnerCommand): boolean { - return RUNNER_COMMAND_RESOLVES_SURFACE[command.command]; +export type RunnerSurfacePolicy = 'none' | 'app-windows' | 'observed-screen' | 'resolved-modal'; + +const RUNNER_COMMAND_SURFACE_POLICY = { + tap: 'app-windows', + mouseClick: 'none', + longPress: 'app-windows', + drag: 'app-windows', + remotePress: 'none', + type: 'app-windows', + swipe: 'app-windows', + scroll: 'app-windows', + desktopScroll: 'none', + findText: 'app-windows', + querySelector: 'app-windows', + readText: 'app-windows', + snapshot: 'app-windows', + screenshot: 'observed-screen', + backInApp: 'app-windows', + backSystem: 'app-windows', + home: 'none', + rotate: 'none', + gesture: 'app-windows', + gestureViewport: 'app-windows', + appSwitcher: 'app-windows', + actionButton: 'none', + volumeUp: 'none', + volumeDown: 'none', + keyboardDismiss: 'app-windows', + keyboardReturn: 'none', + alert: 'resolved-modal', + sequence: 'app-windows', + recordStart: 'observed-screen', + recordStop: 'none', + status: 'none', + uptime: 'none', + appState: 'none', + snapshotBridge: 'none', + pasteboardWrite: 'none', + activate: 'none', + terminate: 'none', + targetReset: 'none', + shutdown: 'none', +} as const satisfies Record; + +export function runnerCommandSurfacePolicy(command: RunnerCommand): RunnerSurfacePolicy { + return RUNNER_COMMAND_SURFACE_POLICY[command.command]; } export function isReadOnlyRunnerCommand(command: RunnerCommand): boolean { diff --git a/packages/platform-apple/src/core/runner-client.ts b/packages/platform-apple/src/core/runner-client.ts index 9dd55eefb1..09c81d280e 100644 --- a/packages/platform-apple/src/core/runner-client.ts +++ b/packages/platform-apple/src/core/runner-client.ts @@ -1,7 +1,7 @@ import { bindAppleRunnerHost, resolveRunnerRequestSignal, - runnerCommandResolvesSurface, + runnerCommandSurfacePolicy, runAppleRunnerCommand as runHostBoundAppleRunnerCommand, type AppleRunnerCommandOptions, type RunnerCommand, @@ -29,8 +29,13 @@ export async function runAppleRunnerCommand( command: RunnerCommand, options: AppleRunnerCommandOptions = {}, ): Promise> { - if (!runnerCommandResolvesSurface(command)) { - return await runHostBoundAppleRunnerCommand(device, command, options); + switch (runnerCommandSurfacePolicy(command)) { + case 'none': + case 'resolved-modal': + return await runHostBoundAppleRunnerCommand(device, command, options); + case 'app-windows': + case 'observed-screen': + break; } const { nameRunnerRequestLitPanel } = await import('../foldable/runner-panel.ts'); return await runHostBoundAppleRunnerCommand( diff --git a/packages/platform-apple/src/foldable/orientation.ts b/packages/platform-apple/src/foldable/orientation.ts index a67802bab1..16c0da9e23 100644 --- a/packages/platform-apple/src/foldable/orientation.ts +++ b/packages/platform-apple/src/foldable/orientation.ts @@ -30,7 +30,7 @@ const ORIENTATION_UNVERIFIED_HINT = /** Resolved panel counts are hardware facts, so each simulator is probed once per daemon. */ const multiPanelByDevice = new Map(); -// A device whose panel count was never read routes as single-panel. A full-budget read that failed +// A device whose panel count was never read keeps the unscoped route. A full-budget read that failed // for it is not repeated for a few seconds, because every surface command would pay it again. const unansweredPanelTables = createTtlMemo({ ttlMs: 5_000 }); // Reads overlap, so a failure may finish after a read that started later and already resolved. @@ -69,8 +69,11 @@ export async function isAppleFoldableSimulator( export type FoldablePanelTableRead = | { kind: 'multi-panel'; inventory: AppleDisplayInventory } - /** A device the `fold` fact refuses, one with one panel, or one whose panel count is unknown. */ | { kind: 'single-panel' } + | { + kind: 'unavailable'; + reason: 'unsupported-device' | 'unknown-panel-count' | 'probe-backed-off'; + } /** A device known to have more than one panel whose table did not answer this time. */ | { kind: 'unreadable' }; @@ -82,10 +85,14 @@ export async function readFoldablePanelTable( device: DeviceInfo, options: { signal?: AbortSignal; timeoutMs: number }, ): Promise { - if (!appleFoldableFacts(device).setFoldPose.available) return { kind: 'single-panel' }; + if (!appleFoldableFacts(device).setFoldPose.available) { + return { kind: 'unavailable', reason: 'unsupported-device' }; + } const known = multiPanelByDevice.get(device.id); if (known === false) return { kind: 'single-panel' }; - if (known === undefined && unansweredPanelTables.get(device.id)) return { kind: 'single-panel' }; + if (known === undefined && unansweredPanelTables.get(device.id)) { + return { kind: 'unavailable', reason: 'probe-backed-off' }; + } const read = ++panelTableReads; const inventory = await queryAppleDisplayInventory(device, options); if (inventory.unresolved) { @@ -95,7 +102,7 @@ export async function readFoldablePanelTable( if (fullBudget && !options.signal?.aborted && !newerReadResolved) { unansweredPanelTables.set(device.id, true); } - return { kind: 'single-panel' }; + return { kind: 'unavailable', reason: 'unknown-panel-count' }; } recordPanelTable(device, inventory, read); return inventory.multiScreen ? { kind: 'multi-panel', inventory } : { kind: 'single-panel' }; diff --git a/packages/platform-apple/src/foldable/runner-panel.test.ts b/packages/platform-apple/src/foldable/runner-panel.test.ts index b1c76fc015..63f8e9c52e 100644 --- a/packages/platform-apple/src/foldable/runner-panel.test.ts +++ b/packages/platform-apple/src/foldable/runner-panel.test.ts @@ -23,7 +23,7 @@ import { recordingRunnerProvider, type RecordedRunnerCall, } from '../__tests__/recording-runner-provider.ts'; -import { isAppleFoldableSimulator } from './orientation.ts'; +import { isAppleFoldableSimulator, readFoldablePanelTable } from './orientation.ts'; import { resolveRunnerRequestLitPanel } from './runner-panel.ts'; import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/device-fixtures.ts'; import { @@ -85,10 +85,31 @@ test('a device with one panel sends no panel and is never asked again', async () answering(IPHONE_17_SINGLE_PANEL); assert.equal(await resolveRunnerRequestLitPanel(device, {}), undefined); const reads = mockPanelTable.mock.calls.length; + assert.deepEqual(await readFoldablePanelTable(device, { timeoutMs: 5_000 }), { + kind: 'single-panel', + }); assert.equal(await resolveRunnerRequestLitPanel(device, {}), undefined); assert.equal(mockPanelTable.mock.calls.length, reads, 'one panel is hardware, probed once'); }); +test('unsupported and unknown panel hardware keep distinct omission reasons', async () => { + assert.deepEqual(await readFoldablePanelTable(IOS_DEVICE, { timeoutMs: 5_000 }), { + kind: 'unavailable', + reason: 'unsupported-device', + }); + const device = simulator(); + mockPanelTable.mockRejectedValue(new Error('devicectl does not report displays')); + assert.deepEqual(await readFoldablePanelTable(device, { timeoutMs: 5_000 }), { + kind: 'unavailable', + reason: 'unknown-panel-count', + }); + assert.deepEqual(await readFoldablePanelTable(device, { timeoutMs: 5_000 }), { + kind: 'unavailable', + reason: 'probe-backed-off', + }); + assert.equal(mockPanelTable.mock.calls.length, 1); +}); + test('a failed first panel read backs off temporarily and probes again after expiry', async () => { const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); try { diff --git a/packages/platform-apple/src/foldable/runner-panel.ts b/packages/platform-apple/src/foldable/runner-panel.ts index 0e8010172d..4fc8bc5a58 100644 --- a/packages/platform-apple/src/foldable/runner-panel.ts +++ b/packages/platform-apple/src/foldable/runner-panel.ts @@ -23,7 +23,8 @@ function panelTableRefusal(device: DeviceInfo, message: string): AppError { /** * The lit panel a runner request names, read fresh per request because a fold changes it (ADR * 0025). The read never outlasts the request's `timeoutMs`. Undefined keeps the historic - * first-window route, for a device not known to have more than one panel. + * first-window route whenever the read cannot name one lit panel. An unreadable table on a + * known multi-panel device refuses instead. */ export async function resolveRunnerRequestLitPanel( device: DeviceInfo, From 1e1aab47b8a66a85484ea137675143d10674038b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 21:05:26 +0100 Subject: [PATCH 2/5] test(apple): name surface policy coverage for its trait owner --- ...olicy.test.ts => runner-command-traits-surface-policy.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/apple-runner/src/__tests__/{runner-surface-policy.test.ts => runner-command-traits-surface-policy.test.ts} (100%) diff --git a/packages/apple-runner/src/__tests__/runner-surface-policy.test.ts b/packages/apple-runner/src/__tests__/runner-command-traits-surface-policy.test.ts similarity index 100% rename from packages/apple-runner/src/__tests__/runner-surface-policy.test.ts rename to packages/apple-runner/src/__tests__/runner-command-traits-surface-policy.test.ts From 1ad78958970641092a93d98ea4da4f4554470634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 21:14:55 +0100 Subject: [PATCH 3/5] refactor(apple): keep the surface policy type with its owner --- .../AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift | 1 - packages/apple-runner/src/index.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 0f6065ae7b..4614d347bd 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -39,7 +39,6 @@ extension RunnerTests { if command.invalidatesRememberedTextEntryTap { clearRememberedTextEntryTap() } - // A screenshot resolves its own surface, the target app and then the system surface. if let refusal = prepareCommandSurface(command: command, app: activeApp) { return refusal.response } diff --git a/packages/apple-runner/src/index.ts b/packages/apple-runner/src/index.ts index d8e9e487e3..477f057334 100644 --- a/packages/apple-runner/src/index.ts +++ b/packages/apple-runner/src/index.ts @@ -41,7 +41,7 @@ export { type RunnerCommand, type RunnerSnapshotBridgeRequest, } from './runner-contract.ts'; -export { runnerCommandSurfacePolicy, type RunnerSurfacePolicy } from './runner-command-traits.ts'; +export { runnerCommandSurfacePolicy } from './runner-command-traits.ts'; export { isRunnerMainThreadTimeoutError } from './runner-error-classification.ts'; export { withAppleRunnerProvider, From d2de6a870294d8ebbe86f1408e05f05b852099c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 21:55:44 +0100 Subject: [PATCH 4/5] refactor(apple): trim surface policy parity coverage Keep the cross-language fixture as a command-to-policy map, check it in the existing trait test, and drop the unconsumed panel-read omission reasons. Co-Authored-By: Claude Opus 5.5 --- .../UnitTests/RunnerTests+ModelsTests.swift | 11 +- contracts/fixtures/runner-surface-policy.json | 199 ++++-------------- ...nner-command-traits-surface-policy.test.ts | 25 --- .../__tests__/runner-command-traits.test.ts | 14 ++ .../src/foldable/orientation.ts | 17 +- .../src/foldable/runner-panel.test.ts | 23 +- 6 files changed, 63 insertions(+), 226 deletions(-) delete mode 100644 packages/apple-runner/src/__tests__/runner-command-traits-surface-policy.test.ts diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index f133fc8c7b..293685b255 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -49,15 +49,8 @@ extension RunnerTests { } func testRunnerSurfacePreparationMatchesTheSharedPolicyFixture() throws { - let rows = try XCTUnwrap(runnerContractFixture("runner-surface-policy.json") as? [[String: String]]) - let names = rows.compactMap { $0["command"] } - XCTAssertEqual(names.count, Set(names).count) - XCTAssertEqual(Set(names), Set(CommandType.allCases.map(\.rawValue))) - for row in rows { - let name = try XCTUnwrap(row["command"]) - let command = try XCTUnwrap(CommandType(rawValue: name)) - XCTAssertEqual(command.surfacePolicy.rawValue, row["policy"], name) - } + let policies = try XCTUnwrap(runnerContractFixture("runner-surface-policy.json") as? [String: String]) + XCTAssertEqual(policies, Dictionary(uniqueKeysWithValues: CommandType.allCases.map { ($0.rawValue, $0.surfacePolicy.rawValue) })) } func testQueuedReadOnlyTraitsMatchTheDaemonParityFixture() throws { diff --git a/contracts/fixtures/runner-surface-policy.json b/contracts/fixtures/runner-surface-policy.json index eb655b4e9f..933b248c88 100644 --- a/contracts/fixtures/runner-surface-policy.json +++ b/contracts/fixtures/runner-surface-policy.json @@ -1,158 +1,41 @@ -[ - { - "command": "tap", - "policy": "app-windows" - }, - { - "command": "mouseClick", - "policy": "none" - }, - { - "command": "longPress", - "policy": "app-windows" - }, - { - "command": "drag", - "policy": "app-windows" - }, - { - "command": "remotePress", - "policy": "none" - }, - { - "command": "type", - "policy": "app-windows" - }, - { - "command": "swipe", - "policy": "app-windows" - }, - { - "command": "scroll", - "policy": "app-windows" - }, - { - "command": "desktopScroll", - "policy": "none" - }, - { - "command": "findText", - "policy": "app-windows" - }, - { - "command": "querySelector", - "policy": "app-windows" - }, - { - "command": "readText", - "policy": "app-windows" - }, - { - "command": "snapshot", - "policy": "app-windows" - }, - { - "command": "snapshotBridge", - "policy": "none" - }, - { - "command": "screenshot", - "policy": "observed-screen" - }, - { - "command": "backInApp", - "policy": "app-windows" - }, - { - "command": "backSystem", - "policy": "app-windows" - }, - { - "command": "home", - "policy": "none" - }, - { - "command": "rotate", - "policy": "none" - }, - { - "command": "gesture", - "policy": "app-windows" - }, - { - "command": "gestureViewport", - "policy": "app-windows" - }, - { - "command": "appSwitcher", - "policy": "app-windows" - }, - { - "command": "actionButton", - "policy": "none" - }, - { - "command": "volumeUp", - "policy": "none" - }, - { - "command": "volumeDown", - "policy": "none" - }, - { - "command": "keyboardDismiss", - "policy": "app-windows" - }, - { - "command": "keyboardReturn", - "policy": "none" - }, - { - "command": "alert", - "policy": "resolved-modal" - }, - { - "command": "sequence", - "policy": "app-windows" - }, - { - "command": "recordStart", - "policy": "observed-screen" - }, - { - "command": "recordStop", - "policy": "none" - }, - { - "command": "status", - "policy": "none" - }, - { - "command": "uptime", - "policy": "none" - }, - { - "command": "appState", - "policy": "none" - }, - { - "command": "pasteboardWrite", - "policy": "none" - }, - { - "command": "activate", - "policy": "none" - }, - { - "command": "terminate", - "policy": "none" - }, - { - "command": "targetReset", - "policy": "none" - }, - { - "command": "shutdown", - "policy": "none" - } -] +{ + "tap": "app-windows", + "mouseClick": "none", + "longPress": "app-windows", + "drag": "app-windows", + "remotePress": "none", + "type": "app-windows", + "swipe": "app-windows", + "scroll": "app-windows", + "desktopScroll": "none", + "findText": "app-windows", + "querySelector": "app-windows", + "readText": "app-windows", + "snapshot": "app-windows", + "snapshotBridge": "none", + "screenshot": "observed-screen", + "backInApp": "app-windows", + "backSystem": "app-windows", + "home": "none", + "rotate": "none", + "gesture": "app-windows", + "gestureViewport": "app-windows", + "appSwitcher": "app-windows", + "actionButton": "none", + "volumeUp": "none", + "volumeDown": "none", + "keyboardDismiss": "app-windows", + "keyboardReturn": "none", + "alert": "resolved-modal", + "sequence": "app-windows", + "recordStart": "observed-screen", + "recordStop": "none", + "status": "none", + "uptime": "none", + "appState": "none", + "pasteboardWrite": "none", + "activate": "none", + "terminate": "none", + "targetReset": "none", + "shutdown": "none" +} diff --git a/packages/apple-runner/src/__tests__/runner-command-traits-surface-policy.test.ts b/packages/apple-runner/src/__tests__/runner-command-traits-surface-policy.test.ts deleted file mode 100644 index 211aa6ffe7..0000000000 --- a/packages/apple-runner/src/__tests__/runner-command-traits-surface-policy.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { test } from 'vitest'; -import type { RunnerCommand } from '../runner-contract.ts'; -import { - RUNNER_COMMAND_TRAITS, - runnerCommandSurfacePolicy, - type RunnerSurfacePolicy, -} from '../runner-command-traits.ts'; - -test('runner surface preparation matches the cross-language policy fixture', () => { - const rows = JSON.parse( - fs.readFileSync( - new URL('../../../../contracts/fixtures/runner-surface-policy.json', import.meta.url), - 'utf8', - ), - ) as Array<{ command: RunnerCommand['command']; policy: RunnerSurfacePolicy }>; - assert.deepEqual( - rows.map(({ command }) => command).sort(), - Object.keys(RUNNER_COMMAND_TRAITS).sort(), - ); - for (const { command, policy } of rows) { - assert.equal(runnerCommandSurfacePolicy({ command }), policy, command); - } -}); diff --git a/packages/apple-runner/src/__tests__/runner-command-traits.test.ts b/packages/apple-runner/src/__tests__/runner-command-traits.test.ts index d0cb3e779a..fd0896691a 100644 --- a/packages/apple-runner/src/__tests__/runner-command-traits.test.ts +++ b/packages/apple-runner/src/__tests__/runner-command-traits.test.ts @@ -8,12 +8,26 @@ import { isReadOnlyRunnerCommand, readRunnerCommandTraits, RUNNER_COMMAND_TRAITS, + runnerCommandSurfacePolicy, type RunnerCommandTraits, } from '../runner-command-traits.ts'; import { readSwiftOffQueueCommands } from './runner-swift-settlement-fixtures.ts'; const RUNNER_COMMANDS = Object.keys(RUNNER_COMMAND_TRAITS) as Array; +test('runner surface policies match the cross-language fixture', () => { + const fixture = new URL( + '../../../../contracts/fixtures/runner-surface-policy.json', + import.meta.url, + ); + assert.deepEqual( + Object.fromEntries( + RUNNER_COMMANDS.map((command) => [command, runnerCommandSurfacePolicy({ command })]), + ), + JSON.parse(fs.readFileSync(fixture, 'utf8')), + ); +}); + test('runner command trait table pins lifecycle-sensitive command groups', () => { const groups = { preflightSkippableTouchMutation: commandsWithTraits(hotMutation()), diff --git a/packages/platform-apple/src/foldable/orientation.ts b/packages/platform-apple/src/foldable/orientation.ts index 16c0da9e23..a67802bab1 100644 --- a/packages/platform-apple/src/foldable/orientation.ts +++ b/packages/platform-apple/src/foldable/orientation.ts @@ -30,7 +30,7 @@ const ORIENTATION_UNVERIFIED_HINT = /** Resolved panel counts are hardware facts, so each simulator is probed once per daemon. */ const multiPanelByDevice = new Map(); -// A device whose panel count was never read keeps the unscoped route. A full-budget read that failed +// A device whose panel count was never read routes as single-panel. A full-budget read that failed // for it is not repeated for a few seconds, because every surface command would pay it again. const unansweredPanelTables = createTtlMemo({ ttlMs: 5_000 }); // Reads overlap, so a failure may finish after a read that started later and already resolved. @@ -69,11 +69,8 @@ export async function isAppleFoldableSimulator( export type FoldablePanelTableRead = | { kind: 'multi-panel'; inventory: AppleDisplayInventory } + /** A device the `fold` fact refuses, one with one panel, or one whose panel count is unknown. */ | { kind: 'single-panel' } - | { - kind: 'unavailable'; - reason: 'unsupported-device' | 'unknown-panel-count' | 'probe-backed-off'; - } /** A device known to have more than one panel whose table did not answer this time. */ | { kind: 'unreadable' }; @@ -85,14 +82,10 @@ export async function readFoldablePanelTable( device: DeviceInfo, options: { signal?: AbortSignal; timeoutMs: number }, ): Promise { - if (!appleFoldableFacts(device).setFoldPose.available) { - return { kind: 'unavailable', reason: 'unsupported-device' }; - } + if (!appleFoldableFacts(device).setFoldPose.available) return { kind: 'single-panel' }; const known = multiPanelByDevice.get(device.id); if (known === false) return { kind: 'single-panel' }; - if (known === undefined && unansweredPanelTables.get(device.id)) { - return { kind: 'unavailable', reason: 'probe-backed-off' }; - } + if (known === undefined && unansweredPanelTables.get(device.id)) return { kind: 'single-panel' }; const read = ++panelTableReads; const inventory = await queryAppleDisplayInventory(device, options); if (inventory.unresolved) { @@ -102,7 +95,7 @@ export async function readFoldablePanelTable( if (fullBudget && !options.signal?.aborted && !newerReadResolved) { unansweredPanelTables.set(device.id, true); } - return { kind: 'unavailable', reason: 'unknown-panel-count' }; + return { kind: 'single-panel' }; } recordPanelTable(device, inventory, read); return inventory.multiScreen ? { kind: 'multi-panel', inventory } : { kind: 'single-panel' }; diff --git a/packages/platform-apple/src/foldable/runner-panel.test.ts b/packages/platform-apple/src/foldable/runner-panel.test.ts index 63f8e9c52e..b1c76fc015 100644 --- a/packages/platform-apple/src/foldable/runner-panel.test.ts +++ b/packages/platform-apple/src/foldable/runner-panel.test.ts @@ -23,7 +23,7 @@ import { recordingRunnerProvider, type RecordedRunnerCall, } from '../__tests__/recording-runner-provider.ts'; -import { isAppleFoldableSimulator, readFoldablePanelTable } from './orientation.ts'; +import { isAppleFoldableSimulator } from './orientation.ts'; import { resolveRunnerRequestLitPanel } from './runner-panel.ts'; import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/device-fixtures.ts'; import { @@ -85,31 +85,10 @@ test('a device with one panel sends no panel and is never asked again', async () answering(IPHONE_17_SINGLE_PANEL); assert.equal(await resolveRunnerRequestLitPanel(device, {}), undefined); const reads = mockPanelTable.mock.calls.length; - assert.deepEqual(await readFoldablePanelTable(device, { timeoutMs: 5_000 }), { - kind: 'single-panel', - }); assert.equal(await resolveRunnerRequestLitPanel(device, {}), undefined); assert.equal(mockPanelTable.mock.calls.length, reads, 'one panel is hardware, probed once'); }); -test('unsupported and unknown panel hardware keep distinct omission reasons', async () => { - assert.deepEqual(await readFoldablePanelTable(IOS_DEVICE, { timeoutMs: 5_000 }), { - kind: 'unavailable', - reason: 'unsupported-device', - }); - const device = simulator(); - mockPanelTable.mockRejectedValue(new Error('devicectl does not report displays')); - assert.deepEqual(await readFoldablePanelTable(device, { timeoutMs: 5_000 }), { - kind: 'unavailable', - reason: 'unknown-panel-count', - }); - assert.deepEqual(await readFoldablePanelTable(device, { timeoutMs: 5_000 }), { - kind: 'unavailable', - reason: 'probe-backed-off', - }); - assert.equal(mockPanelTable.mock.calls.length, 1); -}); - test('a failed first panel read backs off temporarily and probes again after expiry', async () => { const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); try { From 67d90c81243b1b82601b77605137cb68f2909383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 23:25:39 +0100 Subject: [PATCH 5/5] docs(adr): keep the lit-panel refusal contract in ADR 0025 State each typed refusal once, linked to the owner or test that enforces it, while the command lists stay in the policy fixture. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0025-foldable-apple-panels.md | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/adr/0025-foldable-apple-panels.md b/docs/adr/0025-foldable-apple-panels.md index f7af00b6e0..2a07c7b059 100644 --- a/docs/adr/0025-foldable-apple-panels.md +++ b/docs/adr/0025-foldable-apple-panels.md @@ -726,20 +726,15 @@ Modal commands resolve their prompt through the modal owner instead of booking a Omitting `litPanel` preserves the historic route. This includes single-panel devices, unsupported devices, unknown or backed-off inventory, and multi-panel devices whose power is ambiguous or whose inventory is incomplete. -A known multi-panel device whose table does not answer still refuses before dispatch. -The [panel admission owner](../../packages/platform-apple/src/foldable/runner-panel.ts) and [its tests](../../packages/platform-apple/src/foldable/runner-panel.test.ts) own those cases and the request-budget rule. - -Anchor selection and element search answer different questions. -The anchor walk must be bounded, while search must include later windows such as the keyboard. -The [window resolver](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m), [request booking owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerRequestPanel.swift), and [booking tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerRequestPanelTests.swift) own selection, scope, and typed refusals. -Search refreshes lit windows because an interaction can create another window. -Request-scoped booking keeps delayed work on its own panel instead of the next command's panel. - -A snapshot must retain every window proven on the lit display instead of taking only the gesture anchor. -Only the tree backend proves display identity, so a backend pin must not bypass that proof. -The [snapshot preparation owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift), [snapshot owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift) and booking tests own filtering and geometry. -A capture may ask the system surface only when the app has no window; a resolved window's identity refusal must remain visible. -The [capture tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AppScreenCaptureTests.swift) own that distinction. +A known multi-panel device whose table does not answer refuses before dispatch with `COMMAND_FAILED`, `reason: panel-table-unreadable` and `runnerErrorCode: APP_SCREEN_UNRESOLVED`; a read that spends the request budget refuses with `reason: panel-probe-budget-exhausted` ([owner](../../packages/platform-apple/src/foldable/runner-panel.ts), [tests](../../packages/platform-apple/src/foldable/runner-panel.test.ts)). + +A request that names a lit panel books its window before dispatch and refuses before any gesture or read: `APP_SCREEN_OFF_LIT_PANEL` when every app window is on another display, `APP_SCREEN_WINDOW_UNRESOLVED` when the app has no window, and `APP_SCREEN_UNRESOLVED` when the window list cannot be read or placed ([resolver](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m), [resolver tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+XCTestEventBridgeTests.swift), [booking tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerRequestPanelTests.swift)). +The booking walk is bounded, while element search reads every window on the lit display and refuses with `APP_SCREEN_UNRESOLVED` instead of answering no match when it proves none ([owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerRequestPanel.swift), [booking tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerRequestPanelTests.swift)). +Booking is request-scoped, so delayed work stays on its own command's panel ([owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerRequestPanel.swift)). + +A lit-panel snapshot keeps only windows whose snapshot reports the lit display, drops a window that reports none, and refuses with `APP_SCREEN_UNRESOLVED` when no window is proven ([owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift), [booking tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerRequestPanelTests.swift)). +A pin to any backend other than the tree backend refuses with `UNSUPPORTED_OPERATION`, because only that backend proves display identity ([owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift), [booking tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerRequestPanelTests.swift)). +A capture asks the system surface only after `APP_SCREEN_WINDOW_UNRESOLVED`; an app whose windows are all on the dark panel refuses with `APP_SCREEN_OFF_LIT_PANEL` and is never sent to the system surface ([owner](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift), [capture tests](../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AppScreenCaptureTests.swift)). Rejected alternatives: