feat(ios): appstate reads the session app's XCUIApplication state through the runner - #2929
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved integration, schema, runner-demand/readiness, cancellation, and lifecycle-test issues remain.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (5)
Update iOS E2E expectations for runner-backed appstate · New Update MCP schema for runner-backed appstate responses · New Use a session-app label for background runner results · New Propagate appstate request cancellation instead of falling back · New Document either background state after home · New
What changed in this PR
Adds runner-backed iOS session app-state reporting with session fallback behavior.
Changes:
- Adds the Apple
appStaterunner command and lifecycle state mapping. - Extends contracts, daemon responses, CLI output, and runtime/interactor support.
- Updates runner protocol, tests, and documentation.
| File | Summary |
|---|---|
website/docs/docs/commands.md |
Documents iOS app-state behavior. |
src/daemon/handlers/session-state.ts |
Reads runner state with fallback handling. |
src/daemon/handlers/__tests__/session-appstate-input.test.ts |
Tests runner and fallback responses. |
src/commands/system/output.ts |
Formats state and source fields. |
packages/platform-apple/src/runtime.ts |
Adds Apple app-state runtime support. |
packages/platform-apple/src/runtime.test.ts |
Tests runtime capability facts. |
packages/platform-apple/src/runner/runner-contract.ts |
Adds the runner command. |
packages/platform-apple/src/runner/runner-command-traits.ts |
Defines command traits. |
packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts |
Tests command classification. |
packages/platform-apple/src/runner/__tests__/runner-client.test.ts |
Tests protocol fixtures. |
packages/platform-apple/src/interactions.ts |
Routes state reads through the runner. |
packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts |
Tests runner transport routing. |
packages/contracts/src/interactor-types.ts |
Extends the interactor contract. |
packages/contracts/src/app-state.ts |
Extends the public app-state contract. |
packages/contracts/src/app-state-runtime.ts |
Defines runtime state types and binding. |
apple/runner/RUNNER_PROTOCOL.md |
Documents the runner command. |
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ApplicationStateRawValueTests.swift |
Tests native state names. |
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift |
Adds runner command models. |
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift |
Journals the new command. |
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift |
Handles command execution behavior. |
apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift |
Dispatches and maps app state. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| source: state ? 'runner' : 'session', | ||
| ...(state ? { state } : {}), |
There was a problem hiding this comment.
[claude-fable-5-1] responding on behalf of Oskar
Fixed in d8c243f: the scenario accepts runner (asserting state is a declared AppleApplicationState) or session, since the runner is warm in that tier but the read is allowed to answer from the record.
| 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; |
There was a problem hiding this comment.
[claude-fable-5-1] responding on behalf of Oskar
Done in d8c243f: source is an enum of session and runner, state is the APPLE_APPLICATION_STATES enum, and src/mcp/__tests__/command-tools-appstate-schema.test.ts validates both iOS answers and the Android one and rejects an undeclared state.
| function formatAppleAppState( | ||
| data: Extract<AppStateCommandResult, { platform: 'ios' | 'macos' }>, | ||
| ): string { | ||
| const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; |
There was a problem hiding this comment.
[claude-fable-5-1] responding on behalf of Oskar
Agreed; the Apple formatter now labels the line Session app: in d8c243f. Android keeps Foreground app:, which is what it reads.
| } catch (error) { | ||
| emitDiagnostic({ | ||
| level: 'warn', | ||
| phase: 'apple_appstate_runner_read_failed', | ||
| data: { code: asAppError(error).code, message: asAppError(error).message }, |
There was a problem hiding this comment.
[claude-fable-5-1] responding on behalf of Oskar
Fixed in d8c243f: the catch rethrows when isRequestCanceledError says so and only a genuine runner-read failure falls back to the session answer.
|
|
||
| - 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 the runner, which reads the app's `XCUIApplication` state (`runningForeground`, `runningBackground`, `runningBackgroundSuspended`, `notRunning`, `unknown`) with `source: runner`. After `home` the app reports `runningBackground`; which app took the screen is not read, since no Apple target answers a sessionless foreground probe. When 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). |
There was a problem hiding this comment.
[claude-fable-5-1] responding on behalf of Oskar
Reworded in d8c243f: after home the docs promise a background state, runningBackground or runningBackgroundSuspended, which is what the validation run showed.
|
This makes iOS The Not blocking: the new binder pulls a runtime import into what was a type-only contracts module and trips the eager-closure-budgets check (its only consumer is platform-apple, so it could move there next to Could the whole feature live with less surface: keep contracts type-only, put the binder in platform-apple next to I haven't run the no-warm-runner route on a local simulator: stopping the runner (or letting it idle-stop), running |
ab43384 to
d8c243f
Compare
|
[claude-fable-5-1] responding on behalf of Oskar Reworked in d8c243f along the lines you sketched, with less surface:
On your question about a cold start moving the app out of the foreground: moot now, since the read never starts one. Gate at d8c243f: 904 test files, 7,183 tests, both Coverage tripwires green locally. |
d8c243f to
addf05f
Compare
|
[claude-fable-5-1] responding on behalf of Oskar Rebased onto 6428c54 (clean) and pinned the new command in the golden table #2900 added: |
|
This is ready for human review at addf05f. The runner cold-start and MCP schema issues from the earlier review (ab43384, #2929 (comment)) are both fixed, and all 15 checks pass, including the runner-requests golden-table and the eager-closure/test-size checks that failed before. I did not reproduce the author's warm-runner transcript on a simulator, and no live run shows the no-runner route (runner idle-stopped or bridge simulator right after open, then Not blocking: whether |
…ough 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.
addf05f to
197ace9
Compare
… contract failure (#2950) * refactor(apple): put the Apple app states at one source and reuse the contract failure #2929 added `appState` beside the #2682 activation disclosure, and the two state lists ended up pointing the wrong way: the full `XCUIApplication.State` list was spread from the smaller prior-state subset, so the narrower concept owned the names the broader one answers with. The full list is now the single declaration and the prior states derive from it by dropping the one state the runner skips `activate()` in. The Apple binder also restated two things contracts already owns: a resolver type that is the local interactor resolver under another name, and the advertised-without-an-implementation failure the `invalidRuntimeContract` leaf already builds. Both are reused now, and the failure branch finally has a test pinning its typed reason, which is what keeps it outside every closed reason set that licenses answering from the session record. `AppleApplicationState` no longer re-exports through `contracts/app-state-runtime`: the kernel is its declaration site and both consumers read it there. * chore(gates): publish the contracts runtime-contract-error subpath `invalidRuntimeContract` had no export target: it was a leaf kept out of the `platform-runtime` façade so that façade stays exhaustive over its sources and inside its eager-closure budget. Now that a platform package consumes it, the subpath is its declaration site in the manifest, and the independently committed contracts export snapshot carries it with the one-line regeneration.



Summary
iOS
appstateanswered from the session record alone, so afterhomeit still named the app as if in front. The session app's own state is a fact a live runner has.A new
appStaterunner command readsXCUIApplication(bundleIdentifier:).stateand names it (runningForeground,runningBackground,runningBackgroundSuspended,notRunning,unknown), declared once in the kernel asAPPLE_APPLICATION_STATES. A lifecycle read likestatus, it skips the activation preflight.The Apple runtime binds the read (
bindAppleAppStateRuntime) and never starts a runner for it: with no live runner session it answers nothing and the handler keepssource: sessionwith nostate, soappStatestays on the simulator host in the runner-demand table. With a live runner the handler addsstateandsource: runner. A cancelled request stays cancelled. The MCP output schema carries bothsourcevalues and thestateenum. The CLI's Apple lines readSession app:. Which app took the screen is still not read.Validation
Tested at the pushed head, rebased onto 6428c54.
pnpm check:xctest-selection,pnpm check:packaged-runner-swift, andpnpm check:affected --run --base 6428c5485passed (916 test files, 7,280 tests). TheappStaterequest is pinned inrunner-requests.json; its XCTests pass on the booted simulator.Live on an iPhone 17 Pro simulator, runner warm from
open:Handler tests cover the runner answer, no live runner, and a runner that throws; a binder test proves no interactor is resolved without a live session.
Risk:
sourceon the iOS result gains the valuerunner.