Skip to content

Commit 10bc4f7

Browse files
thymikeeclaude
andcommitted
refactor(ios): drop the observe-only capability probe and runner pinning
An observe-only snapshot now sends the `observeOnly` field on the snapshot request alone. The runner enforces the no-activation contract and returns `observation`; the host asserts that shape on the response. The separate `uptime` capability probe, `supportsObserveOnlySnapshot`, the ready-runner-only rule, runner session pinning and the readiness-preflight exemption existed to survive a CLI and runner that disagree about the field, which the repo ships together and already guards through the protocol fixture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 55213fd commit 10bc4f7

13 files changed

Lines changed: 34 additions & 246 deletions

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,9 @@ extension RunnerTests {
4242
func executeUptime() -> Response {
4343
// Placeholder value: the transport layer (jsonResponse) overwrites currentUptimeMs with a
4444
// fresher send-time stamp on every ok response; kept so direct callers still get a value.
45-
#if os(iOS)
46-
let supportsObserveOnlySnapshot = true
47-
#else
48-
let supportsObserveOnlySnapshot = false
49-
#endif
50-
return Response(
45+
Response(
5146
ok: true,
52-
data: DataPayload(currentUptimeMs: currentUptimeMs(), supportsObserveOnlySnapshot: supportsObserveOnlySnapshot)
47+
data: DataPayload(currentUptimeMs: currentUptimeMs())
5348
)
5449
}
5550

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,6 @@ struct DataPayload: Codable {
448448
var referenceWidth: Double?
449449
var referenceHeight: Double?
450450
var currentUptimeMs: Double?
451-
var supportsObserveOnlySnapshot: Bool?
452451
var applicationState: String?
453452
var commandId: String?
454453
var lifecycleState: String?

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,6 @@ import AgentDeviceSnapshotPresentation
33

44
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
55
extension RunnerTests {
6-
func testUptimeAdvertisesObserveOnlyOnIosOnly() {
7-
#if os(iOS)
8-
XCTAssertEqual(executeUptime().data?.supportsObserveOnlySnapshot, true)
9-
#else
10-
XCTAssertEqual(executeUptime().data?.supportsObserveOnlySnapshot, false)
11-
#endif
12-
}
13-
146
func testInjectedTapRecordedFailureGateIsTapOnlyAndCountGated() {
157
// The seam's recording side cannot run in-bundle (a real XCTIssue would
168
// fail this very test run — same constraint the record(_:) suppression

‎contracts/fixtures/runner-requests.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
{"name": "ios-simulator.interactor-set-orientation.rotate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "rotate", "orientation": "landscape-left", "appBundleId": "com.example.app", "commandId": "<commandId>"}},
4545
{"name": "ios-simulator.interactor-snapshot.every-option", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "preferredBackend": "tree", "customActions": true, "depth": 3, "scope": "Go", "raw": true, "commandId": "<commandId>"}},
4646
{"name": "ios-simulator.interactor-snapshot.observe-only", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "observeOnly": true, "commandId": "<commandId>"}},
47-
{"name": "ios-simulator.interactor-snapshot.observe-only.capability", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "uptime", "observeOnly": true, "commandId": "<commandId>"}},
4847
{"name": "ios-simulator.interactor-write-clipboard.text", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "pasteboardWrite", "text": "one-time code 246810", "commandId": "<commandId>"}},
4948
{"name": "ios-simulator.recording-clock-anchor.snapshot", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "depth": 1}},
5049
{"name": "ios-simulator.recording-start.default", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStart", "outPath": "capture.mp4", "appBundleId": "com.example.app"}},

‎packages/platform-apple/src/__tests__/interactor-target-activation.test.ts‎

Lines changed: 7 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,7 @@ const HEALTHY_TREE = {
2929
function interactorServing(payload: Record<string, unknown>) {
3030
const runnerProvider: AppleRunnerProvider = {
3131
hasLiveSession: () => true,
32-
runCommand: async () => ({
33-
...payload,
34-
supportsObserveOnlySnapshot: true,
35-
runnerSessionId: 'observation-runner',
36-
}),
32+
runCommand: async () => payload,
3733
};
3834
return createAppleInteractor(IOS_SIMULATOR, { appBundleId: 'com.example.app' }, runnerProvider);
3935
}
@@ -70,77 +66,24 @@ test.each([
7066
const commands: RunnerCommand[] = [];
7167
const runnerProvider: AppleRunnerProvider = {
7268
hasLiveSession: () => true,
73-
runCommand: async (_device, command, options) => {
69+
runCommand: async (_device, command) => {
7470
commands.push(command);
75-
if (command.command === 'snapshot') {
76-
assert.equal(options?.expectedRunnerSessionId, 'observation-runner');
77-
}
78-
return {
79-
...HEALTHY_TREE,
80-
observation,
81-
supportsObserveOnlySnapshot: true,
82-
runnerSessionId: 'observation-runner',
83-
};
71+
return { ...HEALTHY_TREE, observation };
8472
},
8573
};
8674
const snapshot = (await createAppleInteractor(device, {}, runnerProvider).snapshot({
8775
appBundleId: 'com.example.app',
8876
observeOnly: true,
8977
})) as SnapshotResult;
90-
const runnerCommand = (name: string) => commands.find((command) => command.command === name);
91-
assert.equal(runnerCommand('uptime')?.observeOnly, true);
92-
assert.equal(runnerCommand('snapshot')?.observeOnly, true);
78+
assert.deepEqual(
79+
commands.map((command) => [command.command, command.observeOnly]),
80+
[['snapshot', true]],
81+
);
9382
assert.deepEqual(snapshot.observation, observation);
9483
assert.equal('targetActivation' in snapshot, false);
9584
},
9685
);
9786

98-
test('observe-only refuses a tvOS session before any runner command', async () => {
99-
const commands: string[] = [];
100-
const runnerProvider: AppleRunnerProvider = {
101-
hasLiveSession: () => true,
102-
runCommand: async (_device, command) => {
103-
commands.push(command.command);
104-
return {};
105-
},
106-
};
107-
await assert.rejects(
108-
createAppleInteractor(
109-
{ ...IOS_SIMULATOR, appleOs: 'tvos', target: 'tv' },
110-
{},
111-
runnerProvider,
112-
).snapshot({ appBundleId: 'com.example.app', observeOnly: true }),
113-
(error: unknown) => error instanceof AppError && error.code === 'UNSUPPORTED_OPERATION',
114-
);
115-
assert.deepEqual(commands, []);
116-
});
117-
118-
test.each([
119-
{ supportsObserveOnlySnapshot: false, runnerSessionId: 'observation-runner' },
120-
{ supportsObserveOnlySnapshot: true },
121-
])(
122-
'observe-only rejects an unbound capability response before snapshot dispatch: %j',
123-
async (capabilities) => {
124-
const commands: string[] = [];
125-
const runnerProvider: AppleRunnerProvider = {
126-
hasLiveSession: () => true,
127-
runCommand: async (_device, command) => {
128-
commands.push(command.command);
129-
return capabilities;
130-
},
131-
};
132-
await assert.rejects(
133-
createAppleInteractor(IOS_SIMULATOR, {}, runnerProvider).snapshot({
134-
appBundleId: 'com.example.app',
135-
observeOnly: true,
136-
}),
137-
(error: unknown) =>
138-
error instanceof AppError && error.details?.reason === 'observation-unavailable',
139-
);
140-
assert.deepEqual(commands, ['uptime']);
141-
},
142-
);
143-
14487
test('observe-only refuses absent provenance rather than crediting a legacy activating runner', async () => {
14588
await assert.rejects(
14689
interactorServing(HEALTHY_TREE).snapshot({ appBundleId: 'com.example.app', observeOnly: true }),

‎packages/platform-apple/src/__tests__/runner-requests.test.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import assert from 'node:assert/strict';
21
import fs from 'node:fs';
32
import path from 'node:path';
43
import { test } from 'vitest';
@@ -230,7 +229,6 @@ test('every Apple request site builds exactly its golden runner request', async
230229
IOS_SIMULATOR,
231230
{ appBundleId: APP },
232231
recordingRunnerProvider(observationCalls, {
233-
uptime: { supportsObserveOnlySnapshot: true, runnerSessionId: 'observe-only-runner' },
234232
snapshot: {
235233
nodes: [{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }],
236234
observation: {
@@ -242,15 +240,10 @@ test('every Apple request site builds exactly its golden runner request', async
242240
},
243241
}),
244242
).snapshot({ appBundleId: APP, observeOnly: true });
245-
assert.deepEqual(
246-
observationCalls.map((call) => call.command.command),
247-
['uptime', 'snapshot'],
248-
);
249-
assert.equal(observationCalls[1]!.options.expectedRunnerSessionId, 'observe-only-runner');
250-
captured.push(
251-
['ios-simulator.interactor-snapshot.observe-only.capability', observationCalls[0]!.command],
252-
['ios-simulator.interactor-snapshot.observe-only', observationCalls[1]!.command],
253-
);
243+
captured.push([
244+
'ios-simulator.interactor-snapshot.observe-only',
245+
onlyRequest('ios-simulator.interactor-snapshot.observe-only', observationCalls),
246+
]);
254247
const dir = await mkdtempForTest('agent-device-runner-requests-');
255248
const runnerScreenshot = path.join(dir, 'runner.png');
256249
fs.writeFileSync(runnerScreenshot, '');

‎packages/platform-apple/src/interactor.ts‎

Lines changed: 16 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { appleRemotePressCommand } from './os/tvos/remote.ts';
77
import { runMacOsScreenshotAction } from './os/macos/helper.ts';
88
import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from './alert.ts';
99
import { runAppleRunnerCommand } from './core/runner-client.ts';
10-
import type { AppleRunnerCommandOptions } from './runner/runner-provider.ts';
1110
import {
1211
withAppleRunnerProvider,
1312
type AppleRunnerCommandExecutor,
1413
type AppleRunnerProvider,
14+
type RunnerCommand,
1515
} from './runner/index.ts';
1616
import { toAppleTvRemoteButton } from '@agent-device/contracts/tv-remote';
1717
import { SCREENSHOT_FULLSCREEN_REASONS } from '@agent-device/contracts/capture';
@@ -23,12 +23,7 @@ import {
2323
import { DEVICE_ROTATIONS, type DeviceRotation } from '@agent-device/contracts/device';
2424
import { normalizeSnapshotScope } from '@agent-device/contracts/snapshot';
2525
import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics';
26-
import {
27-
isHandheldAppleDevice,
28-
isMacOs,
29-
isTvOsDevice,
30-
type DeviceInfo,
31-
} from '@agent-device/kernel/device';
26+
import { isMacOs, isTvOsDevice, type DeviceInfo } from '@agent-device/kernel/device';
3227
import { AppError } from '@agent-device/kernel/errors';
3328
import { withMethodScope } from '@agent-device/kernel/scoped-provider';
3429
import type { Point, SnapshotQualityVerdict } from '@agent-device/kernel/snapshot';
@@ -233,12 +228,6 @@ async function captureAppleSnapshot(
233228
runnerOpts: RunnerCallOptions,
234229
helper: MacOsHelperSurface | undefined,
235230
) {
236-
if (options?.observeOnly === true && !isHandheldAppleDevice(device)) {
237-
throw new AppError(
238-
'UNSUPPORTED_OPERATION',
239-
'observe-only snapshot is supported on iOS and iPadOS only.',
240-
);
241-
}
242231
if (helper) {
243232
return await captureMacOsSurfaceSnapshot({ ...options, surface: helper }, options?.signal);
244233
}
@@ -250,25 +239,14 @@ async function captureAppleRunnerSnapshot(
250239
options: SnapshotOptions | undefined,
251240
runnerOpts: RunnerCallOptions,
252241
) {
253-
const observationPolicy = await snapshotObservationPolicy(device, options, runnerOpts);
254242
const result = readAppleSnapshotResult(
255243
await withDiagnosticTimer(
256244
'snapshot_capture',
257245
async () =>
258246
await runAppleRunnerCommand(
259247
device,
260-
{
261-
command: 'snapshot',
262-
appBundleId: options?.appBundleId,
263-
interactiveOnly: options?.interactiveOnly,
264-
preferredBackend: options?.preferredBackend,
265-
customActions: options?.customActions,
266-
...observationPolicy.request,
267-
depth: options?.depth,
268-
scope: options?.scope,
269-
raw: options?.raw,
270-
},
271-
mergeRunnerCallSignal(observationPolicy.runnerOpts, options?.signal),
248+
runnerSnapshotCommand(options),
249+
mergeRunnerCallSignal(runnerOpts, options?.signal),
272250
),
273251
{ backend: 'xctest' },
274252
),
@@ -292,33 +270,19 @@ async function captureAppleRunnerSnapshot(
292270
};
293271
}
294272

295-
async function snapshotObservationPolicy(
296-
device: DeviceInfo,
297-
options: SnapshotOptions | undefined,
298-
runnerOpts: RunnerCallOptions,
299-
): Promise<{
300-
request: { observeOnly?: true };
301-
runnerOpts: RunnerCallOptions & Pick<AppleRunnerCommandOptions, 'expectedRunnerSessionId'>;
302-
}> {
303-
if (options?.observeOnly !== true) return { request: {}, runnerOpts };
304-
const capabilities = await runAppleRunnerCommand(
305-
device,
306-
{ command: 'uptime', observeOnly: true },
307-
mergeRunnerCallSignal(runnerOpts, options.signal),
308-
);
309-
if (
310-
capabilities.supportsObserveOnlySnapshot !== true ||
311-
typeof capabilities.runnerSessionId !== 'string' ||
312-
capabilities.runnerSessionId.length === 0
313-
) {
314-
throw new AppError('COMMAND_FAILED', 'Runner does not support observe-only snapshots.', {
315-
reason: 'observation-unavailable',
316-
dispatched: 'no',
317-
});
318-
}
273+
function runnerSnapshotCommand(options: SnapshotOptions | undefined): RunnerCommand {
274+
const { appBundleId, interactiveOnly, preferredBackend, customActions, depth, scope, raw } =
275+
options ?? {};
319276
return {
320-
request: { observeOnly: true },
321-
runnerOpts: { ...runnerOpts, expectedRunnerSessionId: capabilities.runnerSessionId },
277+
command: 'snapshot',
278+
appBundleId,
279+
interactiveOnly,
280+
preferredBackend,
281+
customActions,
282+
...(options?.observeOnly === true ? { observeOnly: true } : {}),
283+
depth,
284+
scope,
285+
raw,
322286
};
323287
}
324288

‎packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ test('runner command trait table pins lifecycle-sensitive command groups', () =>
3939
'querySelector',
4040
'readText',
4141
'screenshot',
42+
'snapshot',
4243
],
43-
payloadDependent: ['alert', 'snapshot'],
44+
payloadDependent: ['alert'],
4445
readOnlyReadinessProbe: ['status', 'uptime'],
4546
readinessPreflightExemptMutation: ['activate', 'targetReset', 'terminate'],
4647
default: [
@@ -64,18 +65,6 @@ test('runner command trait table pins lifecycle-sensitive command groups', () =>
6465
assert.deepEqual(Object.values(groups).flat().sort(), [...RUNNER_COMMANDS].sort());
6566
});
6667

67-
test('only an observe-only snapshot is exempt from the readiness preflight', () => {
68-
assert.deepEqual(readRunnerCommandTraits({ command: 'snapshot' }), readOnly());
69-
assert.deepEqual(
70-
readRunnerCommandTraits({ command: 'snapshot', observeOnly: false }),
71-
readOnly(),
72-
);
73-
assert.deepEqual(readRunnerCommandTraits({ command: 'snapshot', observeOnly: true }), {
74-
...readOnly(),
75-
readinessPreflightExempt: true,
76-
});
77-
});
78-
7968
test('alert actions match the native read-only golden table', () => {
8069
// The fixture's `query` column records whether the alert request changes anything — `get` is the
8170
// one action that is side-effect-free — and each side consumes it under its own name: `readOnly`

‎packages/platform-apple/src/runner/__tests__/runner-exchange.test.ts‎

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,27 +57,6 @@ test('the exchange preflights a mutation and settles only its answer', async ()
5757
assert.deepEqual(invalidations, []);
5858
});
5959

60-
test('the exchange sends an observe-only snapshot without a readiness preflight', async () => {
61-
server = await startFakeRunnerServer({
62-
snapshot: [{ kind: 'ok', data: { nodes: [] } }],
63-
});
64-
const session = sessionFor(server.port);
65-
66-
await executeRunnerExchange(
67-
IOS_SIMULATOR,
68-
session,
69-
{ command: 'snapshot', observeOnly: true, appBundleId: 'com.example.app' },
70-
undefined,
71-
10_000,
72-
async () => {},
73-
);
74-
75-
assert.deepEqual(
76-
server.requests.map(({ command }) => command),
77-
['snapshot'],
78-
);
79-
});
80-
8160
test('the readiness probe preserves its main-thread busy report when the command omits it', async () => {
8261
server = await startFakeRunnerServer({
8362
uptime: [{ kind: 'ok', data: { runnerMainThreadBusy: true } }],

‎packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts‎

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -597,40 +597,6 @@ test('an exact-session command never dispatches to a replacement runner', async
597597
assert.deepEqual(server.requests, []);
598598
});
599599

600-
test('observe-only refuses an unavailable runner without creating a session', async () => {
601-
await expect(
602-
runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot', observeOnly: true }),
603-
).rejects.toMatchObject({
604-
code: 'COMMAND_FAILED',
605-
details: { reason: 'observation-unavailable', dispatched: 'no' },
606-
});
607-
expect(ensureRunnerSessionMock).not.toHaveBeenCalled();
608-
expect(invalidateRunnerSessionMock).not.toHaveBeenCalled();
609-
});
610-
611-
test('observe-only keeps the capability-probed identity when a replacement runner is ready', async () => {
612-
server = await startFakeRunnerServer({ snapshot: [{ kind: 'ok', data: {} }] });
613-
const replacement = seedSession(server.port);
614-
const sessionModule = await import('../runner-session.ts');
615-
const liveness = vi.spyOn(sessionModule, 'readRunnerSessionLiveness').mockReturnValue({
616-
sessionId: replacement.sessionId,
617-
liveness: 'ready',
618-
});
619-
try {
620-
await expect(
621-
runAppleRunnerCommand(
622-
IOS_SIMULATOR,
623-
{ command: 'snapshot', observeOnly: true },
624-
{ expectedRunnerSessionId: `${replacement.sessionId}:capability-probed` },
625-
),
626-
).rejects.toThrow('runner session ownership changed');
627-
assert.deepEqual(server.requests, []);
628-
expect(invalidateRunnerSessionMock).not.toHaveBeenCalled();
629-
} finally {
630-
liveness.mockRestore();
631-
}
632-
});
633-
634600
test.each(
635601
(['accept', 'dismiss'] as const).flatMap((action) =>
636602
(['accepted', 'started', 'completed'] as const).map((lifecycleState) => ({

0 commit comments

Comments
 (0)