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
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions packages/contracts/src/app-state-runtime.ts
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand All @@ -20,3 +19,6 @@ export type AppStateRuntimeResult = Readonly<{
export type AppStateRuntimeOperations = Readonly<{
appState(input?: AppStateRuntimeInput): Promise<AppStateRuntimeResult>;
}>;

/** Resolves the selected owner's interactor, as every other local interactor-backed read does. */
export type AppStateInteractorResolver = LocalInteractorOperationResolver;
2 changes: 1 addition & 1 deletion packages/contracts/src/app-state.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down
9 changes: 5 additions & 4 deletions packages/contracts/src/runtime-contract-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
35 changes: 20 additions & 15 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppleApplicationState, 'runningForeground'> =>
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 (
Expand Down
21 changes: 19 additions & 2 deletions packages/platform-apple/src/app-state-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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' },
});
});
17 changes: 3 additions & 14 deletions packages/platform-apple/src/app-state-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<Interactor>;

/**
* Binds the runner's read of the session app's state for the lifetime of a request binding, on the
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/contracts-exports.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/handlers/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading