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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum CommandType: String, Codable, CaseIterable {
case recordStop
case status
case uptime
case appState
case activate
case terminate
case targetReset
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
9 changes: 9 additions & 0 deletions apple/runner/RUNNER_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions contracts/fixtures/runner-requests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<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": "<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": "<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": "<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": "<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": "<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": "<commandId>"}},
Expand Down
15 changes: 14 additions & 1 deletion packages/contracts/src/app-state-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<AppStateRuntimeResult>;
appState(input?: AppStateRuntimeInput): Promise<AppStateRuntimeResult>;
}>;
9 changes: 8 additions & 1 deletion packages/contracts/src/app-state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AppleApplicationState } from './app-state-runtime.ts';
import type { SessionSurface } from './session-surface.ts';

/**
Expand All @@ -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;
Comment on lines +21 to +26

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

surface: SessionSurface;
/** iOS only — the session device's UDID. */
device_udid?: string;
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -309,6 +310,11 @@ export type Interactor = {
*/
doubleTap?(x: number, y: number): Promise<Record<string, unknown> | void>;
longPress(x: number, y: number, durationMs?: number): Promise<Record<string, unknown> | 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<AppStateRuntimeResult>;
/**
* Move the pointer to a point without pressing. Only pointer-driven
* platforms (web today) implement it; touch platforms have no hover state
Expand Down
18 changes: 18 additions & 0 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const INTERACTOR_SITES: Record<string, InteractorDrive> = {
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)],
Expand Down
45 changes: 45 additions & 0 deletions packages/platform-apple/src/app-state-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
55 changes: 55 additions & 0 deletions packages/platform-apple/src/app-state-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<Interactor>;

/**
* 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<PlatformRuntimeHost, 'appleApplications'>,
params: Readonly<{
device: DeviceInfo;
signal: AbortSignal;
resolveInteractor: AppStateInteractorResolver;
}>,
): AppStateRuntimeOperations {
return Object.freeze({
appState: async (input?: AppStateRuntimeInput): Promise<AppStateRuntimeResult> => {
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();
},
});
}
17 changes: 17 additions & 0 deletions packages/platform-apple/src/interactions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -55,6 +57,7 @@ type IosRunnerOverrides = Pick<
| 'tapElementSelector'
| 'doubleTap'
| 'longPress'
| 'appState'
| 'focus'
| 'type'
| 'fill'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -312,6 +323,12 @@ function readTypeTextBackendResult(result: Record<string, unknown>): TypeTextBac
return isTextEntryRoute(route) ? { textEntryRoute: route } : {};
}

/** The runner's `appState` payload is untrusted JSON; only a declared state name passes. */
function readAppStateResult(result: Record<string, unknown>): 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);
}
Expand Down
Loading
Loading