From 49902b04d7a9144087335d1fc20cff7b34d37808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 08:42:53 +0200 Subject: [PATCH 1/2] 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. --- packages/contracts/src/app-state-runtime.ts | 6 ++-- packages/contracts/src/app-state.ts | 2 +- .../contracts/src/runtime-contract-error.ts | 9 ++--- packages/kernel/src/snapshot.ts | 35 +++++++++++-------- .../src/app-state-runtime.test.ts | 21 +++++++++-- .../platform-apple/src/app-state-runtime.ts | 17 ++------- src/daemon/handlers/session-state.ts | 2 +- 7 files changed, 53 insertions(+), 39 deletions(-) diff --git a/packages/contracts/src/app-state-runtime.ts b/packages/contracts/src/app-state-runtime.ts index 6b094ee6b1..0d74d91d18 100644 --- a/packages/contracts/src/app-state-runtime.ts +++ b/packages/contracts/src/app-state-runtime.ts @@ -1,6 +1,5 @@ import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; - -export type { AppleApplicationState } from '@agent-device/kernel/snapshot'; +import type { LocalInteractorOperationResolver } from './interactor-operation-binding.ts'; /** Which app a session-scoped read is about; the Android foreground read needs nothing. */ export type AppStateRuntimeInput = Readonly<{ appBundleId?: string }>; @@ -20,3 +19,6 @@ export type AppStateRuntimeResult = Readonly<{ export type AppStateRuntimeOperations = Readonly<{ appState(input?: AppStateRuntimeInput): Promise; }>; + +/** Resolves the selected owner's interactor, as every other local interactor-backed read does. */ +export type AppStateInteractorResolver = LocalInteractorOperationResolver; diff --git a/packages/contracts/src/app-state.ts b/packages/contracts/src/app-state.ts index 6d339fe46d..81d1aa366d 100644 --- a/packages/contracts/src/app-state.ts +++ b/packages/contracts/src/app-state.ts @@ -1,4 +1,4 @@ -import type { AppleApplicationState } from './app-state-runtime.ts'; +import type { AppleApplicationState } from '@agent-device/kernel/snapshot'; import type { SessionSurface } from './session-surface.ts'; /** diff --git a/packages/contracts/src/runtime-contract-error.ts b/packages/contracts/src/runtime-contract-error.ts index 905227bf4f..ce7ac1ebe0 100644 --- a/packages/contracts/src/runtime-contract-error.ts +++ b/packages/contracts/src/runtime-contract-error.ts @@ -8,10 +8,11 @@ import { AppError } from '@agent-device/kernel/errors'; * licenses a fallback, and the command then answers from stale data precisely because the * runtime lied about itself (ADR 0019 §2). * - * Deliberately its own module rather than an export of `platform-runtime.ts`: the façade - * re-exports that module and must stay exhaustive over it, which would make this a public - * symbol with no external consumer — the unused-export defect. Here it stays internal to - * `packages/contracts` and both runtime modules share one construction. + * Deliberately its own module rather than an export of `platform-runtime.ts`: that façade must stay + * exhaustive over its sources and keep its eager closure at budget, so it carries no leaf value + * symbol nobody in `contracts` consumes. This module stays a subpath of its own and imports nothing + * else from `contracts`, so a platform package whose facts advertised an operation its interactor + * cannot perform builds the same failure here instead of restating the code, reason, and hint. */ export function invalidRuntimeContract(message: string): AppError { return new AppError('COMMAND_FAILED', message, { diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index c0d2af3147..7ce4fba3ed 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -471,32 +471,37 @@ export function isIosTargetActivationReason(value: unknown): value is IosTargetA } /** - * States an activation could have been needed for, in `XCApplicationState` raw order: unknown 0, - * notRunning 1, suspended 2, plain background 3 — the SDK declares suspended on non-macOS platforms - * only. `runningForeground` is excluded because the runner skips `activate()` when the app is already - * foreground and never stamps a fact there. The decoder, which assigns raw values, is what keeps this - * order honest. + * How XCTest reports an app running (`XCUIApplication.State`), in the SDK's raw order: unknown 0, + * notRunning 1, suspended 2, plain background 3, foreground 4 — the SDK declares suspended on + * non-macOS platforms only. This is the one declaration of those names; the `appState` runner + * command answers the session app's state with them, and `RunnerTests+ApplicationStateRawValueTests` + * ties them to the SDK enum. The `appState` path names states, so nothing here assigns a raw value; + * only the activation decoder's raw table does. */ -export const IOS_TARGET_ACTIVATION_PRIOR_STATES = [ +export const APPLE_APPLICATION_STATES = [ 'unknown', 'notRunning', 'runningBackgroundSuspended', 'runningBackground', + 'runningForeground', ] as const; -export type IosTargetActivationPriorState = (typeof IOS_TARGET_ACTIVATION_PRIOR_STATES)[number]; +export type AppleApplicationState = (typeof APPLE_APPLICATION_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. + * States an activation could have been needed for: every Apple state except the foreground one, + * which the runner skips `activate()` in and therefore stamps no fact about. Derived from the full + * list so the two cannot drift, and in the SDK's raw order — a state added to the full list lands + * here and must then be pinned natively before the decoder tie accepts it. */ -export const APPLE_APPLICATION_STATES = [ - ...IOS_TARGET_ACTIVATION_PRIOR_STATES, - 'runningForeground', -] as const; +export const IOS_TARGET_ACTIVATION_PRIOR_STATES = Object.freeze( + APPLE_APPLICATION_STATES.filter( + (state): state is Exclude => + state !== 'runningForeground', + ), +); -export type AppleApplicationState = (typeof APPLE_APPLICATION_STATES)[number]; +export type IosTargetActivationPriorState = (typeof IOS_TARGET_ACTIVATION_PRIOR_STATES)[number]; export function isAppleApplicationState(value: unknown): value is AppleApplicationState { return ( diff --git a/packages/platform-apple/src/app-state-runtime.test.ts b/packages/platform-apple/src/app-state-runtime.test.ts index 754426e7fa..bfa83dcbf3 100644 --- a/packages/platform-apple/src/app-state-runtime.test.ts +++ b/packages/platform-apple/src/app-state-runtime.test.ts @@ -13,9 +13,10 @@ const device: DeviceInfo = { booted: true, }; -function bind(liveRunner: boolean) { +function bind(liveRunner: boolean, interactor?: Interactor) { const appState = vi.fn(async () => ({ applicationState: 'runningBackground' as const })); - const resolveInteractor = vi.fn(async () => ({ appState }) as unknown as Interactor); + const resolved: Interactor = interactor ?? ({ appState } as unknown as Interactor); + const resolveInteractor = vi.fn(async () => resolved); const hasLiveRunnerSession = vi.fn(async () => liveRunner); const operations = bindAppleAppStateRuntime( { appleApplications: { hasLiveRunnerSession } as never }, @@ -43,3 +44,19 @@ test('without a live runner session the read answers nothing and resolves no int expect(hasLiveRunnerSession).toHaveBeenCalledWith(device, {}); expect(resolveInteractor).not.toHaveBeenCalled(); }); + +/** + * The facts promised `appState` and the interactor has none. Answering from the session record + * instead would invent a state the runner never read, so this fails as the contract bug it is + * (ADR 0019 §2) rather than degrading, and the typed reason is what keeps it outside every + * closed reason set that licenses a fallback. + */ +test('an advertised appState with no interactor implementation fails as a contract bug', async () => { + // An interactor with NO appState — the mismatch the facts promised away. + const { operations } = bind(true, {} as unknown as Interactor); + await expect(operations.appState({ appBundleId: 'com.example.app' })).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: expect.stringContaining('advertised appState'), + details: { reason: 'runtime-contract-invalid' }, + }); +}); diff --git a/packages/platform-apple/src/app-state-runtime.ts b/packages/platform-apple/src/app-state-runtime.ts index 8250a0602a..97f14860a4 100644 --- a/packages/platform-apple/src/app-state-runtime.ts +++ b/packages/platform-apple/src/app-state-runtime.ts @@ -1,18 +1,12 @@ import type { + AppStateInteractorResolver, AppStateRuntimeInput, AppStateRuntimeOperations, AppStateRuntimeResult, } from '@agent-device/contracts/app-state-runtime'; -import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import { invalidRuntimeContract } from '@agent-device/contracts/runtime-contract-error'; 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 @@ -40,13 +34,8 @@ export function bindAppleAppStateRuntime( 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', + throw invalidRuntimeContract( '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/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index f74f7d7042..1b095e3bb5 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -16,7 +16,7 @@ import { 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 type { AppleApplicationState } from '@agent-device/kernel/snapshot'; import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation'; import { hasExplicitSessionFlag, From d76aeb2141469f4756c98016f551dc25311cb351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 08:42:56 +0200 Subject: [PATCH 2/2] chore(gates): publish the contracts runtime-contract-error subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- packages/contracts/package.json | 4 ++++ scripts/layering/contracts-exports.snapshot.json | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 47dbeded3d..9582f1ada8 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -352,6 +352,10 @@ "types": "./src/platform-runtime-unavailable.ts", "default": "./src/platform-runtime-unavailable.ts" }, + "./runtime-contract-error": { + "types": "./src/runtime-contract-error.ts", + "default": "./src/runtime-contract-error.ts" + }, "./runtime-operation-names": { "types": "./src/runtime-operation-names.ts", "default": "./src/runtime-operation-names.ts" diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 419ebc6e98..3095a7699e 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -95,6 +95,7 @@ "@agent-device/contracts/remote", "@agent-device/contracts/replay", "@agent-device/contracts/runner-lease-context", + "@agent-device/contracts/runtime-contract-error", "@agent-device/contracts/runtime-operation-names", "@agent-device/contracts/screen-capture-contract", "@agent-device/contracts/screen-recording-runtime",