Skip to content

Commit 1600d40

Browse files
committed
feat(ios): route simulator snapshots through AX bridge
1 parent 5bb3ea3 commit 1600d40

19 files changed

Lines changed: 720 additions & 35 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ The same session and evidence model works at every step: the agent explores the
141141

142142
## How it works
143143

144-
`agent-device` keeps device state in sessions. It sends commands to XCTest on iOS and tvOS, ADB and the snapshot helper on Android, HDC and ArkUI `uitest` on HarmonyOS, Vega CLI/VDA on the Vega Virtual Device, a local helper on macOS, and AT-SPI on Linux.
144+
`agent-device` keeps device state in sessions. It uses a local accessibility bridge for iOS Simulator snapshots and XCTest for iOS interactions, physical iOS, and tvOS; ADB and the snapshot helper on Android; HDC and ArkUI `uitest` on HarmonyOS; Vega CLI/VDA on the Vega Virtual Device; a local helper on macOS; and AT-SPI on Linux.
145145

146146
Support depth varies by target. Newer backends such as HarmonyOS and Vega OS cover a subset of commands; run `agent-device capabilities --platform <platform>` to see what a target supports.
147147

docs/adr/0004-ios-snapshot-backend-strategy.md

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22

33
## Status
44

5-
Accepted. Amended after iOS snapshot capture was simplified to two public modes:
6-
regular interactive snapshots and raw diagnostic snapshots.
5+
Accepted. Amended after local iOS Simulator acquisition moved to the host AX bridge while the
6+
public surface remained two modes: regular interactive snapshots and raw diagnostic snapshots.
77

8-
The runner owns capture-plan acquisition and backend fallback. Host-side iOS validation, semantic
9-
presentation, and publication are owned by `@agent-device/capture-kit`; structured snapshot quality
10-
verdicts make degraded or recovered output observable end to end.
8+
The Apple platform runtime owns acquisition routing and its generation-scoped XCTest fallback.
9+
Host-side iOS validation, semantic presentation, and publication are owned by
10+
`@agent-device/capture-kit`; structured snapshot quality verdicts and fallback warnings make
11+
degraded or recovered output observable end to end.
1112

1213
## Context
1314

14-
Agent Device exposes iOS UI state through snapshots produced by the long-lived XCTest runner. The
15-
runner has two durable snapshot needs:
15+
Agent Device exposes iOS UI state through host AX acquisition on local Simulators and the long-lived
16+
XCTest runner everywhere else. The snapshot surface has two durable needs:
1617

1718
- agent-facing regular context, where the important contract is the effective user-visible UI,
1819
fixed controls such as tab bars, and scroll-hidden hints for content outside visible scroll
@@ -35,8 +36,13 @@ predictable.
3536

3637
## Decision
3738

38-
Keep XCTest as the default iOS automation runner and split iOS snapshot capture into explicit
39-
strategies:
39+
Keep XCTest as the iOS automation runner. Route eligible local iOS Simulator snapshots through the
40+
host AX bridge, present them once through the shared TypeScript engine, and use one typed XCTest
41+
fallback when bridge acquisition or presentation fails. Disable the bridge for that app generation
42+
after fallback; a new app generation re-enables it. Physical devices, providers, custom-action
43+
captures, and interactions remain on their existing owners.
44+
45+
Keep the two public snapshot strategies explicit:
4046

4147
- **Regular visible strategy**: use recursive XCTest snapshots, emit the effective user-visible
4248
tree plus visible ancestors and scroll-hidden hints, and fall back through the capture plan when
@@ -51,12 +57,10 @@ strategies:
5157
carry the response, fail explicitly instead of silently truncating the tree at a hard node count.
5258
If XCTest reports a real AX serialization failure, preserve that error instead of pretending the
5359
UI is empty.
54-
- **Future AX-service strategy**: treat Bluesky-class failures as evidence that XCTest is
55-
not a complete semantic snapshot backend. A robust semantic fix should add a host-side simulator
56-
accessibility backend, similar in role to existing simulator accessibility inspection tools,
57-
and acquire its output as `RawAXNode` values. Every backend crosses the same
58-
`SnapshotPresentation` construction boundary before producing wire-facing `PresentedNode` values.
59-
That backend can be simulator-only; physical devices should use an equivalent non-XCTest semantic
60+
- **Host AX strategy**: acquire local Simulator trees as raw facts through the bounded host bridge.
61+
Every result crosses the same presentation boundary before publication. XCTest fallback carries
62+
explicit source residue, and comparisons require matching producer, intent, app generation,
63+
presentation key, and residue. Physical devices should use an equivalent non-XCTest semantic
6064
backend only if Apple exposes a supported channel.
6165

6266
The daemon should make degraded output observable. If an iOS interactive snapshot contains only the

packages/capture-kit/src/ios-snapshot-planning.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ export function areIosSnapshotComparisonIdentitiesEqual(
9090
);
9191
}
9292

93+
export function iosSnapshotComparisonIdentityKey(identity: IosSnapshotComparisonIdentity): string {
94+
return JSON.stringify({
95+
producer: identity.producer,
96+
intent: identity.intent,
97+
lineage: identity.lineage,
98+
presentationKey: identity.presentationKey,
99+
residue: identity.residue.map(residueIdentity).sort(),
100+
});
101+
}
102+
93103
export function buildIosSnapshotComparisonIdentity(
94104
input: IosSnapshotInput,
95105
request: IosSnapshotRequest,

packages/contracts/src/interactor-types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import type { SessionSurface } from './session-surface.ts';
99
import type { BackendSnapshotResult } from './snapshot-types.ts';
1010
import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';
1111
import type {
12-
IosProviderAcquisitionProducer,
12+
IosAcquisitionProducer,
1313
IosSnapshotAcquisitionFacts,
14+
IosSnapshotComparisonIdentity,
1415
} from './ios-snapshot.ts';
1516
import type {
1617
RawSnapshotNode,
@@ -182,6 +183,8 @@ export type SnapshotOptions = BaseSnapshotOptions & {
182183
includeRects?: boolean;
183184
includeHiddenContentHints?: boolean;
184185
surface?: SessionSurface;
186+
/** Internal capture purpose; action outcomes always require the full tree. */
187+
acquisitionIntent?: 'full' | 'surface-observation';
185188
};
186189

187190
/**
@@ -251,11 +254,12 @@ export type KeyboardEnterResult =
251254
*/
252255
export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> & {
253256
nodes?: RawSnapshotNode[];
257+
comparisonIdentity?: IosSnapshotComparisonIdentity;
254258
} & SnapshotProvenance;
255259

256260
export type SnapshotRuntimeAcquiredResult = Readonly<{
257261
stage: 'acquired';
258-
acquisition: IosSnapshotAcquisitionFacts & Readonly<{ producer: IosProviderAcquisitionProducer }>;
262+
acquisition: IosSnapshotAcquisitionFacts & Readonly<{ producer: IosAcquisitionProducer }>;
259263
}>;
260264

261265
export type SnapshotRuntimeResult = SnapshotResult | SnapshotRuntimeAcquiredResult;

packages/kernel/src/snapshot.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ export type SnapshotNode = RawSnapshotNode & {
162162
* snapshot-provenance.test.ts).
163163
*/
164164
export type SnapshotProvenance =
165-
| { backend: 'xctest'; producer: 'apple-runner' | 'appium-source' | 'limrun-ios-tree' }
165+
| {
166+
backend: 'xctest';
167+
producer: 'apple-runner' | 'simulator-ax-bridge' | 'appium-source' | 'limrun-ios-tree';
168+
}
166169
| { backend: 'android'; producer: 'android-uiautomator' | 'appium-source' }
167170
| { backend: 'harmonyos-arkui'; producer: 'harmonyos-uitest' }
168171
| { backend: 'macos-helper'; producer: 'macos-helper' }
@@ -238,6 +241,8 @@ export type SnapshotState = {
238241
snapshotQuality?: SnapshotQualityVerdict;
239242
comparisonSafe?: boolean;
240243
presentationKey?: string;
244+
/** Opaque equality key for iOS acquisition and presentation lineage. */
245+
comparisonKey?: string;
241246
/**
242247
* Android: the capture is an occluding system surface (notification shade, quick settings)
243248
* rather than app content. Consumers that surface this tree to the agent must disclose the

packages/platform-apple/src/runtime-snapshot.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import type {
1414
PlatformRuntimeOperations,
1515
} from '@agent-device/contracts/platform-runtime-operations';
1616
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
17+
import type { AppleSnapshotRoute } from './snapshot-route.ts';
1718

1819
/** Apple-owned selection between app snapshots and explicit macOS surface snapshots. */
1920
export function bindAppleSnapshotRuntime(
2021
host: PlatformRuntimeHost,
2122
request: Readonly<{ device: DeviceInfo; signal: AbortSignal }>,
23+
route?: AppleSnapshotRoute,
2224
): SnapshotRuntimeOperation {
2325
const appSnapshot = bindLocalSnapshotInteractor({
2426
device: request.device,
@@ -37,7 +39,14 @@ export function bindAppleSnapshotRuntime(
3739
captureSnapshotSignal(request.signal, input),
3840
);
3941
}
40-
return await appSnapshot.captureSnapshot(input);
42+
if (!route) return await appSnapshot.captureSnapshot(input);
43+
const signal = captureSnapshotSignal(request.signal, input);
44+
return await route.capture(
45+
request.device,
46+
input,
47+
signal,
48+
async (fallbackInput) => await appSnapshot.captureSnapshot(fallbackInput),
49+
);
4150
};
4251
return Object.freeze({
4352
captureSnapshot,

packages/platform-apple/src/runtime.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
bindAppleFindTextRuntime,
7171
bindAppleSnapshotRuntime,
7272
} from './runtime-snapshot.ts';
73+
import { createAppleSnapshotRoute } from './snapshot-route.ts';
7374

7475
const owner = localRuntimeOwner('apple');
7576
const available = Object.freeze({ available: true } as const);
@@ -268,6 +269,7 @@ function appleFocusFact(device: DeviceInfo): RuntimeOperationFact {
268269

269270
export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner {
270271
const appLogs = createAppleAppLogRuntime(host);
272+
const snapshotRoute = createAppleSnapshotRoute(host);
271273
const inspectFacts = async (device: DeviceInfo) => {
272274
const logs = await appLogs.inspectFacts(device);
273275
const deployment = appleAppDeploymentFacts(device);
@@ -375,10 +377,14 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
375377
}),
376378
),
377379
...whenAdmitted(facts.operations.captureSnapshot, () =>
378-
bindAppleSnapshotRuntime(host, {
379-
device: request.device,
380-
signal: request.scope.signal,
381-
}),
380+
bindAppleSnapshotRuntime(
381+
host,
382+
{
383+
device: request.device,
384+
signal: request.scope.signal,
385+
},
386+
snapshotRoute,
387+
),
382388
),
383389
...whenAdmitted(facts.operations.captureScreenshot, () =>
384390
bindLocalScreenshotInteractor({
@@ -490,7 +496,9 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
490496
[Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](),
491497
}) satisfies DeviceBinding<PlatformRuntimeOperations>;
492498
},
493-
shutdown: async () => await appLogs.shutdown(),
499+
shutdown: async () => {
500+
await Promise.all([appLogs.shutdown(), snapshotRoute.shutdown()]);
501+
},
494502
});
495503
}
496504

0 commit comments

Comments
 (0)