From 197ace9969e696d967867b5f7f526bdb5bba57a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Thu, 24 Sep 2026 19:28:50 +0200 Subject: [PATCH] feat(ios): appstate reads the session app's XCUIApplication state through the runner iOS appstate answered from the session record alone: after home it still named the app as if in front, and the runtime declared the read unavailable because no Apple target answers a sessionless foreground probe. The session app's own state is a fact the runner has: a new appState runner command reads XCUIApplication(bundleIdentifier:).state and names it (runningForeground, runningBackground, runningBackgroundSuspended, notRunning, unknown). It is a lifecycle read, so the activation preflight is skipped and the state reported is the one the app is in. The Apple runtime admits the read on iOS-family simulators and devices, the handler adds state and source: runner to the session answer when the runner answers, and keeps source: session otherwise. The frontmost app is still not read. --- .../RunnerTests+CommandDispatch.swift | 34 ++++ .../RunnerTests+CommandExecution.swift | 2 +- .../RunnerTests+CommandJournal.swift | 2 +- .../RunnerTests+Models.swift | 6 +- ...rTests+ApplicationStateRawValueTests.swift | 11 ++ .../UnitTests/RunnerTests+ModelsTests.swift | 1 + apple/runner/RUNNER_PROTOCOL.md | 9 + contracts/fixtures/runner-requests.json | 1 + packages/contracts/src/app-state-runtime.ts | 15 +- packages/contracts/src/app-state.ts | 9 +- packages/contracts/src/interactor-types.ts | 6 + packages/kernel/src/snapshot.ts | 18 ++ .../interactor-runner-provider.test.ts | 1 + .../src/__tests__/runner-requests.test.ts | 1 + .../src/app-state-runtime.test.ts | 45 +++++ .../platform-apple/src/app-state-runtime.ts | 55 ++++++ packages/platform-apple/src/interactions.ts | 17 ++ .../runner/__tests__/runner-client.test.ts | 2 +- .../__tests__/runner-command-traits.test.ts | 1 + .../src/runner/runner-command-traits.ts | 1 + .../src/runner/runner-contract.ts | 3 + packages/platform-apple/src/runtime.test.ts | 28 ++- packages/platform-apple/src/runtime.ts | 28 ++- src/commands/system/output.ts | 17 +- .../__tests__/session-appstate-input.test.ts | 160 +++++++++++++++++- src/daemon/handlers/session-state.ts | 41 ++++- .../command-tools-appstate-schema.test.ts | 42 +++++ src/mcp/command-output-schemas.ts | 10 +- .../ios-simulator-e2e/live-full-scenarios.ts | 8 +- website/docs/docs/commands.md | 2 +- website/docs/docs/sessions.md | 7 +- 31 files changed, 554 insertions(+), 29 deletions(-) create mode 100644 packages/platform-apple/src/app-state-runtime.test.ts create mode 100644 packages/platform-apple/src/app-state-runtime.ts create mode 100644 src/mcp/__tests__/command-tools-appstate-schema.test.ts diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index 0e532a4514..eb49ab0996 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -48,6 +48,38 @@ extension RunnerTests { ) } + /// The session app's `XCUIApplication.State` by name. A lifecycle read: the activation preflight + /// is skipped, so `runningBackground` after `home` is reported rather than repaired away. + func executeAppState(command: Command) -> Response { + guard let bundleId = command.appBundleId?.trimmedNonEmpty else { + return Response( + ok: false, + error: ErrorPayload( + code: "INVALID_ARGS", + message: "appState requires appBundleId", + hint: "Set appBundleId to the session app's bundle identifier." + ) + ) + } + let state = XCUIApplication(bundleIdentifier: bundleId).state + return Response(ok: true, data: DataPayload(applicationState: Self.applicationStateName(state))) + } + + /// `XCUIApplication.State` by the names the TypeScript `AppleApplicationState` type declares, the + /// same names the activation disclosure gives its prior state. + static func applicationStateName(_ state: XCUIApplication.State) -> String { + switch state { + case .unknown: return "unknown" + case .notRunning: return "notRunning" + case .runningBackground: return "runningBackground" + case .runningForeground: return "runningForeground" +#if !os(macOS) + case .runningBackgroundSuspended: return "runningBackgroundSuspended" +#endif + @unknown default: return "unknown" + } + } + struct ActiveCommandContext { let app: XCUIApplication /// Set when `app` is a system surface served in place over the still-bound session app (#2438). @@ -386,6 +418,8 @@ extension RunnerTests { } case .uptime: return executeUptime() + case .appState: + return executeAppState(command: command) case .activate: guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 2735874292..b0a07980d3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -17,7 +17,7 @@ extension RunnerTests { } switch command.command { case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime, - .snapshot: + .appState, .snapshot: return Response( ok: false, error: ErrorPayload( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift index c91955116d..453d559958 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -139,7 +139,7 @@ final class RunnerCommandJournal { .remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .backInApp, .backSystem, .home, .rotate, .appSwitcher, .actionButton, .keyboardDismiss, .keyboardReturn, .alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop, - .status, .uptime, .activate, .terminate, .targetReset, .shutdown: + .status, .uptime, .appState, .activate, .terminate, .targetReset, .shutdown: return true } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 6c65b9986e..fb2a545c03 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -33,6 +33,7 @@ enum CommandType: String, Codable, CaseIterable { case recordStop case status case uptime + case appState case activate case terminate case targetReset @@ -221,7 +222,9 @@ extension Command { case .findText, .readText, .snapshot, .gestureViewport: return .appRead - case .screenshot, .status: + // appState reads the session app's XCUIApplication.state; bringing no app forward is what makes + // its answer the state the app is in, not the one a repair leaves. + case .screenshot, .status, .appState: return .runnerCaptureRead case .alert: @@ -391,6 +394,7 @@ struct DataPayload: Codable { var referenceWidth: Double? var referenceHeight: Double? var currentUptimeMs: Double? + var applicationState: String? var commandId: String? var lifecycleState: String? var lifecycleCommand: String? diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ApplicationStateRawValueTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ApplicationStateRawValueTests.swift index 84c8f2b472..07ff6b761a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ApplicationStateRawValueTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ApplicationStateRawValueTests.swift @@ -24,6 +24,17 @@ extension RunnerTests { assertDecoderPins(.runningForeground, name: "runningForeground", raw: 4) } + /// The `appState` command names each state for the TypeScript `AppleApplicationState` type. + func testApplicationStateNamesMatchTheAppStateContract() { + XCTAssertEqual(Self.applicationStateName(.unknown), "unknown") + XCTAssertEqual(Self.applicationStateName(.notRunning), "notRunning") +#if !os(macOS) + XCTAssertEqual(Self.applicationStateName(.runningBackgroundSuspended), "runningBackgroundSuspended") +#endif + XCTAssertEqual(Self.applicationStateName(.runningBackground), "runningBackground") + XCTAssertEqual(Self.applicationStateName(.runningForeground), "runningForeground") + } + private func assertDecoderPins(_ state: XCUIApplication.State, name: String, raw: Int) { XCTAssertEqual( Int(state.rawValue), diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index addd861206..69f1170c25 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -236,6 +236,7 @@ extension RunnerTests { (.recordStop, expectation(interaction: false, retry: false, launch: .noApp, converts: false)), (.status, expectation(interaction: false, retry: true, launch: .noApp, converts: false)), (.uptime, expectation(interaction: false, retry: false, launch: .noApp, converts: false)), + (.appState, expectation(interaction: false, retry: true, launch: .noApp, converts: false)), (.activate, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true)), (.terminate, expectation(interaction: false, retry: false, launch: .noApp, converts: false)), (.targetReset, expectation(interaction: false, retry: false, launch: .noApp, converts: false)), diff --git a/apple/runner/RUNNER_PROTOCOL.md b/apple/runner/RUNNER_PROTOCOL.md index 24a9d5d35b..04112d3f1e 100644 --- a/apple/runner/RUNNER_PROTOCOL.md +++ b/apple/runner/RUNNER_PROTOCOL.md @@ -63,6 +63,15 @@ than silently applied: { "command": "rotate", "orientation": "landscape-left" } ``` +```json +{ "command": "appState", "appBundleId": "com.example.app" } +``` + +`appState` answers `data.applicationState` with the named app's `XCUIApplication.State` by name +(`runningForeground`, `runningBackground`, `runningBackgroundSuspended`, `notRunning`, `unknown`). +It is a lifecycle read, so the activation preflight is skipped and the state reported is the one +the app is in, not the one a repair would leave. + The current command names and per-command traits are defined in: - `RunnerCommand` in [`../../packages/platform-apple/src/runner/runner-contract.ts`](../../packages/platform-apple/src/runner/runner-contract.ts) diff --git a/contracts/fixtures/runner-requests.json b/contracts/fixtures/runner-requests.json index 9a5ce46a00..730c8cb359 100644 --- a/contracts/fixtures/runner-requests.json +++ b/contracts/fixtures/runner-requests.json @@ -32,6 +32,7 @@ {"name": "ios-simulator.interactions-type.append", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "type", "text": "hello", "delayMs": 10, "textEntryMode": "append", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactions-type.newline", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "type", "text": "\n", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-action-button.press", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "actionButton", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-app-state.read", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "appState", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-app-switcher.open", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "appSwitcher", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-back.in-app", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "backInApp", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-back.system", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "backSystem", "appBundleId": "com.example.app", "commandId": ""}}, diff --git a/packages/contracts/src/app-state-runtime.ts b/packages/contracts/src/app-state-runtime.ts index a19dac5d32..6b094ee6b1 100644 --- a/packages/contracts/src/app-state-runtime.ts +++ b/packages/contracts/src/app-state-runtime.ts @@ -1,9 +1,22 @@ +import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; + +export type { AppleApplicationState } from '@agent-device/kernel/snapshot'; + +/** Which app a session-scoped read is about; the Android foreground read needs nothing. */ +export type AppStateRuntimeInput = Readonly<{ appBundleId?: string }>; + /** Neutral foreground identity returned by a selected platform/provider runtime. */ export type AppStateRuntimeResult = Readonly<{ package?: string; activity?: string; + /** + * Apple: how the app named by the input is running, as a live runner reads it. It says nothing + * about which app is frontmost; a session app in a background state has left the foreground. + * Absent when no runner session is live to ask, so the read never starts one. + */ + applicationState?: AppleApplicationState; }>; export type AppStateRuntimeOperations = Readonly<{ - appState(): Promise; + appState(input?: AppStateRuntimeInput): Promise; }>; diff --git a/packages/contracts/src/app-state.ts b/packages/contracts/src/app-state.ts index 26418ae456..6d339fe46d 100644 --- a/packages/contracts/src/app-state.ts +++ b/packages/contracts/src/app-state.ts @@ -1,3 +1,4 @@ +import type { AppleApplicationState } from './app-state-runtime.ts'; import type { SessionSurface } from './session-surface.ts'; /** @@ -16,7 +17,13 @@ export type AppStateCommandResult = platform: 'ios' | 'macos'; appName: string; appBundleId?: string; - source: 'session'; + /** `runner` when the runner read the session app's state; `session` when only the record answered. */ + source: 'session' | 'runner'; + /** + * How the session app is running, as the runner reads it; absent with `source: 'session'`. + * `runningBackground` after `home` says the app left the foreground, not what took it. + */ + state?: AppleApplicationState; surface: SessionSurface; /** iOS only — the session device's UDID. */ device_udid?: string; diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 15670d675b..dd5cb82363 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -1,3 +1,4 @@ +import type { AppStateRuntimeResult } from './app-state-runtime.ts'; import type { BackMode } from './back-mode.ts'; import type { IosSystemSurfaceProvenance } from './ios-system-surface.ts'; import type { DeviceRotation } from './device-rotation.ts'; @@ -309,6 +310,11 @@ export type Interactor = { */ doubleTap?(x: number, y: number): Promise | void>; longPress(x: number, y: number, durationMs?: number): Promise | void>; + /** + * How the app the runner context names is running, as the platform reports it. The Apple runner + * reads `XCUIApplication.state`; owners that read the foreground elsewhere leave it undefined. + */ + appState?(): Promise; /** * Move the pointer to a point without pressing. Only pointer-driven * platforms (web today) implement it; touch platforms have no hover state diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index e1367ff19b..c0d2af3147 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -486,6 +486,24 @@ export const IOS_TARGET_ACTIVATION_PRIOR_STATES = [ export type IosTargetActivationPriorState = (typeof IOS_TARGET_ACTIVATION_PRIOR_STATES)[number]; +/** + * How XCTest reports an app running (`XCUIApplication.State`): the prior states above plus the + * foreground state the activation disclosure never carries. The `appState` runner command names + * the session app's state with these words. + */ +export const APPLE_APPLICATION_STATES = [ + ...IOS_TARGET_ACTIVATION_PRIOR_STATES, + 'runningForeground', +] as const; + +export type AppleApplicationState = (typeof APPLE_APPLICATION_STATES)[number]; + +export function isAppleApplicationState(value: unknown): value is AppleApplicationState { + return ( + typeof value === 'string' && (APPLE_APPLICATION_STATES as readonly string[]).includes(value) + ); +} + /** * Foreground repair the Apple runner performed while serving one command (#2682). `priorState` is * the session app's state BEFORE the runner activated it, so the fact describes what was repaired diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index 80589e957c..eed7d1d34e 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -52,6 +52,7 @@ const RUNNER_TRANSPORT_METHODS: Record< }, doubleTap: { invoke: (i) => i.doubleTap!(10, 20), runnerCommand: 'sequence' }, longPress: { invoke: (i) => i.longPress(10, 20, 600), runnerCommand: 'longPress' }, + appState: { invoke: (i) => i.appState!(), runnerCommand: 'appState' }, focus: { invoke: (i) => i.focus(10, 20), runnerCommand: 'tap' }, type: { invoke: (i) => i.type('hi'), runnerCommand: 'type' }, fill: { invoke: (i) => i.fill(10, 20, 'hi'), runnerCommand: 'type' }, diff --git a/packages/platform-apple/src/__tests__/runner-requests.test.ts b/packages/platform-apple/src/__tests__/runner-requests.test.ts index f471daa08d..e053178cc9 100644 --- a/packages/platform-apple/src/__tests__/runner-requests.test.ts +++ b/packages/platform-apple/src/__tests__/runner-requests.test.ts @@ -148,6 +148,7 @@ const INTERACTOR_SITES: Record = { IOS_SIMULATOR, (i) => i.setOrientation('landscape-left'), ], + 'ios-simulator.interactor-app-state.read': [IOS_SIMULATOR, (i) => i.appState!()], 'ios-simulator.interactor-app-switcher.open': [IOS_SIMULATOR, (i) => i.appSwitcher!()], 'ios-simulator.interactor-action-button.press': [IOS_SIMULATOR, (i) => i.actionButton!()], 'tvos.interactor-tv-remote.hold': [TVOS_SIMULATOR, (i) => i.tvRemote!('select', 500)], diff --git a/packages/platform-apple/src/app-state-runtime.test.ts b/packages/platform-apple/src/app-state-runtime.test.ts new file mode 100644 index 0000000000..754426e7fa --- /dev/null +++ b/packages/platform-apple/src/app-state-runtime.test.ts @@ -0,0 +1,45 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { bindAppleAppStateRuntime } from './app-state-runtime.ts'; + +const device: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +function bind(liveRunner: boolean) { + const appState = vi.fn(async () => ({ applicationState: 'runningBackground' as const })); + const resolveInteractor = vi.fn(async () => ({ appState }) as unknown as Interactor); + const hasLiveRunnerSession = vi.fn(async () => liveRunner); + const operations = bindAppleAppStateRuntime( + { appleApplications: { hasLiveRunnerSession } as never }, + { device, signal: new AbortController().signal, resolveInteractor }, + ); + return { operations, appState, resolveInteractor, hasLiveRunnerSession }; +} + +test('a live runner session answers the session app state through the interactor', async () => { + const { operations, appState, resolveInteractor } = bind(true); + await expect(operations.appState({ appBundleId: 'com.example.app' })).resolves.toEqual({ + applicationState: 'runningBackground', + }); + expect(appState).toHaveBeenCalledTimes(1); + expect(resolveInteractor).toHaveBeenCalledWith( + device, + expect.objectContaining({ appBundleId: 'com.example.app' }), + ); +}); + +test('without a live runner session the read answers nothing and resolves no interactor', async () => { + // Resolving the interactor is what would start a runner; a session-state read never does. + const { operations, resolveInteractor, hasLiveRunnerSession } = bind(false); + await expect(operations.appState({ appBundleId: 'com.example.app' })).resolves.toEqual({}); + expect(hasLiveRunnerSession).toHaveBeenCalledWith(device, {}); + expect(resolveInteractor).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/app-state-runtime.ts b/packages/platform-apple/src/app-state-runtime.ts new file mode 100644 index 0000000000..8250a0602a --- /dev/null +++ b/packages/platform-apple/src/app-state-runtime.ts @@ -0,0 +1,55 @@ +import type { + AppStateRuntimeInput, + AppStateRuntimeOperations, + AppStateRuntimeResult, +} from '@agent-device/contracts/app-state-runtime'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +/** Resolves the selected owner's interactor, exactly as the element text runtime does. */ +export type AppStateInteractorResolver = ( + device: DeviceInfo, + runner: RunnerContext, +) => Promise; + +/** + * Binds the runner's read of the session app's state for the lifetime of a request binding, on the + * `Interactor` seam the point reads use. The read never starts a runner: with no live runner session + * (a bridge simulator right after `open`, an idle-stopped runner, a device whose runner is down) it + * answers nothing and the session record alone answers upstream. That is what keeps `appState` on + * the simulator host in the runner-demand table, since it demands no runner of its own. + */ +export function bindAppleAppStateRuntime( + host: Pick, + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: AppStateInteractorResolver; + }>, +): AppStateRuntimeOperations { + return Object.freeze({ + appState: async (input?: AppStateRuntimeInput): Promise => { + params.signal.throwIfAborted(); + if (!(await host.appleApplications.hasLiveRunnerSession(params.device, {}))) return {}; + const interactor = await params.resolveInteractor(params.device, { + appBundleId: input?.appBundleId, + signal: params.signal, + }); + if (typeof interactor.appState !== 'function') { + // Facts advertised the read but the interactor cannot perform it: a contract bug (ADR 0019 + // §2), not a refusal, so nothing upstream may answer from the session record instead. + throw new AppError( + 'COMMAND_FAILED', + 'Runtime owner advertised appState without an interactor implementation', + { + reason: 'runtime-contract-invalid', + hint: 'This is an agent-device runtime contract bug; report the selected device and command.', + }, + ); + } + return await interactor.appState(); + }, + }); +} diff --git a/packages/platform-apple/src/interactions.ts b/packages/platform-apple/src/interactions.ts index a770e19f3d..4cb595c02a 100644 --- a/packages/platform-apple/src/interactions.ts +++ b/packages/platform-apple/src/interactions.ts @@ -1,3 +1,4 @@ +import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { BackMode } from '@agent-device/contracts/back-mode'; import { singlePointerPlanEndpoints } from '@agent-device/contracts/gesture-plan'; import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types'; @@ -26,6 +27,7 @@ import { runnerSynthesizesTap, type DeviceInfo, } from '@agent-device/kernel/device'; +import { isAppleApplicationState } from '@agent-device/kernel/snapshot'; import { AppError } from '@agent-device/kernel/errors'; import { runAppleRunnerCommand, runApplePressSeries } from './core/runner-client.ts'; import { @@ -55,6 +57,7 @@ type IosRunnerOverrides = Pick< | 'tapElementSelector' | 'doubleTap' | 'longPress' + | 'appState' | 'focus' | 'type' | 'fill' @@ -122,6 +125,14 @@ export function iosRunnerOverrides( parseRunnerSequenceResult(runnerResult); return runnerResult; }, + appState: async () => + readAppStateResult( + await runAppleRunnerCommand( + device, + { command: 'appState', appBundleId: ctx.appBundleId }, + runnerOpts, + ), + ), longPress: async (x, y, durationMs) => { return await runAppleRunnerCommand( device, @@ -312,6 +323,12 @@ function readTypeTextBackendResult(result: Record): TypeTextBac return isTextEntryRoute(route) ? { textEntryRoute: route } : {}; } +/** The runner's `appState` payload is untrusted JSON; only a declared state name passes. */ +function readAppStateResult(result: Record): AppStateRuntimeResult { + const state = result.applicationState; + return isAppleApplicationState(state) ? { applicationState: state } : {}; +} + function isTextEntryRoute(value: unknown): value is TextEntryRoute { return typeof value === 'string' && (TEXT_ENTRY_ROUTES as readonly string[]).includes(value); } diff --git a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts index b954d5e73c..1e76d12d53 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts @@ -113,6 +113,7 @@ const runnerProtocolCommandFixtures: Record { diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts index 671592a694..68b7276ef8 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts @@ -36,6 +36,7 @@ test('runner command trait table pins lifecycle-sensitive command groups', () => 'tap', ], readOnly: [ + 'appState', 'findText', 'gestureViewport', 'querySelector', diff --git a/packages/platform-apple/src/runner/runner-command-traits.ts b/packages/platform-apple/src/runner/runner-command-traits.ts index 75307908c5..79ec993b28 100644 --- a/packages/platform-apple/src/runner/runner-command-traits.ts +++ b/packages/platform-apple/src/runner/runner-command-traits.ts @@ -82,6 +82,7 @@ export const RUNNER_COMMAND_TRAITS = { recordStop: DEFAULT_TRAITS, status: READ_ONLY_READINESS_PROBE_TRAITS, uptime: READ_ONLY_READINESS_PROBE_TRAITS, + appState: READ_ONLY_TRAITS, activate: READINESS_PREFLIGHT_EXEMPT_MUTATION_TRAITS, terminate: READINESS_PREFLIGHT_EXEMPT_MUTATION_TRAITS, targetReset: READINESS_PREFLIGHT_EXEMPT_MUTATION_TRAITS, diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index 3ffb929e19..86611eca91 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -76,6 +76,9 @@ export type RunnerCommand = { | 'recordStop' | 'status' | 'uptime' + // The session app's XCUIApplication.state by name. A lifecycle read: it skips the activation + // preflight, so it reports the state the app is in rather than the one a repair leaves. + | 'appState' | 'activate' | 'terminate' | 'targetReset' diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 233190dda6..fdee94259d 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -69,6 +69,27 @@ test('tvOS audio capture availability follows the exact host-owned runtime fact' ); }); +/** + * The runner reads the session app's state wherever it runs: every iOS-family leaf but the watchOS + * sentinel. macOS and watchOS keep the refusal that names the missing foreground probe. + */ +function expectAppStateFact( + device: DeviceInfo, + binding: Awaited['bind']>>, +): void { + if (device.appleOs === 'macos' || device.appleOs === 'watchos') { + expect(binding.facts.operations.appState).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: expect.stringContaining('no sessionless foreground probe'), + }); + expect(binding.operations.appState).toBeUndefined(); + return; + } + expect(binding.facts.operations.appState).toEqual({ available: true }); + expect(binding.operations.appState).toBeTypeOf('function'); +} + test.each([ ['iOS simulator', leaves.ios, true, undefined], [ @@ -100,12 +121,7 @@ test.each([ }); const { facts } = binding; expect(facts.device.providerMode).toBe('local'); - expect(facts.operations.appState).toEqual({ - available: false, - reason: 'unsupported-platform-leaf', - hint: expect.stringContaining('no sessionless foreground probe'), - }); - expect(binding.operations.appState).toBeUndefined(); + expectAppStateFact(device, binding); expect(facts.operations.networkDump).toEqual({ available: true }); expect(facts.operations.listApps.available).toBe( device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest', diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index c427038f3f..3907f76689 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -6,6 +6,8 @@ import { } from '@agent-device/contracts/platform-runtime'; import { bindSimulatorReadiness } from './runtime-simulator-readiness.ts'; import type { NetworkDumpInput } from '@agent-device/contracts/network-runtime'; +import type { AppStateRuntimeOperations } from '@agent-device/contracts/app-state-runtime'; +import { bindAppleAppStateRuntime } from './app-state-runtime.ts'; import type { PlatformRuntimeHost, PlatformRuntimeOperations, @@ -104,8 +106,19 @@ const focusKindUnavailable = Object.freeze({ const appStateUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', - hint: 'Apple appstate is unavailable: the Apple target answers no sessionless foreground probe, and a session-scoped guess about the foreground is not a fact. The per-command answer is the targetActivation disclosure, which a capture carries when its command had to re-activate the session app (#2682).', + hint: "Apple appstate is unavailable here: the runner reads the session app's XCUIApplication state on iOS-family simulators and physical devices only, and the Apple target answers no sessionless foreground probe. The per-command answer is the targetActivation disclosure, which a capture carries when its command had to re-activate the session app (#2682).", } as const); + +/** + * A live runner reads the session app's `XCUIApplication.state` on the kinds it drives; the read + * never starts one (see `bindAppleAppStateRuntime`). That is a fact about the session app, never a + * guess about the foreground (#2682): after `home` the app reports a background state, and which + * app took the screen stays nobody's to tell. + */ +function appleAppStateFact(device: DeviceInfo): RuntimeOperationFact { + if (!isIosFamily(device) || device.appleOs === 'watchos') return appStateUnavailable; + return device.kind === 'simulator' || device.kind === 'device' ? available : appStateUnavailable; +} const headlessUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -285,7 +298,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR operations: { ...logs.operations, ...deployment, - appState: appStateUnavailable, + appState: appleAppStateFact(device), networkDump: available, screenRecordingStart: recordingFacts, screenRecordingReattach: recordingFacts, @@ -334,7 +347,18 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR const logs = await appLogs.bind(request); const facts = await inspectFacts(request.device); const recordingFacts = facts.operations.screenRecordingStart; + // Typed on its own so the operations literal below stays within what tsc can represent. + const appStateOperations: Partial = whenAdmitted( + facts.operations.appState, + () => + bindAppleAppStateRuntime(host, { + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }), + ); const operations: DeviceBinding['operations'] = { + ...appStateOperations, ...logs.operations, ...createAppleAppDeploymentOperations({ host, diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts index 92804e4479..eedaef0403 100644 --- a/src/commands/system/output.ts +++ b/src/commands/system/output.ts @@ -55,12 +55,7 @@ export const systemCliOutputFormatters = withSettleCapableNotes({ } satisfies Record); function formatAppState(data: AppStateCommandResult): string | null { - if (data.platform === 'ios') { - const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; - if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); - if (data.source) lines.push(`Source: ${data.source}`); - return lines.join('\n'); - } + if (data.platform === 'ios') return formatAppleAppState(data); if (data.platform === 'android') { const lines = [`Foreground app: ${data.package ?? 'unknown'}`]; if (data.activity) lines.push(`Activity: ${data.activity}`); @@ -69,6 +64,16 @@ function formatAppState(data: AppStateCommandResult): string | null { return null; } +function formatAppleAppState( + data: Extract, +): string { + const lines = [`Session app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; + if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); + if (data.state) lines.push(`State: ${data.state}`); + if (data.source) lines.push(`Source: ${data.source}`); + return lines.join('\n'); +} + function androidKeyboardNextAction( visible: boolean | undefined, inputOwner: KeyboardCommandResult['inputOwner'], diff --git a/src/daemon/handlers/__tests__/session-appstate-input.test.ts b/src/daemon/handlers/__tests__/session-appstate-input.test.ts index 855694ffac..bf1a1327e6 100644 --- a/src/daemon/handlers/__tests__/session-appstate-input.test.ts +++ b/src/daemon/handlers/__tests__/session-appstate-input.test.ts @@ -7,9 +7,27 @@ import { makeSession, noopInvoke, } from './session-test-harness.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SessionState } from '../../session-state.ts'; -import { handleSessionCommands } from './session-command-harness.ts'; +import type { DaemonRequest } from '../../daemon-request.ts'; +import { handleSessionCommands, mockInspectDeviceRuntimeFacts } from './session-command-harness.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + AppStateRuntimeInput, + AppStateRuntimeResult, +} from '@agent-device/contracts/app-state-runtime'; +import { + appStateUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type RuntimeFacts, +} from '@agent-device/contracts/platform-runtime'; +import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); @@ -272,3 +290,143 @@ test('clipboard rejects unsupported iOS physical devices', async () => { expect(response.error.message).toMatch(/clipboard is not supported on this device/i); } }); + +const IOS_SESSION_DEVICE: SessionState['device'] = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +}; + +/** An Apple owner that admits the appstate read and answers it with `read`. */ +function appleAppStateRuntime( + read: (input: AppStateRuntimeInput | undefined) => Promise, +): { + inspectFacts: (device: DeviceInfo) => Promise>; + bindDevice: BindDeviceRuntime; +} { + const factsFor = async (device: DeviceInfo): Promise> => { + const base = await mockInspectDeviceRuntimeFacts(device); + return { ...base, operations: { ...base.operations, appState: { available: true } } }; + }; + return { + inspectFacts: factsFor, + bindDevice: async (selected, use) => { + const binding: DeviceBinding = { + device: selected, + owner: localRuntimeOwner('apple'), + facts: await factsFor(selected), + operations: { + ensureReady: async () => selected, + appState: read, + }, + [Symbol.asyncDispose]: async () => undefined, + }; + return narrowDeviceBinding(binding, use); + }, + }; +} + +function iosSessionRequest(sessionName: string): DaemonRequest { + return { + token: 't', + session: sessionName, + command: 'appstate', + positionals: [], + flags: { platform: 'ios', device: 'iPhone 17 Pro' }, + }; +} + +test('appstate on iOS reads the session app state from the runner when its owner admits it', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'sim'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, IOS_SESSION_DEVICE), + appBundleId: 'dev.e2e.benchmark', + appName: 'Benchmark', + }); + mockResolveTargetDevice.mockResolvedValue(IOS_SESSION_DEVICE); + const asked: Array = []; + const runtime = appleAppStateRuntime(async (input) => { + asked.push(input); + return { applicationState: 'runningBackground' }; + }); + + const response = await handleSessionCommands({ + req: iosSessionRequest(sessionName), + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ...runtime, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.appBundleId).toBe('dev.e2e.benchmark'); + expect(response.data?.state).toBe('runningBackground'); + expect(response.data?.source).toBe('runner'); + } + // The read is about the session app, and the use it went through is the appstate use. + expect(asked).toEqual([{ appBundleId: 'dev.e2e.benchmark' }]); + expect(appStateUse.required).toEqual(['ensureReady', 'appState']); +}); + +test('appstate on iOS keeps the session answer, with no state, when no runner is live to ask', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'sim'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, IOS_SESSION_DEVICE), + appBundleId: 'dev.e2e.benchmark', + appName: 'Benchmark', + }); + mockResolveTargetDevice.mockResolvedValue(IOS_SESSION_DEVICE); + const runtime = appleAppStateRuntime(async () => ({})); + + const response = await handleSessionCommands({ + req: iosSessionRequest(sessionName), + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ...runtime, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.source).toBe('session'); + expect(response.data).not.toHaveProperty('state'); + } +}); + +test('appstate on iOS keeps the session answer, with no state, when the runner cannot read one', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'sim'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, IOS_SESSION_DEVICE), + appBundleId: 'dev.e2e.benchmark', + appName: 'Benchmark', + }); + mockResolveTargetDevice.mockResolvedValue(IOS_SESSION_DEVICE); + const runtime = appleAppStateRuntime(async () => { + throw new AppError('COMMAND_FAILED', 'runner is busy'); + }); + + const response = await handleSessionCommands({ + req: iosSessionRequest(sessionName), + sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + ...runtime, + }); + + expect(response?.ok).toBe(true); + if (response && response.ok) { + expect(response.data?.appName).toBe('Benchmark'); + expect(response.data?.source).toBe('session'); + expect(response.data).not.toHaveProperty('state'); + } +}); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index a75834f0ac..f74f7d7042 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -1,4 +1,4 @@ -import { AppError, asAppError } from '@agent-device/kernel/errors'; +import { AppError, asAppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { TargetShutdownResult } from '@agent-device/contracts/device'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { @@ -15,6 +15,8 @@ import { } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { SessionStore } from '../session-store.ts'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { AppleApplicationState } from '@agent-device/contracts/app-state-runtime'; import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; import { hasExplicitSessionFlag, @@ -85,6 +87,39 @@ function hasAndroidAvdIdentity( ); } +/** + * The session app's state as a live runner reads it, when this device's owner admits the read; + * nothing otherwise, so the session record alone answers and no state is invented. The owner never + * starts a runner for it. A runner that cannot answer right now (busy, mid-restart) leaves the + * session answer as it was and says so in the log; a cancelled request stays cancelled. + */ +async function readAppleSessionAppState( + params: RuntimeCommandHandlerParams, + session: Readonly<{ device: DeviceInfo; appBundleId?: string }>, +): Promise { + if (!session.appBundleId) return undefined; + const admitted = await admitRuntimeUse({ + command: 'appstate', + device: session.device, + use: appStateUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + if (admitted.type === 'response') return undefined; + try { + const read = await admitted.runtime.operations.appState({ appBundleId: session.appBundleId }); + return read.applicationState; + } catch (error) { + if (isRequestCanceledError(error)) throw error; + emitDiagnostic({ + level: 'warn', + phase: 'apple_appstate_runner_read_failed', + data: { code: asAppError(error).code, message: asAppError(error).message }, + }); + return undefined; + } +} + async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promise { const { req, sessionName, sessionStore } = params; const session = sessionStore.get(sessionName); @@ -142,13 +177,15 @@ async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promi ); } + const state = await readAppleSessionAppState(params, session); return { ok: true, data: { platform: publicPlatformString(session.device), appName: appName ?? 'unknown', appBundleId: session.appBundleId, - source: 'session', + source: state ? 'runner' : 'session', + ...(state ? { state } : {}), surface: session.surface ?? 'app', ...(isIosFamily(session.device) ? { diff --git a/src/mcp/__tests__/command-tools-appstate-schema.test.ts b/src/mcp/__tests__/command-tools-appstate-schema.test.ts new file mode 100644 index 0000000000..4b520454c2 --- /dev/null +++ b/src/mcp/__tests__/command-tools-appstate-schema.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; +import { validateAgainstSchema } from './output-schema-validator.ts'; + +// `appstate` on iOS answers from the session record, and from a live runner when one can read the +// session app's XCUIApplication state; the schema must accept both answers and reject a state word +// the contract does not declare. +const IOS_SESSION_ANSWER: Readonly> = { + platform: 'ios', + appName: 'Benchmark', + appBundleId: 'dev.e2e.benchmark', + source: 'session', + surface: 'app', + device_udid: '279A81EC-B61A-4BE2-9F71-6A40FB8D2F9A', + ios_simulator_device_set: null, +}; + +test('MCP appstate schema accepts the runner-read iOS answer beside the session-only one', () => { + assert.deepEqual(validateAgainstSchema(IOS_SESSION_ANSWER, COMMAND_OUTPUT_SCHEMAS.appstate), []); + assert.deepEqual( + validateAgainstSchema( + { ...IOS_SESSION_ANSWER, source: 'runner', state: 'runningBackgroundSuspended' }, + COMMAND_OUTPUT_SCHEMAS.appstate, + ), + [], + ); + assert.notDeepEqual( + validateAgainstSchema( + { ...IOS_SESSION_ANSWER, source: 'runner', state: 'sleeping' }, + COMMAND_OUTPUT_SCHEMAS.appstate, + ), + [], + ); + assert.deepEqual( + validateAgainstSchema( + { platform: 'android', package: 'com.example.app', activity: '.MainActivity' }, + COMMAND_OUTPUT_SCHEMAS.appstate, + ), + [], + ); +}); diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 13839625f1..8909079f82 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -12,6 +12,7 @@ import { } from '../commands/command-input.ts'; import { REPLAY_COMMAND_OUTPUT_SCHEMAS } from '../commands/replay/index.ts'; import { BACK_MODES } from '@agent-device/contracts/back-mode'; +import { APPLE_APPLICATION_STATES } from '@agent-device/kernel/snapshot'; import { NATIVE_PATH_DISPOSITION_VALUES } from '@agent-device/contracts/recording-native-path'; import { RECORDER_OBSERVATION_VALUES } from '@agent-device/contracts/recording-stop-observation'; import { DEVICE_ROTATIONS, FOLD_POSES } from '@agent-device/contracts/device'; @@ -695,7 +696,14 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { platform: enumSchema(['ios', 'macos']), appName: stringSchema(), appBundleId: stringSchema(), - source: constSchema('session'), + source: enumSchema( + ['session', 'runner'], + 'runner when a live runner read the session app state; session when the record alone answered.', + ), + state: enumSchema( + APPLE_APPLICATION_STATES, + 'The session app XCUIApplication state as a live runner reads it; absent with source session.', + ), surface: enumSchema(SESSION_SURFACES), device_udid: stringSchema('iOS only — the session device UDID.'), ios_simulator_device_set: { diff --git a/test/integration/ios-simulator-e2e/live-full-scenarios.ts b/test/integration/ios-simulator-e2e/live-full-scenarios.ts index d8d978d7f6..62383d30b0 100644 --- a/test/integration/ios-simulator-e2e/live-full-scenarios.ts +++ b/test/integration/ios-simulator-e2e/live-full-scenarios.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { isAppleApplicationState } from '@agent-device/kernel/snapshot'; import { assertElementText, assertElementTextAfterScrolling, @@ -24,7 +25,12 @@ const C = PUBLIC_COMMANDS; export async function assertLifecycleAndSystem(context: LiveContext): Promise { const appState = await runStep(context, 'read fixture app state', ['appstate']); assert.equal(appState.json?.data?.appBundleId, context.appId, JSON.stringify(appState.json)); - assert.equal(appState.json?.data?.source, 'session', JSON.stringify(appState.json)); + // A live runner answers with the session app's state; without one the record alone answers. + const source = appState.json?.data?.source; + assert.ok(source === 'runner' || source === 'session', JSON.stringify(appState.json)); + if (source === 'runner') { + assert.ok(isAppleApplicationState(appState.json?.data?.state), JSON.stringify(appState.json)); + } assert.equal(appState.json?.data?.device_udid, context.udid, JSON.stringify(appState.json)); verifyCommand(context, C.appState, 'typed appstate retains active session and fixture identity'); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index d9eb54e337..a2c586c9bc 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -777,7 +777,7 @@ agent-device apps --platform android --all ``` - Android `appstate` reports live foreground package/activity. -- iOS `appstate` is unavailable: the Apple target answers no sessionless foreground probe, and a session-scoped guess about the foreground is not a fact. The per-command answer arrives as the [`targetActivation` disclosure](#foreground-repairs-on-ios), and the refusal's hint says so. +- iOS `appstate` needs a session and answers about that session's app: `appName` and `appBundleId` from the session record, and `state` from a live runner, which reads the app's `XCUIApplication` state (`runningForeground`, `runningBackground`, `runningBackgroundSuspended`, `notRunning`, `unknown`) with `source: runner`. After `home` the app reports a background state (`runningBackground` or `runningBackgroundSuspended`); which app took the screen is not read, since no Apple target answers a sessionless foreground probe. The read never starts a runner: when none is live, or the runner cannot answer, the record alone does, with `source: session` and no `state`. A command that had to bring the app back reports that as the [`targetActivation` disclosure](#foreground-repairs-on-ios). - `apps` shows user-installed apps by default. Use `--all` when you need the full inventory, including system/OEM apps. ## Foreground repairs on iOS diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index f65d275cbb..ede659ecc1 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -87,9 +87,10 @@ Notes: - `open ` in iOS sessions opens deep links. - `open ` in iOS sessions opens deep links. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs require an active app in the session. -- On iOS, `appstate` is unavailable: the Apple target answers no sessionless foreground probe. Whether - the session app actually held the foreground is answered per command instead, by the - [`targetActivation` disclosure](/docs/commands#foreground-repairs-on-ios). +- On iOS, `appstate` answers about the session app: its name from the session record and, when a + runner is live, its `XCUIApplication` state (`state`, `source: runner`). No Apple target answers a + sessionless foreground probe, so which app is in front is not read; whether the session app held + the foreground during a command is the [`targetActivation` disclosure](/docs/commands#foreground-repairs-on-ios). - For remote `connect --remote-config` sessions, see [Commands](/docs/commands#remote-metro-workflow). - Use `--session ` for intentional named-session sharing. Do not parallelize mutating commands against the same session; serialize stateful actions such as open, press, fill, type, scroll, back, alert, replay, batch, and close.