From 66cab9dd5dbb3b89b849b3a210f11c626361a742 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:20:43 +0000 Subject: [PATCH 01/16] refactor(daemon): migrate clipboard onto request-bound runtimes (R55) Wave 6 unit 1 of the ADR 0019 platform-free daemon migration (#1739). `clipboard` leaves the legacy dispatch projection: admission is now the action-selected `readClipboard`/`writeClipboard` fact the parsed subcommand names, and the only execution is that one bound operation. - new `@agent-device/contracts/clipboard-runtime` facet, riding the existing `Interactor` seam through `interactor-operation-binding.ts`; read and write are separate cells because a provider can genuinely expose one half only. - every owner states its own cells: Apple gains a `system/` facts module (simulator or the macOS host, matching the retired `supportsHostOrSimulatorSurface` closure), Android admits every real kind, Linux the desktop device, and HarmonyOS/Vega/web refuse -- none ever carried a bucket. Limrun reuses the local Android interactor and refuses on iOS; WebDriver rides interactor reachability like `back`/`home`. - retires the `core/dispatch.ts` clipboard arm and handler, the descriptor's capability bucket and `dispatch` leaf, and the Apple plugin's clipboard admission closure. `handlers/session.ts` loses its inline handler (and its last `dispatchCommand`/`requireCommandSupported` imports) to the new `handlers/session-clipboard.ts`. - `bindLocalInteractorOperationSet` collapses the byte-identical local interaction bind list Android and Linux each held a copy of. Cutover row R55 with its retirement, admission-member and single-bind claims. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/contracts/package.json | 8 + .../contracts/src/clipboard-runtime.test.ts | 115 ++++++++++ packages/contracts/src/clipboard-runtime.ts | 152 ++++++++++++++ packages/contracts/src/facades/platform.ts | 3 + .../src/interactor-operation-catalog.ts | 57 +++-- .../src/local-interactor-operation-set.ts | 47 +++++ .../src/platform-runtime-operations.ts | 14 ++ .../src/platform-runtime-unavailable.test.ts | 2 + .../src/platform-runtime-unavailable.ts | 11 + packages/platform-android/src/runtime.test.ts | 24 +++ packages/platform-android/src/runtime.ts | 72 ++----- packages/platform-apple/src/runtime.ts | 7 + .../platform-apple/src/system/runtime.test.ts | 80 +++++++ packages/platform-apple/src/system/runtime.ts | 69 ++++++ .../platform-harmonyos/src/runtime.test.ts | 11 + packages/platform-harmonyos/src/runtime.ts | 7 + packages/platform-linux/src/runtime.test.ts | 10 +- packages/platform-linux/src/runtime.ts | 85 +++----- packages/platform-vega/src/runtime.test.ts | 9 + packages/platform-vega/src/runtime.ts | 6 + packages/platform-web/src/runtime.test.ts | 15 ++ packages/platform-web/src/runtime.ts | 7 + .../provider-limrun/src/app-log-runtime.ts | 2 + packages/provider-limrun/src/facts-runtime.ts | 3 + .../src/interaction-operations.test.ts | 27 ++- .../src/interaction-operations.ts | 22 ++ .../src/platform-runtime.test.ts | 13 +- .../src/platform-runtime.ts | 23 ++ scripts/layering/package-boundaries.test.ts | 2 + .../layering/runtime-command-cutover-table.ts | 39 ++++ .../apple-os-capability-table-parity.test.ts | 10 +- src/__tests__/eager-closure-budgets.ts | 14 +- .../test-utils/runtime-operation-facts.ts | 2 + src/core/__tests__/capabilities.test.ts | 16 +- .../capability-plugin-routing-parity.test.ts | 5 - .../__tests__/parity.test.ts | 1 + src/core/command-descriptor/registry.ts | 13 +- src/core/dispatch.ts | 28 --- .../handlers/__tests__/install-source.test.ts | 2 + .../interaction-get-runtime-fixture.ts | 2 + .../session-capabilities.fixtures.ts | 2 + .../__tests__/session-clipboard.test.ts | 197 ++++++++++++++++++ .../__tests__/session-command-harness.ts | 2 + .../handlers/__tests__/session-state.test.ts | 4 + src/daemon/handlers/session-clipboard.ts | 167 +++++++++++++++ src/daemon/handlers/session.ts | 58 +----- src/platform-runtime-gateway.test.ts | 4 + src/platform-runtime-gateway.ts | 4 + src/platforms/apple/plugin.ts | 7 +- .../smoke-web-platform-coverage.test.ts | 4 +- test/integration/web-e2e/coverage-manifest.ts | 6 +- 51 files changed, 1239 insertions(+), 251 deletions(-) create mode 100644 packages/contracts/src/clipboard-runtime.test.ts create mode 100644 packages/contracts/src/clipboard-runtime.ts create mode 100644 packages/contracts/src/local-interactor-operation-set.ts create mode 100644 packages/platform-apple/src/system/runtime.test.ts create mode 100644 packages/platform-apple/src/system/runtime.ts create mode 100644 src/daemon/handlers/__tests__/session-clipboard.test.ts create mode 100644 src/daemon/handlers/session-clipboard.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8d9fccd2d9..4de72841ba 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -95,6 +95,10 @@ "types": "./src/facades/client.ts", "default": "./src/facades/client.ts" }, + "./clipboard-runtime": { + "types": "./src/clipboard-runtime.ts", + "default": "./src/clipboard-runtime.ts" + }, "./command": { "types": "./src/facades/command.ts", "default": "./src/facades/command.ts" @@ -187,6 +191,10 @@ "types": "./src/keyboard-runtime.ts", "default": "./src/keyboard-runtime.ts" }, + "./local-interactor-operation-set": { + "types": "./src/local-interactor-operation-set.ts", + "default": "./src/local-interactor-operation-set.ts" + }, "./logs-runtime-plan": { "types": "./src/logs-runtime-plan.ts", "default": "./src/logs-runtime-plan.ts" diff --git a/packages/contracts/src/clipboard-runtime.test.ts b/packages/contracts/src/clipboard-runtime.test.ts new file mode 100644 index 0000000000..4756347d14 --- /dev/null +++ b/packages/contracts/src/clipboard-runtime.test.ts @@ -0,0 +1,115 @@ +import { expect, test, vi } from 'vitest'; +import { + bindLocalClipboardReadInteractor, + bindLocalClipboardWriteInteractor, + bindProviderClipboardReadInteractor, + bindProviderClipboardWriteInteractor, + clipboardRuntimeOperationFacts, +} from './clipboard-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact clipboard operation fact catalog', () => { + const read = { available: true } as const; + const write = { available: false, reason: 'owner-capability-missing' } as const; + expect(clipboardRuntimeOperationFacts({ read, write })).toEqual({ + readClipboard: read, + writeClipboard: write, + }); +}); + +test('a local read binding returns the interactor pasteboard text verbatim', async () => { + const readClipboard = vi.fn(async () => 'copied\ntext'); + const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalClipboardReadInteractor({ device, signal, resolveInteractor }); + await expect( + operations.readClipboard({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'clipboard-1' }, + }), + ).resolves.toBe('copied\ntext'); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'clipboard-1', + appBundleId: 'com.example.app', + signal, + }); +}); + +test('a local write binding hands the interactor the already-joined text', async () => { + const writeClipboard = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ writeClipboard }) as unknown as Interactor); + + const operations = bindLocalClipboardWriteInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor, + }); + await operations.writeClipboard({ text: 'hello world' }); + + expect(writeClipboard).toHaveBeenCalledWith('hello world'); +}); + +test('a provider binding drives its own resolved interactor', async () => { + const readClipboard = vi.fn(async () => 'provider text'); + const resolveInteractor = vi.fn(() => ({ readClipboard }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindProviderClipboardReadInteractor({ device, signal, resolveInteractor }); + await expect(operations.readClipboard({ execution: { requestId: 'clipboard-2' } })).resolves.toBe( + 'provider text', + ); + + expect(resolveInteractor).toHaveBeenCalledWith({ + requestId: 'clipboard-2', + appBundleId: undefined, + signal, + }); +}); + +test.each([ + { half: 'read', bind: bindProviderClipboardReadInteractor }, + { half: 'write', bind: bindProviderClipboardWriteInteractor }, +])('a provider $half binding fails closed with no owner interactor', async ({ bind }) => { + const operations = bind({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + const invoke = + 'readClipboard' in operations + ? operations.readClipboard({}) + : operations.writeClipboard({ text: '' }); + await expect(invoke).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const readClipboard = vi.fn(async () => ''); + const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor); + + const operations = bindLocalClipboardReadInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.readClipboard({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(readClipboard).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/clipboard-runtime.ts b/packages/contracts/src/clipboard-runtime.ts new file mode 100644 index 0000000000..1dc003a2ac --- /dev/null +++ b/packages/contracts/src/clipboard-runtime.ts @@ -0,0 +1,152 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one clipboard read. The operation names no command, request, session, or CLI + * flag: `clipboard read`'s whole input is the runner metadata every request-bound operation + * forwards, which is why the read and the write share this base. + */ +export type ClipboardReadInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * One clipboard write. `text` is already joined and validated by the caller (`clipboard write` + * accepts `""` to clear), so the owner receives content, never argv. + */ +export type ClipboardWriteInput = ClipboardReadInput & Readonly<{ text: string }>; + +export type ClipboardReadRuntimeOperations = Readonly<{ + readClipboard(input: ClipboardReadInput): Promise; +}>; + +/** + * The write returns nothing. The retired leaf discarded whatever the interactor answered and + * reported only the length of the text it sent, so a result type here would be a surface the + * command never had. + */ +export type ClipboardWriteRuntimeOperations = Readonly<{ + writeClipboard(input: ClipboardWriteInput): Promise; +}>; + +export type ClipboardRuntimeOperations = ClipboardReadRuntimeOperations & + ClipboardWriteRuntimeOperations; + +export type ClipboardRuntimeOperationFacts = Readonly<{ + readClipboard: RuntimeOperationFact; + writeClipboard: RuntimeOperationFact; +}>; + +/** + * Read and write are separate cells because an owner can genuinely have one without the other — + * a WebDriver provider whose Appium clipboard extension exposes only a getter is the real case — + * and `clipboard read` must not be refused because the write half is missing. + */ +export function clipboardRuntimeOperationFacts( + input: Readonly<{ read: RuntimeOperationFact; write: RuntimeOperationFact }>, +): ClipboardRuntimeOperationFacts { + return Object.freeze({ readClipboard: input.read, writeClipboard: input.write }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both operations share: the runner context. + */ +async function resolveClipboardInteractor( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, + input: ClipboardReadInput, +): Promise { + signal.throwIfAborted(); + return await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); +} + +function bindClipboardRead( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): ClipboardReadRuntimeOperations { + return Object.freeze({ + readClipboard: async (input: ClipboardReadInput) => { + const interactor = await resolveClipboardInteractor(signal, resolveInteractor, input); + return await interactor.readClipboard(); + }, + }); +} + +function bindClipboardWrite( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): ClipboardWriteRuntimeOperations { + return Object.freeze({ + writeClipboard: async (input: ClipboardWriteInput) => { + const interactor = await resolveClipboardInteractor(signal, resolveInteractor, input); + await interactor.writeClipboard(input.text); + }, + }); +} + +export type LocalClipboardInteractorResolver = LocalInteractorOperationResolver; +export type ProviderClipboardInteractorResolver = ProviderInteractorOperationResolver; + +export function bindLocalClipboardReadInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalClipboardInteractorResolver; + }>, +): ClipboardReadRuntimeOperations { + return bindClipboardRead(params.signal, localInteractorSource(params)); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderClipboardReadInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderClipboardInteractorResolver; + }>, +): ClipboardReadRuntimeOperations { + return bindClipboardRead( + params.signal, + providerInteractorSource({ ...params, operation: 'clipboard read' }), + ); +} + +export function bindLocalClipboardWriteInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalClipboardInteractorResolver; + }>, +): ClipboardWriteRuntimeOperations { + return bindClipboardWrite(params.signal, localInteractorSource(params)); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderClipboardWriteInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderClipboardInteractorResolver; + }>, +): ClipboardWriteRuntimeOperations { + return bindClipboardWrite( + params.signal, + providerInteractorSource({ ...params, operation: 'clipboard write' }), + ); +} diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index bafcb39b72..ff917a4e78 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -245,6 +245,9 @@ export { keyboardStatusUse, keyboardDismissUse, keyboardEnterUse, + clipboardRuntimePlanUses, + clipboardReadUse, + clipboardWriteUse, } from '../platform-runtime-operations.ts'; export type { GestureRuntimePlan, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 26a7d88595..258bbd38d9 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -1,5 +1,11 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { bindLocalBackInteractor, bindProviderBackInteractor } from './back-runtime.ts'; +import { + bindLocalClipboardReadInteractor, + bindLocalClipboardWriteInteractor, + bindProviderClipboardReadInteractor, + bindProviderClipboardWriteInteractor, +} from './clipboard-runtime.ts'; import { bindLocalHomeInteractor, bindProviderHomeInteractor } from './home-runtime.ts'; import { bindLocalKeyboardDismissInteractor, @@ -25,17 +31,18 @@ import { } from './tv-remote-runtime.ts'; /** - * The seven navigation/keyboard operations every owner admits from the same shape: one fact, - * one bind call, no owner mechanics in between. This tuple is the single canonical declaration: - * the {@link NavigationInteractorOperation} union type is derived from it below, and - * `LOCAL_BINDERS`/`PROVIDER_BINDERS`'s `Record` types are then - * checked against that derived union — so a member can never be added to one and silently missing - * from another. Not caller-supplied: `facts[operation]` is the only thing that decides whether an - * operation binds — a key this tuple names but the caller's facts never define is simply never - * available (`facts[operation]?.available` reads `undefined`), which is how a caller passing a - * narrower, dedicated facts object (e.g. Limrun's keyboard-less navigation facts) opts a subset out. + * Every operation whose whole binding is "one fact, one bind call, no owner mechanics in between" + * — the navigation/keyboard leaves and the system-surface leaves that joined them in Wave 6. This + * tuple is the single canonical declaration: the {@link CatalogInteractorOperation} union type is + * derived from it below, and `LOCAL_BINDERS`/`PROVIDER_BINDERS`'s + * `Record` types are then checked against that derived union — so a + * member can never be added to one and silently missing from another. Not caller-supplied: + * `facts[operation]` is the only thing that decides whether an operation binds — a key this tuple + * names but the caller's facts never define is simply never available + * (`facts[operation]?.available` reads `undefined`), which is how a caller passing a narrower, + * dedicated facts object (e.g. Limrun's keyboard-less navigation facts) opts a subset out. */ -const NAVIGATION_INTERACTOR_OPERATIONS = [ +const CATALOG_INTERACTOR_OPERATIONS = [ 'back', 'home', 'setOrientation', @@ -43,9 +50,11 @@ const NAVIGATION_INTERACTOR_OPERATIONS = [ 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', + 'readClipboard', + 'writeClipboard', ] as const; -export type NavigationInteractorOperation = (typeof NAVIGATION_INTERACTOR_OPERATIONS)[number]; +export type CatalogInteractorOperation = (typeof CATALOG_INTERACTOR_OPERATIONS)[number]; type LocalBinderParams = Readonly<{ device: DeviceInfo; @@ -60,7 +69,7 @@ type ProviderBinderParams = Readonly<{ const LOCAL_BINDERS: Readonly< Record< - NavigationInteractorOperation, + CatalogInteractorOperation, (params: LocalBinderParams) => Partial > > = Object.freeze({ @@ -71,11 +80,13 @@ const LOCAL_BINDERS: Readonly< keyboardStatus: bindLocalKeyboardStatusInteractor, keyboardDismiss: bindLocalKeyboardDismissInteractor, keyboardEnter: bindLocalKeyboardEnterInteractor, + readClipboard: bindLocalClipboardReadInteractor, + writeClipboard: bindLocalClipboardWriteInteractor, }); const PROVIDER_BINDERS: Readonly< Record< - NavigationInteractorOperation, + CatalogInteractorOperation, (params: ProviderBinderParams) => Partial > > = Object.freeze({ @@ -86,6 +97,8 @@ const PROVIDER_BINDERS: Readonly< keyboardStatus: bindProviderKeyboardStatusInteractor, keyboardDismiss: bindProviderKeyboardDismissInteractor, keyboardEnter: bindProviderKeyboardEnterInteractor, + readClipboard: bindProviderClipboardReadInteractor, + writeClipboard: bindProviderClipboardWriteInteractor, }); /** @@ -95,15 +108,15 @@ const PROVIDER_BINDERS: Readonly< * `limrunNavigationOperationFacts`), that flat map directly — which may cover only the subset of * operations the owner admits at all, so a missing key here is simply never bindable. */ -type NavigationOperationFacts = Readonly< - Partial> +type CatalogOperationFacts = Readonly< + Partial> >; -/** Walks every navigation operation against one binder table, binding each the facts admitted. */ +/** Walks every catalog operation against one binder table, binding each the facts admitted. */ function bindAdmittedInteractorOperations( binders: Readonly< Record< - NavigationInteractorOperation, + CatalogInteractorOperation, (params: { device: DeviceInfo; signal: AbortSignal; @@ -115,12 +128,12 @@ function bindAdmittedInteractorOperations( device: DeviceInfo; signal: AbortSignal; resolveInteractor: Resolver; - facts: NavigationOperationFacts; + facts: CatalogOperationFacts; }>, ): Partial { const { device, signal, resolveInteractor, facts } = params; const bound: Partial = {}; - for (const operation of NAVIGATION_INTERACTOR_OPERATIONS) { + for (const operation of CATALOG_INTERACTOR_OPERATIONS) { if (facts[operation]?.available) { Object.assign(bound, binders[operation]({ device, signal, resolveInteractor })); } @@ -130,7 +143,7 @@ function bindAdmittedInteractorOperations( /** * Binds whichever local operations the owner's own facts admitted. The owner keeps full - * authority — its facts alone decide what binds — this only removes the seven-times-repeated + * authority — its facts alone decide what binds — this only removes the once-per-operation * ternary that read them. */ export function bindAdmittedLocalInteractorOperations( @@ -138,7 +151,7 @@ export function bindAdmittedLocalInteractorOperations( device: DeviceInfo; signal: AbortSignal; resolveInteractor: LocalInteractorOperationResolver; - facts: NavigationOperationFacts; + facts: CatalogOperationFacts; }>, ): Partial { return bindAdmittedInteractorOperations(LOCAL_BINDERS, params); @@ -154,7 +167,7 @@ export function bindAdmittedProviderInteractorOperations( device: DeviceInfo; signal: AbortSignal; resolveInteractor: ProviderInteractorOperationResolver; - facts: NavigationOperationFacts; + facts: CatalogOperationFacts; }>, ): Partial { return bindAdmittedInteractorOperations(PROVIDER_BINDERS, params); diff --git a/packages/contracts/src/local-interactor-operation-set.ts b/packages/contracts/src/local-interactor-operation-set.ts new file mode 100644 index 0000000000..9d624783e0 --- /dev/null +++ b/packages/contracts/src/local-interactor-operation-set.ts @@ -0,0 +1,47 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { bindElementTextRuntime } from './element-text-runtime.ts'; +import { bindLocalFocusInteractor } from './focus-runtime.ts'; +import { bindLocalGestureInteractor } from './gesture-runtime.ts'; +import { bindAdmittedLocalInteractorOperations } from './interactor-operation-catalog.ts'; +import type { LocalInteractorOperationResolver } from './interactor-operation-binding.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; +import { whenAdmitted, type RuntimeFacts } from './platform-runtime.ts'; +import { bindLocalScreenshotInteractor } from './screenshot-runtime.ts'; +import { bindLocalScrollInteractor } from './scroll-runtime.ts'; +import { bindLocalTouchInteractor } from './touch-runtime.ts'; +import { bindLocalTypeTextInteractor } from './type-text-runtime.ts'; + +/** + * The whole local interactor-backed operation set a pointer-driving family binds: the uniform + * one-fact-one-bind leaves the interactor catalog walks, plus the four that carry extra input of their own — gesture and touch read the + * fact map, and touch needs a pause clock. Android and Linux each held a byte-identical copy of + * this list; it is one list now, so neither family can drift from the other by forgetting a + * member. Capture stays out: an owner's snapshot mechanics are its own (Linux captures a surface + * through its host, Android drives the interactor), so there is no shared reading to make. + */ +export function bindLocalInteractorOperationSet( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalInteractorOperationResolver; + facts: RuntimeFacts['operations']; + pause: (milliseconds: number) => Promise; + }>, +): Partial { + const { facts, pause } = params; + const resolver = { + device: params.device, + signal: params.signal, + resolveInteractor: params.resolveInteractor, + }; + return { + ...(facts.captureScreenshot.available ? bindLocalScreenshotInteractor(resolver) : {}), + ...(facts.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}), + ...bindLocalGestureInteractor({ ...resolver, facts }), + ...(facts.scrollDirection.available ? bindLocalScrollInteractor(resolver) : {}), + ...(facts.typeText.available ? bindLocalTypeTextInteractor(resolver) : {}), + ...(facts.readTextAtPoint.available ? bindElementTextRuntime(resolver) : {}), + ...whenAdmitted(facts.tapPoint, () => bindLocalTouchInteractor({ ...resolver, facts, pause })), + ...bindAdmittedLocalInteractorOperations({ ...resolver, facts }), + }; +} diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 43aaa3e1f2..614e56a814 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -28,6 +28,7 @@ import type { HomeRuntimeOperations } from './home-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; +import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; import type { TouchRuntimeOperations } from './touch-runtime.ts'; import type { DeviceReadinessRuntimeHost, @@ -72,6 +73,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & OrientationRuntimeOperations & TvRemoteRuntimeOperations & KeyboardRuntimeOperations & + ClipboardRuntimeOperations & TouchRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & @@ -101,6 +103,8 @@ export const tvRemoteRuntimeUse = defineUse({ required: ['tvRemote'] }); export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); +export const clipboardReadUse = defineUse({ required: ['readClipboard'] }); +export const clipboardWriteUse = defineUse({ required: ['writeClipboard'] }); export const tapPointUse = defineUse({ required: ['tapPoint'] }); export const capturedTapUse = defineUse({ required: ['captureSnapshot', 'tapPoint'], @@ -623,6 +627,16 @@ export const appStateRuntimeUses = Object.freeze([appStateUse] as const); export const shutdownTargetUse = defineUse({ required: ['shutdownTarget'] }); +/** + * `clipboard`'s action-selected uses (ADR 0019 §9: one bind per handler). `read` and `write` are + * separate cells because an owner can genuinely have one without the other, so the daemon's + * `session-clipboard.ts` admits and binds exactly the one the parsed subcommand names. + */ +export const clipboardRuntimePlanUses = Object.freeze([ + clipboardReadUse, + clipboardWriteUse, +] as const); + /** * `keyboard`'s action-selected uses (ADR 0019 §9: one bind per handler). `status` is Android-only * (parity with the retired leaf's per-family rejection of `status`/`get` everywhere else); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index ad023a0710..2e9c6c4e09 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -44,6 +44,8 @@ test('generic unavailable binding preserves exact provider ownership and mode', keyboardStatus: { available: false, reason: 'unsupported-provider-mode' }, keyboardDismiss: { available: false, reason: 'unsupported-provider-mode' }, keyboardEnter: { available: false, reason: 'unsupported-provider-mode' }, + readClipboard: { available: false, reason: 'unsupported-provider-mode' }, + writeClipboard: { available: false, reason: 'unsupported-provider-mode' }, lifecycle, }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 88f8b3ad9f..a895e4c361 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -24,6 +24,7 @@ import { homeRuntimeOperationFacts } from './home-runtime.ts'; import { orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; +import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; import { touchRuntimeOperationFacts } from './touch-runtime.ts'; /** @@ -53,6 +54,8 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ keyboardStatus: RuntimeOperationUnavailability; keyboardDismiss: RuntimeOperationUnavailability; keyboardEnter: RuntimeOperationUnavailability; + readClipboard: RuntimeOperationUnavailability; + writeClipboard: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -109,6 +112,8 @@ export function createUnavailablePlatformRuntimeFacts( keyboardStatus, keyboardDismiss, keyboardEnter, + readClipboard, + writeClipboard, readiness, shutdown, lifecycle, @@ -178,6 +183,7 @@ export function createUnavailablePlatformRuntimeFacts( dismiss: keyboardDismiss, enter: keyboardEnter, }), + ...clipboardRuntimeOperationFacts({ read: readClipboard, write: writeClipboard }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -225,6 +231,11 @@ function freezeUnavailableFacts( keyboardStatus: Object.freeze({ ...unavailable.keyboardStatus }), keyboardDismiss: Object.freeze({ ...unavailable.keyboardDismiss }), keyboardEnter: Object.freeze({ ...unavailable.keyboardEnter }), + // Clipboard cells are stated by their owner for the same reason: the surface differs by leaf + // and kind (an Apple simulator has one, a physical non-macOS Apple device does not), and read + // and write can diverge on a provider whose extension exposes only one half. + readClipboard: Object.freeze({ ...unavailable.readClipboard }), + writeClipboard: Object.freeze({ ...unavailable.writeClipboard }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), }); } diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 067ca37806..550c516612 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -289,6 +289,28 @@ test('admits Android tv-remote only for a real TV target', async () => { expect(binding.operations.tvRemote).toBeTypeOf('function'); }); +// R55 parity: the retired `clipboard` bucket was `ANDROID_ALL` (emulator/device/unknown) with no +// Android admission closure, so `cmd clipboard get/set text` is admitted on every real kind and +// refused only on the synthetic `simulator` row the bucket never listed. (`unknown` is the +// bucket's name for a device with no declared kind, which `DeviceKind` cannot express.) +test('admits both clipboard halves on every real Android kind', async () => { + for (const kind of ['emulator', 'device'] as const) { + const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ + device: { ...device, id: `android-${kind}`, kind }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.operations.readClipboard).toEqual({ available: true }); + expect(binding.facts.operations.writeClipboard).toEqual({ available: true }); + expect(binding.operations.readClipboard).toBeTypeOf('function'); + expect(binding.operations.writeClipboard).toBeTypeOf('function'); + } +}); + test('the synthetic Android simulator cell refuses back/home/orientation/keyboard like every other touch operation', async () => { const simulatorDevice = { ...device, id: 'android-simulator', kind: 'simulator' as const }; const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ @@ -310,6 +332,8 @@ test('the synthetic Android simulator cell refuses back/home/orientation/keyboar 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', + 'readClipboard', + 'writeClipboard', ] as const) { expect(facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index c834d3f7ea..d4f4b180a9 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -14,48 +14,28 @@ import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; -import { - bindElementTextRuntime, - elementTextRuntimeOperationFacts, -} from '@agent-device/contracts/element-text-runtime'; -import { - bindLocalFocusInteractor, - focusRuntimeOperationFacts, -} from '@agent-device/contracts/focus-runtime'; +import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import { focusRuntimeOperationFacts } from '@agent-device/contracts/focus-runtime'; import { ANDROID_TV_MULTI_TOUCH_UNSUPPORTED_HINT, TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT, } from '@agent-device/contracts/gesture-admission'; -import { - bindLocalGestureInteractor, - gestureRuntimeOperationFacts, -} from '@agent-device/contracts/gesture-runtime'; -import { - bindLocalScrollInteractor, - scrollRuntimeOperationFacts, -} from '@agent-device/contracts/scroll-runtime'; -import { localRuntimeOwner, whenAdmitted } from '@agent-device/contracts/platform-runtime'; -import { - bindLocalScreenshotInteractor, - screenshotRuntimeOperationFacts, -} from '@agent-device/contracts/screenshot-runtime'; +import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; +import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; +import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; import { selectorObservationRuntimeOperationFacts } from '@agent-device/contracts/selector-observation-runtime'; import { bindLocalSnapshotInteractor, snapshotRuntimeOperationFacts, } from '@agent-device/contracts/snapshot-runtime'; -import { - bindLocalTypeTextInteractor, - typeTextRuntimeOperationFacts, -} from '@agent-device/contracts/type-text-runtime'; -import { - bindLocalTouchInteractor, - touchRuntimeOperationFacts, -} from '@agent-device/contracts/touch-runtime'; +import { typeTextRuntimeOperationFacts } from '@agent-device/contracts/type-text-runtime'; +import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtime'; import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; -import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; @@ -302,6 +282,12 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor dismiss: androidTouchFact(device), enter: androidTouchFact(device), }), + // `cmd clipboard get/set text` runs over adb on every real Android kind, so read and + // write share the same cell the retired `clipboard` bucket declared. + ...clipboardRuntimeOperationFacts({ + read: androidTouchFact(device), + write: androidTouchFact(device), + }), ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, @@ -349,12 +335,6 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor await dumpAndroidNetworkTraffic(host, request.device, input, request.scope.signal), ...recording, ...androidInteractionOperations(host, request, facts), - ...bindAdmittedLocalInteractorOperations({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - facts: facts.operations, - }), ensureReady: async (input: EnsureReadyInput) => await ensureAndroidReady( host, @@ -428,20 +408,10 @@ function androidInteractionOperations( }; return { ...(facts.operations.captureSnapshot.available ? bindLocalSnapshotInteractor(resolver) : {}), - ...(facts.operations.captureScreenshot.available - ? bindLocalScreenshotInteractor(resolver) - : {}), - ...(facts.operations.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}), - ...bindLocalGestureInteractor({ ...resolver, facts: facts.operations }), - ...(facts.operations.scrollDirection.available ? bindLocalScrollInteractor(resolver) : {}), - ...(facts.operations.typeText.available ? bindLocalTypeTextInteractor(resolver) : {}), - ...whenAdmitted(facts.operations.tapPoint, () => - bindLocalTouchInteractor({ - ...resolver, - facts: facts.operations, - pause: async (milliseconds) => await host.clock.sleep(milliseconds, request.scope.signal), - }), - ), - ...(facts.operations.readTextAtPoint.available ? bindElementTextRuntime(resolver) : {}), + ...bindLocalInteractorOperationSet({ + ...resolver, + facts: facts.operations, + pause: async (milliseconds) => await host.clock.sleep(milliseconds, request.scope.signal), + }), }; } diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index a80e2cdbb7..0c69dabc38 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -56,6 +56,7 @@ import { createAppleAppDeploymentOperations, } from './deployment/runtime.ts'; import { appleNavigationFacts, createAppleNavigationOperations } from './navigation/runtime.ts'; +import { appleSystemFacts, createAppleSystemOperations } from './system/runtime.ts'; import { bindAppleFindSelectorRuntime, bindAppleFindTextRuntime, @@ -301,6 +302,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), ...appleNavigationFacts(device), + ...appleSystemFacts(device), ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, @@ -409,6 +411,11 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR device: request.device, signal: request.scope.signal, }), + ...createAppleSystemOperations({ + host, + device: request.device, + signal: request.scope.signal, + }), ...whenAdmitted(facts.operations.ensureReady, () => ({ ensureReady: async () => await ensureAppleReady(host, request.device, request.scope.signal), diff --git a/packages/platform-apple/src/system/runtime.test.ts b/packages/platform-apple/src/system/runtime.test.ts new file mode 100644 index 0000000000..5898f923a4 --- /dev/null +++ b/packages/platform-apple/src/system/runtime.test.ts @@ -0,0 +1,80 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from '@agent-device/contracts/interaction'; +import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; +import { appleSystemFacts, createAppleSystemOperations } from './runtime.ts'; + +function appleDevice( + appleOs: AppleOS, + kind: DeviceInfo['kind'], + overrides: Partial = {}, +): DeviceInfo { + return { + platform: 'apple', + appleOs, + id: `${appleOs}-${kind}`, + name: `${appleOs} ${kind}`, + kind, + booted: true, + ...overrides, + }; +} + +/** + * The R55 parity cell table, restated as facts. The retired admission was the `clipboard` + * capability bucket (`{ simulator: true, device: true }`) intersected with the Apple plugin's + * `supportsHostOrSimulatorSurface` closure — `macos || simulator`. Every row below is that + * verdict; the one deliberate narrowing is watchOS, which the bucket admitted as a simulator but + * for which no XCUITest interactor can be constructed at all (the same reading `appleBackFact` + * takes). + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: false }, + { appleOs: 'ipados', kind: 'simulator', expected: true }, + { appleOs: 'ipados', kind: 'device', expected: false }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'tvos', kind: 'device', expected: false }, + { appleOs: 'visionos', kind: 'simulator', expected: true }, + { appleOs: 'visionos', kind: 'device', expected: false }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'clipboard on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + const facts = appleSystemFacts(appleDevice(appleOs, kind)); + expect(facts.readClipboard.available).toBe(expected); + expect(facts.writeClipboard.available).toBe(expected); + }, +); + +test('a non-simulator, non-device Apple kind is refused by kind, with its own reason', () => { + const facts = appleSystemFacts(appleDevice('ios', 'emulator')); + expect(facts.readClipboard).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'clipboard is supported on Apple simulators and the macOS host.', + }); +}); + +test('binds both clipboard halves for an admitted cell and neither for a refused one', () => { + const resolve = vi.fn(async () => ({}) as unknown as Interactor); + const host = { localInteractors: { resolve } }; + const signal = new AbortController().signal; + + const admitted = createAppleSystemOperations({ + host, + device: appleDevice('ios', 'simulator'), + signal, + }); + expect(admitted.readClipboard).toBeTypeOf('function'); + expect(admitted.writeClipboard).toBeTypeOf('function'); + + const refused = createAppleSystemOperations({ + host, + device: appleDevice('ios', 'device'), + signal, + }); + expect(refused.readClipboard).toBeUndefined(); + expect(refused.writeClipboard).toBeUndefined(); + expect(resolve).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts new file mode 100644 index 0000000000..9e19d9b8f7 --- /dev/null +++ b/packages/platform-apple/src/system/runtime.ts @@ -0,0 +1,69 @@ +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; + +const available = Object.freeze({ available: true } as const); + +const clipboardKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'clipboard is supported on Apple simulators and the macOS host.', +} as const); +/** + * Parity with the retired `supportsHostOrSimulatorSurface` closure: the Apple pasteboard is + * reachable through `simctl pbpaste`/`pbcopy` on any simulator, and directly on the macOS host; + * a physical iOS/iPadOS/tvOS/visionOS device has neither route. + */ +const clipboardLeafUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'clipboard is supported on Apple simulators and the macOS host, not on physical devices of this OS.', +} as const); +/** + * watchOS has no XCUITest-driveable UI (ADR-0009), so no Apple interactor can be constructed for + * it and every interactor-backed operation stays unavailable there — the same reading + * `appleBackFact` takes, and for the same reason: facts are the support authority, not a mirror + * of a capability table that never modeled interactor constructibility. + */ +const clipboardOsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +/** + * Read and write share one cell: both routes (`simctl pbpaste`/`pbcopy`, and the macOS host + * pasteboard) expose the pair or neither, so splitting them here would invent a cell no Apple + * owner can actually be in. + */ +function appleClipboardFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return clipboardKindUnavailable; + const os = resolveDeviceAppleOs(device); + if (os === 'watchos') return clipboardOsUnavailable; + if (device.kind === 'simulator') return available; + return os === 'macos' ? available : clipboardLeafUnavailable; +} + +/** The system-surface cells: clipboard read/write. */ +export function appleSystemFacts(device: DeviceInfo) { + const clipboard = appleClipboardFact(device); + return Object.freeze({ + ...clipboardRuntimeOperationFacts({ read: clipboard, write: clipboard }), + }); +} + +/** Binds whichever system operations {@link appleSystemFacts} admitted. */ +export function createAppleSystemOperations(params: { + host: Pick; + device: DeviceInfo; + signal: AbortSignal; +}) { + const { host, device, signal } = params; + return bindAdmittedLocalInteractorOperations({ + device, + signal, + resolveInteractor: host.localInteractors.resolve, + facts: appleSystemFacts(device), + }); +} diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 7148c03450..9fec18ab55 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -105,6 +105,15 @@ test.each([ hint: 'keyboard status/get is not available through the public HarmonyOS HDC API; use keyboard dismiss or enter', }); expect(binding.operations.keyboardStatus).toBeUndefined(); + // R55: HarmonyOS never carried a `clipboard` bucket and is absent from the HarmonyOS overlay + // set, so neither half was ever admitted here. + for (const operation of ['readClipboard', 'writeClipboard'] as const) { + expect(facts.operations[operation]).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations[operation]).toBeUndefined(); + } await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true }); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), @@ -150,6 +159,8 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', + 'readClipboard', + 'writeClipboard', ] as const) { expect(binding.facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 97c06dd2e7..1ab6f59809 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -25,6 +25,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { localRuntimeOwner, whenAdmitted } from '@agent-device/contracts/platform-runtime'; @@ -277,6 +278,12 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor dismiss: harmonyFocusFact(device), enter: harmonyFocusFact(device), }), + // HarmonyOS never carried a `clipboard` capability bucket and is absent from the + // HarmonyOS overlay set, so no clipboard cell was ever admitted here. + ...clipboardRuntimeOperationFacts({ + read: harmonyPlatformLeafUnavailable, + write: harmonyPlatformLeafUnavailable, + }), ensureReady: available, bootTarget: unavailable, bootTargetHeadless: unavailable, diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index d6b863d7c4..7c0127d1e7 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -177,8 +177,8 @@ function expectLifecycleFacts( } /** - * back/home parity with the retired capability bucket: the desktop is the only Linux cell with a - * target to drive. orientation/tv-remote/keyboard never carried a Linux bucket at all. + * back/home/clipboard parity with the retired capability buckets: the desktop is the only Linux + * cell with a target to drive. orientation/tv-remote/keyboard never carried a Linux bucket at all. */ function expectLinuxNavigationAndKeyboardFacts( binding: DeviceBinding, @@ -189,6 +189,12 @@ function expectLinuxNavigationAndKeyboardFacts( expect(binding.facts.operations.home.available).toBe(desktop); expect(binding.operations.back).toBeTypeOf(desktop ? 'function' : 'undefined'); expect(binding.operations.home).toBeTypeOf(desktop ? 'function' : 'undefined'); + // R55: `clipboard`'s retired bucket was `{ device: true }` too — wl-clipboard/xclip/xsel drive + // the desktop session's selection, and no other Linux cell has one. + expect(binding.facts.operations.readClipboard.available).toBe(desktop); + expect(binding.facts.operations.writeClipboard.available).toBe(desktop); + expect(binding.operations.readClipboard).toBeTypeOf(desktop ? 'function' : 'undefined'); + expect(binding.operations.writeClipboard).toBeTypeOf(desktop ? 'function' : 'undefined'); for (const operation of [ 'setOrientation', 'tvRemote', diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 5f8f43949c..2e22582a5d 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -14,48 +14,24 @@ import { availableApplicationLifecycleOperations, } from '@agent-device/contracts/application-lifecycle-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; -import { - bindElementTextRuntime, - elementTextRuntimeOperationFacts, -} from '@agent-device/contracts/element-text-runtime'; -import { - bindLocalFocusInteractor, - focusRuntimeOperationFacts, -} from '@agent-device/contracts/focus-runtime'; +import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime'; +import { focusRuntimeOperationFacts } from '@agent-device/contracts/focus-runtime'; import { TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT } from '@agent-device/contracts/gesture-admission'; -import { - bindLocalGestureInteractor, - gestureRuntimeOperationFacts, -} from '@agent-device/contracts/gesture-runtime'; -import { - bindLocalScrollInteractor, - scrollRuntimeOperationFacts, -} from '@agent-device/contracts/scroll-runtime'; +import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; +import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; -import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; -import { - localRuntimeOwner, - sameRuntimeOwner, - whenAdmitted, -} from '@agent-device/contracts/platform-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; +import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; -import { - bindLocalScreenshotInteractor, - screenshotRuntimeOperationFacts, -} from '@agent-device/contracts/screenshot-runtime'; +import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; import { captureSnapshotSignal, snapshotRuntimeOperationFacts, type CaptureSnapshotInput, } from '@agent-device/contracts/snapshot-runtime'; -import { - bindLocalTypeTextInteractor, - typeTextRuntimeOperationFacts, -} from '@agent-device/contracts/type-text-runtime'; -import { - bindLocalTouchInteractor, - touchRuntimeOperationFacts, -} from '@agent-device/contracts/touch-runtime'; +import { typeTextRuntimeOperationFacts } from '@agent-device/contracts/type-text-runtime'; +import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { bindLinuxApplicationLifecycle } from './lifecycle.ts'; @@ -133,6 +109,10 @@ const homeKindUnavailable = unavailableLinuxRuntimeFact( 'unsupported-device-kind', 'home is supported only for the Linux desktop device.', ); +const clipboardKindUnavailable = unavailableLinuxRuntimeFact( + 'unsupported-device-kind', + 'clipboard is supported only for the Linux desktop device.', +); // `orientation`, `tv-remote`, and every keyboard action never carried a Linux capability bucket // at all (the retired descriptors declared `linux: {}`), so they are unavailable unconditionally. const linuxPlatformLeafUnavailable = unsupportedPlatformLeaf; @@ -167,16 +147,6 @@ export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformR ? linuxSnapshotOperations(host, request) : {}), ...linuxInteractionOperations(host, request, facts), - ...whenAdmitted(facts.operations.tapPoint, () => - bindLocalTouchInteractor({ - facts: facts.operations, - device: request.device, - signal: request.scope.signal, - resolveInteractor: host.localInteractors.resolve, - pause: async (milliseconds) => - await host.clock.sleep(milliseconds, request.scope.signal), - }), - ), }), [Symbol.asyncDispose]: async () => undefined, }) satisfies DeviceBinding; @@ -196,20 +166,11 @@ function linuxInteractionOperations( signal: request.scope.signal, resolveInteractor: host.localInteractors.resolve, }; - return { - ...(facts.operations.captureScreenshot.available - ? bindLocalScreenshotInteractor(resolver) - : {}), - ...(facts.operations.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}), - ...bindLocalGestureInteractor({ ...resolver, facts: facts.operations }), - ...(facts.operations.scrollDirection.available ? bindLocalScrollInteractor(resolver) : {}), - ...(facts.operations.typeText.available ? bindLocalTypeTextInteractor(resolver) : {}), - ...(facts.operations.readTextAtPoint.available ? bindElementTextRuntime(resolver) : {}), - ...bindAdmittedLocalInteractorOperations({ - ...resolver, - facts: facts.operations, - }), - }; + return bindLocalInteractorOperationSet({ + ...resolver, + facts: facts.operations, + pause: async (milliseconds) => await host.clock.sleep(milliseconds, request.scope.signal), + }); } function linuxFacts(device: DeviceInfo): RuntimeFacts { @@ -231,6 +192,8 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts home: homeKindUnavailable, orientation: linuxPlatformLeafUnavailable, tvRemote: linuxPlatformLeafUnavailable, + readClipboard: clipboardKindUnavailable, + writeClipboard: clipboardKindUnavailable, keyboardStatus: linuxPlatformLeafUnavailable, keyboardDismiss: linuxPlatformLeafUnavailable, keyboardEnter: linuxPlatformLeafUnavailable, @@ -285,6 +248,12 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // is the only Linux cell with a target to drive. ...backRuntimeOperationFacts({ back: linuxDesktopFact(device, backKindUnavailable) }), ...homeRuntimeOperationFacts({ home: linuxDesktopFact(device, homeKindUnavailable) }), + // Parity with the retired `clipboard` capability bucket (`{ device: true }`): wl-clipboard + // / xclip / xsel drive the desktop session's selection, and no other Linux cell has one. + ...clipboardRuntimeOperationFacts({ + read: linuxDesktopFact(device, clipboardKindUnavailable), + write: linuxDesktopFact(device, clipboardKindUnavailable), + }), }, }); } diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index d967c7bf0b..30ff570cd8 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -179,6 +179,15 @@ test.each([ }); expect(binding.operations[operation]).toBeUndefined(); } + // R55: Vega never carried a `clipboard` bucket either. + for (const operation of ['readClipboard', 'writeClipboard'] as const) { + expect(binding.facts.operations[operation]).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'clipboard is not supported on Vega OS.', + }); + expect(binding.operations[operation]).toBeUndefined(); + } expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 84ecbee93e..db243a59d6 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -131,6 +131,10 @@ const keyboardUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'keyboard is not supported on Vega OS.', ); +const clipboardUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'clipboard is not supported on Vega OS.', +); const backUnavailable = vegaUnavailable( 'unsupported-device-kind', 'back currently supports only Vega Virtual Devices.', @@ -167,6 +171,8 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts home: homeUnavailable, orientation: orientationUnavailable, tvRemote: tvRemoteUnavailable, + readClipboard: clipboardUnavailable, + writeClipboard: clipboardUnavailable, keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index accb5f5ca3..02c6889ec6 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -199,6 +199,21 @@ test.each([ }, ); +test('clipboard never carried a web capability bucket', async () => { + const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ + device, + intent: { kind: 'ordinary' }, + scope: scope(), + }); + for (const operation of ['readClipboard', 'writeClipboard'] as const) { + expect(binding.facts.operations[operation]).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations[operation]).toBeUndefined(); + } +}); + test('back/home/orientation/tv-remote/keyboard never carried a web capability bucket', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index a26c02bf0b..81cd9d8f91 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -48,6 +48,7 @@ import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime' import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -395,6 +396,12 @@ function webRuntimeFacts( dismiss: navigationUnavailable, enter: navigationUnavailable, }), + // The web backend never carried a `clipboard` capability bucket (`WEB_QUERY_COMMANDS` + // lists `audio` alone), so no clipboard cell was ever admitted here. + ...clipboardRuntimeOperationFacts({ + read: navigationUnavailable, + write: navigationUnavailable, + }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index b6506f4109..3a45956409 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -107,6 +107,8 @@ export function createLimrunPlatformRuntimeOwner( keyboardStatus: liveSessionUnavailable, keyboardDismiss: liveSessionUnavailable, keyboardEnter: liveSessionUnavailable, + readClipboard: liveSessionUnavailable, + writeClipboard: liveSessionUnavailable, readiness: liveSessionUnavailable, shutdown: liveSessionUnavailable, lifecycle: limrunLifecycleFacts(device, false), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 9f793f463a..4c50bc2dff 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -15,6 +15,7 @@ import { import { limrunInteractionOperationFacts, limrunKeyboardOperationFacts, + limrunClipboardOperationFacts, limrunNavigationOperationFacts, } from './interaction-operations.ts'; @@ -185,6 +186,7 @@ export function limrunAppLogFacts( ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), ...limrunNavigationOperationFacts(device), ...limrunKeyboardOperationFacts(device), + ...limrunClipboardOperationFacts(device), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -232,6 +234,7 @@ export function limrunAppLogRecoveryFacts( ...limrunInteractionOperationFacts(device, liveSessionUnavailable), ...limrunNavigationOperationFacts(device, liveSessionUnavailable), ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), + ...limrunClipboardOperationFacts(device, liveSessionUnavailable), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index ad0291234e..7cdb1d4ca9 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -2,7 +2,10 @@ import type { Interactor, RunnerContext } from '@agent-device/contracts/interact import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; -import { limrunNavigationOperationFacts } from './interaction-operations.ts'; +import { + limrunClipboardOperationFacts, + limrunNavigationOperationFacts, +} from './interaction-operations.ts'; const iosDevice: DeviceInfo = { platform: 'apple', @@ -48,6 +51,28 @@ test('the Android leg admits back/home/orientation and gates tv-remote on a real expect(tv.tvRemote).toEqual({ available: true }); }); +// R55: the Android leg reuses the local family's `createAndroidInteractor`, so `cmd clipboard +// get/set text` reaches the device exactly as it does locally; the iOS leg's own clipboard +// methods throw, so both halves stay unavailable there. +test('clipboard follows the same Android-reuse / iOS-refusal split its siblings do', () => { + const android = limrunClipboardOperationFacts(androidMobileDevice); + expect(android.readClipboard).toEqual({ available: true }); + expect(android.writeClipboard).toEqual({ available: true }); + + const ios = limrunClipboardOperationFacts(iosDevice); + const refusal = { + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', + }; + expect(ios.readClipboard).toEqual(refusal); + expect(ios.writeClipboard).toEqual(refusal); + + const stale = limrunClipboardOperationFacts(androidMobileDevice, liveSessionUnavailable); + expect(stale.readClipboard).toEqual(liveSessionUnavailable); + expect(stale.writeClipboard).toEqual(liveSessionUnavailable); +}); + test('the iOS leg admits back/orientation but explicitly refuses home and tv-remote', () => { const facts = limrunNavigationOperationFacts(iosDevice); expect(facts.back).toEqual({ available: true }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 3a104a843c..8b7fb7c53e 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -17,6 +17,7 @@ import { scrollRuntimeOperationFacts, } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { bindProviderScreenshotInteractor } from '@agent-device/contracts/screenshot-runtime'; @@ -120,6 +121,11 @@ const keyboardUnavailableIos = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose keyboard actions.', } as const); +const clipboardUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', +} as const); /** * The interactor-backed interaction cells a live Limrun session serves: everything here rides @@ -237,6 +243,22 @@ export function limrunNavigationOperationFacts( * interactor factory `limrunNavigationOperationFacts` above describes; the iOS leg has no tested * provider keyboard behavior, so it stays unavailable. */ +/** + * `clipboard` shares the split its siblings have: the Android leg rides + * `session.dependencies.android.createInteractor` — the SAME factory the local Android family + * binds, so `cmd clipboard get/set text` reaches the device exactly as it does locally — while + * the iOS leg's own `readClipboard`/`writeClipboard` throw, so both cells stay unavailable there + * and carry the interactor's wording. + */ +export function limrunClipboardOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? (device.platform === 'android' ? available : clipboardUnavailableIos); + return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: cell, write: cell }) }); +} + export function limrunKeyboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 4abf1453c2..465036b917 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -251,8 +251,15 @@ test('captures through only the active exact WebDriver interactor', async () => reason: 'unsupported-provider-mode', }); expect(binding.operations.readTextAtPoint).toBeUndefined(); - // back/home/orientation ride the same reachable interactor focus/type do. - for (const operation of ['back', 'home', 'setOrientation'] as const) { + // back/home/orientation and both clipboard halves ride the same reachable interactor + // focus/type do; the declared-capability gate stays inside the interactor. + for (const operation of [ + 'back', + 'home', + 'setOrientation', + 'readClipboard', + 'writeClipboard', + ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); } @@ -315,6 +322,8 @@ test.each([ expect(facts.operations.back.available).toBe(state.isSessionActive()); expect(facts.operations.home.available).toBe(state.isSessionActive()); expect(facts.operations.setOrientation.available).toBe(state.isSessionActive()); + expect(facts.operations.readClipboard.available).toBe(state.isSessionActive()); + expect(facts.operations.writeClipboard.available).toBe(state.isSessionActive()); for (const operation of [ 'tvRemote', 'keyboardStatus', diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 44f7723610..5ccbb1224e 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -24,6 +24,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; @@ -197,6 +198,18 @@ const keyboardUnavailable = Object.freeze({ hint: 'WebDriver provider runtimes do not expose keyboard actions.', } as const); +/** + * The interactor's own `readClipboard`/`writeClipboard` call `requireSupport('clipboard.read')` / + * `('clipboard.write')`, so a provider whose declared capability map refuses the extension still + * refuses at call time. This cell states the seam the same way `back`/`home` do: what the fact + * answers is whether this runtime has a reachable interactor to ask at all. + */ +const clipboardUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose clipboard access for this device.', +} as const); + const appStateUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -421,6 +434,8 @@ function webDriverFacts( keyboardStatus: inactiveSession, keyboardDismiss: inactiveSession, keyboardEnter: inactiveSession, + readClipboard: inactiveSession, + writeClipboard: inactiveSession, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: inactiveSession, prepareApplicationOpen: inactiveSession, @@ -455,6 +470,8 @@ function webDriverFacts( keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, + readClipboard: clipboardUnavailable, + writeClipboard: clipboardUnavailable, lifecycle: webDriverLifecycleFacts(device), }); // Both capture cells need the same reachability: an interactor this provider can drive, on a @@ -519,6 +536,12 @@ function webDriverFacts( dismiss: keyboardUnavailable, enter: keyboardUnavailable, }), + // Clipboard rides the same reachable interactor `back`/`home` do; the declared-capability + // gate stays inside the interactor, where it already lives. + ...clipboardRuntimeOperationFacts({ + read: interactorCell(reachable, clipboardUnavailable), + write: interactorCell(reachable, clipboardUnavailable), + }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ensureReady: available, bootTarget: available, diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 2e27769f8d..ae131986b1 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -68,6 +68,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/capture', '@agent-device/contracts/click-button', '@agent-device/contracts/client', + '@agent-device/contracts/clipboard-runtime', '@agent-device/contracts/command', '@agent-device/contracts/command-platform-execution', '@agent-device/contracts/device', @@ -91,6 +92,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/interactor-operation-catalog', '@agent-device/contracts/interactor-types', '@agent-device/contracts/keyboard-runtime', + '@agent-device/contracts/local-interactor-operation-set', '@agent-device/contracts/logs-runtime-plan', '@agent-device/contracts/navigation', '@agent-device/contracts/network-runtime', diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 05dfeac696..9022368795 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -30,6 +30,7 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * leaves follow: back at R42, home at R43, orientation at R44, tv-remote at R45, and the * action-selected keyboard at R46. * The gesture cluster follows the touch leaves: gesture at R52, scroll at R53, swipe at R54. + * Wave 6 closure starts at R55 clipboard. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -1075,8 +1076,46 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, }, }, + { + rule: 'R55 clipboard-runtime-cutover', + command: 'clipboard', + subject: 'device clipboard', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its `core/dispatch.ts` handler. The daemon route function was + // renamed to `handleSessionClipboardCommand` when it moved out of the over-budget + // `handlers/session.ts`, so this name is now genuinely absent from production rather than + // shadowed by a surviving namesake. + routeNames: ['handleClipboardCommand'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy clipboard support or hint closure', + }, + runtimeTypeNames: ['ClipboardRuntimeOperations'], + operations: { names: ['readClipboard', 'writeClipboard'] }, + singularExecution: { + // `clipboard` is action-selected (R35's lesson): the session route resolves exactly one of + // the two operations per request, binds once, and never both together. + routes: ['resolveBoundClipboardRuntime'], + operations: ['readClipboard', 'writeClipboard'], + operationOwners: { + readClipboard: ['executeClipboardRead'], + writeClipboard: ['executeClipboardWrite'], + }, + }, + extensions: [clipboardRetiredDispatchProjectionProof], + }, ]; +function clipboardRetiredDispatchProjectionProof( + sources: ReadonlyMap, +): UnruledViolation[] { + return retiredDispatchProjectionViolations(sources, 'clipboard'); +} + function snapshotRetiredDispatchProjectionProof( sources: ReadonlyMap, ): UnruledViolation[] { diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts index fc329f821d..c78639acd8 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts @@ -59,16 +59,12 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef ? undefined : 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.'; // `home`/`keyboard`/`orientation`/`tv-remote` are gone from this table (R42/R43/R44/R45/R46 -// retired their AppleOS-table-reading closures along with their descriptor capability buckets); -// their per-AppleOS admission now lives as owner facts in `packages/platform-apple/src/runtime.ts`. +// retired their AppleOS-table-reading closures along with their descriptor capability buckets), +// and `clipboard` left with R55; their per-AppleOS admission now lives as owner facts in +// `packages/platform-apple/src/runtime.ts` and its `system/`, `navigation/` siblings. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, 'app-switcher': isNotMacOs, - clipboard: (device) => - device.platform === 'android' || - device.platform === 'linux' || - isMacOs(device) || - device.kind === 'simulator', alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index 23c9c4159d..4ef214bd9f 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -144,7 +144,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/facades/divergence.ts': 3, 'packages/contracts/src/facades/interaction.ts': 25, 'packages/contracts/src/facades/observability.ts': 7, - 'packages/contracts/src/facades/platform.ts': 51, + 'packages/contracts/src/facades/platform.ts': 52, 'packages/contracts/src/facades/progress.ts': 1, 'packages/contracts/src/facades/recording.ts': 3, 'packages/contracts/src/facades/remote.ts': 2, @@ -168,7 +168,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/platform-module.ts': 5, 'packages/contracts/src/platform-runtime-host.ts': 1, 'packages/contracts/src/platform-runtime-operations.ts': 2, - 'packages/contracts/src/platform-runtime-unavailable.ts': 23, + 'packages/contracts/src/platform-runtime-unavailable.ts': 24, 'packages/contracts/src/platform-runtime.ts': 6, 'packages/contracts/src/record-runtime-cutover.ts': 7, 'packages/contracts/src/react-native-overlay.ts': 1, @@ -243,8 +243,14 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // Added by #1993 (device-inventory context moved out of core). 'packages/contracts/src/back-runtime.ts': 4, + // Added by Wave 6 R55: the clipboard facet, and the local interaction set Android and Linux + // used to hold a byte-identical copy of each. The set is its own module rather than part of + // the interactor catalog so the catalog's closure stays leaf-thin -- every module the set + // pulls in is one its two consumers already evaluate. + 'packages/contracts/src/clipboard-runtime.ts': 4, + 'packages/contracts/src/local-interactor-operation-set.ts': 22, 'packages/contracts/src/home-runtime.ts': 4, - 'packages/contracts/src/interactor-operation-catalog.ts': 9, + 'packages/contracts/src/interactor-operation-catalog.ts': 10, 'packages/contracts/src/keyboard-runtime.ts': 4, 'packages/contracts/src/orientation-runtime.ts': 4, 'packages/contracts/src/tv-remote-runtime.ts': 4, @@ -275,7 +281,7 @@ export const HUB_BUDGETS: Readonly> = Object.freeze({ // the same PR added and only a source checkout reaches, and which therefore loads on demand // (`resolveLocalDaemonCodeSignature`) rather than appearing here. 'src/cli.ts': 365, - 'src/platform-runtime.ts': 39, + 'src/platform-runtime.ts': 40, 'src/core/dispatch.ts': 83, 'src/core/capabilities.ts': 75, 'src/core/command-descriptor/registry.ts': 66, diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 2af8e143b2..21670d1589 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -62,6 +62,8 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, }); /** Default facts for tests that are unrelated to application lifecycle commands. */ diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 07f4eb4f48..901b33f548 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -104,7 +104,7 @@ test('device capability matrix stays consistent across shared command groups', ( ], }, { - commands: ['settings', 'clipboard'], + commands: ['settings'], checks: [ { device: iosSimulator, expected: true, label: 'on iOS sim' }, { device: iosDevice, expected: false, label: 'on iOS device' }, @@ -293,11 +293,13 @@ test('web supports only the initial browser interaction slice', () => { 'fill', 'focus', 'find', - // `gesture` and `swipe` (R42/R44) join the migrated commands here for the same reason - // `focus`, `find`, `screenshot`, `scroll`, `snapshot`, `type` and `wait` already do: a - // command whose admission comes from exact owner facts carries no capability-matrix row, - // and a command with no row is not decided by this matrix at all. The web owner refuses - // every gesture tier — `platform-web/src/runtime.test.ts` is where that cell is pinned. + // `clipboard` (R55), `gesture` and `swipe` (R42/R44) join the migrated commands here for + // the same reason `focus`, `find`, `screenshot`, `scroll`, `snapshot`, `type` and `wait` + // already do: a command whose admission comes from exact owner facts carries no + // capability-matrix row, and a command with no row is not decided by this matrix at all. + // The web owner refuses every gesture tier and both clipboard halves — + // `platform-web/src/runtime.test.ts` is where those cells are pinned. + 'clipboard', 'gesture', 'get', 'hover', @@ -313,7 +315,7 @@ test('web supports only the initial browser interaction slice', () => { [{ device: webDevice, expected: true, label: 'on web' }], ); assertCommandSupport( - ['alert', 'app-switcher', 'clipboard', 'perf', 'settings', 'trigger-app-event'], + ['alert', 'app-switcher', 'perf', 'settings', 'trigger-app-event'], [{ device: webDevice, expected: false, label: 'on web' }], ); assertCommandSupport( diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 107d229087..dc8436a17f 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -126,11 +126,6 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, 'app-switcher': isNotMacOs, - clipboard: (device) => - device.platform === 'android' || - device.platform === 'linux' || - isMacOs(device) || - device.kind === 'simulator', alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 7ef469a2a6..ec2d44f2fa 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -53,6 +53,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.boot, PUBLIC_COMMANDS.capabilities, + PUBLIC_COMMANDS.clipboard, PUBLIC_COMMANDS.close, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.diff, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 093feb946a..dc29dce933 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -38,6 +38,7 @@ import { gestureRuntimePlanUses, homeRuntimeUse, hoverRuntimeUses, + clipboardRuntimePlanUses, keyboardRuntimePlanUses, longPressRuntimeUses, orientationRuntimeUse, @@ -240,7 +241,6 @@ function readOnlySubactionRecordingEffect( const APPLE_SIM_AND_DEVICE = { simulator: true, device: true }; const ANDROID_ALL = { emulator: true, device: true, unknown: true }; -const LINUX_DEVICE = { device: true }; const LINUX_NONE = {}; // --------------------------------------------------------------------------- @@ -747,18 +747,15 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', + // R55 retires this command's capability bucket and its `dispatch` leaf together: admission is + // whichever action-selected fact (`readClipboard`/`writeClipboard`) the parsed subcommand + // names, and the only execution is that one bound operation (ADR 0019 §9). recordsSessionAction: true, recordingEffect: 'observes-app', daemon: { route: 'session', refFrameEffect: 'preserve' }, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_DEVICE, - }, - dispatch: {}, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: clipboardRuntimePlanUses }, }, { name: 'keyboard', diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 8012791884..6d3bb3b108 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -122,7 +122,6 @@ const DISPATCH_HANDLERS: Record = { await interactor.appSwitcher(); return { action: 'app-switcher', ...successText('Opened app switcher') }; }, - clipboard: ({ interactor, positionals }) => handleClipboardCommand(interactor, positionals), settings: ({ device, interactor, positionals, context }) => handleSettingsCommand(device, interactor, positionals, context), }; @@ -176,33 +175,6 @@ async function handleTriggerAppEventCommand( }; } -async function handleClipboardCommand( - interactor: Interactor, - positionals: string[], -): Promise> { - const action = (positionals[0] ?? '').toLowerCase(); - if (action !== 'read' && action !== 'write') { - throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); - } - if (action === 'read') { - if (positionals.length !== 1) { - throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments'); - } - const text = await interactor.readClipboard(); - return { action, text }; - } - if (positionals.length < 2) { - throw new AppError('INVALID_ARGS', 'clipboard write requires text (use "" to clear clipboard)'); - } - const text = positionals.slice(1).join(' '); - await interactor.writeClipboard(text); - return { - action, - textLength: Array.from(text).length, - ...successText('Clipboard updated'), - }; -} - async function handleSettingsCommand( device: DeviceInfo, interactor: Interactor, diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index ba148bfab0..2a5f5f8d3e 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -383,6 +383,8 @@ function sourceRuntimeFacts( keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts index b742438900..343ca6631f 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -170,6 +170,8 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts, + device: DeviceInfo = androidDevice, +) { + const readClipboard = vi.fn(async () => 'copied text'); + const writeClipboard = vi.fn(async () => undefined); + const runtimeFacts: RuntimeFacts = { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: clipboardRuntimeOperationFacts( + facts, + ) as RuntimeFacts['operations'], + }; + const binding = { + device, + owner: localRuntimeOwner(device.platform as never), + facts: runtimeFacts, + operations: { readClipboard, writeClipboard }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => runtimeFacts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + return { readClipboard, writeClipboard, inspectFacts, bindDevice }; +} + +function request(positionals: string[]) { + const sessionName = 'clipboard-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + mockResolveTargetDevice.mockResolvedValue(androidDevice); + return { + sessionName, + sessionStore, + req: { + token: 't', + session: sessionName, + command: 'clipboard', + positionals, + flags: {}, + }, + logPath: '/tmp/daemon.log', + } as const; +} + +test('clipboard read admits clipboardReadUse and reports the platform-labelled text', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + + expect(response.ok).toBe(true); + expect(response.ok && response.data).toEqual({ + platform: 'android', + action: 'read', + text: 'copied text', + }); + expect(spies.bindDevice).toHaveBeenCalledWith(androidDevice, clipboardReadUse); + expect(spies.writeClipboard).not.toHaveBeenCalled(); +}); + +test('clipboard write joins its positionals and reports the code-point length', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ + ...request(['write', 'hello', 'wörld']), + ...spies, + }); + + expect(response.ok).toBe(true); + expect(spies.writeClipboard).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello wörld' }), + ); + expect(response.ok && response.data).toMatchObject({ + platform: 'android', + action: 'write', + textLength: 11, + }); + expect(spies.bindDevice).toHaveBeenCalledWith(androidDevice, clipboardWriteUse); + expect(spies.readClipboard).not.toHaveBeenCalled(); +}); + +// `clipboard write ""` clears the clipboard, so an empty string is a value the command must +// forward — not the missing argument the length check would otherwise reject. +test('clipboard write forwards an explicit empty string as a clear', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ ...request(['write', '']), ...spies }); + + expect(response.ok).toBe(true); + expect(spies.writeClipboard).toHaveBeenCalledWith(expect.objectContaining({ text: '' })); + expect(response.ok && response.data).toMatchObject({ action: 'write', textLength: 0 }); +}); + +// ADR 0019 §9: the parsed subcommand selects exactly one use, so a request inspects facts once +// and binds once — never both halves, and never a bind per operation. +test.each([{ positionals: ['read'] }, { positionals: ['write', 'text'] }])( + 'clipboard $positionals.0 inspects facts once and binds once', + async ({ positionals }) => { + const spies = harness({ read: available, write: available }); + await handleSessionClipboardCommand({ ...request(positionals), ...spies }); + + expect(spies.inspectFacts).toHaveBeenCalledTimes(1); + expect(spies.bindDevice).toHaveBeenCalledTimes(1); + }, +); + +test('an unadmitted cell refuses with the retired capability gate wording and its owner hint', async () => { + const spies = harness({ read: unavailable, write: unavailable }); + const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toBe('clipboard is not supported on this device'); + expect(response.error.hint).toBe(unavailable.hint); + } + expect(spies.bindDevice).not.toHaveBeenCalled(); +}); + +// Read and write are separate cells: a write-only refusal must not take the read down with it. +test('a write-only refusal still admits the read', async () => { + const spies = harness({ read: available, write: unavailable }); + + const write = await handleSessionClipboardCommand({ + ...request(['write', 'text']), + ...spies, + }); + expect(write.ok).toBe(false); + + const read = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + expect(read.ok).toBe(true); +}); + +test('an unknown subcommand fails before any device is resolved', async () => { + const spies = harness({ read: available, write: available }); + const response = await handleSessionClipboardCommand({ ...request(['paste']), ...spies }); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toBe('clipboard requires a subcommand: read or write'); + } + expect(spies.inspectFacts).not.toHaveBeenCalled(); +}); + +// The retired leaf validated argument counts inside `dispatchCommand`, downstream of admission, +// so an over-argued read on an admitted device still reaches that same rejection. +test('clipboard read rejects extra arguments', async () => { + const spies = harness({ read: available, write: available }); + await expect( + handleSessionClipboardCommand({ ...request(['read', 'extra']), ...spies }), + ).rejects.toThrow('clipboard read does not accept additional arguments'); +}); + +test('clipboard write with no text argument reports how to clear instead', async () => { + const spies = harness({ read: available, write: available }); + await expect(handleSessionClipboardCommand({ ...request(['write']), ...spies })).rejects.toThrow( + 'clipboard write requires text (use "" to clear clipboard)', + ); +}); diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index e01ca666c7..c2607b4fbc 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -175,6 +175,8 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts { keyboardStatus: { available: false, reason: 'owner-capability-missing' }, keyboardDismiss: { available: false, reason: 'owner-capability-missing' }, keyboardEnter: { available: false, reason: 'owner-capability-missing' }, + readClipboard: { available: false, reason: 'owner-capability-missing' }, + writeClipboard: { available: false, reason: 'owner-capability-missing' }, readiness: { available: false, reason: 'unsupported-device-kind' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, @@ -159,6 +161,8 @@ test('appstate rejects web before Android app-state backend dispatch', async () keyboardStatus: { available: false, reason: 'unsupported-platform-leaf' }, keyboardDismiss: { available: false, reason: 'unsupported-platform-leaf' }, keyboardEnter: { available: false, reason: 'unsupported-platform-leaf' }, + readClipboard: { available: false, reason: 'unsupported-platform-leaf' }, + writeClipboard: { available: false, reason: 'unsupported-platform-leaf' }, readiness: { available: false, reason: 'unsupported-platform-leaf' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts new file mode 100644 index 0000000000..e275295ae2 --- /dev/null +++ b/src/daemon/handlers/session-clipboard.ts @@ -0,0 +1,167 @@ +import type { + ClipboardReadInput, + ClipboardWriteInput, +} from '@agent-device/contracts/clipboard-runtime'; +import { + clipboardReadUse, + clipboardWriteUse, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { contextFromFlags, type DaemonCommandContext } from '../context.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { admitRuntimeUse, type RuntimeAdmissionBindings } from '../runtime-admission.ts'; +import { runtimeExecutionFromContext } from '../snapshot-runtime-capture-input.ts'; +import { successText } from '../../utils/success-text.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; +import { recordSessionAction } from './handler-utils.ts'; +import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; + +type ClipboardAction = 'read' | 'write'; + +/** + * What the admit-then-bind step reports: either the refusal an unadmitted cell produced — nothing + * has touched the device yet — or the one bound invocation to run. Mirrors + * {@link ResolvedKeyboardExecution}'s shape for the same reason: the plan is chosen from the + * parsed action, and only the chosen leg ever binds. + */ +type ResolvedClipboardExecution = + | Readonly<{ ok: false; response: DaemonFailureResponse }> + | Readonly<{ + ok: true; + execute: (context: DaemonCommandContext) => Promise>; + }>; + +/** + * `clipboard `, on the same parse the retired leaf used. The subcommand is read + * before any device is resolved, exactly as the retired daemon handler did, so a typo still + * fails as `INVALID_ARGS` without waking a device. + */ +function readClipboardAction(positionals: readonly string[]): ClipboardAction | undefined { + const action = (positionals[0] ?? '').toLowerCase(); + return action === 'read' || action === 'write' ? action : undefined; +} + +function clipboardInput(context: DaemonCommandContext): ClipboardReadInput { + return { + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * `clipboard read`. The argument check stays inside the bound execution rather than moving ahead + * of admission: the retired leaf validated it in `dispatchCommand`, downstream of the capability + * gate, so an over-argued read on an unsupported device must still report the unsupported cell. + */ +async function executeClipboardRead( + runtime: BoundDeviceRuntime, + context: DaemonCommandContext, + positionals: readonly string[], +): Promise> { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments'); + } + const text = await runtime.operations.readClipboard(clipboardInput(context)); + return { action: 'read', text }; +} + +/** `clipboard write `; `""` clears, so an empty string is a value, not a missing argument. */ +async function executeClipboardWrite( + runtime: BoundDeviceRuntime, + context: DaemonCommandContext, + positionals: readonly string[], +): Promise> { + if (positionals.length < 2) { + throw new AppError('INVALID_ARGS', 'clipboard write requires text (use "" to clear clipboard)'); + } + const text = positionals.slice(1).join(' '); + const input: ClipboardWriteInput = { ...clipboardInput(context), text }; + await runtime.operations.writeClipboard(input); + return { + action: 'write', + textLength: Array.from(text).length, + ...successText('Clipboard updated'), + }; +} + +/** + * The one place `clipboard` reaches a device (ADR 0019 §9). Exactly one action-selected use is + * admitted and bound — `read` or `write`, never both. Each branch admits its own literal use, so + * the bound runtime narrows from that instantiation rather than from an assertion. Both name the + * bare command in their refusal: the retired `requireCommandSupported('clipboard', device)` gate + * refused per command, not per subcommand, and the wording is parity-pinned. + */ +async function resolveBoundClipboardRuntime( + params: Readonly<{ + device: DeviceInfo; + action: ClipboardAction; + positionals: readonly string[]; + }> & + RuntimeAdmissionBindings, +): Promise { + const { device, action, positionals, inspectFacts, bindDevice } = params; + if (action === 'read') { + const admission = await admitRuntimeUse({ + command: 'clipboard', + device, + use: clipboardReadUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (context) => executeClipboardRead(runtime, context, positionals) }; + } + const admission = await admitRuntimeUse({ + command: 'clipboard', + device, + use: clipboardWriteUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (context) => executeClipboardWrite(runtime, context, positionals) }; +} + +export async function handleSessionClipboardCommand(params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; +}): Promise { + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.clipboard, session, flags); + if (guard) return guard; + + const positionals = req.positionals ?? []; + const action = readClipboardAction(positionals); + if (!action) { + return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); + } + + const device = await resolveCommandDevice({ session, flags, ensureReady: true }); + const bound = await resolveBoundClipboardRuntime({ + device, + action, + positionals, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return bound.response; + + const result = await bound.execute( + contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + ); + recordSessionAction(sessionStore, session, req, req.command, result); + return { ok: true, data: { platform: publicPlatformString(device), ...result } }; +} diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 0278088e03..68d4ee8873 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,14 +1,7 @@ -import { dispatchCommand } from '../../core/dispatch.ts'; -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; -import { publicPlatformString } from '@agent-device/kernel/device'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { contextFromFlags } from '../context.ts'; import { handleReleaseMaterializedPathsCommand } from './session-app-source-deployment.ts'; -import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; -import { errorResponse, requireCommandSupported } from './response.ts'; -import { recordSessionAction } from './handler-utils.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; import { requireRuntimeBinding, requireRuntimeFacts } from './session-runtime-admission.ts'; import { handleOpenCommand } from './session-open.ts'; @@ -25,6 +18,7 @@ import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; import { handleSessionScriptPublication } from './session-script-publication.ts'; +import { handleSessionClipboardCommand } from './session-clipboard.ts'; import { handleDoctorCommand } from './session-doctor.ts'; import { handlePrepareCommand } from './session-prepare.ts'; import type { DescriptorSessionRouteCommandName } from '../../core/command-descriptor/registry.ts'; @@ -40,45 +34,6 @@ import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; import type { ScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; import type { PlatformRequestScope } from '@agent-device/contracts/platform'; -// fallow-ignore-next-line complexity -async function handleClipboardCommand(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; -}): Promise { - const { req, sessionName, logPath, sessionStore } = params; - const session = sessionStore.get(sessionName); - const flags = req.flags ?? {}; - const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.clipboard, session, flags); - if (guard) return guard; - - const action = (req.positionals?.[0] ?? '').toLowerCase(); - if (action !== 'read' && action !== 'write') { - return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); - } - - const device = await resolveCommandDevice({ - session, - flags, - ensureReady: true, - }); - const unsupported = requireCommandSupported(PUBLIC_COMMANDS.clipboard, device); - if (unsupported) return unsupported; - - const result = await dispatchCommand( - device, - PUBLIC_COMMANDS.clipboard, - req.positionals ?? [], - req.flags?.out, - { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - }, - ); - recordSessionAction(sessionStore, session, req, req.command, result ?? {}); - return { ok: true, data: { platform: publicPlatformString(device), ...(result ?? {}) } }; -} - export type SessionCommandInput = { req: DaemonRequest; sessionName: string; @@ -231,8 +186,15 @@ const SESSION_COMMAND_HANDLER_IMPLS = { inspectFacts, bindDevice, }), - clipboard: async ({ req, sessionName, logPath, sessionStore }) => - await handleClipboardCommand({ req, sessionName, logPath, sessionStore }), + clipboard: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => + await handleSessionClipboardCommand({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + }), keyboard: handleKeyboardCommand, perf: handleSessionObservabilityCommandGroup, logs: handleSessionObservabilityCommandGroup, diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index 662401f957..afc3eda426 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -58,6 +58,8 @@ describe('composed platform runtime gateway', () => { keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, touch: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, @@ -148,6 +150,8 @@ describe('composed platform runtime gateway', () => { keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/platform-runtime-gateway.ts b/src/platform-runtime-gateway.ts index 1619a1bdc6..bed349397a 100644 --- a/src/platform-runtime-gateway.ts +++ b/src/platform-runtime-gateway.ts @@ -322,6 +322,8 @@ function unavailableProviderBinding( keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }); } @@ -353,6 +355,8 @@ function unavailableProviderFacts(runtime: ProviderDeviceRuntime, device: Device keyboardStatus: unavailable, keyboardDismiss: unavailable, keyboardEnter: unavailable, + readClipboard: unavailable, + writeClipboard: unavailable, readiness: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }, diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 6ca251704a..5f9310b1ea 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -33,7 +33,8 @@ const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.kind !== 'device' || device.iosPhysicalDeviceBackend !== 'xctest'; -// The Apple arm shared by `clipboard`/`settings` (was `macos || simulator`): +// The Apple arm `settings` uses (was `macos || simulator`); `clipboard` retired its own copy of +// this closure with R55, which restated it as the Apple owner's clipboard facts: // reachable on the macOS host directly, on every other Apple OS only on the simulator. // Off Apple this preserves the trailing `device.kind === 'simulator'` term verbatim. const supportsHostOrSimulatorSurface = (device: DeviceInfo): boolean => { @@ -55,10 +56,6 @@ const supportsAlertSurface = (device: DeviceInfo): boolean => const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> = { [PUBLIC_COMMANDS.perf]: supportsCoreDevicePhysicalOperation, [PUBLIC_COMMANDS.appSwitcher]: supportsAppAndDeviceLifecycle, - [PUBLIC_COMMANDS.clipboard]: (device) => - device.platform === 'android' || - device.platform === 'linux' || - supportsHostOrSimulatorSurface(device), [PUBLIC_COMMANDS.alert]: supportsAlertSurface, [PUBLIC_COMMANDS.settings]: (device) => device.platform === 'android' || supportsHostOrSimulatorSurface(device), diff --git a/test/integration/smoke-web-platform-coverage.test.ts b/test/integration/smoke-web-platform-coverage.test.ts index 6ac8268f4a..ea3608edf3 100644 --- a/test/integration/smoke-web-platform-coverage.test.ts +++ b/test/integration/smoke-web-platform-coverage.test.ts @@ -41,8 +41,8 @@ test('web coverage exhaustively classifies the public catalog', () => { test('web coverage report has the expected classification counts', () => { assert.deepEqual(WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 7, - contract: 34, + capabilityDenial: 6, + contract: 35, gap: 1, live: 12, total: 54, diff --git a/test/integration/web-e2e/coverage-manifest.ts b/test/integration/web-e2e/coverage-manifest.ts index c06b30aac0..f99b588581 100644 --- a/test/integration/web-e2e/coverage-manifest.ts +++ b/test/integration/web-e2e/coverage-manifest.ts @@ -138,7 +138,11 @@ export const WEB_PLATFORM_COVERAGE = { [C.test]: gap( "Web test-suite execution has no executable web evidence: ReplayTestPlatform = Exclude structurally excludes web from the declared-platform filter, so `test --platform web` can never select a script (proven by a regression test in session-command-replay.test.ts) — that is evidence of what the command cannot do, not that it works on web", ), - [C.clipboard]: denial('Web capability model rejects native clipboard operations'), + [C.clipboard]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard never carried a web capability bucket', + 'the exact-owner runtime fact rejects native clipboard operations on the web target', + ), [C.keyboard]: contract( 'packages/platform-web/src/runtime.test.ts', 'back/home/orientation/tv-remote/keyboard never carried a web capability bucket', From 09bbd1c1c1b3cbe64656de742ce932c6cca09e93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 17:47:00 +0000 Subject: [PATCH 02/16] refactor(daemon): migrate app-switcher onto request-bound runtimes (R56) Wave 6 unit 2 of the ADR 0019 platform-free daemon migration (#1739). `app-switcher` leaves the legacy dispatch projection: admission is the owner's `appSwitcher` fact and the only execution is that one bound operation, resolved by the generic route alongside back/home/orientation/tv-remote. - new `@agent-device/contracts/app-switcher-runtime` facet on the shared `Interactor` seam, bound through the interactor catalog. - Apple states one springboard reading for `home` and `app-switcher` (parity: the retired `supportsAppAndDeviceLifecycle` closure gated both off the same per-AppleOS row, so macOS and watchOS refuse); Android admits every real kind; HarmonyOS admits both kinds, restating the retired overlay membership; Linux/Vega/web refuse. Limrun reuses the local Android interactor and refuses on iOS; WebDriver rides interactor reachability. - retires the `core/dispatch.ts` arm, the capability bucket, the `dispatch` leaf, `HARMONYOS_SUPPORTED_COMMANDS` membership, the Apple plugin closure, and the now-readerless `appAndDeviceLifecycle` row in the per-AppleOS table. - router tests that used `app-switcher` as their legacy-dispatch stand-in move onto bound operations; the typed-error `supportedOn` test moves to `perf`, the one command that keeps a capability-matrix row after this wave. Cutover row R56 with its retirement, admission-member and single-bind claims. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/contracts/package.json | 4 + .../src/app-switcher-runtime.test.ts | 70 +++++++++++++++ .../contracts/src/app-switcher-runtime.ts | 85 +++++++++++++++++++ packages/contracts/src/facades/platform.ts | 1 + .../src/interactor-operation-catalog.ts | 7 ++ .../src/platform-runtime-operations.ts | 3 + .../src/platform-runtime-unavailable.test.ts | 1 + .../src/platform-runtime-unavailable.ts | 7 ++ packages/platform-android/src/runtime.test.ts | 6 +- packages/platform-android/src/runtime.ts | 4 + .../platform-apple/src/navigation/runtime.ts | 29 ++++++- packages/platform-apple/src/runtime.test.ts | 15 ++-- .../platform-harmonyos/src/runtime.test.ts | 11 ++- packages/platform-harmonyos/src/runtime.ts | 5 ++ packages/platform-linux/src/runtime.test.ts | 3 + packages/platform-linux/src/runtime.ts | 3 + packages/platform-vega/src/runtime.test.ts | 8 +- packages/platform-vega/src/runtime.ts | 5 ++ packages/platform-web/src/runtime.test.ts | 4 +- packages/platform-web/src/runtime.ts | 2 + .../provider-limrun/src/app-log-runtime.ts | 1 + packages/provider-limrun/src/facts-runtime.ts | 3 + .../src/interaction-operations.test.ts | 16 ++++ .../src/interaction-operations.ts | 21 +++++ .../src/platform-runtime.test.ts | 2 + .../src/platform-runtime.ts | 17 ++++ scripts/layering/package-boundaries.test.ts | 1 + .../layering/runtime-command-cutover-table.ts | 34 +++++++- .../apple-os-capability-table-parity.test.ts | 2 - src/__tests__/eager-closure-budgets.ts | 19 +++-- .../test-utils/runtime-operation-facts.ts | 1 + src/core/__tests__/capabilities.test.ts | 9 +- .../capability-plugin-routing-parity.test.ts | 4 +- src/core/capabilities.ts | 2 +- .../__tests__/parity.test.ts | 4 + src/core/command-descriptor/registry.ts | 12 ++- src/core/dispatch.ts | 4 - .../__tests__/request-router-cost.test.ts | 59 +++++++------ .../request-router-lock-policy.test.ts | 24 +++--- .../request-router-replay-scope.test.ts | 14 ++- .../request-router-response-level.test.ts | 38 ++++----- .../request-router-typed-error.test.ts | 35 ++++++-- .../__tests__/test-device-runtime-gateway.ts | 10 +++ src/daemon/app-switcher-runtime.ts | 51 +++++++++++ src/daemon/generic-runtime-execution.ts | 7 ++ .../handlers/__tests__/install-source.test.ts | 1 + .../interaction-get-runtime-fixture.ts | 1 + .../session-capabilities.fixtures.ts | 1 + .../__tests__/session-command-harness.ts | 1 + .../handlers/__tests__/session-state.test.ts | 2 + src/platform-runtime-gateway.test.ts | 2 + src/platform-runtime-gateway.ts | 2 + src/platforms/apple/capabilities.ts | 31 +++---- src/platforms/apple/plugin.ts | 9 -- .../ios-simulator-e2e/coverage-manifest.ts | 2 +- .../linux-e2e/coverage-manifest.ts | 7 +- .../macos-e2e/coverage-manifest.ts | 25 +++--- test/integration/smoke-linux-coverage.test.ts | 8 +- test/integration/smoke-macos-coverage.test.ts | 4 +- .../smoke-web-platform-coverage.test.ts | 4 +- .../integration/tvos-e2e/coverage-manifest.ts | 6 +- test/integration/web-e2e/coverage-manifest.ts | 8 +- 62 files changed, 595 insertions(+), 182 deletions(-) create mode 100644 packages/contracts/src/app-switcher-runtime.test.ts create mode 100644 packages/contracts/src/app-switcher-runtime.ts create mode 100644 src/daemon/app-switcher-runtime.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 4de72841ba..e8569bb177 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -47,6 +47,10 @@ "types": "./src/app-state-runtime.ts", "default": "./src/app-state-runtime.ts" }, + "./app-switcher-runtime": { + "types": "./src/app-switcher-runtime.ts", + "default": "./src/app-switcher-runtime.ts" + }, "./apple-multitouch-support": { "types": "./src/apple-multitouch-support.ts", "default": "./src/apple-multitouch-support.ts" diff --git a/packages/contracts/src/app-switcher-runtime.test.ts b/packages/contracts/src/app-switcher-runtime.test.ts new file mode 100644 index 0000000000..9222d5eeb5 --- /dev/null +++ b/packages/contracts/src/app-switcher-runtime.test.ts @@ -0,0 +1,70 @@ +import { expect, test, vi } from 'vitest'; +import { + appSwitcherRuntimeOperationFacts, + bindLocalAppSwitcherInteractor, + bindProviderAppSwitcherInteractor, +} from './app-switcher-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact app-switcher operation fact catalog', () => { + const appSwitcher = { available: true } as const; + expect(appSwitcherRuntimeOperationFacts({ appSwitcher })).toEqual({ appSwitcher }); +}); + +test('a local binding drives the interactor with the request runner context', async () => { + const appSwitcher = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalAppSwitcherInteractor({ device, signal, resolveInteractor }); + await operations.appSwitcher({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'switcher-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'switcher-1', + appBundleId: 'com.example.app', + signal, + }); + expect(appSwitcher).toHaveBeenCalledOnce(); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderAppSwitcherInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.appSwitcher({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const appSwitcher = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); + + const operations = bindLocalAppSwitcherInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.appSwitcher({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(appSwitcher).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/app-switcher-runtime.ts b/packages/contracts/src/app-switcher-runtime.ts new file mode 100644 index 0000000000..a1cadd1188 --- /dev/null +++ b/packages/contracts/src/app-switcher-runtime.ts @@ -0,0 +1,85 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one app-switcher reveal: no arguments, so only runner metadata travels — + * the same shape `home` carries, because it is the same springboard surface. + */ +export type AppSwitcherInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** The reveal returns nothing; the retired leaf discarded whatever the interactor answered. */ +export type AppSwitcherRuntimeOperations = Readonly<{ + appSwitcher(input: AppSwitcherInput): Promise; +}>; + +export type AppSwitcherRuntimeOperationFacts = Readonly<{ + appSwitcher: RuntimeOperationFact; +}>; + +export function appSwitcherRuntimeOperationFacts( + input: Readonly<{ appSwitcher: RuntimeOperationFact }>, +): AppSwitcherRuntimeOperationFacts { + return Object.freeze({ appSwitcher: input.appSwitcher }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the reveal itself. + */ +function bindAppSwitcher( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): AppSwitcherRuntimeOperations { + return Object.freeze({ + appSwitcher: async (input: AppSwitcherInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + await interactor.appSwitcher(); + }, + }); +} + +export type LocalAppSwitcherInteractorResolver = LocalInteractorOperationResolver; + +export function bindLocalAppSwitcherInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalAppSwitcherInteractorResolver; + }>, +): AppSwitcherRuntimeOperations { + return bindAppSwitcher(params.signal, localInteractorSource(params)); +} + +export type ProviderAppSwitcherInteractorResolver = ProviderInteractorOperationResolver; + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderAppSwitcherInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderAppSwitcherInteractorResolver; + }>, +): AppSwitcherRuntimeOperations { + return bindAppSwitcher( + params.signal, + providerInteractorSource({ ...params, operation: 'app-switcher' }), + ); +} diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index ff917a4e78..18d90c3c3f 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -245,6 +245,7 @@ export { keyboardStatusUse, keyboardDismissUse, keyboardEnterUse, + appSwitcherRuntimeUse, clipboardRuntimePlanUses, clipboardReadUse, clipboardWriteUse, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 258bbd38d9..c5908f175e 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -1,5 +1,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { bindLocalBackInteractor, bindProviderBackInteractor } from './back-runtime.ts'; +import { + bindLocalAppSwitcherInteractor, + bindProviderAppSwitcherInteractor, +} from './app-switcher-runtime.ts'; import { bindLocalClipboardReadInteractor, bindLocalClipboardWriteInteractor, @@ -52,6 +56,7 @@ const CATALOG_INTERACTOR_OPERATIONS = [ 'keyboardEnter', 'readClipboard', 'writeClipboard', + 'appSwitcher', ] as const; export type CatalogInteractorOperation = (typeof CATALOG_INTERACTOR_OPERATIONS)[number]; @@ -82,6 +87,7 @@ const LOCAL_BINDERS: Readonly< keyboardEnter: bindLocalKeyboardEnterInteractor, readClipboard: bindLocalClipboardReadInteractor, writeClipboard: bindLocalClipboardWriteInteractor, + appSwitcher: bindLocalAppSwitcherInteractor, }); const PROVIDER_BINDERS: Readonly< @@ -99,6 +105,7 @@ const PROVIDER_BINDERS: Readonly< keyboardEnter: bindProviderKeyboardEnterInteractor, readClipboard: bindProviderClipboardReadInteractor, writeClipboard: bindProviderClipboardWriteInteractor, + appSwitcher: bindProviderAppSwitcherInteractor, }); /** diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 614e56a814..3a4cd50b3b 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -29,6 +29,7 @@ import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; +import type { AppSwitcherRuntimeOperations } from './app-switcher-runtime.ts'; import type { TouchRuntimeOperations } from './touch-runtime.ts'; import type { DeviceReadinessRuntimeHost, @@ -74,6 +75,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & TvRemoteRuntimeOperations & KeyboardRuntimeOperations & ClipboardRuntimeOperations & + AppSwitcherRuntimeOperations & TouchRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & @@ -103,6 +105,7 @@ export const tvRemoteRuntimeUse = defineUse({ required: ['tvRemote'] }); export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); +export const appSwitcherRuntimeUse = defineUse({ required: ['appSwitcher'] }); export const clipboardReadUse = defineUse({ required: ['readClipboard'] }); export const clipboardWriteUse = defineUse({ required: ['writeClipboard'] }); export const tapPointUse = defineUse({ required: ['tapPoint'] }); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 2e9c6c4e09..ad734f4a88 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -46,6 +46,7 @@ test('generic unavailable binding preserves exact provider ownership and mode', keyboardEnter: { available: false, reason: 'unsupported-provider-mode' }, readClipboard: { available: false, reason: 'unsupported-provider-mode' }, writeClipboard: { available: false, reason: 'unsupported-provider-mode' }, + appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, lifecycle, }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index a895e4c361..3e54ec2dda 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -25,6 +25,7 @@ import { orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; +import { appSwitcherRuntimeOperationFacts } from './app-switcher-runtime.ts'; import { touchRuntimeOperationFacts } from './touch-runtime.ts'; /** @@ -56,6 +57,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ keyboardEnter: RuntimeOperationUnavailability; readClipboard: RuntimeOperationUnavailability; writeClipboard: RuntimeOperationUnavailability; + appSwitcher: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -114,6 +116,7 @@ export function createUnavailablePlatformRuntimeFacts( keyboardEnter, readClipboard, writeClipboard, + appSwitcher, readiness, shutdown, lifecycle, @@ -184,6 +187,7 @@ export function createUnavailablePlatformRuntimeFacts( enter: keyboardEnter, }), ...clipboardRuntimeOperationFacts({ read: readClipboard, write: writeClipboard }), + ...appSwitcherRuntimeOperationFacts({ appSwitcher }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -236,6 +240,9 @@ function freezeUnavailableFacts( // and write can diverge on a provider whose extension exposes only one half. readClipboard: Object.freeze({ ...unavailable.readClipboard }), writeClipboard: Object.freeze({ ...unavailable.writeClipboard }), + // The app switcher is the springboard surface `home` drives, and differs by owner the same + // way: an owner states it for its exact leaf rather than inheriting a sibling's gap. + appSwitcher: Object.freeze({ ...unavailable.appSwitcher }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), }); } diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 550c516612..da28d5141b 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -293,7 +293,7 @@ test('admits Android tv-remote only for a real TV target', async () => { // Android admission closure, so `cmd clipboard get/set text` is admitted on every real kind and // refused only on the synthetic `simulator` row the bucket never listed. (`unknown` is the // bucket's name for a device with no declared kind, which `DeviceKind` cannot express.) -test('admits both clipboard halves on every real Android kind', async () => { +test('admits both clipboard halves and the app switcher on every real Android kind', async () => { for (const kind of ['emulator', 'device'] as const) { const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ device: { ...device, id: `android-${kind}`, kind }, @@ -308,6 +308,9 @@ test('admits both clipboard halves on every real Android kind', async () => { expect(binding.facts.operations.writeClipboard).toEqual({ available: true }); expect(binding.operations.readClipboard).toBeTypeOf('function'); expect(binding.operations.writeClipboard).toBeTypeOf('function'); + // R56: `app-switcher` shares `home`'s cell — one `input keyevent` on every real kind. + expect(binding.facts.operations.appSwitcher).toEqual({ available: true }); + expect(binding.operations.appSwitcher).toBeTypeOf('function'); } }); @@ -334,6 +337,7 @@ test('the synthetic Android simulator cell refuses back/home/orientation/keyboar 'keyboardEnter', 'readClipboard', 'writeClipboard', + 'appSwitcher', ] as const) { expect(facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index d4f4b180a9..f122c6712d 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -34,6 +34,7 @@ import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtim import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -273,6 +274,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor }), ...backRuntimeOperationFacts({ back: androidTouchFact(device) }), ...homeRuntimeOperationFacts({ home: androidTouchFact(device) }), + // `app-switcher` shares `home`'s cell: one `input keyevent`, admitted wherever the + // retired `ANDROID_ALL` bucket admitted it. + ...appSwitcherRuntimeOperationFacts({ appSwitcher: androidTouchFact(device) }), ...orientationRuntimeOperationFacts({ orientation: androidTouchFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: androidTvRemoteFact(device) }), // The only owner with a live IME status read; dismiss/enter share every other diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts index 4825cef422..d7898a50ef 100644 --- a/packages/platform-apple/src/navigation/runtime.ts +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -1,3 +1,4 @@ +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; @@ -42,8 +43,22 @@ const homeLifecycleUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', } as const); -function appleHomeFact(device: DeviceInfo): RuntimeOperationFact { - if (device.kind !== 'simulator' && device.kind !== 'device') return homeKindUnavailable; +const appSwitcherKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'app-switcher is supported on Apple simulators and physical devices.', +} as const); +/** + * `home` and `app-switcher` are one springboard reading, and sharing it here is parity rather + * than convenience: the retired `supportsAppAndDeviceLifecycle` closure gated both off the same + * per-AppleOS `appAndDeviceLifecycle` row. Only the kind-refusal hint differs, so each caller + * supplies its own. + */ +function appleSpringboardFact( + device: DeviceInfo, + kindUnavailable: RuntimeOperationFact, +): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return kindUnavailable; const os = resolveDeviceAppleOs(device); return os === 'macos' || os === 'watchos' ? homeLifecycleUnavailable : available; } @@ -109,11 +124,17 @@ function appleKeyboardEnterFact(device: DeviceInfo): RuntimeOperationFact { return appleMobileInputEligible(device) ? available : keyboardCellUnavailable; } -/** The navigation cells: back, home, orientation, tv-remote, and keyboard status/dismiss/enter. */ +/** + * The navigation cells: back, home, app-switcher, orientation, tv-remote, and keyboard + * status/dismiss/enter. + */ export function appleNavigationFacts(device: DeviceInfo) { return Object.freeze({ ...backRuntimeOperationFacts({ back: appleBackFact(device) }), - ...homeRuntimeOperationFacts({ home: appleHomeFact(device) }), + ...homeRuntimeOperationFacts({ home: appleSpringboardFact(device, homeKindUnavailable) }), + ...appSwitcherRuntimeOperationFacts({ + appSwitcher: appleSpringboardFact(device, appSwitcherKindUnavailable), + }), ...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }), ...keyboardRuntimeOperationFacts({ diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 58d726bd1a..9af0ec905d 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -154,7 +154,7 @@ function expectAppleSnapshotAvailability( } test.each(Object.entries(leaves))( - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', async (_name, device) => { const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ device, @@ -192,13 +192,12 @@ function expectNavigationAndKeyboardFacts( // constructibility. expectOperationAvailability(binding, 'back', device.appleOs !== 'watchos'); - // home is unavailable on macOS, which drives an already-running app with no springboard, and - // on watchOS. - expectOperationAvailability( - binding, - 'home', - device.appleOs !== 'macos' && device.appleOs !== 'watchos', - ); + // home and app-switcher share one springboard reading (R56): both are unavailable on macOS, + // which drives an already-running app with no springboard, and on watchOS. That is parity, not + // convenience — the retired `supportsAppAndDeviceLifecycle` closure gated both off the same row. + const springboard = device.appleOs !== 'macos' && device.appleOs !== 'watchos'; + expectOperationAvailability(binding, 'home', springboard); + expectOperationAvailability(binding, 'appSwitcher', springboard); // orientation and keyboard dismiss/enter share mobile-input eligibility: unavailable on tvOS // (focus-only XCUIRemote navigation), macOS (an AppKit desktop host), and watchOS. diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 9fec18ab55..0076309a02 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -82,7 +82,15 @@ test.each([ expect(binding.operations.focusPoint).toBeTypeOf('function'); expect(binding.operations.typeText).toBeTypeOf('function'); // back/home/keyboard dismiss+enter share focus's hdc-driven gate on both real kinds. - for (const operation of ['back', 'home', 'keyboardDismiss', 'keyboardEnter'] as const) { + for (const operation of [ + 'back', + 'home', + // R56 parity: the retired `HARMONYOS_SUPPORTED_COMMANDS` overlay listed `app-switcher` for + // both HarmonyOS kinds, and it rides the same hdc key input `home` does. + 'appSwitcher', + 'keyboardDismiss', + 'keyboardEnter', + ] as const) { expect(facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); } @@ -161,6 +169,7 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( 'keyboardEnter', 'readClipboard', 'writeClipboard', + 'appSwitcher', ] as const) { expect(binding.facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 1ab6f59809..a4bc5c3eaf 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -25,6 +25,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; @@ -271,6 +272,10 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), ...backRuntimeOperationFacts({ back: harmonyFocusFact(device) }), ...homeRuntimeOperationFacts({ home: harmonyFocusFact(device) }), + // Parity with the retired `HARMONYOS_SUPPORTED_COMMANDS` overlay, which listed + // `app-switcher` for both HarmonyOS kinds: it rides the same hdc-driven key input `home` + // does, so it shares that cell. + ...appSwitcherRuntimeOperationFacts({ appSwitcher: harmonyFocusFact(device) }), ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), ...keyboardRuntimeOperationFacts({ diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 7c0127d1e7..4f2e22ed43 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -201,6 +201,9 @@ function expectLinuxNavigationAndKeyboardFacts( 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', + // R56: the Linux interactor's own `appSwitcher` throws, and the retired descriptor declared + // `linux: {}`, so no Linux cell was ever admitted. + 'appSwitcher', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 2e22582a5d..43f855aa57 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -194,6 +194,9 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts tvRemote: linuxPlatformLeafUnavailable, readClipboard: clipboardKindUnavailable, writeClipboard: clipboardKindUnavailable, + // The Linux interactor's own `appSwitcher` throws unsupported, and the retired descriptor + // declared `linux: {}`, so no Linux cell was ever admitted. + appSwitcher: linuxPlatformLeafUnavailable, keyboardStatus: linuxPlatformLeafUnavailable, keyboardDismiss: linuxPlatformLeafUnavailable, keyboardEnter: linuxPlatformLeafUnavailable, diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index 30ff570cd8..c4173efc18 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -179,7 +179,7 @@ test.each([ }); expect(binding.operations[operation]).toBeUndefined(); } - // R55: Vega never carried a `clipboard` bucket either. + // R55/R56: Vega never carried a `clipboard` or `app-switcher` bucket either. for (const operation of ['readClipboard', 'writeClipboard'] as const) { expect(binding.facts.operations[operation]).toMatchObject({ available: false, @@ -188,6 +188,12 @@ test.each([ }); expect(binding.operations[operation]).toBeUndefined(); } + expect(binding.facts.operations.appSwitcher).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'app-switcher is not supported on Vega OS.', + }); + expect(binding.operations.appSwitcher).toBeUndefined(); expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index db243a59d6..a68b72d7fc 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -131,6 +131,10 @@ const keyboardUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'keyboard is not supported on Vega OS.', ); +const appSwitcherUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'app-switcher is not supported on Vega OS.', +); const clipboardUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'clipboard is not supported on Vega OS.', @@ -173,6 +177,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts tvRemote: tvRemoteUnavailable, readClipboard: clipboardUnavailable, writeClipboard: clipboardUnavailable, + appSwitcher: appSwitcherUnavailable, keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 02c6889ec6..569a774b19 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -199,13 +199,13 @@ test.each([ }, ); -test('clipboard never carried a web capability bucket', async () => { +test('clipboard and the app switcher never carried a web capability bucket', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, intent: { kind: 'ordinary' }, scope: scope(), }); - for (const operation of ['readClipboard', 'writeClipboard'] as const) { + for (const operation of ['readClipboard', 'writeClipboard', 'appSwitcher'] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, reason: 'unsupported-platform-leaf', diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 81cd9d8f91..7a14b2b774 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -48,6 +48,7 @@ import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime' import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -402,6 +403,7 @@ function webRuntimeFacts( read: navigationUnavailable, write: navigationUnavailable, }), + ...appSwitcherRuntimeOperationFacts({ appSwitcher: navigationUnavailable }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 3a45956409..aa8089e0fb 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -109,6 +109,7 @@ export function createLimrunPlatformRuntimeOwner( keyboardEnter: liveSessionUnavailable, readClipboard: liveSessionUnavailable, writeClipboard: liveSessionUnavailable, + appSwitcher: liveSessionUnavailable, readiness: liveSessionUnavailable, shutdown: liveSessionUnavailable, lifecycle: limrunLifecycleFacts(device, false), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 4c50bc2dff..1c5fe380e2 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -15,6 +15,7 @@ import { import { limrunInteractionOperationFacts, limrunKeyboardOperationFacts, + limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, } from './interaction-operations.ts'; @@ -187,6 +188,7 @@ export function limrunAppLogFacts( ...limrunNavigationOperationFacts(device), ...limrunKeyboardOperationFacts(device), ...limrunClipboardOperationFacts(device), + ...limrunAppSwitcherOperationFacts(device), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -235,6 +237,7 @@ export function limrunAppLogRecoveryFacts( ...limrunNavigationOperationFacts(device, liveSessionUnavailable), ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), ...limrunClipboardOperationFacts(device, liveSessionUnavailable), + ...limrunAppSwitcherOperationFacts(device, liveSessionUnavailable), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 7cdb1d4ca9..14db5b165e 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -3,6 +3,7 @@ import { bindAdmittedProviderInteractorOperations } from '@agent-device/contract import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; import { + limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, } from './interaction-operations.ts'; @@ -73,6 +74,21 @@ test('clipboard follows the same Android-reuse / iOS-refusal split its siblings expect(stale.writeClipboard).toEqual(liveSessionUnavailable); }); +// R56: same Android-reuse / iOS-refusal split. +test('app-switcher rides the Android interactor and is refused on the iOS leg', () => { + expect(limrunAppSwitcherOperationFacts(androidMobileDevice).appSwitcher).toEqual({ + available: true, + }); + expect(limrunAppSwitcherOperationFacts(iosDevice).appSwitcher).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose app switcher yet.', + }); + expect( + limrunAppSwitcherOperationFacts(androidMobileDevice, liveSessionUnavailable).appSwitcher, + ).toEqual(liveSessionUnavailable); +}); + test('the iOS leg admits back/orientation but explicitly refuses home and tv-remote', () => { const facts = limrunNavigationOperationFacts(iosDevice); expect(facts.back).toEqual({ available: true }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 8b7fb7c53e..f8a4be9d06 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -17,6 +17,7 @@ import { scrollRuntimeOperationFacts, } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; @@ -126,6 +127,11 @@ const clipboardUnavailableIos = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', } as const); +const appSwitcherUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose app switcher yet.', +} as const); /** * The interactor-backed interaction cells a live Limrun session serves: everything here rides @@ -259,6 +265,21 @@ export function limrunClipboardOperationFacts( return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: cell, write: cell }) }); } +/** + * `app-switcher` splits the same way its siblings do: the Android leg rides + * `session.dependencies.android.createInteractor` -- the SAME factory the local Android family + * binds -- while the iOS leg's own `appSwitcher` throws. + */ +export function limrunAppSwitcherOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? + (device.platform === 'android' ? available : appSwitcherUnavailableIos); + return Object.freeze({ ...appSwitcherRuntimeOperationFacts({ appSwitcher: cell }) }); +} + export function limrunKeyboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 465036b917..65ef7f60c8 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -259,6 +259,7 @@ test('captures through only the active exact WebDriver interactor', async () => 'setOrientation', 'readClipboard', 'writeClipboard', + 'appSwitcher', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); @@ -324,6 +325,7 @@ test.each([ expect(facts.operations.setOrientation.available).toBe(state.isSessionActive()); expect(facts.operations.readClipboard.available).toBe(state.isSessionActive()); expect(facts.operations.writeClipboard.available).toBe(state.isSessionActive()); + expect(facts.operations.appSwitcher.available).toBe(state.isSessionActive()); for (const operation of [ 'tvRemote', 'keyboardStatus', diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 5ccbb1224e..c36630271f 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -24,6 +24,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; @@ -210,6 +211,17 @@ const clipboardUnavailable = Object.freeze({ hint: 'This WebDriver provider runtime does not expose clipboard access for this device.', } as const); +/** + * `appSwitcher` calls `requireSupport('appSwitcher')` inside the interactor, so a provider whose + * declared capability map refuses the button still refuses at call time. This cell states the + * seam the same way `back`/`home` do: whether this runtime has a reachable interactor to ask. + */ +const appSwitcherUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose the app switcher for this device.', +} as const); + const appStateUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -436,6 +448,7 @@ function webDriverFacts( keyboardEnter: inactiveSession, readClipboard: inactiveSession, writeClipboard: inactiveSession, + appSwitcher: inactiveSession, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: inactiveSession, prepareApplicationOpen: inactiveSession, @@ -472,6 +485,7 @@ function webDriverFacts( keyboardEnter: keyboardUnavailable, readClipboard: clipboardUnavailable, writeClipboard: clipboardUnavailable, + appSwitcher: appSwitcherUnavailable, lifecycle: webDriverLifecycleFacts(device), }); // Both capture cells need the same reachability: an interactor this provider can drive, on a @@ -542,6 +556,9 @@ function webDriverFacts( read: interactorCell(reachable, clipboardUnavailable), write: interactorCell(reachable, clipboardUnavailable), }), + ...appSwitcherRuntimeOperationFacts({ + appSwitcher: interactorCell(reachable, appSwitcherUnavailable), + }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ensureReady: available, bootTarget: available, diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index ae131986b1..4cd1b9f0bc 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -56,6 +56,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/app-inventory-runtime', '@agent-device/contracts/app-log-runtime', '@agent-device/contracts/app-state-runtime', + '@agent-device/contracts/app-switcher-runtime', '@agent-device/contracts/apple-multitouch-support', '@agent-device/contracts/application-lifecycle-interaction', '@agent-device/contracts/application-lifecycle-runtime', diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 9022368795..311da87aeb 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -30,7 +30,7 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * leaves follow: back at R42, home at R43, orientation at R44, tv-remote at R45, and the * action-selected keyboard at R46. * The gesture cluster follows the touch leaves: gesture at R52, scroll at R53, swipe at R54. - * Wave 6 closure starts at R55 clipboard. + * Wave 6 closure starts at R55 clipboard, then R56 app-switcher. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -1108,8 +1108,40 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, extensions: [clipboardRetiredDispatchProjectionProof], }, + { + rule: 'R56 app-switcher-runtime-cutover', + command: 'app-switcher', + subject: 'app switcher reveal', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm; `app-switcher` had no dedicated named handler function to retire + // (its legacy body lived inline in the `DISPATCH_HANDLERS` literal). It also leaves the + // HarmonyOS overlay that granted it a capability bucket the descriptor never listed. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy app-switcher support or hint closure', + }, + runtimeTypeNames: ['AppSwitcherRuntimeOperations'], + operations: { names: ['appSwitcher'] }, + singularExecution: { + routes: ['dispatchGenericCommand'], + operations: ['appSwitcher'], + operationOwners: { appSwitcher: ['executeAppSwitcher'] }, + }, + extensions: [appSwitcherRetiredDispatchProjectionProof], + }, ]; +function appSwitcherRetiredDispatchProjectionProof( + sources: ReadonlyMap, +): UnruledViolation[] { + return retiredDispatchProjectionViolations(sources, 'app-switcher'); +} + function clipboardRetiredDispatchProjectionProof( sources: ReadonlyMap, ): UnruledViolation[] { diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts index c78639acd8..515f1892e5 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts @@ -44,7 +44,6 @@ registerBuiltinPlatformPlugins(); // AppleOS predicates and current backend-specific gates. This oracle stays independent // of the table it pins (mirrors capability-plugin-routing-parity.test.ts). // --------------------------------------------------------------------------- -const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => isMacOs(device) || device.kind === 'simulator'; const isIosOs = (device: DeviceInfo): boolean => @@ -64,7 +63,6 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef // `packages/platform-apple/src/runtime.ts` and its `system/`, `navigation/` siblings. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - 'app-switcher': isNotMacOs, alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index 4ef214bd9f..ec8fb1f44c 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -144,7 +144,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/facades/divergence.ts': 3, 'packages/contracts/src/facades/interaction.ts': 25, 'packages/contracts/src/facades/observability.ts': 7, - 'packages/contracts/src/facades/platform.ts': 52, + 'packages/contracts/src/facades/platform.ts': 53, 'packages/contracts/src/facades/progress.ts': 1, 'packages/contracts/src/facades/recording.ts': 3, 'packages/contracts/src/facades/remote.ts': 2, @@ -168,7 +168,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/platform-module.ts': 5, 'packages/contracts/src/platform-runtime-host.ts': 1, 'packages/contracts/src/platform-runtime-operations.ts': 2, - 'packages/contracts/src/platform-runtime-unavailable.ts': 24, + 'packages/contracts/src/platform-runtime-unavailable.ts': 25, 'packages/contracts/src/platform-runtime.ts': 6, 'packages/contracts/src/record-runtime-cutover.ts': 7, 'packages/contracts/src/react-native-overlay.ts': 1, @@ -243,14 +243,15 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // Added by #1993 (device-inventory context moved out of core). 'packages/contracts/src/back-runtime.ts': 4, - // Added by Wave 6 R55: the clipboard facet, and the local interaction set Android and Linux - // used to hold a byte-identical copy of each. The set is its own module rather than part of - // the interactor catalog so the catalog's closure stays leaf-thin -- every module the set - // pulls in is one its two consumers already evaluate. + // Added by Wave 6 R55/R56: the clipboard and app-switcher facets, and the local interaction set + // Android and Linux used to hold a byte-identical copy of each. The set is its own module + // rather than part of the interactor catalog so the catalog's closure stays leaf-thin -- every + // module the set pulls in is one its two consumers already evaluate. + 'packages/contracts/src/app-switcher-runtime.ts': 4, 'packages/contracts/src/clipboard-runtime.ts': 4, - 'packages/contracts/src/local-interactor-operation-set.ts': 22, + 'packages/contracts/src/local-interactor-operation-set.ts': 23, 'packages/contracts/src/home-runtime.ts': 4, - 'packages/contracts/src/interactor-operation-catalog.ts': 10, + 'packages/contracts/src/interactor-operation-catalog.ts': 11, 'packages/contracts/src/keyboard-runtime.ts': 4, 'packages/contracts/src/orientation-runtime.ts': 4, 'packages/contracts/src/tv-remote-runtime.ts': 4, @@ -281,7 +282,7 @@ export const HUB_BUDGETS: Readonly> = Object.freeze({ // the same PR added and only a source checkout reaches, and which therefore loads on demand // (`resolveLocalDaemonCodeSignature`) rather than appearing here. 'src/cli.ts': 365, - 'src/platform-runtime.ts': 40, + 'src/platform-runtime.ts': 41, 'src/core/dispatch.ts': 83, 'src/core/capabilities.ts': 75, 'src/core/command-descriptor/registry.ts': 66, diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 21670d1589..33b7109d51 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -64,6 +64,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre keyboardEnter: unavailable, readClipboard: unavailable, writeClipboard: unavailable, + appSwitcher: unavailable, }); /** Default facts for tests that are unrelated to application lifecycle commands. */ diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 901b33f548..75e41caf35 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -222,9 +222,11 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only ], [{ device: macOsDevice, expected: true, label: 'on macOS' }], ); + // R56 moved `app-switcher` off this matrix onto the Apple owner's springboard fact, which is + // where the macOS refusal now lives (`platform-apple/src/runtime.test.ts` pins that cell). assertCommandSupport( ['app-switcher'], - [{ device: macOsDevice, expected: false, label: 'on macOS' }], + [{ device: macOsDevice, expected: true, label: 'through runtime admission on macOS' }], ); }); @@ -280,7 +282,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported [{ device: linuxDevice, expected: true, label: 'on Linux' }], ); assertCommandSupport( - ['alert', 'app-switcher', 'perf', 'settings', 'trigger-app-event'], + ['alert', 'perf', 'settings', 'trigger-app-event'], [{ device: linuxDevice, expected: false, label: 'on Linux' }], ); }); @@ -299,6 +301,7 @@ test('web supports only the initial browser interaction slice', () => { // capability-matrix row, and a command with no row is not decided by this matrix at all. // The web owner refuses every gesture tier and both clipboard halves — // `platform-web/src/runtime.test.ts` is where those cells are pinned. + 'app-switcher', 'clipboard', 'gesture', 'get', @@ -315,7 +318,7 @@ test('web supports only the initial browser interaction slice', () => { [{ device: webDevice, expected: true, label: 'on web' }], ); assertCommandSupport( - ['alert', 'app-switcher', 'perf', 'settings', 'trigger-app-event'], + ['alert', 'perf', 'settings', 'trigger-app-event'], [{ device: webDevice, expected: false, label: 'on web' }], ); assertCommandSupport( diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index dc8436a17f..46e1b78762 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -100,7 +100,6 @@ const SAMPLE_DEVICES: DeviceInfo[] = [ // (b.2) Independent copies of the per-command supports()/unsupportedHint() // contracts. Kept in sync by hand so this oracle stays independent of production. // --------------------------------------------------------------------------- -const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => isMacOs(device) || device.kind === 'simulator'; const isIosOs = (device: DeviceInfo): boolean => @@ -125,7 +124,6 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef // gains/loses a closure (or whose closure body changes) breaks parity. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - 'app-switcher': isNotMacOs, alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), settings: (device) => @@ -233,7 +231,7 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', () // Runtime-backed navigation, keyboard, and touch commands dropped out of the matrix entirely: // capability buckets), so they are absent here — not because HarmonyOS admission changed, but // because there is no bucket left for `isCommandSupportedOnDevice` to consult at all. - assert.deepEqual(availableCommands, ['app-switcher', 'perf', 'settings']); + assert.deepEqual(availableCommands, ['perf', 'settings']); }); test('(b.2) unsupportedHint closures are verbatim across the full device matrix', () => { diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 189e64e2c2..dfaf43d076 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -30,7 +30,7 @@ export type CommandCapability = { const WEB_DEVICE: KindMatrix = { device: true }; const HARMONYOS_ALL: KindMatrix = { emulator: true, device: true }; -const HARMONYOS_SUPPORTED_COMMANDS = new Set(['perf', 'app-switcher', 'settings']); +const HARMONYOS_SUPPORTED_COMMANDS = new Set(['perf', 'settings']); const WEB_QUERY_COMMANDS = ['audio'] as const; const WEB_SUPPORTED_COMMANDS = new Set(WEB_QUERY_COMMANDS); // Built from the additive command-descriptor registry (ADR-0008, Phase 1 step 3). diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index ec2d44f2fa..f15e165d6f 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -48,6 +48,7 @@ const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSou const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.apps, + PUBLIC_COMMANDS.appSwitcher, PUBLIC_COMMANDS.artifacts, PUBLIC_COMMANDS.back, PUBLIC_COMMANDS.batch, @@ -232,6 +233,9 @@ test('generic route commands that reach platform dispatch declare the dispatch f PUBLIC_COMMANDS.home, PUBLIC_COMMANDS.orientation, PUBLIC_COMMANDS.tvRemote, + // R56 retired app-switcher's dispatch leaf with its capability bucket: the bound + // `appSwitcher` operation is its only execution. + PUBLIC_COMMANDS.appSwitcher, ]); for (const descriptor of commandDescriptors) { diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index dc29dce933..1967ac51e7 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -38,6 +38,7 @@ import { gestureRuntimePlanUses, homeRuntimeUse, hoverRuntimeUses, + appSwitcherRuntimeUse, clipboardRuntimePlanUses, keyboardRuntimePlanUses, longPressRuntimeUses, @@ -1367,6 +1368,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, frameworkTier: 'extended', + // R56 retires this command's capability bucket, its `dispatch` leaf, and its HarmonyOS + // overlay membership together: admission is the owner's `appSwitcher` fact, and the only + // execution is that one bound operation (ADR 0019 §9). recordsSessionAction: true, recordingEffect: 'mutates-app', // ADR 0014: app-switcher previously reached the generic daemon leaf via the @@ -1375,15 +1379,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ // covered by the completeness gate; this is the escape hatch the ADR calls // out, not a new specialized route. daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [appSwitcherRuntimeUse] }, }, { name: 'install-from-source', diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 6d3bb3b108..95fe253d27 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -118,10 +118,6 @@ type DispatchHandler = (args: DispatchHandlerArgs) => Promise = { 'trigger-app-event': ({ device, interactor, positionals, context }) => handleTriggerAppEventCommand(device, interactor, positionals, context), - 'app-switcher': async ({ interactor }) => { - await interactor.appSwitcher(); - return { action: 'app-switcher', ...successText('Opened app switcher') }; - }, settings: ({ device, interactor, positionals, context }) => handleSettingsCommand(device, interactor, positionals, context), }; diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index 59c38deb09..f5694d8d68 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -3,11 +3,6 @@ import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -16,20 +11,22 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from './test-device-runtime-gateway.ts'; +import { + createRequestHandler, + gestureDeviceRuntimeGateway, + gestureRuntimeSpies, +} from './test-device-runtime-gateway.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; -const mockDispatch = vi.mocked(dispatchCommand); - -// A representative, structurally rich daemon payload so the parity assertions -// exercise nested objects/arrays rather than a trivial flat record. +// A representative, structurally rich owner payload so the parity assertions exercise nested +// objects/arrays rather than a trivial flat record. `scroll` is the subject because it reaches a +// bound operation (R53) whose return this file controls, and its daemon leaf spreads that return +// into `response.data` — which is what the cost graft reads. const REPRESENTATIVE_PAYLOAD = { - message: 'app-switcher-ok', detail: { nested: true, count: 3 }, items: [1, 2, 3], } as const; @@ -61,6 +58,7 @@ function makeHandler(sessionStore = makeSessionStore('agent-device-router-cost-' leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', + deviceRuntimeGateway: gestureDeviceRuntimeGateway, }), }; } @@ -69,16 +67,18 @@ function baseRequest(overrides: Partial = {}): DaemonRequest { return { token: 'test-token', session: 'cost-session', - command: 'app-switcher', - positionals: [], + command: 'scroll', + positionals: ['down'], flags: {}, ...overrides, }; } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async () => ({ ...REPRESENTATIVE_PAYLOAD })); + gestureRuntimeSpies.scrollDirection.mockReset(); + gestureRuntimeSpies.scrollDirection.mockImplementation(async () => ({ + ...REPRESENTATIVE_PAYLOAD, + })); }); test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-identical and no cost', async () => { @@ -98,7 +98,8 @@ test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-id expect('cost' in (respMetaWithoutCost.data ?? {})).toBe(false); } if (respNoMeta.ok) { - expect(respNoMeta.data).toEqual(REPRESENTATIVE_PAYLOAD); + // The owner payload passes through beside scroll's own result fields. + expect(respNoMeta.data).toMatchObject(REPRESENTATIVE_PAYLOAD); } }); @@ -133,9 +134,9 @@ test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scop const { sessionStore, handler } = makeHandler(); sessionStore.set('cost-session', makeIosSession('cost-session')); - // The mocked dispatch runs inside the request's diagnostics scope, so emitting - // here is equivalent to the runner-session emitting these phases per round-trip. - mockDispatch.mockImplementation(async () => { + // The bound operation runs inside the request's diagnostics scope, so emitting here is + // equivalent to the runner-session emitting these phases per round-trip. + gestureRuntimeSpies.scrollDirection.mockImplementation(async () => { emitDiagnostic({ phase: 'ios_runner_readiness_preflight' }); // real round-trip emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip @@ -158,7 +159,7 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar // The nodeCount read is command-agnostic: it triggers on any response.data that // carries a `nodes` array (in production only the snapshot node-tree commands - // do). We drive it through the generic dispatch path with a node-bearing payload. + // do). We drive it through a bound operation returning a node-bearing payload. const nodeTreePayload = { nodes: [ { ref: 'e1', type: 'Button', label: 'A' }, @@ -167,7 +168,9 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar ], truncated: false, }; - mockDispatch.mockImplementation(async () => structuredClone(nodeTreePayload)); + gestureRuntimeSpies.scrollDirection.mockImplementation(async () => + structuredClone(nodeTreePayload), + ); const respFlagOff = await handler(baseRequest()); const respFlagOn = await handler(baseRequest({ meta: { includeCost: true } })); @@ -181,14 +184,14 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar // a pure read of the existing `nodes` array, never a mutation of the payload. delete respFlagOn.data?.cost; expect(respFlagOn.data).toEqual(respFlagOff.data); - expect(respFlagOff.data).toEqual(nodeTreePayload); + expect(respFlagOff.data).toMatchObject(nodeTreePayload); }); test('(d) error path: a failing request with includeCost:true produces NO cost', async () => { const { sessionStore, handler } = makeHandler(); sessionStore.set('cost-session', makeIosSession('cost-session')); - // Conflicting explicit selector under a reject lock policy fails before dispatch. + // Conflicting explicit selector under a reject lock policy fails before the bound execution. const failingRequest = baseRequest({ flags: { udid: 'SIM-999' }, meta: { lockPolicy: 'reject', includeCost: true }, @@ -208,15 +211,15 @@ test('(d) error path: a failing request with includeCost:true produces NO cost', test('(e) boundary survival: meta.includeCost survives commandRpcParamsSchema parsing', () => { const parsed = commandRpcParamsSchema.parse({ - command: 'app-switcher', - positionals: [], + command: 'scroll', + positionals: ['down'], meta: { includeCost: true }, }); expect(parsed.meta?.includeCost).toBe(true); const parsedOff = commandRpcParamsSchema.parse({ - command: 'app-switcher', - positionals: [], + command: 'scroll', + positionals: ['down'], meta: {}, }); expect(parsedOff.meta?.includeCost).toBeUndefined(); diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 6ba338bc6c..9b6b61f562 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -20,7 +20,11 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); import { dispatchCommand } from '../../core/dispatch.ts'; -import { createRequestHandler } from './test-device-runtime-gateway.ts'; +import { + createRequestHandler, + lifecycleDeviceRuntimeGateway, + systemRuntimeSpies, +} from './test-device-runtime-gateway.ts'; import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -503,17 +507,14 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a test('direct daemon requests apply strip lock policy for existing sessions before dispatch', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('qa-ios', makeIosSession('qa-ios')); - let dispatchCalls = 0; - mockDispatch.mockImplementation(async () => { - dispatchCalls += 1; - return {}; - }); + systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -532,7 +533,7 @@ test('direct daemon requests apply strip lock policy for existing sessions befor }, }); - expect(dispatchCalls).toBe(1); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(response.ok).toBe(true); const action = sessionStore.get('qa-ios')?.actions.at(-1); expect(action?.flags.platform).toBe('ios'); @@ -587,17 +588,14 @@ test('batch preserves tenant-scoped session names across nested requests', async tenantId: 'tenant-a', runId: 'run-1', }); - let dispatchCalls = 0; - mockDispatch.mockImplementation(async () => { - dispatchCalls += 1; - return {}; - }); + systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), token: 'test-token', sessionStore, leaseRegistry, + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -619,6 +617,6 @@ test('batch preserves tenant-scoped session names across nested requests', async }); expect(response.ok).toBe(true); - expect(dispatchCalls).toBe(1); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(sessionStore.get('tenant-a:default')?.actions.at(-1)?.command).toBe('app-switcher'); }); diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index e329461674..a0b576e44c 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -37,6 +37,7 @@ import { createRequestHandler, gestureRuntimeSpies, lifecycleDeviceRuntimeGateway, + systemRuntimeSpies, } from './test-device-runtime-gateway.ts'; import { ensureDeviceReady } from '../device-ready.ts'; // Readiness is package-owned; hold the open at the fixture's platform-neutral readiness gate. @@ -49,6 +50,7 @@ const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); beforeEach(() => { gestureRuntimeSpies.scrollDirection.mockClear(); + systemRuntimeSpies.appSwitcher.mockClear(); mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); @@ -87,9 +89,10 @@ test('replay runs active-session actions inside the parent request provider scop }); expect(response).toMatchObject({ ok: true }); - // `app-switcher` is still a legacy dispatch leaf; `scroll down` reaches its bound operation - // instead (R53), so the flow's two actions land on two different execution paths. - expect(mockDispatch).toHaveBeenCalledTimes(1); + // Both actions now reach bound operations — `app-switcher` through R56, `scroll down` through + // R53 — so the flow drives the runtime gateway twice and the legacy dispatcher not at all. + expect(mockDispatch).not.toHaveBeenCalled(); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(appleRunnerProvider).toHaveBeenCalledTimes(1); }); @@ -122,7 +125,10 @@ test('replay routes session-changing actions through the full request path', asy }); expect(response).toMatchObject({ ok: true }); - expect(mockDispatch).toHaveBeenCalledTimes(1); + // `app-switcher` reaches its bound operation since R56; `runtime set` mutates session state and + // never dispatched, so the legacy dispatcher sees neither action. + expect(mockDispatch).not.toHaveBeenCalled(); + expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(appleRunnerProvider).toHaveBeenCalledTimes(2); }); diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index f63b542393..8f32857914 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -3,11 +3,6 @@ import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -16,10 +11,10 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -// Register a test view on a command that flows through the (mocked) generic -// dispatch path, so the router graft mechanics can be exercised end to end -// without the real snapshot handler (the actual snapshot view is unit-tested in -// response-views.test.ts). +// Register a test view on a command whose payload this file controls end to end, so the router +// graft mechanics can be exercised without the real snapshot handler (the actual snapshot view is +// unit-tested in response-views.test.ts). `app-switcher` reaches a bound operation since R56, so +// its payload is the one the daemon leaf builds rather than a mocked dispatcher's. vi.mock('../response-views.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -28,26 +23,25 @@ vi.mock('../response-views.ts', async (importOriginal) => { ...actual.RESPONSE_VIEWS, 'app-switcher': (data: Record, level: string) => level === 'digest' - ? { appSwitcherDigest: true, hadItems: Array.isArray(data.items) } + ? { appSwitcherDigest: true, hadAction: data.action === 'app-switcher' } : data, }, }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler, - gestureDeviceRuntimeGateway, gestureRuntimeSpies, + lifecycleDeviceRuntimeGateway, } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { commandRpcParamsSchema } from '@agent-device/kernel/contracts'; -const mockDispatch = vi.mocked(dispatchCommand); - -const REPRESENTATIVE_PAYLOAD = { message: 'app-switcher-ok', items: [1, 2, 3] } as const; +const REPRESENTATIVE_PAYLOAD = { message: 'scroll-ok', items: [1, 2, 3] } as const; +/** What the bound `app-switcher` leaf answers; this file's registered view digests it. */ +const APP_SWITCHER_PAYLOAD = { action: 'app-switcher', message: 'Opened app switcher' } as const; function makeIosSession(name: string): SessionState { return { @@ -78,9 +72,9 @@ function makeHandler() { leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', - // `scroll` is the view-less subject of case (e), and R53 moved it onto a bound runtime, so - // the handler needs an owner that admits `scrollDirection`. - deviceRuntimeGateway: gestureDeviceRuntimeGateway, + // Both subjects reach bound runtimes now — `app-switcher` through R56 and `scroll` through + // R53 — so the handler needs an owner admitting `appSwitcher` and `scrollDirection` alike. + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, }), }; } @@ -97,8 +91,6 @@ function request(command: string, overrides: Partial = {}): Daemo } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async () => ({ ...REPRESENTATIVE_PAYLOAD })); gestureRuntimeSpies.scrollDirection.mockReset(); gestureRuntimeSpies.scrollDirection.mockResolvedValue({}); }); @@ -113,7 +105,7 @@ test('(a) default identity: responseLevel absent === default === no meta, byte-i expect(JSON.stringify(noMeta)).toBe(JSON.stringify(emptyMeta)); expect(JSON.stringify(noMeta)).toBe(JSON.stringify(explicitDefault)); - if (noMeta.ok) expect(noMeta.data).toEqual(REPRESENTATIVE_PAYLOAD); + if (noMeta.ok) expect(noMeta.data).toEqual(APP_SWITCHER_PAYLOAD); }); test('(b) digest applies the registered view, dropping the full payload', async () => { @@ -121,7 +113,7 @@ test('(b) digest applies the registered view, dropping the full payload', async const resp = await handler(request('app-switcher', { meta: { responseLevel: 'digest' } })); expect(resp.ok).toBe(true); if (!resp.ok) return; - expect(resp.data).toEqual({ appSwitcherDigest: true, hadItems: true }); + expect(resp.data).toEqual({ appSwitcherDigest: true, hadAction: true }); expect('message' in (resp.data ?? {})).toBe(false); }); @@ -139,7 +131,7 @@ test('(d) digest composes with --cost: viewed data plus an additive cost block', ); expect(resp.ok).toBe(true); if (!resp.ok) return; - expect(resp.data).toMatchObject({ appSwitcherDigest: true, hadItems: true }); + expect(resp.data).toMatchObject({ appSwitcherDigest: true, hadAction: true }); expect(typeof resp.data?.cost?.wallClockMs).toBe('number'); }); diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index 2aac875041..03b3bbeed8 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -22,6 +22,7 @@ import { dispatchApplicationLifecycleEffect } from './application-lifecycle-runt import { createRequestHandler, lifecycleDeviceRuntimeGateway, + systemRuntimeSpies, } from './test-device-runtime-gateway.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -87,6 +88,8 @@ function request(command: string, overrides: Partial = {}): Daemo beforeEach(() => { mockDispatch.mockReset(); + systemRuntimeSpies.appSwitcher.mockReset(); + systemRuntimeSpies.appSwitcher.mockResolvedValue(undefined); mockLifecycleEffect.mockReset(); mockLifecycleEffect.mockResolvedValue(undefined); }); @@ -100,23 +103,41 @@ test('retriableForErrorCode is a conservative policy: transient => true, others test('UNSUPPORTED_OPERATION errors carry supportedOn derived from the capability matrix', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('typed-error', makeIosSession('typed-error')); - mockDispatch.mockRejectedValue(new AppError('UNSUPPORTED_OPERATION', 'nope on this platform')); + // `perf` is the subject because the graft reads the CAPABILITY MATRIX, and perf is the command + // that still has a row there: its migration is Wave 2, sequenced behind the next major. Its + // xctrace collector refuses a non-Apple session in production, so no mocking is needed to reach + // a real UNSUPPORTED_OPERATION. + sessionStore.set( + 'typed-error', + makeSession('typed-error', { + ...TENANT_SESSION_DEFAULTS, + device: { + platform: 'linux', + id: 'local', + name: 'Linux Desktop', + kind: 'device', + target: 'desktop', + booted: true, + }, + }), + ); - // `app-switcher` routes through the (mocked) generic dispatch and is platform-restricted. - const response = await handler(request('app-switcher')); + const response = await handler(request('perf', { positionals: ['trace', 'start', 'xctrace'] })); expect(response.ok).toBe(false); if (response.ok) return; - const expected = supportedPlatformsForCommand('app-switcher'); - expect(expected.length).toBeGreaterThan(0); // app-switcher is a platform-restricted command + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + const expected = supportedPlatformsForCommand('perf'); + expect(expected.length).toBeGreaterThan(0); // perf is a platform-restricted command expect(response.error.supportedOn).toBe(expected.join(', ')); }); test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', async () => { const { sessionStore, handler } = makeHandler(); sessionStore.set('typed-error', makeIosSession('typed-error')); - mockDispatch.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy')); + // R56 put `app-switcher` on a bound operation, so the failure is raised where the device work + // happens rather than by the retired dispatcher. + systemRuntimeSpies.appSwitcher.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy')); const response = await handler(request('app-switcher')); diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 6a2a4acd54..615877bf41 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -77,6 +77,7 @@ async function lifecycleBindingForTest(device: DeviceInfo) { listApps: unavailable, ...lifecycleFacts, ...admittedGestureFamilyFacts, + ...admittedSystemFamilyFacts, }, }, operations: { @@ -87,6 +88,9 @@ async function lifecycleBindingForTest(device: DeviceInfo) { // The gesture family rides along: replay flows drive `scroll`/`swipe` through this gateway, // and R52/R53 put them on bound operations rather than the mocked dispatcher. ...gestureRuntimeSpies, + // Same reason for the Wave 6 system leaves: R56 put `app-switcher` on a bound operation, + // so router/replay flows that drive it no longer reach the mocked dispatcher either. + ...systemRuntimeSpies, }, [Symbol.asyncDispose]: async () => {}, }; @@ -168,6 +172,12 @@ const admittedGestureFamilyFacts = Object.freeze({ scrollDirection: available, }); +const admittedSystemFamilyFacts = Object.freeze({ appSwitcher: available }); + +export const systemRuntimeSpies = { + appSwitcher: vi.fn(async () => undefined), +}; + export const gestureRuntimeSpies = { captureSnapshot: vi.fn(async () => ({ backend: 'xctest' as const, diff --git a/src/daemon/app-switcher-runtime.ts b/src/daemon/app-switcher-runtime.ts new file mode 100644 index 0000000000..f678bc43a2 --- /dev/null +++ b/src/daemon/app-switcher-runtime.ts @@ -0,0 +1,51 @@ +import type { AppSwitcherInput } from '@agent-device/contracts/app-switcher-runtime'; +import { appSwitcherRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; +import { resolveBoundGenericRuntime, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; + +/** The neutral intent one `app-switcher` carries, projected from a resolved command context. */ +function appSwitcherInput(context: DaemonCommandContext): AppSwitcherInput { + return { + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place `app-switcher` reaches a device (ADR 0019). Admission inspects the exact owner's + * `appSwitcher` fact and binds once, before the dispatcher runs, so an owner with no springboard + * to reveal is refused rather than discovered mid-execution. + */ +export async function resolveBoundAppSwitcherRuntime( + params: { + device: DeviceInfo; + } & RuntimeAdmissionBindings, +): Promise { + return await resolveBoundGenericRuntime( + { + command: 'app-switcher', + device: params.device, + use: appSwitcherRuntimeUse, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }, + executeAppSwitcher, + ); +} + +/** + * The ONE place a bound `app-switcher` executes (R56). Typed off `typeof appSwitcherRuntimeUse` + * rather than a hand-restated operations shape, so a change to what it binds can't drift here. + */ +async function executeAppSwitcher( + runtime: BoundDeviceRuntime, + context: DaemonCommandContext, +): Promise> { + await runtime.operations.appSwitcher(appSwitcherInput(context)); + return { action: 'app-switcher', ...successText('Opened app switcher') }; +} diff --git a/src/daemon/generic-runtime-execution.ts b/src/daemon/generic-runtime-execution.ts index 9409a45ccf..5c2c1795a4 100644 --- a/src/daemon/generic-runtime-execution.ts +++ b/src/daemon/generic-runtime-execution.ts @@ -8,6 +8,7 @@ import type { DaemonRequest, SessionState } from './types.ts'; import { resolveBoundViewportRuntime } from './viewport-runtime.ts'; import { resolveBoundBackRuntime } from './back-runtime.ts'; import { resolveBoundHomeRuntime } from './home-runtime.ts'; +import { resolveBoundAppSwitcherRuntime } from './app-switcher-runtime.ts'; import { resolveBoundOrientationRuntime } from './orientation-runtime.ts'; import { resolveBoundTvRemoteRuntime } from './tv-remote-runtime.ts'; @@ -61,6 +62,12 @@ export async function resolveGenericRuntimeExecution( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }); + case 'app-switcher': + return await resolveBoundAppSwitcherRuntime({ + device: params.session.device, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); case 'orientation': return await resolveBoundOrientationRuntime({ device: params.session.device, diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 2a5f5f8d3e..3acbb93207 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -385,6 +385,7 @@ function sourceRuntimeFacts( keyboardEnter: unavailable, readClipboard: unavailable, writeClipboard: unavailable, + appSwitcher: unavailable, deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts index 343ca6631f..e5dca0f83b 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -172,6 +172,7 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts { keyboardEnter: { available: false, reason: 'owner-capability-missing' }, readClipboard: { available: false, reason: 'owner-capability-missing' }, writeClipboard: { available: false, reason: 'owner-capability-missing' }, + appSwitcher: { available: false, reason: 'owner-capability-missing' }, readiness: { available: false, reason: 'unsupported-device-kind' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, @@ -163,6 +164,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () keyboardEnter: { available: false, reason: 'unsupported-platform-leaf' }, readClipboard: { available: false, reason: 'unsupported-platform-leaf' }, writeClipboard: { available: false, reason: 'unsupported-platform-leaf' }, + appSwitcher: { available: false, reason: 'unsupported-platform-leaf' }, readiness: { available: false, reason: 'unsupported-platform-leaf' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index afc3eda426..88f5cf4c44 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -60,6 +60,7 @@ describe('composed platform runtime gateway', () => { keyboardEnter: unavailable, readClipboard: unavailable, writeClipboard: unavailable, + appSwitcher: unavailable, touch: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, @@ -152,6 +153,7 @@ describe('composed platform runtime gateway', () => { keyboardEnter: unavailable, readClipboard: unavailable, writeClipboard: unavailable, + appSwitcher: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/platform-runtime-gateway.ts b/src/platform-runtime-gateway.ts index bed349397a..b8c862ae55 100644 --- a/src/platform-runtime-gateway.ts +++ b/src/platform-runtime-gateway.ts @@ -324,6 +324,7 @@ function unavailableProviderBinding( keyboardEnter: unavailable, readClipboard: unavailable, writeClipboard: unavailable, + appSwitcher: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }); } @@ -357,6 +358,7 @@ function unavailableProviderFacts(runtime: ProviderDeviceRuntime, device: Device keyboardEnter: unavailable, readClipboard: unavailable, writeClipboard: unavailable, + appSwitcher: unavailable, readiness: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }, diff --git a/src/platforms/apple/capabilities.ts b/src/platforms/apple/capabilities.ts index 162cad5e53..7abdc64d3d 100644 --- a/src/platforms/apple/capabilities.ts +++ b/src/platforms/apple/capabilities.ts @@ -10,7 +10,9 @@ import { // This is the capability-axis sibling of the runner // table `RUNNER_PLATFORM_PROFILES` (src/platforms/apple/core/apple-runner-platform.ts) // and it encodes the SAME per-OS facts the Swift `#if os()` guards do: keyboard, -// device orientation, app/device lifecycle, and desktop host surfaces. +// device orientation and desktop host surfaces. The app/device-lifecycle row left with R56, whose +// `app-switcher` cutover retired its last reader (`home`'s went with R43, `boot`'s with R20); the +// springboard reading now lives as owner facts in `platform-apple/src/navigation/runtime.ts`. // // DISCIPLINE (ADR-0009): the table holds ONLY the AppleOS-axis facts — it // collapses the scattered `target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice` @@ -23,21 +25,15 @@ import { // --------------------------------------------------------------------------- export type AppleOsCapabilityProfile = { - /** - * `boot` / `home` / `app-switcher` — the app + device lifecycle and springboard surfaces. `false` - * on the macOS host, which drives an already-running app (nothing to boot, no - * springboard home or app switcher). - */ - readonly appAndDeviceLifecycle: boolean; /** `keyboard` — hardware/text keyboard input. tvOS (focus-only) and macOS lack it. */ readonly keyboard: boolean; /** `orientation` — device orientation. tvOS (focus-only) and macOS lack it. */ readonly orientation: boolean; /** - * Whether `clipboard` / `settings` are reachable on a PHYSICAL device of - * this OS. Only the macOS host exposes them without a simulator; the reading closure - * still admits every Apple *simulator* via its own `kind === 'simulator'` check. - * Alert support has a separate command predicate because physical iOS is verified. + * Whether `settings` is reachable on a PHYSICAL device of this OS (`clipboard`'s reader left + * with R56). Only the macOS host exposes it without a simulator; the reading closure still + * admits every Apple *simulator* via its own `kind === 'simulator'` check. Alert support has a + * separate command predicate because physical iOS is verified. */ readonly physicalDeviceSurfaces: boolean; }; @@ -45,7 +41,6 @@ export type AppleOsCapabilityProfile = { // iOS and iPadOS share this platform capability profile. Gesture synthesis support is // intentionally owned by the separate shared gesture policy. const IOS_FAMILY_CAPABILITIES: AppleOsCapabilityProfile = { - appAndDeviceLifecycle: true, keyboard: true, orientation: true, physicalDeviceSurfaces: false, @@ -55,24 +50,19 @@ export const APPLE_OS_CAPABILITIES: Record = ios: IOS_FAMILY_CAPABILITIES, ipados: IOS_FAMILY_CAPABILITIES, visionos: { - appAndDeviceLifecycle: true, keyboard: true, orientation: true, physicalDeviceSurfaces: false, }, - // tvOS: focus-only (XCUIRemote), so no keyboard or orientation; the app + device - // lifecycle (boot/home/app-switcher) is supported like the mobile family. + // tvOS: focus-only (XCUIRemote), so no keyboard or orientation. tvos: { - appAndDeviceLifecycle: true, keyboard: false, orientation: false, physicalDeviceSurfaces: false, }, - // macOS: an AppKit desktop host driving an already-running app. No app/device - // lifecycle (nothing to boot, no springboard), keyboard, or orientation; - // clipboard/alert/settings are reachable on the host directly (no simulator required). + // macOS: an AppKit desktop host driving an already-running app. No keyboard or orientation; + // alert/settings are reachable on the host directly (no simulator required). macos: { - appAndDeviceLifecycle: false, keyboard: false, orientation: false, physicalDeviceSurfaces: true, @@ -82,7 +72,6 @@ export const APPLE_OS_CAPABILITIES: Record = // unsupported sentinel). This row is therefore unreachable today; the all-`false` // sentinel keeps the `Record` exhaustive without granting anything. watchos: { - appAndDeviceLifecycle: false, keyboard: false, orientation: false, physicalDeviceSurfaces: false, diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 5f9310b1ea..50d5db5e86 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -20,14 +20,6 @@ import type { RunnerContext } from '@agent-device/contracts/interaction'; // the full {command x sample-device} matrix (iOS/iPadOS/tvOS/macOS/visionOS). // --------------------------------------------------------------------------- -// `home`/`app-switcher` -// (was `!isMacOs(device)`). Off Apple (caps undefined) the original was -// always true — no non-Apple platform is macOS. -const supportsAppAndDeviceLifecycle = (device: DeviceInfo): boolean => { - const caps = appleOsCapabilities(device); - return caps ? caps.appAndDeviceLifecycle : true; -}; - const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.platform !== 'apple' || device.kind !== 'device' || @@ -55,7 +47,6 @@ const supportsAlertSurface = (device: DeviceInfo): boolean => // the command-descriptor registry (a command absent here has no Apple gate). const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> = { [PUBLIC_COMMANDS.perf]: supportsCoreDevicePhysicalOperation, - [PUBLIC_COMMANDS.appSwitcher]: supportsAppAndDeviceLifecycle, [PUBLIC_COMMANDS.alert]: supportsAlertSurface, [PUBLIC_COMMANDS.settings]: (device) => device.platform === 'android' || supportsHostOrSimulatorSurface(device), diff --git a/test/integration/ios-simulator-e2e/coverage-manifest.ts b/test/integration/ios-simulator-e2e/coverage-manifest.ts index 3a48128df8..83572fd29b 100644 --- a/test/integration/ios-simulator-e2e/coverage-manifest.ts +++ b/test/integration/ios-simulator-e2e/coverage-manifest.ts @@ -185,7 +185,7 @@ export const IOS_SIMULATOR_E2E_COVERAGE = { ), [C.tvRemote]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'tv-remote admission is owned by its exact-owner runtime fact: available only for the tvOS leaf, not the iOS mobile simulator', ), [C.type]: live( diff --git a/test/integration/linux-e2e/coverage-manifest.ts b/test/integration/linux-e2e/coverage-manifest.ts index 79facc95a4..d5a19aada1 100644 --- a/test/integration/linux-e2e/coverage-manifest.ts +++ b/test/integration/linux-e2e/coverage-manifest.ts @@ -264,9 +264,10 @@ export const LINUX_PLATFORM_COVERAGE = { LINUX_RUNTIME_EVIDENCE.test, 'Linux runtime facts explicitly report viewport changes unavailable', ), - [C.appSwitcher]: denial( - 'app-switcher', - 'Linux capability declaration rejects native app-switcher navigation', + [C.appSwitcher]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native app-switcher navigation on Linux', ), [C.installFromSource]: gap('No Linux-specific source-install command evidence exists yet'), } satisfies Record; diff --git a/test/integration/macos-e2e/coverage-manifest.ts b/test/integration/macos-e2e/coverage-manifest.ts index a8036ed448..db11f0f6d5 100644 --- a/test/integration/macos-e2e/coverage-manifest.ts +++ b/test/integration/macos-e2e/coverage-manifest.ts @@ -47,11 +47,10 @@ const contract = (path: string, test: string, assertion: string): MacOsPlatformC level: 'command-contract', owner: { path, test }, }); -const denial = (path: string, test: string, assertion: string): MacOsPlatformCoverageEntry => ({ - assertion, - level: 'capability-denial', - owner: { path, test }, -}); +// No `denial` constructor: R56 moved the last macOS capability-denial row (`app-switcher`) onto +// owner facts, so no command is denied on this host by a capability declaration any more. The +// level stays in the entry type because `macOS capability-denial rows match the owning capability +// matrix` still enforces both sides — today it asserts the denied set is empty. const gap = (assertion: string): MacOsPlatformCoverageEntry => ({ assertion, level: 'known-gap', @@ -112,7 +111,7 @@ export const MACOS_PLATFORM_COVERAGE = { ), [C.keyboard]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects keyboard actions on the macOS AppKit desktop leaf', ), [C.install]: contract( @@ -205,17 +204,17 @@ export const MACOS_PLATFORM_COVERAGE = { ), [C.home]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects mobile Home navigation on the macOS leaf, which drives an already-running app with no springboard', ), [C.tvRemote]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact admits TV remote input only for the tvOS leaf, not macOS', ), [C.orientation]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects device orientation changes on the macOS leaf', ), [C.scroll]: live( @@ -233,10 +232,10 @@ export const MACOS_PLATFORM_COVERAGE = { 'classifies the %s leaf explicitly', 'Apple runtime facts reject viewport resizing on the macOS host', ), - [C.appSwitcher]: denial( - 'src/platforms/apple/capabilities.ts', - 'appAndDeviceLifecycle', - 'macOS capability declarations reject mobile app-switcher navigation', + [C.appSwitcher]: contract( + 'packages/platform-apple/src/runtime.test.ts', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', + 'the exact-owner runtime fact rejects app-switcher navigation on the macOS host leaf', ), [C.installFromSource]: contract( 'packages/platform-apple/src/deployment/runtime.test.ts', diff --git a/test/integration/smoke-linux-coverage.test.ts b/test/integration/smoke-linux-coverage.test.ts index a69d9f096e..18001df66a 100644 --- a/test/integration/smoke-linux-coverage.test.ts +++ b/test/integration/smoke-linux-coverage.test.ts @@ -48,10 +48,10 @@ test('Linux coverage report has the expected classification counts', () => { // focus (#1925), click, and type remain live via the existing replay. The separate // command-evidence lane adds nine generic-command rows without changing that replay. Artifact // inventory remains a gap because local Linux screenshot paths are not daemon-downloadable. - // Keyboard, orientation, and tv-remote remain fact-owned command-contract rows, not catalog - // denials. - capabilityDenial: 7, - contract: 21, + // Keyboard, orientation and tv-remote were already fact-owned command-contract rows rather + // than catalog denials; R56 moves app-switcher the same way, for the same reason. + capabilityDenial: 6, + contract: 22, gap: 9, live: 17, total: 54, diff --git a/test/integration/smoke-macos-coverage.test.ts b/test/integration/smoke-macos-coverage.test.ts index b9b4ed4b55..186b36c4cb 100644 --- a/test/integration/smoke-macos-coverage.test.ts +++ b/test/integration/smoke-macos-coverage.test.ts @@ -49,8 +49,8 @@ test('macOS coverage exhaustively classifies the public catalog', () => { test('macOS coverage report counts every manifest classification', () => { assert.deepEqual(MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 1, - contract: 20, + capabilityDenial: 0, + contract: 21, gap: 15, live: 18, total: 54, diff --git a/test/integration/smoke-web-platform-coverage.test.ts b/test/integration/smoke-web-platform-coverage.test.ts index ea3608edf3..fe216a6741 100644 --- a/test/integration/smoke-web-platform-coverage.test.ts +++ b/test/integration/smoke-web-platform-coverage.test.ts @@ -41,8 +41,8 @@ test('web coverage exhaustively classifies the public catalog', () => { test('web coverage report has the expected classification counts', () => { assert.deepEqual(WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 6, - contract: 35, + capabilityDenial: 5, + contract: 36, gap: 1, live: 12, total: 54, diff --git a/test/integration/tvos-e2e/coverage-manifest.ts b/test/integration/tvos-e2e/coverage-manifest.ts index 4b0a49a208..3225497853 100644 --- a/test/integration/tvos-e2e/coverage-manifest.ts +++ b/test/integration/tvos-e2e/coverage-manifest.ts @@ -115,7 +115,7 @@ export const TVOS_PLATFORM_COVERAGE = { [C.clipboard]: gap('No tvOS-specific clipboard command evidence exists yet'), [C.keyboard]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects keyboard input on the tvOS focus-only leaf', ), [C.install]: contract( @@ -184,12 +184,12 @@ export const TVOS_PLATFORM_COVERAGE = { ), [C.tvRemote]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact admits tv-remote for the tvOS leaf, which drives navigation through XCUIRemote presses', ), [C.orientation]: contract( 'packages/platform-apple/src/runtime.test.ts', - 'classifies back/home/orientation/tv-remote/keyboard facts for the %s leaf', + 'classifies back/home/app-switcher/orientation/tv-remote/keyboard facts for the %s leaf', 'the exact-owner runtime fact rejects device orientation changes on the tvOS leaf', ), [C.scroll]: contract( diff --git a/test/integration/web-e2e/coverage-manifest.ts b/test/integration/web-e2e/coverage-manifest.ts index f99b588581..b785af5965 100644 --- a/test/integration/web-e2e/coverage-manifest.ts +++ b/test/integration/web-e2e/coverage-manifest.ts @@ -140,7 +140,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.clipboard]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard never carried a web capability bucket', + 'clipboard and the app switcher never carried a web capability bucket', 'the exact-owner runtime fact rejects native clipboard operations on the web target', ), [C.keyboard]: contract( @@ -265,7 +265,11 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.screenshot]: live('screenshot creates a valid 640x480 PNG artifact'), [C.viewport]: live('viewport resizes the browser and the PNG reports 640x480 dimensions'), - [C.appSwitcher]: denial('Web capability model rejects native app switcher navigation'), + [C.appSwitcher]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard and the app switcher never carried a web capability bucket', + 'the exact-owner runtime fact rejects native app-switcher navigation on the web target', + ), [C.installFromSource]: contract( 'packages/platform-web/src/runtime.test.ts', 'install-from-source reports the runtime-owned unavailable materialize and deploy facts', From 42ac0b75167e64f86de2594fa3ede5527268d760 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 18:21:50 +0000 Subject: [PATCH 03/16] refactor(daemon): migrate trigger-app-event onto request-bound runtimes (R57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 6 unit 3 of the ADR 0019 platform-free daemon migration (#1739). `trigger-app-event` leaves the legacy dispatch projection: admission is the owner's `triggerAppEvent` fact and the only execution is that one bound operation. The split follows ADR 0019 §2 — a facet input names no command, request, or CLI flag. The event name pattern, the payload size limit, and the per-platform `AGENT_DEVICE_*_APP_EVENT_URL_TEMPLATE` are daemon policy and stay in `core/app-events.ts`; what reaches the owner is a resolved URL to open. They also stay downstream of admission, where the retired `dispatchCommand` ran them, so an unsupported device still reports its unsupported cell rather than an argument error. - new `@agent-device/contracts/app-event-runtime` facet on the shared `Interactor` seam, bound through the interactor catalog. - Apple admits every leaf with a constructible interactor (no closure ever gated this command beyond its bucket), Android every real kind, and Linux/HarmonyOS/Vega/web refuse. It is the one system leaf both Limrun legs serve, since each implements `open`; WebDriver rides interactor reachability. - retires the `core/dispatch.ts` arm and handler, the capability bucket, the `dispatch` leaf, and the session route's last capability-gate-then-`dispatchCommand` thunk: every leaf on that route now supplies a bind-and-execute thunk. - the end-to-end delivery tests keep their shell-level assertions and move onto the migrated composition. Cutover row R57 with its retirement and single-bind claims. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/contracts/package.json | 4 + .../contracts/src/app-event-runtime.test.ts | 75 +++++++++++++++ packages/contracts/src/app-event-runtime.ts | 92 +++++++++++++++++++ packages/contracts/src/facades/platform.ts | 1 + .../src/interactor-operation-catalog.ts | 7 ++ .../src/platform-runtime-operations.ts | 3 + .../src/platform-runtime-unavailable.test.ts | 1 + .../src/platform-runtime-unavailable.ts | 7 ++ packages/platform-android/src/runtime.test.ts | 4 + packages/platform-android/src/runtime.ts | 4 + .../platform-apple/src/system/runtime.test.ts | 18 ++++ packages/platform-apple/src/system/runtime.ts | 20 +++- .../platform-harmonyos/src/runtime.test.ts | 3 +- packages/platform-harmonyos/src/runtime.ts | 3 + packages/platform-linux/src/runtime.test.ts | 2 + packages/platform-linux/src/runtime.ts | 2 + packages/platform-vega/src/runtime.test.ts | 6 ++ packages/platform-vega/src/runtime.ts | 5 + packages/platform-web/src/runtime.test.ts | 9 +- packages/platform-web/src/runtime.ts | 2 + .../provider-limrun/src/app-log-runtime.ts | 1 + packages/provider-limrun/src/facts-runtime.ts | 3 + .../src/interaction-operations.test.ts | 13 +++ .../src/interaction-operations.ts | 16 ++++ .../src/platform-runtime.test.ts | 2 + .../src/platform-runtime.ts | 14 +++ scripts/layering/package-boundaries.test.ts | 1 + .../layering/runtime-command-cutover-table.ts | 31 ++++++- src/__tests__/eager-closure-budgets.ts | 21 +++-- .../test-utils/runtime-operation-facts.ts | 1 + ...ent.test.ts => app-event-delivery.test.ts} | 64 +++++++------ src/core/__tests__/capabilities.test.ts | 6 +- .../__tests__/parity.test.ts | 1 + src/core/command-descriptor/registry.ts | 9 +- src/core/dispatch.ts | 20 ---- src/daemon/app-event-runtime.ts | 75 +++++++++++++++ .../handlers/__tests__/install-source.test.ts | 1 + .../interaction-get-runtime-fixture.ts | 1 + .../session-capabilities.fixtures.ts | 1 + .../__tests__/session-command-harness.ts | 1 + .../handlers/__tests__/session-state.test.ts | 2 + .../handlers/session-selector-dispatch.ts | 83 ++++++++--------- src/daemon/handlers/session.ts | 7 +- src/platform-runtime-gateway.test.ts | 2 + src/platform-runtime-gateway.ts | 2 + .../linux-e2e/coverage-manifest.ts | 7 +- .../macos-e2e/coverage-manifest.ts | 2 +- test/integration/smoke-linux-coverage.test.ts | 5 +- .../smoke-web-platform-coverage.test.ts | 4 +- test/integration/web-e2e/coverage-manifest.ts | 10 +- 50 files changed, 551 insertions(+), 123 deletions(-) create mode 100644 packages/contracts/src/app-event-runtime.test.ts create mode 100644 packages/contracts/src/app-event-runtime.ts rename src/core/__tests__/{dispatch-trigger-app-event.test.ts => app-event-delivery.test.ts} (85%) create mode 100644 src/daemon/app-event-runtime.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e8569bb177..17f11e232c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -35,6 +35,10 @@ "types": "./src/app-deployment-runtime-plan.ts", "default": "./src/app-deployment-runtime-plan.ts" }, + "./app-event-runtime": { + "types": "./src/app-event-runtime.ts", + "default": "./src/app-event-runtime.ts" + }, "./app-inventory-runtime": { "types": "./src/app-inventory-runtime.ts", "default": "./src/app-inventory-runtime.ts" diff --git a/packages/contracts/src/app-event-runtime.test.ts b/packages/contracts/src/app-event-runtime.test.ts new file mode 100644 index 0000000000..188cf42f12 --- /dev/null +++ b/packages/contracts/src/app-event-runtime.test.ts @@ -0,0 +1,75 @@ +import { expect, test, vi } from 'vitest'; +import { + appEventRuntimeOperationFacts, + bindLocalAppEventInteractor, + bindProviderAppEventInteractor, +} from './app-event-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +test('builds the exact app-event operation fact catalog', () => { + const triggerAppEvent = { available: true } as const; + expect(appEventRuntimeOperationFacts({ triggerAppEvent })).toEqual({ triggerAppEvent }); +}); + +// The URL is resolved daemon-side from the event name, payload, and per-platform template; what +// the owner receives is that URL and the app to open it against, nothing command-shaped. +test('a local binding opens the resolved event URL against the session app', async () => { + const open = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalAppEventInteractor({ device, signal, resolveInteractor }); + await operations.triggerAppEvent({ + eventUrl: 'myapp://agent-device/event?name=checkout', + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'event-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'event-1', + appBundleId: 'com.example.app', + signal, + }); + expect(open).toHaveBeenCalledWith('myapp://agent-device/event?name=checkout', { + appBundleId: 'com.example.app', + }); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderAppEventInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.triggerAppEvent({ eventUrl: 'myapp://x' })).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const open = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); + + const operations = bindLocalAppEventInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.triggerAppEvent({ eventUrl: 'myapp://x' })).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/app-event-runtime.ts b/packages/contracts/src/app-event-runtime.ts new file mode 100644 index 0000000000..8993b9c374 --- /dev/null +++ b/packages/contracts/src/app-event-runtime.ts @@ -0,0 +1,92 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one app-event delivery. `eventUrl` arrives already resolved: the event name, + * its payload, and the per-platform URL template are the daemon's policy (env-configured + * templates, size limits, name validation), and none of that is device mechanics. What reaches + * the owner is a URL to open on the device. + */ +export type AppEventInput = Readonly<{ + eventUrl: string; + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * Delivery returns nothing. The retired leaf discarded whatever the interactor answered and + * reported only the event name and URL it sent, so a result type here would be a surface the + * command never had. + */ +export type AppEventRuntimeOperations = Readonly<{ + triggerAppEvent(input: AppEventInput): Promise; +}>; + +export type AppEventRuntimeOperationFacts = Readonly<{ + triggerAppEvent: RuntimeOperationFact; +}>; + +export function appEventRuntimeOperationFacts( + input: Readonly<{ triggerAppEvent: RuntimeOperationFact }>, +): AppEventRuntimeOperationFacts { + return Object.freeze({ triggerAppEvent: input.triggerAppEvent }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the delivery itself. + */ +function bindAppEvent( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): AppEventRuntimeOperations { + return Object.freeze({ + triggerAppEvent: async (input: AppEventInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + await interactor.open(input.eventUrl, { appBundleId: input.options?.appBundleId }); + }, + }); +} + +export type LocalAppEventInteractorResolver = LocalInteractorOperationResolver; + +export function bindLocalAppEventInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalAppEventInteractorResolver; + }>, +): AppEventRuntimeOperations { + return bindAppEvent(params.signal, localInteractorSource(params)); +} + +export type ProviderAppEventInteractorResolver = ProviderInteractorOperationResolver; + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderAppEventInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderAppEventInteractorResolver; + }>, +): AppEventRuntimeOperations { + return bindAppEvent( + params.signal, + providerInteractorSource({ ...params, operation: 'trigger-app-event' }), + ); +} diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 18d90c3c3f..9a149895a4 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -245,6 +245,7 @@ export { keyboardStatusUse, keyboardDismissUse, keyboardEnterUse, + appEventRuntimeUse, appSwitcherRuntimeUse, clipboardRuntimePlanUses, clipboardReadUse, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index c5908f175e..03fbc8573e 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -1,5 +1,9 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { bindLocalBackInteractor, bindProviderBackInteractor } from './back-runtime.ts'; +import { + bindLocalAppEventInteractor, + bindProviderAppEventInteractor, +} from './app-event-runtime.ts'; import { bindLocalAppSwitcherInteractor, bindProviderAppSwitcherInteractor, @@ -57,6 +61,7 @@ const CATALOG_INTERACTOR_OPERATIONS = [ 'readClipboard', 'writeClipboard', 'appSwitcher', + 'triggerAppEvent', ] as const; export type CatalogInteractorOperation = (typeof CATALOG_INTERACTOR_OPERATIONS)[number]; @@ -88,6 +93,7 @@ const LOCAL_BINDERS: Readonly< readClipboard: bindLocalClipboardReadInteractor, writeClipboard: bindLocalClipboardWriteInteractor, appSwitcher: bindLocalAppSwitcherInteractor, + triggerAppEvent: bindLocalAppEventInteractor, }); const PROVIDER_BINDERS: Readonly< @@ -106,6 +112,7 @@ const PROVIDER_BINDERS: Readonly< readClipboard: bindProviderClipboardReadInteractor, writeClipboard: bindProviderClipboardWriteInteractor, appSwitcher: bindProviderAppSwitcherInteractor, + triggerAppEvent: bindProviderAppEventInteractor, }); /** diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3a4cd50b3b..4e10f6140b 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -30,6 +30,7 @@ import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; import type { AppSwitcherRuntimeOperations } from './app-switcher-runtime.ts'; +import type { AppEventRuntimeOperations } from './app-event-runtime.ts'; import type { TouchRuntimeOperations } from './touch-runtime.ts'; import type { DeviceReadinessRuntimeHost, @@ -76,6 +77,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & KeyboardRuntimeOperations & ClipboardRuntimeOperations & AppSwitcherRuntimeOperations & + AppEventRuntimeOperations & TouchRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & @@ -106,6 +108,7 @@ export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); export const appSwitcherRuntimeUse = defineUse({ required: ['appSwitcher'] }); +export const appEventRuntimeUse = defineUse({ required: ['triggerAppEvent'] }); export const clipboardReadUse = defineUse({ required: ['readClipboard'] }); export const clipboardWriteUse = defineUse({ required: ['writeClipboard'] }); export const tapPointUse = defineUse({ required: ['tapPoint'] }); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index ad734f4a88..06e3ee20b3 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -47,6 +47,7 @@ test('generic unavailable binding preserves exact provider ownership and mode', readClipboard: { available: false, reason: 'unsupported-provider-mode' }, writeClipboard: { available: false, reason: 'unsupported-provider-mode' }, appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, + triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, lifecycle, }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 3e54ec2dda..0eb633ce55 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -26,6 +26,7 @@ import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; import { appSwitcherRuntimeOperationFacts } from './app-switcher-runtime.ts'; +import { appEventRuntimeOperationFacts } from './app-event-runtime.ts'; import { touchRuntimeOperationFacts } from './touch-runtime.ts'; /** @@ -58,6 +59,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ readClipboard: RuntimeOperationUnavailability; writeClipboard: RuntimeOperationUnavailability; appSwitcher: RuntimeOperationUnavailability; + triggerAppEvent: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -117,6 +119,7 @@ export function createUnavailablePlatformRuntimeFacts( readClipboard, writeClipboard, appSwitcher, + triggerAppEvent, readiness, shutdown, lifecycle, @@ -188,6 +191,7 @@ export function createUnavailablePlatformRuntimeFacts( }), ...clipboardRuntimeOperationFacts({ read: readClipboard, write: writeClipboard }), ...appSwitcherRuntimeOperationFacts({ appSwitcher }), + ...appEventRuntimeOperationFacts({ triggerAppEvent }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -243,6 +247,9 @@ function freezeUnavailableFacts( // The app switcher is the springboard surface `home` drives, and differs by owner the same // way: an owner states it for its exact leaf rather than inheriting a sibling's gap. appSwitcher: Object.freeze({ ...unavailable.appSwitcher }), + // App-event delivery opens a URL on the device, which is not something a transport gap can + // speak for: each owner states whether it can open one at all. + triggerAppEvent: Object.freeze({ ...unavailable.triggerAppEvent }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), }); } diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index da28d5141b..365c05f35a 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -311,6 +311,9 @@ test('admits both clipboard halves and the app switcher on every real Android ki // R56: `app-switcher` shares `home`'s cell — one `input keyevent` on every real kind. expect(binding.facts.operations.appSwitcher).toEqual({ available: true }); expect(binding.operations.appSwitcher).toBeTypeOf('function'); + // R57: the deep link opens through `am start` on the same cell. + expect(binding.facts.operations.triggerAppEvent).toEqual({ available: true }); + expect(binding.operations.triggerAppEvent).toBeTypeOf('function'); } }); @@ -338,6 +341,7 @@ test('the synthetic Android simulator cell refuses back/home/orientation/keyboar 'readClipboard', 'writeClipboard', 'appSwitcher', + 'triggerAppEvent', ] as const) { expect(facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index f122c6712d..e2674cbce6 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -34,6 +34,7 @@ import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtim import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; @@ -277,6 +278,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor // `app-switcher` shares `home`'s cell: one `input keyevent`, admitted wherever the // retired `ANDROID_ALL` bucket admitted it. ...appSwitcherRuntimeOperationFacts({ appSwitcher: androidTouchFact(device) }), + // The deep link opens through `am start`, admitted wherever the retired `ANDROID_ALL` + // bucket admitted it. + ...appEventRuntimeOperationFacts({ triggerAppEvent: androidTouchFact(device) }), ...orientationRuntimeOperationFacts({ orientation: androidTouchFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: androidTvRemoteFact(device) }), // The only owner with a live IME status read; dismiss/enter share every other diff --git a/packages/platform-apple/src/system/runtime.test.ts b/packages/platform-apple/src/system/runtime.test.ts index 5898f923a4..845e284468 100644 --- a/packages/platform-apple/src/system/runtime.test.ts +++ b/packages/platform-apple/src/system/runtime.test.ts @@ -47,6 +47,24 @@ test.each([ }, ); +/** + * `trigger-app-event` carried no Apple admission closure at all: its retired bucket was + * `{ simulator, device }` flat, so every leaf with a constructible interactor admits it — macOS + * included, unlike clipboard. + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: true }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'trigger-app-event on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + expect(appleSystemFacts(appleDevice(appleOs, kind)).triggerAppEvent.available).toBe(expected); + }, +); + test('a non-simulator, non-device Apple kind is refused by kind, with its own reason', () => { const facts = appleSystemFacts(appleDevice('ios', 'emulator')); expect(facts.readClipboard).toEqual({ diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts index 9e19d9b8f7..5ffa72950a 100644 --- a/packages/platform-apple/src/system/runtime.ts +++ b/packages/platform-apple/src/system/runtime.ts @@ -1,3 +1,4 @@ +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; @@ -45,11 +46,28 @@ function appleClipboardFact(device: DeviceInfo): RuntimeOperationFact { return os === 'macos' ? available : clipboardLeafUnavailable; } -/** The system-surface cells: clipboard read/write. */ +const appEventKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'trigger-app-event is supported on Apple simulators and physical devices.', +} as const); + +/** + * No apple-family closure ever gated `trigger-app-event` beyond its capability bucket + * (`{ simulator, device }`): the deep link opens through the same interactor `open` every Apple + * leaf drives. watchOS is the one narrowing, for want of a constructible interactor at all. + */ +function appleAppEventFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return appEventKindUnavailable; + return resolveDeviceAppleOs(device) === 'watchos' ? clipboardOsUnavailable : available; +} + +/** The system-surface cells: clipboard read/write and app-event delivery. */ export function appleSystemFacts(device: DeviceInfo) { const clipboard = appleClipboardFact(device); return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: clipboard, write: clipboard }), + ...appEventRuntimeOperationFacts({ triggerAppEvent: appleAppEventFact(device) }), }); } diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 0076309a02..9dfc45aa1c 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -115,7 +115,7 @@ test.each([ expect(binding.operations.keyboardStatus).toBeUndefined(); // R55: HarmonyOS never carried a `clipboard` bucket and is absent from the HarmonyOS overlay // set, so neither half was ever admitted here. - for (const operation of ['readClipboard', 'writeClipboard'] as const) { + for (const operation of ['readClipboard', 'writeClipboard', 'triggerAppEvent'] as const) { expect(facts.operations[operation]).toEqual({ available: false, reason: 'unsupported-platform-leaf', @@ -170,6 +170,7 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( 'readClipboard', 'writeClipboard', 'appSwitcher', + 'triggerAppEvent', ] as const) { expect(binding.facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index a4bc5c3eaf..9d0a774997 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -25,6 +25,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -276,6 +277,8 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor // `app-switcher` for both HarmonyOS kinds: it rides the same hdc-driven key input `home` // does, so it shares that cell. ...appSwitcherRuntimeOperationFacts({ appSwitcher: harmonyFocusFact(device) }), + // HarmonyOS never carried a `trigger-app-event` bucket and is absent from the overlay. + ...appEventRuntimeOperationFacts({ triggerAppEvent: harmonyPlatformLeafUnavailable }), ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), ...keyboardRuntimeOperationFacts({ diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 4f2e22ed43..d5a3e3b8a8 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -204,6 +204,8 @@ function expectLinuxNavigationAndKeyboardFacts( // R56: the Linux interactor's own `appSwitcher` throws, and the retired descriptor declared // `linux: {}`, so no Linux cell was ever admitted. 'appSwitcher', + // R57: the retired `trigger-app-event` descriptor declared `linux: {}` too. + 'triggerAppEvent', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 43f855aa57..4260fac3bd 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -197,6 +197,8 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // The Linux interactor's own `appSwitcher` throws unsupported, and the retired descriptor // declared `linux: {}`, so no Linux cell was ever admitted. appSwitcher: linuxPlatformLeafUnavailable, + // The retired `trigger-app-event` descriptor declared `linux: {}`. + triggerAppEvent: linuxPlatformLeafUnavailable, keyboardStatus: linuxPlatformLeafUnavailable, keyboardDismiss: linuxPlatformLeafUnavailable, keyboardEnter: linuxPlatformLeafUnavailable, diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index c4173efc18..089d913cba 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -194,6 +194,12 @@ test.each([ hint: 'app-switcher is not supported on Vega OS.', }); expect(binding.operations.appSwitcher).toBeUndefined(); + expect(binding.facts.operations.triggerAppEvent).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'trigger-app-event is not supported on Vega OS.', + }); + expect(binding.operations.triggerAppEvent).toBeUndefined(); expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index a68b72d7fc..d196aeb62e 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -131,6 +131,10 @@ const keyboardUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'keyboard is not supported on Vega OS.', ); +const appEventUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'trigger-app-event is not supported on Vega OS.', +); const appSwitcherUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'app-switcher is not supported on Vega OS.', @@ -178,6 +182,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts readClipboard: clipboardUnavailable, writeClipboard: clipboardUnavailable, appSwitcher: appSwitcherUnavailable, + triggerAppEvent: appEventUnavailable, keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 569a774b19..d130a97e2f 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -199,13 +199,18 @@ test.each([ }, ); -test('clipboard and the app switcher never carried a web capability bucket', async () => { +test('clipboard, the app switcher and app events never carried a web capability bucket', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, intent: { kind: 'ordinary' }, scope: scope(), }); - for (const operation of ['readClipboard', 'writeClipboard', 'appSwitcher'] as const) { + for (const operation of [ + 'readClipboard', + 'writeClipboard', + 'appSwitcher', + 'triggerAppEvent', + ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, reason: 'unsupported-platform-leaf', diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 7a14b2b774..9edcb815dd 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -48,6 +48,7 @@ import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime' import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -404,6 +405,7 @@ function webRuntimeFacts( write: navigationUnavailable, }), ...appSwitcherRuntimeOperationFacts({ appSwitcher: navigationUnavailable }), + ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index aa8089e0fb..ed5aadbabb 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -110,6 +110,7 @@ export function createLimrunPlatformRuntimeOwner( readClipboard: liveSessionUnavailable, writeClipboard: liveSessionUnavailable, appSwitcher: liveSessionUnavailable, + triggerAppEvent: liveSessionUnavailable, readiness: liveSessionUnavailable, shutdown: liveSessionUnavailable, lifecycle: limrunLifecycleFacts(device, false), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 1c5fe380e2..31ac1cc05f 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -15,6 +15,7 @@ import { import { limrunInteractionOperationFacts, limrunKeyboardOperationFacts, + limrunAppEventOperationFacts, limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, @@ -189,6 +190,7 @@ export function limrunAppLogFacts( ...limrunKeyboardOperationFacts(device), ...limrunClipboardOperationFacts(device), ...limrunAppSwitcherOperationFacts(device), + ...limrunAppEventOperationFacts(device), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -238,6 +240,7 @@ export function limrunAppLogRecoveryFacts( ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), ...limrunClipboardOperationFacts(device, liveSessionUnavailable), ...limrunAppSwitcherOperationFacts(device, liveSessionUnavailable), + ...limrunAppEventOperationFacts(device, liveSessionUnavailable), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 14db5b165e..6ea012c7ca 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -3,6 +3,7 @@ import { bindAdmittedProviderInteractorOperations } from '@agent-device/contract import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; import { + limrunAppEventOperationFacts, limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, @@ -89,6 +90,18 @@ test('app-switcher rides the Android interactor and is refused on the iOS leg', ).toEqual(liveSessionUnavailable); }); +// R57: app-event delivery is the one system leaf BOTH direct-session legs serve, because each +// implements `open` and a deep link is exactly what that method routes. +test('app-event delivery is admitted on both direct-session legs', () => { + expect(limrunAppEventOperationFacts(androidMobileDevice).triggerAppEvent).toEqual({ + available: true, + }); + expect(limrunAppEventOperationFacts(iosDevice).triggerAppEvent).toEqual({ available: true }); + expect(limrunAppEventOperationFacts(iosDevice, liveSessionUnavailable).triggerAppEvent).toEqual( + liveSessionUnavailable, + ); +}); + test('the iOS leg admits back/orientation but explicitly refuses home and tv-remote', () => { const facts = limrunNavigationOperationFacts(iosDevice); expect(facts.back).toEqual({ available: true }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index f8a4be9d06..ab0bb0b39e 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -17,6 +17,7 @@ import { scrollRuntimeOperationFacts, } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -280,6 +281,21 @@ export function limrunAppSwitcherOperationFacts( return Object.freeze({ ...appSwitcherRuntimeOperationFacts({ appSwitcher: cell }) }); } +/** + * `trigger-app-event` is the one system leaf both direct-session legs genuinely serve: each + * implements `open`, and a deep link is exactly what that method routes (`openUrl` on iOS, the + * local Android interactor's `am start` on Android). + */ +export function limrunAppEventOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + void device; + return Object.freeze({ + ...appEventRuntimeOperationFacts({ triggerAppEvent: liveSessionUnavailable ?? available }), + }); +} + export function limrunKeyboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 65ef7f60c8..b06118cfcf 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -260,6 +260,7 @@ test('captures through only the active exact WebDriver interactor', async () => 'readClipboard', 'writeClipboard', 'appSwitcher', + 'triggerAppEvent', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); @@ -326,6 +327,7 @@ test.each([ expect(facts.operations.readClipboard.available).toBe(state.isSessionActive()); expect(facts.operations.writeClipboard.available).toBe(state.isSessionActive()); expect(facts.operations.appSwitcher.available).toBe(state.isSessionActive()); + expect(facts.operations.triggerAppEvent.available).toBe(state.isSessionActive()); for (const operation of [ 'tvRemote', 'keyboardStatus', diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index c36630271f..6977ccaeae 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -24,6 +24,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -222,6 +223,12 @@ const appSwitcherUnavailable = Object.freeze({ hint: 'This WebDriver provider runtime does not expose the app switcher for this device.', } as const); +const appEventUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose app-event delivery for this device.', +} as const); + const appStateUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -449,6 +456,7 @@ function webDriverFacts( readClipboard: inactiveSession, writeClipboard: inactiveSession, appSwitcher: inactiveSession, + triggerAppEvent: inactiveSession, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: inactiveSession, prepareApplicationOpen: inactiveSession, @@ -486,6 +494,7 @@ function webDriverFacts( readClipboard: clipboardUnavailable, writeClipboard: clipboardUnavailable, appSwitcher: appSwitcherUnavailable, + triggerAppEvent: appEventUnavailable, lifecycle: webDriverLifecycleFacts(device), }); // Both capture cells need the same reachability: an interactor this provider can drive, on a @@ -559,6 +568,11 @@ function webDriverFacts( ...appSwitcherRuntimeOperationFacts({ appSwitcher: interactorCell(reachable, appSwitcherUnavailable), }), + // The deep link opens through the same reachable interactor `open` every lifecycle command + // drives on this provider. + ...appEventRuntimeOperationFacts({ + triggerAppEvent: interactorCell(reachable, appEventUnavailable), + }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ensureReady: available, bootTarget: available, diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 4cd1b9f0bc..20ae284cff 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -53,6 +53,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/android-system-chrome', '@agent-device/contracts/app-deployment-runtime', '@agent-device/contracts/app-deployment-runtime-plan', + '@agent-device/contracts/app-event-runtime', '@agent-device/contracts/app-inventory-runtime', '@agent-device/contracts/app-log-runtime', '@agent-device/contracts/app-state-runtime', diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 311da87aeb..60915a8cab 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -30,7 +30,7 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * leaves follow: back at R42, home at R43, orientation at R44, tv-remote at R45, and the * action-selected keyboard at R46. * The gesture cluster follows the touch leaves: gesture at R52, scroll at R53, swipe at R54. - * Wave 6 closure starts at R55 clipboard, then R56 app-switcher. + * Wave 6 closure starts at R55 clipboard, then R56 app-switcher and R57 trigger-app-event. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -1134,8 +1134,37 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, extensions: [appSwitcherRetiredDispatchProjectionProof], }, + { + rule: 'R57 trigger-app-event-runtime-cutover', + command: 'trigger-app-event', + subject: 'app-event delivery', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its `core/dispatch.ts` handler, plus the session route's + // last capability-gate-then-`dispatchCommand` thunk: with `trigger-app-event` bound, every + // leaf on that route supplies a bind-and-execute thunk instead. The daemon route function + // is `handleAppEventCommand` now, so the retired name is genuinely absent rather than + // shadowed by a surviving namesake. + routeNames: ['handleTriggerAppEventCommand', 'legacySessionDispatchExecute'], + }, + runtimeTypeNames: ['AppEventRuntimeOperations'], + operations: { names: ['triggerAppEvent'] }, + singularExecution: { + routes: ['resolveBoundAppEventRuntime'], + operations: ['triggerAppEvent'], + operationOwners: { triggerAppEvent: ['executeAppEvent'] }, + }, + extensions: [appEventRetiredDispatchProjectionProof], + }, ]; +function appEventRetiredDispatchProjectionProof( + sources: ReadonlyMap, +): UnruledViolation[] { + return retiredDispatchProjectionViolations(sources, 'trigger-app-event'); +} + function appSwitcherRetiredDispatchProjectionProof( sources: ReadonlyMap, ): UnruledViolation[] { diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index ec8fb1f44c..116fb8cfa8 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -144,7 +144,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/facades/divergence.ts': 3, 'packages/contracts/src/facades/interaction.ts': 25, 'packages/contracts/src/facades/observability.ts': 7, - 'packages/contracts/src/facades/platform.ts': 53, + 'packages/contracts/src/facades/platform.ts': 54, 'packages/contracts/src/facades/progress.ts': 1, 'packages/contracts/src/facades/recording.ts': 3, 'packages/contracts/src/facades/remote.ts': 2, @@ -168,7 +168,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/platform-module.ts': 5, 'packages/contracts/src/platform-runtime-host.ts': 1, 'packages/contracts/src/platform-runtime-operations.ts': 2, - 'packages/contracts/src/platform-runtime-unavailable.ts': 25, + 'packages/contracts/src/platform-runtime-unavailable.ts': 26, 'packages/contracts/src/platform-runtime.ts': 6, 'packages/contracts/src/record-runtime-cutover.ts': 7, 'packages/contracts/src/react-native-overlay.ts': 1, @@ -243,15 +243,16 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // Added by #1993 (device-inventory context moved out of core). 'packages/contracts/src/back-runtime.ts': 4, - // Added by Wave 6 R55/R56: the clipboard and app-switcher facets, and the local interaction set - // Android and Linux used to hold a byte-identical copy of each. The set is its own module - // rather than part of the interactor catalog so the catalog's closure stays leaf-thin -- every - // module the set pulls in is one its two consumers already evaluate. + // Added by Wave 6 R55/R56/R57: the clipboard, app-switcher and app-event facets, plus the local + // interaction set Android and Linux used to hold a byte-identical copy of each. The set is its + // own module rather than part of the interactor catalog so the catalog's closure stays + // leaf-thin -- every module the set pulls in is one its two consumers already evaluate. + 'packages/contracts/src/app-event-runtime.ts': 4, 'packages/contracts/src/app-switcher-runtime.ts': 4, 'packages/contracts/src/clipboard-runtime.ts': 4, - 'packages/contracts/src/local-interactor-operation-set.ts': 23, + 'packages/contracts/src/local-interactor-operation-set.ts': 24, 'packages/contracts/src/home-runtime.ts': 4, - 'packages/contracts/src/interactor-operation-catalog.ts': 11, + 'packages/contracts/src/interactor-operation-catalog.ts': 12, 'packages/contracts/src/keyboard-runtime.ts': 4, 'packages/contracts/src/orientation-runtime.ts': 4, 'packages/contracts/src/tv-remote-runtime.ts': 4, @@ -282,8 +283,8 @@ export const HUB_BUDGETS: Readonly> = Object.freeze({ // the same PR added and only a source checkout reaches, and which therefore loads on demand // (`resolveLocalDaemonCodeSignature`) rather than appearing here. 'src/cli.ts': 365, - 'src/platform-runtime.ts': 41, - 'src/core/dispatch.ts': 83, + 'src/platform-runtime.ts': 42, + 'src/core/dispatch.ts': 82, 'src/core/capabilities.ts': 75, 'src/core/command-descriptor/registry.ts': 66, 'src/core/command-descriptor/platform-execution-entry.ts': 3, diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 33b7109d51..c6ef0cc848 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -65,6 +65,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, + triggerAppEvent: unavailable, }); /** Default facts for tests that are unrelated to application lifecycle commands. */ diff --git a/src/core/__tests__/dispatch-trigger-app-event.test.ts b/src/core/__tests__/app-event-delivery.test.ts similarity index 85% rename from src/core/__tests__/dispatch-trigger-app-event.test.ts rename to src/core/__tests__/app-event-delivery.test.ts index 63c52454a9..2db2a18362 100644 --- a/src/core/__tests__/dispatch-trigger-app-event.test.ts +++ b/src/core/__tests__/app-event-delivery.test.ts @@ -2,7 +2,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { dispatchCommand } from '../dispatch.ts'; +import { bindLocalAppEventInteractor } from '@agent-device/contracts/app-event-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { parseTriggerAppEventArgs, resolveAppEventUrl } from '../app-events.ts'; +import { getInteractor } from '../interactors.ts'; import { AppError } from '@agent-device/kernel/errors'; import { ANDROID_EMULATOR, @@ -11,6 +14,29 @@ import { } from '../../__tests__/test-utils/device-fixtures.ts'; import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; +/** + * The composed `trigger-app-event` path as R57 left it: the daemon's own policy resolves the + * event URL, and the bound `triggerAppEvent` operation opens it through the selected owner's + * interactor. This helper is the same composition `daemon/app-event-runtime.ts` performs, minus + * the request plumbing, so these tests keep asserting the real shell invocation the retired + * `dispatchCommand('trigger-app-event', …)` used to reach. + */ +async function triggerAppEvent( + device: DeviceInfo, + positionals: string[], + options: { appBundleId?: string } = {}, +): Promise<{ event: string; eventUrl: string; transport: 'deep-link' }> { + const { eventName, payload } = parseTriggerAppEventArgs(positionals); + const eventUrl = resolveAppEventUrl(device, eventName, payload); + const operations = bindLocalAppEventInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: async (owned, runner) => await getInteractor(owned, runner), + }); + await operations.triggerAppEvent({ eventUrl, options }); + return { event: eventName, eventUrl, transport: 'deep-link' }; +} + test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', async () => { const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; const previousAndroidTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; @@ -19,7 +45,7 @@ test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', try { await assert.rejects( - () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken']), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); @@ -43,11 +69,7 @@ test('trigger-app-event validates payload JSON', async () => { 'myapp://agent-device/event?name={event}&payload={payload}'; try { await assert.rejects( - () => - dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ - 'screenshot_taken', - '{invalid-json', - ]), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken', '{invalid-json']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -82,7 +104,7 @@ test('trigger-app-event opens deep link with encoded event payload', async () => 'myapp://agent-device/event?name={event}&payload={payload}&platform={platform}'; try { - const result = await dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ + const result = await triggerAppEvent(ANDROID_EMULATOR, [ 'screenshot_taken', '{"source":"qa","count":2}', ]); @@ -127,9 +149,7 @@ test('trigger-app-event prefers platform-specific template over global template' process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://android?name={event}'; try { - const result = await dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ - 'screenshot_taken', - ]); + const result = await triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']); assert.equal(result?.eventUrl, 'myapp://android?name=screenshot_taken'); } finally { process.env.PATH = previousPath; @@ -167,13 +187,9 @@ test('trigger-app-event supports iOS device path and prefers iOS template', asyn 'myapp://ios?name={event}&payload={payload}'; try { - const result = await dispatchCommand( - IOS_DEVICE, - 'trigger-app-event', - ['screenshot_taken', '{"source":"ios"}'], - undefined, - { appBundleId: 'com.example.app' }, - ); + const result = await triggerAppEvent(IOS_DEVICE, ['screenshot_taken', '{"source":"ios"}'], { + appBundleId: 'com.example.app', + }); const expectedUrl = 'myapp://ios?name=screenshot_taken&payload=%7B%22source%22%3A%22ios%22%7D'; assert.equal(result?.eventUrl, expectedUrl); const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); @@ -224,7 +240,7 @@ test('trigger-app-event supports macOS and prefers macOS template', async () => 'myapp://macos?name={event}&payload={payload}&platform={platform}'; try { - const result = await dispatchCommand(MACOS_DEVICE, 'trigger-app-event', [ + const result = await triggerAppEvent(MACOS_DEVICE, [ 'screenshot_taken', '{"source":"desktop"}', ]); @@ -253,7 +269,7 @@ test('trigger-app-event rejects invalid event names', async () => { 'myapp://agent-device/event?name={event}'; try { await assert.rejects( - () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['bad event']), + () => triggerAppEvent(ANDROID_EMULATOR, ['bad event']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -275,11 +291,7 @@ test('trigger-app-event rejects payloads that exceed size limits', async () => { const oversizedPayload = JSON.stringify({ value: 'x'.repeat(9000) }); try { await assert.rejects( - () => - dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', [ - 'screenshot_taken', - oversizedPayload, - ]), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken', oversizedPayload]), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); @@ -299,7 +311,7 @@ test('trigger-app-event rejects event URLs that exceed length limits', async () process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = `myapp://${'a'.repeat(5000)}?name={event}`; try { await assert.rejects( - () => dispatchCommand(ANDROID_EMULATOR, 'trigger-app-event', ['screenshot_taken']), + () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']), (error: unknown) => { assert.equal(error instanceof AppError, true); assert.equal((error as AppError).code, 'INVALID_ARGS'); diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 75e41caf35..6e2f8cb32b 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -276,13 +276,14 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'scroll', 'snapshot', 'swipe', + 'trigger-app-event', 'type', 'wait', ], [{ device: linuxDevice, expected: true, label: 'on Linux' }], ); assertCommandSupport( - ['alert', 'perf', 'settings', 'trigger-app-event'], + ['alert', 'perf', 'settings'], [{ device: linuxDevice, expected: false, label: 'on Linux' }], ); }); @@ -312,13 +313,14 @@ test('web supports only the initial browser interaction slice', () => { 'scroll', 'snapshot', 'swipe', + 'trigger-app-event', 'type', 'wait', ], [{ device: webDevice, expected: true, label: 'on web' }], ); assertCommandSupport( - ['alert', 'perf', 'settings', 'trigger-app-event'], + ['alert', 'perf', 'settings'], [{ device: webDevice, expected: false, label: 'on web' }], ); assertCommandSupport( diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index f15e165d6f..317e59a345 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -85,6 +85,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.swipe, PUBLIC_COMMANDS.test, PUBLIC_COMMANDS.trace, + PUBLIC_COMMANDS.triggerAppEvent, PUBLIC_COMMANDS.tvRemote, PUBLIC_COMMANDS.type, PUBLIC_COMMANDS.viewport, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 1967ac51e7..ab877a8b56 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -38,6 +38,7 @@ import { gestureRuntimePlanUses, homeRuntimeUse, hoverRuntimeUses, + appEventRuntimeUse, appSwitcherRuntimeUse, clipboardRuntimePlanUses, keyboardRuntimePlanUses, @@ -850,14 +851,16 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public', key: 'triggerAppEvent' }, frameworkTier: 'extended', + // R57 retires this command's capability bucket and its `dispatch` leaf together: admission is + // the owner's `triggerAppEvent` fact, and the only execution is that one bound operation. The + // event name, payload, and URL template stay daemon policy (ADR 0019 §2 — a facet input names + // no command, request, or CLI flag), so the owner receives a URL to open. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, - dispatch: {}, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [appEventRuntimeUse] }, }, { name: 'open', diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 95fe253d27..a4250bc7d2 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -5,7 +5,6 @@ import type { Rect } from '@agent-device/kernel/snapshot'; import { emitDiagnostic, withDiagnosticTimer } from '../utils/diagnostics.ts'; import { readLocationCoordinate } from '../utils/location-coordinates.ts'; import { successText, withSuccessText } from '../utils/success-text.ts'; -import { parseTriggerAppEventArgs, resolveAppEventUrl } from './app-events.ts'; import type { DescriptorDispatchCommandName } from './command-descriptor/registry.ts'; import type { DispatchContext } from './dispatch-context.ts'; import { getInteractor } from './interactors.ts'; @@ -116,8 +115,6 @@ type DispatchHandler = (args: DispatchHandlerArgs) => Promise = { - 'trigger-app-event': ({ device, interactor, positionals, context }) => - handleTriggerAppEventCommand(device, interactor, positionals, context), settings: ({ device, interactor, positionals, context }) => handleSettingsCommand(device, interactor, positionals, context), }; @@ -154,23 +151,6 @@ async function dispatchKnownCommand( // Command handlers // --------------------------------------------------------------------------- -async function handleTriggerAppEventCommand( - device: DeviceInfo, - interactor: Interactor, - positionals: string[], - context: DispatchContext | undefined, -): Promise> { - const { eventName, payload } = parseTriggerAppEventArgs(positionals); - const eventUrl = resolveAppEventUrl(device, eventName, payload); - await interactor.open(eventUrl, { appBundleId: context?.appBundleId }); - return { - event: eventName, - eventUrl, - transport: 'deep-link', - ...successText(`Triggered app event: ${eventName}`), - }; -} - async function handleSettingsCommand( device: DeviceInfo, interactor: Interactor, diff --git a/src/daemon/app-event-runtime.ts b/src/daemon/app-event-runtime.ts new file mode 100644 index 0000000000..0f5cd3ee1f --- /dev/null +++ b/src/daemon/app-event-runtime.ts @@ -0,0 +1,75 @@ +import type { AppEventInput } from '@agent-device/contracts/app-event-runtime'; +import { appEventRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { parseTriggerAppEventArgs, resolveAppEventUrl } from '../core/app-events.ts'; +import { successText } from '../utils/success-text.ts'; +import type { DaemonCommandContext } from './context.ts'; +import { admitRuntimeUse, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; +import type { DaemonFailureResponse } from './handlers/response.ts'; + +/** + * What the admit-then-bind step reports: either the refusal an unadmitted cell produced, or the + * one bound invocation to run once the caller has expired the ref frame (ADR 0014). + */ +export type ResolvedAppEventExecution = + | Readonly<{ ok: false; response: DaemonFailureResponse }> + | Readonly<{ + ok: true; + execute: (context: DaemonCommandContext) => Promise>; + }>; + +/** + * The ONE place a bound `trigger-app-event` executes (R57). Argument parsing and URL resolution + * stay here rather than moving into the owner: the event name pattern, the payload size limit, + * and the per-platform `AGENT_DEVICE_*_APP_EVENT_URL_TEMPLATE` are daemon policy, not device + * mechanics. They also stay downstream of admission, where the retired `dispatchCommand` ran + * them, so an unsupported device still reports the unsupported cell rather than an argument error. + */ +async function executeAppEvent( + runtime: BoundDeviceRuntime, + device: DeviceInfo, + context: DaemonCommandContext, + positionals: readonly string[], +): Promise> { + const { eventName, payload } = parseTriggerAppEventArgs([...positionals]); + const eventUrl = resolveAppEventUrl(device, eventName, payload); + const input: AppEventInput = { + eventUrl, + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; + await runtime.operations.triggerAppEvent(input); + return { + event: eventName, + eventUrl, + transport: 'deep-link', + ...successText(`Triggered app event: ${eventName}`), + }; +} + +/** + * The one place `trigger-app-event` reaches a device (ADR 0019 §9). Admission inspects the exact + * owner's `triggerAppEvent` fact and binds once, before the frame expires and before any + * argument is read. + */ +export async function resolveBoundAppEventRuntime( + params: Readonly<{ device: DeviceInfo; positionals: readonly string[] }> & + RuntimeAdmissionBindings, +): Promise { + const { device, positionals, inspectFacts, bindDevice } = params; + const admission = await admitRuntimeUse({ + command: 'trigger-app-event', + device, + use: appEventRuntimeUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { + ok: true, + execute: (context) => executeAppEvent(runtime, device, context, positionals), + }; +} diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 3acbb93207..a569831fb2 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -386,6 +386,7 @@ function sourceRuntimeFacts( readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, + triggerAppEvent: unavailable, deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts index e5dca0f83b..7762de6c64 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -173,6 +173,7 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts { readClipboard: { available: false, reason: 'owner-capability-missing' }, writeClipboard: { available: false, reason: 'owner-capability-missing' }, appSwitcher: { available: false, reason: 'owner-capability-missing' }, + triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, readiness: { available: false, reason: 'unsupported-device-kind' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, @@ -165,6 +166,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () readClipboard: { available: false, reason: 'unsupported-platform-leaf' }, writeClipboard: { available: false, reason: 'unsupported-platform-leaf' }, appSwitcher: { available: false, reason: 'unsupported-platform-leaf' }, + triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, readiness: { available: false, reason: 'unsupported-platform-leaf' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index 556b297f72..faa32d649c 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -1,12 +1,12 @@ -import { dispatchCommand } from '../../core/dispatch.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import type { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; -import { errorResponse, requireCommandSupported } from './response.ts'; +import { errorResponse } from './response.ts'; import { recordSessionAction } from './handler-utils.ts'; +import { resolveBoundAppEventRuntime } from '../app-event-runtime.ts'; import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; @@ -31,10 +31,10 @@ type SessionCommandPrepareOutcome = * The one orchestration every session/selector-route leaf shares: guard, resolve the device, * admit-then-prepare via the caller's own strategy, expire the ref frame if the command mutates * (immediately before the prepared invocation runs, never after), derive and record the next - * session. `prepare` is where the two strategies session-route commands use today diverge — - * {@link legacySessionDispatchExecute} for a still-legacy command (capability gate, then - * `dispatchCommand`), or a bind-and-execute thunk like `keyboard`'s below for a migrated one — - * everything around it is identical either way, so it lives here once instead of once per command. + * session. `prepare` is where each leaf's own admission and binding lives; everything around it + * is identical, so it lives here once instead of once per command. Every leaf on this route now + * supplies a bind-and-execute thunk — R57 retired the last capability-gate-then-`dispatchCommand` + * one with `trigger-app-event`. */ // fallow-ignore-next-line complexity async function runSessionOrSelectorDispatch(params: { @@ -101,33 +101,6 @@ async function runSessionOrSelectorDispatch(params: { return { ok: true, data: result ?? {} }; } -/** The still-legacy `prepare` thunk: the capability gate is admission (nothing mutated yet if it - * refuses); `dispatchCommand` is the invocation, deferred until `runSessionOrSelectorDispatch` has - * expired the ref frame. Every remaining unmigrated session-route command passes this until its - * own migration replaces it with a bind-and-execute thunk, same as `keyboard`'s - * {@link handleKeyboardCommand} below. */ -function legacySessionDispatchExecute( - command: string, - positionals: string[], - req: DaemonRequest, - logPath: string, -): ( - device: DeviceInfo, - session: SessionState | undefined, -) => Promise { - return async (device, session) => { - const unsupported = requireCommandSupported(command, device); - if (unsupported) return { ok: false, response: unsupported }; - return { - ok: true, - execute: () => - dispatchCommand(device, command, positionals, req.flags?.out, { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - }), - }; - }; -} - /** * A dismiss/enter/return sent with no session and no explicit iOS selector would target whatever * app happens to be foreground on a later-resolved device, silently. Refuse it up front rather @@ -209,13 +182,46 @@ export async function handleKeyboardCommand(params: { }); } -export async function handleTriggerAppEventCommand(params: { +/** + * `trigger-app-event`'s migrated `prepare` thunk (ADR 0019 §9): bind the one use its fact admits — + * admission only, no device I/O and no argument read yet — and defer the bound runtime's own + * `execute` as the invocation `runSessionOrSelectorDispatch` runs after expiring the frame. + */ +function appEventSessionExecute( + positionals: string[], + inspectFacts: InspectDeviceRuntimeFacts | undefined, + bindDevice: BindDeviceRuntime | undefined, + req: DaemonRequest, + logPath: string, +): ( + device: DeviceInfo, + session: SessionState | undefined, +) => Promise { + return async (device, session) => { + const bound = await resolveBoundAppEventRuntime({ + device, + positionals, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return { ok: false, response: bound.response }; + const dispatchContext = { + ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + surface: session?.surface, + }; + return { ok: true, execute: () => bound.execute(dispatchContext) }; + }; +} + +export async function handleAppEventCommand(params: { req: DaemonRequest; sessionName: string; logPath: string; sessionStore: SessionStore; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }): Promise { - const { req, sessionName, logPath, sessionStore } = params; + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; const positionals = req.positionals ?? []; return await runSessionOrSelectorDispatch({ req, @@ -223,12 +229,7 @@ export async function handleTriggerAppEventCommand(params: { sessionStore, command: PUBLIC_COMMANDS.triggerAppEvent, positionals, - prepare: legacySessionDispatchExecute( - PUBLIC_COMMANDS.triggerAppEvent, - positionals, - req, - logPath, - ), + prepare: appEventSessionExecute(positionals, inspectFacts, bindDevice, req, logPath), deriveNextSession: async (session, result) => { const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; const nextAppBundleId = eventUrl diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 68d4ee8873..3beb7594fa 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -6,10 +6,7 @@ import { handleRuntimeCommand } from './session-runtime-command.ts'; import { requireRuntimeBinding, requireRuntimeFacts } from './session-runtime-admission.ts'; import { handleOpenCommand } from './session-open.ts'; import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts'; -import { - handleKeyboardCommand, - handleTriggerAppEventCommand, -} from './session-selector-dispatch.ts'; +import { handleKeyboardCommand, handleAppEventCommand } from './session-selector-dispatch.ts'; import { handleCloseCommand } from './session-close.ts'; import { handleSessionAppDeploymentCommand } from './session-app-deployment-route.ts'; import { runBatchCommands } from './session-batch.ts'; @@ -216,7 +213,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { release_materialized_paths: async ({ req }) => await handleReleaseMaterializedPathsCommand({ req }), push: handleSessionAppDeploymentCommand, - 'trigger-app-event': handleTriggerAppEventCommand, + 'trigger-app-event': handleAppEventCommand, open: async ({ req, sessionName, diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index 88f5cf4c44..97d29f1803 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -61,6 +61,7 @@ describe('composed platform runtime gateway', () => { readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, + triggerAppEvent: unavailable, touch: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, @@ -154,6 +155,7 @@ describe('composed platform runtime gateway', () => { readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, + triggerAppEvent: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/platform-runtime-gateway.ts b/src/platform-runtime-gateway.ts index b8c862ae55..77f0e318bc 100644 --- a/src/platform-runtime-gateway.ts +++ b/src/platform-runtime-gateway.ts @@ -325,6 +325,7 @@ function unavailableProviderBinding( readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, + triggerAppEvent: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }); } @@ -359,6 +360,7 @@ function unavailableProviderFacts(runtime: ProviderDeviceRuntime, device: Device readClipboard: unavailable, writeClipboard: unavailable, appSwitcher: unavailable, + triggerAppEvent: unavailable, readiness: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }, diff --git a/test/integration/linux-e2e/coverage-manifest.ts b/test/integration/linux-e2e/coverage-manifest.ts index d5a19aada1..b753e1504e 100644 --- a/test/integration/linux-e2e/coverage-manifest.ts +++ b/test/integration/linux-e2e/coverage-manifest.ts @@ -154,9 +154,10 @@ export const LINUX_PLATFORM_COVERAGE = { [C.install]: gap('No Linux-specific application installation command evidence exists yet'), [C.reinstall]: gap('No Linux-specific application reinstallation command evidence exists yet'), [C.push]: gap('No Linux-specific push delivery command evidence exists yet'), - [C.triggerAppEvent]: denial( - 'trigger-app-event', - 'Linux capability declaration rejects native application event delivery', + [C.triggerAppEvent]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native application event delivery on Linux', ), [C.open]: live('the existing Linux replay opens gnome-calculator'), [C.prepare]: contract( diff --git a/test/integration/macos-e2e/coverage-manifest.ts b/test/integration/macos-e2e/coverage-manifest.ts index db11f0f6d5..596cbc673a 100644 --- a/test/integration/macos-e2e/coverage-manifest.ts +++ b/test/integration/macos-e2e/coverage-manifest.ts @@ -130,7 +130,7 @@ export const MACOS_PLATFORM_COVERAGE = { 'Apple deployment facts close push delivery on the macOS host', ), [C.triggerAppEvent]: contract( - 'src/core/__tests__/dispatch-trigger-app-event.test.ts', + 'src/core/__tests__/app-event-delivery.test.ts', 'trigger-app-event supports macOS and prefers macOS template', 'macOS app-event dispatch selects the macOS URL template', ), diff --git a/test/integration/smoke-linux-coverage.test.ts b/test/integration/smoke-linux-coverage.test.ts index 18001df66a..c78f7ec055 100644 --- a/test/integration/smoke-linux-coverage.test.ts +++ b/test/integration/smoke-linux-coverage.test.ts @@ -50,8 +50,9 @@ test('Linux coverage report has the expected classification counts', () => { // inventory remains a gap because local Linux screenshot paths are not daemon-downloadable. // Keyboard, orientation and tv-remote were already fact-owned command-contract rows rather // than catalog denials; R56 moves app-switcher the same way, for the same reason. - capabilityDenial: 6, - contract: 22, + // R57 moves trigger-app-event from capability-denial to command-contract: it is fact-owned now. + capabilityDenial: 5, + contract: 23, gap: 9, live: 17, total: 54, diff --git a/test/integration/smoke-web-platform-coverage.test.ts b/test/integration/smoke-web-platform-coverage.test.ts index fe216a6741..b8b8a293d6 100644 --- a/test/integration/smoke-web-platform-coverage.test.ts +++ b/test/integration/smoke-web-platform-coverage.test.ts @@ -41,8 +41,8 @@ test('web coverage exhaustively classifies the public catalog', () => { test('web coverage report has the expected classification counts', () => { assert.deepEqual(WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 5, - contract: 36, + capabilityDenial: 4, + contract: 37, gap: 1, live: 12, total: 54, diff --git a/test/integration/web-e2e/coverage-manifest.ts b/test/integration/web-e2e/coverage-manifest.ts index b785af5965..a5624d5eac 100644 --- a/test/integration/web-e2e/coverage-manifest.ts +++ b/test/integration/web-e2e/coverage-manifest.ts @@ -140,7 +140,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.clipboard]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard and the app switcher never carried a web capability bucket', + 'clipboard, the app switcher and app events never carried a web capability bucket', 'the exact-owner runtime fact rejects native clipboard operations on the web target', ), [C.keyboard]: contract( @@ -163,7 +163,11 @@ export const WEB_PLATFORM_COVERAGE = { 'push reports the runtime-owned unavailable readiness and push facts', 'the web runtime fact rejects native push notification delivery', ), - [C.triggerAppEvent]: denial('Web capability model rejects native app event delivery'), + [C.triggerAppEvent]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher and app events never carried a web capability bucket', + 'the exact-owner runtime fact rejects native app-event delivery on the web target', + ), [C.open]: live('the managed browser opens the local fixture page'), [C.prepare]: contract( 'packages/platform-web/src/runtime.test.ts', @@ -267,7 +271,7 @@ export const WEB_PLATFORM_COVERAGE = { [C.viewport]: live('viewport resizes the browser and the PNG reports 640x480 dimensions'), [C.appSwitcher]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard and the app switcher never carried a web capability bucket', + 'clipboard, the app switcher and app events never carried a web capability bucket', 'the exact-owner runtime fact rejects native app-switcher navigation on the web target', ), [C.installFromSource]: contract( From c5f2ca3b29a429c2725f49d8acac21173a0a68cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:59:53 +0000 Subject: [PATCH 04/16] refactor(daemon): migrate settings onto request-bound runtimes and retire the legacy dispatcher (R58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 6 unit 4 of the ADR 0019 platform-free daemon migration (#1739). `settings` was the last `DISPATCH_HANDLERS` arm, so this change closes the command and retires the legacy command dispatcher whole. - new `@agent-device/contracts/settings-runtime` facet on the shared `Interactor` seam, bound through the interactor catalog. What reaches the owner is its own settings vocabulary (setting, state, resolved app id, typed coordinates); the CLI parse, the macOS setting-name gate, the clear-app-state app-id check and the coordinate typing are daemon policy and stay daemon-side, downstream of admission where the retired leaf ran them. - Apple shares clipboard's exact host-or-simulator reading (the retired admission intersected the `settings` bucket with the same `supportsHostOrSimulatorSurface` closure); Android admits every real kind; HarmonyOS matches its retired overlay membership; Linux/Vega/web refuse. Limrun splits Android-reuse / iOS-refusal like `app-switcher`; WebDriver refuses unconditionally, since its interactor declares settings unsupported. - retires `dispatchCommand`, `dispatchWithInteractor`, `dispatchKnownCommand`, `DISPATCH_HANDLERS`, `listRegisteredDispatchCommandNames`, and the request router's `executeGenericPlatformCommand` fallback. `core/dispatch.ts` keeps only `dispatchGestureViewport`, whose last consumers are replay/test. Retiring the dispatcher surfaced two callers broken since Wave 5 moved `press` onto a bound runtime: react-native overlay dismissal and the opt-in interaction no-change retry both called `dispatchCommand(device, 'press', …)`, which has thrown `INVALID_ARGS: Unknown command: press` on main since R48. Both now run the same bound `tapPoint` every other touch leaf uses. The retry declares its own callback seam rather than importing runtime admission, so the policy stays readable without the binding stack — and that inversion, plus the dispatcher's retirement, drops the largest type-level import cycle from 25 files to 21. Cutover row R58 with its retirement and single-bind claims. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/contracts/package.json | 4 + packages/contracts/src/facades/platform.ts | 1 + .../src/interactor-operation-catalog.ts | 4 + .../src/platform-runtime-operations.ts | 3 + .../src/platform-runtime-unavailable.test.ts | 1 + .../src/platform-runtime-unavailable.ts | 7 + .../contracts/src/settings-runtime.test.ts | 82 +++++ packages/contracts/src/settings-runtime.ts | 100 +++++++ packages/platform-android/src/runtime.test.ts | 4 + packages/platform-android/src/runtime.ts | 4 + .../platform-apple/src/system/runtime.test.ts | 43 ++- packages/platform-apple/src/system/runtime.ts | 44 ++- .../platform-harmonyos/src/runtime.test.ts | 5 + packages/platform-harmonyos/src/runtime.ts | 4 + packages/platform-linux/src/runtime.test.ts | 2 + packages/platform-linux/src/runtime.ts | 2 + packages/platform-vega/src/runtime.test.ts | 6 + packages/platform-vega/src/runtime.ts | 5 + packages/platform-web/src/runtime.test.ts | 4 +- packages/platform-web/src/runtime.ts | 2 + .../provider-limrun/src/app-log-runtime.ts | 1 + packages/provider-limrun/src/facts-runtime.ts | 3 + .../src/interaction-operations.test.ts | 15 + .../src/interaction-operations.ts | 19 ++ .../src/platform-runtime.test.ts | 6 +- .../src/platform-runtime.ts | 15 + .../__tests__/test-file-size-ratchet.test.ts | 10 +- scripts/layering/daemon-modularity.test.ts | 8 +- scripts/layering/daemon-modularity.ts | 9 +- scripts/layering/package-boundaries.test.ts | 1 + .../layering/runtime-command-cutover-table.ts | 46 ++- .../apple-os-capability-table-parity.test.ts | 2 - src/__tests__/eager-closure-budgets.ts | 21 +- .../test-utils/runtime-operation-facts.ts | 1 + src/core/__tests__/capabilities.test.ts | 18 +- .../capability-plugin-routing-parity.test.ts | 4 +- src/core/__tests__/dispatch-back.test.ts | 24 -- src/core/__tests__/dispatch-keyboard.test.ts | 52 ---- .../__tests__/dispatch-orientation.test.ts | 38 --- .../__tests__/dispatch-screenshot.test.ts | 42 --- src/core/__tests__/dispatch-tv-remote.test.ts | 39 --- src/core/__tests__/dispatch-viewport.test.ts | 43 --- src/core/capabilities.ts | 2 +- .../__tests__/parity.test.ts | 20 +- src/core/command-descriptor/registry.ts | 22 +- src/core/dispatch.ts | 279 +----------------- .../deferred-interaction-outcome.test.ts | 60 +++- src/daemon/__tests__/generic-settle.test.ts | 47 +-- .../__tests__/interaction-retry-tap.test.ts | 99 +++++++ .../legacy-snapshot-capture-fixture.ts | 42 ++- .../request-router-dispatch-mocks.ts | 1 - .../request-router-lock-policy.test.ts | 27 +- .../__tests__/request-router-open.test.ts | 11 +- .../request-router-recording-health.test.ts | 14 +- .../request-router-replay-scope.test.ts | 11 +- .../request-router-screenshot.test.ts | 13 +- .../request-router-typed-error.test.ts | 14 +- .../snapshot-command-runtime.test.ts | 39 +-- .../__tests__/snapshot-quality-latch.test.ts | 15 +- .../__tests__/snapshot-runtime-fixture.ts | 53 +++- src/daemon/deferred-interaction-outcome.ts | 5 + src/daemon/generic-runtime-execution.ts | 20 +- .../__tests__/find-single-bind.test.ts | 16 +- .../__tests__/find-touch-runtime-fixture.ts | 17 +- src/daemon/handlers/__tests__/find.test.ts | 3 - .../handlers/__tests__/install-source.test.ts | 1 + .../__tests__/interaction-common.test.ts | 20 +- .../interaction-get-runtime-fixture.ts | 1 + .../interaction-ios-tap-outcome.test.ts | 102 +++---- .../__tests__/interaction-settle.test.ts | 20 +- .../interaction-target-evidence.test.ts | 17 +- ...interaction-type-android-readiness.test.ts | 19 +- .../handlers/__tests__/interaction.test.ts | 52 ++-- .../handlers/__tests__/react-native.test.ts | 102 +++---- .../session-capabilities.fixtures.ts | 1 + .../session-close-shutdown.fixtures.ts | 5 - .../__tests__/session-command-harness.ts | 1 + .../__tests__/session-device-claims.test.ts | 1 - .../session-open-execution-runtime.test.ts | 1 - .../__tests__/session-open-runtime.test.ts | 1 - ...sion-replay-dispatch-selector-miss.test.ts | 10 +- ...eplay-divergence-android-occlusion.test.ts | 10 +- ...sion-replay-divergence-publication.test.ts | 12 +- .../session-replay-divergence.test.ts | 15 +- .../session-replay-maestro-failure.test.ts | 10 +- .../session-replay-repair-acceptance.test.ts | 10 +- .../session-replay-repair-empty-tail.test.ts | 10 +- .../session-replay-repair-loop.test.ts | 10 +- ...ion-replay-repair-record-exclusion.test.ts | 10 +- .../session-replay-repair-transaction.test.ts | 14 +- .../session-replay-runtime-binding.test.ts | 2 +- ...on-replay-runtime-failure-response.test.ts | 10 +- .../session-replay-runtime-failure.test.ts | 10 +- ...ession-replay-runtime-keep-session.test.ts | 2 +- .../session-replay-runtime-maestro.test.ts | 12 +- .../session-replay-runtime-plan.test.ts | 11 +- .../__tests__/session-replay-runtime.test.ts | 10 +- .../session-replay-script-source.test.ts | 12 +- .../session-replay-selector-routes.test.ts | 10 +- ...replay-target-verification-runtime.test.ts | 15 +- .../handlers/__tests__/session-state.test.ts | 2 + .../__tests__/session-test-harness.ts | 7 +- ...ssion-test-reporter-values-maestro.test.ts | 11 +- .../__tests__/snapshot-handler.test.ts | 191 ++++++------ .../__tests__/snapshot-scoped-refs.test.ts | 18 +- .../system-surface-disclosure.test.ts | 10 +- .../__tests__/wait-landmark-recording.test.ts | 14 +- .../handlers/interaction-touch-response.ts | 4 +- src/daemon/handlers/react-native.ts | 24 +- src/daemon/handlers/snapshot-capture.ts | 10 + src/daemon/handlers/snapshot-settings.ts | 148 ++++++++-- src/daemon/handlers/snapshot.ts | 4 +- src/daemon/interaction-outcome-policy.ts | 77 ++++- src/daemon/interaction-retry-tap.ts | 39 +++ src/daemon/request-generic-dispatch.ts | 14 +- src/daemon/request-router.ts | 9 +- src/daemon/request-runtime-binding.ts | 12 + src/daemon/runtime-admission.ts | 25 +- src/platform-runtime-gateway.test.ts | 2 + src/platform-runtime-gateway.ts | 2 + src/platforms/apple/plugin.ts | 2 - .../linux-e2e/coverage-manifest.ts | 7 +- .../ios-alert-settings.test.ts | 14 + test/integration/smoke-linux-coverage.test.ts | 5 +- .../smoke-web-platform-coverage.test.ts | 4 +- test/integration/web-e2e/coverage-manifest.ts | 12 +- 126 files changed, 1519 insertions(+), 1271 deletions(-) create mode 100644 packages/contracts/src/settings-runtime.test.ts create mode 100644 packages/contracts/src/settings-runtime.ts delete mode 100644 src/core/__tests__/dispatch-back.test.ts delete mode 100644 src/core/__tests__/dispatch-keyboard.test.ts delete mode 100644 src/core/__tests__/dispatch-orientation.test.ts delete mode 100644 src/core/__tests__/dispatch-screenshot.test.ts delete mode 100644 src/core/__tests__/dispatch-tv-remote.test.ts delete mode 100644 src/core/__tests__/dispatch-viewport.test.ts create mode 100644 src/daemon/__tests__/interaction-retry-tap.test.ts create mode 100644 src/daemon/interaction-retry-tap.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 17f11e232c..461feadaa7 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -311,6 +311,10 @@ "types": "./src/settings.ts", "default": "./src/settings.ts" }, + "./settings-runtime": { + "types": "./src/settings-runtime.ts", + "default": "./src/settings-runtime.ts" + }, "./snapshot": { "types": "./src/facades/snapshot.ts", "default": "./src/facades/snapshot.ts" diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 9a149895a4..8b277d957b 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -246,6 +246,7 @@ export { keyboardDismissUse, keyboardEnterUse, appEventRuntimeUse, + settingsRuntimeUse, appSwitcherRuntimeUse, clipboardRuntimePlanUses, clipboardReadUse, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 03fbc8573e..2bf1f6b73b 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -33,6 +33,7 @@ import type { } from './interactor-operation-binding.ts'; import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; +import { bindLocalSettingsInteractor, bindProviderSettingsInteractor } from './settings-runtime.ts'; import { bindLocalTvRemoteInteractor, bindProviderTvRemoteInteractor, @@ -62,6 +63,7 @@ const CATALOG_INTERACTOR_OPERATIONS = [ 'writeClipboard', 'appSwitcher', 'triggerAppEvent', + 'setSetting', ] as const; export type CatalogInteractorOperation = (typeof CATALOG_INTERACTOR_OPERATIONS)[number]; @@ -94,6 +96,7 @@ const LOCAL_BINDERS: Readonly< writeClipboard: bindLocalClipboardWriteInteractor, appSwitcher: bindLocalAppSwitcherInteractor, triggerAppEvent: bindLocalAppEventInteractor, + setSetting: bindLocalSettingsInteractor, }); const PROVIDER_BINDERS: Readonly< @@ -113,6 +116,7 @@ const PROVIDER_BINDERS: Readonly< writeClipboard: bindProviderClipboardWriteInteractor, appSwitcher: bindProviderAppSwitcherInteractor, triggerAppEvent: bindProviderAppEventInteractor, + setSetting: bindProviderSettingsInteractor, }); /** diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 4e10f6140b..1d12496867 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -31,6 +31,7 @@ import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; import type { AppSwitcherRuntimeOperations } from './app-switcher-runtime.ts'; import type { AppEventRuntimeOperations } from './app-event-runtime.ts'; +import type { SettingsRuntimeOperations } from './settings-runtime.ts'; import type { TouchRuntimeOperations } from './touch-runtime.ts'; import type { DeviceReadinessRuntimeHost, @@ -78,6 +79,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & ClipboardRuntimeOperations & AppSwitcherRuntimeOperations & AppEventRuntimeOperations & + SettingsRuntimeOperations & TouchRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & @@ -109,6 +111,7 @@ export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); export const appSwitcherRuntimeUse = defineUse({ required: ['appSwitcher'] }); export const appEventRuntimeUse = defineUse({ required: ['triggerAppEvent'] }); +export const settingsRuntimeUse = defineUse({ required: ['setSetting'] }); export const clipboardReadUse = defineUse({ required: ['readClipboard'] }); export const clipboardWriteUse = defineUse({ required: ['writeClipboard'] }); export const tapPointUse = defineUse({ required: ['tapPoint'] }); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 06e3ee20b3..4be6ccaf87 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -48,6 +48,7 @@ test('generic unavailable binding preserves exact provider ownership and mode', writeClipboard: { available: false, reason: 'unsupported-provider-mode' }, appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, + setSetting: { available: false, reason: 'unsupported-provider-mode' }, lifecycle, }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 0eb633ce55..49562b29e8 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -27,6 +27,7 @@ import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; import { appSwitcherRuntimeOperationFacts } from './app-switcher-runtime.ts'; import { appEventRuntimeOperationFacts } from './app-event-runtime.ts'; +import { settingsRuntimeOperationFacts } from './settings-runtime.ts'; import { touchRuntimeOperationFacts } from './touch-runtime.ts'; /** @@ -60,6 +61,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ writeClipboard: RuntimeOperationUnavailability; appSwitcher: RuntimeOperationUnavailability; triggerAppEvent: RuntimeOperationUnavailability; + setSetting: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -120,6 +122,7 @@ export function createUnavailablePlatformRuntimeFacts( writeClipboard, appSwitcher, triggerAppEvent, + setSetting, readiness, shutdown, lifecycle, @@ -192,6 +195,7 @@ export function createUnavailablePlatformRuntimeFacts( ...clipboardRuntimeOperationFacts({ read: readClipboard, write: writeClipboard }), ...appSwitcherRuntimeOperationFacts({ appSwitcher }), ...appEventRuntimeOperationFacts({ triggerAppEvent }), + ...settingsRuntimeOperationFacts({ setSetting }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -250,6 +254,9 @@ function freezeUnavailableFacts( // App-event delivery opens a URL on the device, which is not something a transport gap can // speak for: each owner states whether it can open one at all. triggerAppEvent: Object.freeze({ ...unavailable.triggerAppEvent }), + // Device settings differ by leaf and kind the way the pasteboard does, and a provider can + // own a device without exposing any settings API at all, so each owner states its own cell. + setSetting: Object.freeze({ ...unavailable.setSetting }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), }); } diff --git a/packages/contracts/src/settings-runtime.test.ts b/packages/contracts/src/settings-runtime.test.ts new file mode 100644 index 0000000000..3529aff4b9 --- /dev/null +++ b/packages/contracts/src/settings-runtime.test.ts @@ -0,0 +1,82 @@ +import { expect, test, vi } from 'vitest'; +import { + bindLocalSettingsInteractor, + bindProviderSettingsInteractor, + settingsRuntimeOperationFacts, +} from './settings-runtime.ts'; +import type { Interactor } from './interactor-types.ts'; + +const device = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +} as const; + +test('builds the exact settings operation fact catalog', () => { + const setSetting = { available: true } as const; + expect(settingsRuntimeOperationFacts({ setSetting })).toEqual({ setSetting }); +}); + +// The daemon has already parsed the CLI form, resolved the target app, and typed the coordinates +// by the time a binding runs, so what reaches the owner is its own settings vocabulary. +test('a local binding forwards the neutral mutation to its owner', async () => { + const setSetting = vi.fn(async () => ({ message: 'Location updated' })); + const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindLocalSettingsInteractor({ device, signal, resolveInteractor }); + const result = await operations.setSetting({ + setting: 'location', + state: 'set', + appBundleId: 'com.example.app', + options: { latitude: 37.33, longitude: -122.03 }, + execution: { logPath: '/tmp/daemon.log', requestId: 'settings-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'settings-1', + appBundleId: 'com.example.app', + signal, + }); + expect(setSetting).toHaveBeenCalledWith('location', 'set', 'com.example.app', { + latitude: 37.33, + longitude: -122.03, + }); + expect(result).toEqual({ message: 'Location updated' }); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderSettingsInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect( + operations.setSetting({ setting: 'appearance', state: 'dark' }), + ).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const setSetting = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); + + const operations = bindLocalSettingsInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.setSetting({ setting: 'appearance', state: 'dark' })).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(setSetting).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/settings-runtime.ts b/packages/contracts/src/settings-runtime.ts new file mode 100644 index 0000000000..227e7fe31b --- /dev/null +++ b/packages/contracts/src/settings-runtime.ts @@ -0,0 +1,100 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SettingOptions } from './settings.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one settings mutation. `setting` and `state` are the device-settings + * vocabulary the owner's own API is keyed by (`interactor.setSetting`), not a command payload: + * the daemon has already parsed the CLI form, validated it, resolved the target app, and typed + * the coordinates in `options`, so nothing command-shaped or argv-shaped travels here. + */ +export type SetSettingInput = Readonly<{ + setting: string; + state: string; + /** The app the setting targets, already resolved from the request or the session. */ + appBundleId?: string; + options?: SettingOptions; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * Owners answer with their own settings payload or nothing at all; the daemon composes the + * response text around whichever it gets, exactly as the retired leaf did. + */ +export type SettingsRuntimeOperations = Readonly<{ + setSetting(input: SetSettingInput): Promise | void>; +}>; + +export type SettingsRuntimeOperationFacts = Readonly<{ + setSetting: RuntimeOperationFact; +}>; + +export function settingsRuntimeOperationFacts( + input: Readonly<{ setSetting: RuntimeOperationFact }>, +): SettingsRuntimeOperationFacts { + return Object.freeze({ setSetting: input.setSetting }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what both share: the runner context and the mutation itself. + */ +function bindSetSetting( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): SettingsRuntimeOperations { + return Object.freeze({ + setSetting: async (input: SetSettingInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.appBundleId, + signal, + }); + return await interactor.setSetting( + input.setting, + input.state, + input.appBundleId, + input.options, + ); + }, + }); +} + +export type LocalSettingsInteractorResolver = LocalInteractorOperationResolver; + +export function bindLocalSettingsInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalSettingsInteractorResolver; + }>, +): SettingsRuntimeOperations { + return bindSetSetting(params.signal, localInteractorSource(params)); +} + +export type ProviderSettingsInteractorResolver = ProviderInteractorOperationResolver; + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderSettingsInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderSettingsInteractorResolver; + }>, +): SettingsRuntimeOperations { + return bindSetSetting( + params.signal, + providerInteractorSource({ ...params, operation: 'settings' }), + ); +} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 365c05f35a..a7a0aec1c1 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -314,6 +314,9 @@ test('admits both clipboard halves and the app switcher on every real Android ki // R57: the deep link opens through `am start` on the same cell. expect(binding.facts.operations.triggerAppEvent).toEqual({ available: true }); expect(binding.operations.triggerAppEvent).toBeTypeOf('function'); + // R58: settings run over adb (`appops`, `settings put`, `pm clear`, …) on that cell too. + expect(binding.facts.operations.setSetting).toEqual({ available: true }); + expect(binding.operations.setSetting).toBeTypeOf('function'); } }); @@ -342,6 +345,7 @@ test('the synthetic Android simulator cell refuses back/home/orientation/keyboar 'writeClipboard', 'appSwitcher', 'triggerAppEvent', + 'setSetting', ] as const) { expect(facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index e2674cbce6..e0802e8b5d 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -35,6 +35,7 @@ import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport- import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; @@ -281,6 +282,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor // The deep link opens through `am start`, admitted wherever the retired `ANDROID_ALL` // bucket admitted it. ...appEventRuntimeOperationFacts({ triggerAppEvent: androidTouchFact(device) }), + // Settings run over adb (`appops`, `settings put`, `pm clear`, …) on every real kind, so + // the cell is the retired `ANDROID_ALL` bucket verbatim. + ...settingsRuntimeOperationFacts({ setSetting: androidTouchFact(device) }), ...orientationRuntimeOperationFacts({ orientation: androidTouchFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: androidTvRemoteFact(device) }), // The only owner with a live IME status read; dismiss/enter share every other diff --git a/packages/platform-apple/src/system/runtime.test.ts b/packages/platform-apple/src/system/runtime.test.ts index 845e284468..4514aeec69 100644 --- a/packages/platform-apple/src/system/runtime.test.ts +++ b/packages/platform-apple/src/system/runtime.test.ts @@ -65,6 +65,40 @@ test.each([ }, ); +/** + * `settings` shares clipboard's exact reading, and that sharing is the parity claim: the retired + * admission intersected the `settings` bucket (`{ simulator: true, device: true }`) with the same + * `supportsHostOrSimulatorSurface` closure. Only the refusal wording differs, so the table is + * clipboard's verbatim — including the watchOS narrowing. + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: false }, + { appleOs: 'ipados', kind: 'simulator', expected: true }, + { appleOs: 'ipados', kind: 'device', expected: false }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'tvos', kind: 'device', expected: false }, + { appleOs: 'visionos', kind: 'simulator', expected: true }, + { appleOs: 'visionos', kind: 'device', expected: false }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'settings on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + expect(appleSystemFacts(appleDevice(appleOs, kind)).setSetting.available).toBe(expected); + }, +); + +// Same cell, different sentence: a physical iOS device is refused as a platform leaf, and the +// hint names the two surfaces that do work rather than clipboard's shorter phrasing. +test('a physical non-macOS Apple device refuses settings as a platform leaf', () => { + expect(appleSystemFacts(appleDevice('ios', 'device')).setSetting).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', + }); +}); + test('a non-simulator, non-device Apple kind is refused by kind, with its own reason', () => { const facts = appleSystemFacts(appleDevice('ios', 'emulator')); expect(facts.readClipboard).toEqual({ @@ -72,9 +106,14 @@ test('a non-simulator, non-device Apple kind is refused by kind, with its own re reason: 'unsupported-device-kind', hint: 'clipboard is supported on Apple simulators and the macOS host.', }); + expect(facts.setSetting).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'settings is supported on Apple simulators and the macOS host.', + }); }); -test('binds both clipboard halves for an admitted cell and neither for a refused one', () => { +test('binds the system operations for an admitted cell and none for a refused one', () => { const resolve = vi.fn(async () => ({}) as unknown as Interactor); const host = { localInteractors: { resolve } }; const signal = new AbortController().signal; @@ -86,6 +125,7 @@ test('binds both clipboard halves for an admitted cell and neither for a refused }); expect(admitted.readClipboard).toBeTypeOf('function'); expect(admitted.writeClipboard).toBeTypeOf('function'); + expect(admitted.setSetting).toBeTypeOf('function'); const refused = createAppleSystemOperations({ host, @@ -94,5 +134,6 @@ test('binds both clipboard halves for an admitted cell and neither for a refused }); expect(refused.readClipboard).toBeUndefined(); expect(refused.writeClipboard).toBeUndefined(); + expect(refused.setSetting).toBeUndefined(); expect(resolve).not.toHaveBeenCalled(); }); diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts index 5ffa72950a..77502d1f1a 100644 --- a/packages/platform-apple/src/system/runtime.ts +++ b/packages/platform-apple/src/system/runtime.ts @@ -1,5 +1,6 @@ import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; @@ -12,6 +13,16 @@ const clipboardKindUnavailable = Object.freeze({ reason: 'unsupported-device-kind', hint: 'clipboard is supported on Apple simulators and the macOS host.', } as const); +const settingsKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'settings is supported on Apple simulators and the macOS host.', +} as const); +const settingsLeafUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', +} as const); /** * Parity with the retired `supportsHostOrSimulatorSurface` closure: the Apple pasteboard is * reachable through `simctl pbpaste`/`pbcopy` on any simulator, and directly on the macOS host; @@ -33,17 +44,31 @@ const clipboardOsUnavailable = Object.freeze({ reason: 'unsupported-platform-leaf', } as const); +/** + * The one host-or-simulator reading `clipboard` and `settings` share, and sharing it is parity + * rather than convenience: the retired `supportsHostOrSimulatorSurface` closure gated both off + * the same per-AppleOS `physicalDeviceSurfaces` row. Only the refusal wording differs, so each + * caller supplies its own pair. + */ +function appleHostOrSimulatorFact( + device: DeviceInfo, + kindUnavailable: RuntimeOperationFact, + leafUnavailable: RuntimeOperationFact, +): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return kindUnavailable; + const os = resolveDeviceAppleOs(device); + if (os === 'watchos') return clipboardOsUnavailable; + if (device.kind === 'simulator') return available; + return os === 'macos' ? available : leafUnavailable; +} + /** * Read and write share one cell: both routes (`simctl pbpaste`/`pbcopy`, and the macOS host * pasteboard) expose the pair or neither, so splitting them here would invent a cell no Apple * owner can actually be in. */ function appleClipboardFact(device: DeviceInfo): RuntimeOperationFact { - if (device.kind !== 'simulator' && device.kind !== 'device') return clipboardKindUnavailable; - const os = resolveDeviceAppleOs(device); - if (os === 'watchos') return clipboardOsUnavailable; - if (device.kind === 'simulator') return available; - return os === 'macos' ? available : clipboardLeafUnavailable; + return appleHostOrSimulatorFact(device, clipboardKindUnavailable, clipboardLeafUnavailable); } const appEventKindUnavailable = Object.freeze({ @@ -62,12 +87,19 @@ function appleAppEventFact(device: DeviceInfo): RuntimeOperationFact { return resolveDeviceAppleOs(device) === 'watchos' ? clipboardOsUnavailable : available; } -/** The system-surface cells: clipboard read/write and app-event delivery. */ +/** The system-surface cells: clipboard read/write, app-event delivery, and settings. */ export function appleSystemFacts(device: DeviceInfo) { const clipboard = appleClipboardFact(device); return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: clipboard, write: clipboard }), ...appEventRuntimeOperationFacts({ triggerAppEvent: appleAppEventFact(device) }), + ...settingsRuntimeOperationFacts({ + setSetting: appleHostOrSimulatorFact( + device, + settingsKindUnavailable, + settingsLeafUnavailable, + ), + }), }); } diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 9dfc45aa1c..58f3f9b839 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -122,6 +122,10 @@ test.each([ }); expect(binding.operations[operation]).toBeUndefined(); } + // R58: `settings` is the other way round — the retired overlay set listed it for both HarmonyOS + // kinds, so a real kind admits it off the same hdc gate the interaction leaves use. + expect(facts.operations.setSetting).toEqual({ available: true }); + expect(binding.operations.setSetting).toBeTypeOf('function'); await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true }); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), @@ -171,6 +175,7 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( 'writeClipboard', 'appSwitcher', 'triggerAppEvent', + 'setSetting', ] as const) { expect(binding.facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 9d0a774997..f1f029b755 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -26,6 +26,7 @@ import { import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -279,6 +280,9 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor ...appSwitcherRuntimeOperationFacts({ appSwitcher: harmonyFocusFact(device) }), // HarmonyOS never carried a `trigger-app-event` bucket and is absent from the overlay. ...appEventRuntimeOperationFacts({ triggerAppEvent: harmonyPlatformLeafUnavailable }), + // Parity with the retired `HARMONYOS_SUPPORTED_COMMANDS` overlay, which listed `settings` + // for both HarmonyOS kinds; the hdc-driven settings surface shares the interaction gate. + ...settingsRuntimeOperationFacts({ setSetting: harmonyFocusFact(device) }), ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), ...keyboardRuntimeOperationFacts({ diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index d5a3e3b8a8..ddf3814995 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -206,6 +206,8 @@ function expectLinuxNavigationAndKeyboardFacts( 'appSwitcher', // R57: the retired `trigger-app-event` descriptor declared `linux: {}` too. 'triggerAppEvent', + // R58: and so did `settings`. + 'setSetting', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 4260fac3bd..63807a4702 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -199,6 +199,8 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts appSwitcher: linuxPlatformLeafUnavailable, // The retired `trigger-app-event` descriptor declared `linux: {}`. triggerAppEvent: linuxPlatformLeafUnavailable, + // The retired `settings` descriptor declared `linux: {}` too. + setSetting: linuxPlatformLeafUnavailable, keyboardStatus: linuxPlatformLeafUnavailable, keyboardDismiss: linuxPlatformLeafUnavailable, keyboardEnter: linuxPlatformLeafUnavailable, diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index 089d913cba..29ba6c62dc 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -200,6 +200,12 @@ test.each([ hint: 'trigger-app-event is not supported on Vega OS.', }); expect(binding.operations.triggerAppEvent).toBeUndefined(); + expect(binding.facts.operations.setSetting).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is not supported on Vega OS.', + }); + expect(binding.operations.setSetting).toBeUndefined(); expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index d196aeb62e..0d251f2f3f 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -131,6 +131,10 @@ const keyboardUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'keyboard is not supported on Vega OS.', ); +const settingsUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'settings is not supported on Vega OS.', +); const appEventUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'trigger-app-event is not supported on Vega OS.', @@ -183,6 +187,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts writeClipboard: clipboardUnavailable, appSwitcher: appSwitcherUnavailable, triggerAppEvent: appEventUnavailable, + setSetting: settingsUnavailable, keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index d130a97e2f..eb30519e29 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -199,7 +199,7 @@ test.each([ }, ); -test('clipboard, the app switcher and app events never carried a web capability bucket', async () => { +test('clipboard, the app switcher, app events and settings carry no web capability bucket', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, intent: { kind: 'ordinary' }, @@ -210,6 +210,8 @@ test('clipboard, the app switcher and app events never carried a web capability 'writeClipboard', 'appSwitcher', 'triggerAppEvent', + // R58: the retired `settings` descriptor declared no web leaf either. + 'setSetting', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 9edcb815dd..708de022be 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -49,6 +49,7 @@ import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime' import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -406,6 +407,7 @@ function webRuntimeFacts( }), ...appSwitcherRuntimeOperationFacts({ appSwitcher: navigationUnavailable }), ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), + ...settingsRuntimeOperationFacts({ setSetting: navigationUnavailable }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index ed5aadbabb..8aa21052d5 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -111,6 +111,7 @@ export function createLimrunPlatformRuntimeOwner( writeClipboard: liveSessionUnavailable, appSwitcher: liveSessionUnavailable, triggerAppEvent: liveSessionUnavailable, + setSetting: liveSessionUnavailable, readiness: liveSessionUnavailable, shutdown: liveSessionUnavailable, lifecycle: limrunLifecycleFacts(device, false), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 31ac1cc05f..44e96aad64 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -16,6 +16,7 @@ import { limrunInteractionOperationFacts, limrunKeyboardOperationFacts, limrunAppEventOperationFacts, + limrunSettingsOperationFacts, limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, @@ -191,6 +192,7 @@ export function limrunAppLogFacts( ...limrunClipboardOperationFacts(device), ...limrunAppSwitcherOperationFacts(device), ...limrunAppEventOperationFacts(device), + ...limrunSettingsOperationFacts(device), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -241,6 +243,7 @@ export function limrunAppLogRecoveryFacts( ...limrunClipboardOperationFacts(device, liveSessionUnavailable), ...limrunAppSwitcherOperationFacts(device, liveSessionUnavailable), ...limrunAppEventOperationFacts(device, liveSessionUnavailable), + ...limrunSettingsOperationFacts(device, liveSessionUnavailable), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 6ea012c7ca..182394661f 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -4,6 +4,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; import { limrunAppEventOperationFacts, + limrunSettingsOperationFacts, limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, @@ -102,6 +103,20 @@ test('app-event delivery is admitted on both direct-session legs', () => { ); }); +// R58: back to the app-switcher split — the Android leg reuses the local family's interactor, +// and the iOS direct session's own `setSetting` throws. +test('settings ride the Android interactor and are refused on the iOS leg', () => { + expect(limrunSettingsOperationFacts(androidMobileDevice).setSetting).toEqual({ available: true }); + expect(limrunSettingsOperationFacts(iosDevice).setSetting).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose settings changes yet.', + }); + expect( + limrunSettingsOperationFacts(androidMobileDevice, liveSessionUnavailable).setSetting, + ).toEqual(liveSessionUnavailable); +}); + test('the iOS leg admits back/orientation but explicitly refuses home and tv-remote', () => { const facts = limrunNavigationOperationFacts(iosDevice); expect(facts.back).toEqual({ available: true }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index ab0bb0b39e..ac39da1282 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -18,6 +18,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -128,6 +129,11 @@ const clipboardUnavailableIos = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', } as const); +const settingsUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose settings changes yet.', +} as const); const appSwitcherUnavailableIos = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -296,6 +302,19 @@ export function limrunAppEventOperationFacts( }); } +/** + * `settings` splits the same way `app-switcher` does: the Android leg rides the local family's + * own interactor factory, while the iOS leg's `setSetting` throws. + */ +export function limrunSettingsOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? (device.platform === 'android' ? available : settingsUnavailableIos); + return Object.freeze({ ...settingsRuntimeOperationFacts({ setSetting: cell }) }); +} + export function limrunKeyboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index b06118cfcf..2d8386f400 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -265,10 +265,11 @@ test('captures through only the active exact WebDriver interactor', async () => expect(binding.facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); } - // tv-remote and every keyboard action always throw unsupported in this interactor regardless - // of reachability: no capability ever declared them. + // tv-remote, settings, and every keyboard action always throw unsupported in this interactor + // regardless of reachability: no capability ever declared them. for (const operation of [ 'tvRemote', + 'setSetting', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', @@ -330,6 +331,7 @@ test.each([ expect(facts.operations.triggerAppEvent.available).toBe(state.isSessionActive()); for (const operation of [ 'tvRemote', + 'setSetting', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 6977ccaeae..3e1a4a3bc3 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -25,6 +25,7 @@ import { import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -223,6 +224,17 @@ const appSwitcherUnavailable = Object.freeze({ hint: 'This WebDriver provider runtime does not expose the app switcher for this device.', } as const); +/** + * The WebDriver interactor's own `setSetting` always throws unsupported (its capability map + * declares `settings: unsupported`), so this cell is unavailable unconditionally rather than + * gated by interactor reachability — the same shape `tvRemote` takes. + */ +const settingsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose device settings.', +} as const); + const appEventUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -457,6 +469,7 @@ function webDriverFacts( writeClipboard: inactiveSession, appSwitcher: inactiveSession, triggerAppEvent: inactiveSession, + setSetting: inactiveSession, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: inactiveSession, prepareApplicationOpen: inactiveSession, @@ -495,6 +508,7 @@ function webDriverFacts( writeClipboard: clipboardUnavailable, appSwitcher: appSwitcherUnavailable, triggerAppEvent: appEventUnavailable, + setSetting: settingsUnavailable, lifecycle: webDriverLifecycleFacts(device), }); // Both capture cells need the same reachability: an interactor this provider can drive, on a @@ -573,6 +587,7 @@ function webDriverFacts( ...appEventRuntimeOperationFacts({ triggerAppEvent: interactorCell(reachable, appEventUnavailable), }), + ...settingsRuntimeOperationFacts({ setSetting: settingsUnavailable }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ensureReady: available, bootTarget: available, diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index 4079619082..4e5964381a 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -34,9 +34,9 @@ const TRIPWIRE_LINES = 1_000; // Exact current lengths. Lower a pin when its file shrinks; never raise one — extract instead. const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/__tests__/remote-connection.test.ts': 2973, - 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2310, + 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2301, 'src/commands/interaction/runtime/settle.test.ts': 2359, - 'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 2024, + 'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 2020, 'src/platforms/apple/core/__tests__/runner-session.test.ts': 2001, 'src/utils/__tests__/daemon-client.test.ts': 1873, 'src/utils/__tests__/output.test.ts': 1861, @@ -48,13 +48,13 @@ const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/platforms/apple/core/__tests__/runner-command-retry.test.ts': 1327, 'src/__tests__/cli-client-commands.test.ts': 1317, 'src/__tests__/cli-config.test.ts': 1282, - 'src/daemon/handlers/__tests__/find.test.ts': 1202, + 'src/daemon/handlers/__tests__/find.test.ts': 1199, 'src/platforms/apple/core/__tests__/perf.test.ts': 1222, 'src/mcp/__tests__/command-tools.test.ts': 1216, - 'src/daemon/handlers/__tests__/session-replay-divergence.test.ts': 1137, + 'src/daemon/handlers/__tests__/session-replay-divergence.test.ts': 1136, 'src/platforms/apple/core/__tests__/apps.test.ts': 1210, 'src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts': 1208, - 'src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts': 1183, + 'src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts': 1182, 'src/__tests__/client-metro.test.ts': 1105, 'src/__tests__/cli-network.test.ts': 1092, 'src/platforms/android/__tests__/snapshot-helper.test.ts': 1002, diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index b7f41161fd..0c61107fd7 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -73,8 +73,8 @@ test('daemon modularity baseline records the measured R7 ownership pressure', () Object.values(SESSION_STATE_FIELD_OWNERS).reduce((sum, owners) => sum + owners.length, 0), DAEMON_MODULARITY_BASELINE.sessionState.ownerFileClaims, ); - assert.equal(TYPE_CYCLE_BASELINE, 25); - assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 14); + assert.equal(TYPE_CYCLE_BASELINE, 21); + assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 12); assert.equal('daemon' in DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers, false); }); @@ -239,7 +239,7 @@ test('R10 zone overflow lists the whole zone so the joining member is visible', const [violation] = violations; assert.equal(violation!.rule, 'R10 daemon-modularity'); assert.equal(violation!.file, 'scripts/layering/daemon-modularity.ts'); - assert.match(violation!.message, /contains 15 daemon-server file\(s\) \(baseline 14\)/); + assert.match(violation!.message, /contains 13 daemon-server file\(s\) \(baseline 12\)/); for (const member of daemonMembers) { assert.ok(violation!.message.includes(member), `${member} missing from: ${violation!.message}`); } @@ -257,6 +257,6 @@ test('R9 rejects a baseline left above the measured cycle', () => { assert.equal(violations.length, 1); assert.match(violations[0]!.rule, /^R9 /); - assert.match(violations[0]!.message, /dropped to 24 files \(baseline 25\)/); + assert.match(violations[0]!.message, /dropped to 20 files \(baseline 21\)/); assert.match(violations[0]!.message, /Lower LARGEST_TYPE_CYCLE_ZONE_CEILINGS by the same 1/); }); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 648f468732..a7537c6314 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -4,8 +4,13 @@ import { SESSION_STATE_FIELD_OWNERS } from './session-state.ts'; const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { '(root)': 2, - core: 8, - 'daemon-server': 14, + // R58 retired the legacy command dispatcher, taking `core/dispatch.ts` and the + // `core/interactors.ts` registry it pulled in out of the cycle with it. + core: 6, + // Same move, daemon side: with no dispatcher to re-fire a tap through, the pending-outcome + // retry declares its own callback seam instead of importing runtime admission, so + // `interaction-outcome-policy.ts` and `deferred-interaction-outcome.ts` both left the cycle. + 'daemon-server': 12, // R42/R43/R45 deleted `vega/plugin.ts`'s `PUBLIC_COMMANDS` import (the retired // back/home/tv-remote closures were its only consumer), dropping it out of the cycle and // leaving `apple/plugin.ts` as the platforms zone's sole remaining member. diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 20ae284cff..a5c7feb96f 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -122,6 +122,7 @@ const CONTRACT_EXPORTS = [ '@agent-device/contracts/selector-observation-runtime', '@agent-device/contracts/session', '@agent-device/contracts/settings', + '@agent-device/contracts/settings-runtime', '@agent-device/contracts/snapshot', '@agent-device/contracts/snapshot-presentation', '@agent-device/contracts/snapshot-runtime', diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 60915a8cab..68bbb0b254 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -30,7 +30,8 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * leaves follow: back at R42, home at R43, orientation at R44, tv-remote at R45, and the * action-selected keyboard at R46. * The gesture cluster follows the touch leaves: gesture at R52, scroll at R53, swipe at R54. - * Wave 6 closure starts at R55 clipboard, then R56 app-switcher and R57 trigger-app-event. + * Wave 6 closure starts at R55 clipboard, then R56 app-switcher, R57 trigger-app-event, and + * R58 settings — the arm that retires the legacy dispatcher itself. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -1157,8 +1158,51 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, extensions: [appEventRetiredDispatchProjectionProof], }, + { + rule: 'R58 settings-runtime-cutover', + command: 'settings', + subject: 'device settings mutation', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // `settings` was the last `DISPATCH_HANDLERS` arm, so this row retires the legacy command + // dispatcher whole — its table, its three entry points, its name enumerator, and the + // request-router fallback that reached for it when no runtime route claimed a command. + // Every one of these names is now absent from production rather than shadowed. + routeNames: [ + 'dispatchCommand', + 'dispatchWithInteractor', + 'dispatchKnownCommand', + 'DISPATCH_HANDLERS', + 'listRegisteredDispatchCommandNames', + 'executeGenericPlatformCommand', + ], + // Settings also leaves the HarmonyOS overlay that granted it a bucket membership the + // descriptor never listed; the set still exists for `perf` and must no longer name it. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy settings support or hint closure', + }, + runtimeTypeNames: ['SettingsRuntimeOperations'], + operations: { names: ['setSetting'] }, + singularExecution: { + routes: ['handleSettingsCommand'], + operations: ['setSetting'], + operationOwners: { setSetting: ['executeSetSetting'] }, + }, + extensions: [settingsRetiredDispatchProjectionProof], + }, ]; +function settingsRetiredDispatchProjectionProof( + sources: ReadonlyMap, +): UnruledViolation[] { + return retiredDispatchProjectionViolations(sources, 'settings'); +} + function appEventRetiredDispatchProjectionProof( sources: ReadonlyMap, ): UnruledViolation[] { diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts index 515f1892e5..f9462ced7c 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-os-capability-table-parity.test.ts @@ -65,8 +65,6 @@ const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), - settings: (device) => - device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', // `audio` is NOT part of the AppleOS-table relocation — it stays the standalone // `isAudioProbeSupportedDevice` predicate. Included here only so the key-set // assertion stays strict (catches a dropped command) and confirms the rebase diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index 116fb8cfa8..388508f4b8 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -144,7 +144,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/facades/divergence.ts': 3, 'packages/contracts/src/facades/interaction.ts': 25, 'packages/contracts/src/facades/observability.ts': 7, - 'packages/contracts/src/facades/platform.ts': 54, + 'packages/contracts/src/facades/platform.ts': 55, 'packages/contracts/src/facades/progress.ts': 1, 'packages/contracts/src/facades/recording.ts': 3, 'packages/contracts/src/facades/remote.ts': 2, @@ -168,7 +168,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/platform-module.ts': 5, 'packages/contracts/src/platform-runtime-host.ts': 1, 'packages/contracts/src/platform-runtime-operations.ts': 2, - 'packages/contracts/src/platform-runtime-unavailable.ts': 26, + 'packages/contracts/src/platform-runtime-unavailable.ts': 27, 'packages/contracts/src/platform-runtime.ts': 6, 'packages/contracts/src/record-runtime-cutover.ts': 7, 'packages/contracts/src/react-native-overlay.ts': 1, @@ -243,16 +243,17 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // Added by #1993 (device-inventory context moved out of core). 'packages/contracts/src/back-runtime.ts': 4, - // Added by Wave 6 R55/R56/R57: the clipboard, app-switcher and app-event facets, plus the local - // interaction set Android and Linux used to hold a byte-identical copy of each. The set is its - // own module rather than part of the interactor catalog so the catalog's closure stays - // leaf-thin -- every module the set pulls in is one its two consumers already evaluate. + // Added by Wave 6 R55/R56/R57/R58: the clipboard, app-switcher, app-event and settings facets, + // plus the local interaction set Android and Linux used to hold a byte-identical copy of each. + // The set is its own module rather than part of the interactor catalog so the catalog's closure + // stays leaf-thin -- every module the set pulls in is one its two consumers already evaluate. 'packages/contracts/src/app-event-runtime.ts': 4, 'packages/contracts/src/app-switcher-runtime.ts': 4, 'packages/contracts/src/clipboard-runtime.ts': 4, - 'packages/contracts/src/local-interactor-operation-set.ts': 24, + 'packages/contracts/src/settings-runtime.ts': 4, + 'packages/contracts/src/local-interactor-operation-set.ts': 25, 'packages/contracts/src/home-runtime.ts': 4, - 'packages/contracts/src/interactor-operation-catalog.ts': 12, + 'packages/contracts/src/interactor-operation-catalog.ts': 13, 'packages/contracts/src/keyboard-runtime.ts': 4, 'packages/contracts/src/orientation-runtime.ts': 4, 'packages/contracts/src/tv-remote-runtime.ts': 4, @@ -283,8 +284,8 @@ export const HUB_BUDGETS: Readonly> = Object.freeze({ // the same PR added and only a source checkout reaches, and which therefore loads on demand // (`resolveLocalDaemonCodeSignature`) rather than appearing here. 'src/cli.ts': 365, - 'src/platform-runtime.ts': 42, - 'src/core/dispatch.ts': 82, + 'src/platform-runtime.ts': 43, + 'src/core/dispatch.ts': 80, 'src/core/capabilities.ts': 75, 'src/core/command-descriptor/registry.ts': 66, 'src/core/command-descriptor/platform-execution-entry.ts': 3, diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index c6ef0cc848..f9f31aaaaf 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -66,6 +66,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre writeClipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, + setSetting: unavailable, }); /** Default facts for tests that are unrelated to application lifecycle commands. */ diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 6e2f8cb32b..cc686ebecb 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -103,15 +103,6 @@ test('device capability matrix stays consistent across shared command groups', ( { device: macOsDevice, expected: true, label: 'on macOS' }, ], }, - { - commands: ['settings'], - checks: [ - { device: iosSimulator, expected: true, label: 'on iOS sim' }, - { device: iosDevice, expected: false, label: 'on iOS device' }, - { device: androidDevice, expected: true, label: 'on Android' }, - { device: macOsDevice, expected: true, label: 'on macOS' }, - ], - }, { commands: ['gesture', 'swipe'], checks: [ @@ -210,7 +201,6 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only 'perf', 'press', 'record', - 'settings', 'screenshot', 'scroll', 'snapshot', @@ -252,7 +242,7 @@ test('tvOS follows iOS capability matrix by device kind', () => { [{ device: tvOsSimulator, expected: true, label: 'on tvOS' }], ); assertCommandSupport( - ['settings', 'alert'], + ['alert'], [{ device: tvOsSimulator, expected: true, label: 'on tvOS simulator' }], ); }); @@ -275,6 +265,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'screenshot', 'scroll', 'snapshot', + 'settings', 'swipe', 'trigger-app-event', 'type', @@ -283,7 +274,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported [{ device: linuxDevice, expected: true, label: 'on Linux' }], ); assertCommandSupport( - ['alert', 'perf', 'settings'], + ['alert', 'perf'], [{ device: linuxDevice, expected: false, label: 'on Linux' }], ); }); @@ -312,6 +303,7 @@ test('web supports only the initial browser interaction slice', () => { 'screenshot', 'scroll', 'snapshot', + 'settings', 'swipe', 'trigger-app-event', 'type', @@ -320,7 +312,7 @@ test('web supports only the initial browser interaction slice', () => { [{ device: webDevice, expected: true, label: 'on web' }], ); assertCommandSupport( - ['alert', 'perf', 'settings'], + ['alert', 'perf'], [{ device: webDevice, expected: false, label: 'on web' }], ); assertCommandSupport( diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 46e1b78762..d9ddcf6547 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -126,8 +126,6 @@ const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, alert: (device) => device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), - settings: (device) => - device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', audio: supportsHostAudioProbe, }; const HINT_REF: Record string | undefined> = { @@ -231,7 +229,7 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', () // Runtime-backed navigation, keyboard, and touch commands dropped out of the matrix entirely: // capability buckets), so they are absent here — not because HarmonyOS admission changed, but // because there is no bucket left for `isCommandSupportedOnDevice` to consult at all. - assert.deepEqual(availableCommands, ['perf', 'settings']); + assert.deepEqual(availableCommands, ['perf']); }); test('(b.2) unsupportedHint closures are verbatim across the full device matrix', () => { diff --git a/src/core/__tests__/dispatch-back.test.ts b/src/core/__tests__/dispatch-back.test.ts deleted file mode 100644 index f7eca6bfb9..0000000000 --- a/src/core/__tests__/dispatch-back.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { dispatchCommand } from '../dispatch.ts'; -import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -// R42 retired `back` from `DISPATCH_HANDLERS`; its ADB-command-level parity pin now lives on -// `backAndroid` directly (`src/platforms/android/__tests__/input-actions.test.ts`), and its -// admitted-runtime behavior in `src/daemon/__tests__/back-runtime.test.ts`. -test('legacy dispatch no longer reaches the back leaf', async () => { - await withMockedAdb('agent-device-dispatch-back-retired-', async (argsLogPath) => { - await assert.rejects( - dispatchCommand(ANDROID_EMULATOR, 'back', [], undefined, { backMode: 'in-app' }), - { code: 'INVALID_ARGS', message: 'Unknown command: back' }, - ); - - const { promises: fs } = await import('node:fs'); - await assert.rejects( - fs.readFile(argsLogPath, 'utf8'), - { code: 'ENOENT' }, - 'no adb command was ever issued, so the args log was never created', - ); - }); -}); diff --git a/src/core/__tests__/dispatch-keyboard.test.ts b/src/core/__tests__/dispatch-keyboard.test.ts deleted file mode 100644 index deffa930cd..0000000000 --- a/src/core/__tests__/dispatch-keyboard.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { test, vi } from 'vitest'; -import assert from 'node:assert/strict'; - -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn() }; -}); - -import { dispatchCommand } from '../dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { ANDROID_EMULATOR, IOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); - -// R46 retired `keyboard` from `DISPATCH_HANDLERS` and its dedicated -// `handleAndroidKeyboardCommand`/`handleHarmonyKeyboardCommand`/`handleIosKeyboardCommand` -// helpers. Its ADB ENTER-keyevent parity pin now lives on `pressAndroidEnter` directly -// (`src/platforms/android/__tests__/input-actions.test.ts`); its iOS runner routing (including -// the dismiss-mechanism disclosure and degradation cases) is covered by the Apple interactor's -// own runner-provider suite (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`) -// and by `src/daemon/__tests__/keyboard-runtime.test.ts`, which also covers the admitted-runtime -// action-selection and per-platform response shapes. -test('legacy dispatch no longer reaches the keyboard leaf on Android', async () => { - await withMockedAdb('agent-device-dispatch-keyboard-retired-android-', async (argsLogPath) => { - await assert.rejects(dispatchCommand(ANDROID_EMULATOR, 'keyboard', ['enter']), { - code: 'INVALID_ARGS', - message: 'Unknown command: keyboard', - }); - - const { promises: fs } = await import('node:fs'); - await assert.rejects( - fs.readFile(argsLogPath, 'utf8'), - { code: 'ENOENT' }, - 'no adb command was ever issued, so the args log was never created', - ); - }); -}); - -test('legacy dispatch no longer reaches the keyboard leaf on iOS', async () => { - await assert.rejects( - dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, { - appBundleId: 'com.example.app', - }), - { - code: 'INVALID_ARGS', - message: 'Unknown command: keyboard', - }, - ); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); -}); diff --git a/src/core/__tests__/dispatch-orientation.test.ts b/src/core/__tests__/dispatch-orientation.test.ts deleted file mode 100644 index c1825fe1b8..0000000000 --- a/src/core/__tests__/dispatch-orientation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { test, vi } from 'vitest'; -import assert from 'node:assert/strict'; - -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn() }; -}); - -import { dispatchCommand } from '../dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { ANDROID_EMULATOR, IOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); - -// R44 retired `orientation` from `DISPATCH_HANDLERS`. Its ADB-command-level parity pin now lives -// on `setAndroidOrientation` directly (`src/platforms/android/__tests__/input-actions.test.ts`); -// its iOS runner routing (including the mismatched-readback rejection) is covered by the Apple -// interactor's own runner-provider suite -// (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`); its admitted-runtime -// behavior is covered in `src/daemon/__tests__/orientation-runtime.test.ts`. -test('legacy dispatch no longer reaches the orientation leaf on Android', async () => { - await withMockedAdb('agent-device-dispatch-orientation-retired-android-', async () => { - await assert.rejects(dispatchCommand(ANDROID_EMULATOR, 'orientation', ['left']), { - code: 'INVALID_ARGS', - message: 'Unknown command: orientation', - }); - }); -}); - -test('legacy dispatch no longer reaches the orientation leaf on iOS', async () => { - await assert.rejects(dispatchCommand(IOS_DEVICE, 'orientation', ['left']), { - code: 'INVALID_ARGS', - message: 'Unknown command: orientation', - }); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); -}); diff --git a/src/core/__tests__/dispatch-screenshot.test.ts b/src/core/__tests__/dispatch-screenshot.test.ts deleted file mode 100644 index fc5ca7829b..0000000000 --- a/src/core/__tests__/dispatch-screenshot.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { dispatchCommand } from '../dispatch.ts'; -import { withWebProvider, type WebProvider } from '../../platforms/web/provider.ts'; - -const webDevice = { - id: 'web', - name: 'Web', - platform: 'web', - kind: 'device', - booted: true, -} as const; - -test('legacy dispatch no longer reaches an interactor screenshot operation', async () => { - const screenshot = vi.fn(async () => undefined); - - await expect( - withWebProvider( - makeWebProvider({ screenshot }), - async () => await dispatchCommand(webDevice, 'screenshot', ['/tmp/out.png']), - ), - ).rejects.toMatchObject({ - code: 'INVALID_ARGS', - message: 'Unknown command: screenshot', - }); - - expect(screenshot).not.toHaveBeenCalled(); -}); - -function makeWebProvider(overrides: Partial = {}): WebProvider { - return { - open: async () => {}, - close: async () => {}, - snapshot: async () => ({ nodes: [] }), - screenshot: async () => {}, - setViewport: async () => {}, - click: async () => {}, - fill: async () => {}, - typeText: async () => {}, - scroll: async () => {}, - ...overrides, - }; -} diff --git a/src/core/__tests__/dispatch-tv-remote.test.ts b/src/core/__tests__/dispatch-tv-remote.test.ts deleted file mode 100644 index bb09d9fd19..0000000000 --- a/src/core/__tests__/dispatch-tv-remote.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { test, vi } from 'vitest'; -import assert from 'node:assert/strict'; - -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn() }; -}); - -import { dispatchCommand } from '../dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { ANDROID_TV_DEVICE, TVOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; -import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts'; - -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); - -// R45 retired `tv-remote` from `DISPATCH_HANDLERS` and its dedicated handler function. Its -// ADB D-pad-keyevent parity pin now lives on `pressAndroidTvRemote` directly -// (`src/platforms/android/__tests__/input-actions.test.ts`); its tvOS runner routing is covered -// by the Apple interactor's own runner-provider suite -// (`src/platforms/apple/__tests__/interactor-runner-provider.test.ts`); its TV-target admission -// (formerly an in-handler check, now an owner fact) and admitted-runtime behavior are covered in -// `src/daemon/__tests__/tv-remote-runtime.test.ts`. -test('legacy dispatch no longer reaches the tv-remote leaf on Android TV', async () => { - await withMockedAdb('agent-device-dispatch-tv-remote-retired-android-', async () => { - await assert.rejects(dispatchCommand(ANDROID_TV_DEVICE, 'tv-remote', ['right']), { - code: 'INVALID_ARGS', - message: 'Unknown command: tv-remote', - }); - }); -}); - -test('legacy dispatch no longer reaches the tv-remote leaf on tvOS', async () => { - await assert.rejects(dispatchCommand(TVOS_SIMULATOR, 'tv-remote', ['back']), { - code: 'INVALID_ARGS', - message: 'Unknown command: tv-remote', - }); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); -}); diff --git a/src/core/__tests__/dispatch-viewport.test.ts b/src/core/__tests__/dispatch-viewport.test.ts deleted file mode 100644 index c8b3057382..0000000000 --- a/src/core/__tests__/dispatch-viewport.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { dispatchCommand } from '../dispatch.ts'; -import { withWebProvider, type WebProvider } from '../../platforms/web/provider.ts'; - -const webDevice = { - id: 'web', - name: 'Web', - platform: 'web', - kind: 'device', - booted: true, -} as const; - -test('legacy dispatch no longer reaches the web interactor viewport operation', async () => { - const setViewport = vi.fn(async () => undefined); - const provider = makeWebProvider({ setViewport }); - - await expect( - withWebProvider( - provider, - async () => await dispatchCommand(webDevice, 'viewport', ['1280', '900']), - ), - ).rejects.toMatchObject({ - code: 'INVALID_ARGS', - message: 'Unknown command: viewport', - }); - - expect(setViewport).not.toHaveBeenCalled(); -}); - -function makeWebProvider(overrides: Partial = {}): WebProvider { - return { - open: async () => {}, - close: async () => {}, - snapshot: async () => ({ nodes: [] }), - screenshot: async () => {}, - setViewport: async () => {}, - click: async () => {}, - fill: async () => {}, - typeText: async () => {}, - scroll: async () => {}, - ...overrides, - }; -} diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index dfaf43d076..acdd389ee9 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -30,7 +30,7 @@ export type CommandCapability = { const WEB_DEVICE: KindMatrix = { device: true }; const HARMONYOS_ALL: KindMatrix = { emulator: true, device: true }; -const HARMONYOS_SUPPORTED_COMMANDS = new Set(['perf', 'settings']); +const HARMONYOS_SUPPORTED_COMMANDS = new Set(['perf']); const WEB_QUERY_COMMANDS = ['audio'] as const; const WEB_SUPPORTED_COMMANDS = new Set(WEB_QUERY_COMMANDS); // Built from the additive command-descriptor registry (ADR-0008, Phase 1 step 3). diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 317e59a345..c3137bc87e 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -13,7 +13,6 @@ import { type DaemonCommandDescriptor, } from '../../../daemon/daemon-command-registry.ts'; import type { DaemonRequest } from '../../../daemon/types.ts'; -import { listRegisteredDispatchCommandNames } from '../../dispatch.ts'; import { deriveDaemonCommandDescriptors, deriveStructuredBatchCommandNames } from '../derive.ts'; import { commandDescriptors, @@ -79,6 +78,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.reinstall, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.settings, PUBLIC_COMMANDS.shutdown, PUBLIC_COMMANDS.screenshot, PUBLIC_COMMANDS.snapshot, @@ -202,23 +202,17 @@ test('descriptor-only commands explicitly declare a non-public catalog group', ( } }); -test('platform dispatch command list is built from descriptor dispatch facets', () => { +// R58 retired the legacy platform dispatcher itself: `settings` was its last arm, so no +// descriptor projects into it any more. The successor property is one-sided and total — the +// `dispatch` facet is vestigial vocabulary, and a descriptor that grows one again would be +// declaring a dispatcher that no longer exists. +test('no descriptor projects into the retired platform dispatcher', () => { const dispatchCommands = commandDescriptors .filter((descriptor) => 'dispatch' in descriptor && descriptor.dispatch !== undefined) .map((descriptor) => descriptor.name) .sort(); - assert.deepEqual(listRegisteredDispatchCommandNames(), dispatchCommands); - assert.equal( - dispatchCommands.includes('read' as never), - false, - 'the read dispatch alias retired with the selector element-read cutover (#1739)', - ); - assert.equal( - dispatchCommands.includes(PUBLIC_COMMANDS.gesture), - false, - 'gesture executes through the typed runtime/backend seam', - ); + assert.deepEqual(dispatchCommands, []); }); test('generic route commands that reach platform dispatch declare the dispatch facet', () => { diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index ab877a8b56..8bfebae407 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -39,6 +39,7 @@ import { homeRuntimeUse, hoverRuntimeUses, appEventRuntimeUse, + settingsRuntimeUse, appSwitcherRuntimeUse, clipboardRuntimePlanUses, keyboardRuntimePlanUses, @@ -111,17 +112,10 @@ export type DescriptorCatalogRecord = { ]: Descriptor['name']; }; -export type DescriptorDispatchCommandName = - Extract<(typeof commandDescriptors)[number], { dispatch: object }> extends infer Descriptor - ? Descriptor extends { name: infer Name extends string } - ? Name - : never - : never; - /** * The literal union of every command whose `daemon.route` is `'session'`. * Drives `SESSION_COMMAND_HANDLER_IMPLS` in `src/daemon/handlers/session.ts` - * (mirrors `DescriptorDispatchCommandName` above): adding a session-routed + * : adding a session-routed * descriptor without a matching handler table entry is a compile error rather * than a runtime routing gap caught only by `expectHandlerResponse`. */ @@ -1016,18 +1010,16 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/settings.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'extended', + // R58 retires this command's capability bucket, its `dispatch` leaf, and its HarmonyOS + // overlay membership together: admission is the owner's `setSetting` fact, and the only + // execution is that one bound operation. The macOS setting-name gate stays daemon-side — + // it keys on the requested setting, which is not a device fact. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'snapshot', refFrameEffect: 'may-invalidate' }, - dispatch: {}, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [settingsRuntimeUse] }, }, // -- specialized routes -- diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index a4250bc7d2..988ebaa944 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -1,75 +1,19 @@ -import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; +import type { RunnerContext } from '@agent-device/contracts/interaction'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; -import { emitDiagnostic, withDiagnosticTimer } from '../utils/diagnostics.ts'; -import { readLocationCoordinate } from '../utils/location-coordinates.ts'; -import { successText, withSuccessText } from '../utils/success-text.ts'; -import type { DescriptorDispatchCommandName } from './command-descriptor/registry.ts'; import type { DispatchContext } from './dispatch-context.ts'; import { getInteractor } from './interactors.ts'; export type { DispatchContext } from './dispatch-context.ts'; export { resolveTargetDevice, resolveTargetDeviceSelection } from './dispatch-resolve.ts'; -export async function dispatchCommand( - device: DeviceInfo, - command: string, - positionals: string[], - outPath?: string, - context?: DispatchContext, -): Promise | void> { - const runnerCtx = runnerContextFromDispatchContext(context); - const interactor = await getInteractor(device, runnerCtx); - return await dispatchWithInteractor( - device, - interactor, - command, - positionals, - outPath, - context, - runnerCtx, - ); -} - -async function dispatchWithInteractor( - device: DeviceInfo, - interactor: Interactor, - command: string, - positionals: string[], - outPath: string | undefined, - context: DispatchContext | undefined, - runnerCtx: RunnerContext, -): Promise | void> { - emitDiagnostic({ - level: 'debug', - phase: 'platform_command_prepare', - data: { - command, - platform: device.platform, - kind: device.kind, - }, - }); - return await withDiagnosticTimer( - 'platform_command', - async () => { - return await dispatchKnownCommand( - device, - interactor, - command, - positionals, - outPath, - context, - runnerCtx, - ); - }, - { - command, - platform: device.platform, - }, - ); -} - +/** + * The legacy command dispatcher retired with R58: `settings` was its last arm, and every command + * that once routed through it now admits its exact owner's facts and executes one bound + * operation. What remains here is the single frame read that is not a command at all — + * `replay`/`test` still consume it through `session-replay-maestro-runtime.ts` (ADR 0019 §6: a + * shared helper stays in place until its last consumer can move). + */ export async function dispatchGestureViewport( device: DeviceInfo, context?: DispatchContext, @@ -92,210 +36,3 @@ function runnerContextFromDispatchContext(context?: DispatchContext): RunnerCont runnerLeaseContext: context?.runnerLeaseContext, }; } - -type DispatchCommand = DescriptorDispatchCommandName; - -type DispatchHandlerArgs = { - device: DeviceInfo; - interactor: Interactor; - positionals: string[]; - outPath: string | undefined; - context: DispatchContext | undefined; - runnerCtx: RunnerContext; -}; - -type DispatchHandler = (args: DispatchHandlerArgs) => Promise | void>; - -/** - * Descriptor-driven exhaustive dispatch table. The `Record` - * type forces every descriptor-declared dispatch command to have a handler — a - * missing entry is a COMPILE error, which replaces the former runtime `default: - * throw` as the coverage safety net. Each entry routes to the IDENTICAL handler - * with the IDENTICAL arguments the `switch` used, so dispatch stays strictly - * behaviorless. - */ -const DISPATCH_HANDLERS: Record = { - settings: ({ device, interactor, positionals, context }) => - handleSettingsCommand(device, interactor, positionals, context), -}; - -/** - * @internal Introspection helper used by parity tests. - */ -export function listRegisteredDispatchCommandNames(): string[] { - return Object.keys(DISPATCH_HANDLERS).sort(); -} - -async function dispatchKnownCommand( - device: DeviceInfo, - interactor: Interactor, - command: string, - positionals: string[], - outPath: string | undefined, - context: DispatchContext | undefined, - runnerCtx: RunnerContext, -): Promise | void> { - // `Object.hasOwn` keeps the lookup behaviorless: any unknown command — - // including inherited keys like `toString` — falls through to the same - // `INVALID_ARGS` error the former `default:` branch threw. - const handler = Object.hasOwn(DISPATCH_HANDLERS, command) - ? DISPATCH_HANDLERS[command as DispatchCommand] - : undefined; - if (!handler) { - throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); - } - return await handler({ device, interactor, positionals, outPath, context, runnerCtx }); -} - -// --------------------------------------------------------------------------- -// Command handlers -// --------------------------------------------------------------------------- - -async function handleSettingsCommand( - device: DeviceInfo, - interactor: Interactor, - positionals: string[], - context: DispatchContext | undefined, -): Promise> { - const [setting, state, target, mode] = positionals; - if (!setting || (!state && setting !== 'clear-app-state')) { - throw new AppError('INVALID_ARGS', 'settings requires setting state'); - } - if (setting === 'clear-app-state') { - return await handleClearAppStateSetting(device, interactor, state, target, context); - } - if (!state) { - throw new AppError('INVALID_ARGS', 'settings requires setting state'); - } - return await handleStandardSetting( - device, - interactor, - setting, - state, - target, - mode, - positionals, - context, - ); -} - -async function handleClearAppStateSetting( - device: DeviceInfo, - interactor: Interactor, - state: string | undefined, - target: string | undefined, - context: DispatchContext | undefined, -): Promise> { - const appBundleId = (state === 'clear' ? target : state) ?? context?.appBundleId; - if (!appBundleId) { - throw new AppError( - 'INVALID_ARGS', - 'settings clear-app-state requires an app id or an active app session.', - ); - } - emitDiagnostic({ - level: 'debug', - phase: 'settings_apply', - data: { setting: 'clear-app-state', state: 'clear', appBundleId, platform: device.platform }, - }); - const result = await interactor.setSetting('clear-app-state', 'clear', appBundleId); - return result && typeof result === 'object' - ? withSuccessText( - { setting: 'clear-app-state', state: 'clear', ...result }, - readResultMessage(result) ?? `Cleared user data for ${appBundleId}`, - ) - : { - setting: 'clear-app-state', - state: 'clear', - ...successText(`Cleared user data for ${appBundleId}`), - }; -} - -async function handleStandardSetting( - device: DeviceInfo, - interactor: Interactor, - setting: string, - state: string, - target: string | undefined, - mode: string | undefined, - positionals: string[], - context: DispatchContext | undefined, -): Promise> { - const isLocationSet = setting === 'location' && state === 'set'; - const usesPayloadAppBundleSlot = setting === 'permission' || isLocationSet; - const appBundleId = - (usesPayloadAppBundleSlot ? positionals[4] : positionals[2]) ?? context?.appBundleId; - emitDiagnostic({ - level: 'debug', - phase: 'settings_apply', - data: buildSettingsDiagnosticPayload( - device, - setting, - state, - target, - mode, - appBundleId, - isLocationSet, - ), - }); - const result = await interactor.setSetting( - setting, - state, - appBundleId, - buildSettingOptions(setting, target, mode, isLocationSet), - ); - return result && typeof result === 'object' - ? withSuccessText( - { setting, state, ...result }, - readResultMessage(result) ?? `Updated setting: ${setting}`, - ) - : { setting, state, ...successText(`Updated setting: ${setting}`) }; -} - -function buildSettingOptions( - setting: string, - target: string | undefined, - mode: string | undefined, - isLocationSet: boolean, -) { - if (setting === 'permission') { - return { permissionTarget: target, permissionMode: mode }; - } - if (isLocationSet) { - return { - latitude: readLocationCoordinate(target, 'latitude'), - longitude: readLocationCoordinate(mode, 'longitude'), - }; - } - return undefined; -} - -function buildSettingsDiagnosticPayload( - device: DeviceInfo, - setting: string, - state: string, - target: string | undefined, - mode: string | undefined, - appBundleId: string | undefined, - isLocationSet: boolean, -): Record { - if (isLocationSet) { - return { setting, state, latitude: target, longitude: mode, platform: device.platform }; - } - if (setting === 'permission') { - return { - setting, - state, - permissionTarget: target, - permissionMode: mode, - platform: device.platform, - }; - } - return { setting, state, appBundleId, platform: device.platform }; -} - -function readResultMessage(result: Record): string | undefined { - return typeof result.message === 'string' && result.message.length > 0 - ? result.message - : undefined; -} diff --git a/src/daemon/__tests__/deferred-interaction-outcome.test.ts b/src/daemon/__tests__/deferred-interaction-outcome.test.ts index 4afecbc437..3ba6f57746 100644 --- a/src/daemon/__tests__/deferred-interaction-outcome.test.ts +++ b/src/daemon/__tests__/deferred-interaction-outcome.test.ts @@ -6,6 +6,8 @@ import { resolveDeferredInteractionOutcome, type DeferredOutcomeSnapshotAttempt, } from '../deferred-interaction-outcome.ts'; +import type { InteractionRetryTap } from '../interaction-outcome-policy.ts'; +import { countDiagnosticEventsByPhase, withDiagnosticsScope } from '../../utils/diagnostics.ts'; import type { SessionState } from '../types.ts'; import { deliverySnapshot, @@ -13,21 +15,14 @@ import { pickupSnapshot, } from './post-gesture-stabilization-fixtures.ts'; -// The one device boundary in this cluster: pending-outcome retries re-dispatch -// the recorded tap through core dispatch. Everything else runs real. -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({})), - }; -}); -const { dispatchCommand } = await import('../../core/dispatch.ts'); -const dispatchMock = vi.mocked(dispatchCommand); +// The one device boundary in this cluster: a pending-outcome retry re-fires the recorded tap +// through the seam its caller supplies (R58). Everything else runs real, and a test that omits +// the seam is exercising a capture route that genuinely cannot tap. +const retryTap = vi.fn(async () => true); afterEach(() => { vi.useRealTimers(); - dispatchMock.mockClear(); + retryTap.mockClear(); }); function scriptedCapture(snapshots: ReturnType[]): { @@ -56,6 +51,7 @@ function resolveParams( logPath: '/tmp/agent-device-test.log', interactiveOnly: false, capture, + retryTap, }; } @@ -158,8 +154,8 @@ test('an unchanged surface retries the recorded tap once, then settles on the ch } const result = await pendingResult; - assert.equal(dispatchMock.mock.calls.length, 1); - assert.equal(dispatchMock.mock.calls[0]?.[1], 'press'); + assert.equal(retryTap.mock.calls.length, 1); + assert.deepEqual(retryTap.mock.calls[0]?.[0]?.point, { x: 100, y: 200 }); assert.equal( result?.snapshot.nodes.some((node) => node.identifier === 'shipping-delivery'), true, @@ -181,12 +177,46 @@ test('a changed surface settles immediately with no retry dispatch', async () => const result = await resolveDeferredInteractionOutcome(resolveParams(session, capture)); - assert.equal(dispatchMock.mock.calls.length, 0); + assert.equal(retryTap.mock.calls.length, 0); assert.equal(calls(), 1); assert.ok(result?.snapshot); assert.equal(session.pendingInteractionOutcome, undefined); }); +// R58: the policy owns whether to retry; its caller owns how the tap reaches the device. A +// capture route that carries no bindings (an internal capture decorating someone else's request) +// therefore hands no seam, and the retry must report that instead of burning an attempt. +test('a capture route with no retry seam reports a skip instead of spending an attempt', async () => { + const session = makeSession('ios'); + session.snapshot = pickupSnapshot(); + markDeferredInteractionOutcome({ + session, + command: 'click', + positionals: ['100', '200'], + flags: { interactionOutcome: { retryOnNoChange: true } }, + scheduleOutcomeRetry: true, + }); + const { capture } = scriptedCapture([pickupSnapshot()]); + + const observed = await withDiagnosticsScope({}, async () => { + const result = await resolveDeferredInteractionOutcome({ + ...resolveParams(session, capture), + retryTap: undefined, + }); + return { + result, + skips: countDiagnosticEventsByPhase(['interaction_no_change_retry_skipped']), + retries: countDiagnosticEventsByPhase(['interaction_no_change_retry']), + }; + }); + + assert.ok(observed.result?.snapshot); + assert.equal(retryTap.mock.calls.length, 0); + // One skip, no retry: nothing was delivered, so no attempt was spent on a refusal. + assert.equal(observed.skips, 1); + assert.equal(observed.retries, 0); +}); + test('a pending stabilization resolves through the quiet-window loop and clears itself', async () => { vi.useFakeTimers(); const session = makeSession('android'); diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 2512d6d27b..efd647835d 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -19,7 +19,6 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), }; }); @@ -35,14 +34,9 @@ vi.mock('../handlers/interaction-snapshot.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { captureSnapshotForSession } from '../handlers/interaction-snapshot.ts'; -import { - dispatchGenericCommand, - executeGenericPlatformCommand, -} from '../request-generic-dispatch.ts'; +import { dispatchGenericCommand } from '../request-generic-dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); const BEFORE_NODES = [ @@ -92,24 +86,16 @@ async function emulateCaptureSnapshotForSession( session: SessionState, flags: CommandFlags | undefined, sessionStore: SessionStore, - contextFromFlags: ( - flags: CommandFlags | undefined, - appBundleId?: string, - traceLogPath?: string, - ) => Record, options: { interactiveOnly: boolean }, ) { captureObservations.push({ postGestureStabilizationPending: session.postGestureStabilization !== undefined, }); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; - const snapshotData = (await mockDispatch( - session.device, - 'snapshot', - [], - effectiveFlags.out, - contextFromFlags(effectiveFlags, session.appBundleId, session.trace?.outPath), - )) as { nodes?: never[]; backend?: SnapshotBackend }; + const snapshotData = (await mockDispatch('snapshot')) as { + nodes?: never[]; + backend?: SnapshotBackend; + }; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); setSessionSnapshot(session, snapshot); sessionStore.set(session.name, session); @@ -118,7 +104,7 @@ async function emulateCaptureSnapshotForSession( function mockCommandDispatch(snapshots: Array) { let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + mockDispatch.mockImplementation(async (command) => { if (command === 'snapshot') { const nodes = snapshots[Math.min(snapshotCalls, snapshots.length - 1)]; snapshotCalls += 1; @@ -130,6 +116,18 @@ function mockCommandDispatch(snapshots: Array) { const contextFromFlags = () => ({}) as never; +/** + * The bound execution `dispatchGenericCommand` runs. R58 retired the legacy dispatcher this file + * used to borrow, so the double lives here: these tests are about the settle/stabilization + * orchestration around a generic leaf, not about which owner performs it. + */ +const platformExecution = vi.fn( + async (params: { command: string }) => await mockDispatch(params.command), +); + +/** Stands in for the device work a bound generic leaf performs, keyed by command name. */ +const mockDispatch = vi.fn<(command: string) => Promise>>(async () => ({})); + function seedSession(sessionName: string, sessionStore: SessionStore): SessionState { const session = makeIosSession(sessionName); setSessionSnapshot(session, buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest' }, {})); @@ -160,7 +158,7 @@ async function dispatchGeneric(params: { logPath: '', sessionStore: params.sessionStore, contextFromFlags, - executePlatformCommand: executeGenericPlatformCommand, + executePlatformCommand: platformExecution, }); } @@ -175,7 +173,10 @@ beforeEach(() => { mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation(emulateCaptureSnapshotForSession); + mockCaptureSnapshotForSession.mockImplementation( + (session, flags, sessionStore, _contextFromFlags, options) => + emulateCaptureSnapshotForSession(session, flags, sessionStore, options), + ); }); test('scroll --settle answers with the settled diff against the stored pre-action tree', async () => { @@ -228,7 +229,7 @@ test('back --settle answers with the settled diff alongside the command result', const sessionStore = makeSessionStore(); const sessionName = 'generic-settle-back'; const session = seedSession(sessionName, sessionStore); - mockDispatch.mockImplementation(async (_device, command) => { + mockDispatch.mockImplementation(async (command) => { if (command === 'snapshot') return { nodes: AFTER_NODES, backend: 'xctest' }; return { action: 'back', mode: 'in-app', message: 'Back' }; }); diff --git a/src/daemon/__tests__/interaction-retry-tap.test.ts b/src/daemon/__tests__/interaction-retry-tap.test.ts new file mode 100644 index 0000000000..0ed73cf5fe --- /dev/null +++ b/src/daemon/__tests__/interaction-retry-tap.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { TapPointInput } from '@agent-device/contracts/touch-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createInteractionRetryTap } from '../interaction-retry-tap.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +/** One owner cell, parametrized on whether it can tap — the only fact this adapter reads. */ +function bindings( + canTap: boolean, + taps: TapPointInput[], +): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime }> { + const facts = async () => { + const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); + return Object.freeze({ + device: base.device, + operations: { + ...base.operations, + tapPoint: canTap + ? ({ available: true } as const) + : ({ available: false, reason: 'owner-capability-missing' } as const), + }, + }); + }; + const inspectFacts: InspectDeviceRuntimeFacts = async () => await facts(); + const bindDevice: BindDeviceRuntime = (async (target: DeviceInfo, use) => { + const binding: DeviceBinding = Object.freeze({ + device: target, + owner: localRuntimeOwner('android'), + facts: await facts(), + operations: Object.freeze( + canTap + ? { + tapPoint: async (input: TapPointInput) => { + taps.push(input); + return {}; + }, + } + : {}, + ), + [Symbol.asyncDispose]: async () => undefined, + }) as DeviceBinding; + return narrowDeviceBinding(binding, use); + }) as BindDeviceRuntime; + return { inspectFacts, bindDevice }; +} + +test('a capture route with no runtime bindings has no retry seam to hand the policy', () => { + assert.equal(createInteractionRetryTap({}), undefined); + assert.equal(createInteractionRetryTap({ inspectFacts: async () => ({}) as never }), undefined); +}); + +test('the seam re-fires the recorded point through the owner-bound tapPoint', async () => { + const taps: TapPointInput[] = []; + const retryTap = createInteractionRetryTap(bindings(true, taps)); + assert.ok(retryTap); + + const fired = await retryTap({ + device, + point: { x: 100, y: 200 }, + context: { logPath: '/tmp/daemon.log', requestId: 'retry-1' }, + }); + + assert.equal(fired, true); + assert.equal(taps.length, 1); + assert.deepEqual(taps[0]?.point, { x: 100, y: 200 }); +}); + +// The policy spends an attempt only on a delivered tap, so a cell that cannot tap must answer +// `false` here rather than throwing out of the capture the retry was decorating. +test('an owner cell that cannot tap answers false instead of throwing', async () => { + const taps: TapPointInput[] = []; + const retryTap = createInteractionRetryTap(bindings(false, taps)); + assert.ok(retryTap); + + const fired = await retryTap({ + device, + point: { x: 100, y: 200 }, + context: { logPath: '/tmp/daemon.log' }, + }); + + assert.equal(fired, false); + assert.equal(taps.length, 0); +}); diff --git a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts index 08c582620f..bece22bd52 100644 --- a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts +++ b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts @@ -1,16 +1,52 @@ +import { vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SnapshotResult } from '@agent-device/contracts/interaction'; -import { dispatchCommand } from '../../core/dispatch.ts'; import type { captureSnapshotWithInteractor } from '../handlers/snapshot-interactor-capture.ts'; type CaptureParams = Parameters[0]; -/** Adapts legacy dispatch mocks while production uses the snapshot-specific interactor seam. */ +/** + * The capture double these suites drive their snapshots through. It kept the retired + * `dispatchCommand` signature when R58 deleted that dispatcher, so every suite that configured + * captures through it — and every assertion about which commands reached a device — reads + * unchanged; what moved is ownership. This is a test seam now, not an adapter over production + * dispatch. + */ +export const legacyDispatchCapture = vi.fn< + ( + device: DeviceInfo, + command: string, + positionals?: string[], + outPath?: string, + context?: Record, + ) => Promise | void> +>(async () => ({})); + +/** + * One reset for the whole seam: clears the double, restores its empty-payload default, and + * re-points the suite's mocked `captureSnapshotWithInteractor` at the adapter below. Every suite + * that drives captures this way wired the same four statements by hand; they are the seam's own + * mechanics, not per-suite policy, so they live with the seam. + */ +export function resetLegacySnapshotCapture( + mockedInteractorCapture: Readonly<{ + mockReset: () => void; + mockImplementation: (fn: typeof captureSnapshotThroughLegacyDispatchFixture) => void; + }>, +): void { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); + mockedInteractorCapture.mockReset(); + mockedInteractorCapture.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); +} + +/** Adapts the capture double into the snapshot-specific interactor seam production uses. */ export async function captureSnapshotThroughLegacyDispatchFixture({ device, runnerContext, options, }: CaptureParams): Promise { - return (await dispatchCommand(device, 'snapshot', [], undefined, { + return (await legacyDispatchCapture(device, 'snapshot', [], undefined, { ...runnerContext, ...options, snapshotInteractiveOnly: options.interactiveOnly, diff --git a/src/daemon/__tests__/request-router-dispatch-mocks.ts b/src/daemon/__tests__/request-router-dispatch-mocks.ts index f98d89eba4..491c6ded1e 100644 --- a/src/daemon/__tests__/request-router-dispatch-mocks.ts +++ b/src/daemon/__tests__/request-router-dispatch-mocks.ts @@ -21,7 +21,6 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { const { selectionFromResolveTargetDevice } = await import('./device-selection-stub.ts'); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: dispatchMocks.resolveTargetDevice, resolveTargetDeviceSelection: vi.fn( selectionFromResolveTargetDevice(dispatchMocks.resolveTargetDevice), diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 9b6b61f562..2e01fef201 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -3,14 +3,10 @@ import { createTestDeviceInventoryGatewaysFromProvider, } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -19,7 +15,6 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler, lifecycleDeviceRuntimeGateway, @@ -35,8 +30,6 @@ import { snapshotRuntimePlanUses, } from '@agent-device/contracts/platform-runtime-operations'; -const mockDispatch = vi.mocked(dispatchCommand); - function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { const runtime = snapshotRuntimeFixture(); return { @@ -90,8 +83,8 @@ function makeAndroidSession(name: string, id = 'emulator-5554'): SessionState { } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({ nodes: [] }); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({ nodes: [] }); }); function installGatedDispatch(): { @@ -104,7 +97,7 @@ function installGatedDispatch(): { let active = 0; let maxActive = 0; - mockDispatch.mockImplementation(async (device, command) => { + legacyDispatchCapture.mockImplementation(async (device, command) => { order.push(`start-${command}-${device.id}`); active += 1; maxActive = Math.max(maxActive, active); @@ -153,7 +146,7 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi }, }); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(response.ok).toBe(false); if (!response.ok) { expect(response.error.code).toBe('INVALID_ARGS'); @@ -310,7 +303,7 @@ test('fresh named sessions with the same name serialize first binding before rej 'end-snapshot-emulator-5554', ]); expect(dispatchGate.getMaxActive()).toBe(1); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); expect(sessionStore.get('qa-android')?.device.id).toBe('emulator-5554'); }); @@ -454,9 +447,9 @@ test('fresh named sessions reject incompatible selector combinations before bind expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toMatch(testCase.conflict); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(sessionStore.get(testCase.name)).toBeUndefined(); - mockDispatch.mockClear(); + legacyDispatchCapture.mockClear(); } }); @@ -493,7 +486,7 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a }, }); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(response.ok).toBe(false); if (!response.ok) { expect(response.error.code).toBe('INVALID_ARGS'); @@ -547,7 +540,7 @@ test('strip lock policy still refuses a request naming a different device, befor const sessionStore = makeSessionStore('agent-device-router-lock-'); sessionStore.set('qa-ios', makeIosSession('qa-ios')); let dispatchCalls = 0; - mockDispatch.mockImplementation(async () => { + legacyDispatchCapture.mockImplementation(async () => { dispatchCalls += 1; return {}; }); diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 23ce5a2950..86259f8482 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -26,7 +27,6 @@ vi.mock('../../platforms/apple/core/tool-provider.ts', async (importOriginal) => }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler, lifecycleDeviceRuntimeGateway, @@ -43,7 +43,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { inspectDeviceClaims } from '../device-claim-inspection.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); // The open path reaches readiness through its admitted package binding, so router-level @@ -106,8 +105,8 @@ function openRequest( } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); mockEnsureDeviceReady.mockReset(); mockEnsureDeviceReady.mockResolvedValue(undefined); @@ -362,7 +361,7 @@ test('proxy open without required lease metadata fails before device resolution' expect(response.error.message).toMatch(/Proxy open requires leaseId/); } expect(mockResolveTargetDevice).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('close releases the session lease', async () => { @@ -435,7 +434,7 @@ test('close rejects a different client before cleanup', async () => { expect(response.ok).toBe(false); expect(sessionStore.get('default')).toBeDefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(1); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('router serializes same-device open requests before first session creation finishes', async () => { diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 5ed3421d36..4c74c4e599 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -1,18 +1,13 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', () => ({ getRunnerSessionSnapshot: vi.fn(), })); -import { dispatchCommand } from '../../core/dispatch.ts'; import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner/runner-client.ts'; import { createRequestHandler, @@ -24,12 +19,11 @@ import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); for (const spy of Object.values(gestureRuntimeSpies)) spy.mockClear(); mockGetRunnerSessionSnapshot.mockReset(); }); @@ -82,7 +76,7 @@ test('router blocks non-record commands when recording was invalidated', async ( } expect(response.error.code).toBe('COMMAND_FAILED'); expect(response.error.message).toBe('iOS runner session restarted during recording'); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('router allows canonical iOS simulator gestures during overlay recording after runner restart', async () => { diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index a0b576e44c..303b6a6e59 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -28,7 +29,6 @@ vi.mock('../../platforms/apple/core/apps.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -43,7 +43,6 @@ import { ensureDeviceReady } from '../device-ready.ts'; // Readiness is package-owned; hold the open at the fixture's platform-neutral readiness gate. import { awaitFixtureReadiness } from './application-lifecycle-runtime-fixture.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); @@ -51,8 +50,8 @@ const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); beforeEach(() => { gestureRuntimeSpies.scrollDirection.mockClear(); systemRuntimeSpies.appSwitcher.mockClear(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); mockEnsureDeviceReady.mockReset(); @@ -91,7 +90,7 @@ test('replay runs active-session actions inside the parent request provider scop expect(response).toMatchObject({ ok: true }); // Both actions now reach bound operations — `app-switcher` through R56, `scroll down` through // R53 — so the flow drives the runtime gateway twice and the legacy dispatcher not at all. - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(appleRunnerProvider).toHaveBeenCalledTimes(1); @@ -127,7 +126,7 @@ test('replay routes session-changing actions through the full request path', asy expect(response).toMatchObject({ ok: true }); // `app-switcher` reaches its bound operation since R56; `runtime set` mutates session state and // never dispatched, so the legacy dispatcher sees neither action. - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(appleRunnerProvider).toHaveBeenCalledTimes(2); }); diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index de999f4c14..7b46191cba 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -7,11 +8,6 @@ import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; // `scroll` still executes through legacy platform dispatch; screenshot and click bind their fake // at the facts/bind seam below instead (ADR 0019). -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/android/window-state.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -20,7 +16,6 @@ vi.mock('../../platforms/android/window-state.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { screenshotRuntimeFixture, @@ -36,8 +31,6 @@ import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/devi import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeSession as makeBaseSession } from '../../__tests__/test-utils/session-factories.ts'; -const mockDispatch = vi.mocked(dispatchCommand); - function makeSession(name: string): SessionState { return makeBaseSession(name, { device: ANDROID_EMULATOR }); } @@ -66,8 +59,8 @@ function makeMacOsMenubarSession(name: string): SessionState { } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); }); type ScreenshotRouter = Readonly<{ diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index 03b3bbeed8..26201bdfaa 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -1,14 +1,10 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -17,7 +13,6 @@ vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOrigi vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); -import { dispatchCommand } from '../../core/dispatch.ts'; import { dispatchApplicationLifecycleEffect } from './application-lifecycle-runtime-fixture.ts'; import { createRequestHandler, @@ -36,7 +31,6 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, retriableForErrorCode } from '@agent-device/kernel/errors'; import { supportedPlatformsForCommand } from '../../core/capabilities.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockLifecycleEffect = vi.mocked(dispatchApplicationLifecycleEffect); /** @@ -87,7 +81,7 @@ function request(command: string, overrides: Partial = {}): Daemo } beforeEach(() => { - mockDispatch.mockReset(); + legacyDispatchCapture.mockReset(); systemRuntimeSpies.appSwitcher.mockReset(); systemRuntimeSpies.appSwitcher.mockResolvedValue(undefined); mockLifecycleEffect.mockReset(); @@ -162,7 +156,7 @@ test('deterministic errors (INVALID_ARGS) are returned with the default shape expect(response.error.code).toBe('INVALID_ARGS'); expect('retriable' in response.error).toBe(false); expect('supportedOn' in response.error).toBe(false); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); // ADR 0012 decision 6, BLOCKER 2 (second follow-up): a repair-armed `close` @@ -243,7 +237,7 @@ test('#1391: an ordinary close-time script-save failure surfaces details.reason/ // Unlike the repair-armed case above, an ordinary session's teardown // never withholds on a failed script save — it is always torn down. expect(sessionStore.get('typed-error')).toBeUndefined(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); } finally { fs.rmSync(targetPath, { force: true }); } diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index ecbad97d46..4715550341 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test, vi } from 'vitest'; +import { afterEach, expect, test } from 'vitest'; import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { @@ -8,34 +8,15 @@ import { } from '../../request/cancel.ts'; import { dispatchSnapshotDiffViaRuntime } from '../snapshot-diff-runtime.ts'; import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; -const dispatchCommandMock = vi.hoisted(() => vi.fn()); - -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: dispatchCommandMock, - }; -}); - -vi.mock('../handlers/snapshot-interactor-capture.ts', () => ({ - captureSnapshotWithInteractor: vi.fn( - async ({ device, runnerContext, options }) => - await dispatchCommandMock(device, 'snapshot', [], undefined, { - ...runnerContext, - ...options, - snapshotInteractiveOnly: options.interactiveOnly, - snapshotPreferredBackend: options.preferredBackend, - snapshotDepth: options.depth, - snapshotScope: options.scope, - snapshotRaw: options.raw, - snapshotCustomActions: options.customActions, - snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, - }), - ), -})); +/** + * The capture double the snapshot runtime fixture's bound operation delegates to. Cancellation is + * what these tests are about, so the double is the one place that can observe the signal the + * request scope handed the binding. + */ +const captureMock = legacyDispatchCapture; type Deferred = { promise: Promise; @@ -51,7 +32,7 @@ function deferred(): Deferred { } afterEach(() => { - dispatchCommandMock.mockReset(); + captureMock.mockReset(); }); for (const command of ['snapshot', 'diff snapshot'] as const) { @@ -66,7 +47,7 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { const releaseDispatch = deferred(); let observedSignal: AbortSignal | undefined; - dispatchCommandMock.mockImplementation(async (...args: unknown[]) => { + captureMock.mockImplementation(async (...args) => { const context = args[4] as { signal?: AbortSignal } | undefined; observedSignal = context?.signal; dispatchEntered.resolve(); diff --git a/src/daemon/__tests__/snapshot-quality-latch.test.ts b/src/daemon/__tests__/snapshot-quality-latch.test.ts index 4e19329a3b..bdb7a81049 100644 --- a/src/daemon/__tests__/snapshot-quality-latch.test.ts +++ b/src/daemon/__tests__/snapshot-quality-latch.test.ts @@ -15,18 +15,9 @@ import { dispatchSnapshotDiffViaRuntime } from '../snapshot-diff-runtime.ts'; import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; -const dispatchCommandMock = vi.hoisted(() => vi.fn()); - -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: dispatchCommandMock, - }; -}); - vi.mock('../handlers/snapshot-interactor-capture.ts', async () => { const fixture = await import('./legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; @@ -184,7 +175,7 @@ function scenario() { } function seedCapture(verdict: SnapshotQualityVerdict, label = 'Continue') { - dispatchCommandMock.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', truncated: false, quality: verdict, @@ -293,7 +284,7 @@ test('an empty ref-scoped diff latches on the captured verdict, not the retained ], }; // The runner owns scope publication and returns the healthy empty projection for a miss. - dispatchCommandMock.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', truncated: false, quality: deferredVerdict(), diff --git a/src/daemon/__tests__/snapshot-runtime-fixture.ts b/src/daemon/__tests__/snapshot-runtime-fixture.ts index e86693a385..12a8d2ddea 100644 --- a/src/daemon/__tests__/snapshot-runtime-fixture.ts +++ b/src/daemon/__tests__/snapshot-runtime-fixture.ts @@ -5,6 +5,11 @@ import { providerRuntimeOwner, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; +import { + type SetSettingInput, + settingsRuntimeOperationFacts, +} from '@agent-device/contracts/settings-runtime'; import { type CaptureScreenshotInput, screenshotRuntimeOperationFacts, @@ -14,8 +19,8 @@ import { type SnapshotResult, snapshotRuntimeOperationFacts, } from '@agent-device/contracts/snapshot-runtime'; -import { deviceShape, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { dispatchCommand, type DispatchContext } from '../../core/dispatch.ts'; +import { deviceShape, isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { type DispatchContext } from '../../core/dispatch.ts'; import { getRequestSignal } from '../../request/cancel.ts'; import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; @@ -29,6 +34,19 @@ import { writeSolidPng } from './screenshot-runtime-fixture.ts'; */ export const fixtureScreenshotCaptures: CaptureScreenshotInput[] = []; +/** + * Every mutation the fixture's bound settings operation received, newest last. The neutral input + * is the whole assertion surface now: whether a request reached the owner, and with which setting, + * state and resolved app id, is exactly what the retired positional dispatch used to witness. + */ +export const fixtureSettingsMutations: SetSettingInput[] = []; + +/** Clears both recorders so a suite can assert "the owner was never reached" from a known zero. */ +export function resetSnapshotRuntimeFixture(): void { + fixtureScreenshotCaptures.length = 0; + fixtureSettingsMutations.length = 0; +} + /** Request-scoped snapshot seam for handler tests that mock the legacy leaf dispatch. */ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; @@ -46,6 +64,10 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ fixtureScreenshotCaptures.push(input); writeSolidPng(input.outPath); }; + const setSetting = async (input: SetSettingInput) => { + fixtureSettingsMutations.push(input); + return {}; + }; return narrowDeviceBinding( { device, @@ -58,6 +80,7 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ captureSnapshotWithCustomActions: captureSnapshot, captureSnapshotWithoutActiveApp: captureSnapshot, captureScreenshot, + setSetting, }, [Symbol.asyncDispose]: async () => {}, }, @@ -68,6 +91,17 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ return { inspectFacts, bindDevice }; } +/** A physical Apple device that is not the macOS host: the one settings refusal these suites use. */ +function appleHostOrSimulatorOnly(device: DeviceInfo): boolean { + return device.platform === 'apple' && device.kind === 'device' && !isMacOs(device); +} + +const settingsUnavailable = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', +} as const; + async function snapshotFacts(device: DeviceInfo): Promise> { const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); const providerOwned = isActiveProviderDevice(device); @@ -96,6 +130,13 @@ async function snapshotFacts(device: DeviceInfo): Promise & ScreenshotRuntimeBindings, -): Promise { +): Promise { switch (params.req.command) { case 'screenshot': return await resolveScreenshotGenericExecution(params); @@ -84,6 +91,13 @@ export async function resolveGenericRuntimeExecution( bindDevice: params.bindDevice, }); default: - return undefined; + return { + ok: false, + response: errorResponse( + 'COMMAND_FAILED', + `${params.req.command} has no runtime execution on the generic route`, + { reason: 'generic-route-runtime-missing' }, + ), + }; } } diff --git a/src/daemon/handlers/__tests__/find-single-bind.test.ts b/src/daemon/handlers/__tests__/find-single-bind.test.ts index f266c90324..5f06ae4329 100644 --- a/src/daemon/handlers/__tests__/find-single-bind.test.ts +++ b/src/daemon/handlers/__tests__/find-single-bind.test.ts @@ -9,17 +9,7 @@ import { runtimeBindingSpies, } from './interaction-get-runtime-fixture.ts'; import { invokeFindHandler } from './find-handler-fixture.ts'; - -const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: mockDispatch, - resolveTargetDevice: actual.resolveTargetDevice, - }; -}); +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); @@ -28,7 +18,7 @@ vi.mock('../snapshot-interactor-capture.ts', async () => { beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); + legacyDispatchCapture.mockReset(); }); async function runMutatingFind(positionals: string[], node: Record) { @@ -36,7 +26,7 @@ async function runMutatingFind(positionals: string[], node: Record + legacyDispatchCapture.mockImplementation(async (_device, command) => command === 'snapshot' ? { nodes: [node] } : {}, ); return await invokeFindHandler({ diff --git a/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts b/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts index 7c36e805b1..c54fec8306 100644 --- a/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/find-touch-runtime-fixture.ts @@ -1,6 +1,5 @@ -import { vi } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { getRuntimeBindings, mockFillPoint, @@ -11,16 +10,20 @@ import { export { mockFocusPoint }; export const findTouchRuntimeBindings = getRuntimeBindings; -export const mockDispatch = vi.mocked(dispatchCommand); +/** + * Find's delegated touch legs record their calls on the shared capture double, so the suite can + * assert which command each leg re-invoked without a dispatcher to observe (R58). + */ +export const mockDispatch = legacyDispatchCapture; export function resetFindTouchRuntimeFixture(): void { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? { nodes: [] } : {}; }); mockTapPoint.mockImplementation(async (input) => { - return await dispatchCommand( + return await legacyDispatchCapture( IOS_SIMULATOR, 'press', [String(input.point.x), String(input.point.y)], @@ -29,7 +32,7 @@ export function resetFindTouchRuntimeFixture(): void { ); }); mockFillPoint.mockImplementation(async (input) => { - return await dispatchCommand( + return await legacyDispatchCapture( IOS_SIMULATOR, 'fill', [String(input.point.x), String(input.point.y), input.text], diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index 7d1a80a3ab..33e6e25ae4 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -14,9 +14,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async (_device: unknown, command: string) => { - return command === 'snapshot' ? { nodes: [] } : {}; - }), resolveTargetDevice: actual.resolveTargetDevice, }; }); diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index a569831fb2..74f70918ec 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -387,6 +387,7 @@ function sourceRuntimeFacts( writeClipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, + setSetting: unavailable, deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/interaction-common.test.ts b/src/daemon/handlers/__tests__/interaction-common.test.ts index 5a4509325d..91f2614b73 100644 --- a/src/daemon/handlers/__tests__/interaction-common.test.ts +++ b/src/daemon/handlers/__tests__/interaction-common.test.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '@agent-device/contracts/command'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { makeIosSession, @@ -20,20 +21,17 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), }; }); -import { dispatchCommand } from '../../../core/dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockFillPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'fill', [String(input.point.x), String(input.point.y), input.text], @@ -159,7 +157,7 @@ test('parameterized fill scrubs concatenated backend values and object keys thro backend: 'xctest', }; sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => + legacyDispatchCapture.mockImplementation(async (_device, command) => command === 'fill' ? { message: `prefix${secret}suffix`, @@ -194,8 +192,8 @@ test('parameterized fill scrubs concatenated backend values and object keys thro expect(response.data).not.toHaveProperty(`prefix${placeholder}suffix`); expect(JSON.stringify(response.data)).not.toContain(secret); expect(JSON.stringify(session.actions)).not.toContain(secret); - expect(mockDispatch.mock.calls[0]?.[1]).toBe('fill'); - expect(mockDispatch.mock.calls[0]?.[2]).toContain(secret); + expect(legacyDispatchCapture.mock.calls[0]?.[1]).toBe('fill'); + expect(legacyDispatchCapture.mock.calls[0]?.[2]).toContain(secret); }); test('parameterized fill collapses whitespace-only backend echoes through the handler route', async () => { @@ -220,7 +218,7 @@ test('parameterized fill collapses whitespace-only backend echoes through the ha backend: 'xctest', }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ [`prefix${secret}suffix`]: { [`key${secret}tail`]: `value${secret}tail`, }, @@ -253,7 +251,7 @@ test('parameterized fill collapses whitespace-only backend echoes through the ha expect(session.actions[0]?.result).not.toHaveProperty(placeholder); expect(session.actions[0]?.result?.selectorChain).not.toContain(`value="prefix${secret}suffix"`); expect(JSON.stringify(session.actions)).not.toContain(secret); - expect(mockDispatch.mock.calls[0]?.[2]).toContain(secret); + expect(legacyDispatchCapture.mock.calls[0]?.[2]).toContain(secret); }); test.each([ diff --git a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts index 7762de6c64..f15bbc169c 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -174,6 +174,7 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), }; }); @@ -46,7 +45,6 @@ vi.mock('../snapshot-interactor-capture.ts', async () => { }; }); -const mockDispatch = vi.mocked(dispatchCommand); const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); const contextFromFlags = (flags: CommandFlags | undefined) => ({ @@ -95,9 +93,9 @@ async function runClick( beforeEach(() => { resetGetRuntimeFixture(); mockCaptureSnapshotForSession.mockClear(); - mockDispatch.mockReset(); + legacyDispatchCapture.mockReset(); mockTapPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'press', [String(input.point.x), String(input.point.y)], @@ -115,7 +113,7 @@ test('a changed post-action capture corroborates an iOS tap reported as failed', snapshot: snapshot(profileNodes), }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -133,7 +131,7 @@ test('a changed post-action capture corroborates an iOS tap reported as failed', expect(response.data?.warning).toMatch(/post-action accessibility capture changed/); expect(response.data?.selector).toBe('id="unfollow"'); } - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'press')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'press')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -145,7 +143,7 @@ test('an unchanged post-action capture keeps a failed iOS tap failed', async () snapshot: snapshot(profileNodes), }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -178,19 +176,21 @@ test('a private-ax baseline pins the corroboration probe to private-ax', async ( }), ); const snapshotContexts: Array | undefined> = []; - mockDispatch.mockImplementation(async (_device, command, _positionals, _outPath, context) => { - if (command === 'press') { - throw new AppError( - 'XCTEST_RECORDED_FAILURE', - 'XCTest recorded a failure while executing tap; the action may not have been performed.', - ); - } - if (command === 'snapshot') { - snapshotContexts.push(context as Record | undefined); - return snapshotPayload(imageViewerNodes, 'private-ax'); - } - return {}; - }); + legacyDispatchCapture.mockImplementation( + async (_device, command, _positionals, _outPath, context) => { + if (command === 'press') { + throw new AppError( + 'XCTEST_RECORDED_FAILURE', + 'XCTest recorded a failure while executing tap; the action may not have been performed.', + ); + } + if (command === 'snapshot') { + snapshotContexts.push(context as Record | undefined); + return snapshotPayload(imageViewerNodes, 'private-ax'); + } + return {}; + }, + ); const response = await runClick(sessionStore, sessionName); @@ -216,7 +216,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' snapshot: snapshot(profileNodes, 'private-ax', { raw: true }), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -230,7 +230,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' const response = await runClick(sessionStore, sessionName); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); }); test('a tree baseline does not pin the corroboration probe backend', async () => { @@ -244,19 +244,21 @@ test('a tree baseline does not pin the corroboration probe backend', async () => }), ); const snapshotContexts: Array | undefined> = []; - mockDispatch.mockImplementation(async (_device, command, _positionals, _outPath, context) => { - if (command === 'press') { - throw new AppError( - 'XCTEST_RECORDED_FAILURE', - 'XCTest recorded a failure while executing tap; the action may not have been performed.', - ); - } - if (command === 'snapshot') { - snapshotContexts.push(context as Record | undefined); - return snapshotPayload(imageViewerNodes); - } - return {}; - }); + legacyDispatchCapture.mockImplementation( + async (_device, command, _positionals, _outPath, context) => { + if (command === 'press') { + throw new AppError( + 'XCTEST_RECORDED_FAILURE', + 'XCTest recorded a failure while executing tap; the action may not have been performed.', + ); + } + if (command === 'snapshot') { + snapshotContexts.push(context as Record | undefined); + return snapshotPayload(imageViewerNodes); + } + return {}; + }, + ); const response = await runClick(sessionStore, sessionName); @@ -275,7 +277,7 @@ test('a changed capture from a different iOS backend keeps the tap failure', asy snapshot: snapshot(profileNodes), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -303,7 +305,7 @@ test('a sparse changed capture keeps the tap failure', async () => { snapshot: snapshot(profileNodes), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -336,7 +338,7 @@ test('a corroboration capture failure keeps the tap failure', async () => { snapshot: snapshot(profileNodes), }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -366,7 +368,7 @@ test('the canonical selector capture aligns presentation before corroboration', snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -396,7 +398,7 @@ test('corroborates a tap when the request carries no flags and the baseline used }), ); let snapshotCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -446,7 +448,7 @@ test('a changed capture after ordinary agent turn latency still corroborates the snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -463,7 +465,7 @@ test('a changed capture after ordinary agent turn latency still corroborates the if (response?.ok) { expect(response.data?.warning).toMatch(/post-action accessibility capture changed/); } - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -479,7 +481,7 @@ test('the canonical selector capture replaces a stale baseline before corroborat snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -493,7 +495,7 @@ test('the canonical selector capture replaces a stale baseline before corroborat const response = await runClick(sessionStore, sessionName); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -509,7 +511,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor snapshot: baseline, }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -523,7 +525,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor const response = await runClick(sessionStore, sessionName); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); expect(sessionStore.get(sessionName)?.actions).toHaveLength(1); }); @@ -538,7 +540,7 @@ test('runtime-resolved taps use the same corroboration boundary', async () => { }), ); let snapshotCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( 'XCTEST_RECORDED_FAILURE', @@ -572,7 +574,7 @@ test('a corroborated runtime coordinate tap does not schedule a no-change retry' }), ); let pressCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { pressCount += 1; if (pressCount === 1) { @@ -630,7 +632,7 @@ test('corroborated runtime taps retain target evidence through save and replay', let recording = true; let snapshotCount = 0; let pressCount = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { pressCount += 1; if (recording) { diff --git a/src/daemon/handlers/__tests__/interaction-settle.test.ts b/src/daemon/handlers/__tests__/interaction-settle.test.ts index e77cac4b4d..80b2ded1c7 100644 --- a/src/daemon/handlers/__tests__/interaction-settle.test.ts +++ b/src/daemon/handlers/__tests__/interaction-settle.test.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '@agent-device/contracts/command'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { handleInteractionCommands } from '../interaction.ts'; import type { SessionStore } from '../../session-store.ts'; @@ -29,7 +30,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), }; }); @@ -45,9 +45,7 @@ vi.mock('../interaction-snapshot.ts', async (importOriginal) => { }; }); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotForSession } from '../interaction-snapshot.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); const BEFORE_NODES = [ @@ -86,7 +84,7 @@ async function emulateCaptureSnapshotForSession( options: { interactiveOnly: boolean }, ) { const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; - const snapshotData = (await mockDispatch( + const snapshotData = (await legacyDispatchCapture( session.device, 'snapshot', [], @@ -140,7 +138,7 @@ test('interaction runtime inherits the registered daemon request signal', () => function mockCommandDispatch(params: { snapshots: Array }) { let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { const nodes = params.snapshots[Math.min(snapshotCalls, params.snapshots.length - 1)]; snapshotCalls += 1; @@ -154,10 +152,10 @@ const contextFromFlags = () => ({}); beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockTapPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'press', [String(input.point.x), String(input.point.y)], @@ -166,7 +164,7 @@ beforeEach(() => { ); }); mockFillPoint.mockImplementation(async (input) => { - return await mockDispatch( + return await legacyDispatchCapture( IOS_SIMULATOR, 'fill', [String(input.point.x), String(input.point.y), input.text], @@ -328,7 +326,7 @@ test('press --settle rejects an expired-frame ref before dispatch or observation // ADR 0014: a device action since the snapshot expired the ref frame. session.refFrameState = 'expired'; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for an expired-frame ref'), ); @@ -362,7 +360,7 @@ test('a settle observation without a diff leaves ref staleness untouched', async const sessionName = 'settle-stalled'; seedSession(sessionName, sessionStore); let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { snapshotCalls += 1; if (snapshotCalls === 1) return { nodes: BEFORE_NODES, backend: 'xctest' }; diff --git a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts index 6b9f8c1e06..ec35c1cd39 100644 --- a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts +++ b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '@agent-device/contracts/command'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -29,7 +30,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), }; }); @@ -48,14 +48,11 @@ vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOr }); import { getRuntimeBindings, resetGetRuntimeFixture } from './interaction-get-runtime-fixture.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); - const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); resetGetRuntimeFixture(); @@ -194,7 +191,7 @@ test('get text simple iOS id selector while recording skips the direct runner qu const sessionName = 'recording-get-direct-gate'; const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', @@ -235,7 +232,7 @@ test('get text simple iOS id selector while recording skips the direct runner qu expect.objectContaining({ command: 'querySelector' }), expect.anything(), ); - expect(mockDispatch.mock.calls.map((call) => call[1])).toContain('snapshot'); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toContain('snapshot'); const recordedAction = sessionStore.get(sessionName)?.actions[0]; expect(recordedAction?.targetEvidence).toMatchObject({ @@ -333,7 +330,7 @@ test('press on an identity-empty container: container-based daemon response, des // would burn wall-clock time this unit lane must not spend. const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); sessionStore.set(sessionName, session); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', nodes: IDENTITY_EMPTY_ROW_NODES }; } @@ -366,7 +363,7 @@ test('press on an identity-empty container: container-based daemon response, des // --------------------------------------------------------------------------- function mockSnapshotWithSaveButton() { - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', nodes: SAVE_BUTTON_NODES }; } diff --git a/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts b/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts index b44442c1de..5b652c193e 100644 --- a/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts +++ b/src/daemon/handlers/__tests__/interaction-type-android-readiness.test.ts @@ -11,13 +11,6 @@ import { resetGetRuntimeFixture, } from './interaction-get-runtime-fixture.ts'; -const { mockDispatch } = vi.hoisted(() => ({ mockDispatch: vi.fn() })); - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: mockDispatch }; -}); - vi.mock('../../../platforms/android/snapshot.ts', () => ({ snapshotAndroid: vi.fn() })); vi.mock('../../../platforms/android/window-state.ts', async (importOriginal) => { @@ -34,8 +27,6 @@ import { snapshotAndroid } from '../../../platforms/android/snapshot.ts'; beforeEach(() => { resetGetRuntimeFixture(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); vi.mocked(snapshotAndroid).mockReset(); vi.mocked(snapshotAndroid).mockRejectedValue( new AppError( @@ -72,15 +63,9 @@ test('type continues and composes the recording readiness warning', async () => }); expect(response?.ok).toBe(true); - // R41: the text reaches the device through the bound operation, never through dispatch. + // R41: the text reaches the device through the bound operation. That it reaches no other path + // is R58's claim now — there is no dispatcher left for this suite to watch. expect(mockTypeText).toHaveBeenCalledWith(expect.objectContaining({ text: 'hello' })); - expect(mockDispatch).not.toHaveBeenCalledWith( - expect.anything(), - 'type', - expect.anything(), - expect.anything(), - expect.anything(), - ); if (response?.ok) { expect(response.data?.warning).toMatch( /Android blocking-dialog readiness could not be inspected.*command continued/i, diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 2431972016..46b733b90c 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1,4 +1,5 @@ import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { WEB_DESKTOP_DEVICE } from '../../../__tests__/test-utils/device-fixtures.ts'; import { @@ -29,7 +30,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), }; }); @@ -64,8 +64,6 @@ import { mockReadTextAtPoint, resetGetRuntimeFixture, } from './interaction-get-runtime-fixture.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; -const mockDispatch = vi.mocked(dispatchCommand); import { getAndroidAppState, getAndroidBlockingDialogObservation, @@ -73,8 +71,8 @@ import { const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogObservation = vi.mocked(getAndroidBlockingDialogObservation); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockGetAndroidAppState.mockReset(); mockGetAndroidAppState.mockResolvedValue({}); mockGetAndroidBlockingDialogObservation.mockReset(); @@ -103,7 +101,7 @@ test('get text prefers underlying value for text surfaces and avoids recording g }; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); @@ -172,7 +170,7 @@ test('get text uses backend read expansion when the resolved node has a rect', a }); // The live read now reaches the bound runtime operation, not the legacy `read` dispatch. - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(mockReadTextAtPoint).toHaveBeenCalledTimes(1); expect(mockReadTextAtPoint.mock.calls[0]?.[0].point).toEqual({ x: 80, y: 80 }); expect(response?.ok).toBe(true); @@ -219,7 +217,7 @@ test('get text answers from the captured tree when the bound owner advertises no // Preferred-operation absence is not a failure and not a fallback: the required capture path // answers completely, and nothing reaches the legacy dispatcher. expect(mockReadTextAtPoint).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(response?.ok).toBe(true); if (response?.ok) { expect(response.data?.text).toBe('preview only'); @@ -259,7 +257,7 @@ test('an eligible direct iOS selector cannot operate before admission', async () if (response && !response.ok) expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); // The whole point: the fast path never ran. expect(mockRunAppleRunnerCommand).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); // The direct-iOS shortcut is RETIRED (#1739): `get` declares `device-runtime`, so a simple @@ -270,7 +268,7 @@ test('get text simple iOS id selector resolves through the bound capture, not a const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-direct-selector'; sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', nodes: [ { @@ -327,7 +325,7 @@ test('get text iOS label selector uses snapshot disambiguation instead of runner const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-label-selector'; sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', nodes: [ { @@ -387,8 +385,8 @@ test('get text iOS label selector uses snapshot disambiguation instead of runner expect(response?.ok).toBe(true); expect(mockRunAppleRunnerCommand).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch.mock.calls[0]?.[1]).toBe('snapshot'); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); + expect(legacyDispatchCapture.mock.calls[0]?.[1]).toBe('snapshot'); if (response?.ok) { expect(response.data?.text).toBe('General'); expect(response.data?.selector).toBe('label="General"'); @@ -400,7 +398,7 @@ test('is visible preserves CLI snapshot flags during runtime snapshot capture', const sessionName = 'snapshot-flags'; sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [ @@ -443,7 +441,7 @@ test('is visible preserves CLI snapshot flags during runtime snapshot capture', }); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls[0]?.[4]).toMatchObject({ + expect(legacyDispatchCapture.mock.calls[0]?.[4]).toMatchObject({ snapshotDepth: 2, snapshotScope: 'Login', snapshotRaw: true, @@ -458,7 +456,7 @@ test('is visible reuses fresh cached iOS snapshots with rects', async () => { const session = makeSession(sessionName); session.snapshot = makeVisibleButtonSnapshot('Cached action', 'xctest'); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('unexpected fresh snapshot')); + legacyDispatchCapture.mockRejectedValue(new Error('unexpected fresh snapshot')); const response = await handleInteractionCommands({ req: { @@ -475,7 +473,7 @@ test('is visible reuses fresh cached iOS snapshots with rects', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('is visible recaptures web snapshots when cached nodes may lack rects', async () => { @@ -491,7 +489,7 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy { snapshotInteractiveOnly: false }, ); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue(makeVisibleButtonSnapshot('Submit order', 'web')); + legacyDispatchCapture.mockResolvedValue(makeVisibleButtonSnapshot('Submit order', 'web')); const response = await handleInteractionCommands({ req: { @@ -508,7 +506,7 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy }); expect(response?.ok).toBe(true); - expect(mockDispatch.mock.calls[0]?.[4]).toMatchObject({ + expect(legacyDispatchCapture.mock.calls[0]?.[4]).toMatchObject({ snapshotIncludeRects: true, }); }); @@ -556,7 +554,7 @@ test('a failing is predicate is COMMAND_FAILED, never a zero-exit pass', async ( // The session snapshot has no `id=submit`, so the bound capture reports the typed selector // failure. Nothing can report a failed assertion as a completed command. expect(response?.ok).toBe(false); - expect(mockDispatch.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); + expect(legacyDispatchCapture.mock.calls.filter((call) => call[1] === 'snapshot')).toHaveLength(1); if (response?.ok === false) { expect(response.error?.code).toBe('COMMAND_FAILED'); } @@ -567,7 +565,7 @@ test('is visible passes for list text that inherits viewport visibility from an const sessionName = 'visible-list-item'; sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [ @@ -619,7 +617,7 @@ test('is visible fails for nodes outside the current viewport', async () => { const sessionName = 'visible-offscreen'; sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [ @@ -667,7 +665,7 @@ test('is reports Android permission dialog blocker when app content assertion fa makeBaseAndroidSession(sessionName, { appBundleId: 'com.example.demo' }), ); - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); return { nodes: [], backend: 'uiautomator' }; }); @@ -726,7 +724,7 @@ test('ADR 0014 evidence #17: get text @ref reads the retained frame tree, not a backend: 'xctest', }); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue(new Error('get text @ref must not recapture')); + legacyDispatchCapture.mockRejectedValue(new Error('get text @ref must not recapture')); // Resolves against the frame tree's @e2 (Continue), never the observation's // positional @e2 (Different) — no fall-through by positional coincidence. @@ -745,7 +743,7 @@ test('ADR 0014 evidence #17: get text @ref reads the retained frame tree, not a expect(missing.error.code).toBe('COMMAND_FAILED'); expect(missing.error.message).toMatch(/not found/i); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('get text @ref warns while the frame is expired (retained evidence still resolves)', async () => { @@ -756,7 +754,7 @@ test('get text @ref warns while the frame is expired (retained evidence still re // retained frame tree and stays fail-open with a warning (ADR 0014). expireRefFrame(session); sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); @@ -774,7 +772,7 @@ test('get text with a pinned stale ref gets the precise warning', async () => { const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 4; sessionStore.set(sessionName, session); - mockDispatch.mockRejectedValue( + legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 50db0e96e2..c27be4725c 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -2,30 +2,30 @@ import { beforeEach, expect, test, vi } from 'vitest'; import path from 'node:path'; import { handleReactNativeCommands } from '../react-native.ts'; import { captureSnapshot } from '../snapshot-capture.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { + getRuntimeBindings, + mockTapPoint, + resetGetRuntimeFixture, +} from './interaction-get-runtime-fixture.ts'; vi.mock('../snapshot-capture.ts', () => ({ captureSnapshot: vi.fn(), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => ({ x: 379, y: 820 })), - }; -}); - const mockCaptureSnapshot = vi.mocked(captureSnapshot); -const mockDispatchCommand = vi.mocked(dispatchCommand); +/** + * R58: overlay dismissal taps through the same bound `tapPoint` every other touch leaf uses, so + * this suite watches the bound operation rather than a dispatcher. The recorded point is the + * whole assertion — the coordinate the overlay heuristic picked. + */ +const mockDismissTap = mockTapPoint; beforeEach(() => { mockCaptureSnapshot.mockReset(); - mockDispatchCommand.mockReset(); - mockDispatchCommand.mockResolvedValue({ x: 379, y: 820 }); + resetGetRuntimeFixture(); }); test('react-native dismiss-overlay taps collapsed warning close affordance instead of banner center', async () => { @@ -73,18 +73,15 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); // ADR 0014 side-effect seam: overlay dismissal taps the device, so it expires // the ref frame. expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['379', '820'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 379, y: 820 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -100,7 +97,6 @@ test('react-native dismiss-overlay prefers non-trailing collapsed warning close const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 27, y: 820 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -135,15 +131,12 @@ test('react-native dismiss-overlay prefers non-trailing collapsed warning close logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['27', '820'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 27, y: 820 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -158,7 +151,6 @@ test('react-native dismiss-overlay does not confuse app dismiss buttons with ove const sessionName = 'rn-collapsed-with-app-dismiss-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 379, y: 820 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -207,15 +199,12 @@ test('react-native dismiss-overlay does not confuse app dismiss buttons with ove logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['369', '813'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 369, y: 813 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -260,10 +249,11 @@ test('react-native dismiss-overlay rejects unsafe collapsed warning coordinate f logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(false); - expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(mockDismissTap).not.toHaveBeenCalled(); expect(!response?.ok && response?.error).toMatchObject({ code: 'COMMAND_FAILED', details: { @@ -276,7 +266,6 @@ test('react-native dismiss-overlay dismisses RedBox error overlays instead of mi const sessionName = 'rn-redbox-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 95, y: 752 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -322,15 +311,12 @@ test('react-native dismiss-overlay dismisses RedBox error overlays instead of mi logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['95', '752'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 95, y: 752 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -350,7 +336,6 @@ test('react-native dismiss-overlay reports unverified dismiss when RedBox contro const sessionName = 'rn-redbox-still-full-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 95, y: 752 }); const fullRedBoxSnapshot = { snapshot: { nodes: [ @@ -392,6 +377,7 @@ test('react-native dismiss-overlay reports unverified dismiss when RedBox contro logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -411,7 +397,6 @@ test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', const sessionName = 'rn-redbox-dismiss-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 95, y: 752 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -444,15 +429,12 @@ test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['95', '752'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 95, y: 752 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -466,7 +448,6 @@ test('react-native dismiss-overlay accepts RedBox control labels with keyboard s const sessionName = 'rn-redbox-shortcut-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 70, y: 722 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -499,15 +480,12 @@ test('react-native dismiss-overlay accepts RedBox control labels with keyboard s logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'apple' }), - 'press', - ['70', '722'], - undefined, - expect.any(Object), + expect(mockDismissTap).toHaveBeenCalledWith( + expect.objectContaining({ point: { x: 70, y: 722 } }), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', @@ -521,7 +499,6 @@ test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled const sessionName = 'rn-redbox-wrapper-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 201, y: 827 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -562,6 +539,7 @@ test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -578,7 +556,6 @@ test('react-native dismiss-overlay reports verified success after a clean post-d const sessionName = 'rn-verify-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName, 'android')); - mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -625,6 +602,7 @@ test('react-native dismiss-overlay reports verified success after a clean post-d logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -688,11 +666,12 @@ test('react-native dismiss-overlay reports sparse verdict instead of no overlay logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(false); expect(session.snapshot).toBe(previousSnapshot); - expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(mockDismissTap).not.toHaveBeenCalled(); expect(!response?.ok && response?.error).toMatchObject({ code: 'COMMAND_FAILED', message: @@ -708,7 +687,6 @@ test('react-native dismiss-overlay reports unverified dismiss when post-dismiss const sessionName = 'rn-verify-sparse-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); - mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -760,6 +738,7 @@ test('react-native dismiss-overlay reports unverified dismiss when post-dismiss logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -776,7 +755,6 @@ test('react-native dismiss-overlay reports still-visible overlays with recovery const sessionName = 'rn-verify-still-visible-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName, 'android')); - mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); const overlaySnapshot = { snapshot: { nodes: [ @@ -810,6 +788,7 @@ test('react-native dismiss-overlay reports still-visible overlays with recovery logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); @@ -852,10 +831,11 @@ test('react-native dismiss-overlay ignores app copy that only mentions RN overla logPath: '/tmp/daemon.log', sessionStore, contextFromFlags: () => ({}), + ...getRuntimeBindings(), }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(mockDismissTap).not.toHaveBeenCalled(); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', detected: false, diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index e5e26722cf..481d09d271 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts @@ -105,6 +105,7 @@ function createAdmissionFacts( writeClipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, + setSetting: unavailable, ...touchRuntimeOperationFacts({ tap: unavailable, longPress: unavailable, diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts b/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts index 8a4edf46a5..7fdbee3618 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.fixtures.ts @@ -44,11 +44,6 @@ vi.mock('../../../utils/video.ts', () => ({ waitForStableFile: vi.fn(async () => {}), isPlayableVideo: vi.fn(async () => true), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); - import { handleSessionCommands, mockBindDeviceRuntime, diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index 7a3dbe9f3b..7118c4038b 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -179,6 +179,7 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts { const resolveTargetDevice = vi.fn(); return { ...actual, - dispatchCommand: vi.fn(), resolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(resolveTargetDevice)), }; diff --git a/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts b/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts index 908c026fde..55d6dc8f58 100644 --- a/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-open-execution-runtime.test.ts @@ -12,7 +12,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { await import('../../__tests__/device-selection-stub.ts'); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: mockResolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), }; diff --git a/src/daemon/handlers/__tests__/session-open-runtime.test.ts b/src/daemon/handlers/__tests__/session-open-runtime.test.ts index 579d0b59b3..d2cd499944 100644 --- a/src/daemon/handlers/__tests__/session-open-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-open-runtime.test.ts @@ -10,7 +10,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { await import('../../__tests__/device-selection-stub.ts'); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: mockResolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), }; diff --git a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts index 310dace98f..d774210a5f 100644 --- a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts @@ -24,7 +24,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -34,7 +34,6 @@ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { @@ -45,9 +44,12 @@ import { bottomTabsRealCaptureFixture, recordArticleEvidence, } from './session-replay-target-classification-fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts index bfd3d30f02..3e75391985 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence-android-occlusion.test.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; import { ANDROID_QS_SHADE_CAPTURE_RAW_NODES, @@ -10,19 +9,22 @@ import { import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; import { SessionStore } from '../../session-store.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { buildReplayFailureDivergence } from '../session-replay-divergence.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts index c6299d8392..a1dd2e2950 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts @@ -3,15 +3,10 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; -}); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; @@ -22,9 +17,12 @@ import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../session- import { SessionStore } from '../../session-store.ts'; import { captureDivergenceObservation } from '../session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from '../session-replay-divergence-publication.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 6dd247672a..9e32247b4e 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -4,7 +4,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -23,7 +23,6 @@ vi.mock('../../../utils/timeouts.ts', async (importOriginal) => { return { ...actual, sleep: vi.fn(async () => {}) }; }); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -40,15 +39,15 @@ import { buildReplayFailureDivergence, captureDivergenceObservation, } from '../session-replay-divergence.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + legacyDispatchCapture, + resetLegacySnapshotCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { - mockDispatchCommand.mockReset(); - mockDispatchCommand.mockResolvedValue({}); - mockCaptureSnapshotWithInteractor.mockReset(); - mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); + resetLegacySnapshotCapture(mockCaptureSnapshotWithInteractor); }); test('buildReplayFailureDivergence dedupes suggestions using the strongest basis', async () => { diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts index 8547ef5490..cb29dc598f 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts @@ -4,7 +4,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -25,14 +25,16 @@ import { } from '../session-replay-maestro-failure.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts index 043bcc36c0..6d58eebcdd 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts @@ -11,7 +11,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -21,7 +21,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import type { DaemonRequest } from '../../types.ts'; @@ -31,9 +30,12 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts index 018038fd67..aadd41810b 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts @@ -27,7 +27,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -37,9 +37,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { repairSessionBoundary } from '../../session-replay-transaction.ts'; import type { SessionState } from '../../types.ts'; @@ -61,7 +63,7 @@ function sessionRepairStatus(session: SessionState | undefined) { : undefined; } -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts index 2a7e025884..9100b9c49d 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts @@ -15,7 +15,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -24,7 +24,6 @@ vi.mock('../snapshot-interactor-capture.ts', () => ({ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, @@ -38,7 +37,10 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; /** Repair-transaction status, or `undefined` outside a repair publication. */ function sessionRepairStatus(session: SessionState | undefined) { @@ -47,7 +49,7 @@ function sessionRepairStatus(session: SessionState | undefined) { : undefined; } -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts index d9700d5c65..c0f136adbe 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts @@ -46,7 +46,7 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => }); vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -57,7 +57,6 @@ import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, @@ -73,9 +72,12 @@ import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); function handleCloseCommand( diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts index c3816e2d77..805924b589 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts @@ -48,7 +48,7 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => }); vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -59,9 +59,11 @@ import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { handleCloseCommand as handleProductionCloseCommand } from '../session-close.ts'; import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + legacyDispatchCapture, + resetLegacySnapshotCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -92,7 +94,7 @@ import { inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); @@ -124,9 +126,7 @@ function sessionCloseReceipt(session: SessionState | undefined): string | undefi } beforeEach(() => { - mockDispatchCommand.mockReset(); - mockCaptureSnapshotWithInteractor.mockReset(); - mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); + resetLegacySnapshotCapture(mockCaptureSnapshotWithInteractor); mockLifecycleDispatch.mockReset(); mockLifecycleDispatch.mockResolvedValue(undefined); // The "current" app state: "save" was renamed to "save-v2" (why step 2 diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts index 7adb5c1967..1a9c0a456d 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts @@ -12,7 +12,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts index c3952b82b8..718d8344f6 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts @@ -3,7 +3,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -12,16 +12,18 @@ import path from 'node:path'; import { buildReplayDivergenceFailureResponseFromDescriptor } from '../session-replay-runtime-failure-response.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts index 383fc947ef..ff38103fe4 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts @@ -3,7 +3,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -13,18 +13,20 @@ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonResponse } from '../../types.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; import { maestroScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts index 6e65de7588..9dd31700a0 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-keep-session.test.ts @@ -34,7 +34,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); import fs from 'node:fs'; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts index 24af8f73b2..0028cecb8d 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts @@ -14,11 +14,10 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; // session-replay-vars.test.ts, plus a handful of generic (non-Maestro) `.ad` // runReplayScriptSource tests that happen to share the same runReplayFixture // helper and mock configuration below. It is a sibling of -// session-replay-runtime.test.ts rather than a merge into it because that -// file mocks '../../../core/dispatch.ts' differently (dispatchCommand -// resolves `{}`, not throws) — vitest allows only one vi.mock per module per -// file, so reconciling the two configurations was out of scope for a pure -// test-file split (see #1460). +// session-replay-runtime.test.ts rather than a merge into it because that file +// mocks '../../../core/dispatch.ts' with its own device resolution — vitest +// allows only one vi.mock per module per file, so reconciling the two +// configurations was out of scope for a pure test-file split (see #1460). vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -41,9 +40,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { booted: true, }, ), - dispatchCommand: vi.fn(async () => { - throw new Error('no device runner available in this test'); - }), dispatchGestureViewport: vi.fn(async () => ({ x: 0, y: 0, width: 400, height: 800 })), }; }); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts index 405cba8f68..745fd11d62 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts @@ -3,7 +3,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ @@ -15,8 +15,11 @@ import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; -import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { resolveTargetDevice } from '../../../core/dispatch.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeAndroidSession, @@ -27,7 +30,7 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index 003d9ba1dc..9fd9901a87 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -4,7 +4,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ @@ -15,8 +15,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, @@ -27,7 +29,7 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts index 14de3c5dae..08dd8b87eb 100644 --- a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts @@ -10,7 +10,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), @@ -20,21 +20,23 @@ import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { maestroScriptSourceBundleFor, replayScriptSourceBundleFor, } from '../../../__tests__/test-utils/replay-script-source.ts'; import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../../replay/script-source-bundle.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { - vi.mocked(dispatchCommand).mockReset(); - vi.mocked(dispatchCommand).mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); mockCaptureSnapshotWithInteractor.mockReset(); mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); diff --git a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts index a7ebdedb54..f4066615f3 100644 --- a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts @@ -3,23 +3,25 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + captureSnapshotThroughLegacyDispatchFixture, + legacyDispatchCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest, writeReplayFile } from './session-replay-runtime.fixtures.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(), })); -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts index b81413660f..fa34a0eae3 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts @@ -11,7 +11,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; + return { ...actual, resolveTargetDevice: vi.fn() }; }); vi.mock('../snapshot-interactor-capture.ts', () => ({ @@ -32,24 +32,23 @@ import path from 'node:path'; import type { DaemonRequest } from '../../types.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { AppError } from '@agent-device/kernel/errors'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; -import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + legacyDispatchCapture, + resetLegacySnapshotCapture, +} from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; -const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockDispatchCommand = legacyDispatchCapture; const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { - mockDispatchCommand.mockReset(); - mockDispatchCommand.mockResolvedValue({}); - mockCaptureSnapshotWithInteractor.mockReset(); - mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); + resetLegacySnapshotCapture(mockCaptureSnapshotWithInteractor); }); const SAVE_ANNOTATION = diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 3dab98e86a..e2c3b1c95a 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -65,6 +65,7 @@ test('boot rejects --headless outside Android directly', async () => { writeClipboard: { available: false, reason: 'owner-capability-missing' }, appSwitcher: { available: false, reason: 'owner-capability-missing' }, triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, + setSetting: { available: false, reason: 'owner-capability-missing' }, readiness: { available: false, reason: 'unsupported-device-kind' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, @@ -167,6 +168,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () writeClipboard: { available: false, reason: 'unsupported-platform-leaf' }, appSwitcher: { available: false, reason: 'unsupported-platform-leaf' }, triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, + setSetting: { available: false, reason: 'unsupported-platform-leaf' }, readiness: { available: false, reason: 'unsupported-platform-leaf' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index f1b69bfbb7..d6016d587e 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -1,4 +1,5 @@ import { isMacOs } from '@agent-device/kernel/device'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { expect, vi, beforeEach } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import type { AppLogLiveState } from '@agent-device/contracts/app-log-runtime'; @@ -19,7 +20,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const resolveTargetDevice = vi.fn(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice, resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(resolveTargetDevice)), }; @@ -95,7 +95,7 @@ import * as path from 'node:path'; import { cleanupRetainedMaterializedPathsForSession } from '../../materialized-path-registry.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts'; -import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; +import { resolveTargetDevice } from '../../../core/dispatch.ts'; import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintValues, @@ -118,7 +118,6 @@ import { } from '../../../platforms/apple/core/apps.ts'; import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; -export const mockDispatch = vi.mocked(dispatchCommand); export const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); export const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); export const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -136,6 +135,8 @@ export const mockCleanupRetainedMaterializedPaths = vi.mocked( cleanupRetainedMaterializedPathsForSession, ); export const mockRunCmd = vi.mocked(runCmd); +/** The retired dispatcher's snapshot leg, now an owned test double (R58). */ +export const mockDispatch = legacyDispatchCapture; export const mockResolveIosApp = vi.mocked(resolveIosApp); export const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked( resolveIosSimulatorDeepLinkBundleId, diff --git a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts b/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts index 5f8fe4e90f..57e5ad5832 100644 --- a/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-test-reporter-values-maestro.test.ts @@ -16,10 +16,10 @@ // // These tests document today's behavior; they do not propose behavior. -// Maestro replay resolves a target device and captures view hierarchies through -// core/dispatch. These fixtures model no runner, so both are stubbed: the device resolves to a -// fixed Android emulator, and `snapshot` returns an empty hierarchy (which is what makes -// `assertNotVisible` pass deterministically without a screen). +// Maestro replay resolves a target device and reads a gesture viewport through core/dispatch. +// These fixtures model no runner, so both are stubbed: the device resolves to a fixed Android +// emulator, and captures return an empty hierarchy (which is what makes `assertNotVisible` pass +// deterministically without a screen). import { expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -34,9 +34,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { kind: 'emulator', booted: true, })), - dispatchCommand: vi.fn(async () => { - throw new Error('no device runner available in this test'); - }), dispatchGestureViewport: vi.fn(async () => ({ x: 0, y: 0, width: 400, height: 800 })), }; }); diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 0748eaef2e..7c84c31917 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -1,4 +1,10 @@ import { test, expect, vi, afterEach, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { + getRuntimeBindings, + mockTapPoint, + resetGetRuntimeFixture, +} from './interaction-get-runtime-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; @@ -17,37 +23,17 @@ import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { fixtureScreenshotCaptures, + fixtureSettingsMutations, + resetSnapshotRuntimeFixture, snapshotRuntimeFixture, } from '../../__tests__/snapshot-runtime-fixture.ts'; import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; -const dispatchCommandMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({}))); - -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: dispatchCommandMock, - }; +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ - captureSnapshotWithInteractor: vi.fn( - async ({ device, runnerContext, options }) => - await dispatchCommandMock(device, 'snapshot', [], undefined, { - ...runnerContext, - ...options, - snapshotInteractiveOnly: options.interactiveOnly, - snapshotPreferredBackend: options.preferredBackend, - snapshotDepth: options.depth, - snapshotScope: options.scope, - snapshotRaw: options.raw, - snapshotCustomActions: options.customActions, - snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, - }), - ), -})); - vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -74,7 +60,6 @@ vi.mock('../../ios-app-session-hint.ts', () => ({ buildIosOpenCommandHint: vi.fn(async () => undefined), })); -import { dispatchCommand } from '../../../core/dispatch.ts'; import { runAppleRunnerCommand, stopIosRunnerSession, @@ -82,16 +67,18 @@ import { import { closeIosApp } from '../../../platforms/apple/core/apps.ts'; import { buildIosOpenCommandHint } from '../../ios-app-session-hint.ts'; -const mockDispatch = vi.mocked(dispatchCommand); const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); const mockCloseIosApp = vi.mocked(closeIosApp); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); +const SNAPSHOT_ROUTE_RUNTIME_COMMANDS = new Set(['snapshot', 'diff', 'settings']); + function handleSnapshotCommands( params: Parameters[0], ): ReturnType { - if (params.req.command !== 'snapshot' && params.req.command !== 'diff') { + // R58 put `settings` on a bound operation too, so the snapshot-route fixture serves it as well. + if (!SNAPSHOT_ROUTE_RUNTIME_COMMANDS.has(params.req.command)) { return handleProductionSnapshotCommands(params); } const runtime = snapshotRuntimeFixture(params.req.meta?.requestId); @@ -168,8 +155,10 @@ afterEach(() => { }); beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + resetSnapshotRuntimeFixture(); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); + resetGetRuntimeFixture(); mockRunnerCommand.mockReset(); mockRunnerCommand.mockResolvedValue({}); mockStopIosRunnerSession.mockReset(); @@ -203,7 +192,7 @@ function makeAndroidTimeoutEvidenceSession(sessionName: string): SessionStore { } function mockAndroidTimeoutEvidenceDispatch(): void { - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') throw androidSnapshotTimeoutError(); return {}; }); @@ -432,7 +421,7 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { expect(response.error.details?.reason).toBe('ios_app_session_required'); expect(response.error.details?.hint).toBeUndefined(); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(bindCount).toBe(0); }); @@ -478,7 +467,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { const sessionName = 'ios-cloud-no-app'; sessionStore.set(sessionName, makeSession(sessionName, providerIosDevice)); setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(providerIosDevice)]); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'XCUIElementTypeButton', label: 'Sign in' }], truncated: false, backend: 'xctest', @@ -506,7 +495,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalled(); + expect(legacyDispatchCapture).toHaveBeenCalled(); // The bypass has to happen before the hint probe (#1662), which shells out to // simctl and can only ever see local simulators — for a hosted device it is a // guaranteed-useless spawn on what is now a success path. @@ -537,7 +526,7 @@ test('diff on local iOS still requires a tracked app', async () => { expect(response.error.code).toBe('SESSION_NOT_FOUND'); expect(response.error.message).toMatch(/iOS diff requires an active app session/i); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('snapshot on iOS runs when the session tracks an app', async () => { @@ -547,7 +536,7 @@ test('snapshot on iOS runs when the session tracks an app', async () => { ...makeSession(sessionName, iosSimulatorDevice), appBundleId: 'org.reactnavigation.playground', }); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Home' }], truncated: false, backend: 'ios', @@ -567,7 +556,7 @@ test('snapshot on iOS runs when the session tracks an app', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith( + expect(legacyDispatchCapture).toHaveBeenCalledWith( iosSimulatorDevice, 'snapshot', [], @@ -588,7 +577,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy // A prior device action expired the frame. session.refFrameState = 'expired'; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, backend: 'android', @@ -650,7 +639,7 @@ async function runVersionedRefsCommand(params: { function makeVersionedRefsScenario(sessionName: string) { const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, backend: 'android', @@ -714,7 +703,7 @@ test('daemon-private snapshot observation advances capture state without publish const publishedGeneration = published?.refFrameGeneration; const publishedTree = published?.refFrameTree; - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Internal' }], truncated: false, backend: 'android', @@ -747,7 +736,7 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh const sessionName = 'android-empty-interactive'; sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [], truncated: false, backend: 'android', @@ -812,7 +801,7 @@ test('snapshot annotations survive pending interaction capture into CLI JSON', a }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: changedNodes, truncated: false, backend: 'android', @@ -870,7 +859,7 @@ test('snapshot timeout captures Android screenshot evidence with overlay refs', sessionStore, }); expectAndroidTimeoutEvidence(response); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot']); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot']); expect(fixtureScreenshotCaptures.at(-1)?.options).toMatchObject({ stabilize: false }); }); @@ -891,7 +880,7 @@ test('snapshot warns when recent snapshot node count collapses sharply', async ( }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ index, depth: 0, @@ -943,7 +932,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ index, depth: 0, @@ -1004,7 +993,7 @@ test('snapshot automatically retries stale Android trees after recent navigation }; sessionStore.set(sessionName, session); - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: Array.from({ length: 24 }, (_, index) => ({ index, @@ -1046,7 +1035,7 @@ test('snapshot automatically retries stale Android trees after recent navigation expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), ); } - expect(mockDispatch).toHaveBeenCalledTimes(2); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); expect(sessionStore.get(sessionName)?.androidSnapshotFreshness).toBeUndefined(); }); @@ -1076,7 +1065,7 @@ test('snapshot warns when Android freshness retries still return the previous ro }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 24 }, (_, index) => ({ index, depth: 0, @@ -1109,7 +1098,7 @@ test('snapshot warns when Android freshness retries still return the previous ro ), ]); } - expect(mockDispatch).toHaveBeenCalledTimes(4); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(4); }); test('snapshot response includes normalized visibility metadata', async () => { @@ -1117,7 +1106,7 @@ test('snapshot response includes normalized visibility metadata', async () => { const sessionName = 'android-visibility'; sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [ { index: 0, @@ -1192,7 +1181,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 24 }, (_, index) => ({ index, depth: 0, @@ -1225,7 +1214,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy ), ]); } - expect(mockDispatch).toHaveBeenCalledTimes(4); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(4); }); test('Android ref refresh mode does not retry narrow snapshots as sharp drops', async () => { @@ -1254,7 +1243,7 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops', }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ index, depth: 0, @@ -1274,7 +1263,7 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops', }); expect(result.freshness).toBeUndefined(); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(1); expect(session.androidSnapshotFreshness).toBeUndefined(); }); @@ -1318,11 +1307,11 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr }; let pressed = false; - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'press') { - pressed = true; - return { clicked: true }; - } + mockTapPoint.mockImplementation(async () => { + pressed = true; + return { clicked: true }; + }); + legacyDispatchCapture.mockImplementation(async () => { return { nodes: !pressed ? baselineNodes @@ -1353,15 +1342,15 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr session, flags: { snapshotInteractiveOnly: true }, logPath: '/tmp/daemon.log', + ...getRuntimeBindings(), }); expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Feed' })]), ); - expect( - mockDispatch.mock.calls.map((call) => call[1]).filter((command) => command === 'press'), - ).toEqual(['press']); - expect(mockDispatch.mock.calls.find((call) => call[1] === 'press')?.[2]).toEqual(['100', '144']); + // R58: the retry re-fires through the bound `tapPoint`, on the recorded coordinate pair. + expect(mockTapPoint).toHaveBeenCalledTimes(1); + expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 100, y: 144 }); expect(session.pendingInteractionOutcome).toBeUndefined(); }); @@ -1399,7 +1388,7 @@ test('captureSnapshot does not retry when a tap change appears after a short del }; let snapshotCalls = 0; - mockDispatch.mockImplementation(async (_device, command) => { + legacyDispatchCapture.mockImplementation(async (_device, command) => { expect(command).toBe('snapshot'); snapshotCalls += 1; return { @@ -1418,7 +1407,7 @@ test('captureSnapshot does not retry when a tap change appears after a short del expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Albums' })]), ); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); expect(session.pendingInteractionOutcome).toBeUndefined(); }); @@ -1466,11 +1455,11 @@ test('captureSnapshot retries pending tap outcome before post-gesture stabilizat }; let pressed = false; - mockDispatch.mockImplementation(async (_device, command) => { - if (command === 'press') { - pressed = true; - return { clicked: true }; - } + mockTapPoint.mockImplementation(async () => { + pressed = true; + return { clicked: true }; + }); + legacyDispatchCapture.mockImplementation(async () => { return { nodes: !pressed ? baselineNodes @@ -1492,15 +1481,15 @@ test('captureSnapshot retries pending tap outcome before post-gesture stabilizat session, flags: { snapshotInteractiveOnly: true }, logPath: '/tmp/daemon.log', + ...getRuntimeBindings(), }); expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Tab Third (3)' })]), ); - expect( - mockDispatch.mock.calls.map((call) => call[1]).filter((command) => command === 'press'), - ).toEqual(['press']); - expect(mockDispatch.mock.calls.find((call) => call[1] === 'press')?.[2]).toEqual(['540', '1356']); + // R58: the retry re-fires through the bound `tapPoint`, on the recorded coordinate pair. + expect(mockTapPoint).toHaveBeenCalledTimes(1); + expect(mockTapPoint.mock.calls[0]?.[0]?.point).toEqual({ x: 540, y: 1356 }); expect(session.pendingInteractionOutcome).toBeUndefined(); expect(session.postGestureStabilization).toBeUndefined(); }); @@ -1541,7 +1530,7 @@ test('captureSnapshot composes post-gesture stabilization with Android freshness markedAt: Date.now(), }; - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: baselineNodes, truncated: false, @@ -1571,7 +1560,7 @@ test('captureSnapshot composes post-gesture stabilization with Android freshness expect(result.snapshot.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'album-0' })]), ); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual([ + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual([ 'snapshot', 'snapshot', 'snapshot', @@ -1613,7 +1602,7 @@ test('captureSnapshot composes pending outcome retry with Android freshness capt preSignature: buildInteractionSurfaceSignature(baselineNodes), }; - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: [], truncated: false, @@ -1651,7 +1640,7 @@ test('captureSnapshot composes pending outcome retry with Android freshness capt staleAfterRetries: false, reason: undefined, }); - expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'snapshot']); expect(session.pendingInteractionOutcome).toBeUndefined(); expect(session.androidSnapshotFreshness).toBeUndefined(); }); @@ -1682,7 +1671,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap }; sessionStore.set(sessionName, session); - mockDispatch + legacyDispatchCapture .mockResolvedValueOnce({ nodes: Array.from({ length: 18 }, (_, index) => ({ index, @@ -1723,7 +1712,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap if (response?.ok) { expect(response.data?.text).toBe('Create document'); } - expect(mockDispatch).toHaveBeenCalledTimes(2); + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); expect(sessionStore.get(sessionName)?.snapshot?.nodes).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'Create document' })]), ); @@ -1731,7 +1720,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap test('wait text timeout includes compact current-surface labels and buttons', async () => { const sessionName = 'android-wait-timeout-surface'; - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: locationPermissionNodes, truncated: false, backend: 'android', @@ -1754,7 +1743,7 @@ test('wait text timeout includes compact current-surface labels and buttons', as test('wait selector timeout includes compact current-surface details', async () => { const sessionName = 'android-wait-selector-timeout-surface'; - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: locationRequiredNodes, truncated: false, backend: 'android', @@ -1800,12 +1789,14 @@ test('wait selector polling skips hidden-content hint derivation on every poll ( backend: 'android', analysis: { rawNodeCount: 1, maxDepth: 0 }, }; - mockDispatch.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); + legacyDispatchCapture.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); const response = await runWaitCommand(sessionName, androidDevice, ['label="Battery"', '8000']); expect(response?.ok).toBe(true); - const snapshotCalls = mockDispatch.mock.calls.filter(([, command]) => command === 'snapshot'); + const snapshotCalls = legacyDispatchCapture.mock.calls.filter( + ([, command]) => command === 'snapshot', + ); expect(snapshotCalls.length).toBe(2); for (const call of snapshotCalls) { const context = call[4] as { snapshotIncludeHiddenContentHints?: boolean } | undefined; @@ -1835,12 +1826,14 @@ test('wait text polling skips hidden-content hint derivation on every poll (#127 backend: 'android', analysis: { rawNodeCount: 1, maxDepth: 0 }, }; - mockDispatch.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); + legacyDispatchCapture.mockResolvedValueOnce(withoutBattery).mockResolvedValueOnce(withBattery); const response = await runWaitCommand(sessionName, androidDevice, ['Battery', '8000']); expect(response?.ok).toBe(true); - const snapshotCalls = mockDispatch.mock.calls.filter(([, command]) => command === 'snapshot'); + const snapshotCalls = legacyDispatchCapture.mock.calls.filter( + ([, command]) => command === 'snapshot', + ); expect(snapshotCalls.length).toBe(2); for (const call of snapshotCalls) { const context = call[4] as { snapshotIncludeHiddenContentHints?: boolean } | undefined; @@ -1851,7 +1844,7 @@ test('wait text polling skips hidden-content hint derivation on every poll (#127 test('wait timeout summary prefers content labels over chrome and identifier noise', async () => { const sessionName = 'ios-wait-timeout-surface-summary'; mockRunnerCommand.mockResolvedValue({ found: false }); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: iosSurfaceSummaryNodes, truncated: false, backend: 'xctest', @@ -1883,7 +1876,7 @@ test('wait timeout summary prefers content labels over chrome and identifier noi test('wait timeout without readable capture does not inspect the current surface', async () => { const sessionName = 'android-wait-timeout-surface-fails'; - mockDispatch.mockRejectedValue(new Error('snapshot unavailable')); + legacyDispatchCapture.mockRejectedValue(new Error('snapshot unavailable')); const response = await runWaitCommand(sessionName, androidDevice, ['Receipt uploaded', '0']); @@ -1894,7 +1887,7 @@ test('wait timeout without readable capture does not inspect the current surface expect(response.error.details?.retriable).toBe(true); expect(response.error.details?.readableCaptures).toBe(0); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); test('settings rejects unsupported iOS physical devices', async () => { @@ -1951,13 +1944,11 @@ test('settings clear-app-state dispatches explicit app id without an active app }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith( - iosSimulatorDevice, - 'settings', - ['clear-app-state', 'clear', 'org.reactnavigation.playground'], - undefined, - expect.objectContaining({ appBundleId: 'org.reactnavigation.playground' }), - ); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ + setting: 'clear-app-state', + state: 'clear', + appBundleId: 'org.reactnavigation.playground', + }); }); test('settings clear-app-state rejects missing app id when no app session is bound', async () => { @@ -1983,7 +1974,7 @@ test('settings clear-app-state rejects missing app id when no app session is bou expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toMatch(/requires an app id/i); } - expect(mockDispatch).not.toHaveBeenCalled(); + expect(fixtureSettingsMutations).toHaveLength(0); }); test('settings usage hint documents canonical faceid states', async () => { @@ -2032,7 +2023,7 @@ test('settings on macOS rejects wifi before dispatch with explicit subset guidan expect(response).toBeTruthy(); expect(response?.ok).toBe(false); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(fixtureSettingsMutations).toHaveLength(0); if (response && !response.ok) { expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toMatch(/Unsupported macOS setting: wifi/i); @@ -2109,7 +2100,7 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { ], }; sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [ { index: 0, @@ -2134,7 +2125,7 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { }); expect(response?.ok).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith( + expect(legacyDispatchCapture).toHaveBeenCalledWith( expect.anything(), 'snapshot', [], diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index 501741a42d..5c4d6f44f4 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -1,7 +1,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; import type { RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; -import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { expireRefFrame } from '../../ref-frame.ts'; @@ -11,16 +11,14 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), }; }); -const mockDispatch = vi.mocked(dispatchCommand); const ANDROID_SCRIPT_ERROR = 'Unable to load script. Make sure you are running Metro.'; beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); }); test('snapshot resolves @ref scope with the stored source after scoped output replaces refs', async () => { @@ -29,7 +27,7 @@ test('snapshot resolves @ref scope with the stored source after scoped output re const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), truncated: false, backend: 'android', @@ -42,8 +40,8 @@ test('snapshot resolves @ref scope with the stored source after scoped output re if (response?.ok) expect(response.data?.nodes).toHaveLength(2); } - expect(mockDispatch).toHaveBeenCalledTimes(2); - expect(mockDispatch.mock.calls.map((call) => call[4])).toEqual([ + expect(legacyDispatchCapture).toHaveBeenCalledTimes(2); + expect(legacyDispatchCapture.mock.calls.map((call) => call[4])).toEqual([ expect.objectContaining({ snapshotScope: ANDROID_SCRIPT_ERROR }), expect.objectContaining({ snapshotScope: ANDROID_SCRIPT_ERROR }), ]); @@ -57,7 +55,7 @@ test('a mutation clears scoped-snapshot lineage so a repeated snapshot -s @ref c const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), truncated: false, backend: 'android', @@ -86,7 +84,7 @@ test('empty @ref-scoped snapshot output does not replace the stored session snap const session = makeAndroidSession(sessionName, { snapshot: currentScreenSnapshot() }); sessionStore.set(sessionName, session); - mockDispatch.mockResolvedValue({ + legacyDispatchCapture.mockResolvedValue({ nodes: [], truncated: false, backend: 'android', diff --git a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts index f01085bb23..915176e887 100644 --- a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts +++ b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts @@ -1,4 +1,5 @@ import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { handleFindCommands } from '../find.ts'; import { getRuntimeBindings } from './interaction-get-runtime-fixture.ts'; import { dispatchFindReadOnlyViaRuntime, dispatchWaitViaRuntime } from '../../selector-runtime.ts'; @@ -12,7 +13,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn(actual.resolveTargetDevice), }; }); @@ -26,12 +26,10 @@ vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); -import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; +import { resolveTargetDevice } from '../../../core/dispatch.ts'; import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; import { withSystemSurfaceDisclosure } from '../system-surface-disclosure.ts'; -const mockDispatch = vi.mocked(dispatchCommand); - // The occluding-shade capture every scenario below consumes: no application window content, one // active quick-settings surface. The Android capture route stamps systemSurfaceOnly on both the // annotations and the SnapshotState (see snapshot-capture.ts), so selector routes must disclose it. @@ -59,8 +57,8 @@ const SHADE_SNAPSHOT_DATA = { }; beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? SHADE_SNAPSHOT_DATA : {}; }); }); diff --git a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts index de028b4e63..d8b301b368 100644 --- a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts +++ b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts @@ -12,6 +12,7 @@ * `WAIT_LANDMARK_MISMATCH_REASON` refusal. */ import { test, expect, vi, beforeEach } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { dispatchWaitViaRuntime } from '../../selector-runtime.ts'; import type { DaemonRequest } from '../../types.ts'; import { WAIT_LANDMARK_MISMATCH_REASON } from '@agent-device/contracts/replay'; @@ -27,7 +28,6 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn(actual.resolveTargetDevice), }; }); @@ -41,10 +41,6 @@ vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); -import { dispatchCommand } from '../../../core/dispatch.ts'; - -const mockDispatch = vi.mocked(dispatchCommand); - function screenSnapshot(parentLabel: string) { return { backend: 'android', @@ -69,8 +65,8 @@ function screenSnapshot(parentLabel: string) { } beforeEach(() => { - mockDispatch.mockReset(); - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? screenSnapshot('Detail Screen') : {}; }); }); @@ -152,7 +148,7 @@ test('a replayed wait with a landmark guard succeeds when a match carries the re }); test('a replayed wait with a landmark guard refuses at the deadline when only impostors matched', async () => { - mockDispatch.mockImplementation(async (_device: unknown, command: string) => { + legacyDispatchCapture.mockImplementation(async (_device: unknown, command: string) => { return command === 'snapshot' ? screenSnapshot('List Screen') : {}; }); @@ -181,5 +177,5 @@ test('a selector-shaped positional list that fails to parse as a selector is rej if (response.ok) return; expect(response.error.code).toBe('INVALID_ARGS'); expect(response.error.message).toContain('label="Open"'); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(legacyDispatchCapture).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/interaction-touch-response.ts b/src/daemon/handlers/interaction-touch-response.ts index 1865d48743..116a3011f7 100644 --- a/src/daemon/handlers/interaction-touch-response.ts +++ b/src/daemon/handlers/interaction-touch-response.ts @@ -331,12 +331,12 @@ export function transformTouchResponseData(params: { export function readInteractionResponseDataTransformCommand( requestCommand: string, - dispatchCommand: 'press' | 'fill', + executedCommand: 'press' | 'fill', ): InteractionResponseDataTransformCommand { if (requestCommand === 'click' || requestCommand === 'press' || requestCommand === 'fill') { return requestCommand; } - return dispatchCommand; + return executedCommand; } /** The response fields disclosing the Maestro coordinate fallback's policy and outcome. */ diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index ab562664bc..0f0b7753ad 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -1,4 +1,4 @@ -import { dispatchCommand } from '../../core/dispatch.ts'; +import { createBoundTouchExecutor, resolveBoundTouchRuntime } from '../touch-runtime.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { analyzeReactNativeOverlay, @@ -106,18 +106,24 @@ async function dismissReactNativeOverlayTarget( target: ReactNativeOverlayDismissTarget, ): Promise { const { req, sessionStore } = params; + // The dismissal press rides the same bound `tapPoint` a user-typed `press` does (R48): admit + // and bind before anything mutates, so an owner that cannot tap refuses here rather than + // through a dispatcher that no longer exists. + const bound = await resolveBoundTouchRuntime({ + device: session.device, + command: 'press', + requiresCapture: false, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + }); + if (!bound.ok) return bound.response; + const context = params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath); + const executor = createBoundTouchExecutor(bound.runtime, context); const actionStartedAt = Date.now(); // ADR 0014 side-effect seam: React Native overlay dismissal taps the device; // the target is already resolved, so expire the frame before the press. expireRefFrame(session); - const data = - (await dispatchCommand( - session.device, - 'press', - [String(target.point.x), String(target.point.y)], - req.flags?.out, - params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - )) ?? {}; + const data = (await executor.tapPoint?.(target.point)) ?? {}; const actionFinishedAt = Date.now(); const verification = await verifyReactNativeOverlayDismissal(params, session); const responseData = stripUndefined({ diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 54367ee6b9..96161a57b5 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -20,7 +20,9 @@ import { clearAndroidSnapshotFreshness } from '../session-snapshot-freshness.ts' import type { SnapshotFreshnessMode } from '../../snapshot/snapshot-freshness/index.ts'; import { contextFromFlags } from '../context.ts'; import { resolveDeferredInteractionOutcome } from '../deferred-interaction-outcome.ts'; +import { createInteractionRetryTap } from '../interaction-retry-tap.ts'; import type { SessionState } from '../types.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; type CaptureSnapshotParams = { @@ -39,6 +41,13 @@ type CaptureSnapshotParams = { * until their own command descriptor cuts over. */ captureData?: () => Promise; + /** + * The pending-outcome retry re-fires a bound `tapPoint` (R48), so a capture that can settle a + * deferred interaction outcome carries the request's own runtime bindings and builds the retry + * seam from them. A caller that has none simply never retries. + */ + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }; type SnapshotData = { @@ -68,6 +77,7 @@ export async function captureSnapshot( interactiveOnly: params.flags?.snapshotInteractiveOnly === true, androidFreshnessMode: params.androidFreshnessMode, capture: () => captureSnapshotAttempt(params), + retryTap: createInteractionRetryTap(params), }); if (deferred) return deferred; diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index b544c0e5ae..9ad2b89069 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -4,19 +4,29 @@ import { isMacOsSettingSupported, SETTINGS_INVALID_ARGS_MESSAGE, } from '@agent-device/contracts/settings'; -import { dispatchCommand } from '../../core/dispatch.ts'; +import type { SettingOptions } from '@agent-device/contracts/settings'; +import type { SetSettingInput } from '@agent-device/contracts/settings-runtime'; +import { settingsRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import { contextFromFlags } from '../context.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { recordIfSession } from './snapshot-session.ts'; -import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; import { expireRefFrame } from '../ref-frame.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; +import { successText, withSuccessText } from '../../utils/success-text.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { admitRuntimeUse } from '../runtime-admission.ts'; +import { runtimeExecutionFromContext } from '../snapshot-runtime-capture-input.ts'; type ParsedSettingsArgs = { setting: string; state: string; appBundleId?: string; permissionTarget?: string; + permissionMode?: string; latitude?: string; longitude?: string; }; @@ -28,6 +38,8 @@ type HandleSettingsCommandParams = { session: SessionState | undefined; device: SessionState['device']; parsed: ParsedSettingsArgs; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }; export function parseSettingsArgs( @@ -61,52 +73,128 @@ export function parseSettingsArgs( setting, state, permissionTarget, + permissionMode: req.positionals?.[3], latitude: req.positionals?.[2], longitude: req.positionals?.[3], }, }; } +/** + * The owner-facing options for one mutation. `permission` and `location set` are the two settings + * whose payload is not just `(setting, state)`; everything else sends none. Coordinate typing + * happens here rather than in the owner: `readLocationCoordinate` is input validation, and it + * throws the same `INVALID_ARGS` the retired dispatcher threw from the same point in the + * sequence — after admission, immediately before the device call. + */ +function buildSettingOptions(parsed: ParsedSettingsArgs): SettingOptions | undefined { + if (parsed.setting === 'permission') { + return { permissionTarget: parsed.permissionTarget, permissionMode: parsed.permissionMode }; + } + if (parsed.setting === 'location' && parsed.state === 'set') { + return { + latitude: readLocationCoordinate(parsed.latitude, 'latitude'), + longitude: readLocationCoordinate(parsed.longitude, 'longitude'), + }; + } + return undefined; +} + +/** The `settings_apply` payload the retired dispatcher emitted, kept byte-for-byte. */ +function settingsDiagnosticData( + parsed: ParsedSettingsArgs, + appBundleId: string | undefined, + platform: string, +): Record { + const { setting, state } = parsed; + if (setting === 'clear-app-state') { + return { setting: 'clear-app-state', state: 'clear', appBundleId, platform }; + } + if (setting === 'location' && state === 'set') { + return { setting, state, latitude: parsed.latitude, longitude: parsed.longitude, platform }; + } + if (setting === 'permission') { + return { + setting, + state, + permissionTarget: parsed.permissionTarget, + permissionMode: parsed.permissionMode, + platform, + }; + } + return { setting, state, appBundleId, platform }; +} + +function readResultMessage(result: Record): string | undefined { + return typeof result.message === 'string' && result.message.length > 0 + ? result.message + : undefined; +} + +/** + * The ONE place a bound `settings` executes (R58). The owner answers with its own payload or + * nothing; either way the response carries the requested `setting`/`state` and a message the + * owner may override, exactly as the retired leaf composed it. + */ +async function executeSetSetting( + runtime: BoundDeviceRuntime, + input: SetSettingInput, + fallbackMessage: string, +): Promise> { + const { setting, state } = input; + const result = await runtime.operations.setSetting(input); + return result && typeof result === 'object' + ? withSuccessText({ setting, state, ...result }, readResultMessage(result) ?? fallbackMessage) + : { setting, state, ...successText(fallbackMessage) }; +} + export async function handleSettingsCommand( params: HandleSettingsCommandParams, ): Promise { - const { req, logPath, sessionStore, session, device, parsed } = params; - const { - setting, - state, - appBundleId: parsedAppBundleId, - permissionTarget, - latitude, - longitude, - } = parsed; - const unsupported = requireCommandSupported('settings', device); - if (unsupported) return unsupported; + const { req, logPath, sessionStore, session, device, parsed, inspectFacts, bindDevice } = params; + const { setting, state } = parsed; + const admission = await admitRuntimeUse({ + command: 'settings', + device, + use: settingsRuntimeUse, + inspectFacts, + bindDevice, + }); + if (admission.type === 'response') return admission.response; if (isMacOs(device) && !isMacOsSettingSupported(setting)) { return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); } - const appBundleId = parsedAppBundleId ?? session?.appBundleId; + const appBundleId = parsed.appBundleId ?? session?.appBundleId; if (setting === 'clear-app-state' && !appBundleId) { return errorResponse( 'INVALID_ARGS', 'settings clear-app-state requires an app id when no app is bound to the session', ); } - // Settings positional layout for dispatch: setting, state, command payload, appBundleId. - const positionals = - setting === 'clear-app-state' - ? [setting, state, appBundleId ?? ''] - : setting === 'permission' - ? [setting, state, permissionTarget ?? '', req.positionals?.[3] ?? '', appBundleId ?? ''] - : setting === 'location' && state === 'set' - ? [setting, state, latitude ?? '', longitude ?? '', appBundleId ?? ''] - : [setting, state, appBundleId ?? '']; - // ADR 0014 side-effect seam: a settings mutation changes device state; expire - // the frame before the dispatch (settings is always classified may-invalidate). - if (session) expireRefFrame(session); - const data = await dispatchCommand(device, 'settings', positionals, req.flags?.out, { - ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath), + const options = buildSettingOptions(parsed); + emitDiagnostic({ + level: 'debug', + phase: 'settings_apply', + data: settingsDiagnosticData(parsed, appBundleId, device.platform), }); - recordIfSession(sessionStore, session, req, data ?? { setting, state }); - return { ok: true, data: data ?? { setting, state } }; + // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before + // the bound call (settings is always classified may-invalidate). + if (session) expireRefFrame(session); + const context = contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath); + const data = await executeSetSetting( + admission.runtime, + { + setting, + state, + ...(appBundleId === undefined ? {} : { appBundleId }), + ...(options === undefined ? {} : { options }), + execution: runtimeExecutionFromContext(context), + }, + setting === 'clear-app-state' + ? `Cleared user data for ${appBundleId}` + : `Updated setting: ${setting}`, + ); + recordIfSession(sessionStore, session, req, data); + return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 72b2d114dd..a845c53a2b 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -64,7 +64,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { }); }); }, - settings: async ({ req, sessionName, logPath, sessionStore }) => { + settings: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => { const parsedSettings = parseSettingsArgs(req); if (!parsedSettings.ok) return parsedSettings; const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); @@ -76,6 +76,8 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { session, device, parsed: parsedSettings.parsed, + inspectFacts, + bindDevice, }); }); }, diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 8c8b30a0c0..38d23e06ab 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -1,11 +1,10 @@ import type { CommandFlags } from '@agent-device/contracts/command'; -import { dispatchCommand } from '../core/dispatch.ts'; import { isMobilePlatform } from '@agent-device/kernel/device'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { collectKeyboardChromeRefs } from '../core/snapshot-chrome.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; import { isViewportRootNode } from '@agent-device/contracts/snapshot'; -import { contextFromFlags } from './context.ts'; +import { contextFromFlags, type DaemonCommandContext } from './context.ts'; import type { SessionState } from './types.ts'; const OUTCOME_RETRY_WINDOW_MS = 30_000; @@ -18,6 +17,27 @@ export type InteractionSurfaceSignature = NonNullable< export type InteractionSurfaceChange = 'changed' | 'unchanged' | 'ambiguous'; +/** + * How this policy re-fires a recorded tap. The policy owns *whether* a retry is warranted; the + * request route that already admitted a device cell owns *how* the tap reaches the device, and + * supplies it here. Keeping the seam a plain callback is what stops the policy from importing + * runtime admission — a read of "should we retry?" must stay readable without the binding stack. + * + * Answers `false` when the caller's owner cannot tap, so the policy can report a skipped retry + * instead of burning an attempt. + */ +export type InteractionRetryTap = ( + request: Readonly<{ + device: SessionState['device']; + point: Readonly<{ x: number; y: number }>; + context: InteractionRetryContext; + }>, +) => Promise; + +/** The runner metadata a re-fired tap carries — the same context any bound touch executes on. */ +export type InteractionRetryContext = DaemonCommandContext & + Readonly<{ surface?: SessionState['surface'] }>; + function shouldRetryTouchOnNoChange(flags: CommandFlags | undefined): boolean { return flags?.interactionOutcome?.retryOnNoChange === true; } @@ -74,6 +94,7 @@ export async function retryPendingInteractionOutcome(params: { pending: NonNullable; logPath: string; snapshot: SnapshotState; + retryTap?: InteractionRetryTap; }): Promise<{ retried: boolean; change: InteractionSurfaceChange }> { const { session, pending, snapshot } = params; const change = classifyInteractionSurfaceChange( @@ -84,14 +105,46 @@ export async function retryPendingInteractionOutcome(params: { return { retried: false, change }; } + // The retry re-fires the same coordinate tap the original press used (R48). The attempt is + // spent only once the tap is actually delivered, so a capture path carrying no retry seam — or + // an owner whose cell cannot tap — leaves the pending record intact instead of burning an + // attempt on a refusal, and says so rather than throwing out of the capture it was decorating. + const point = params.retryTap ? readRetryPoint(pending.positionals) : undefined; const startedAt = Date.now(); - pending.attemptsRemaining -= 1; // Opt-in Maestro retries intentionally re-fire the same coordinate tap; delayed or // non-visual side effects can duplicate, but unchanged visual taps are the target gap. - await dispatchCommand(session.device, pending.command, pending.positionals, pending.flags?.out, { - ...contextFromFlags(params.logPath, pending.flags, session.appBundleId, session.trace?.outPath), - surface: session.surface, - }); + const fired = + params.retryTap && point + ? await params.retryTap({ + device: session.device, + point, + context: { + ...contextFromFlags( + params.logPath, + pending.flags, + session.appBundleId, + session.trace?.outPath, + ), + surface: session.surface, + }, + }) + : false; + if (!fired) { + // Never silent: a retry the opt-in flag asked for and this request could not fire says so, + // rather than leaving the caller to infer it from an unchanged surface. + emitDiagnostic({ + level: 'info', + phase: 'interaction_no_change_retry_skipped', + data: { + action: pending.action, + attemptsRemaining: pending.attemptsRemaining, + reason: params.retryTap && !point ? 'unreadable-retry-point' : 'device-runtime-unavailable', + }, + }); + return { retried: false, change }; + } + + pending.attemptsRemaining -= 1; emitDiagnostic({ level: 'info', phase: 'interaction_no_change_retry', @@ -369,6 +422,16 @@ function supportsInteractionOutcomePolicy(session: SessionState): boolean { return isMobilePlatform(session.device); } +/** + * The pending record stores the coordinate pair `isCoordinatePair` already validated, so this + * only re-reads it; a record that somehow fails the read is skipped rather than retried blind. + */ +function readRetryPoint(positionals: readonly string[]): { x: number; y: number } | undefined { + const x = Number(positionals[0]); + const y = Number(positionals[1]); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; +} + function retryCommandForTap(command: string): string | undefined { if (command === 'click') return 'press'; if (command === 'press') return 'press'; diff --git a/src/daemon/interaction-retry-tap.ts b/src/daemon/interaction-retry-tap.ts new file mode 100644 index 0000000000..e4a6f29a14 --- /dev/null +++ b/src/daemon/interaction-retry-tap.ts @@ -0,0 +1,39 @@ +import type { InteractionRetryTap } from './interaction-outcome-policy.ts'; +import type { RuntimeAdmissionBindings } from './request-runtime-binding.ts'; +import { createBoundTouchExecutor, resolveBoundTouchRuntime } from './touch-runtime.ts'; + +/** + * Supplies the interaction-outcome policy with the one device effect it needs: re-firing a + * recorded coordinate tap through the same bound `tapPoint` the original press used (R48). + * + * It lives beside neither party. The policy decides whether a retry is warranted and must stay + * readable without the binding stack, so it declares the seam and never imports admission; the + * capture route holds the request's bindings but has no business knowing which touch plan a + * re-fired tap needs. This is the adapter between them, and every request route that can retry + * builds it from its own bindings rather than reaching into a module-level one. + * + * Answers `undefined` for a route with no bindings to admit with — an internal capture that + * decorates someone else's request cannot fire a tap, and the policy reports that as a skip. + */ +export function createInteractionRetryTap( + bindings: RuntimeAdmissionBindings, +): InteractionRetryTap | undefined { + const { inspectFacts, bindDevice } = bindings; + if (!inspectFacts || !bindDevice) return undefined; + return async ({ device, point, context }) => { + // Admission runs before the policy spends an attempt: an owner whose cell cannot tap answers + // `false` here and leaves the pending record intact. + const bound = await resolveBoundTouchRuntime({ + device, + command: 'press', + requiresCapture: false, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return false; + const executor = createBoundTouchExecutor(bound.runtime, context); + if (!executor.tapPoint) return false; + await executor.tapPoint(point); + return true; + }; +} diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index dde34355a5..5d1f7c0e0d 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -4,7 +4,6 @@ import { commandSupportsSettleObservation, commandUsesDeviceRuntimeExecution, } from '../core/command-descriptor/registry.ts'; -import { dispatchCommand } from '../core/dispatch.ts'; import { requireCommandSupported } from './handlers/response.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; @@ -39,9 +38,9 @@ export type GenericPlatformExecutionParams = { }; /** - * What actually performs a generic leaf's platform work. Legacy leaves get - * {@link executeGenericPlatformCommand}; a command migrated onto a request-bound device runtime - * supplies its own already-admitted, already-bound closure instead (ADR 0019). + * What actually performs a generic leaf's platform work: the already-admitted, already-bound + * closure the leaf's own runtime resolution supplied (ADR 0019). R58 retired the legacy + * alternative, so this is the only shape. */ export type GenericPlatformExecution = ( params: GenericPlatformExecutionParams, @@ -317,13 +316,6 @@ async function ensureGenericCommandReady( }; } -export const executeGenericPlatformCommand: GenericPlatformExecution = async (params) => { - const { session, command, positionals, out, dispatchContext } = params; - return await dispatchCommand(session.device, command, positionals, out, { - ...dispatchContext, - }); -}; - function recordVisualizationAndAction(params: { session: SessionState; sessionStore: SessionStore; diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 067a87a632..f3f8e13691 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -451,10 +451,9 @@ async function dispatchGenericForLockedScope(params: { inspectFacts: lockedScope.inspectFacts, bindDevice: lockedScope.bindDevice, }); - if (runtimeExecution && !runtimeExecution.ok) return runtimeExecution.response; + if (!runtimeExecution.ok) return runtimeExecution.response; - const { dispatchGenericCommand, executeGenericPlatformCommand } = - await loadGenericRequestHandlerModule(); + const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); const dispatchResponse = await dispatchGenericCommand({ req: lockedScope.req, session, @@ -462,8 +461,8 @@ async function dispatchGenericForLockedScope(params: { logPath, sessionStore, contextFromFlags: lockedScope.contextFromFlags, - executePlatformCommand: runtimeExecution?.execute ?? executeGenericPlatformCommand, - ...(runtimeExecution?.recorded ? { recordedRequest: runtimeExecution.recorded } : {}), + executePlatformCommand: runtimeExecution.execute, + ...(runtimeExecution.recorded ? { recordedRequest: runtimeExecution.recorded } : {}), }); return dispatchResponse; } diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index b17f465b84..afb572b849 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -31,6 +31,18 @@ export type BindDeviceRuntime = < BoundDeviceRuntime> >; +/** + * The two request-scoped seams a caller threads down to whichever route finally admits a device + * cell. It lives here, beside the two function types it is composed of, rather than beside the + * admission entry point that consumes it: callers that only forward the seams (a deferred capture + * handing them to a retry, say) would otherwise have to import the whole admission module and + * close a type cycle through it. + */ +export type RuntimeAdmissionBindings = Readonly<{ + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; +}>; + export type BindExactDeviceRuntime = < const Required extends readonly RuntimeOperationKey[], const Preferred extends readonly Exclude< diff --git a/src/daemon/runtime-admission.ts b/src/daemon/runtime-admission.ts index 7ad73ea1ec..d374787636 100644 --- a/src/daemon/runtime-admission.ts +++ b/src/daemon/runtime-admission.ts @@ -7,7 +7,11 @@ import type { } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, + RuntimeAdmissionBindings, +} from './request-runtime-binding.ts'; import { errorResponse, type DaemonFailureResponse } from './handlers/response.ts'; import type { DaemonCommandContext } from './context.ts'; import type { ResolvedGenericExecution } from './request-generic-dispatch.ts'; @@ -25,17 +29,16 @@ export type RuntimeAdmission = | Readonly<{ type: 'response'; response: DaemonFailureResponse }> | Readonly<{ type: 'runtime'; runtime: BoundDeviceRuntime }>; -export type RuntimeAdmissionRequest = Readonly<{ - /** Command wording for the default unsupported message, e.g. `open`, `runtime port-reverse`. */ - command: string; - device: DeviceInfo; - required: readonly RuntimeOperationKey[]; - inspectFacts?: InspectDeviceRuntimeFacts; - bindDevice?: BindDeviceRuntime; - unavailableResponse?: UnavailableRuntimeResponse; -}>; +export type RuntimeAdmissionRequest = RuntimeAdmissionBindings & + Readonly<{ + /** Command wording for the default unsupported message, e.g. `open`, `runtime port-reverse`. */ + command: string; + device: DeviceInfo; + required: readonly RuntimeOperationKey[]; + unavailableResponse?: UnavailableRuntimeResponse; + }>; -export type RuntimeAdmissionBindings = Pick; +export type { RuntimeAdmissionBindings }; /** * The one facts-admission seam every migrated command route shares. It performs exactly one diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index 97d29f1803..c7bac55f88 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -62,6 +62,7 @@ describe('composed platform runtime gateway', () => { writeClipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, + setSetting: unavailable, touch: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, @@ -156,6 +157,7 @@ describe('composed platform runtime gateway', () => { writeClipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, + setSetting: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/platform-runtime-gateway.ts b/src/platform-runtime-gateway.ts index 77f0e318bc..4f2ae19be7 100644 --- a/src/platform-runtime-gateway.ts +++ b/src/platform-runtime-gateway.ts @@ -326,6 +326,7 @@ function unavailableProviderBinding( writeClipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, + setSetting: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }); } @@ -361,6 +362,7 @@ function unavailableProviderFacts(runtime: ProviderDeviceRuntime, device: Device writeClipboard: unavailable, appSwitcher: unavailable, triggerAppEvent: unavailable, + setSetting: unavailable, readiness: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }, diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 50d5db5e86..8f8f57dcb6 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -48,8 +48,6 @@ const supportsAlertSurface = (device: DeviceInfo): boolean => const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> = { [PUBLIC_COMMANDS.perf]: supportsCoreDevicePhysicalOperation, [PUBLIC_COMMANDS.alert]: supportsAlertSurface, - [PUBLIC_COMMANDS.settings]: (device) => - device.platform === 'android' || supportsHostOrSimulatorSurface(device), [PUBLIC_COMMANDS.audio]: isAudioProbeSupportedDevice, }; diff --git a/test/integration/linux-e2e/coverage-manifest.ts b/test/integration/linux-e2e/coverage-manifest.ts index b753e1504e..76b7c1c51a 100644 --- a/test/integration/linux-e2e/coverage-manifest.ts +++ b/test/integration/linux-e2e/coverage-manifest.ts @@ -177,9 +177,10 @@ export const LINUX_PLATFORM_COVERAGE = { ), [C.wait]: live('the existing Linux replay waits for an observable calculator landmark'), [C.alert]: denial('alert', 'Linux capability declaration rejects native alert operations'), - [C.settings]: denial( - 'settings', - 'Linux capability declaration rejects native device settings operations', + [C.settings]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native device settings on Linux', ), [C.reactNative]: denial( 'react-native', diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index de6f2d9696..40f38644ba 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -13,6 +13,7 @@ import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../.. import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createTestAppLogLiveHandle } from '../../../src/__tests__/test-utils/app-log-live-handle.ts'; +import { setIosSetting } from '../../../src/platforms/apple/core/app-settings.ts'; import { assertFlatToolCall } from './assertions.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; import { createProviderScenarioHarness } from './harness.ts'; @@ -279,6 +280,18 @@ function createRecordingPlatformRuntimeGateway(params: { }), ); }, + // R58 put `settings` behind the owner's own `setSetting` fact, so the scenario's + // gateway states and serves that cell like any other. The Apple leg reuses the local + // family's implementation — the same shape Limrun's Android leg takes — so the simctl + // argument mapping this scenario asserts still runs through the recorded tool seam. + setSetting: async (input) => + await setIosSetting( + device, + input.setting, + input.state, + input.appBundleId, + input.options, + ), appLogReattach: async () => ({ status: 'missing' }), appLogCleanup: async () => ({ status: 'already-missing' }), resolveOpenTarget: async (input) => ({ @@ -326,6 +339,7 @@ function recordingRuntimeFacts(device: DeviceInfo): RuntimeFacts { // Keyboard, orientation and tv-remote were already fact-owned command-contract rows rather // than catalog denials; R56 moves app-switcher the same way, for the same reason. // R57 moves trigger-app-event from capability-denial to command-contract: it is fact-owned now. - capabilityDenial: 5, - contract: 23, + // R58 moves settings from capability-denial to command-contract: it is fact-owned now. + capabilityDenial: 4, + contract: 24, gap: 9, live: 17, total: 54, diff --git a/test/integration/smoke-web-platform-coverage.test.ts b/test/integration/smoke-web-platform-coverage.test.ts index b8b8a293d6..eb6eccfbce 100644 --- a/test/integration/smoke-web-platform-coverage.test.ts +++ b/test/integration/smoke-web-platform-coverage.test.ts @@ -41,8 +41,8 @@ test('web coverage exhaustively classifies the public catalog', () => { test('web coverage report has the expected classification counts', () => { assert.deepEqual(WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 4, - contract: 37, + capabilityDenial: 3, + contract: 38, gap: 1, live: 12, total: 54, diff --git a/test/integration/web-e2e/coverage-manifest.ts b/test/integration/web-e2e/coverage-manifest.ts index a5624d5eac..f47f36a821 100644 --- a/test/integration/web-e2e/coverage-manifest.ts +++ b/test/integration/web-e2e/coverage-manifest.ts @@ -140,7 +140,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.clipboard]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard, the app switcher and app events never carried a web capability bucket', + 'clipboard, the app switcher, app events and settings carry no web capability bucket', 'the exact-owner runtime fact rejects native clipboard operations on the web target', ), [C.keyboard]: contract( @@ -165,7 +165,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.triggerAppEvent]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard, the app switcher and app events never carried a web capability bucket', + 'clipboard, the app switcher, app events and settings carry no web capability bucket', 'the exact-owner runtime fact rejects native app-event delivery on the web target', ), [C.open]: live('the managed browser opens the local fixture page'), @@ -188,7 +188,11 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.wait]: live('wait observes ready text and post-interaction fixture state'), [C.alert]: denial('Web capability model rejects native alert operations'), - [C.settings]: denial('Web capability model rejects native device settings operations'), + [C.settings]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events and settings carry no web capability bucket', + 'the exact-owner runtime fact rejects native device settings on the web target', + ), [C.reactNative]: denial('Web capability model rejects React Native inspection'), [C.record]: contract( 'test/integration/provider-scenarios/web-desktop.test.ts', @@ -271,7 +275,7 @@ export const WEB_PLATFORM_COVERAGE = { [C.viewport]: live('viewport resizes the browser and the PNG reports 640x480 dimensions'), [C.appSwitcher]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard, the app switcher and app events never carried a web capability bucket', + 'clipboard, the app switcher, app events and settings carry no web capability bucket', 'the exact-owner runtime fact rejects native app-switcher navigation on the web target', ), [C.installFromSource]: contract( From 323f8ad69c7a9cc828a1c96598f1d6c35b520188 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:37:35 +0000 Subject: [PATCH 05/16] refactor(daemon): migrate alert, react-native and capabilities onto facts (R59/R61/R63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 6 units 5, 7 and 9 of the ADR 0019 platform-free daemon migration (#1739), plus the residue reclassification the tracker asks for as an analysis task. R59 `alert` — new `@agent-device/contracts/alert-runtime` facet with four action-selected legs (`readAlert`, `awaitAlert`, `acceptAlert`, `dismissAlert`) on the shared `Interactor` seam. The daemon route admits and binds exactly the leg the parsed subcommand names, and the poll and retry windows move to the owners with it: how long a transient sheet takes to appear, and how many times to re-ask a runner that says it is not there yet, are family mechanics, not request policy. `src/platforms/apple/alert.ts` now holds the Apple windows verbatim (with the macOS-helper / XCTest-runner split), and Android's legs read the same presented tree `snapshot` publishes, which is why their occlusion reading still holds. Apple's cell is the retired `supportsAlertSurface` closure restated as facts — the host-or-simulator reading widened by physical iOS — and that closure was the per-AppleOS capability table's last reader, so `src/platforms/apple/capabilities.ts` goes with it. R61 `react-native` — the command's device work moved onto a bound `tapPoint` with R48; this retires the capability gate that still stood in front of it and moves admission ahead of the observing capture, so an owner that cannot dismiss an overlay refuses without first spending a snapshot on it. That exposed a real defect: the request handler chain never forwarded the request's runtime bindings to this route, so the dismissal leg had been reaching a missing gateway ever since R48 — only the no-overlay-detected path returned early enough to hide it. Fixed, with a chain-level regression test. R63 `capabilities` — the projection now reads each command's own declared `platformExecution` uses instead of a hand-written map plus a "no capability bucket means supported everywhere" fallback. That fallback is what let a stopped Android AVD advertise `snapshot press fill` it cannot run, and a Vega VVD advertise every migrated command; both collapse to the fact-derived set here. The command itself executes nothing on a device, so it declares `none`. Residue: `batch`, `debug` and `events` reclassify to `none` — each reaches no device and delegates nothing that does. `replay`/`test` keep their gesture viewport and boot-diagnostics edges, `daemon`/`web` hold platform imports in their own CLI modules, and `react-devtools` still injects device-runtime `runtime`, so all five stay `legacy`. Cutover rows R59 and R61 with their retirement and single-bind claims. Descriptors: 32 legacy at the wave checkpoint, 9 now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/contracts/package.json | 4 + packages/contracts/src/alert-runtime.test.ts | 115 ++++++++ packages/contracts/src/alert-runtime.ts | 205 ++++++++++++++ packages/contracts/src/facades/interaction.ts | 1 + packages/contracts/src/facades/platform.ts | 5 + .../src/interactor-operation-catalog.ts | 22 ++ packages/contracts/src/interactor-types.ts | 21 ++ .../src/platform-runtime-operations.ts | 18 ++ .../src/platform-runtime-unavailable.test.ts | 4 + .../src/platform-runtime-unavailable.ts | 19 ++ packages/platform-android/src/runtime.test.ts | 9 + packages/platform-android/src/runtime.ts | 10 + .../platform-apple/src/system/runtime.test.ts | 37 +++ packages/platform-apple/src/system/runtime.ts | 38 ++- .../platform-harmonyos/src/runtime.test.ts | 15 +- packages/platform-harmonyos/src/runtime.ts | 9 + packages/platform-linux/src/runtime.test.ts | 6 +- packages/platform-linux/src/runtime.ts | 5 + packages/platform-vega/src/runtime.test.ts | 8 + packages/platform-vega/src/runtime.ts | 8 + packages/platform-web/src/runtime.test.ts | 15 +- packages/platform-web/src/runtime.ts | 7 + .../provider-limrun/src/app-log-runtime.ts | 4 + packages/provider-limrun/src/facts-runtime.ts | 3 + .../src/interaction-operations.test.ts | 17 ++ .../src/interaction-operations.ts | 23 ++ packages/provider-limrun/src/ios.ts | 20 ++ .../provider-webdriver/src/capabilities.ts | 2 + .../src/platform-runtime.test.ts | 8 + .../src/platform-runtime.ts | 26 ++ .../src/webdriver-interactor.ts | 18 ++ scripts/layering/package-boundaries.test.ts | 1 + .../layering/runtime-command-cutover-table.ts | 82 +++++- ...> apple-capability-closure-parity.test.ts} | 52 ++-- src/__tests__/eager-closure-budgets.ts | 20 +- .../test-utils/runtime-operation-facts.ts | 4 + src/core/__tests__/capabilities.test.ts | 47 +--- .../capability-plugin-routing-parity.test.ts | 9 +- .../__tests__/snapshot-state.test.ts | 0 src/core/capabilities.ts | 24 ++ .../__tests__/parity.test.ts | 2 + src/core/command-descriptor/registry.ts | 39 ++- src/core/interactors/android.ts | 18 ++ src/core/interactors/harmonyos.ts | 6 + src/core/interactors/linux.ts | 9 + src/{daemon => core}/snapshot-state.ts | 0 src/daemon/__tests__/generic-settle.test.ts | 9 +- .../__tests__/request-handler-chain.test.ts | 26 ++ .../snapshot-presentation-transitions.test.ts | 2 +- .../snapshot-publication-membership.test.ts | 2 +- .../__tests__/snapshot-runtime-fixture.ts | 53 +++- src/daemon/android-system-dialog.ts | 2 +- .../handlers/__tests__/install-source.test.ts | 4 + .../__tests__/interaction-common.test.ts | 9 +- .../interaction-get-runtime-fixture.ts | 4 + .../interaction-ios-tap-outcome-fixtures.ts | 2 +- .../interaction-ios-tap-outcome.test.ts | 7 - .../__tests__/interaction-settle.test.ts | 9 +- .../interaction-target-evidence.test.ts | 7 - .../__tests__/interaction-touch-fixtures.ts | 2 +- .../handlers/__tests__/interaction.test.ts | 9 +- ...on-capabilities-install-projection.test.ts | 12 +- .../session-capabilities.fixtures.ts | 4 + .../__tests__/session-capabilities.test.ts | 77 +++-- .../__tests__/session-command-harness.ts | 4 + .../handlers/__tests__/session-state.test.ts | 8 + .../snapshot-alert-android-occlusion.test.ts | 45 ++- .../__tests__/snapshot-handler.test.ts | 4 +- .../__tests__/snapshot-scoped-refs.test.ts | 9 +- src/daemon/handlers/react-native.ts | 53 ++-- src/daemon/handlers/response.ts | 13 +- src/daemon/handlers/session-inventory.ts | 50 ++-- .../handlers/session-selector-dispatch.ts | 167 +++++------ src/daemon/handlers/snapshot-alert.ts | 263 ++++++++---------- src/daemon/handlers/snapshot-capture.ts | 2 +- src/daemon/handlers/snapshot.ts | 4 +- src/daemon/request-handler-chain.ts | 6 + src/daemon/screenshot-runtime.ts | 2 +- src/daemon/touch-runtime.ts | 11 +- src/platform-runtime-gateway.test.ts | 8 + src/platform-runtime-gateway.ts | 8 + .../snapshot-occlusion-context.test.ts | 2 +- .../__tests__/ui-hierarchy-scope.test.ts | 2 +- .../android/__tests__/ui-hierarchy.test.ts | 2 +- src/platforms/apple/__tests__/alert.test.ts | 128 +++++++++ .../interactor-runner-provider.test.ts | 7 + src/platforms/apple/alert.ts | 133 +++++++++ src/platforms/apple/capabilities.ts | 93 ------- src/platforms/apple/interactor.ts | 5 + src/platforms/apple/plugin.ts | 22 +- src/platforms/unsupported-interactor.ts | 4 + .../linux-e2e/coverage-manifest.ts | 16 +- .../ios-alert-settings.test.ts | 28 ++ test/integration/smoke-linux-coverage.test.ts | 5 +- .../smoke-web-platform-coverage.test.ts | 4 +- test/integration/web-e2e/coverage-manifest.ts | 24 +- 96 files changed, 1731 insertions(+), 656 deletions(-) create mode 100644 packages/contracts/src/alert-runtime.test.ts create mode 100644 packages/contracts/src/alert-runtime.ts rename src/__tests__/contracts/{apple-os-capability-table-parity.test.ts => apple-capability-closure-parity.test.ts} (73%) rename src/{daemon => core}/__tests__/snapshot-state.test.ts (100%) rename src/{daemon => core}/snapshot-state.ts (100%) create mode 100644 src/platforms/apple/__tests__/alert.test.ts create mode 100644 src/platforms/apple/alert.ts delete mode 100644 src/platforms/apple/capabilities.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 461feadaa7..374f711e0a 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -15,6 +15,10 @@ "types": "./src/alert-contract.ts", "default": "./src/alert-contract.ts" }, + "./alert-runtime": { + "types": "./src/alert-runtime.ts", + "default": "./src/alert-runtime.ts" + }, "./android-input-ownership": { "types": "./src/android-input-ownership.ts", "default": "./src/android-input-ownership.ts" diff --git a/packages/contracts/src/alert-runtime.test.ts b/packages/contracts/src/alert-runtime.test.ts new file mode 100644 index 0000000000..f00488825c --- /dev/null +++ b/packages/contracts/src/alert-runtime.test.ts @@ -0,0 +1,115 @@ +import { expect, test, vi } from 'vitest'; +import { + alertRuntimeOperationFacts, + bindLocalAlertAcceptInteractor, + bindLocalAlertDismissInteractor, + bindLocalAlertReadInteractor, + bindLocalAlertWaitInteractor, + bindProviderAlertAcceptInteractor, +} from './alert-runtime.ts'; +import type { AlertInteractorOptions, Interactor } from './interactor-types.ts'; + +const device = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, +} as const; + +test('builds the exact alert operation fact catalog', () => { + const read = { available: true } as const; + const wait = { available: false, reason: 'owner-capability-missing' } as const; + const accept = { available: true } as const; + const dismiss = { available: true } as const; + + expect(alertRuntimeOperationFacts({ read, wait, accept, dismiss })).toEqual({ + readAlert: read, + awaitAlert: wait, + acceptAlert: accept, + dismissAlert: dismiss, + }); +}); + +// The whole input is the window this request allows and the session's own target; the owner +// decides how to spend the window and which backend answers. +test('each local leg forwards the window and the session target to its own owner method', async () => { + const legs = { + readAlert: vi.fn(async () => ({ title: 'Camera Access' })), + awaitAlert: vi.fn(async () => ({ title: 'Camera Access' })), + acceptAlert: vi.fn(async () => ({ accepted: true })), + dismissAlert: vi.fn(async () => ({ dismissed: true })), + }; + const resolveInteractor = vi.fn(async () => legs as unknown as Interactor); + const signal = new AbortController().signal; + const params = { device, signal, resolveInteractor }; + const input = { + timeoutMs: 37, + appBundleId: 'com.example.app', + surface: 'app' as const, + execution: { logPath: '/tmp/daemon.log', requestId: 'alert-1' }, + }; + + await bindLocalAlertReadInteractor(params).readAlert(input); + await bindLocalAlertWaitInteractor(params).awaitAlert(input); + await bindLocalAlertAcceptInteractor(params).acceptAlert(input); + await bindLocalAlertDismissInteractor(params).dismissAlert(input); + + const expectedOptions = { timeoutMs: 37, appBundleId: 'com.example.app', surface: 'app' }; + expect(legs.readAlert).toHaveBeenCalledWith(expectedOptions); + expect(legs.awaitAlert).toHaveBeenCalledWith(expectedOptions); + expect(legs.acceptAlert).toHaveBeenCalledWith(expectedOptions); + expect(legs.dismissAlert).toHaveBeenCalledWith(expectedOptions); + expect(resolveInteractor).toHaveBeenLastCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'alert-1', + appBundleId: 'com.example.app', + signal, + }); +}); + +// A frontmost-app session carries no bundle at all, and the option object must not invent one. +test('an absent target field never reaches the owner as an explicit undefined', async () => { + const readAlert = vi.fn(async (_options?: AlertInteractorOptions) => ({})); + const operations = bindLocalAlertReadInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: async () => ({ readAlert }) as unknown as Interactor, + }); + + await operations.readAlert({ surface: 'frontmost-app' }); + + expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app' }); + expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface']); +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + const operations = bindProviderAlertAcceptInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await expect(operations.acceptAlert({})).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, + }); +}); + +test('an already-cancelled request never resolves an interactor', async () => { + const controller = new AbortController(); + controller.abort(); + const readAlert = vi.fn(async () => ({})); + const resolveInteractor = vi.fn(async () => ({ readAlert }) as unknown as Interactor); + + const operations = bindLocalAlertReadInteractor({ + device, + signal: controller.signal, + resolveInteractor, + }); + + await expect(operations.readAlert({})).rejects.toThrow(); + expect(resolveInteractor).not.toHaveBeenCalled(); + expect(readAlert).not.toHaveBeenCalled(); +}); diff --git a/packages/contracts/src/alert-runtime.ts b/packages/contracts/src/alert-runtime.ts new file mode 100644 index 0000000000..9913b5486f --- /dev/null +++ b/packages/contracts/src/alert-runtime.ts @@ -0,0 +1,205 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, +} from './interactor-operation-binding.ts'; +import type { AlertInteractorOptions, Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { SessionSurface } from './session-surface.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * Neutral intent for one alert leg. Nothing command-shaped travels here: the daemon has already + * parsed the subcommand, chosen which of the four operations to bind, and turned the CLI's + * optional timeout positional into a millisecond window. + * + * `appBundleId` and `surface` are the session's own target, forwarded as the pair the owner + * needs — a macOS frontmost-app session deliberately carries no bundle, and collapsing the two + * into one field would lose that. + */ +export type AlertRuntimeInput = Readonly<{ + /** The whole window this request allows the owner; absent means the owner's own default. */ + timeoutMs?: number; + appBundleId?: string; + surface?: SessionSurface; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * Owners answer with their own alert payload — Android's discriminated `alertStatus`/`alertWait`/ + * `alertHandled` records, the XCTest runner's alert fields, the macOS helper's. The daemon + * composes the response around whichever it gets, exactly as the retired leaf did. + */ +export type AlertReadRuntimeOperations = Readonly<{ + readAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertWaitRuntimeOperations = Readonly<{ + awaitAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertAcceptRuntimeOperations = Readonly<{ + acceptAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertDismissRuntimeOperations = Readonly<{ + dismissAlert(input: AlertRuntimeInput): Promise>; +}>; + +export type AlertRuntimeOperations = AlertReadRuntimeOperations & + AlertWaitRuntimeOperations & + AlertAcceptRuntimeOperations & + AlertDismissRuntimeOperations; + +export type AlertRuntimeOperationFacts = Readonly<{ + readAlert: RuntimeOperationFact; + awaitAlert: RuntimeOperationFact; + acceptAlert: RuntimeOperationFact; + dismissAlert: RuntimeOperationFact; +}>; + +/** + * Four cells rather than one, because the daemon binds exactly the leg the parsed subcommand + * names (ADR 0019 §9). No owner today observes an alert it cannot act on, so every owner passes + * the same fact four times — but a partial owner would then refuse only the legs it lacks, rather + * than taking the whole command down with it. + */ +export function alertRuntimeOperationFacts( + input: Readonly<{ + read: RuntimeOperationFact; + wait: RuntimeOperationFact; + accept: RuntimeOperationFact; + dismiss: RuntimeOperationFact; + }>, +): AlertRuntimeOperationFacts { + return Object.freeze({ + readAlert: input.read, + awaitAlert: input.wait, + acceptAlert: input.accept, + dismissAlert: input.dismiss, + }); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding. The + * owner is already chosen by the time a binder is called, so each entry point supplies its own + * resolution and this holds only what all four legs share: the runner context and the target. + */ +async function resolveAlertInteractor( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, + input: AlertRuntimeInput, +): Promise { + signal.throwIfAborted(); + return await resolveInteractor({ + ...input.execution, + appBundleId: input.appBundleId, + signal, + }); +} + +function alertInteractorOptions(input: AlertRuntimeInput): AlertInteractorOptions { + return { + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }), + ...(input.surface === undefined ? {} : { surface: input.surface }), + }; +} + +type AlertLeg = 'readAlert' | 'awaitAlert' | 'acceptAlert' | 'dismissAlert'; + +function bindAlertLeg( + leg: Leg, + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): Readonly Promise>>> { + return Object.freeze({ + [leg]: async (input: AlertRuntimeInput) => { + const interactor = await resolveAlertInteractor(signal, resolveInteractor, input); + return await interactor[leg](alertInteractorOptions(input)); + }, + }) as Readonly Promise>>>; +} + +export type LocalAlertInteractorResolver = LocalInteractorOperationResolver; +export type ProviderAlertInteractorResolver = ProviderInteractorOperationResolver; + +type LocalAlertBinding = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalAlertInteractorResolver; +}>; + +type ProviderAlertBinding = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderAlertInteractorResolver; +}>; + +export function bindLocalAlertReadInteractor( + params: LocalAlertBinding, +): AlertReadRuntimeOperations { + return bindAlertLeg('readAlert', params.signal, localInteractorSource(params)); +} + +export function bindLocalAlertWaitInteractor( + params: LocalAlertBinding, +): AlertWaitRuntimeOperations { + return bindAlertLeg('awaitAlert', params.signal, localInteractorSource(params)); +} + +export function bindLocalAlertAcceptInteractor( + params: LocalAlertBinding, +): AlertAcceptRuntimeOperations { + return bindAlertLeg('acceptAlert', params.signal, localInteractorSource(params)); +} + +export function bindLocalAlertDismissInteractor( + params: LocalAlertBinding, +): AlertDismissRuntimeOperations { + return bindAlertLeg('dismissAlert', params.signal, localInteractorSource(params)); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderAlertReadInteractor( + params: ProviderAlertBinding, +): AlertReadRuntimeOperations { + return bindAlertLeg( + 'readAlert', + params.signal, + providerInteractorSource({ ...params, operation: 'alert get' }), + ); +} + +export function bindProviderAlertWaitInteractor( + params: ProviderAlertBinding, +): AlertWaitRuntimeOperations { + return bindAlertLeg( + 'awaitAlert', + params.signal, + providerInteractorSource({ ...params, operation: 'alert wait' }), + ); +} + +export function bindProviderAlertAcceptInteractor( + params: ProviderAlertBinding, +): AlertAcceptRuntimeOperations { + return bindAlertLeg( + 'acceptAlert', + params.signal, + providerInteractorSource({ ...params, operation: 'alert accept' }), + ); +} + +export function bindProviderAlertDismissInteractor( + params: ProviderAlertBinding, +): AlertDismissRuntimeOperations { + return bindAlertLeg( + 'dismissAlert', + params.signal, + providerInteractorSource({ ...params, operation: 'alert dismiss' }), + ); +} diff --git a/packages/contracts/src/facades/interaction.ts b/packages/contracts/src/facades/interaction.ts index 826ea4bd85..62b8dc0499 100644 --- a/packages/contracts/src/facades/interaction.ts +++ b/packages/contracts/src/facades/interaction.ts @@ -117,6 +117,7 @@ export { TEXT_ENTRY_ROUTES, } from '../interactor-types.ts'; export type { + AlertInteractorOptions, CloudTextEntryReadiness, ElementSelectorKey, ElementSelectorTapOptions, diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 8b277d957b..a64b29d804 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -247,6 +247,11 @@ export { keyboardEnterUse, appEventRuntimeUse, settingsRuntimeUse, + alertRuntimePlanUses, + alertReadUse, + alertWaitUse, + alertAcceptUse, + alertDismissUse, appSwitcherRuntimeUse, clipboardRuntimePlanUses, clipboardReadUse, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 2bf1f6b73b..badb0922e4 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -34,6 +34,16 @@ import type { import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import { bindLocalSettingsInteractor, bindProviderSettingsInteractor } from './settings-runtime.ts'; +import { + bindLocalAlertAcceptInteractor, + bindLocalAlertDismissInteractor, + bindLocalAlertReadInteractor, + bindLocalAlertWaitInteractor, + bindProviderAlertAcceptInteractor, + bindProviderAlertDismissInteractor, + bindProviderAlertReadInteractor, + bindProviderAlertWaitInteractor, +} from './alert-runtime.ts'; import { bindLocalTvRemoteInteractor, bindProviderTvRemoteInteractor, @@ -64,6 +74,10 @@ const CATALOG_INTERACTOR_OPERATIONS = [ 'appSwitcher', 'triggerAppEvent', 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const; export type CatalogInteractorOperation = (typeof CATALOG_INTERACTOR_OPERATIONS)[number]; @@ -97,6 +111,10 @@ const LOCAL_BINDERS: Readonly< appSwitcher: bindLocalAppSwitcherInteractor, triggerAppEvent: bindLocalAppEventInteractor, setSetting: bindLocalSettingsInteractor, + readAlert: bindLocalAlertReadInteractor, + awaitAlert: bindLocalAlertWaitInteractor, + acceptAlert: bindLocalAlertAcceptInteractor, + dismissAlert: bindLocalAlertDismissInteractor, }); const PROVIDER_BINDERS: Readonly< @@ -117,6 +135,10 @@ const PROVIDER_BINDERS: Readonly< appSwitcher: bindProviderAppSwitcherInteractor, triggerAppEvent: bindProviderAppEventInteractor, setSetting: bindProviderSettingsInteractor, + readAlert: bindProviderAlertReadInteractor, + awaitAlert: bindProviderAlertWaitInteractor, + acceptAlert: bindProviderAlertAcceptInteractor, + dismissAlert: bindProviderAlertDismissInteractor, }); /** diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 1242668861..cd761470b9 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -347,4 +347,25 @@ export type Interactor = { appId?: string, options?: SettingOptions, ): Promise | void>; + /** + * The four alert legs. Each owner runs its own observation and, where it needs one, its own + * poll: an alert is a transient device surface, and how long to look for it — and how to press + * its buttons — is family mechanics, not something a caller can supply. `timeoutMs` is the + * whole window the caller allows; the owner spends it however its backend requires. + */ + readAlert(options?: AlertInteractorOptions): Promise>; + awaitAlert(options?: AlertInteractorOptions): Promise>; + acceptAlert(options?: AlertInteractorOptions): Promise>; + dismissAlert(options?: AlertInteractorOptions): Promise>; +}; + +/** + * The session-derived target one alert leg acts on. `appBundleId` is separate from the runner + * context's because the macOS helper reads a frontmost-app surface with no bundle at all, and + * the two must not collapse into one field that means both. + */ +export type AlertInteractorOptions = { + timeoutMs?: number; + appBundleId?: string; + surface?: SessionSurface; }; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 1d12496867..505147bf16 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -32,6 +32,7 @@ import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; import type { AppSwitcherRuntimeOperations } from './app-switcher-runtime.ts'; import type { AppEventRuntimeOperations } from './app-event-runtime.ts'; import type { SettingsRuntimeOperations } from './settings-runtime.ts'; +import type { AlertRuntimeOperations } from './alert-runtime.ts'; import type { TouchRuntimeOperations } from './touch-runtime.ts'; import type { DeviceReadinessRuntimeHost, @@ -80,6 +81,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & AppSwitcherRuntimeOperations & AppEventRuntimeOperations & SettingsRuntimeOperations & + AlertRuntimeOperations & TouchRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & @@ -112,6 +114,10 @@ export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); export const appSwitcherRuntimeUse = defineUse({ required: ['appSwitcher'] }); export const appEventRuntimeUse = defineUse({ required: ['triggerAppEvent'] }); export const settingsRuntimeUse = defineUse({ required: ['setSetting'] }); +export const alertReadUse = defineUse({ required: ['readAlert'] }); +export const alertWaitUse = defineUse({ required: ['awaitAlert'] }); +export const alertAcceptUse = defineUse({ required: ['acceptAlert'] }); +export const alertDismissUse = defineUse({ required: ['dismissAlert'] }); export const clipboardReadUse = defineUse({ required: ['readClipboard'] }); export const clipboardWriteUse = defineUse({ required: ['writeClipboard'] }); export const tapPointUse = defineUse({ required: ['tapPoint'] }); @@ -646,6 +652,18 @@ export const clipboardRuntimePlanUses = Object.freeze([ clipboardWriteUse, ] as const); +/** + * `alert`'s action-selected uses (ADR 0019 §9: one bind per handler). The four legs differ in + * what they do to the device — one observes, one waits, two press a button — so the daemon's + * `snapshot-alert.ts` admits and binds exactly the one the parsed subcommand names. + */ +export const alertRuntimePlanUses = Object.freeze([ + alertReadUse, + alertWaitUse, + alertAcceptUse, + alertDismissUse, +] as const); + /** * `keyboard`'s action-selected uses (ADR 0019 §9: one bind per handler). `status` is Android-only * (parity with the retired leaf's per-family rejection of `status`/`get` everywhere else); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 4be6ccaf87..1ff23a27de 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -49,6 +49,10 @@ test('generic unavailable binding preserves exact provider ownership and mode', appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, setSetting: { available: false, reason: 'unsupported-provider-mode' }, + readAlert: { available: false, reason: 'unsupported-provider-mode' }, + awaitAlert: { available: false, reason: 'unsupported-provider-mode' }, + acceptAlert: { available: false, reason: 'unsupported-provider-mode' }, + dismissAlert: { available: false, reason: 'unsupported-provider-mode' }, lifecycle, }); diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 49562b29e8..97176c1ded 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -28,6 +28,7 @@ import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; import { appSwitcherRuntimeOperationFacts } from './app-switcher-runtime.ts'; import { appEventRuntimeOperationFacts } from './app-event-runtime.ts'; import { settingsRuntimeOperationFacts } from './settings-runtime.ts'; +import { alertRuntimeOperationFacts } from './alert-runtime.ts'; import { touchRuntimeOperationFacts } from './touch-runtime.ts'; /** @@ -62,6 +63,10 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ appSwitcher: RuntimeOperationUnavailability; triggerAppEvent: RuntimeOperationUnavailability; setSetting: RuntimeOperationUnavailability; + readAlert: RuntimeOperationUnavailability; + awaitAlert: RuntimeOperationUnavailability; + acceptAlert: RuntimeOperationUnavailability; + dismissAlert: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -123,6 +128,10 @@ export function createUnavailablePlatformRuntimeFacts( appSwitcher, triggerAppEvent, setSetting, + readAlert, + awaitAlert, + acceptAlert, + dismissAlert, readiness, shutdown, lifecycle, @@ -196,6 +205,12 @@ export function createUnavailablePlatformRuntimeFacts( ...appSwitcherRuntimeOperationFacts({ appSwitcher }), ...appEventRuntimeOperationFacts({ triggerAppEvent }), ...settingsRuntimeOperationFacts({ setSetting }), + ...alertRuntimeOperationFacts({ + read: readAlert, + wait: awaitAlert, + accept: acceptAlert, + dismiss: dismissAlert, + }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -257,6 +272,10 @@ function freezeUnavailableFacts( // Device settings differ by leaf and kind the way the pasteboard does, and a provider can // own a device without exposing any settings API at all, so each owner states its own cell. setSetting: Object.freeze({ ...unavailable.setSetting }), + readAlert: Object.freeze({ ...unavailable.readAlert }), + awaitAlert: Object.freeze({ ...unavailable.awaitAlert }), + acceptAlert: Object.freeze({ ...unavailable.acceptAlert }), + dismissAlert: Object.freeze({ ...unavailable.dismissAlert }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), }); } diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index a7a0aec1c1..0f9bb9fad2 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -317,6 +317,11 @@ test('admits both clipboard halves and the app switcher on every real Android ki // R58: settings run over adb (`appops`, `settings put`, `pm clear`, …) on that cell too. expect(binding.facts.operations.setSetting).toEqual({ available: true }); expect(binding.operations.setSetting).toBeTypeOf('function'); + // R59: all four alert legs read the same dump and tap with the same `input tap`. + for (const operation of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(binding.facts.operations[operation]).toEqual({ available: true }); + expect(binding.operations[operation]).toBeTypeOf('function'); + } } }); @@ -346,6 +351,10 @@ test('the synthetic Android simulator cell refuses back/home/orientation/keyboar 'appSwitcher', 'triggerAppEvent', 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const) { expect(facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index e0802e8b5d..5d235703ee 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -34,6 +34,7 @@ import { touchRuntimeOperationFacts } from '@agent-device/contracts/touch-runtim import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; @@ -285,6 +286,15 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor // Settings run over adb (`appops`, `settings put`, `pm clear`, …) on every real kind, so // the cell is the retired `ANDROID_ALL` bucket verbatim. ...settingsRuntimeOperationFacts({ setSetting: androidTouchFact(device) }), + // R59: Android reads alerts out of the same accessibility dump every interaction cell + // depends on and presses their buttons with the same `input tap`, so all four legs take + // that cell — the retired `ANDROID_ALL` bucket verbatim. + ...alertRuntimeOperationFacts({ + read: androidTouchFact(device), + wait: androidTouchFact(device), + accept: androidTouchFact(device), + dismiss: androidTouchFact(device), + }), ...orientationRuntimeOperationFacts({ orientation: androidTouchFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: androidTvRemoteFact(device) }), // The only owner with a live IME status read; dismiss/enter share every other diff --git a/packages/platform-apple/src/system/runtime.test.ts b/packages/platform-apple/src/system/runtime.test.ts index 4514aeec69..a52d4bb489 100644 --- a/packages/platform-apple/src/system/runtime.test.ts +++ b/packages/platform-apple/src/system/runtime.test.ts @@ -99,6 +99,33 @@ test('a physical non-macOS Apple device refuses settings as a platform leaf', () }); }); +/** + * `alert`'s retired admission was the host-or-simulator closure widened by one leaf: physical + * iOS, whose XCTest alert path is device-verified. Every other physical Apple leaf stays closed + * exactly as `supportsAlertSurface` left it, and watchOS narrows for want of an interactor. + */ +test.each([ + { appleOs: 'ios', kind: 'simulator', expected: true }, + { appleOs: 'ios', kind: 'device', expected: true }, + { appleOs: 'ipados', kind: 'simulator', expected: true }, + { appleOs: 'ipados', kind: 'device', expected: false }, + { appleOs: 'tvos', kind: 'simulator', expected: true }, + { appleOs: 'tvos', kind: 'device', expected: false }, + { appleOs: 'visionos', kind: 'simulator', expected: true }, + { appleOs: 'visionos', kind: 'device', expected: false }, + { appleOs: 'macos', kind: 'device', expected: true }, + { appleOs: 'watchos', kind: 'simulator', expected: false }, +] as const)( + 'alert on an Apple $appleOs $kind is available: $expected', + ({ appleOs, kind, expected }) => { + const facts = appleSystemFacts(appleDevice(appleOs, kind)); + // One cell, four legs: an Apple backend that can read a sheet can press its buttons too. + for (const leg of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(facts[leg].available).toBe(expected); + } + }, +); + test('a non-simulator, non-device Apple kind is refused by kind, with its own reason', () => { const facts = appleSystemFacts(appleDevice('ios', 'emulator')); expect(facts.readClipboard).toEqual({ @@ -111,6 +138,11 @@ test('a non-simulator, non-device Apple kind is refused by kind, with its own re reason: 'unsupported-device-kind', hint: 'settings is supported on Apple simulators and the macOS host.', }); + expect(facts.readAlert).toEqual({ + available: false, + reason: 'unsupported-device-kind', + hint: 'alert is supported on Apple simulators and physical devices.', + }); }); test('binds the system operations for an admitted cell and none for a refused one', () => { @@ -126,6 +158,8 @@ test('binds the system operations for an admitted cell and none for a refused on expect(admitted.readClipboard).toBeTypeOf('function'); expect(admitted.writeClipboard).toBeTypeOf('function'); expect(admitted.setSetting).toBeTypeOf('function'); + expect(admitted.readAlert).toBeTypeOf('function'); + expect(admitted.acceptAlert).toBeTypeOf('function'); const refused = createAppleSystemOperations({ host, @@ -135,5 +169,8 @@ test('binds the system operations for an admitted cell and none for a refused on expect(refused.readClipboard).toBeUndefined(); expect(refused.writeClipboard).toBeUndefined(); expect(refused.setSetting).toBeUndefined(); + // The refused cell here is clipboard's and settings' — a physical iOS device, which `alert` + // deliberately still admits, so its legs stay bound. + expect(refused.readAlert).toBeTypeOf('function'); expect(resolve).not.toHaveBeenCalled(); }); diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts index 77502d1f1a..4aaab146ce 100644 --- a/packages/platform-apple/src/system/runtime.ts +++ b/packages/platform-apple/src/system/runtime.ts @@ -1,3 +1,4 @@ +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; @@ -39,7 +40,7 @@ const clipboardLeafUnavailable = Object.freeze({ * `appleBackFact` takes, and for the same reason: facts are the support authority, not a mirror * of a capability table that never modeled interactor constructibility. */ -const clipboardOsUnavailable = Object.freeze({ +const appleWatchOsUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', } as const); @@ -57,7 +58,7 @@ function appleHostOrSimulatorFact( ): RuntimeOperationFact { if (device.kind !== 'simulator' && device.kind !== 'device') return kindUnavailable; const os = resolveDeviceAppleOs(device); - if (os === 'watchos') return clipboardOsUnavailable; + if (os === 'watchos') return appleWatchOsUnavailable; if (device.kind === 'simulator') return available; return os === 'macos' ? available : leafUnavailable; } @@ -77,6 +78,31 @@ const appEventKindUnavailable = Object.freeze({ hint: 'trigger-app-event is supported on Apple simulators and physical devices.', } as const); +const alertKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'alert is supported on Apple simulators and physical devices.', +} as const); +const alertLeafUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'alert is supported on Apple simulators, the macOS host, and physical iOS devices.', +} as const); + +/** + * Parity with the retired `supportsAlertSurface` closure, which was the host-or-simulator reading + * widened by one leaf: physical iOS, whose XCTest alert path is device-verified. iPadOS, + * tvOS and visionOS devices stay closed exactly as that closure left them — not because the + * runner could not reach them, but because nobody has verified it there. + */ +function appleAlertFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') return alertKindUnavailable; + const os = resolveDeviceAppleOs(device); + if (os === 'watchos') return appleWatchOsUnavailable; + if (device.kind === 'simulator' || os === 'ios' || os === 'macos') return available; + return alertLeafUnavailable; +} + /** * No apple-family closure ever gated `trigger-app-event` beyond its capability bucket * (`{ simulator, device }`): the deep link opens through the same interactor `open` every Apple @@ -84,14 +110,18 @@ const appEventKindUnavailable = Object.freeze({ */ function appleAppEventFact(device: DeviceInfo): RuntimeOperationFact { if (device.kind !== 'simulator' && device.kind !== 'device') return appEventKindUnavailable; - return resolveDeviceAppleOs(device) === 'watchos' ? clipboardOsUnavailable : available; + return resolveDeviceAppleOs(device) === 'watchos' ? appleWatchOsUnavailable : available; } -/** The system-surface cells: clipboard read/write, app-event delivery, and settings. */ +/** The system-surface cells: clipboard read/write, app-event delivery, settings, and alerts. */ export function appleSystemFacts(device: DeviceInfo) { const clipboard = appleClipboardFact(device); + // The four alert legs share one cell: an Apple leaf whose backend can read an alert can also + // press its buttons, so splitting them would invent a cell no Apple owner is ever in. + const alert = appleAlertFact(device); return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: clipboard, write: clipboard }), + ...alertRuntimeOperationFacts({ read: alert, wait: alert, accept: alert, dismiss: alert }), ...appEventRuntimeOperationFacts({ triggerAppEvent: appleAppEventFact(device) }), ...settingsRuntimeOperationFacts({ setSetting: appleHostOrSimulatorFact( diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 58f3f9b839..93c07e6d93 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -115,7 +115,16 @@ test.each([ expect(binding.operations.keyboardStatus).toBeUndefined(); // R55: HarmonyOS never carried a `clipboard` bucket and is absent from the HarmonyOS overlay // set, so neither half was ever admitted here. - for (const operation of ['readClipboard', 'writeClipboard', 'triggerAppEvent'] as const) { + for (const operation of [ + 'readClipboard', + 'writeClipboard', + 'triggerAppEvent', + // R59: `alert` never had a HarmonyOS leaf either — hdc exposes no dialog surface. + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', + ] as const) { expect(facts.operations[operation]).toEqual({ available: false, reason: 'unsupported-platform-leaf', @@ -176,6 +185,10 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( 'appSwitcher', 'triggerAppEvent', 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const) { expect(binding.facts.operations[operation].available).toBe(false); expect(binding.operations[operation]).toBeUndefined(); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index f1f029b755..286acec82b 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -25,6 +25,7 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; @@ -283,6 +284,14 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor // Parity with the retired `HARMONYOS_SUPPORTED_COMMANDS` overlay, which listed `settings` // for both HarmonyOS kinds; the hdc-driven settings surface shares the interaction gate. ...settingsRuntimeOperationFacts({ setSetting: harmonyFocusFact(device) }), + // R59: the retired `alert` descriptor declared no HarmonyOS leaf and the overlay set + // never listed it, so no HarmonyOS cell was ever admitted. + ...alertRuntimeOperationFacts({ + read: harmonyPlatformLeafUnavailable, + wait: harmonyPlatformLeafUnavailable, + accept: harmonyPlatformLeafUnavailable, + dismiss: harmonyPlatformLeafUnavailable, + }), ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), ...keyboardRuntimeOperationFacts({ diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index ddf3814995..60b73074b7 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -206,8 +206,12 @@ function expectLinuxNavigationAndKeyboardFacts( 'appSwitcher', // R57: the retired `trigger-app-event` descriptor declared `linux: {}` too. 'triggerAppEvent', - // R58: and so did `settings`. + // R58/R59: and so did `settings` and `alert`. 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 63807a4702..4e14abebbb 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -201,6 +201,11 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts triggerAppEvent: linuxPlatformLeafUnavailable, // The retired `settings` descriptor declared `linux: {}` too. setSetting: linuxPlatformLeafUnavailable, + // R59: `alert` declared `linux: {}` too — AT-SPI exposes no dialog affordance to act on. + readAlert: linuxPlatformLeafUnavailable, + awaitAlert: linuxPlatformLeafUnavailable, + acceptAlert: linuxPlatformLeafUnavailable, + dismissAlert: linuxPlatformLeafUnavailable, keyboardStatus: linuxPlatformLeafUnavailable, keyboardDismiss: linuxPlatformLeafUnavailable, keyboardEnter: linuxPlatformLeafUnavailable, diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index 29ba6c62dc..6949992398 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -206,6 +206,14 @@ test.each([ hint: 'settings is not supported on Vega OS.', }); expect(binding.operations.setSetting).toBeUndefined(); + for (const operation of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(binding.facts.operations[operation]).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'alert is not supported on Vega OS.', + }); + expect(binding.operations[operation]).toBeUndefined(); + } expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 0d251f2f3f..6efb9fe1f2 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -131,6 +131,10 @@ const keyboardUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'keyboard is not supported on Vega OS.', ); +const alertUnavailable = vegaUnavailable( + 'unsupported-platform-leaf', + 'alert is not supported on Vega OS.', +); const settingsUnavailable = vegaUnavailable( 'unsupported-platform-leaf', 'settings is not supported on Vega OS.', @@ -188,6 +192,10 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts appSwitcher: appSwitcherUnavailable, triggerAppEvent: appEventUnavailable, setSetting: settingsUnavailable, + readAlert: alertUnavailable, + awaitAlert: alertUnavailable, + acceptAlert: alertUnavailable, + dismissAlert: alertUnavailable, keyboardStatus: keyboardUnavailable, keyboardDismiss: keyboardUnavailable, keyboardEnter: keyboardUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index eb30519e29..746e861880 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -199,7 +199,7 @@ test.each([ }, ); -test('clipboard, the app switcher, app events and settings carry no web capability bucket', async () => { +test('clipboard, the app switcher, app events, settings and alerts carry no web bucket', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, intent: { kind: 'ordinary' }, @@ -210,8 +210,12 @@ test('clipboard, the app switcher, app events and settings carry no web capabili 'writeClipboard', 'appSwitcher', 'triggerAppEvent', - // R58: the retired `settings` descriptor declared no web leaf either. + // R58/R59: the retired `settings` and `alert` descriptors declared no web leaf either. 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: false, @@ -590,8 +594,11 @@ test('diff shares the admitted captureSnapshot fact that live snapshot and diff }); // #1900: `pressRuntimeUses` is literally `clickRuntimeUses` (`platform-runtime-operations.ts`), -// so the admitted fact backing the live `click` command also backs `press`. -test('press shares the admitted tapPoint fact that live click and press both require', async () => { +// so the admitted fact backing the live `click` command also backs `press`. R61 added a third +// consumer of that same cell: `react-native dismiss-overlay` executes one bound `tapPoint`, so a +// browser admits it and answers truthfully that no React Native overlay is present — the widening +// the retired capability bucket had been hiding. +test('press shares the admitted tapPoint fact that live click, press and react-native require', async () => { const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ device, intent: { kind: 'ordinary' }, diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 708de022be..3dfb206868 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -48,6 +48,7 @@ import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime' import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; @@ -408,6 +409,12 @@ function webRuntimeFacts( ...appSwitcherRuntimeOperationFacts({ appSwitcher: navigationUnavailable }), ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), ...settingsRuntimeOperationFacts({ setSetting: navigationUnavailable }), + ...alertRuntimeOperationFacts({ + read: navigationUnavailable, + wait: navigationUnavailable, + accept: navigationUnavailable, + dismiss: navigationUnavailable, + }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 8aa21052d5..fada5de279 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -112,6 +112,10 @@ export function createLimrunPlatformRuntimeOwner( appSwitcher: liveSessionUnavailable, triggerAppEvent: liveSessionUnavailable, setSetting: liveSessionUnavailable, + readAlert: liveSessionUnavailable, + awaitAlert: liveSessionUnavailable, + acceptAlert: liveSessionUnavailable, + dismissAlert: liveSessionUnavailable, readiness: liveSessionUnavailable, shutdown: liveSessionUnavailable, lifecycle: limrunLifecycleFacts(device, false), diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 44e96aad64..844c45c14d 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -17,6 +17,7 @@ import { limrunKeyboardOperationFacts, limrunAppEventOperationFacts, limrunSettingsOperationFacts, + limrunAlertOperationFacts, limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, @@ -193,6 +194,7 @@ export function limrunAppLogFacts( ...limrunAppSwitcherOperationFacts(device), ...limrunAppEventOperationFacts(device), ...limrunSettingsOperationFacts(device), + ...limrunAlertOperationFacts(device), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -244,6 +246,7 @@ export function limrunAppLogRecoveryFacts( ...limrunAppSwitcherOperationFacts(device, liveSessionUnavailable), ...limrunAppEventOperationFacts(device, liveSessionUnavailable), ...limrunSettingsOperationFacts(device, liveSessionUnavailable), + ...limrunAlertOperationFacts(device, liveSessionUnavailable), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 182394661f..be51bbfcd9 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -4,6 +4,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test } from 'vitest'; import { limrunAppEventOperationFacts, + limrunAlertOperationFacts, limrunSettingsOperationFacts, limrunAppSwitcherOperationFacts, limrunClipboardOperationFacts, @@ -117,6 +118,22 @@ test('settings ride the Android interactor and are refused on the iOS leg', () = ).toEqual(liveSessionUnavailable); }); +// R59: same split again — the Android leg reuses the local family's interactor, and the iOS +// direct session has no XCUITest runner to read a sheet from. +test('alert legs ride the Android interactor and are refused on the iOS leg', () => { + for (const leg of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { + expect(limrunAlertOperationFacts(androidMobileDevice)[leg]).toEqual({ available: true }); + expect(limrunAlertOperationFacts(iosDevice)[leg]).toEqual({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose alert inspection yet.', + }); + expect(limrunAlertOperationFacts(androidMobileDevice, liveSessionUnavailable)[leg]).toEqual( + liveSessionUnavailable, + ); + } +}); + test('the iOS leg admits back/orientation but explicitly refuses home and tv-remote', () => { const facts = limrunNavigationOperationFacts(iosDevice); expect(facts.back).toEqual({ available: true }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index ac39da1282..2c4082f953 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -19,6 +19,7 @@ import { import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; @@ -272,6 +273,28 @@ export function limrunClipboardOperationFacts( return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: cell, write: cell }) }); } +const alertUnavailableIos = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Limrun iOS direct sessions do not expose alert inspection yet.', +} as const); + +/** + * `alert` splits like every other interaction leaf: the Android leg rides the local family's own + * interactor factory, which reads the same accessibility dump it always did; the iOS direct + * session has no XCUITest runner to read a sheet from, so all four legs refuse together. + */ +export function limrunAlertOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + const cell = + liveSessionUnavailable ?? (device.platform === 'android' ? available : alertUnavailableIos); + return Object.freeze({ + ...alertRuntimeOperationFacts({ read: cell, wait: cell, accept: cell, dismiss: cell }), + }); +} + /** * `app-switcher` splits the same way its siblings do: the Android leg rides * `session.dependencies.android.createInteractor` -- the SAME factory the local Android family diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index 596b434594..1f0417f60e 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -293,6 +293,22 @@ class LimrunIosInteractor implements Interactor { throw unsupported('tv-remote', 'Limrun iOS direct sessions do not expose tv remote control.'); } + async readAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + + async awaitAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + + async acceptAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + + async dismissAlert(): Promise { + throw unsupported('alert', LIMRUN_IOS_ALERT_UNSUPPORTED); + } + async readClipboard(): Promise { throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.'); } @@ -392,6 +408,10 @@ export function isUserInstalledIosApp(app: LimrunIosApp): boolean { ); } +/** One sentence for all four alert legs: this session has no XCUITest runner to read a sheet. */ +const LIMRUN_IOS_ALERT_UNSUPPORTED = + 'Limrun iOS direct sessions do not expose alert inspection yet.'; + function unsupported(command: string, message: string): never { throw new AppError('UNSUPPORTED_OPERATION', message, { command }); } diff --git a/packages/provider-webdriver/src/capabilities.ts b/packages/provider-webdriver/src/capabilities.ts index 5052307997..0df933e220 100644 --- a/packages/provider-webdriver/src/capabilities.ts +++ b/packages/provider-webdriver/src/capabilities.ts @@ -22,6 +22,7 @@ export type CloudWebDriverOperation = | 'clipboard.read' | 'clipboard.write' | 'settings' + | 'alert' | 'pinch' | 'rotateGesture' | 'transformGesture' @@ -106,6 +107,7 @@ const BASE_WEBDRIVER_CAPABILITIES: CloudWebDriverCapabilityMap = { note: 'Uses provider/Appium clipboard extension support where available.', }, settings: unsupported, + alert: unsupported, pinch: unsupported, rotateGesture: unsupported, transformGesture: unsupported, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 2d8386f400..7ff0e8b906 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -270,6 +270,10 @@ test('captures through only the active exact WebDriver interactor', async () => for (const operation of [ 'tvRemote', 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', @@ -332,6 +336,10 @@ test.each([ for (const operation of [ 'tvRemote', 'setSetting', + 'readAlert', + 'awaitAlert', + 'acceptAlert', + 'dismissAlert', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 3e1a4a3bc3..9784b7691d 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -25,6 +25,7 @@ import { import { homeRuntimeOperationFacts } from '@agent-device/contracts/home-runtime'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; +import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; @@ -229,6 +230,17 @@ const appSwitcherUnavailable = Object.freeze({ * declares `settings: unsupported`), so this cell is unavailable unconditionally rather than * gated by interactor reachability — the same shape `tvRemote` takes. */ +/** + * Same shape as `settings`: the WebDriver interactor's own alert legs always throw unsupported + * (its capability map declares `alert: unsupported`), so this cell is unavailable unconditionally + * rather than gated by interactor reachability. + */ +const alertUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose native alert handling.', +} as const); + const settingsUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -470,6 +482,10 @@ function webDriverFacts( appSwitcher: inactiveSession, triggerAppEvent: inactiveSession, setSetting: inactiveSession, + readAlert: inactiveSession, + awaitAlert: inactiveSession, + acceptAlert: inactiveSession, + dismissAlert: inactiveSession, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: inactiveSession, prepareApplicationOpen: inactiveSession, @@ -509,6 +525,10 @@ function webDriverFacts( appSwitcher: appSwitcherUnavailable, triggerAppEvent: appEventUnavailable, setSetting: settingsUnavailable, + readAlert: alertUnavailable, + awaitAlert: alertUnavailable, + acceptAlert: alertUnavailable, + dismissAlert: alertUnavailable, lifecycle: webDriverLifecycleFacts(device), }); // Both capture cells need the same reachability: an interactor this provider can drive, on a @@ -588,6 +608,12 @@ function webDriverFacts( triggerAppEvent: interactorCell(reachable, appEventUnavailable), }), ...settingsRuntimeOperationFacts({ setSetting: settingsUnavailable }), + ...alertRuntimeOperationFacts({ + read: alertUnavailable, + wait: alertUnavailable, + accept: alertUnavailable, + dismiss: alertUnavailable, + }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ensureReady: available, bootTarget: available, diff --git a/packages/provider-webdriver/src/webdriver-interactor.ts b/packages/provider-webdriver/src/webdriver-interactor.ts index 1edd2a114b..7ee48accc5 100644 --- a/packages/provider-webdriver/src/webdriver-interactor.ts +++ b/packages/provider-webdriver/src/webdriver-interactor.ts @@ -333,6 +333,24 @@ class WebDriverInteractor implements Interactor { this.unsupported('settings'); } + // The four alert legs share one declared capability: a driver that cannot read a native alert + // cannot press its buttons either, and no provider in this family declares either half. + async readAlert(): Promise> { + this.unsupported('alert'); + } + + async awaitAlert(): Promise> { + this.unsupported('alert'); + } + + async acceptAlert(): Promise> { + this.unsupported('alert'); + } + + async dismissAlert(): Promise> { + this.unsupported('alert'); + } + /** * The cloud twin of the local Apple runner's focus -> readiness -> type * pipeline (RunnerTests+TextEntry.swift). A WebView input does not take first diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index a5c7feb96f..90c7c7e8e0 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -48,6 +48,7 @@ const contracts: WorkspacePackage = { const ALL = [kernel, contracts]; const CONTRACT_EXPORTS = [ '@agent-device/contracts/alert-contract', + '@agent-device/contracts/alert-runtime', '@agent-device/contracts/android-input-ownership', '@agent-device/contracts/android-snapshot-quality', '@agent-device/contracts/android-system-chrome', diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 68bbb0b254..f33ced2859 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -30,8 +30,9 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * leaves follow: back at R42, home at R43, orientation at R44, tv-remote at R45, and the * action-selected keyboard at R46. * The gesture cluster follows the touch leaves: gesture at R52, scroll at R53, swipe at R54. - * Wave 6 closure starts at R55 clipboard, then R56 app-switcher, R57 trigger-app-event, and - * R58 settings — the arm that retires the legacy dispatcher itself. + * Wave 6 closure starts at R55 clipboard, then R56 app-switcher, R57 trigger-app-event, + * R58 settings — the arm that retires the legacy dispatcher itself — R59 alert and + * R61 react-native. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -1195,8 +1196,85 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, extensions: [settingsRetiredDispatchProjectionProof], }, + { + rule: 'R59 alert-runtime-cutover', + command: 'alert', + subject: 'native alert handling', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // `alert` never had a dispatch-table arm: its daemon route called the Apple runner, the + // macOS helper and the Android alert module directly, and owned their poll and retry + // windows itself. Those four names are what R59 retires, and with the Apple closure gone + // the per-AppleOS capability table lost its last reader and went too. + routeNames: [ + 'handleNativeAlertCommand', + 'waitForNativeAlert', + 'handleNativeAlertAction', + 'supportsAlertSurface', + ], + modulePaths: ['src/platforms/apple/capabilities.ts'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy alert support or hint closure', + }, + runtimeTypeNames: ['AlertRuntimeOperations'], + operations: { names: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] }, + singularExecution: { + // Action-selected (R35's lesson): the snapshot route resolves exactly one of the four legs + // per request, binds once, and never two together. + routes: ['resolveBoundAlertRuntime'], + operations: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'], + operationOwners: { + readAlert: ['executeReadAlert'], + awaitAlert: ['executeAwaitAlert'], + acceptAlert: ['executeAcceptAlert'], + dismissAlert: ['executeDismissAlert'], + }, + }, + extensions: [alertRetiredDispatchProjectionProof], + }, + { + rule: 'R61 react-native-runtime-cutover', + command: 'react-native', + subject: 'React Native overlay dismissal', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The command's device work moved onto a bound `tapPoint` with R48; what R61 retires is the + // capability gate that still stood in front of it (proved by this table's own admission + // check) and the resolve-then-execute shape that gate implied. The dismissal function was + // renamed to `executeReactNativeOverlayDismiss` to record that: it no longer resolves + // anything, so the old name is genuinely absent rather than shadowed by a namesake. + routeNames: ['dismissReactNativeOverlayTarget'], + }, + runtimeTypeNames: ['BoundTouchRuntime'], + operations: { names: ['tapPoint'] }, + singularExecution: { + // Admission happens once, before the observing capture; the dismissal reuses that binding + // rather than admitting a second time after the overlay is known. + routes: ['executeReactNativeOverlayDismiss'], + operations: ['tapPoint'], + operationOwners: { tapPoint: ['createTapTouchExecutor'] }, + }, + extensions: [reactNativeRetiredDispatchProjectionProof], + }, ]; +function reactNativeRetiredDispatchProjectionProof( + sources: ReadonlyMap, +): UnruledViolation[] { + return retiredDispatchProjectionViolations(sources, 'react-native'); +} + +function alertRetiredDispatchProjectionProof( + sources: ReadonlyMap, +): UnruledViolation[] { + return retiredDispatchProjectionViolations(sources, 'alert'); +} + function settingsRetiredDispatchProjectionProof( sources: ReadonlyMap, ): UnruledViolation[] { diff --git a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts b/src/__tests__/contracts/apple-capability-closure-parity.test.ts similarity index 73% rename from src/__tests__/contracts/apple-os-capability-table-parity.test.ts rename to src/__tests__/contracts/apple-capability-closure-parity.test.ts index f9462ced7c..5482c32c13 100644 --- a/src/__tests__/contracts/apple-os-capability-table-parity.test.ts +++ b/src/__tests__/contracts/apple-capability-closure-parity.test.ts @@ -2,11 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/audio-probe-support'; import { - isMacOs, resolveDeviceAppleOs, DEVICE_TARGETS, PLATFORMS, - type AppleOS, type DeviceInfo, type DeviceKind, type DeviceTarget, @@ -23,19 +21,17 @@ import { VISIONOS_SIMULATOR, WEB_DESKTOP_DEVICE, } from '../test-utils/device-fixtures.ts'; -import { APPLE_OS_CAPABILITIES } from '../../platforms/apple/capabilities.ts'; import { getPlugin } from '../../core/platform-plugin-registry.ts'; import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; -// Phase 3 step d.5 table-equivalence gate. The AppleOS-axis predicates -// (`target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice`) that used to be -// open-coded in the Apple capability closures now READ the per-`AppleOS` data table -// (`apple-os-capabilities.ts`). This test pins that the swap is byte-for-byte -// behaviorless: the closures now living on the Apple plugin return an identical -// boolean / identical hint STRING to an INDEPENDENT verbatim copy of the ORIGINAL -// predicates plus intentional backend-specific gates, across the full -// {command x sample-device} matrix — real discovery shapes -// for iOS/iPadOS/tvOS/macOS/visionOS plus the exhaustive synthetic cross-product. +// The equivalence gate for whatever Apple capability closures still exist. It began as the +// ADR-0009 step d.5 table-equivalence test, pinning closures that had been rewritten to read a +// per-`AppleOS` data table; R59 retired that table with its last reader (`alert`), so what +// remains are the two closures that were never table-driven. The shape of the check is unchanged: +// each closure on the Apple plugin must return an identical boolean / identical hint STRING to an +// INDEPENDENT verbatim copy of its contract, across the full {command x sample-device} matrix — +// real discovery shapes for iOS/iPadOS/tvOS/macOS/visionOS plus the exhaustive synthetic +// cross-product. registerBuiltinPlatformPlugins(); @@ -44,11 +40,6 @@ registerBuiltinPlatformPlugins(); // AppleOS predicates and current backend-specific gates. This oracle stays independent // of the table it pins (mirrors capability-plugin-routing-parity.test.ts). // --------------------------------------------------------------------------- -const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => - isMacOs(device) || device.kind === 'simulator'; -const isIosOs = (device: DeviceInfo): boolean => - device.platform === 'apple' && - (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.platform !== 'apple' || device.kind !== 'device' || @@ -57,18 +48,16 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef supportsCoreDevicePhysicalOperation(device) ? undefined : 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.'; -// `home`/`keyboard`/`orientation`/`tv-remote` are gone from this table (R42/R43/R44/R45/R46 -// retired their AppleOS-table-reading closures along with their descriptor capability buckets), -// and `clipboard` left with R55; their per-AppleOS admission now lives as owner facts in -// `packages/platform-apple/src/runtime.ts` and its `system/`, `navigation/` siblings. +// `home`/`keyboard`/`orientation`/`tv-remote` left with R42-R46, `clipboard` with R55, +// `app-switcher` with R56, `settings` with R58 and `alert` with R59 — each cutover retiring its +// AppleOS-table-reading closure along with its descriptor capability bucket. `alert` was the +// table's last reader, so the table went with it; per-AppleOS admission now lives as owner facts +// in `packages/platform-apple/src/runtime.ts` and its `system/`, `navigation/` siblings. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - alert: (device) => - device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), - // `audio` is NOT part of the AppleOS-table relocation — it stays the standalone - // `isAudioProbeSupportedDevice` predicate. Included here only so the key-set - // assertion stays strict (catches a dropped command) and confirms the rebase - // did not alter it. + // `audio` was never part of the AppleOS-table relocation — it is the standalone + // `isAudioProbeSupportedDevice` predicate. Included here so the key-set assertion stays strict + // (it catches a dropped command) and confirms no rebase altered it. audio: isAudioProbeSupportedDevice, }; const HINT_REF: Record string | undefined> = { @@ -128,15 +117,6 @@ const SAMPLE_DEVICES: DeviceInfo[] = [ ...buildSyntheticMatrix(), ]; -test('the per-AppleOS capability table row keys are exhaustive', () => { - const rows: AppleOS[] = ['ios', 'ipados', 'tvos', 'watchos', 'visionos', 'macos']; - for (const os of rows) { - assert.ok(APPLE_OS_CAPABILITIES[os], `capability row present for ${os}`); - } - // iOS/iPadOS share the same platform capability profile. - assert.equal(APPLE_OS_CAPABILITIES.ios, APPLE_OS_CAPABILITIES.ipados); -}); - test('resolveDeviceAppleOs prefers the stored discriminant, else infers from target', () => { // Stored `appleOs` wins. assert.equal(resolveDeviceAppleOs(IPADOS_SIMULATOR), 'ipados'); diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index 388508f4b8..b29a81dc08 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -144,7 +144,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/facades/divergence.ts': 3, 'packages/contracts/src/facades/interaction.ts': 25, 'packages/contracts/src/facades/observability.ts': 7, - 'packages/contracts/src/facades/platform.ts': 55, + 'packages/contracts/src/facades/platform.ts': 56, 'packages/contracts/src/facades/progress.ts': 1, 'packages/contracts/src/facades/recording.ts': 3, 'packages/contracts/src/facades/remote.ts': 2, @@ -168,7 +168,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/contracts/src/platform-module.ts': 5, 'packages/contracts/src/platform-runtime-host.ts': 1, 'packages/contracts/src/platform-runtime-operations.ts': 2, - 'packages/contracts/src/platform-runtime-unavailable.ts': 27, + 'packages/contracts/src/platform-runtime-unavailable.ts': 28, 'packages/contracts/src/platform-runtime.ts': 6, 'packages/contracts/src/record-runtime-cutover.ts': 7, 'packages/contracts/src/react-native-overlay.ts': 1, @@ -243,17 +243,19 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // Added by #1993 (device-inventory context moved out of core). 'packages/contracts/src/back-runtime.ts': 4, - // Added by Wave 6 R55/R56/R57/R58: the clipboard, app-switcher, app-event and settings facets, + // Added by Wave 6 R55/R56/R57/R58/R59: the clipboard, app-switcher, app-event, settings and + // alert facets, // plus the local interaction set Android and Linux used to hold a byte-identical copy of each. // The set is its own module rather than part of the interactor catalog so the catalog's closure // stays leaf-thin -- every module the set pulls in is one its two consumers already evaluate. + 'packages/contracts/src/alert-runtime.ts': 4, 'packages/contracts/src/app-event-runtime.ts': 4, 'packages/contracts/src/app-switcher-runtime.ts': 4, 'packages/contracts/src/clipboard-runtime.ts': 4, 'packages/contracts/src/settings-runtime.ts': 4, - 'packages/contracts/src/local-interactor-operation-set.ts': 25, + 'packages/contracts/src/local-interactor-operation-set.ts': 26, 'packages/contracts/src/home-runtime.ts': 4, - 'packages/contracts/src/interactor-operation-catalog.ts': 13, + 'packages/contracts/src/interactor-operation-catalog.ts': 14, 'packages/contracts/src/keyboard-runtime.ts': 4, 'packages/contracts/src/orientation-runtime.ts': 4, 'packages/contracts/src/tv-remote-runtime.ts': 4, @@ -284,12 +286,12 @@ export const HUB_BUDGETS: Readonly> = Object.freeze({ // the same PR added and only a source checkout reaches, and which therefore loads on demand // (`resolveLocalDaemonCodeSignature`) rather than appearing here. 'src/cli.ts': 365, - 'src/platform-runtime.ts': 43, - 'src/core/dispatch.ts': 80, - 'src/core/capabilities.ts': 75, + 'src/platform-runtime.ts': 44, + 'src/core/dispatch.ts': 79, + 'src/core/capabilities.ts': 74, 'src/core/command-descriptor/registry.ts': 66, 'src/core/command-descriptor/platform-execution-entry.ts': 3, - 'src/core/interactors/register-builtins.ts': 73, + 'src/core/interactors/register-builtins.ts': 72, 'src/daemon/session-teardown.ts': 90, }); diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index f9f31aaaaf..7c2a1d0a7b 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -67,6 +67,10 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, }); /** Default facts for tests that are unrelated to application lifecycle commands. */ diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index cc686ebecb..7c074447b1 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -24,14 +24,6 @@ const xctestIosDevice: DeviceInfo = { iosPhysicalDeviceBackend: 'xctest', }; -const iPadOsDevice: DeviceInfo = { - platform: 'apple', - appleOs: 'ipados', - id: 'ipad-dev-1', - name: 'iPad', - kind: 'device', -}; - const androidDevice: DeviceInfo = { platform: 'android', id: 'and-1', @@ -93,16 +85,6 @@ function assertCommandSupport(commands: string[], checks: SupportCheck[]): void test('device capability matrix stays consistent across shared command groups', () => { const scenarios: Array<{ commands: string[]; checks: SupportCheck[] }> = [ - { - commands: ['alert'], - checks: [ - { device: iosSimulator, expected: true, label: 'on iOS sim' }, - { device: iosDevice, expected: true, label: 'on iOS device' }, - { device: iPadOsDevice, expected: false, label: 'on iPadOS device' }, - { device: androidDevice, expected: true, label: 'on Android' }, - { device: macOsDevice, expected: true, label: 'on macOS' }, - ], - }, { commands: ['gesture', 'swipe'], checks: [ @@ -190,7 +172,6 @@ test('capabilities reject CoreDevice-only commands for XCTest-backed devices', ( test('macOS supports the Apple runner interaction core but excludes mobile-only commands', () => { assertCommandSupport( [ - 'alert', 'back', 'click', 'fill', @@ -241,10 +222,6 @@ test('tvOS follows iOS capability matrix by device kind', () => { ], [{ device: tvOsSimulator, expected: true, label: 'on tvOS' }], ); - assertCommandSupport( - ['alert'], - [{ device: tvOsSimulator, expected: true, label: 'on tvOS simulator' }], - ); }); test('Linux supports desktop interaction commands and blocks mobile/unsupported ones', () => { @@ -252,6 +229,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported // session-capabilities.test.ts, not through this legacy matrix projection. assertCommandSupport( [ + 'alert', 'back', 'click', 'clipboard', @@ -273,10 +251,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported ], [{ device: linuxDevice, expected: true, label: 'on Linux' }], ); - assertCommandSupport( - ['alert', 'perf'], - [{ device: linuxDevice, expected: false, label: 'on Linux' }], - ); + assertCommandSupport(['perf'], [{ device: linuxDevice, expected: false, label: 'on Linux' }]); }); test('web supports only the initial browser interaction slice', () => { @@ -287,12 +262,13 @@ test('web supports only the initial browser interaction slice', () => { 'fill', 'focus', 'find', - // `clipboard` (R55), `gesture` and `swipe` (R42/R44) join the migrated commands here for - // the same reason `focus`, `find`, `screenshot`, `scroll`, `snapshot`, `type` and `wait` - // already do: a command whose admission comes from exact owner facts carries no - // capability-matrix row, and a command with no row is not decided by this matrix at all. - // The web owner refuses every gesture tier and both clipboard halves — - // `platform-web/src/runtime.test.ts` is where those cells are pinned. + // `clipboard` (R55), `gesture` and `swipe` (R42/R44), and `alert` (R59) join the migrated + // commands here for the same reason `focus`, `find`, `screenshot`, `scroll`, `snapshot`, + // `type` and `wait` already do: a command whose admission comes from exact owner facts + // carries no capability-matrix row, and a command with no row is not decided by this matrix + // at all. The web owner refuses every gesture tier, both clipboard halves and all four + // alert legs — `platform-web/src/runtime.test.ts` is where those cells are pinned. + 'alert', 'app-switcher', 'clipboard', 'gesture', @@ -311,10 +287,7 @@ test('web supports only the initial browser interaction slice', () => { ], [{ device: webDevice, expected: true, label: 'on web' }], ); - assertCommandSupport( - ['alert', 'perf'], - [{ device: webDevice, expected: false, label: 'on web' }], - ); + assertCommandSupport(['perf'], [{ device: webDevice, expected: false, label: 'on web' }]); assertCommandSupport( ['longpress'], [{ device: webDevice, expected: true, label: 'through runtime admission on web' }], diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index d9ddcf6547..39047d0aa6 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -100,11 +100,6 @@ const SAMPLE_DEVICES: DeviceInfo[] = [ // (b.2) Independent copies of the per-command supports()/unsupportedHint() // contracts. Kept in sync by hand so this oracle stays independent of production. // --------------------------------------------------------------------------- -const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => - isMacOs(device) || device.kind === 'simulator'; -const isIosOs = (device: DeviceInfo): boolean => - device.platform === 'apple' && - (device.appleOs ? device.appleOs === 'ios' : device.target !== 'tv'); const supportsHostAudioProbe = (device: DeviceInfo): boolean => device.platform === 'web' || (process.platform === 'darwin' && @@ -124,8 +119,8 @@ const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undef // gains/loses a closure (or whose closure body changes) breaks parity. const SUPPORTS_REF: Record boolean> = { perf: supportsCoreDevicePhysicalOperation, - alert: (device) => - device.platform === 'android' || isIosOs(device) || isMacOsOrAppleSimulator(device), + // `alert`'s closure left with R59, whose cutover made the owner's own alert facts the whole + // admission; the per-leaf verdicts it encoded are pinned in `platform-apple/src/system/`. audio: supportsHostAudioProbe, }; const HINT_REF: Record string | undefined> = { diff --git a/src/daemon/__tests__/snapshot-state.test.ts b/src/core/__tests__/snapshot-state.test.ts similarity index 100% rename from src/daemon/__tests__/snapshot-state.test.ts rename to src/core/__tests__/snapshot-state.test.ts diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index acdd389ee9..057b6642c3 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -110,6 +110,30 @@ export function unsupportedHintForDevice(command: string, device: DeviceInfo): s return tryGetPlugin(device.platform)?.capability.unsupportedHintByDefault?.[command]?.(device); } +/** + * The operation sets a command's declared runtime uses require, or `undefined` for a command + * whose admission is not fact-owned (it still carries a capability bucket, or it reaches no + * device at all). + * + * R63 derives this from the descriptors themselves rather than a hand-written map, which is what + * makes the capability projection self-maintaining: a unit that migrates a command declares its + * uses in one place, and the projection follows in the same commit. The alternative — a second + * list — is exactly how the projection drifted into reading "no bucket" as "supported + * everywhere" for every command Wave 4-6 migrated. + * + * An action-selected command answers with several sets. A request names exactly one action, so + * the command is available when ANY set is fully admitted. + */ +export function commandRuntimeUseRequirements( + command: string, +): readonly (readonly string[])[] | undefined { + const descriptor = commandDescriptors.find((candidate) => candidate.name === command); + const execution = descriptor?.platformExecution; + if (execution?.kind !== 'device-runtime') return undefined; + const uses = 'uses' in execution ? execution.uses : [execution.use]; + return uses.map((use) => use.required); +} + export function listCapabilityCommands(): string[] { return commandDescriptors .filter( diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index c3137bc87e..0d1e0a2625 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -45,6 +45,7 @@ const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSou // pure control-plane or always-admitted commands; migrated runtime commands are admitted from // exact runtime facts and therefore belong to the capability catalog without matrix rows. const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ + PUBLIC_COMMANDS.alert, PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.apps, PUBLIC_COMMANDS.appSwitcher, @@ -74,6 +75,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.orientation, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.push, + PUBLIC_COMMANDS.reactNative, PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.reinstall, PUBLIC_COMMANDS.replay, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 8bfebae407..c5064374fe 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -40,7 +40,9 @@ import { hoverRuntimeUses, appEventRuntimeUse, settingsRuntimeUse, + alertRuntimePlanUses, appSwitcherRuntimeUse, + tapPointUse, clipboardRuntimePlanUses, keyboardRuntimePlanUses, longPressRuntimeUses, @@ -533,7 +535,12 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // R63: `capabilities` executes nothing on a device. It reads one side-effect-free facts + // inspection and projects each command's own declared uses against it, so it has no platform + // execution path of its own to migrate — which is what `none` states. It is deliberately last + // among the command units: it projects the union of every migrated command's facts, so it is + // only truthful once that surface is complete. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'doctor', @@ -650,7 +657,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // Wave 6 residue: `events` flushes and reads the session's own event log. It touches no + // device at all, so it has no platform execution path to migrate. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'network', @@ -902,7 +911,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ daemon: { route: 'session', refFrameEffect: 'delegated' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // Wave 6 residue: every step runs as its own daemon request under its own descriptor, which + // is what `refFrameEffect: 'delegated'` already says. `batch` itself reaches no device. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'close', @@ -992,17 +1003,16 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/alert.ts'] as const } : {}), catalog: { group: 'public' }, frameworkTier: 'core', + // R59 retires this command's capability bucket and its Apple `supportsAlertSurface` closure + // together: admission is the owner's own alert facts, and the only execution is the one bound + // leg the parsed subcommand names. The poll and retry windows moved to the owners with it — + // how long a transient sheet takes to appear is family mechanics, not request policy. recordsSessionAction: true, recordingEffect: alertRecordingEffect, daemon: { route: 'snapshot', refFrameEffect: alertRefFrameEffect }, - capability: { - apple: APPLE_SIM_AND_DEVICE, - android: ANDROID_ALL, - linux: LINUX_NONE, - }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: alertRuntimePlanUses }, }, { name: 'settings', @@ -1029,13 +1039,15 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/react-native/index.ts'] as const } : {}), catalog: { group: 'public', key: 'reactNative' }, frameworkTier: 'extended', + // R61 retires this command's capability bucket: admission is the owner's own `tapPoint` fact, + // which is the one device operation the command executes. The overlay analysis and its + // verification capture are daemon policy over an already-migrated snapshot route. recordsSessionAction: true, recordingEffect: 'mutates-app', daemon: { route: 'reactNative', refFrameEffect: 'may-invalidate' }, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: [tapPointUse] }, }, { name: 'record', @@ -1400,7 +1412,10 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordsSessionAction: false, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, - platformExecution: LEGACY_PLATFORM_EXECUTION, + // Wave 6 residue: this route reads local diagnostics files and has no daemon route, no + // platform import, and no injected dispatch — it was `legacy` only because the discriminator + // pass had nothing better to say about it. + platformExecution: NO_PLATFORM_EXECUTION, }, { name: 'daemon', diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 339d679817..802ba76e5f 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -39,6 +39,8 @@ import { withMethodScope } from '../../utils/method-scope.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; import { androidSnapshotPublicationInput } from '../../platforms/android/snapshot-capture.ts'; +import { handleAndroidAlert } from '../../platforms/android/alert.ts'; +import { buildSnapshotState } from '../snapshot-state.ts'; /** * `appBundleId` is present exactly for app-backed daemon sessions, whose teardown releases the @@ -55,6 +57,13 @@ export function createAndroidInteractor( runnerContext?: Pick, ): Interactor { const helperSessionScope = androidHelperSessionScope(runnerContext?.appBundleId); + const alertOptions = (timeoutMs?: number) => ({ + ...(timeoutMs === undefined ? {} : { timeoutMs }), + captureNodes: async () => { + const capture = await snapshotAndroid(device, { includeHiddenContentHints: false }); + return buildSnapshotState(androidSnapshotPublicationInput(capture), undefined).nodes; + }, + }); const interactor: Interactor = { open: (app, options) => openAndroidApp(device, app, { @@ -122,6 +131,15 @@ export function createAndroidInteractor( writeClipboard: (text) => writeAndroidClipboardText(device, text), setSetting: (setting, state, appId, options) => setAndroidSetting(device, setting, state, appId, options), + // R59: Android's alert legs read the same presented accessibility tree `snapshot` publishes + // and own their own polling, so the family supplies the node capture rather than the daemon. + // The presentation pass matters: alert candidacy skips occlusion-blocked nodes, and only a + // presented tree carries that annotation. + readAlert: async () => await handleAndroidAlert(device, 'get', alertOptions()), + awaitAlert: async (options) => + await handleAndroidAlert(device, 'wait', alertOptions(options?.timeoutMs)), + acceptAlert: async () => await handleAndroidAlert(device, 'accept', alertOptions()), + dismissAlert: async () => await handleAndroidAlert(device, 'dismiss', alertOptions()), }; if (!provider) return interactor; return withMethodScope(interactor, (task) => diff --git a/src/core/interactors/harmonyos.ts b/src/core/interactors/harmonyos.ts index 8166d77056..2c7cca53f9 100644 --- a/src/core/interactors/harmonyos.ts +++ b/src/core/interactors/harmonyos.ts @@ -63,5 +63,11 @@ export function createHarmonyInteractor(device: DeviceInfo, _runner?: RunnerCont readClipboard: unsupported('clipboard'), writeClipboard: unsupported('clipboard'), setSetting: (setting, state, appId) => setHarmonySetting(device, setting, state, appId), + // R59: the retired `alert` descriptor declared no HarmonyOS leaf, and hdc exposes no dialog + // surface to read one from. + readAlert: unsupported('alert'), + awaitAlert: unsupported('alert'), + acceptAlert: unsupported('alert'), + dismissAlert: unsupported('alert'), }; } diff --git a/src/core/interactors/linux.ts b/src/core/interactors/linux.ts index 7b4ba69b47..7c8cf90f33 100644 --- a/src/core/interactors/linux.ts +++ b/src/core/interactors/linux.ts @@ -24,6 +24,10 @@ import { screenshotLinux } from '../../platforms/linux/screenshot.ts'; import { captureLinuxSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; import type { Interactor } from '@agent-device/contracts/interaction'; +function unsupportedLinuxAlert(): Promise { + throw new AppError('UNSUPPORTED_OPERATION', 'alert not supported on Linux'); +} + export function createLinuxInteractor(): Interactor { return { open: (app) => openLinuxApp(app), @@ -80,5 +84,10 @@ export function createLinuxInteractor(): Interactor { setSetting: () => { throw new AppError('UNSUPPORTED_OPERATION', 'setSetting not supported on Linux'); }, + // R59: the retired `alert` descriptor declared `linux: {}`, so no Linux cell was admitted. + readAlert: unsupportedLinuxAlert, + awaitAlert: unsupportedLinuxAlert, + acceptAlert: unsupportedLinuxAlert, + dismissAlert: unsupportedLinuxAlert, }; } diff --git a/src/daemon/snapshot-state.ts b/src/core/snapshot-state.ts similarity index 100% rename from src/daemon/snapshot-state.ts rename to src/core/snapshot-state.ts diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index efd647835d..9619583d96 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -7,7 +7,7 @@ import { activateCompleteRefFrame } from '../ref-frame.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; // #1638 `--settle` on the GENERIC daemon route (scroll/back): the settled diff, // its refs, and the ref-frame/generation dance are the same contract the touch @@ -15,13 +15,6 @@ import { buildSnapshotState } from '../snapshot-state.ts'; // a resolution, and the observation must run after the deferred-outcome // markers. Quiet windows are tuned down so no test waits real time. -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - vi.mock('../handlers/interaction-snapshot.ts', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index e93cd63f28..6ee6bb2503 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -20,6 +20,7 @@ import { import { unavailableBindDevice, unavailableBindExactDevice, + unavailableDeviceRuntimeGateway, unavailableInspectFacts, } from './test-device-runtime-gateway.ts'; import { createScreenRecordingAdmissionLedger } from '../screen-recording-admission-ledger.ts'; @@ -100,6 +101,31 @@ test('request handler chain routes trace commands to the record-trace family', a assert.equal(response?.data?.trace, 'started'); }); +// R61 put `react-native dismiss-overlay` behind the owner's own `tapPoint` admission, and the +// chain had never forwarded the request's runtime bindings to that route — so the dismissal leg +// had been reaching a missing gateway ever since R48 moved it off the retired dispatcher. Only +// the no-overlay path returned early enough to hide it, which is why no suite caught it. +test('request handler chain forwards the request runtime bindings to react-native', async () => { + const inspected: string[] = []; + const params = makeChainParams(makeRequest('react-native', ['dismiss-overlay'])); + + const response = await runRequestHandlerChain({ + ...params, + inspectFacts: async (device) => { + inspected.push(device.id); + return await unavailableDeviceRuntimeGateway.inspectFacts(device); + }, + }); + + // The bindings reached the route, and the owner's refusal — not a missing gateway — is what + // came back. A chain that dropped them would answer `runtime-gateway-missing` instead. + assert.equal(inspected.length, 1); + assert.equal(response?.ok, false); + if (response?.ok === false) { + assert.match(response.error.message, /react-native dismiss-overlay is not supported/); + } +}); + test('request handler chain leaves generic commands for fallback dispatch', async () => { for (const command of ['back', 'home', 'screenshot', 'scroll']) { const response = await runRequestHandlerChain(makeChainParams(makeRequest(command))); diff --git a/src/daemon/__tests__/snapshot-presentation-transitions.test.ts b/src/daemon/__tests__/snapshot-presentation-transitions.test.ts index a4dd4f13ae..7926fb6955 100644 --- a/src/daemon/__tests__/snapshot-presentation-transitions.test.ts +++ b/src/daemon/__tests__/snapshot-presentation-transitions.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts'; import { createInteractionDevice } from '../../commands/interaction/runtime/__tests__/test-utils/index.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; import { presentIosInteractiveSnapshot } from '../../snapshot/snapshot-presentation/ios/index.ts'; import { navigationTitleWithAppProvidedDetailsAffordanceNodes } from '../../snapshot/snapshot-presentation/ios/transitions.fixtures.ts'; diff --git a/src/daemon/__tests__/snapshot-publication-membership.test.ts b/src/daemon/__tests__/snapshot-publication-membership.test.ts index 5cedf996d5..7cc1232f5d 100644 --- a/src/daemon/__tests__/snapshot-publication-membership.test.ts +++ b/src/daemon/__tests__/snapshot-publication-membership.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; // End-to-end publication-membership contract for the acquire/present design (#1797, external // review pass 4 finding 1), exercised through the production interface. Runner presentation owns diff --git a/src/daemon/__tests__/snapshot-runtime-fixture.ts b/src/daemon/__tests__/snapshot-runtime-fixture.ts index 12a8d2ddea..a68b1b31e1 100644 --- a/src/daemon/__tests__/snapshot-runtime-fixture.ts +++ b/src/daemon/__tests__/snapshot-runtime-fixture.ts @@ -6,6 +6,10 @@ import { } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; +import { + type AlertRuntimeInput, + alertRuntimeOperationFacts, +} from '@agent-device/contracts/alert-runtime'; import { type SetSettingInput, settingsRuntimeOperationFacts, @@ -19,7 +23,14 @@ import { type SnapshotResult, snapshotRuntimeOperationFacts, } from '@agent-device/contracts/snapshot-runtime'; -import { deviceShape, isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { + deviceShape, + isApplePlatform, + isIosFamily, + isMacOs, + type DeviceInfo, +} from '@agent-device/kernel/device'; +import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from '../../platforms/apple/alert.ts'; import { type DispatchContext } from '../../core/dispatch.ts'; import { getRequestSignal } from '../../request/cancel.ts'; import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; @@ -68,6 +79,15 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ fixtureSettingsMutations.push(input); return {}; }; + // R59: the alert legs delegate to the Apple owner's own module, so the poll and retry windows + // these suites exercise are the shipped ones rather than a fixture's imitation of them. The + // runner underneath is the suite's own mock. + const runnerOptions = { signal: requestSignal }; + const alertOptions = (input: AlertRuntimeInput) => ({ + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + ...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }), + ...(input.surface === undefined ? {} : { surface: input.surface }), + }); return narrowDeviceBinding( { device, @@ -81,6 +101,18 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ captureSnapshotWithoutActiveApp: captureSnapshot, captureScreenshot, setSetting, + ...(isApplePlatform(device.platform) + ? { + readAlert: async (input: AlertRuntimeInput) => + await readAppleAlert(device, runnerOptions, alertOptions(input)), + awaitAlert: async (input: AlertRuntimeInput) => + await awaitAppleAlert(device, runnerOptions, alertOptions(input)), + acceptAlert: async (input: AlertRuntimeInput) => + await actOnAppleAlert(device, runnerOptions, 'accept', alertOptions(input)), + dismissAlert: async (input: AlertRuntimeInput) => + await actOnAppleAlert(device, runnerOptions, 'dismiss', alertOptions(input)), + } + : {}), }, [Symbol.asyncDispose]: async () => {}, }, @@ -102,6 +134,16 @@ const settingsUnavailable = { hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', } as const; +const alertUnavailable = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'This fixture models the Apple alert legs only.', +} as const; + +function alertCell(device: DeviceInfo) { + return isApplePlatform(device.platform) ? ({ available: true } as const) : alertUnavailable; +} + async function snapshotFacts(device: DeviceInfo): Promise> { const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); const providerOwned = isActiveProviderDevice(device); @@ -137,6 +179,15 @@ async function snapshotFacts(device: DeviceInfo): Promise { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); beforeEach(() => { diff --git a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts index f15bbc169c..c07fa650d6 100644 --- a/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/handlers/__tests__/interaction-get-runtime-fixture.ts @@ -175,6 +175,10 @@ function elementReadFacts(device: DeviceInfo): RuntimeFacts { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { diff --git a/src/daemon/handlers/__tests__/interaction-settle.test.ts b/src/daemon/handlers/__tests__/interaction-settle.test.ts index 80b2ded1c7..d547a9d70e 100644 --- a/src/daemon/handlers/__tests__/interaction-settle.test.ts +++ b/src/daemon/handlers/__tests__/interaction-settle.test.ts @@ -5,7 +5,7 @@ import { handleInteractionCommands } from '../interaction.ts'; import type { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; -import { buildSnapshotState } from '../../snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { setSessionSnapshot } from '../../session-snapshot.ts'; import { activateCompleteRefFrame } from '../../ref-frame.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; @@ -26,13 +26,6 @@ import { // Quiet windows are tuned down (--settle-quiet 25) so no test waits real time // beyond a few poll ticks. -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - vi.mock('../interaction-snapshot.ts', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts index ec35c1cd39..46fdc4f26b 100644 --- a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts +++ b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts @@ -26,13 +26,6 @@ const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; diff --git a/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts b/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts index 99f2204a1c..89cdb8c0aa 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts @@ -11,7 +11,7 @@ import type { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import { handleInteractionCommands } from '../interaction.ts'; import { getRuntimeBindings } from './interaction-get-runtime-fixture.ts'; -import { buildSnapshotState } from '../../snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; /** * Shared factories for the interaction touch handler tests. Named pure diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 46b733b90c..932082b723 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -10,7 +10,7 @@ import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts import { expireRefFrame } from '../../ref-frame.ts'; import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../session-snapshot.ts'; import { handleInteractionCommands } from '../interaction.ts'; -import { buildSnapshotState } from '../../snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { contextFromFlags, makeSession, @@ -26,13 +26,6 @@ const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), })); -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; diff --git a/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts b/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts index 6e864f0ec6..8aa3f2b3e8 100644 --- a/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts @@ -68,9 +68,10 @@ test('capabilities keeps legacy runtime commands outside the install-family fact expect(response).toMatchObject({ ok: true }); if (!response?.ok) return; - // `bootTarget` facts are unavailable in this fixture. A global descriptor projection - // would hide boot; only this unit's install family is authorized to consume facts. - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.boot); + // R63 made the projection global: it reads every migrated command's declared uses, so + // `bootTarget`/`bootTargetHeadless` being unavailable in this fixture now hides `boot` — where + // this test previously pinned the opposite, because only the install family consumed facts. + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.boot); expect(runtime.inspections).toHaveLength(1); expect(runtime.uses).toEqual(legacyCapabilityUses); }); @@ -94,7 +95,10 @@ test('capabilities fails closed only for install-family projection when facts in expect(response).toMatchObject({ ok: true }); if (!response?.ok) return; - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.boot); + // R63: with no facts at all, every fact-owned command fails closed rather than being + // advertised on faith. `logs` survives because its availability is a BIND-time fact, and the + // binding here still succeeds. + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.boot); expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.logs); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.install); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.reinstall); diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index 481d09d271..5ce84eca7a 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts @@ -106,6 +106,10 @@ function createAdmissionFacts( appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, ...touchRuntimeOperationFacts({ tap: unavailable, longPress: unavailable, diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index e30e105e0e..e7f69b3c95 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -28,15 +28,21 @@ import type { } from '../../request-runtime-binding.ts'; import { handleSessionCommands } from './session-command-harness.ts'; +/** The system leaves this owner refuses: the retired fallback listed them unconditionally. */ +const ANDROID_REFUSED_SYSTEM_COMMANDS = ['clipboard', 'alert', 'settings', 'app-switcher']; + function assertAndroidCapabilityHonesty(availableCommands: unknown): void { + for (const command of ANDROID_REFUSED_SYSTEM_COMMANDS) { + expect(availableCommands).not.toContain(command); + } expect(availableCommands).toContain(PUBLIC_COMMANDS.open); expect(availableCommands).toContain(PUBLIC_COMMANDS.close); expect(availableCommands).not.toContain(PUBLIC_COMMANDS.prepare); expect(availableCommands).not.toContain(PUBLIC_COMMANDS.viewport); } -test('capabilities reports supported commands for the selected session device', async () => { - const sessionName = 'android-capabilities'; +/** The one interaction-capable Android owner both projection tests below read. */ +async function projectAndroidCapabilities(sessionName: string) { const sessionStore = makeSessionStore('agent-device-capabilities-'); sessionStore.set(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ @@ -44,9 +50,9 @@ test('capabilities reports supported commands for the selected session device', ensureReadyAvailable: true, networkAvailable: true, appsAvailable: true, + interactionAvailable: true, providerMode: 'local', }); - const response = await handleSessionCommands({ req: { token: 't', @@ -62,15 +68,21 @@ test('capabilities reports supported commands for the selected session device', bindDevice: runtime.bindDevice, invoke: async () => ({ ok: true, data: {} }), }); - expect(response?.ok).toBe(true); - if (!response?.ok) return; + const data = response?.ok ? response.data : undefined; + return { runtime, device: data?.device, availableCommands: data?.availableCommands }; +} - expect(response.data?.device).toMatchObject({ - platform: 'android', - kind: 'emulator', - }); - expect(response.data?.availableCommands).toEqual( +test('capabilities reports supported commands for the selected session device', async () => { + const { runtime, device, availableCommands } = + await projectAndroidCapabilities('android-capabilities'); + + expect(device).toMatchObject({ platform: 'android', kind: 'emulator' }); + // R63: every one of these comes from a declared use this owner's facts admit — the + // `interactionAvailable` cells are what put `snapshot`, `press`, `fill` and `gesture` in the + // list, and dropping them drops the commands (proved by the stopped-AVD case below). + // `react-native` rides the same admitted `tapPoint`, so it is listed here too. + expect(availableCommands).toEqual( expect.arrayContaining([ 'open', 'screenshot', @@ -83,11 +95,9 @@ test('capabilities reports supported commands for the selected session device', 'perf', PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.reactNative, ]), ); - expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); - expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.devices); - assertAndroidCapabilityHonesty(response.data?.availableCommands); expect(runtime.inspections).toHaveLength(1); expect(runtime.uses).toEqual([ { required: [], preferred: ['appLogInspect'] }, @@ -96,6 +106,14 @@ test('capabilities reports supported commands for the selected session device', ]); }); +test('capabilities omits the commands this Android owner does not admit', async () => { + const { availableCommands } = await projectAndroidCapabilities('android-capabilities-honesty'); + + expect(availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); + expect(availableCommands).not.toContain(PUBLIC_COMMANDS.devices); + assertAndroidCapabilityHonesty(availableCommands); +}); + test('capabilities excludes logs from an unavailable provider-mode XCTest runtime fact', async () => { const sessionName = 'provider-xctest-capabilities'; const sessionStore = makeSessionStore('agent-device-capabilities-provider-xctest-'); @@ -488,21 +506,20 @@ test('capabilities accepts a stopped Android AVD placeholder for explicit platfo kind: 'emulator', booted: false, }); + // R63: the projection reads each migrated command's declared uses, so a STOPPED AVD no longer + // advertises the interaction commands it cannot run. `open`/`screenshot` stay because the + // Android owner admits them on a placeholder (it boots the AVD first); `snapshot`, `press` and + // `fill` need a live adb connection this device does not have. Before this unit the projection + // read "no capability bucket" as "supported everywhere" and listed all three. expect(response.data?.availableCommands).toEqual( - expect.arrayContaining(['open', 'screenshot', 'snapshot', 'press', 'fill']), + expect.arrayContaining(['open', 'screenshot', PUBLIC_COMMANDS.shutdown]), ); - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.shutdown); + for (const command of ['snapshot', 'press', 'fill']) { + expect(response.data?.availableCommands).not.toContain(command); + } }); -function createAdmissionRuntime(options: { - appLogAvailable: boolean; - appStateAvailable?: boolean; - ensureReadyAvailable?: boolean; - networkAvailable: boolean; - appsAvailable?: boolean; - screenshotAvailable?: boolean; - providerMode: RuntimeProviderMode; -}) { +function createAdmissionRuntime(options: AdmissionRuntimeOptions) { const uses: Array<{ required: readonly string[]; preferred: readonly string[]; @@ -532,6 +549,13 @@ type AdmissionRuntimeOptions = Readonly<{ appsAvailable?: boolean; /** `screenshot` is fact-owned since R39; the projection reads this cell, not a bucket. */ screenshotAvailable?: boolean; + /** + * R63 made the projection read every migrated command's declared uses, so a fixture that leaves + * the interaction cells unavailable now correctly answers that `snapshot`, `press`, `fill` and + * `gesture` are NOT available — where the retired fallback used to list all four unconditionally. + * Opt in to model an owner that can actually drive them. + */ + interactionAvailable?: boolean; providerMode: RuntimeProviderMode; }>; @@ -592,11 +616,16 @@ function createAdmissionOperationFacts( appsFact: ReturnType, lifecycleAvailable: boolean, ) { + const interaction = options.interactionAvailable ? ({ available: true } as const) : unavailable; return { ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...screenshotRuntimeOperationFacts({ capture: options.screenshotAvailable === false ? unavailable : { available: true as const }, }), + captureSnapshot: interaction, + tapPoint: interaction, + fillPoint: interaction, + performGesturePlan: interaction, appLogInspect: options.appLogAvailable ? { available: true as const } : unavailable, appLogDoctor: unavailable, appLogStart: unavailable, diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index 7118c4038b..a3de048cb1 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -180,6 +180,10 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts { appSwitcher: { available: false, reason: 'owner-capability-missing' }, triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, setSetting: { available: false, reason: 'owner-capability-missing' }, + readAlert: { available: false, reason: 'owner-capability-missing' }, + awaitAlert: { available: false, reason: 'owner-capability-missing' }, + acceptAlert: { available: false, reason: 'owner-capability-missing' }, + dismissAlert: { available: false, reason: 'owner-capability-missing' }, readiness: { available: false, reason: 'unsupported-device-kind' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'owner-capability-missing' }, @@ -169,6 +173,10 @@ test('appstate rejects web before Android app-state backend dispatch', async () appSwitcher: { available: false, reason: 'unsupported-platform-leaf' }, triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, setSetting: { available: false, reason: 'unsupported-platform-leaf' }, + readAlert: { available: false, reason: 'unsupported-platform-leaf' }, + awaitAlert: { available: false, reason: 'unsupported-platform-leaf' }, + acceptAlert: { available: false, reason: 'unsupported-platform-leaf' }, + dismissAlert: { available: false, reason: 'unsupported-platform-leaf' }, readiness: { available: false, reason: 'unsupported-platform-leaf' }, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts b/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts index 1d4e8ba3d5..705e048d25 100644 --- a/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-alert-android-occlusion.test.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; vi.mock('../../../platforms/android/snapshot.ts', () => ({ snapshotAndroid: vi.fn() })); @@ -10,11 +9,15 @@ vi.mock('../../../platforms/android/input-actions.ts', async (importOriginal) => import { snapshotAndroid } from '../../../platforms/android/snapshot.ts'; import { pressAndroid } from '../../../platforms/android/input-actions.ts'; -import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { SessionStore } from '../../session-store.ts'; -import { handleAlertCommand } from '../snapshot-alert.ts'; +import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { createAndroidInteractor } from '../../../core/interactors/android.ts'; import { makeAndroidSnapshotCapture } from '../../../__tests__/test-utils/android-snapshot-capture.ts'; -import type { DaemonRequest } from '../../types.ts'; + +// R59 moved the alert legs onto the Android interactor, which is where the occlusion reading +// they depend on lives: the legs capture through the same presentation pass `snapshot` publishes, +// and only a presented tree annotates a candidate as covered. The daemon route above them now +// only admits and forwards, so this suite drives the owner directly. +const alertLegs = () => createAndroidInteractor(ANDROID_EMULATOR); afterEach(() => { vi.useRealTimers(); @@ -25,9 +28,9 @@ afterEach(() => { test('Android alert get does not choose an exactly covered candidate', async () => { vi.mocked(snapshotAndroid).mockResolvedValue(coveredAlertCapture() as never); - const response = await handleAlertCommand(alertParams('get')); + const result = await alertLegs().readAlert(); - expect(response).toMatchObject({ ok: true, data: { action: 'get', alert: null } }); + expect(result).toMatchObject({ action: 'get', alert: null }); expect(pressAndroid).not.toHaveBeenCalled(); }); @@ -35,11 +38,12 @@ test('Android alert accept does not tap an exactly covered candidate', async () vi.useFakeTimers(); vi.mocked(snapshotAndroid).mockResolvedValue(coveredAlertCapture() as never); - const response = handleAlertCommand(alertParams('accept')); - const outcome = response.then( - () => undefined, - (error: unknown) => error, - ); + const outcome = alertLegs() + .acceptAlert() + .then( + () => undefined, + (error: unknown) => error, + ); await vi.advanceTimersByTimeAsync(3_500); const error = await outcome; expect(error).toBeInstanceOf(Error); @@ -48,23 +52,6 @@ test('Android alert accept does not tap an exactly covered candidate', async () expect(pressAndroid).not.toHaveBeenCalled(); }); -function alertParams(action: 'get' | 'accept') { - const session = makeAndroidSession(`covered-alert-${action}`); - const sessionStore = new SessionStore(path.join('/tmp', `covered-alert-${action}`)); - return { - req: { - token: 'test-token', - session: session.name, - command: 'alert', - positionals: [action], - } satisfies DaemonRequest, - logPath: path.join('/tmp', `covered-alert-${action}.log`), - sessionStore, - session, - device: session.device, - }; -} - function coveredAlertCapture() { const nodes = [ { diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 7c84c31917..457678410b 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -72,12 +72,12 @@ const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); const mockCloseIosApp = vi.mocked(closeIosApp); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); -const SNAPSHOT_ROUTE_RUNTIME_COMMANDS = new Set(['snapshot', 'diff', 'settings']); +const SNAPSHOT_ROUTE_RUNTIME_COMMANDS = new Set(['snapshot', 'diff', 'settings', 'alert']); function handleSnapshotCommands( params: Parameters[0], ): ReturnType { - // R58 put `settings` on a bound operation too, so the snapshot-route fixture serves it as well. + // R58/R59 bound `settings` and `alert` too, so the fixture serves those commands as well. if (!SNAPSHOT_ROUTE_RUNTIME_COMMANDS.has(params.req.command)) { return handleProductionSnapshotCommands(params); } diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index 5c4d6f44f4..57ca82d23a 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, expect, test, vi } from 'vitest'; +import { beforeEach, expect, test } from 'vitest'; import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; import type { RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; @@ -7,13 +7,6 @@ import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts import { expireRefFrame } from '../../ref-frame.ts'; import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - const ANDROID_SCRIPT_ERROR = 'Unable to load script. Make sure you are running Metro.'; beforeEach(() => { diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 0f0b7753ad..ecc9f17f0a 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -1,4 +1,8 @@ -import { createBoundTouchExecutor, resolveBoundTouchRuntime } from '../touch-runtime.ts'; +import { + createBoundTouchExecutor, + resolveBoundTouchRuntime, + type BoundTouchRuntime, +} from '../touch-runtime.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { analyzeReactNativeOverlay, @@ -10,7 +14,7 @@ import { successText } from '../../utils/success-text.ts'; import type { SnapshotQualityVerdict, SnapshotState } from '@agent-device/kernel/snapshot'; import { isSparseSnapshotQualityVerdict } from '../../snapshot-quality/verdict.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; -import { errorResponse, noActiveSessionError, requireCommandSupported } from './response.ts'; +import { errorResponse, noActiveSessionError } from './response.ts'; import { captureSnapshotForSession } from './interaction-snapshot.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; import { expireRefFrame } from '../ref-frame.ts'; @@ -26,10 +30,24 @@ export async function handleReactNativeCommands( const session = sessionStore.get(sessionName); if (!session) return noActiveSessionError(); - const unsupported = requireCommandSupported(PUBLIC_COMMANDS.reactNative, session.device, { - message: 'react-native dismiss-overlay is not supported on this device', + // R61: admission is the owner's own `tapPoint` fact — the one operation this command executes. + // It runs before the observing capture, exactly where the retired capability gate ran, so an + // owner that cannot dismiss an overlay refuses without first spending a snapshot on it. + const bound = await resolveBoundTouchRuntime({ + device: session.device, + command: 'press', + requiresCapture: false, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, + unavailableResponse: (unavailable) => + errorResponse( + 'UNSUPPORTED_OPERATION', + 'react-native dismiss-overlay is not supported on this device', + undefined, + unavailable.hint ? { hint: unavailable.hint } : undefined, + ), }); - if (unsupported) return unsupported; + if (!bound.ok) return bound.response; try { const snapshot = await captureSnapshotForSession( @@ -47,7 +65,7 @@ export async function handleReactNativeCommands( if (!target) { return responseForMissingReactNativeOverlayTarget(overlay.detected); } - return await dismissReactNativeOverlayTarget(params, session, snapshot, target); + return await executeReactNativeOverlayDismiss(params, session, snapshot, target, bound.runtime); } catch (error) { return { ok: false, error: normalizeError(error) }; } @@ -99,26 +117,23 @@ function responseForSparseReactNativeOverlaySnapshot( ); } -async function dismissReactNativeOverlayTarget( +/** + * R61 renamed this from `dismissReactNativeOverlayTarget` to record the contract change: it no + * longer resolves anything, because admission moved up to the route entry. It receives an + * already-bound runtime and executes the one tap. + */ +async function executeReactNativeOverlayDismiss( params: InteractionHandlerParams, session: SessionState, snapshot: SnapshotState, target: ReactNativeOverlayDismissTarget, + runtime: BoundTouchRuntime, ): Promise { const { req, sessionStore } = params; - // The dismissal press rides the same bound `tapPoint` a user-typed `press` does (R48): admit - // and bind before anything mutates, so an owner that cannot tap refuses here rather than - // through a dispatcher that no longer exists. - const bound = await resolveBoundTouchRuntime({ - device: session.device, - command: 'press', - requiresCapture: false, - inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, - }); - if (!bound.ok) return bound.response; + // The dismissal press rides the same bound `tapPoint` a user-typed `press` does (R48). The + // binding is the caller's: admission happened before the overlay was even observed. const context = params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath); - const executor = createBoundTouchExecutor(bound.runtime, context); + const executor = createBoundTouchExecutor(runtime, context); const actionStartedAt = Date.now(); // ADR 0014 side-effect seam: React Native overlay dismissal taps the device; // the target is already resolved, so expire the frame before the press. diff --git a/src/daemon/handlers/response.ts b/src/daemon/handlers/response.ts index a4a80fbbdc..4cfdda7f3b 100644 --- a/src/daemon/handlers/response.ts +++ b/src/daemon/handlers/response.ts @@ -33,15 +33,16 @@ export function noActiveSessionError(): DaemonFailureResponse { } /** - * Capability guard: returns an `UNSUPPORTED_OPERATION` failure when `command` is not - * supported on `device`, otherwise `null`. Pass `message` to override the default - * " is not supported on this device" text, or `hint: true` to attach the - * device-specific unsupported hint (as generic command dispatch does). + * Capability guard: returns an `UNSUPPORTED_OPERATION` failure when `command` is not supported on + * `device`, otherwise `null`. Pass `hint: true` to attach the device-specific unsupported hint. + * + * The `message` override left with R61: `react-native` was its only caller, and a migrated command + * states its own refusal through the admission seam's `unavailableResponse` instead. */ export function requireCommandSupported( command: string, device: DeviceInfo, - options?: { message?: string; hint?: boolean }, + options?: { hint?: boolean }, ): DaemonFailureResponse | null { if (isCommandSupportedOnDevice(command, device)) return null; const hint = options?.hint ? unsupportedHintForDevice(command, device) : undefined; @@ -49,7 +50,7 @@ export function requireCommandSupported( ok: false, error: { code: 'UNSUPPORTED_OPERATION', - message: options?.message ?? `${command} is not supported on this device`, + message: `${command} is not supported on this device`, ...(hint ? { hint } : {}), }, }; diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 24ffd2b6bd..fd270f8c19 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -1,4 +1,8 @@ -import { isCommandSupportedOnDevice, listCapabilityCommands } from '../../core/capabilities.ts'; +import { + commandRuntimeUseRequirements, + isCommandSupportedOnDevice, + listCapabilityCommands, +} from '../../core/capabilities.ts'; import { listDeviceInventory } from '../../request/device-inventory-context.ts'; import { assertResolvedAppsFilter } from '@agent-device/contracts/device'; import { AppError, asAppError } from '@agent-device/kernel/errors'; @@ -270,32 +274,36 @@ function hasMacSessionSurface( } /** - * Every command whose availability is a runtime fact, and the exact operations it needs. A command - * absent from this record is not fact-owned, so its availability is decided elsewhere. + * The three durable-resource commands whose availability is a BIND-time fact, not an inspection + * one: their owner resolves an adopted resource while binding, so the inspected snapshot cannot + * answer for them. They are read through `bindDevice` above and skipped here. */ -const factOwnedCapabilityOperations: Readonly< - Record[] | undefined> -> = Object.freeze({ - apps: ['ensureReady', 'listApps'], - appstate: ['ensureReady', 'appState'], - shutdown: ['shutdownTarget'], - open: ['resolveOpenTarget', 'prepareApplicationOpen', 'openApplication'], - close: ['closeApplication', 'finalizeApplicationClose'], - prepare: ['prepareAppleRunner'], - runtime: ['clearRuntimeHints'], - screenshot: ['captureScreenshot'], - viewport: ['setViewport'], -}); +const BIND_ADMITTED_CAPABILITY_COMMANDS = new Set(['logs', 'network', 'record']); +/** + * Whether the exact device admits any of `command`'s declared runtime uses, or `undefined` when + * the command is not fact-owned at all. + * + * R63 reads the requirement straight off the descriptor (`commandRuntimeUseRequirements`), so a + * command cannot be migrated in one place and left projecting from a stale list in another — + * which is precisely how a real Vega VVD came to advertise `snapshot diff get is wait focus` it + * cannot run. + */ function factOwnedCapabilityAvailable( command: string, facts: RuntimeFacts | undefined, ): boolean | undefined { - const requiredOperations = factOwnedCapabilityOperations[command]; - if (!requiredOperations) return undefined; - return facts - ? requiredOperations.every((operation) => facts.operations[operation].available) - : false; + if (BIND_ADMITTED_CAPABILITY_COMMANDS.has(command)) return undefined; + const declaredUses = commandRuntimeUseRequirements(command); + if (!declaredUses) return undefined; + if (!facts) return false; + // A request names exactly one action, so one fully admitted use is enough. + return declaredUses.some((required) => + required.every( + (operation) => + facts.operations[operation as RuntimeOperationKey].available, + ), + ); } async function handleAppsInventory(params: { diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index faa32d649c..111d9686b1 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -15,6 +15,7 @@ import { resolveSessionAppBundleIdForTarget, } from '../../platform-runtime-open-target.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { DaemonCommandContext } from '../context.ts'; /** * What `runSessionOrSelectorDispatch`'s `prepare` thunk reports: either the early-exit response an @@ -120,116 +121,100 @@ function requireForegroundIosKeyboardSession( ); } -/** - * `keyboard`'s migrated `prepare` thunk (ADR 0019 §9): bind whichever action-selected use the - * parsed action names — admission only, no device I/O yet — and defer the bound runtime's own - * `execute` as the invocation `runSessionOrSelectorDispatch` runs after expiring the frame. - * Replaces the resolve-then-record shape `runSessionOrSelectorDispatch` now owns — this only - * supplies what the generic route's `dispatchCommand` cannot: the bound runtime and the - * action-specific execution context. - */ -function keyboardSessionExecute( - positionals: string[], - inspectFacts: InspectDeviceRuntimeFacts | undefined, - bindDevice: BindDeviceRuntime | undefined, - req: DaemonRequest, - logPath: string, -): ( - device: DeviceInfo, - session: SessionState | undefined, -) => Promise { - return async (device, session) => { - const bound = await resolveBoundKeyboardRuntime({ - device, - positionals, - inspectFacts, - bindDevice, - }); - if (!bound.ok) return { ok: false, response: bound.response }; - const dispatchContext = { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - surface: session?.surface, - }; - return { ok: true, execute: () => bound.execute(dispatchContext) }; - }; -} - -export async function handleKeyboardCommand(params: { +/** The params every migrated session-route handler takes; identical across the leaves. */ +type SessionRouteHandlerParams = Readonly<{ req: DaemonRequest; sessionName: string; logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; -}): Promise { - const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; - const positionals = req.positionals ?? []; +}>; - const foregroundGuard = requireForegroundIosKeyboardSession( - sessionStore.get(sessionName), - positionals[0]?.trim().toLowerCase(), - req.flags ?? {}, - ); - if (foregroundGuard) return foregroundGuard; +type SessionRouteRuntimeResolver = ( + params: Readonly<{ + device: DeviceInfo; + positionals: string[]; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; + }>, +) => Promise< + | Readonly<{ ok: false; response: DaemonResponse }> + | Readonly<{ + ok: true; + execute: (context: DaemonCommandContext) => Promise | void>; + }> +>; +/** + * The whole shape a migrated session-route leaf needs: admit and bind through the caller's own + * resolver, then hand `runSessionOrSelectorDispatch` the bound runtime's `execute` to invoke after + * expiring the frame. Only the resolver, the command name and the optional session derivation + * differ per leaf, so one entry point here is what keeps `keyboard` and `trigger-app-event` from + * drifting into two copies of the same wiring. + */ +async function runBoundSessionRoute( + params: SessionRouteHandlerParams & + Readonly<{ + command: string; + /** + * Written as a call at each leaf rather than passed by reference: the resolver call site is + * this command's own single-bind evidence (ADR 0019 §9), and the cutover gate reads it + * lexically. A bare reference would dedupe the wiring and delete the proof with it. + */ + resolveRuntime: SessionRouteRuntimeResolver; + deriveNextSession?: Parameters[0]['deriveNextSession']; + }>, +): Promise { + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const positionals = req.positionals ?? []; return await runSessionOrSelectorDispatch({ req, sessionName, sessionStore, - command: PUBLIC_COMMANDS.keyboard, + command: params.command, positionals, - prepare: keyboardSessionExecute(positionals, inspectFacts, bindDevice, req, logPath), + ...(params.deriveNextSession ? { deriveNextSession: params.deriveNextSession } : {}), + prepare: async (device, session) => { + const bound = await params.resolveRuntime({ + device, + positionals, + inspectFacts, + bindDevice, + }); + if (!bound.ok) return { ok: false, response: bound.response }; + const dispatchContext = { + ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + surface: session?.surface, + }; + return { ok: true, execute: () => bound.execute(dispatchContext) }; + }, }); } -/** - * `trigger-app-event`'s migrated `prepare` thunk (ADR 0019 §9): bind the one use its fact admits — - * admission only, no device I/O and no argument read yet — and defer the bound runtime's own - * `execute` as the invocation `runSessionOrSelectorDispatch` runs after expiring the frame. - */ -function appEventSessionExecute( - positionals: string[], - inspectFacts: InspectDeviceRuntimeFacts | undefined, - bindDevice: BindDeviceRuntime | undefined, - req: DaemonRequest, - logPath: string, -): ( - device: DeviceInfo, - session: SessionState | undefined, -) => Promise { - return async (device, session) => { - const bound = await resolveBoundAppEventRuntime({ - device, - positionals, - inspectFacts, - bindDevice, - }); - if (!bound.ok) return { ok: false, response: bound.response }; - const dispatchContext = { - ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), - surface: session?.surface, - }; - return { ok: true, execute: () => bound.execute(dispatchContext) }; - }; +export async function handleKeyboardCommand( + params: SessionRouteHandlerParams, +): Promise { + const foregroundGuard = requireForegroundIosKeyboardSession( + params.sessionStore.get(params.sessionName), + params.req.positionals?.[0]?.trim().toLowerCase(), + params.req.flags ?? {}, + ); + if (foregroundGuard) return foregroundGuard; + return await runBoundSessionRoute({ + ...params, + command: PUBLIC_COMMANDS.keyboard, + resolveRuntime: (runtimeParams) => resolveBoundKeyboardRuntime(runtimeParams), + }); } -export async function handleAppEventCommand(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - inspectFacts?: InspectDeviceRuntimeFacts; - bindDevice?: BindDeviceRuntime; -}): Promise { - const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; - const positionals = req.positionals ?? []; - return await runSessionOrSelectorDispatch({ - req, - sessionName, - sessionStore, +export async function handleAppEventCommand( + params: SessionRouteHandlerParams, +): Promise { + return await runBoundSessionRoute({ + ...params, command: PUBLIC_COMMANDS.triggerAppEvent, - positionals, - prepare: appEventSessionExecute(positionals, inspectFacts, bindDevice, req, logPath), + resolveRuntime: (runtimeParams) => resolveBoundAppEventRuntime(runtimeParams), deriveNextSession: async (session, result) => { const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; const nextAppBundleId = eventUrl diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index d35d741e5b..053bd36828 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -1,26 +1,27 @@ -import { isIosFamily, isMacOs } from '@agent-device/kernel/device'; +import type { AlertRuntimeInput } from '@agent-device/contracts/alert-runtime'; import { - ALERT_ACTION_RETRY_MS, - ALERT_POLL_INTERVAL_MS as POLL_INTERVAL_MS, type AlertAction, DEFAULT_ALERT_TIMEOUT_MS as DEFAULT_TIMEOUT_MS, } from '@agent-device/contracts/alert-contract'; -import { sleep } from '../../utils/timeouts.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { runMacOsAlertAction } from '../../platforms/apple/os/macos/helper.ts'; -import { handleAndroidAlert } from '../../platforms/android/alert.ts'; -import { snapshotAndroid } from '../../platforms/android/snapshot.ts'; -import { androidSnapshotPublicationInput } from '../../platforms/android/snapshot-capture.ts'; -import { AppError } from '@agent-device/kernel/errors'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { + alertAcceptUse, + alertDismissUse, + alertReadUse, + alertWaitUse, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { contextFromFlags } from '../context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts'; import { recordIfSession } from './snapshot-session.ts'; import { parseTimeout } from '../../utils/parse-timeout.ts'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; -import { errorResponse, requireCommandSupported } from './response.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import type { DaemonFailureResponse } from './response.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { admitRuntimeUse, type RuntimeAdmissionBindings } from '../runtime-admission.ts'; +import { runtimeExecutionFromContext } from '../snapshot-runtime-capture-input.ts'; type HandleAlertCommandParams = { req: DaemonRequest; @@ -28,163 +29,125 @@ type HandleAlertCommandParams = { sessionStore: SessionStore; session: SessionState | undefined; device: SessionState['device']; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }; -type NativeAlertAction = Exclude; -type NativeAlertRunner = (action: NativeAlertAction, timeoutMs: number) => Promise; - -const ALERT_FALLBACK_HINT = - 'If the permission sheet is visible in snapshot or screenshot but alert reports no alert, take a scoped snapshot around the visible button label and use press @ref.'; +type ResolvedAlertExecution = + | Readonly<{ ok: false; response: DaemonFailureResponse }> + | Readonly<{ ok: true; execute: (input: AlertRuntimeInput) => Promise> }>; -export async function handleAlertCommand( - params: HandleAlertCommandParams, -): Promise { - const { req, logPath, session, device } = params; - const action = normalizeAlertAction(req.positionals?.[0]); - const macOsAlertTarget = (() => { - if (!session) return {}; - if (session.surface === 'frontmost-app') { - return { surface: 'frontmost-app' as const }; - } - return { - bundleId: session.appBundleId, - surface: session.surface, - }; - })(); - const unsupported = requireCommandSupported('alert', device); - if (unsupported) return unsupported; - // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait - // are read-only. The alert resolver returns `may-invalidate` only for the - // acting subactions, so this covers both the Android and native accept/dismiss - // mutations without touching the read paths. - if (session && resolveRefFrameEffect(req) === 'may-invalidate') { - expireRefFrame(session); +/** + * The ONE place `alert` reaches a device (R59). Exactly one action-selected use is admitted and + * bound per request — the leg the parsed subcommand names, never all four. Each branch admits its + * own literal use, so the bound runtime narrows from that instantiation rather than an assertion. + * + * Every branch names the bare command in its refusal: the retired + * `requireCommandSupported('alert', device)` gate refused per command, not per subcommand, and + * that wording is parity-pinned. + */ +async function resolveBoundAlertRuntime( + params: Readonly<{ device: DeviceInfo; action: AlertAction }> & RuntimeAdmissionBindings, +): Promise { + const { device, action, inspectFacts, bindDevice } = params; + const shared = { command: 'alert', device, inspectFacts, bindDevice }; + if (action === 'wait') { + const admission = await admitRuntimeUse({ ...shared, use: alertWaitUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeAwaitAlert(runtime, input) }; } - if (device.platform === 'android') { - const timeoutMs = parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; - return recordAlertResponse( - params, - await handleAndroidAlert(device, action, { - timeoutMs, - captureNodes: async () => { - const capture = await snapshotAndroid(device, { includeHiddenContentHints: false }); - return buildSnapshotState(androidSnapshotPublicationInput(capture), undefined).nodes; - }, - }), - ); + if (action === 'accept') { + const admission = await admitRuntimeUse({ ...shared, use: alertAcceptUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeAcceptAlert(runtime, input) }; } - if (isMacOs(device)) { - const runAlert: NativeAlertRunner = async (alertAction) => - await runMacOsAlertAction(alertAction, macOsAlertTarget); - return await handleNativeAlertCommand(params, action, runAlert); + if (action === 'dismiss') { + const admission = await admitRuntimeUse({ ...shared, use: alertDismissUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeDismissAlert(runtime, input) }; } - - const runnerOptions = buildAppleRunnerRequestOptions({ - req, - logPath, - traceLogPath: session?.trace?.outPath, - }); - const runAlert: NativeAlertRunner = async (alertAction, timeoutMs) => - await runAppleRunnerCommand( - device, - { command: 'alert', action: alertAction, appBundleId: session?.appBundleId, timeoutMs }, - runnerOptions, - ); - return await handleNativeAlertCommand(params, action, runAlert); + const admission = await admitRuntimeUse({ ...shared, use: alertReadUse }); + if (admission.type === 'response') return { ok: false, response: admission.response }; + const runtime = admission.runtime; + return { ok: true, execute: (input) => executeReadAlert(runtime, input) }; } -async function handleNativeAlertCommand( - params: HandleAlertCommandParams, - action: AlertAction, - runAlert: NativeAlertRunner, -): Promise { - if (action === 'wait') { - return await waitForNativeAlert(params, runAlert); - } - - const resolvedAction = action === 'accept' || action === 'dismiss' ? action : 'get'; - if (resolvedAction === 'accept' || resolvedAction === 'dismiss') { - return await handleNativeAlertAction(params, resolvedAction, runAlert); - } +async function executeReadAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.readAlert(input); +} - return recordAlertResponse(params, await runAlert('get', DEFAULT_TIMEOUT_MS)); +async function executeAwaitAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.awaitAlert(input); } -function normalizeAlertAction(action: string | undefined): AlertAction { - if (action === 'accept' || action === 'dismiss' || action === 'wait') return action; - return 'get'; +async function executeAcceptAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.acceptAlert(input); } -async function waitForNativeAlert( - params: HandleAlertCommandParams, - runAlert: NativeAlertRunner, -): Promise { - const timeout = parseTimeout(params.req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS; - const start = Date.now(); - let firstAttempt = true; - while (Date.now() - start < timeout) { - try { - const budgetMs = firstAttempt ? timeout : remainingBudgetMs(start, timeout); - firstAttempt = false; - return recordAlertResponse(params, await runAlert('get', budgetMs)); - } catch { - // keep waiting - } - await sleep(POLL_INTERVAL_MS); - } - return errorResponse('COMMAND_FAILED', 'alert wait timed out'); +async function executeDismissAlert( + runtime: BoundDeviceRuntime, + input: AlertRuntimeInput, +): Promise> { + return await runtime.operations.dismissAlert(input); } -async function handleNativeAlertAction( +export async function handleAlertCommand( params: HandleAlertCommandParams, - action: 'accept' | 'dismiss', - runAlert: NativeAlertRunner, ): Promise { - const runnerTimeoutMs = isIosFamily(params.device) ? DEFAULT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; - const start = Date.now(); - let lastError: unknown; - let firstAttempt = true; - while (Date.now() - start < ALERT_ACTION_RETRY_MS) { - try { - const budgetMs = firstAttempt - ? runnerTimeoutMs - : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); - firstAttempt = false; - return recordAlertResponse(params, await runAlert(action, budgetMs)); - } catch (err) { - lastError = err; - const msg = String((err as { message?: unknown })?.message ?? '').toLowerCase(); - if (!msg.includes('alert not found') && !msg.includes('no alert')) break; - } - await sleep(POLL_INTERVAL_MS); + const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const action = normalizeAlertAction(req.positionals?.[0]); + const bound = await resolveBoundAlertRuntime({ device, action, inspectFacts, bindDevice }); + if (!bound.ok) return bound.response; + // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait are read-only. + // The alert resolver returns `may-invalidate` only for the acting subactions, so this covers + // the accept/dismiss mutations on every owner without touching the read paths. + if (session && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(session); } - throw withAlertFallbackHint(lastError); -} - -function remainingBudgetMs(start: number, timeoutMs: number): number { - return Math.max(1, timeoutMs - (Date.now() - start)); -} - -function recordAlertResponse(params: HandleAlertCommandParams, data: unknown): DaemonResponse { - const responseData = data as Record; - recordIfSession(params.sessionStore, params.session, params.req, responseData); - return { ok: true, data: responseData }; + const context = contextFromFlags( + logPath, + req.flags, + session?.appBundleId, + session?.trace?.outPath, + ); + const data = await bound.execute({ + timeoutMs: parseTimeout(req.positionals?.[1]) ?? DEFAULT_TIMEOUT_MS, + ...alertTarget(session), + execution: runtimeExecutionFromContext(context), + }); + recordIfSession(sessionStore, session, req, data); + return { ok: true, data }; } -function withAlertFallbackHint(error: unknown): unknown { - if (!(error instanceof AppError)) { - return error; - } - if (!isAlertNotFoundError(error)) { - return error; - } - return new AppError(error.code, error.message, { - ...(error.details ?? {}), - hint: ALERT_FALLBACK_HINT, - }); +/** + * The session's own alert target. A frontmost-app session deliberately carries no bundle — that + * surface means "whatever is frontmost", and naming an app would contradict it — so the pair is + * forwarded as the session holds it rather than collapsed into one field. + */ +function alertTarget( + session: SessionState | undefined, +): Readonly<{ appBundleId?: string; surface?: SessionState['surface'] }> { + if (!session) return {}; + if (session.surface === 'frontmost-app') return { surface: session.surface }; + return { + ...(session.appBundleId === undefined ? {} : { appBundleId: session.appBundleId }), + ...(session.surface === undefined ? {} : { surface: session.surface }), + }; } -function isAlertNotFoundError(error: unknown): boolean { - const message = String((error as { message?: unknown })?.message ?? '').toLowerCase(); - return message.includes('alert not found') || message.includes('no alert'); +function normalizeAlertAction(action: string | undefined): AlertAction { + if (action === 'accept' || action === 'dismiss' || action === 'wait') return action; + return 'get'; } diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 96161a57b5..eb35cdc27f 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -15,7 +15,7 @@ import { } from '@agent-device/kernel/snapshot'; import { resolveRefLabel } from '../../core/snapshot-node-lookup.ts'; import { captureSnapshotWithInteractor } from './snapshot-interactor-capture.ts'; -import { buildSnapshotState } from '../snapshot-state.ts'; +import { buildSnapshotState } from '../../core/snapshot-state.ts'; import { clearAndroidSnapshotFreshness } from '../session-snapshot-freshness.ts'; import type { SnapshotFreshnessMode } from '../../snapshot/snapshot-freshness/index.ts'; import { contextFromFlags } from '../context.ts'; diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index a845c53a2b..b92ba8e435 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -52,7 +52,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { inspectFacts, bindDevice, }), - alert: async ({ req, sessionName, logPath, sessionStore }) => { + alert: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => { const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); return await withSessionlessRunnerCleanup(session, device, async () => { return await handleAlertCommand({ @@ -61,6 +61,8 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, session, device, + inspectFacts, + bindDevice, }); }); }, diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 2b757fc8dc..d3ade3571c 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -185,6 +185,12 @@ async function runReactNativeHandler( logPath: params.logPath, sessionStore: params.sessionStore, contextFromFlags: params.contextFromFlags, + // R61: overlay dismissal admits and binds the owner's own `tapPoint`. The chain has never + // passed these through, so the dismissal leg has been reaching a missing gateway ever since + // R48 moved it off the retired dispatcher — the provider scenario only ever exercised the + // no-overlay-detected path, which returned before the bind. + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }), ); } diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index 9da78cba1e..96bfbb8666 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -21,7 +21,7 @@ import { import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; import type { DaemonCommandContext } from './context.ts'; import { captureSnapshotData } from './handlers/snapshot-capture.ts'; -import { buildSnapshotState } from './snapshot-state.ts'; +import { buildSnapshotState } from '../core/snapshot-state.ts'; import type { RecordedGenericRequest, ResolvedGenericExecution, diff --git a/src/daemon/touch-runtime.ts b/src/daemon/touch-runtime.ts index ccddff328e..02a9675802 100644 --- a/src/daemon/touch-runtime.ts +++ b/src/daemon/touch-runtime.ts @@ -25,12 +25,16 @@ import { requireIntInRange } from '../utils/validation.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DirectIosSelectorTarget } from './direct-ios-selector.ts'; import type { DaemonFailureResponse } from './handlers/response.ts'; -import { admitRuntimeOperations, type RuntimeAdmissionBindings } from './runtime-admission.ts'; +import { + admitRuntimeOperations, + type RuntimeAdmissionBindings, + type UnavailableRuntimeResponse, +} from './runtime-admission.ts'; import { runtimeExecutionFromContext } from './snapshot-runtime-capture-input.ts'; export type TouchRuntimeCommand = 'click' | 'press' | 'fill' | 'longpress' | 'hover'; -type BoundTouchRuntime = +export type BoundTouchRuntime = | Readonly<{ kind: 'tap'; captured: false; runtime: BoundDeviceRuntime }> | Readonly<{ kind: 'tap'; captured: true; runtime: BoundDeviceRuntime }> | Readonly<{ @@ -71,6 +75,8 @@ export async function resolveBoundTouchRuntime( device: DeviceInfo; command: TouchRuntimeCommand; requiresCapture: boolean; + /** A caller whose command is not the touch leaf itself supplies its own refusal wording. */ + unavailableResponse?: UnavailableRuntimeResponse; } & RuntimeAdmissionBindings, ): Promise { const shared = { @@ -82,6 +88,7 @@ export async function resolveBoundTouchRuntime( const admission = await admitRuntimeOperations({ command: params.command, required: plan.use.required, + ...(params.unavailableResponse ? { unavailableResponse: params.unavailableResponse } : {}), ...shared, }); if (admission.type === 'response') return { ok: false, response: admission.response }; diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index c7bac55f88..28c0166e07 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -63,6 +63,10 @@ describe('composed platform runtime gateway', () => { appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, touch: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, @@ -158,6 +162,10 @@ describe('composed platform runtime gateway', () => { appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/platform-runtime-gateway.ts b/src/platform-runtime-gateway.ts index 4f2ae19be7..71f8ff05c0 100644 --- a/src/platform-runtime-gateway.ts +++ b/src/platform-runtime-gateway.ts @@ -327,6 +327,10 @@ function unavailableProviderBinding( appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }); } @@ -363,6 +367,10 @@ function unavailableProviderFacts(runtime: ProviderDeviceRuntime, device: Device appSwitcher: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, + readAlert: unavailable, + awaitAlert: unavailable, + acceptAlert: unavailable, + dismissAlert: unavailable, readiness: unavailable, lifecycle: unavailableProviderLifecycleFacts(unavailable), }, diff --git a/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts b/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts index ef7d005047..af5ab174e3 100644 --- a/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts +++ b/src/platforms/android/__tests__/snapshot-occlusion-context.test.ts @@ -3,7 +3,7 @@ import { afterEach, test, vi } from 'vitest'; import { readSnapshotOcclusionContextEvidence } from '@agent-device/contracts/capture'; import { ANDROID_EMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../__tests__/test-utils/android-snapshot-helper.ts'; -import { buildSnapshotState } from '../../../daemon/snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { coveredAndroidReplacementNodeIndexes } from '../../../snapshot/android-replacement-surface-occlusion.ts'; import { resetAndroidSnapshotHelperInstallCache } from '../snapshot-helper-install.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; diff --git a/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts b/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts index b3cbcb727e..86af1c1419 100644 --- a/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts +++ b/src/platforms/android/__tests__/ui-hierarchy-scope.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { buildSnapshotState } from '../../../daemon/snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { parseUiHierarchy } from './ui-hierarchy-fixtures.ts'; import { AndroidSnapshotPresentationFailure, diff --git a/src/platforms/android/__tests__/ui-hierarchy.test.ts b/src/platforms/android/__tests__/ui-hierarchy.test.ts index 95bc1d764c..a29c703a8b 100644 --- a/src/platforms/android/__tests__/ui-hierarchy.test.ts +++ b/src/platforms/android/__tests__/ui-hierarchy.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { buildSnapshotState } from '../../../daemon/snapshot-state.ts'; +import { buildSnapshotState } from '../../../core/snapshot-state.ts'; import { isNodeVisibleOnScreen } from '@agent-device/contracts/snapshot'; import { androidUiNodes, parseUiHierarchyTree } from '../ui-hierarchy.ts'; import { parseUiHierarchy } from './ui-hierarchy-fixtures.ts'; diff --git a/src/platforms/apple/__tests__/alert.test.ts b/src/platforms/apple/__tests__/alert.test.ts new file mode 100644 index 0000000000..e9e52a4f03 --- /dev/null +++ b/src/platforms/apple/__tests__/alert.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { afterEach, test, vi } from 'vitest'; + +vi.mock('../core/runner/runner-client.ts', () => ({ runAppleRunnerCommand: vi.fn() })); +vi.mock('../os/macos/helper.ts', () => ({ runMacOsAlertAction: vi.fn() })); + +import { AppError } from '@agent-device/kernel/errors'; +import { IOS_SIMULATOR, MACOS_DEVICE } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { runAppleRunnerCommand } from '../core/runner/runner-client.ts'; +import { runMacOsAlertAction } from '../os/macos/helper.ts'; +import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from '../alert.ts'; + +const mockRunner = vi.mocked(runAppleRunnerCommand); +const mockHelper = vi.mocked(runMacOsAlertAction); +const runnerOptions = {}; + +afterEach(() => { + vi.useRealTimers(); + mockRunner.mockReset(); + mockHelper.mockReset(); +}); + +// R59 moved these windows out of the daemon: how long a transient sheet takes to appear, and how +// many times to re-ask a runner that says it is not there yet, are Apple family mechanics. +test('a read spends the family default, not the caller window', async () => { + mockRunner.mockResolvedValue({ title: 'Camera Access' }); + + await readAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 37 }); + + assert.equal(mockRunner.mock.calls.length, 1); + assert.deepEqual(mockRunner.mock.calls[0]?.[1], { + command: 'alert', + action: 'get', + appBundleId: undefined, + timeoutMs: 10_000, + }); +}); + +test('a wait polls until one attempt answers, and the first attempt gets the whole window', async () => { + let calls = 0; + mockRunner.mockImplementation(async () => { + calls += 1; + if (calls === 1) throw new AppError('COMMAND_FAILED', 'alert not found'); + return { title: 'Camera Access' }; + }); + + const result = await awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }); + + assert.deepEqual(result, { title: 'Camera Access' }); + assert.equal(calls, 2); + const budgets = mockRunner.mock.calls.map((call) => (call[1] as { timeoutMs: number }).timeoutMs); + assert.equal(budgets[0], 5_000); + // Later attempts get only what is left, so a blocking runner cannot outlive the budget. + assert.ok(budgets[1] !== undefined && budgets[1] <= 5_000); +}); + +test('a wait that never sees an alert reports the timeout rather than the last attempt error', async () => { + vi.useFakeTimers(); + mockRunner.mockRejectedValue(new AppError('COMMAND_FAILED', 'alert not found')); + + const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 900 }).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + assert.match(String(((await outcome) as Error).message), /alert wait timed out/); +}); + +test('an accept retries only while the backend says the alert is not there yet', async () => { + vi.useFakeTimers(); + let calls = 0; + mockRunner.mockImplementation(async () => { + calls += 1; + throw new AppError('COMMAND_FAILED', 'runner crashed'); + }); + + const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + + assert.match(String(((await outcome) as Error).message), /runner crashed/); + assert.equal(calls, 1); +}); + +test('an exhausted accept carries the scoped-snapshot fallback the agent needs next', async () => { + vi.useFakeTimers(); + mockRunner.mockRejectedValue(new AppError('COMMAND_FAILED', 'alert not found')); + + const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + const error = (await outcome) as AppError; + + assert.equal(error.message, 'alert not found'); + assert.match(String(error.details?.hint), /scoped snapshot/i); +}); + +// The macOS host answers through its helper, and a frontmost-app session names no bundle at all. +test('the macOS host reads through its helper, and a frontmost-app session names no bundle', async () => { + mockHelper.mockResolvedValue({ title: 'Allow access' }); + + await readAppleAlert(MACOS_DEVICE, runnerOptions, { + surface: 'frontmost-app', + appBundleId: 'com.example.app', + }); + + assert.equal(mockRunner.mock.calls.length, 0); + assert.deepEqual(mockHelper.mock.calls[0], ['get', { surface: 'frontmost-app' }]); +}); + +test('a macOS app session forwards the bundle its surface is scoped to', async () => { + mockHelper.mockResolvedValue({ action: 'dismiss' }); + + await actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'dismiss', { + surface: 'app', + appBundleId: 'com.example.app', + }); + + assert.deepEqual(mockHelper.mock.calls[0], [ + 'dismiss', + { bundleId: 'com.example.app', surface: 'app' }, + ]); +}); diff --git a/src/platforms/apple/__tests__/interactor-runner-provider.test.ts b/src/platforms/apple/__tests__/interactor-runner-provider.test.ts index 5881729fc2..4147b3a65e 100644 --- a/src/platforms/apple/__tests__/interactor-runner-provider.test.ts +++ b/src/platforms/apple/__tests__/interactor-runner-provider.test.ts @@ -70,6 +70,13 @@ const RUNNER_TRANSPORT_METHODS: Record< tvRemote: { invoke: (i) => i.tvRemote('select'), runnerCommand: 'remotePress' }, keyboardDismiss: { invoke: (i) => i.keyboardDismiss!(), runnerCommand: 'keyboardDismiss' }, keyboardEnter: { invoke: (i) => i.keyboardEnter!(), runnerCommand: 'keyboardReturn' }, + // R59: same reading as `readTextAtPoint` — the macOS-helper branch is reachable only for a + // local desktop surface, which a provider-owned mobile device never carries, so every + // provider-backed alert leg rides the runner. Each spends one runner call when it succeeds. + readAlert: { invoke: (i) => i.readAlert(), runnerCommand: 'alert' }, + awaitAlert: { invoke: (i) => i.awaitAlert(), runnerCommand: 'alert' }, + acceptAlert: { invoke: (i) => i.acceptAlert(), runnerCommand: 'alert' }, + dismissAlert: { invoke: (i) => i.dismissAlert(), runnerCommand: 'alert' }, }; const LOCAL_TOOL_METHODS: Record Promise> = { diff --git a/src/platforms/apple/alert.ts b/src/platforms/apple/alert.ts new file mode 100644 index 0000000000..523924877e --- /dev/null +++ b/src/platforms/apple/alert.ts @@ -0,0 +1,133 @@ +import { + ALERT_ACTION_RETRY_MS, + ALERT_POLL_INTERVAL_MS, + DEFAULT_ALERT_TIMEOUT_MS, +} from '@agent-device/contracts/alert-contract'; +import type { + AlertInteractorOptions, + RunnerCallOptions, +} from '@agent-device/contracts/interaction'; +import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '../../utils/timeouts.ts'; +import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; +import { runMacOsAlertAction } from './os/macos/helper.ts'; + +/** + * Apple's four alert legs. R59 moved them here from the daemon: how long to look for a transient + * alert, how many times to re-ask a runner that answers "alert not found", and which of the two + * Apple backends answers at all are family mechanics, not request policy. What the caller supplies + * is the window it allows and the session's target; everything else is this family's. + */ +type NativeAlertAction = 'get' | 'accept' | 'dismiss'; + +const ALERT_FALLBACK_HINT = + 'If the permission sheet is visible in snapshot or screenshot but alert reports no alert, take a scoped snapshot around the visible button label and use press @ref.'; + +/** + * The one backend split: the macOS host answers through its helper, every other Apple leaf + * through the XCTest runner. The macOS target deliberately carries no bundle on a frontmost-app + * session — that surface means "whatever is frontmost", and naming a bundle would contradict it. + */ +function runAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + options: AlertInteractorOptions | undefined, +): (action: NativeAlertAction, timeoutMs: number) => Promise> { + if (isMacOs(device)) { + const target = + options?.surface === 'frontmost-app' + ? { surface: options.surface } + : { bundleId: options?.appBundleId, surface: options?.surface }; + return async (action) => (await runMacOsAlertAction(action, target)) as Record; + } + return async (action, timeoutMs) => + (await runAppleRunnerCommand( + device, + { command: 'alert', action, appBundleId: options?.appBundleId, timeoutMs }, + runnerOptions, + )) as Record; +} + +export async function readAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + options?: AlertInteractorOptions, +): Promise> { + return await runAppleAlert(device, runnerOptions, options)('get', DEFAULT_ALERT_TIMEOUT_MS); +} + +/** + * Poll `get` until one answers or the caller's window runs out. The first attempt gets the whole + * window and later ones only what is left, so a runner that blocks cannot outlive the budget. + */ +export async function awaitAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + options?: AlertInteractorOptions, +): Promise> { + const runAlert = runAppleAlert(device, runnerOptions, options); + const timeout = options?.timeoutMs ?? DEFAULT_ALERT_TIMEOUT_MS; + const start = Date.now(); + let firstAttempt = true; + while (Date.now() - start < timeout) { + try { + const budgetMs = firstAttempt ? timeout : remainingBudgetMs(start, timeout); + firstAttempt = false; + return await runAlert('get', budgetMs); + } catch { + // keep waiting + } + await sleep(ALERT_POLL_INTERVAL_MS); + } + throw new AppError('COMMAND_FAILED', 'alert wait timed out'); +} + +/** + * Accept and dismiss retry only while the backend keeps saying the alert is not there yet — a + * sheet that is still animating in. Any other failure is reported on its first occurrence, and + * an exhausted retry window carries the scoped-snapshot fallback the agent needs next. + */ +export async function actOnAppleAlert( + device: DeviceInfo, + runnerOptions: RunnerCallOptions, + action: 'accept' | 'dismiss', + options?: AlertInteractorOptions, +): Promise> { + const runAlert = runAppleAlert(device, runnerOptions, options); + const runnerTimeoutMs = isIosFamily(device) ? DEFAULT_ALERT_TIMEOUT_MS : ALERT_ACTION_RETRY_MS; + const start = Date.now(); + let lastError: unknown; + let firstAttempt = true; + while (Date.now() - start < ALERT_ACTION_RETRY_MS) { + try { + const budgetMs = firstAttempt + ? runnerTimeoutMs + : remainingBudgetMs(start, ALERT_ACTION_RETRY_MS); + firstAttempt = false; + return await runAlert(action, budgetMs); + } catch (err) { + lastError = err; + if (!isAlertNotFoundError(err)) break; + } + await sleep(ALERT_POLL_INTERVAL_MS); + } + throw withAlertFallbackHint(lastError); +} + +function remainingBudgetMs(start: number, timeoutMs: number): number { + return Math.max(1, timeoutMs - (Date.now() - start)); +} + +function withAlertFallbackHint(error: unknown): unknown { + if (!(error instanceof AppError) || !isAlertNotFoundError(error)) return error; + return new AppError(error.code, error.message, { + ...(error.details ?? {}), + hint: ALERT_FALLBACK_HINT, + }); +} + +function isAlertNotFoundError(error: unknown): boolean { + const message = String((error as { message?: unknown })?.message ?? '').toLowerCase(); + return message.includes('alert not found') || message.includes('no alert'); +} diff --git a/src/platforms/apple/capabilities.ts b/src/platforms/apple/capabilities.ts deleted file mode 100644 index 7abdc64d3d..0000000000 --- a/src/platforms/apple/capabilities.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - isApplePlatform, - resolveDeviceAppleOs, - type AppleOS, - type DeviceInfo, -} from '@agent-device/kernel/device'; - -// --------------------------------------------------------------------------- -// Per-`AppleOS` capability data table (ADR-0009 "per-AppleOS capability table"). -// This is the capability-axis sibling of the runner -// table `RUNNER_PLATFORM_PROFILES` (src/platforms/apple/core/apple-runner-platform.ts) -// and it encodes the SAME per-OS facts the Swift `#if os()` guards do: keyboard, -// device orientation and desktop host surfaces. The app/device-lifecycle row left with R56, whose -// `app-switcher` cutover retired its last reader (`home`'s went with R43, `boot`'s with R20); the -// springboard reading now lives as owner facts in `platform-apple/src/navigation/runtime.ts`. -// -// DISCIPLINE (ADR-0009): the table holds ONLY the AppleOS-axis facts — it -// collapses the scattered `target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice` -// predicates into one lookup. Gesture synthesis has its own shared policy in -// `contracts/apple-multitouch-support.ts`; keeping it out of this table prevents a -// second source of truth. The predicate rewrite is behaviorless: -// `apple-os-capability-table-parity.test.ts` pins the table-driven -// closures byte-for-byte against a verbatim copy of the original predicates across the -// full {command x sample-device} matrix (iOS/iPadOS/tvOS/macOS/visionOS). -// --------------------------------------------------------------------------- - -export type AppleOsCapabilityProfile = { - /** `keyboard` — hardware/text keyboard input. tvOS (focus-only) and macOS lack it. */ - readonly keyboard: boolean; - /** `orientation` — device orientation. tvOS (focus-only) and macOS lack it. */ - readonly orientation: boolean; - /** - * Whether `settings` is reachable on a PHYSICAL device of this OS (`clipboard`'s reader left - * with R56). Only the macOS host exposes it without a simulator; the reading closure still - * admits every Apple *simulator* via its own `kind === 'simulator'` check. Alert support has a - * separate command predicate because physical iOS is verified. - */ - readonly physicalDeviceSurfaces: boolean; -}; - -// iOS and iPadOS share this platform capability profile. Gesture synthesis support is -// intentionally owned by the separate shared gesture policy. -const IOS_FAMILY_CAPABILITIES: AppleOsCapabilityProfile = { - keyboard: true, - orientation: true, - physicalDeviceSurfaces: false, -}; - -export const APPLE_OS_CAPABILITIES: Record = { - ios: IOS_FAMILY_CAPABILITIES, - ipados: IOS_FAMILY_CAPABILITIES, - visionos: { - keyboard: true, - orientation: true, - physicalDeviceSurfaces: false, - }, - // tvOS: focus-only (XCUIRemote), so no keyboard or orientation. - tvos: { - keyboard: false, - orientation: false, - physicalDeviceSurfaces: false, - }, - // macOS: an AppKit desktop host driving an already-running app. No keyboard or orientation; - // alert/settings are reachable on the host directly (no simulator required). - macos: { - keyboard: false, - orientation: false, - physicalDeviceSurfaces: true, - }, - // watchOS is reserved in the `AppleOS` type but never produced by discovery or by - // `resolveDeviceAppleOs` inference (XCUITest cannot drive watchOS UI — ADR-0009's - // unsupported sentinel). This row is therefore unreachable today; the all-`false` - // sentinel keeps the `Record` exhaustive without granting anything. - watchos: { - keyboard: false, - orientation: false, - physicalDeviceSurfaces: false, - }, -}; - -/** - * The {@link AppleOsCapabilityProfile} for `device`, or `undefined` for a non-Apple - * platform (which has no AppleOS row). The capability closures fall back to their - * verbatim non-Apple verdicts when this returns `undefined`, so consulting the table - * only for the Apple family leaves admission for android/linux/web unchanged. - */ -export function appleOsCapabilities( - device: Pick, -): AppleOsCapabilityProfile | undefined { - return isApplePlatform(device.platform) - ? APPLE_OS_CAPABILITIES[resolveDeviceAppleOs(device)] - : undefined; -} diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 3b477f8ab6..119ca69e64 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -11,6 +11,7 @@ import { captureScreenshotViaRunner } from './core/screenshot.ts'; import { iosRunnerOverrides, resolveAppleBackRunnerCommand } from './interactions.ts'; import { appleRemotePressCommand } from './os/tvos/remote.ts'; import { runMacOsScreenshotAction } from './os/macos/helper.ts'; +import { actOnAppleAlert, awaitAppleAlert, readAppleAlert } from './alert.ts'; import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; import { queryAppleRunnerSelector } from './core/runner/runner-selector-query.ts'; import { @@ -194,6 +195,10 @@ export function createAppleInteractor( writeClipboard: (text) => writeIosClipboardText(device, text), setSetting: (setting, state, appId, options) => setIosSetting(device, setting, state, appId, options), + readAlert: (options) => readAppleAlert(device, runnerOpts, options), + awaitAlert: (options) => awaitAppleAlert(device, runnerOpts, options), + acceptAlert: (options) => actOnAppleAlert(device, runnerOpts, 'accept', options), + dismissAlert: (options) => actOnAppleAlert(device, runnerOpts, 'dismiss', options), ...overrides, }; if (!runnerProvider) return interactor; diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 8f8f57dcb6..6bc1c2a45b 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -1,8 +1,7 @@ -import { appleOsCapabilities } from './capabilities.ts'; import type { PlatformPlugin } from '@agent-device/contracts/platform'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/audio-probe-support'; -import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerContext } from '@agent-device/contracts/interaction'; // --------------------------------------------------------------------------- @@ -25,29 +24,10 @@ const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.kind !== 'device' || device.iosPhysicalDeviceBackend !== 'xctest'; -// The Apple arm `settings` uses (was `macos || simulator`); `clipboard` retired its own copy of -// this closure with R55, which restated it as the Apple owner's clipboard facts: -// reachable on the macOS host directly, on every other Apple OS only on the simulator. -// Off Apple this preserves the trailing `device.kind === 'simulator'` term verbatim. -const supportsHostOrSimulatorSurface = (device: DeviceInfo): boolean => { - const caps = appleOsCapabilities(device); - return caps - ? caps.physicalDeviceSurfaces || device.kind === 'simulator' - : device.kind === 'simulator'; -}; - -// Alerts use the host/simulator surface plus physical iOS, whose XCTest path is -// device-verified. iPadOS/visionOS remain closed until independently verified. -const supportsAlertSurface = (device: DeviceInfo): boolean => - device.platform === 'android' || - (device.platform === 'apple' && resolveDeviceAppleOs(device) === 'ios') || - supportsHostOrSimulatorSurface(device); - // Per-command support gates the Apple family applies by default, keyed exactly as in // the command-descriptor registry (a command absent here has no Apple gate). const APPLE_SUPPORTS_BY_DEFAULT: Record boolean> = { [PUBLIC_COMMANDS.perf]: supportsCoreDevicePhysicalOperation, - [PUBLIC_COMMANDS.alert]: supportsAlertSurface, [PUBLIC_COMMANDS.audio]: isAudioProbeSupportedDevice, }; diff --git a/src/platforms/unsupported-interactor.ts b/src/platforms/unsupported-interactor.ts index 4781fb1e0a..c0fa6cf557 100644 --- a/src/platforms/unsupported-interactor.ts +++ b/src/platforms/unsupported-interactor.ts @@ -30,5 +30,9 @@ export function createUnsupportedInteractor(platformLabel: string): Interactor { readClipboard: () => unsupported('readClipboard'), writeClipboard: () => unsupported('writeClipboard'), setSetting: () => unsupported('setSetting'), + readAlert: () => unsupported('readAlert'), + awaitAlert: () => unsupported('awaitAlert'), + acceptAlert: () => unsupported('acceptAlert'), + dismissAlert: () => unsupported('dismissAlert'), }; } diff --git a/test/integration/linux-e2e/coverage-manifest.ts b/test/integration/linux-e2e/coverage-manifest.ts index 76b7c1c51a..06d1b03b90 100644 --- a/test/integration/linux-e2e/coverage-manifest.ts +++ b/test/integration/linux-e2e/coverage-manifest.ts @@ -176,15 +176,23 @@ export const LINUX_PLATFORM_COVERAGE = { 'the command-evidence lane observes a non-empty calculator snapshot mutation', ), [C.wait]: live('the existing Linux replay waits for an observable calculator landmark'), - [C.alert]: denial('alert', 'Linux capability declaration rejects native alert operations'), + [C.alert]: contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact rejects native alert handling on Linux', + ), [C.settings]: contract( LINUX_RUNTIME_EVIDENCE.path, LINUX_RUNTIME_EVIDENCE.test, 'the exact-owner runtime fact rejects native device settings on Linux', ), - [C.reactNative]: denial( - 'react-native', - 'Linux capability declaration rejects React Native inspection', + // R61: no owner fact refuses this command on Linux — its whole device work is one bound + // `tapPoint`, the same cell the live click leg uses — so it now runs and reports truthfully + // that no React Native overlay is present on a GTK desktop. + [C.reactNative]: contract( + LINUX_PROVIDER_EVIDENCE.path, + LINUX_PROVIDER_EVIDENCE.test, + 'React Native overlay dismissal binds the same desktop tap the press leg does', ), [C.record]: gap('No Linux-specific recording command evidence exists yet'), [C.trace]: gap('No Linux-specific trace command evidence exists yet'), diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index 40f38644ba..926e6f0fc4 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -14,6 +14,12 @@ import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-d import type { DeviceInfo } from '@agent-device/kernel/device'; import { createTestAppLogLiveHandle } from '../../../src/__tests__/test-utils/app-log-live-handle.ts'; import { setIosSetting } from '../../../src/platforms/apple/core/app-settings.ts'; +import { + actOnAppleAlert, + awaitAppleAlert, + readAppleAlert, +} from '../../../src/platforms/apple/alert.ts'; +import type { AlertRuntimeInput } from '@agent-device/contracts/alert-runtime'; import { assertFlatToolCall } from './assertions.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; import { createProviderScenarioHarness } from './harness.ts'; @@ -292,6 +298,15 @@ function createRecordingPlatformRuntimeGateway(params: { input.appBundleId, input.options, ), + // R59 does the same for `alert`: the scenario's gateway states and serves the four + // legs, reusing the Apple family's own module so the runner transcript this scenario + // scripts — including its retry and poll windows — is what actually runs. + readAlert: async (input) => await readAppleAlert(device, {}, alertOptions(input)), + awaitAlert: async (input) => await awaitAppleAlert(device, {}, alertOptions(input)), + acceptAlert: async (input) => + await actOnAppleAlert(device, {}, 'accept', alertOptions(input)), + dismissAlert: async (input) => + await actOnAppleAlert(device, {}, 'dismiss', alertOptions(input)), appLogReattach: async () => ({ status: 'missing' }), appLogCleanup: async () => ({ status: 'already-missing' }), resolveOpenTarget: async (input) => ({ @@ -340,6 +355,10 @@ function recordingRuntimeFacts(device: DeviceInfo): RuntimeFacts { // than catalog denials; R56 moves app-switcher the same way, for the same reason. // R57 moves trigger-app-event from capability-denial to command-contract: it is fact-owned now. // R58 moves settings from capability-denial to command-contract: it is fact-owned now. - capabilityDenial: 4, - contract: 24, + // R59/R61 moves alert and react-native from capability-denial to command-contract: it is fact-owned now. + capabilityDenial: 2, + contract: 26, gap: 9, live: 17, total: 54, diff --git a/test/integration/smoke-web-platform-coverage.test.ts b/test/integration/smoke-web-platform-coverage.test.ts index eb6eccfbce..8b5d21b29e 100644 --- a/test/integration/smoke-web-platform-coverage.test.ts +++ b/test/integration/smoke-web-platform-coverage.test.ts @@ -41,8 +41,8 @@ test('web coverage exhaustively classifies the public catalog', () => { test('web coverage report has the expected classification counts', () => { assert.deepEqual(WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - capabilityDenial: 3, - contract: 38, + capabilityDenial: 1, + contract: 40, gap: 1, live: 12, total: 54, diff --git a/test/integration/web-e2e/coverage-manifest.ts b/test/integration/web-e2e/coverage-manifest.ts index f47f36a821..b31a7a6702 100644 --- a/test/integration/web-e2e/coverage-manifest.ts +++ b/test/integration/web-e2e/coverage-manifest.ts @@ -140,7 +140,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.clipboard]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard, the app switcher, app events and settings carry no web capability bucket', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', 'the exact-owner runtime fact rejects native clipboard operations on the web target', ), [C.keyboard]: contract( @@ -165,7 +165,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.triggerAppEvent]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard, the app switcher, app events and settings carry no web capability bucket', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', 'the exact-owner runtime fact rejects native app-event delivery on the web target', ), [C.open]: live('the managed browser opens the local fixture page'), @@ -187,13 +187,23 @@ export const WEB_PLATFORM_COVERAGE = { 'web diff shares the browser-admitted snapshot capture that backs the live snapshot command', ), [C.wait]: live('wait observes ready text and post-interaction fixture state'), - [C.alert]: denial('Web capability model rejects native alert operations'), + [C.alert]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact rejects native alert handling on the web target', + ), [C.settings]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard, the app switcher, app events and settings carry no web capability bucket', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', 'the exact-owner runtime fact rejects native device settings on the web target', ), - [C.reactNative]: denial('Web capability model rejects React Native inspection'), + // R61: no owner fact refuses this command on a browser — its whole device work is one bound + // `tapPoint` the web target admits — so it now runs and reports that no overlay is present. + [C.reactNative]: contract( + 'packages/platform-web/src/runtime.test.ts', + 'press shares the admitted tapPoint fact that live click, press and react-native require', + 'React Native overlay dismissal binds the same browser tap the live click command does', + ), [C.record]: contract( 'test/integration/provider-scenarios/web-desktop.test.ts', 'start web recording', @@ -219,7 +229,7 @@ export const WEB_PLATFORM_COVERAGE = { ), [C.press]: contract( 'packages/platform-web/src/runtime.test.ts', - 'press shares the admitted tapPoint fact that live click and press both require', + 'press shares the admitted tapPoint fact that live click, press and react-native require', 'web press shares the browser-admitted tap operation that backs the live click command', ), [C.type]: contract( @@ -275,7 +285,7 @@ export const WEB_PLATFORM_COVERAGE = { [C.viewport]: live('viewport resizes the browser and the PNG reports 640x480 dimensions'), [C.appSwitcher]: contract( 'packages/platform-web/src/runtime.test.ts', - 'clipboard, the app switcher, app events and settings carry no web capability bucket', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', 'the exact-owner runtime fact rejects native app-switcher navigation on the web target', ), [C.installFromSource]: contract( From 8c1199e683ac15f68011e81ad595523a3940330c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 21:47:34 +0000 Subject: [PATCH 06/16] fix(daemon): restore two settings/alert sequences the migration had shifted Self-review of the Wave 6 diff against `origin/main` found two places where the migrated routes were faithful in what they did but not in when: - `settings` typed its location coordinates before expiring the ref frame, so a request that failed on a bad coordinate no longer expired it. The retired route expired the frame first, then emitted its diagnostic, then typed the coordinates inside the leaf. Same order again. - `alert` narrowed a frontmost-app session to "no bundle" in the daemon, which also stripped the bundle from the XCTest runner leg. That narrowing was only ever the macOS helper's, and it already lives in `platforms/apple/alert.ts`; the runner leg gets `session.appBundleId` unconditionally again, pinned by a test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- .tmp-probe/probe.ts | 7 +++++++ src/daemon/handlers/snapshot-alert.ts | 14 +++++++------- src/daemon/handlers/snapshot-settings.ts | 13 ++++++++----- src/platforms/apple/__tests__/alert.test.ts | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 .tmp-probe/probe.ts diff --git a/.tmp-probe/probe.ts b/.tmp-probe/probe.ts new file mode 100644 index 0000000000..439e5454e3 --- /dev/null +++ b/.tmp-probe/probe.ts @@ -0,0 +1,7 @@ +import { listCapabilityCommands, commandRuntimeUseRequirements } from '../src/core/capabilities.ts'; +const rows: string[] = []; +for (const c of listCapabilityCommands()) { + const r = commandRuntimeUseRequirements(c); + rows.push(`${c}: ${r === undefined ? 'NOT-FACT-OWNED' : JSON.stringify(r)}`); +} +console.log(rows.join('\n')); diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 053bd36828..cc17768083 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -132,18 +132,18 @@ export async function handleAlertCommand( } /** - * The session's own alert target. A frontmost-app session deliberately carries no bundle — that - * surface means "whatever is frontmost", and naming an app would contradict it — so the pair is - * forwarded as the session holds it rather than collapsed into one field. + * The session's own alert target, forwarded as the session holds it. The two fields stay separate + * because a macOS frontmost-app surface means "whatever is frontmost" and must reach its helper + * with no bundle at all — but that narrowing belongs to the Apple owner, which is the only leg + * that ever applied it. The retired route passed `session?.appBundleId` to the XCTest runner + * unconditionally, and it still does. */ function alertTarget( session: SessionState | undefined, ): Readonly<{ appBundleId?: string; surface?: SessionState['surface'] }> { - if (!session) return {}; - if (session.surface === 'frontmost-app') return { surface: session.surface }; return { - ...(session.appBundleId === undefined ? {} : { appBundleId: session.appBundleId }), - ...(session.surface === undefined ? {} : { surface: session.surface }), + ...(session?.appBundleId === undefined ? {} : { appBundleId: session.appBundleId }), + ...(session?.surface === undefined ? {} : { surface: session.surface }), }; } diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index 9ad2b89069..ae9ba1f8cd 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -85,7 +85,8 @@ export function parseSettingsArgs( * whose payload is not just `(setting, state)`; everything else sends none. Coordinate typing * happens here rather than in the owner: `readLocationCoordinate` is input validation, and it * throws the same `INVALID_ARGS` the retired dispatcher threw from the same point in the - * sequence — after admission, immediately before the device call. + * sequence — after admission, after the frame expiry and the diagnostic, immediately before the + * device call. */ function buildSettingOptions(parsed: ParsedSettingsArgs): SettingOptions | undefined { if (parsed.setting === 'permission') { @@ -172,15 +173,17 @@ export async function handleSettingsCommand( 'settings clear-app-state requires an app id when no app is bound to the session', ); } - const options = buildSettingOptions(parsed); + // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before + // the bound call (settings is always classified may-invalidate). It runs here, ahead of the + // diagnostic and the coordinate typing, because that is where the retired daemon route expired + // it — a request that later fails on a bad coordinate expired the frame then and expires it now. + if (session) expireRefFrame(session); emitDiagnostic({ level: 'debug', phase: 'settings_apply', data: settingsDiagnosticData(parsed, appBundleId, device.platform), }); - // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before - // the bound call (settings is always classified may-invalidate). - if (session) expireRefFrame(session); + const options = buildSettingOptions(parsed); const context = contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath); const data = await executeSetSetting( admission.runtime, diff --git a/src/platforms/apple/__tests__/alert.test.ts b/src/platforms/apple/__tests__/alert.test.ts index e9e52a4f03..411824afba 100644 --- a/src/platforms/apple/__tests__/alert.test.ts +++ b/src/platforms/apple/__tests__/alert.test.ts @@ -113,6 +113,24 @@ test('the macOS host reads through its helper, and a frontmost-app session names assert.deepEqual(mockHelper.mock.calls[0], ['get', { surface: 'frontmost-app' }]); }); +// The narrowing is the macOS helper's alone. A non-macOS Apple leaf keeps the session bundle +// whatever the surface says, exactly as the retired route passed it to the XCTest runner. +test('the XCTest runner keeps the session bundle even on a frontmost-app surface', async () => { + mockRunner.mockResolvedValue({ title: 'Camera Access' }); + + await readAppleAlert(IOS_SIMULATOR, runnerOptions, { + surface: 'frontmost-app', + appBundleId: 'com.example.app', + }); + + assert.deepEqual(mockRunner.mock.calls[0]?.[1], { + command: 'alert', + action: 'get', + appBundleId: 'com.example.app', + timeoutMs: 10_000, + }); +}); + test('a macOS app session forwards the bundle its surface is scoped to', async () => { mockHelper.mockResolvedValue({ action: 'dismiss' }); From 5a24fd33701e500cf9a29adf7f09b4bde55dbdd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 22:28:17 +0000 Subject: [PATCH 07/16] fix(daemon): address adversarial review of the Wave 6 cutovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent reviews (behavior parity, correctness, ADR 0019 conformance) ran against the branch. What they found, and what changed: Correctness - The R48 retry seam was unreachable. `captureSnapshot` builds it from the request's runtime bindings, but no caller forwarded them, so every retry resolved to a skip. The `snapshot` route now threads `inspectFacts`/ `bindDevice` through `createSnapshotRuntime` and the daemon snapshot backend down to the capture. - A retry tap that rejected escaped the capture it was decorating and turned a plain `snapshot` into an error. It is caught and reported as a skip, matching what the seam's own contract already claimed. - The attempt is spent before the device work again, as the retired route did, so an owner that fails mid-flight cannot be re-attempted from a full budget. - `react-native dismiss-overlay` reached its required `tapPoint` through `?.` and answered `dismissed: true` when the operation was absent. It refuses. - `factOwnedCapabilityAvailable` indexed the facts map unguarded, and treated an empty `required` as proof (`[].every` is vacuously true). Both fail closed. ADR 0019 conformance - §6 forbids a `none` descriptor from binding a device, and `capabilities` bound three times to answer `logs`/`network`/`record`. Every owner composes a binding's facts with the same function `inspectFacts` calls, so those probes read back values the single inspection already carries — at the cost of a device claim on a read-only query. They are gone, and with them the last three empty-`required` admission uses. - §9 is one admission per handler; the retry tap re-admitted on every retry round. It memoizes per device. - `installFamilyCapabilityAvailable` was scaffolding this wave was scheduled to retire: the general projection returns the same verdict for all four install-family commands. Deleted. Leftovers the cutovers created - `requireCommandSupported` lost its last production caller when R56 migrated `app-switcher`: every generic-route command is admitted from owner facts before the dispatcher runs. The dead arm, the function, and `commandUsesDeviceRuntimeExecution` are removed. - `CommandDispatchFacet`, `descriptor.dispatch`, and `explain`'s `dispatch=` field described a dispatcher R58 deleted. - `request-router-android-modal.test.ts` asserted on a `dispatchCommand` mock whose module export no longer exists, so three assertions were vacuous. - `generic-route-runtime-completeness.test.ts` now exists — a comment claimed it did. It pins the routing table as total over the generic route. - Comments and test names describing the retired dispatcher, the deleted AppleOS capability table, and a react-native regression that never shipped. Also records two deliberate provider cell changes the migration made (physical Apple `clipboard` admitted, provider `alert` refused) and the react-native widening to Linux, web and HarmonyOS, and drops a scratch probe file that was committed by accident. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- .tmp-probe/probe.ts | 7 -- .../src/platform-runtime.ts | 11 ++ scripts/layering/daemon-modularity.test.ts | 8 +- scripts/layering/daemon-modularity.ts | 4 +- .../apple-capability-closure-parity.test.ts | 9 +- .../__tests__/command-explain.test.ts | 1 - src/commands/command-explain.ts | 7 +- src/core/__tests__/capabilities.test.ts | 23 ++-- .../__tests__/parity.test.ts | 46 -------- src/core/command-descriptor/registry.ts | 17 +-- src/core/command-descriptor/types.ts | 9 -- .../deferred-interaction-outcome.test.ts | 38 ++++++- ...generic-route-runtime-completeness.test.ts | 62 +++++++++++ .../__tests__/interaction-retry-tap.test.ts | 31 ++++++ .../request-router-android-modal.test.ts | 30 +---- ...on-capabilities-install-projection.test.ts | 26 ++--- .../session-capabilities.fixtures.ts | 6 - .../__tests__/session-capabilities.test.ts | 16 +-- src/daemon/handlers/react-native.ts | 18 ++- src/daemon/handlers/response.ts | 26 ----- ...sion-install-capability-projection.test.ts | 53 --------- .../session-install-capability-projection.ts | 62 ----------- src/daemon/handlers/session-inventory.ts | 75 ++++++------- src/daemon/interaction-outcome-policy.ts | 103 ++++++++++++------ src/daemon/interaction-retry-tap.ts | 24 ++-- src/daemon/request-generic-dispatch.ts | 17 +-- src/daemon/request-handler-chain.ts | 7 +- src/daemon/snapshot-command-runtime.ts | 53 +++++---- src/platforms/apple/plugin.ts | 21 ++-- 29 files changed, 380 insertions(+), 430 deletions(-) delete mode 100644 .tmp-probe/probe.ts create mode 100644 src/daemon/__tests__/generic-route-runtime-completeness.test.ts delete mode 100644 src/daemon/handlers/session-install-capability-projection.test.ts delete mode 100644 src/daemon/handlers/session-install-capability-projection.ts diff --git a/.tmp-probe/probe.ts b/.tmp-probe/probe.ts deleted file mode 100644 index 439e5454e3..0000000000 --- a/.tmp-probe/probe.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { listCapabilityCommands, commandRuntimeUseRequirements } from '../src/core/capabilities.ts'; -const rows: string[] = []; -for (const c of listCapabilityCommands()) { - const r = commandRuntimeUseRequirements(c); - rows.push(`${c}: ${r === undefined ? 'NOT-FACT-OWNED' : JSON.stringify(r)}`); -} -console.log(rows.join('\n')); diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 9784b7691d..0a77e61736 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -595,6 +595,12 @@ function webDriverFacts( }), // Clipboard rides the same reachable interactor `back`/`home` do; the declared-capability // gate stays inside the interactor, where it already lives. + // + // R55 cell delta, deliberate: the retired `supportsHostOrSimulatorSurface` closure refused + // `clipboard` on every provider-owned physical Apple device, because it was a LOCAL-Apple + // predicate (host helper or simulator) being applied to a device this provider drives over + // Appium — which does expose the clipboard extension. The refusal moves to where it can be + // true: the interactor, per session. ...clipboardRuntimeOperationFacts({ read: interactorCell(reachable, clipboardUnavailable), write: interactorCell(reachable, clipboardUnavailable), @@ -608,6 +614,11 @@ function webDriverFacts( triggerAppEvent: interactorCell(reachable, appEventUnavailable), }), ...settingsRuntimeOperationFacts({ setSetting: settingsUnavailable }), + // R59 cell delta, deliberate: the retired `supportsAlertSurface` closure ADMITTED `alert` on + // a provider-owned physical iOS device (it keyed on `appleOs === 'ios'` alone), and the + // handler then drove the LOCAL XCTest runner against a device living in someone else's + // cloud. Nothing this provider owns can serve an alert leg, so it states that up front + // instead of admitting and failing mid-execution (ADR 0019 §2). ...alertRuntimeOperationFacts({ read: alertUnavailable, wait: alertUnavailable, diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 0c61107fd7..2a7a5a3d02 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -73,8 +73,8 @@ test('daemon modularity baseline records the measured R7 ownership pressure', () Object.values(SESSION_STATE_FIELD_OWNERS).reduce((sum, owners) => sum + owners.length, 0), DAEMON_MODULARITY_BASELINE.sessionState.ownerFileClaims, ); - assert.equal(TYPE_CYCLE_BASELINE, 21); - assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 12); + assert.equal(TYPE_CYCLE_BASELINE, 20); + assert.equal(DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers['daemon-server'], 11); assert.equal('daemon' in DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers, false); }); @@ -239,7 +239,7 @@ test('R10 zone overflow lists the whole zone so the joining member is visible', const [violation] = violations; assert.equal(violation!.rule, 'R10 daemon-modularity'); assert.equal(violation!.file, 'scripts/layering/daemon-modularity.ts'); - assert.match(violation!.message, /contains 13 daemon-server file\(s\) \(baseline 12\)/); + assert.match(violation!.message, /contains 12 daemon-server file\(s\) \(baseline 11\)/); for (const member of daemonMembers) { assert.ok(violation!.message.includes(member), `${member} missing from: ${violation!.message}`); } @@ -257,6 +257,6 @@ test('R9 rejects a baseline left above the measured cycle', () => { assert.equal(violations.length, 1); assert.match(violations[0]!.rule, /^R9 /); - assert.match(violations[0]!.message, /dropped to 20 files \(baseline 21\)/); + assert.match(violations[0]!.message, /dropped to 19 files \(baseline 20\)/); assert.match(violations[0]!.message, /Lower LARGEST_TYPE_CYCLE_ZONE_CEILINGS by the same 1/); }); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index a7537c6314..770ad65f5b 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -10,7 +10,9 @@ const LARGEST_TYPE_CYCLE_ZONE_CEILINGS: Readonly> = { // Same move, daemon side: with no dispatcher to re-fire a tap through, the pending-outcome // retry declares its own callback seam instead of importing runtime admission, so // `interaction-outcome-policy.ts` and `deferred-interaction-outcome.ts` both left the cycle. - 'daemon-server': 12, + // R63 then deleted `session-install-capability-projection.ts` outright — the general + // fact-owned projection subsumes it — taking a third member with it. + 'daemon-server': 11, // R42/R43/R45 deleted `vega/plugin.ts`'s `PUBLIC_COMMANDS` import (the retired // back/home/tv-remote closures were its only consumer), dropping it out of the cycle and // leaving `apple/plugin.ts` as the platforms zone's sole remaining member. diff --git a/src/__tests__/contracts/apple-capability-closure-parity.test.ts b/src/__tests__/contracts/apple-capability-closure-parity.test.ts index 5482c32c13..7410246ca5 100644 --- a/src/__tests__/contracts/apple-capability-closure-parity.test.ts +++ b/src/__tests__/contracts/apple-capability-closure-parity.test.ts @@ -36,9 +36,8 @@ import { registerBuiltinPlatformPlugins } from '../../core/interactors/register- registerBuiltinPlatformPlugins(); // --------------------------------------------------------------------------- -// Independent copies of the command capability contracts, including the original -// AppleOS predicates and current backend-specific gates. This oracle stays independent -// of the table it pins (mirrors capability-plugin-routing-parity.test.ts). +// Independent copies of the command capability contracts. This oracle stays independent of the +// closures it pins (mirrors capability-plugin-routing-parity.test.ts). // --------------------------------------------------------------------------- const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => device.platform !== 'apple' || @@ -128,7 +127,7 @@ test('resolveDeviceAppleOs prefers the stored discriminant, else infers from tar assert.equal(resolveDeviceAppleOs(MACOS_DEVICE), 'macos'); }); -test('table-driven Apple supports() closures match the independent command contracts', () => { +test('Apple supports() closures match the independent command contracts', () => { const appleSupports = getPlugin('apple').capability.supportsByDefault; assert.ok(appleSupports, 'the Apple plugin carries supportsByDefault'); // Every command that had an original predicate must still carry one, keyed the same. @@ -146,7 +145,7 @@ test('table-driven Apple supports() closures match the independent command contr } }); -test('table-driven Apple unsupportedHint() closures match the independent contracts', () => { +test('Apple unsupportedHint() closures match the independent contracts', () => { const appleHints = getPlugin('apple').capability.unsupportedHintByDefault; assert.ok(appleHints, 'the Apple plugin carries unsupportedHintByDefault'); assert.deepEqual(Object.keys(appleHints).sort(), Object.keys(HINT_REF).sort()); diff --git a/src/commands/__tests__/command-explain.test.ts b/src/commands/__tests__/command-explain.test.ts index 43706f9c82..b9deac2f68 100644 --- a/src/commands/__tests__/command-explain.test.ts +++ b/src/commands/__tests__/command-explain.test.ts @@ -72,7 +72,6 @@ describe('explainCommand', () => { exposure: { batchable: true, mcp: true, - dispatch: false, postActionObservation: 'settle-and-verify', }, cli: { usage: 'click ' }, diff --git a/src/commands/command-explain.ts b/src/commands/command-explain.ts index 9fab6fe4a0..103a107b27 100644 --- a/src/commands/command-explain.ts +++ b/src/commands/command-explain.ts @@ -40,7 +40,6 @@ export type CommandExplanation = { exposure: { batchable: boolean; mcp: boolean; - dispatch: boolean; postActionObservation?: string; }; timeout: { @@ -126,7 +125,6 @@ function buildCommandExplanation( family?.name, 'daemon' in descriptor ? descriptor.daemon?.route : undefined, Boolean('capability' in descriptor && descriptor.capability), - Boolean('dispatch' in descriptor && descriptor.dispatch), fileExists, daemonRouteOwnerFiles, ), @@ -146,7 +144,7 @@ export function formatCommandExplanation( explanation.daemon ? `daemon: ${explanation.daemon.route}${explanation.daemon.traits.length ? ` (${explanation.daemon.traits.join(', ')})` : ''}` : 'daemon: none', - `exposure: batch=${yesNo(explanation.exposure.batchable)}, mcp=${yesNo(explanation.exposure.mcp)}, dispatch=${yesNo(explanation.exposure.dispatch)}${explanation.exposure.postActionObservation ? `, observe=${explanation.exposure.postActionObservation}` : ''}`, + `exposure: batch=${yesNo(explanation.exposure.batchable)}, mcp=${yesNo(explanation.exposure.mcp)}${explanation.exposure.postActionObservation ? `, observe=${explanation.exposure.postActionObservation}` : ''}`, `timeout: envelope=${formatEnvelope(explanation.timeout.envelopeMs)}, on-timeout=${explanation.timeout.onTimeout}, budget=${explanation.timeout.budget}`, ]; if (explanation.capability) { @@ -229,7 +227,6 @@ function describeCommandExposure(descriptor: CommandDescriptor): CommandExplanat return { batchable: descriptor.batchable, mcp: descriptor.mcpExposed, - dispatch: 'dispatch' in descriptor && descriptor.dispatch !== undefined, ...('postActionObservation' in descriptor && descriptor.postActionObservation ? { postActionObservation: descriptor.postActionObservation } : {}), @@ -320,7 +317,6 @@ function commandFiles( family: string | undefined, daemonRoute: DaemonCommandRoute | undefined, hasCapability: boolean, - hasDispatch: boolean, fileExists: FileExists | undefined, daemonRouteOwnerFiles: Readonly>, ): string[] { @@ -336,7 +332,6 @@ function commandFiles( derived.push('src/cli-schema/command-overrides.ts'); } if (daemonRoute) derived.push(daemonRouteOwnerFiles[daemonRoute]); - if (hasDispatch) derived.push('src/core/dispatch.ts'); if (hasCapability) derived.push('src/core/capabilities.ts'); const present = fileExists ? opportunistic.filter(fileExists) : opportunistic; return [...new Set([...derived, ...ownerFiles, ...present])]; diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 7c074447b1..74ef80f7ab 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -224,9 +224,14 @@ test('tvOS follows iOS capability matrix by device kind', () => { ); }); -test('Linux supports desktop interaction commands and blocks mobile/unsupported ones', () => { +test('the residual capability matrix leaves Linux desktop interaction admitted', () => { // Runtime-backed network admission is proven from operation facts in - // session-capabilities.test.ts, not through this legacy matrix projection. + // session-capabilities.test.ts, not through this legacy matrix projection. `alert` (R59), + // `clipboard` (R55), `settings` (R58) and `trigger-app-event` (R57) sit in the admitted list for + // the same reason the already-migrated commands do: a command whose admission comes from exact + // owner facts carries no capability-matrix row, and a command with no row is not decided by this + // matrix at all. `platform-linux/src/runtime.test.ts` is where the real Linux cells are pinned — + // and it is the one that refuses the mobile-only legs this test used to list. assertCommandSupport( [ 'alert', @@ -262,12 +267,14 @@ test('web supports only the initial browser interaction slice', () => { 'fill', 'focus', 'find', - // `clipboard` (R55), `gesture` and `swipe` (R42/R44), and `alert` (R59) join the migrated - // commands here for the same reason `focus`, `find`, `screenshot`, `scroll`, `snapshot`, - // `type` and `wait` already do: a command whose admission comes from exact owner facts - // carries no capability-matrix row, and a command with no row is not decided by this matrix - // at all. The web owner refuses every gesture tier, both clipboard halves and all four - // alert legs — `platform-web/src/runtime.test.ts` is where those cells are pinned. + // `gesture`/`swipe` (R42/R44), `clipboard` (R55), `app-switcher` (R56), + // `trigger-app-event` (R57), `settings` (R58) and `alert` (R59) join the migrated commands + // here for the same reason `focus`, `find`, `screenshot`, `scroll`, `snapshot`, `type` and + // `wait` already do: a command whose admission comes from exact owner facts carries no + // capability-matrix row, and a command with no row is not decided by this matrix at all. The + // web owner refuses every gesture tier, both clipboard halves, the springboard, the app-event + // leg, every setting and all four alert legs — `platform-web/src/runtime.test.ts` is where + // those cells are pinned. 'alert', 'app-switcher', 'clipboard', diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 0d1e0a2625..ed1727d208 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -130,10 +130,6 @@ function isDescriptorOnlyCommand(descriptor: TestCommandDescriptor): boolean { return !hasDaemonFacet(descriptor) && !hasCapabilityFacet(descriptor) && !descriptor.batchable; } -function readDaemonRouteForTest(descriptor: TestCommandDescriptor): string | undefined { - return 'daemon' in descriptor ? descriptor.daemon?.route : undefined; -} - test('derived daemon registry holds its routing invariants', () => { // The daemon registry is now BUILT from these derived descriptors (the // hand-authored literal was deleted after #906 proved byte-equality), so a @@ -204,48 +200,6 @@ test('descriptor-only commands explicitly declare a non-public catalog group', ( } }); -// R58 retired the legacy platform dispatcher itself: `settings` was its last arm, so no -// descriptor projects into it any more. The successor property is one-sided and total — the -// `dispatch` facet is vestigial vocabulary, and a descriptor that grows one again would be -// declaring a dispatcher that no longer exists. -test('no descriptor projects into the retired platform dispatcher', () => { - const dispatchCommands = commandDescriptors - .filter((descriptor) => 'dispatch' in descriptor && descriptor.dispatch !== undefined) - .map((descriptor) => descriptor.name) - .sort(); - - assert.deepEqual(dispatchCommands, []); -}); - -test('generic route commands that reach platform dispatch declare the dispatch facet', () => { - const nonDispatchGenericCommands = new Set([ - PUBLIC_COMMANDS.gesture, - PUBLIC_COMMANDS.focus, - PUBLIC_COMMANDS.screenshot, - // R43 retired scroll's dispatch leaf with its capability bucket: the bound - // `scrollDirection` operation is its only execution. - PUBLIC_COMMANDS.scroll, - PUBLIC_COMMANDS.viewport, - PUBLIC_COMMANDS.back, - PUBLIC_COMMANDS.home, - PUBLIC_COMMANDS.orientation, - PUBLIC_COMMANDS.tvRemote, - // R56 retired app-switcher's dispatch leaf with its capability bucket: the bound - // `appSwitcher` operation is its only execution. - PUBLIC_COMMANDS.appSwitcher, - ]); - - for (const descriptor of commandDescriptors) { - const route = readDaemonRouteForTest(descriptor); - if (route !== 'generic' || nonDispatchGenericCommands.has(descriptor.name)) continue; - - assert.ok( - 'dispatch' in descriptor && descriptor.dispatch !== undefined, - `${descriptor.name} declares dispatch coverage`, - ); - } -}); - test('capability matrix holds its admission invariants', () => { // BASE_COMMAND_CAPABILITY_MATRIX is now BUILT from these derived descriptors // (the hand-authored literal was deleted after #906 proved byte-equality, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c5064374fe..52ebc5f334 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -116,9 +116,8 @@ export type DescriptorCatalogRecord = { /** * The literal union of every command whose `daemon.route` is `'session'`. - * Drives `SESSION_COMMAND_HANDLER_IMPLS` in `src/daemon/handlers/session.ts` - * : adding a session-routed - * descriptor without a matching handler table entry is a compile error rather + * Drives `SESSION_COMMAND_HANDLER_IMPLS` in `src/daemon/handlers/session.ts`: adding a + * session-routed descriptor without a matching handler table entry is a compile error rather * than a runtime routing gap caught only by `expectHandlerResponse`. */ export type DescriptorSessionRouteCommandName = @@ -1706,18 +1705,6 @@ export function commandSupportsVerifyEvidence(command: string | undefined): bool return resolveCommandPostActionObservationSupport(command) === 'settle-and-verify'; } -/** - * Whether a command's platform behavior comes from a request-bound device runtime (ADR 0019). - * Admission for those commands is the owner's exact operation facts, so a route must never also - * consult a capability bucket for them — and a migrated command has no bucket to consult. Reading - * the discriminator here rather than naming commands at each route means the next unit's - * descriptor flip is the whole change. - */ -export function commandUsesDeviceRuntimeExecution(command: string | undefined): boolean { - if (command === undefined) return false; - return COMMAND_DESCRIPTOR_BY_NAME.get(command)?.platformExecution.kind === 'device-runtime'; -} - /** * The declared timeout policy for a command (ADR 0008). Command names outside * the registry (internal probes, unknown commands) fall back to diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index 1be0713408..97c673255a 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -136,14 +136,6 @@ export type CommandCatalogFacet = { key?: string; }; -export type CommandDispatchFacet = { - /** - * Platform dispatch command handled by src/core/dispatch.ts. The descriptor - * name is the dispatched command; dispatch-only names such as `read` are - * modeled as named descriptors rather than hidden aliases. - */ -} & Record; - /** * ADR 0016: whether a recorded request changes app-visible state or only * observes it. The resolver form keeps subcommand-sensitive decisions on the @@ -220,7 +212,6 @@ type CommandDescriptorBase = { catalog: CommandCatalogFacet; /** Required iff `catalog.group === 'public'`; see {@link CommandFrameworkTier}. */ frameworkTier?: CommandFrameworkTier; - dispatch?: CommandDispatchFacet; /** Internal-only ADR 0019 cutover discriminant; public projections must ignore it. */ platformExecution: CommandPlatformExecution; /** ADR 0012 / #1349: present iff this command's recorded steps can carry `target-v1` evidence. */ diff --git a/src/daemon/__tests__/deferred-interaction-outcome.test.ts b/src/daemon/__tests__/deferred-interaction-outcome.test.ts index 3ba6f57746..e588f52f04 100644 --- a/src/daemon/__tests__/deferred-interaction-outcome.test.ts +++ b/src/daemon/__tests__/deferred-interaction-outcome.test.ts @@ -212,11 +212,47 @@ test('a capture route with no retry seam reports a skip instead of spending an a assert.ok(observed.result?.snapshot); assert.equal(retryTap.mock.calls.length, 0); - // One skip, no retry: nothing was delivered, so no attempt was spent on a refusal. + // One skip, no retry: nothing was attempted, so no attempt was spent. The emitted reason + // (`device-runtime-unavailable`) is what separates this from a delivered tap the owner refused — + // a route that stops forwarding its bindings lands here, not there. assert.equal(observed.skips, 1); assert.equal(observed.retries, 0); }); +// A retry that dies on the device is the seam's problem, not the caller's: the capture it was +// decorating still has to answer with the unchanged surface it observed. +test('a retry tap that throws is reported as a skip rather than failing the capture', async () => { + const session = makeSession('android'); + session.snapshot = pickupSnapshot(); + markDeferredInteractionOutcome({ + session, + command: 'click', + positionals: ['100', '200'], + flags: { interactionOutcome: { retryOnNoChange: true } }, + scheduleOutcomeRetry: true, + }); + const attemptsBefore = session.pendingInteractionOutcome?.attemptsRemaining; + const { capture } = scriptedCapture([pickupSnapshot()]); + retryTap.mockRejectedValueOnce(new Error('adb: device offline')); + + const observed = await withDiagnosticsScope({}, async () => { + const result = await resolveDeferredInteractionOutcome(resolveParams(session, capture)); + return { + result, + skips: countDiagnosticEventsByPhase(['interaction_no_change_retry_skipped']), + retries: countDiagnosticEventsByPhase(['interaction_no_change_retry']), + }; + }); + + assert.ok(observed.result?.snapshot); + assert.equal(observed.skips, 1); + assert.equal(observed.retries, 0); + // The attempt is spent before the device work, so a failing owner cannot be re-attempted from a + // full budget by the next capture inside the pending window. + assert.equal(attemptsBefore, 2); + assert.equal(session.pendingInteractionOutcome, undefined); +}); + test('a pending stabilization resolves through the quiet-window loop and clears itself', async () => { vi.useFakeTimers(); const session = makeSession('android'); diff --git a/src/daemon/__tests__/generic-route-runtime-completeness.test.ts b/src/daemon/__tests__/generic-route-runtime-completeness.test.ts new file mode 100644 index 0000000000..5be375696a --- /dev/null +++ b/src/daemon/__tests__/generic-route-runtime-completeness.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'vitest'; +import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; +import { resolveGenericRuntimeExecution } from '../generic-runtime-execution.ts'; + +/** + * The generic route is the router's fallthrough: a descriptor joins it by declaring + * `daemon.route: 'generic'`, not by being listed anywhere the dispatcher can read. R56 migrated + * `app-switcher`, its last legacy leaf, so `resolveGenericRuntimeExecution` is now the route's + * only execution path and `ensureGenericCommandReady` no longer carries a support gate behind it. + * That totality is what these tests pin: a descriptor that joins the route without an arm would + * otherwise reach the dispatcher through a `default` that refuses at request time. + */ +type RegistryDescriptor = (typeof commandDescriptors)[number]; + +function genericRouteDescriptors(): RegistryDescriptor[] { + return commandDescriptors + .filter( + (descriptor) => ('daemon' in descriptor ? descriptor.daemon?.route : undefined) === 'generic', + ) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +test('every generic-route descriptor is runtime-owned', () => { + const legacy = genericRouteDescriptors() + .filter((descriptor) => descriptor.platformExecution.kind !== 'device-runtime') + .map((descriptor) => descriptor.name); + + expect(legacy).toEqual([]); +}); + +async function reachesGenericRoutingGap(command: string): Promise { + // Every arm reaches admission before it can answer, and admission with no bindings throws — so a + // throw proves the arm exists. Only the `default` answers, and it answers with the routing gap. + try { + const resolved = await resolveGenericRuntimeExecution({ + req: { command, token: '', positionals: [], flags: {} }, + session: { device: { id: 'device-1', platform: 'android', kind: 'device' } }, + context: { logPath: '' }, + } as unknown as Parameters[0]); + return ( + !resolved.ok && + resolved.response.ok === false && + resolved.response.error.details?.['reason'] === 'generic-route-runtime-missing' + ); + } catch { + return false; + } +} + +test('every generic-route descriptor has an arm in the runtime execution table', async () => { + const missing: string[] = []; + for (const descriptor of genericRouteDescriptors()) { + if (await reachesGenericRoutingGap(descriptor.name)) missing.push(descriptor.name); + } + + expect(missing).toEqual([]); +}); + +test('the routing gap is what an unrouted command actually reaches', async () => { + // Without this the test above would pass for a table that had no arms at all. + expect(await reachesGenericRoutingGap('not-a-generic-route-command')).toBe(true); +}); diff --git a/src/daemon/__tests__/interaction-retry-tap.test.ts b/src/daemon/__tests__/interaction-retry-tap.test.ts index 0ed73cf5fe..543f56bf79 100644 --- a/src/daemon/__tests__/interaction-retry-tap.test.ts +++ b/src/daemon/__tests__/interaction-retry-tap.test.ts @@ -81,6 +81,37 @@ test('the seam re-fires the recorded point through the owner-bound tapPoint', as assert.deepEqual(taps[0]?.point, { x: 100, y: 200 }); }); +// ADR 0019 §9 is one admission per handler, and the outcome policy calls this seam once per retry +// round. The binding underneath is request-cached, so what memoization saves is the repeated facts +// inspection — and what it pins is that the seam admits once, not once per round. +test('the seam admits once no matter how many rounds the policy runs', async () => { + const taps: TapPointInput[] = []; + const admission = bindings(true, taps); + let inspections = 0; + const retryTap = createInteractionRetryTap({ + inspectFacts: async (target) => { + inspections += 1; + return await admission.inspectFacts(target); + }, + bindDevice: admission.bindDevice, + }); + assert.ok(retryTap); + + const context = { logPath: '/tmp/daemon.log', requestId: 'retry-1' }; + await retryTap({ device, point: { x: 10, y: 20 }, context }); + await retryTap({ device, point: { x: 30, y: 40 }, context }); + + assert.equal(inspections, 1); + assert.equal(taps.length, 2); + assert.deepEqual( + taps.map((tap) => tap.point), + [ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ], + ); +}); + // The policy spends an attempt only on a delivered tap, so a cell that cannot tap must answer // `false` here rather than throwing out of the capture the retry was decorating. test('an owner cell that cannot tap answers false instead of throwing', async () => { diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index 761e62ae0a..b6e699db2a 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -6,20 +6,7 @@ import { AppError } from '@agent-device/kernel/errors'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; let snapshotCalls = 0; -const dispatchCalls: string[][] = []; let snapshotMode: 'blocking-dialog' | 'throws' = 'blocking-dialog'; -let dispatchResult: Record = {}; - -vi.mock('../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async (_device: unknown, command: string, positionals: string[]) => { - dispatchCalls.push([command, ...positionals]); - return dispatchResult; - }), - }; -}); import { createRequestHandler, @@ -139,9 +126,7 @@ function makeAndroidSession(name: string): SessionState { test('generic Android gesture commands dismiss blocking system dialogs during recording', async () => { snapshotCalls = 0; snapshotMode = 'blocking-dialog'; - dispatchResult = {}; execCalls.length = 0; - dispatchCalls.length = 0; gestureRuntimeSpies.scrollDirection.mockClear(); const sessionStore = makeSessionStore('agent-device-router-android-modal-'); @@ -168,8 +153,8 @@ test('generic Android gesture commands dismiss blocking system dialogs during re }); expect(response.ok).toBe(true); - // R43: `scroll` reaches the device through its bound operation, so the dispatcher sees nothing. - expect(dispatchCalls).toEqual([]); + // R43: `scroll` reaches the device through its bound operation, and R58 retired the dispatcher + // it used to reach — the bound call count below is the whole execution record. expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection.mock.calls[0]?.[0]).toMatchObject({ direction: 'down', @@ -187,7 +172,6 @@ test('generic Android gesture commands continue when recording dialog inspection snapshotCalls = 0; snapshotMode = 'throws'; execCalls.length = 0; - dispatchCalls.length = 0; gestureRuntimeSpies.scrollDirection.mockClear(); // The owner's own result is what the readiness warning has to merge with, so the bound // operation carries it now that the dispatcher no longer executes this command. @@ -220,8 +204,8 @@ test('generic Android gesture commands continue when recording dialog inspection }); expect(response.ok).toBe(true); - // R43: `scroll` reaches the device through its bound operation, so the dispatcher sees nothing. - expect(dispatchCalls).toEqual([]); + // R43: `scroll` reaches the device through its bound operation, and R58 retired the dispatcher + // it used to reach — the bound call count below is the whole execution record. expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection.mock.calls[0]?.[0]).toMatchObject({ direction: 'down', @@ -242,9 +226,7 @@ test('generic Android gesture commands continue when recording dialog inspection test('generic Android gesture commands skip local dialog recovery for provider devices', async () => { snapshotCalls = 0; snapshotMode = 'blocking-dialog'; - dispatchResult = {}; execCalls.length = 0; - dispatchCalls.length = 0; gestureRuntimeSpies.scrollDirection.mockClear(); const sessionStore = makeSessionStore('agent-device-router-android-modal-provider-'); @@ -281,8 +263,8 @@ test('generic Android gesture commands skip local dialog recovery for provider d }); expect(response.ok).toBe(true); - // R43: `scroll` reaches the device through its bound operation, so the dispatcher sees nothing. - expect(dispatchCalls).toEqual([]); + // R43: `scroll` reaches the device through its bound operation, and R58 retired the dispatcher + // it used to reach — the bound call count below is the whole execution record. expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection.mock.calls[0]?.[0]).toMatchObject({ direction: 'down', diff --git a/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts b/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts index 8aa3f2b3e8..8ef82309ba 100644 --- a/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities-install-projection.test.ts @@ -8,10 +8,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../request-runtime-binding.ts'; -import { - createCapabilitiesAdmissionRuntime, - legacyCapabilityUses, -} from './session-capabilities.fixtures.ts'; +import { createCapabilitiesAdmissionRuntime } from './session-capabilities.fixtures.ts'; import { handleSessionCommands } from './session-command-harness.ts'; test('capabilities projects the install family from exactly one facts inspection', async () => { @@ -44,10 +41,12 @@ test('capabilities projects the install family from exactly one facts inspection ]), ); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual(legacyCapabilityUses); + // ADR 0019 §6: `capabilities` declares `platformExecution: none`, so it binds nothing. R63 + // retired the three empty-`required` probes that used to answer `logs`/`network`/`record`. + expect(runtime.uses).toEqual([]); }); -test('capabilities keeps legacy runtime commands outside the install-family facts projection', async () => { +test('capabilities projects every fact-owned command from that same inspection', async () => { const { sessionName, sessionStore } = createAndroidCapabilitiesSession('legacy-runtime'); const runtime = createCapabilitiesAdmissionRuntime({ appLogAvailable: true, @@ -73,10 +72,10 @@ test('capabilities keeps legacy runtime commands outside the install-family fact // this test previously pinned the opposite, because only the install family consumed facts. expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.boot); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual(legacyCapabilityUses); + expect(runtime.uses).toEqual([]); }); -test('capabilities fails closed only for install-family projection when facts inspection fails', async () => { +test('capabilities fails closed for every fact-owned command when facts inspection fails', async () => { const { sessionName, sessionStore } = createAndroidCapabilitiesSession('facts-failure'); const runtime = createCapabilitiesAdmissionRuntime({ appLogAvailable: true, @@ -95,16 +94,17 @@ test('capabilities fails closed only for install-family projection when facts in expect(response).toMatchObject({ ok: true }); if (!response?.ok) return; - // R63: with no facts at all, every fact-owned command fails closed rather than being - // advertised on faith. `logs` survives because its availability is a BIND-time fact, and the - // binding here still succeeds. + // R63: with no facts at all, every fact-owned command fails closed rather than being advertised + // on faith — `logs` included. It used to survive on a second `bindDevice` probe, but every owner + // composes a binding's facts from the same inspection that just failed, so that probe was + // answering from a path that cannot outlive the inspection it duplicates. expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.boot); - expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.logs); + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.logs); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.install); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.reinstall); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.installFromSource); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.push); - expect(runtime.uses).toEqual(legacyCapabilityUses); + expect(runtime.uses).toEqual([]); }); function createAndroidCapabilitiesSession(suffix: string) { diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index 5ce84eca7a..cc8540de0f 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts @@ -35,12 +35,6 @@ export type CapabilitiesAdmissionRuntimeOptions = Readonly<{ screenshotAvailable?: boolean; }>; -export const legacyCapabilityUses = [ - { required: [], preferred: ['appLogInspect'] }, - { required: [], preferred: ['networkDump'] }, - { required: [], preferred: ['screenRecordingStart'] }, -]; - export function createCapabilitiesAdmissionRuntime(options: CapabilitiesAdmissionRuntimeOptions) { const uses: Array<{ required: readonly string[]; diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index e7f69b3c95..1563e9934c 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -99,11 +99,9 @@ test('capabilities reports supported commands for the selected session device', ]), ); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual([ - { required: [], preferred: ['appLogInspect'] }, - { required: [], preferred: ['networkDump'] }, - { required: [], preferred: ['screenRecordingStart'] }, - ]); + // ADR 0019 §6: `capabilities` is a `none` descriptor, so the projection binds no device — one + // side-effect-free inspection answers every command, `logs`/`network`/`record` included. + expect(runtime.uses).toEqual([]); }); test('capabilities omits the commands this Android owner does not admit', async () => { @@ -164,11 +162,9 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim expect(availableCommands).not.toContain(PUBLIC_COMMANDS.prepare); expect(availableCommands).not.toContain(PUBLIC_COMMANDS.shutdown); expect(runtime.inspections).toHaveLength(1); - expect(runtime.uses).toEqual([ - { required: [], preferred: ['appLogInspect'] }, - { required: [], preferred: ['networkDump'] }, - { required: [], preferred: ['screenRecordingStart'] }, - ]); + // ADR 0019 §6: `capabilities` is a `none` descriptor, so the projection binds no device — one + // side-effect-free inspection answers every command, `logs`/`network`/`record` included. + expect(runtime.uses).toEqual([]); }); test('capabilities excludes network when the runtime fact is unavailable', async () => { diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index ecc9f17f0a..e02ab5c0e4 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -33,6 +33,12 @@ export async function handleReactNativeCommands( // R61: admission is the owner's own `tapPoint` fact — the one operation this command executes. // It runs before the observing capture, exactly where the retired capability gate ran, so an // owner that cannot dismiss an overlay refuses without first spending a snapshot on it. + // + // Deliberate widening: the retired bucket was `{apple, android, linux: {}}`, so Linux desktop, + // web and HarmonyOS were refused by family. All three admit `tapPoint`, so all three now run — + // and answer `detected: false` on a surface with no React Native overlay, which is the truthful + // result. A family cannot be a support authority for a migrated command (ADR 0019 §8); the + // Linux and web coverage manifests record this, and HarmonyOS has no manifest to record it in. const bound = await resolveBoundTouchRuntime({ device: session.device, command: 'press', @@ -134,11 +140,21 @@ async function executeReactNativeOverlayDismiss( // binding is the caller's: admission happened before the overlay was even observed. const context = params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath); const executor = createBoundTouchExecutor(runtime, context); + // `tapPoint` is this command's one *required* operation, so admission already proved the cell. + // The executor still types every leg optional, and reaching it through `?.` would report + // `dismissed: true` for a dismissal that never touched the device. Refuse instead of lying. + const tapPoint = executor.tapPoint; + if (!tapPoint) { + return errorResponse( + 'UNSUPPORTED_OPERATION', + 'react-native dismiss-overlay is not supported on this device', + ); + } const actionStartedAt = Date.now(); // ADR 0014 side-effect seam: React Native overlay dismissal taps the device; // the target is already resolved, so expire the frame before the press. expireRefFrame(session); - const data = (await executor.tapPoint?.(target.point)) ?? {}; + const data = await tapPoint(target.point); const actionFinishedAt = Date.now(); const verification = await verifyReactNativeOverlayDismissal(params, session); const responseData = stripUndefined({ diff --git a/src/daemon/handlers/response.ts b/src/daemon/handlers/response.ts index 4cfdda7f3b..e18e757a99 100644 --- a/src/daemon/handlers/response.ts +++ b/src/daemon/handlers/response.ts @@ -1,5 +1,3 @@ -import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../../core/capabilities.ts'; -import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonResponse } from '../types.ts'; export type DaemonFailureResponse = Extract; @@ -31,27 +29,3 @@ export function errorResponse( export function noActiveSessionError(): DaemonFailureResponse { return errorResponse('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE); } - -/** - * Capability guard: returns an `UNSUPPORTED_OPERATION` failure when `command` is not supported on - * `device`, otherwise `null`. Pass `hint: true` to attach the device-specific unsupported hint. - * - * The `message` override left with R61: `react-native` was its only caller, and a migrated command - * states its own refusal through the admission seam's `unavailableResponse` instead. - */ -export function requireCommandSupported( - command: string, - device: DeviceInfo, - options?: { hint?: boolean }, -): DaemonFailureResponse | null { - if (isCommandSupportedOnDevice(command, device)) return null; - const hint = options?.hint ? unsupportedHintForDevice(command, device) : undefined; - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: `${command} is not supported on this device`, - ...(hint ? { hint } : {}), - }, - }; -} diff --git a/src/daemon/handlers/session-install-capability-projection.test.ts b/src/daemon/handlers/session-install-capability-projection.test.ts deleted file mode 100644 index 6f9e59f837..0000000000 --- a/src/daemon/handlers/session-install-capability-projection.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, test } from 'vitest'; -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; -import { installFamilyCapabilityAvailable } from './session-install-capability-projection.ts'; - -test('keeps the facts-only capability projection narrow to the install family', () => { - const projectedCommands = commandDescriptors - .filter((descriptor) => descriptor.catalog.group === 'public') - .map((descriptor) => descriptor.name) - .filter((command) => installFamilyCapabilityAvailable(command, undefined) !== undefined) - .sort(); - - expect(projectedCommands).toEqual( - [ - PUBLIC_COMMANDS.install, - PUBLIC_COMMANDS.reinstall, - PUBLIC_COMMANDS.installFromSource, - PUBLIC_COMMANDS.push, - ].sort(), - ); -}); - -test('guards the public source-install alias against descriptor-use drift', () => { - expect(runtimeUse(PUBLIC_COMMANDS.installFromSource)).toEqual({ - required: ['ensureReady', 'materializeAppSource', 'deployMaterializedApp'], - preferred: [], - }); - expect(runtimeUse(PUBLIC_COMMANDS.installFromSource)).toEqual( - runtimeUse(INTERNAL_COMMANDS.installSource), - ); - expect(runtimeUse(PUBLIC_COMMANDS.install)).toEqual({ - required: ['deployApp'], - preferred: [], - }); - expect(runtimeUse(PUBLIC_COMMANDS.reinstall)).toEqual({ - required: ['deployApp'], - preferred: [], - }); - expect(runtimeUse(PUBLIC_COMMANDS.push)).toEqual({ - required: ['ensureReady', 'sendPushNotification'], - preferred: [], - }); -}); - -function runtimeUse(command: string) { - const execution = commandDescriptors.find( - (descriptor) => descriptor.name === command, - )?.platformExecution; - if (execution?.kind !== 'device-runtime' || !('use' in execution)) { - throw new Error(`Expected ${command} to declare one exact device-runtime use`); - } - return execution.use; -} diff --git a/src/daemon/handlers/session-install-capability-projection.ts b/src/daemon/handlers/session-install-capability-projection.ts deleted file mode 100644 index cd299cd2fa..0000000000 --- a/src/daemon/handlers/session-install-capability-projection.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; -import type { - RuntimeOperationFact, - RuntimeUseDeclaration, -} from '@agent-device/contracts/platform-runtime'; -import type { InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; - -type InstallFamilyRuntimeUse = RuntimeUseDeclaration; -type DeviceRuntimeFacts = Awaited>; - -// This is deliberately a narrow transitional projection, not a second capability catalog. The -// public source-install command writes the internal install_source daemon route, so normalize it -// before reading its descriptor. The descriptor owns the required operations in both cases. -const INSTALL_FAMILY_PUBLIC_COMMANDS = new Set([ - PUBLIC_COMMANDS.install, - PUBLIC_COMMANDS.reinstall, - PUBLIC_COMMANDS.installFromSource, - PUBLIC_COMMANDS.push, -]); -const INSTALL_FAMILY_DAEMON_ALIASES = new Map([ - [PUBLIC_COMMANDS.installFromSource, INTERNAL_COMMANDS.installSource], -]); - -/** - * The legacy capabilities handler temporarily projects only this unit's four public - * commands from facts. Wave 6 owns the general capabilities cutover. - */ -export function installFamilyCapabilityAvailable( - command: string, - facts: Awaited> | undefined, -): boolean | undefined { - const use = installFamilyCapabilityUse(command); - if (!use) return undefined; - return ( - facts !== undefined && use.required.every((operation) => hasAvailableFact(facts, operation)) - ); -} - -function installFamilyCapabilityUse(command: string): InstallFamilyRuntimeUse | undefined { - const normalized = normalizeInstallFamilyDaemonCommand(command); - if (!normalized) return undefined; - const execution = commandDescriptors.find( - (descriptor) => descriptor.name === normalized, - )?.platformExecution; - if (execution?.kind !== 'device-runtime' || !('use' in execution)) return undefined; - return execution.use; -} - -function normalizeInstallFamilyDaemonCommand(command: string): string | undefined { - if (!INSTALL_FAMILY_PUBLIC_COMMANDS.has(command)) return undefined; - return INSTALL_FAMILY_DAEMON_ALIASES.get(command) ?? command; -} - -function hasAvailableFact(facts: DeviceRuntimeFacts, operation: string): boolean { - // Descriptor execution metadata is structurally validated as string arrays. Keep the projection - // fail-closed when a future descriptor names an operation that is absent from the concrete facts - // catalog instead of turning an arbitrary property lookup into capability support. - if (!Object.hasOwn(facts.operations, operation)) return false; - const fact = (facts.operations as Readonly>)[operation]; - return fact?.available === true; -} diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index fd270f8c19..939be014fe 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -29,21 +29,17 @@ import { } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts'; -import { appLogAdmissionUse } from '@agent-device/contracts/logs-runtime-plan'; -import { networkAdmissionUse } from '@agent-device/contracts/network-runtime-plan'; import type { BoundDeviceRuntime, RuntimeFacts, - RuntimeOperationKey, + RuntimeOperationFact, } from '@agent-device/contracts/platform-runtime'; import { type PlatformRuntimeOperations, appsRuntimeUse, } from '@agent-device/contracts/platform-runtime-operations'; -import { screenRecordingAdmissionUse } from '@agent-device/contracts/screen-recording-runtime-plan'; import { ensureAppsRuntimeReady, listAppsFromRuntime } from '../apps-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { installFamilyCapabilityAvailable } from './session-install-capability-projection.ts'; export async function handleSessionInventoryCommands(params: { req: DaemonRequest; @@ -64,7 +60,6 @@ export async function handleSessionInventoryCommands(params: { sessionName, sessionStore, inspectFacts: params.inspectFacts, - bindDevice: params.bindDevice, }); case 'apps': return await handleAppsInventory({ @@ -192,7 +187,6 @@ async function capabilitiesInventoryResponse(params: { sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; - bindDevice?: BindDeviceRuntime; }): Promise { const resolution = await resolveInventoryCommandDevice({ ...params, @@ -207,21 +201,13 @@ async function capabilitiesInventoryResponse(params: { params.req.flags, ); // Capability projection is admission-only: every fact-owned command reads this one - // side-effect-free exact-device snapshot, never a deleted descriptor capability bucket. + // side-effect-free exact-device snapshot, never a deleted descriptor capability bucket, and + // never a binding. R63 retired the three `bindDevice` probes that used to answer + // `logs`/`network`/`record`: every owner composes a binding's facts with the same function + // `inspectFacts` calls, so the probes read back values this snapshot already carries while + // costing a device claim on a read-only query. ADR 0019 §6 requires a `none` descriptor to bind + // nothing, and `capabilities` is one. const facts = await inspectCapabilityFacts(device, params.inspectFacts); - const [logsAvailable, networkAvailable, recordingAvailable] = params.bindDevice - ? await Promise.all([ - params - .bindDevice(device, appLogAdmissionUse) - .then((runtime) => runtime.facts.appLogInspect.available), - params - .bindDevice(device, networkAdmissionUse) - .then((runtime) => runtime.facts.networkDump.available), - params - .bindDevice(device, screenRecordingAdmissionUse) - .then((runtime) => runtime.facts.screenRecordingStart.available), - ]) - : [false, false, false]; return { ok: true, data: { @@ -230,17 +216,9 @@ async function capabilitiesInventoryResponse(params: { // A session that already owns an app identity answers appstate itself, so it stays // available even when the sessionless runtime probe cannot see a foreground app. if (command === 'appstate' && sessionOwnedAppStateAvailable) return true; - const installFamilyAvailable = installFamilyCapabilityAvailable(command, facts); - if (installFamilyAvailable !== undefined) return installFamilyAvailable; const factOwned = factOwnedCapabilityAvailable(command, facts); if (factOwned !== undefined) return factOwned; - return command === 'logs' - ? logsAvailable - : command === 'network' - ? networkAvailable - : command === 'record' - ? recordingAvailable - : isCommandSupportedOnDevice(command, device); + return isCommandSupportedOnDevice(command, device); }), }, }; @@ -273,13 +251,6 @@ function hasMacSessionSurface( ); } -/** - * The three durable-resource commands whose availability is a BIND-time fact, not an inspection - * one: their owner resolves an adopted resource while binding, so the inspected snapshot cannot - * answer for them. They are read through `bindDevice` above and skipped here. - */ -const BIND_ADMITTED_CAPABILITY_COMMANDS = new Set(['logs', 'network', 'record']); - /** * Whether the exact device admits any of `command`'s declared runtime uses, or `undefined` when * the command is not fact-owned at all. @@ -293,16 +264,32 @@ function factOwnedCapabilityAvailable( command: string, facts: RuntimeFacts | undefined, ): boolean | undefined { - if (BIND_ADMITTED_CAPABILITY_COMMANDS.has(command)) return undefined; const declaredUses = commandRuntimeUseRequirements(command); if (!declaredUses) return undefined; if (!facts) return false; - // A request names exactly one action, so one fully admitted use is enough. - return declaredUses.some((required) => - required.every( - (operation) => - facts.operations[operation as RuntimeOperationKey].available, - ), + // A request names exactly one action, so one fully admitted use is enough. An empty `required` + // is not one: `[].every` is vacuously true, and a plan that needs no operation must not read as + // proof that the device can run the command. + return declaredUses.some( + (required) => + required.length > 0 && required.every((operation) => hasAvailableFact(facts, operation)), + ); +} + +/** + * Descriptor execution metadata is structurally validated as string arrays, not as keys of the + * concrete facts catalog. Keep the projection fail-closed when a descriptor names an operation the + * inspected owner does not state, instead of letting an arbitrary property lookup throw out of the + * whole `capabilities` response. + */ +function hasAvailableFact( + facts: RuntimeFacts, + operation: string, +): boolean { + if (!Object.hasOwn(facts.operations, operation)) return false; + return ( + (facts.operations as Readonly>)[operation]?.available === + true ); } diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 38d23e06ab..020b2c65d9 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -96,7 +96,7 @@ export async function retryPendingInteractionOutcome(params: { snapshot: SnapshotState; retryTap?: InteractionRetryTap; }): Promise<{ retried: boolean; change: InteractionSurfaceChange }> { - const { session, pending, snapshot } = params; + const { pending, snapshot } = params; const change = classifyInteractionSurfaceChange( pending.preSignature, buildInteractionSurfaceSignature(snapshot.nodes), @@ -105,46 +105,30 @@ export async function retryPendingInteractionOutcome(params: { return { retried: false, change }; } - // The retry re-fires the same coordinate tap the original press used (R48). The attempt is - // spent only once the tap is actually delivered, so a capture path carrying no retry seam — or - // an owner whose cell cannot tap — leaves the pending record intact instead of burning an - // attempt on a refusal, and says so rather than throwing out of the capture it was decorating. - const point = params.retryTap ? readRetryPoint(pending.positionals) : undefined; + // The retry re-fires the same coordinate tap the original press used (R48). Nothing was + // attempted when this capture path carries no retry seam or the recorded coordinates are + // unreadable, so those leave the pending record whole instead of burning an attempt, and say so + // rather than letting the caller infer it from an unchanged surface. + const retryTap = params.retryTap; + const point = retryTap ? readRetryPoint(pending.positionals) : undefined; + if (!retryTap || !point) { + emitSkippedRetry(pending, retryTap ? 'unreadable-retry-point' : 'device-runtime-unavailable'); + return { retried: false, change }; + } + const startedAt = Date.now(); + // Spent before the device work, matching the retired route: an owner that refuses the tap or + // fails mid-flight has still consumed the attempt, so the next capture inside the pending + // window cannot re-attempt it from a full budget. + pending.attemptsRemaining -= 1; // Opt-in Maestro retries intentionally re-fire the same coordinate tap; delayed or // non-visual side effects can duplicate, but unchanged visual taps are the target gap. - const fired = - params.retryTap && point - ? await params.retryTap({ - device: session.device, - point, - context: { - ...contextFromFlags( - params.logPath, - pending.flags, - session.appBundleId, - session.trace?.outPath, - ), - surface: session.surface, - }, - }) - : false; + const fired = await fireRetryTap(retryTap, params); if (!fired) { - // Never silent: a retry the opt-in flag asked for and this request could not fire says so, - // rather than leaving the caller to infer it from an unchanged surface. - emitDiagnostic({ - level: 'info', - phase: 'interaction_no_change_retry_skipped', - data: { - action: pending.action, - attemptsRemaining: pending.attemptsRemaining, - reason: params.retryTap && !point ? 'unreadable-retry-point' : 'device-runtime-unavailable', - }, - }); + emitSkippedRetry(pending, 'retry-tap-unavailable'); return { retried: false, change }; } - pending.attemptsRemaining -= 1; emitDiagnostic({ level: 'info', phase: 'interaction_no_change_retry', @@ -157,6 +141,57 @@ export async function retryPendingInteractionOutcome(params: { return { retried: true, change }; } +/** + * The seam is allowed to refuse and allowed to fail; neither may escape into the capture this + * retry decorates. A press whose re-fire dies on the device leaves the caller with the honest + * unchanged surface plus a diagnostic, not a failed `snapshot`. + */ +async function fireRetryTap( + retryTap: InteractionRetryTap, + params: Readonly<{ + session: SessionState; + pending: NonNullable; + logPath: string; + }>, +): Promise { + const { session, pending } = params; + const point = readRetryPoint(pending.positionals); + if (!point) return false; + try { + return await retryTap({ + device: session.device, + point, + context: { + ...contextFromFlags( + params.logPath, + pending.flags, + session.appBundleId, + session.trace?.outPath, + ), + surface: session.surface, + }, + }); + } catch { + return false; + } +} + +/** Never silent: a retry the opt-in flag asked for and this request did not deliver says why. */ +function emitSkippedRetry( + pending: NonNullable, + reason: 'device-runtime-unavailable' | 'unreadable-retry-point' | 'retry-tap-unavailable', +): void { + emitDiagnostic({ + level: 'info', + phase: 'interaction_no_change_retry_skipped', + data: { + action: pending.action, + attemptsRemaining: pending.attemptsRemaining, + reason, + }, + }); +} + export function emitInteractionSettled(params: { pending: NonNullable; change: InteractionSurfaceChange; diff --git a/src/daemon/interaction-retry-tap.ts b/src/daemon/interaction-retry-tap.ts index e4a6f29a14..95231b65c7 100644 --- a/src/daemon/interaction-retry-tap.ts +++ b/src/daemon/interaction-retry-tap.ts @@ -20,16 +20,26 @@ export function createInteractionRetryTap( ): InteractionRetryTap | undefined { const { inspectFacts, bindDevice } = bindings; if (!inspectFacts || !bindDevice) return undefined; + // ADR 0019 §9 is one admission per handler, and the outcome policy can call this seam once per + // retry round. Memoizing per device keeps the facts inspection and the narrowing to the first + // round: `bindDevice` already caches the underlying binding, so re-resolving would only re-read + // facts the request has already admitted on. + const resolutions = new Map>(); return async ({ device, point, context }) => { // Admission runs before the policy spends an attempt: an owner whose cell cannot tap answers // `false` here and leaves the pending record intact. - const bound = await resolveBoundTouchRuntime({ - device, - command: 'press', - requiresCapture: false, - inspectFacts, - bindDevice, - }); + let resolution = resolutions.get(device.id); + if (!resolution) { + resolution = resolveBoundTouchRuntime({ + device, + command: 'press', + requiresCapture: false, + inspectFacts, + bindDevice, + }); + resolutions.set(device.id, resolution); + } + const bound = await resolution; if (!bound.ok) return false; const executor = createBoundTouchExecutor(bound.runtime, context); if (!executor.tapPoint) return false; diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index 5d1f7c0e0d..81751903c1 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -1,10 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import type { SettleObservation } from '@agent-device/contracts/interaction'; -import { - commandSupportsSettleObservation, - commandUsesDeviceRuntimeExecution, -} from '../core/command-descriptor/registry.ts'; -import { requireCommandSupported } from './handlers/response.ts'; +import { commandSupportsSettleObservation } from '../core/command-descriptor/registry.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; @@ -287,12 +283,11 @@ async function ensureGenericCommandReady( session: SessionState, platformCommand: string, ): Promise { - // A device-runtime command has no capability bucket: its exact owner facts already admitted it - // (or refused it) before this route was reached. - const unsupported = commandUsesDeviceRuntimeExecution(platformCommand) - ? null - : requireCommandSupported(platformCommand, session.device, { hint: true }); - if (unsupported) return { response: unsupported }; + // No support gate survives here. R56 migrated `app-switcher`, the last generic-route descriptor + // with a capability bucket, so every command that reaches this dispatcher was already admitted + // (or refused) against its exact owner's facts by `resolveGenericRuntimeExecution` — which fails + // closed for anything it has no arm for. A second `requireCommandSupported` call would be a + // support authority beside the facts, which ADR 0019 §8 forbids. if ( session.device.platform !== 'android' || isActiveProviderDevice(session.device) || diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index d3ade3571c..450ce880f4 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -185,10 +185,9 @@ async function runReactNativeHandler( logPath: params.logPath, sessionStore: params.sessionStore, contextFromFlags: params.contextFromFlags, - // R61: overlay dismissal admits and binds the owner's own `tapPoint`. The chain has never - // passed these through, so the dismissal leg has been reaching a missing gateway ever since - // R48 moved it off the retired dispatcher — the provider scenario only ever exercised the - // no-overlay-detected path, which returned before the bind. + // R61: overlay dismissal admits and binds the owner's own `tapPoint`, so the chain now has + // to pass the request's bindings through. Before R61 this leg reached the device through the + // retired dispatcher and needed none, which is why the arm had no bindings to forward. inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }), diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index 7cac0cdb30..ab69cd2e5d 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -11,6 +11,7 @@ import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; import type { CommandSessionRecord } from '../runtime.ts'; import { createAgentDevice } from '../runtime.ts'; import { getRequestSignal } from '../request/cancel.ts'; +import type { RuntimeAdmissionBindings } from './request-runtime-binding.ts'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { captureSnapshot } from './handlers/snapshot-capture.ts'; import { buildSnapshotSession, withSessionlessRunnerCleanup } from './handlers/snapshot-session.ts'; @@ -69,6 +70,8 @@ export async function dispatchSnapshotRuntimeCommand( snapshotScope, capturedQuality, captureSnapshotData: capture.captureSnapshot, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }); let result: Awaited>; try { @@ -105,17 +108,19 @@ export async function dispatchSnapshotRuntimeCommand( }); } -function createSnapshotRuntime(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - session: SessionState | undefined; - device: SessionState['device']; - snapshotScope: string | undefined; - capturedQuality: CapturedSnapshotQuality; - captureSnapshotData: () => Promise; -}) { +function createSnapshotRuntime( + params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + session: SessionState | undefined; + device: SessionState['device']; + snapshotScope: string | undefined; + capturedQuality: CapturedSnapshotQuality; + captureSnapshotData: () => Promise; + } & RuntimeAdmissionBindings, +) { const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params; return createAgentDevice({ backend: createDaemonSnapshotBackend({ @@ -126,6 +131,8 @@ function createSnapshotRuntime(params: { snapshotScope, capturedQuality: params.capturedQuality, captureSnapshotData: params.captureSnapshotData, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }), ...createDaemonRuntimePolicy('snapshot'), signal: getRequestSignal(req.meta?.requestId), @@ -221,15 +228,17 @@ function resolveNextSnapshotScopeSource(params: { return current?.snapshotScopeSource ?? current?.snapshot; } -function createDaemonSnapshotBackend(params: { - req: DaemonRequest; - logPath: string; - session: SessionState | undefined; - device: SessionState['device']; - snapshotScope: string | undefined; - capturedQuality: CapturedSnapshotQuality; - captureSnapshotData: () => Promise; -}): AgentDeviceBackend { +function createDaemonSnapshotBackend( + params: { + req: DaemonRequest; + logPath: string; + session: SessionState | undefined; + device: SessionState['device']; + snapshotScope: string | undefined; + capturedQuality: CapturedSnapshotQuality; + captureSnapshotData: () => Promise; + } & RuntimeAdmissionBindings, +): AgentDeviceBackend { const { req, logPath, session, device, snapshotScope } = params; return { platform: publicPlatformString(device), @@ -243,6 +252,10 @@ function createDaemonSnapshotBackend(params: { snapshotScope, signal: context.signal, captureData: params.captureSnapshotData, + // R48's pending-outcome retry re-fires a bound `tapPoint`, so the `snapshot` that settles + // a deferred outcome carries the request's own bindings down to the capture. + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }); const annotations = snapshotCaptureAnnotationsFrom(capture); params.capturedQuality.value = annotations.quality; diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 6bc1c2a45b..ef852a7054 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -5,18 +5,15 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerContext } from '@agent-device/contracts/interaction'; // --------------------------------------------------------------------------- -// Apple family per-command capability closures. Originally RELOCATED VERBATIM from -// src/core/command-descriptor/registry.ts (ADR-0009), the -// AppleOS-axis predicates (`target !== 'tv'` / `platform !== 'macos'` / -// `isTvOsDevice`) are now READ from the per-`AppleOS` capability table -// (`apple-os-capabilities.ts`, step d.5) instead of being open-coded. The rewrite is -// behaviorless: the DEVICE-shaped nuance (simulator vs physical device) stays in the -// closure — only the OS-axis facts moved to data — and the non-Apple branches are the -// verbatim verdicts (`appleOsCapabilities` returns `undefined` off the Apple family, so -// each closure is a no-op on android/linux/web). The table-equivalence gate -// (apple-os-capabilities table parity + capability-plugin-routing-parity tests) pins -// every closure byte-for-byte against a verbatim copy of the original predicate across -// the full {command x sample-device} matrix (iOS/iPadOS/tvOS/macOS/visionOS). +// Apple family per-command capability closures for the commands still admitted by a capability +// bucket. Originally RELOCATED VERBATIM from src/core/command-descriptor/registry.ts (ADR-0009). +// +// R59/R63 deleted the per-`AppleOS` capability table (`apple-os-capabilities.ts`) along with the +// closures that read it: every command it served is now admitted from its owner's operation facts +// (ADR 0019 §8), so the OS-axis predicates live in `packages/platform-apple` rather than here. +// What remains is DEVICE-shaped nuance (simulator vs physical device, XCTest vs CoreDevice) for +// unmigrated commands, pinned against the original predicates by +// `capability-plugin-routing-parity` across the full {command x sample-device} matrix. // --------------------------------------------------------------------------- const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean => From 7d58a453ae3d10a297efa68d2b664d2f46ed8ef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 23:47:39 +0000 Subject: [PATCH 08/16] fix(apple): type the alert-absence retry instead of matching error prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review blocker 1 on #2021. The Apple alert legs decided retry and hint eligibility by substring-matching error messages for "alert not found" / "no alert", and `alert wait` swallowed *every* read failure. A dead runner, an unreachable macOS helper or a canceled request was therefore spent as poll budget and finally reported as `alert wait timed out`, hiding the real cause. Both backends now state absence as typed evidence: - The XCTest runner answers `ErrorPayload(code: "ALERT_NOT_FOUND", ...)`. It is diagnostic-only, so it stays `COMMAND_FAILED` on the wire and surfaces as `details.runnerErrorCode` — the same shape `RUNNER_BUSY` already used. - The macOS helper adds `reason: "alert-not-found"` to its JSON error details, which the helper client already forwards verbatim. `isAlertNotFoundError` reads only those two fields. `awaitAppleAlert` re-throws anything that is not a typed absence instead of polling through it, and the scoped-snapshot fallback hint attaches to typed absence alone. The three tests the review asked for, plus coverage the daemon-altitude copies could not express: a non-absence failure propagates immediately from `wait`; an action does not retry a failure whose message merely reads like an absence; the macOS helper's typed reason is retried like the runner's. The daemon-level non-absence test moved to the family suite that owns this policy since R59, lowering that file's size pin. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- .../Sources/AgentDeviceMacOSHelper/main.swift | 7 +- .../RunnerTests+CommandExecution.swift | 7 +- packages/contracts/src/alert-contract.ts | 10 +++ packages/contracts/src/facades/interaction.ts | 2 + .../__tests__/test-file-size-ratchet.test.ts | 2 +- .../__tests__/snapshot-handler.test.ts | 49 ++++------- src/platforms/apple/__tests__/alert.test.ts | 85 ++++++++++++++++++- src/platforms/apple/alert.ts | 39 ++++++--- .../apple/core/runner/runner-session.ts | 15 +++- 9 files changed, 166 insertions(+), 50 deletions(-) diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift index e2069072cf..6b77e18683 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/main.swift @@ -287,9 +287,14 @@ struct AgentDeviceMacOSHelper { let surface = optionValue(arguments: Array(arguments.dropFirst()), name: "--surface") let app = try resolveTargetApplication(bundleId: bundleId, surface: surface) guard let alertElement = findAlertElement(appElement: AXUIElementCreateApplication(app.processIdentifier)) else { + // `reason` is the typed channel the host retries on; the message is for humans only. throw HelperError.commandFailed( "alert not found", - details: ["bundleId": app.bundleIdentifier ?? "", "appName": app.localizedName ?? ""] + details: [ + "reason": "alert-not-found", + "bundleId": app.bundleIdentifier ?? "", + "appName": app.localizedName ?? "", + ] ) } let buttons = collectButtons(root: alertElement) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 18e3a66792..ae140fc47c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -2190,7 +2190,12 @@ extension RunnerTests { Self.alertCommandTimeout(timeoutMs: command.timeoutMs) ) guard let alert = resolveAlert(app: activeApp, deadline: deadline) else { - return Response(ok: false, error: ErrorPayload(message: "alert not found")) + // Typed so the host retries on absence alone: a transport or runner failure carries no + // code and must not be mistaken for "no alert yet" (ALERT_NOT_FOUND_RUNNER_CODE). + return Response( + ok: false, + error: ErrorPayload(code: "ALERT_NOT_FOUND", message: "alert not found") + ) } return handleAlert(alert, action: action, deadline: deadline) case .gesture: diff --git a/packages/contracts/src/alert-contract.ts b/packages/contracts/src/alert-contract.ts index d0ccd3d9a0..018d5ceb3c 100644 --- a/packages/contracts/src/alert-contract.ts +++ b/packages/contracts/src/alert-contract.ts @@ -2,6 +2,16 @@ export const ALERT_POLL_INTERVAL_MS = 300; export const DEFAULT_ALERT_TIMEOUT_MS = 10_000; export const ALERT_ACTION_RETRY_MS = 2_000; +/** + * The one alert failure the family retries on: the backend looked and there was no alert *yet*. + * Both Apple backends state it in typed form — the XCTest runner as this `ErrorPayload.code` + * (surfacing as `details.runnerErrorCode`, like `RUNNER_BUSY`, without changing the wire error + * code), the macOS helper as `details.reason`. Retry and the fallback hint key on these, never on + * the message text: a transport, runner or helper failure must never read as an absent alert. + */ +export const ALERT_NOT_FOUND_RUNNER_CODE = 'ALERT_NOT_FOUND'; +export const ALERT_NOT_FOUND_REASON = 'alert-not-found'; + export const ALERT_ACTIONS = ['get', 'accept', 'dismiss', 'wait'] as const; export type AlertAction = (typeof ALERT_ACTIONS)[number]; diff --git a/packages/contracts/src/facades/interaction.ts b/packages/contracts/src/facades/interaction.ts index 62b8dc0499..d6928680f3 100644 --- a/packages/contracts/src/facades/interaction.ts +++ b/packages/contracts/src/facades/interaction.ts @@ -1,6 +1,8 @@ export { ALERT_ACTIONS, ALERT_ACTION_RETRY_MS, + ALERT_NOT_FOUND_REASON, + ALERT_NOT_FOUND_RUNNER_CODE, ALERT_POLL_INTERVAL_MS, DEFAULT_ALERT_TIMEOUT_MS, } from '../alert-contract.ts'; diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index 4e5964381a..ce03f6a768 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -34,7 +34,7 @@ const TRIPWIRE_LINES = 1_000; // Exact current lengths. Lower a pin when its file shrinks; never raise one — extract instead. const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/__tests__/remote-connection.test.ts': 2973, - 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2301, + 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2284, 'src/commands/interaction/runtime/settle.test.ts': 2359, 'src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts': 2020, 'src/platforms/apple/core/__tests__/runner-session.test.ts': 2001, diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 457678410b..fbbc2eab6a 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -2134,7 +2134,15 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { ); }); -test('alert accept retries on "alert not found" and succeeds on second attempt', async () => { +/** + * Absence as the XCTest runner states it (`ALERT_NOT_FOUND` surfaces as `details.runnerErrorCode`). + * The retry and the fallback hint key on that evidence, never on the message text. + */ +function alertAbsence(message = 'alert not found'): AppError { + return new AppError('COMMAND_FAILED', message, { runnerErrorCode: 'ALERT_NOT_FOUND' }); +} + +test('alert accept retries a typed alert absence and succeeds on the second attempt', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); @@ -2142,7 +2150,7 @@ test('alert accept retries on "alert not found" and succeeds on second attempt', let calls = 0; mockRunnerCommand.mockImplementation(async () => { calls += 1; - if (calls === 1) throw new AppError('COMMAND_FAILED', 'alert not found'); + if (calls === 1) throw alertAbsence(); return { accepted: true }; }); @@ -2163,41 +2171,16 @@ test('alert accept retries on "alert not found" and succeeds on second attempt', }); }); -test('alert accept does not retry on non-alert errors', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); - - let calls = 0; - mockRunnerCommand.mockImplementation(async () => { - calls += 1; - throw new AppError('COMMAND_FAILED', 'runner crashed'); - }); - - await expect( - handleSnapshotCommands({ - req: { - token: 't', - session: sessionName, - command: 'alert', - positionals: ['accept'], - flags: {}, - }, - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }), - ).rejects.toThrow('runner crashed'); - - expect(calls).toBe(1); -}); +// The non-absence case moved to `src/platforms/apple/__tests__/alert.test.ts` with the retry policy +// itself (R59), where it also covers a failure whose message merely reads like an absence — the +// case this daemon-altitude copy could not distinguish. test('alert accept adds a scoped-snapshot hint after retrying alert-not-found failures', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); - mockRunnerCommand.mockRejectedValue(new AppError('COMMAND_FAILED', 'alert not found')); + mockRunnerCommand.mockRejectedValue(alertAbsence()); let thrown: unknown; try { @@ -2222,7 +2205,7 @@ test('alert accept adds a scoped-snapshot hint after retrying alert-not-found fa expect((thrown as AppError).details?.hint).toMatch(/scoped snapshot/i); }); -test('alert dismiss retries on "no alert" message', async () => { +test('alert dismiss retries a typed absence whatever the message says', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); @@ -2230,7 +2213,7 @@ test('alert dismiss retries on "no alert" message', async () => { let calls = 0; mockRunnerCommand.mockImplementation(async () => { calls += 1; - if (calls < 3) throw new AppError('COMMAND_FAILED', 'no alert present'); + if (calls < 3) throw alertAbsence('no alert present'); return { dismissed: true }; }); diff --git a/src/platforms/apple/__tests__/alert.test.ts b/src/platforms/apple/__tests__/alert.test.ts index 411824afba..fc291dac79 100644 --- a/src/platforms/apple/__tests__/alert.test.ts +++ b/src/platforms/apple/__tests__/alert.test.ts @@ -4,6 +4,10 @@ import { afterEach, test, vi } from 'vitest'; vi.mock('../core/runner/runner-client.ts', () => ({ runAppleRunnerCommand: vi.fn() })); vi.mock('../os/macos/helper.ts', () => ({ runMacOsAlertAction: vi.fn() })); +import { + ALERT_NOT_FOUND_REASON, + ALERT_NOT_FOUND_RUNNER_CODE, +} from '@agent-device/contracts/alert-contract'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR, MACOS_DEVICE } from '../../../__tests__/test-utils/device-fixtures.ts'; import { runAppleRunnerCommand } from '../core/runner/runner-client.ts'; @@ -14,6 +18,20 @@ const mockRunner = vi.mocked(runAppleRunnerCommand); const mockHelper = vi.mocked(runMacOsAlertAction); const runnerOptions = {}; +/** + * Absence as each backend states it. The message is deliberately the same prose the old predicate + * matched on, so a test that passes here is passing on the typed evidence and nothing else. + */ +function runnerAbsence(): AppError { + return new AppError('COMMAND_FAILED', 'alert not found', { + runnerErrorCode: ALERT_NOT_FOUND_RUNNER_CODE, + }); +} + +function helperAbsence(): AppError { + return new AppError('COMMAND_FAILED', 'alert not found', { reason: ALERT_NOT_FOUND_REASON }); +} + afterEach(() => { vi.useRealTimers(); mockRunner.mockReset(); @@ -40,7 +58,7 @@ test('a wait polls until one attempt answers, and the first attempt gets the who let calls = 0; mockRunner.mockImplementation(async () => { calls += 1; - if (calls === 1) throw new AppError('COMMAND_FAILED', 'alert not found'); + if (calls === 1) throw runnerAbsence(); return { title: 'Camera Access' }; }); @@ -56,7 +74,7 @@ test('a wait polls until one attempt answers, and the first attempt gets the who test('a wait that never sees an alert reports the timeout rather than the last attempt error', async () => { vi.useFakeTimers(); - mockRunner.mockRejectedValue(new AppError('COMMAND_FAILED', 'alert not found')); + mockRunner.mockRejectedValue(runnerAbsence()); const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 900 }).then( () => undefined, @@ -87,7 +105,7 @@ test('an accept retries only while the backend says the alert is not there yet', test('an exhausted accept carries the scoped-snapshot fallback the agent needs next', async () => { vi.useFakeTimers(); - mockRunner.mockRejectedValue(new AppError('COMMAND_FAILED', 'alert not found')); + mockRunner.mockRejectedValue(runnerAbsence()); const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'accept').then( () => undefined, @@ -100,6 +118,67 @@ test('an exhausted accept carries the scoped-snapshot fallback the agent needs n assert.match(String(error.details?.hint), /scoped snapshot/i); }); +// The whole point of typing absence: a backend that failed for any other reason must not be +// retried until the window expires and then reported as a timeout. These three pin that the +// evidence — not the message text — is what decides. +test('a wait propagates a non-absence failure instead of spending it as poll budget', async () => { + vi.useFakeTimers(); + let calls = 0; + // The message deliberately says "alert not found"; only the missing typed evidence matters. + mockRunner.mockImplementation(async () => { + calls += 1; + throw new AppError('COMMAND_FAILED', 'runner transport closed: alert not found'); + }); + + const outcome = awaitAppleAlert(IOS_SIMULATOR, runnerOptions, { timeoutMs: 5_000 }).then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(6_000); + + assert.match(String(((await outcome) as Error).message), /runner transport closed/); + assert.equal(calls, 1); +}); + +test('an action does not retry a failure that only reads like an absence', async () => { + vi.useFakeTimers(); + let calls = 0; + mockRunner.mockImplementation(async () => { + calls += 1; + throw new AppError('COMMAND_FAILED', 'no alert service on this runner'); + }); + + const outcome = actOnAppleAlert(IOS_SIMULATOR, runnerOptions, 'dismiss').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + const error = (await outcome) as AppError; + + assert.equal(calls, 1); + // The fallback hint is absence-only advice, so an untyped failure must not carry it either. + assert.equal(error.details?.hint, undefined); +}); + +test('the macOS helper states absence as a typed reason, and the family retries it', async () => { + vi.useFakeTimers(); + let calls = 0; + mockHelper.mockImplementation(async () => { + calls += 1; + throw helperAbsence(); + }); + + const outcome = actOnAppleAlert(MACOS_DEVICE, runnerOptions, 'accept').then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(3_500); + const error = (await outcome) as AppError; + + assert.ok(calls > 1, 'a typed absence is retried'); + assert.match(String(error.details?.hint), /scoped snapshot/i); +}); + // The macOS host answers through its helper, and a frontmost-app session names no bundle at all. test('the macOS host reads through its helper, and a frontmost-app session names no bundle', async () => { mockHelper.mockResolvedValue({ title: 'Allow access' }); diff --git a/src/platforms/apple/alert.ts b/src/platforms/apple/alert.ts index 523924877e..8a917ce39f 100644 --- a/src/platforms/apple/alert.ts +++ b/src/platforms/apple/alert.ts @@ -1,5 +1,7 @@ import { ALERT_ACTION_RETRY_MS, + ALERT_NOT_FOUND_REASON, + ALERT_NOT_FOUND_RUNNER_CODE, ALERT_POLL_INTERVAL_MS, DEFAULT_ALERT_TIMEOUT_MS, } from '@agent-device/contracts/alert-contract'; @@ -15,9 +17,14 @@ import { runMacOsAlertAction } from './os/macos/helper.ts'; /** * Apple's four alert legs. R59 moved them here from the daemon: how long to look for a transient - * alert, how many times to re-ask a runner that answers "alert not found", and which of the two - * Apple backends answers at all are family mechanics, not request policy. What the caller supplies - * is the window it allows and the session's target; everything else is this family's. + * alert, how many times to re-ask a backend that reports the alert is not there yet, and which of + * the two Apple backends answers at all are family mechanics, not request policy. What the caller + * supplies is the window it allows and the session's target; everything else is this family's. + * + * Absence is the one retriable outcome, and both backends state it as typed evidence — the XCTest + * runner as `ALERT_NOT_FOUND`, the macOS helper as `reason: 'alert-not-found'`. Nothing here reads + * an error message: a transport, runner or helper failure that happened to mention an alert would + * otherwise be retried until the window expired and then reported as a timeout, hiding the cause. */ type NativeAlertAction = 'get' | 'accept' | 'dismiss'; @@ -75,8 +82,11 @@ export async function awaitAppleAlert( const budgetMs = firstAttempt ? timeout : remainingBudgetMs(start, timeout); firstAttempt = false; return await runAlert('get', budgetMs); - } catch { - // keep waiting + } catch (error) { + // Only a typed absence is worth waiting out. Anything else — a dead runner, an unreachable + // helper, a canceled request — is reported as itself rather than spent as poll budget and + // relabeled `alert wait timed out`. + if (!isAlertNotFoundError(error)) throw error; } await sleep(ALERT_POLL_INTERVAL_MS); } @@ -84,9 +94,9 @@ export async function awaitAppleAlert( } /** - * Accept and dismiss retry only while the backend keeps saying the alert is not there yet — a - * sheet that is still animating in. Any other failure is reported on its first occurrence, and - * an exhausted retry window carries the scoped-snapshot fallback the agent needs next. + * Accept and dismiss retry only while the backend keeps reporting a typed absence — a sheet that + * is still animating in. Any other failure is reported on its first occurrence, and an exhausted + * retry window carries the scoped-snapshot fallback the agent needs next. */ export async function actOnAppleAlert( device: DeviceInfo, @@ -127,7 +137,16 @@ function withAlertFallbackHint(error: unknown): unknown { }); } +/** + * Absence, as the backend itself classified it. The XCTest runner's `ALERT_NOT_FOUND` arrives as + * `details.runnerErrorCode` (it stays `COMMAND_FAILED` on the wire, like `RUNNER_BUSY`); the macOS + * helper's arrives as `details.reason`, forwarded verbatim from its JSON error envelope. + */ function isAlertNotFoundError(error: unknown): boolean { - const message = String((error as { message?: unknown })?.message ?? '').toLowerCase(); - return message.includes('alert not found') || message.includes('no alert'); + if (!(error instanceof AppError)) return false; + const details = error.details ?? {}; + return ( + details['runnerErrorCode'] === ALERT_NOT_FOUND_RUNNER_CODE || + details['reason'] === ALERT_NOT_FOUND_REASON + ); } diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index 4823fc113f..8f9e0644e8 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -1,4 +1,5 @@ import { AppError, toAppErrorCode, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { ALERT_NOT_FOUND_RUNNER_CODE } from '@agent-device/contracts/alert-contract'; import { type ExecResult } from '../../../../utils/exec.ts'; import { withKeyedLock } from '../../../../utils/keyed-lock.ts'; import { Deadline } from '../../../../utils/retry.ts'; @@ -891,8 +892,20 @@ function readRunnerErrorCode(rawCode: unknown): string | undefined { return typeof rawCode === 'string' && rawCode.trim().length > 0 ? rawCode.trim() : undefined; } +/** + * Runner codes that classify a failure for the host without renaming it on the wire. They stay + * `COMMAND_FAILED` and survive as `details.runnerErrorCode`, which is what family policy reads: + * `RUNNER_BUSY` for retriable contention, `ALERT_NOT_FOUND` for an alert that is not there yet. + */ +const DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES: ReadonlySet = new Set([ + 'RUNNER_BUSY', + ALERT_NOT_FOUND_RUNNER_CODE, +]); + function runnerAppErrorCode(runnerErrorCode: string | undefined): AppError['code'] { - if (runnerErrorCode === 'RUNNER_BUSY') return 'COMMAND_FAILED'; + if (runnerErrorCode !== undefined && DIAGNOSTIC_ONLY_RUNNER_ERROR_CODES.has(runnerErrorCode)) { + return 'COMMAND_FAILED'; + } return runnerErrorCode ? toAppErrorCode(runnerErrorCode) : 'COMMAND_FAILED'; } From 6ed072f4738f26ac288772b1b54ae938483dd7dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 07:07:28 +0000 Subject: [PATCH 09/16] fix(runtime): admit clipboard and provider operations from what execution checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review blocker on #2021, reproduced on a Pixel 9 Pro XL / Android 36 emulator: `capabilities` advertised `clipboard`, then `clipboard read` failed with `UNSUPPORTED_OPERATION: Android shell clipboard read is not supported on this device.` Admission and execution were consulting different authorities, which ADR 0019 §2 forbids — a bound operation must already be admitted. Android. `cmd clipboard` has no shell implementation on every build, and the retired bucket admitted both halves on every real Android kind, leaving the leaf to discover the refusal after the fact. Support is now a fact: the owner probes once per device (cached for its lifetime — a build's shell command set cannot change while the device is up) and states `owner-capability-missing` when adb names the condition. The probe is definitive in one direction only: adb saying so means unsupported, a probe that cannot run means unknown, and reporting unknown as unsupported would hide a working clipboard behind a transport hiccup. The predicate moves to `@agent-device/contracts/android-clipboard-support` so admission and the leaf's own defense-in-depth check cannot drift apart. Cost, stated plainly: the first facts inspection per device now spends one adb round trip, including for requests that never touch the clipboard. WebDriver. `webdriver-interactor.ts` refuses through `capabilitySupported`, while fact generation admitted from interactor reachability alone — so a provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }` was admitted and then thrown out of. The declared capability map is now an input to fact generation, and the refusal carries the map author's own note. Applied to every operation with an unambiguous capability key, not just the two named in review: the mechanism is identical and a half-applied fix would leave the same defect for `back`/`home`/`orientation`/`tap`/`fill`/`type`/`scroll`. Behavior is unchanged by default — every one of those is `supported` or `partial` in the base map — so only an explicit override bites. `focus`, the gesture tiers and `trigger-app-event` keep reachability: no capability key maps to them 1:1. Also collapses the eight identical `*RetiredDispatchProjectionProof` wrappers in the cutover table into one parameterized factory (second review point). Parity tests: an Android build reporting either unsupported-shell phrasing, the probe cache, an adb failure staying admitted, and a WebDriver override refused at admission for each keyed operation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/contracts/package.json | 4 + .../src/android-clipboard-support.ts | 19 +++++ packages/platform-android/src/runtime.test.ts | 81 ++++++++++++++++++- packages/platform-android/src/runtime.ts | 61 ++++++++++++-- .../src/platform-runtime.test.ts | 75 +++++++++++++++++ .../src/platform-runtime.ts | 72 ++++++++++++++--- packages/provider-webdriver/src/runtime.ts | 1 + scripts/layering/package-boundaries.test.ts | 1 + .../layering/runtime-command-cutover-table.ts | 71 ++++------------ src/platforms/android/adb.ts | 7 +- 10 files changed, 314 insertions(+), 78 deletions(-) create mode 100644 packages/contracts/src/android-clipboard-support.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 374f711e0a..11ab1259f7 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -19,6 +19,10 @@ "types": "./src/alert-runtime.ts", "default": "./src/alert-runtime.ts" }, + "./android-clipboard-support": { + "types": "./src/android-clipboard-support.ts", + "default": "./src/android-clipboard-support.ts" + }, "./android-input-ownership": { "types": "./src/android-input-ownership.ts", "default": "./src/android-input-ownership.ts" diff --git a/packages/contracts/src/android-clipboard-support.ts b/packages/contracts/src/android-clipboard-support.ts new file mode 100644 index 0000000000..f07c966560 --- /dev/null +++ b/packages/contracts/src/android-clipboard-support.ts @@ -0,0 +1,19 @@ +/** + * Whether an adb `cmd clipboard` invocation was refused because this Android build ships no shell + * implementation for the clipboard service, rather than because the call itself failed. + * + * Shared so admission and execution decide identically: `packages/platform-android` probes with it + * when generating facts, and the root clipboard leaf re-checks with it as defense in depth. Two + * copies of this predicate could drift into a device that `capabilities` advertises and execution + * refuses, which is exactly the split ADR 0019 §2 forbids. + * + * It reads adb's own output because adb reports this condition in no other way: there is no exit + * code or structured field that distinguishes "service has no shell command" from any other + * non-zero result. + */ +export function isAndroidClipboardShellUnsupported(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}`.toLowerCase(); + return ( + haystack.includes('no shell command implementation') || haystack.includes('unknown command') + ); +} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 0f9bb9fad2..ef87ac727d 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -196,8 +196,18 @@ test('rejects the non-discovered Android simulator cell for appstate', async () expect(binding.operations.appState).toBeUndefined(); }); -function androidNavigationHostFixture() { +function androidNavigationHostFixture( + runAdb?: ( + device: DeviceInfo, + args: readonly string[], + ) => Promise<{ + stdout: string; + stderr: string; + exitCode: number; + }>, +) { return { + androidTools: { runAdb: runAdb ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })) }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, appInventory: { apple: { listApps: async () => [] }, @@ -568,3 +578,72 @@ function gestureHost(): PlatformRuntimeHost { screenRecording: { android: { resolve: async () => ({ mode: 'local' as const }) } }, } as unknown as PlatformRuntimeHost; } + +// R55 defect, found on a Pixel 9 Pro XL / Android 36 emulator: the retired bucket admitted both +// clipboard halves on every real Android kind, `capabilities` advertised `clipboard`, and +// `clipboard read` then failed with `UNSUPPORTED_OPERATION` from the leaf. Admission now probes +// the same condition the leaf checks, so a build with no clipboard shell command refuses up front. +test.each([ + ['no shell command implementation', 'cmd: No shell command implementation.'], + ['unknown command', 'Unknown command: clipboard'], +])('refuses both clipboard halves when adb reports %s', async (_name, stdout) => { + const binding = await createAndroidPlatformRuntime( + androidNavigationHostFixture(async () => ({ stdout, stderr: '', exitCode: 0 })), + ).bind({ + device: { ...device, id: 'android-no-clipboard-shell', kind: 'device' }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + for (const key of ['readClipboard', 'writeClipboard'] as const) { + expect(binding.facts.operations[key]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); + // Never admitted, so never bound: nothing can throw `unsupported` after the fact. + expect(binding.operations[key]).toBeUndefined(); + } + // The probe is scoped to the clipboard; neighbouring adb-driven cells stay admitted. + expect(binding.facts.operations.appSwitcher).toEqual({ available: true }); +}); + +test('probes the clipboard shell once per device, not once per inspection', async () => { + const runAdb = vi.fn(async (_device: DeviceInfo, _args: readonly string[]) => ({ + stdout: '', + stderr: '', + exitCode: 0, + })); + const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(runAdb)); + const target = { ...device, id: 'android-probe-cache', kind: 'device' as const }; + + await runtime.inspectFacts(target); + await runtime.inspectFacts(target); + + const clipboardProbes = runAdb.mock.calls.filter((call) => call[1].includes('clipboard')); + expect(clipboardProbes).toHaveLength(1); +}); + +test('an adb failure leaves the clipboard admitted rather than inventing an unsupported build', async () => { + const binding = await createAndroidPlatformRuntime( + androidNavigationHostFixture(async () => { + throw new Error('adb: device offline'); + }), + ).bind({ + device: { ...device, id: 'android-adb-offline', kind: 'device' }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + // The probe is definitive in one direction only: adb naming the condition means unsupported, a + // probe that cannot run means unknown. Reporting unknown as unsupported would hide a working + // clipboard behind a transport hiccup. + expect(binding.facts.operations.readClipboard).toEqual({ available: true }); +}); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 5d235703ee..986bdc8c9e 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -50,6 +50,7 @@ import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts'; import { ensureAndroidReady } from './readiness/runtime.ts'; import { readAndroidAppState } from './app-state.ts'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; +import { isAndroidClipboardShellUnsupported } from '@agent-device/contracts/android-clipboard-support'; import { androidAppDeploymentFacts, createAndroidAppDeploymentOperations, @@ -203,6 +204,12 @@ function androidTouchFact(device: DeviceInfo) { return device.kind === 'simulator' ? focusKindUnavailable : available; } +const clipboardShellUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'This Android build ships no shell implementation for the clipboard service, so adb cannot read or write the clipboard on it.', +} as const); + const tvRemoteUnavailable = Object.freeze({ available: false, reason: 'unsupported-device-kind', @@ -219,9 +226,31 @@ function androidTvRemoteFact(device: DeviceInfo): RuntimeOperationFact { export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { const appLogs = createAndroidAppLogRuntime(host); + /** + * `cmd clipboard` is not implemented on every Android build. The retired capability bucket + * admitted the clipboard on every real Android kind and the leaf discovered the refusal only + * once a read or write had already run — a device `capabilities` advertised and execution then + * rejected. ADR 0019 §2 requires the opposite, so support is a fact, and the fact needs a probe. + * + * Cached per device for this owner's lifetime: a build's shell command set cannot change while + * the device is up, and admission would otherwise pay an adb round trip per request. + */ + const clipboardShell = new Map>(); + const clipboardShellSupported = async (device: DeviceInfo): Promise => { + let probe = clipboardShell.get(device.id); + if (!probe) { + probe = probeAndroidClipboardShell(host, device); + clipboardShell.set(device.id, probe); + } + return await probe; + }; const inspectFacts = async (device: Parameters[0]) => { const logs = await appLogs.inspectFacts(device); const deployment = androidAppDeploymentFacts(device); + const clipboardCell = + device.kind === 'simulator' || !(await clipboardShellSupported(device)) + ? clipboardShellUnavailable + : available; return Object.freeze({ device: logs.device, operations: { @@ -304,12 +333,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor dismiss: androidTouchFact(device), enter: androidTouchFact(device), }), - // `cmd clipboard get/set text` runs over adb on every real Android kind, so read and - // write share the same cell the retired `clipboard` bucket declared. - ...clipboardRuntimeOperationFacts({ - read: androidTouchFact(device), - write: androidTouchFact(device), - }), + // Read and write share one cell: `cmd clipboard` either has a shell implementation on this + // build or it has none, and no Android build ships one half of it. + ...clipboardRuntimeOperationFacts({ read: clipboardCell, write: clipboardCell }), ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, @@ -437,3 +463,26 @@ function androidInteractionOperations( }), }; } + +/** + * Asks the device, once, whether its clipboard service answers shell commands at all. + * + * Definitive only in one direction. adb naming the condition means unsupported; a probe that + * cannot run (offline transport, adb failure) says nothing about shell support, so it reports + * supported and leaves the real transport error to the operation that actually runs. + */ +async function probeAndroidClipboardShell( + host: PlatformRuntimeHost, + device: DeviceInfo, +): Promise { + try { + const result = await host.androidTools.runAdb( + device, + ['shell', 'cmd', 'clipboard', 'get', 'text'], + { allowFailure: true }, + ); + return !isAndroidClipboardShellUnsupported(result.stdout, result.stderr); + } catch { + return true; + } +} diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 7ff0e8b906..6a7b3ee254 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -4,6 +4,23 @@ import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor } from '@agent-device/contracts/interaction'; import { createWebDriverPlatformRuntimeOwner } from './platform-runtime.ts'; +import { + createCloudWebDriverCapabilities, + type CloudWebDriverCapabilityOverrides, +} from './capabilities.ts'; +import type { CloudWebDriverPlatform } from './runtime.ts'; + +/** The declared map fact generation now reads. Defaults to a provider with no overrides. */ +function capabilities( + platform: CloudWebDriverPlatform = 'android', + overrides?: CloudWebDriverCapabilityOverrides, +) { + return createCloudWebDriverCapabilities({ + provider: 'browserstack', + platform, + ...(overrides ? { overrides } : {}), + }); +} const device: DeviceInfo = { platform: 'android', @@ -20,6 +37,7 @@ test('direct WebDriver network uses only the canonical session log and preserves host: host(run), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), }); const binding = await owner.bind({ device, @@ -79,6 +97,7 @@ test.each([ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', String(_name).toLowerCase()), ownsDevice: () => true, + capabilities: capabilities(), }); const binding = await owner.bind({ device: runtimeDevice, @@ -162,6 +181,7 @@ test.each([ runtimeDevice.platform === 'apple' ? 'ios' : 'android', ), ownsDevice: () => true, + capabilities: capabilities(), deployment: { fact: () => ({ available: true }), deployApp, @@ -219,6 +239,7 @@ test('captures through only the active exact WebDriver interactor', async () => host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), snapshotAvailable: true, getInteractor, }); @@ -309,6 +330,7 @@ test.each([ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), ...state, getInteractor, }); @@ -488,6 +510,7 @@ test.each([ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), getInteractor: () => ({}) as unknown as Interactor, }); const facts = await owner.inspectFacts(owned); @@ -509,6 +532,7 @@ test('closes every WebDriver gesture and scroll cell when the interactor is unre host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', 'android'), ownsDevice: () => true, + capabilities: capabilities(), getInteractor: undefined, }); const facts = await owner.inspectFacts(device); @@ -526,3 +550,54 @@ test('closes every WebDriver gesture and scroll cell when the interactor is unre }); } }); + +// The defect this pins: a provider configured with `capabilityOverrides` used to be admitted from +// interactor reachability alone, so `capabilities` advertised the operation and the interactor's +// own `requireSupport` threw `UNSUPPORTED_OPERATION` after binding. Admission and execution now +// read the same declared map (ADR 0019 §2). +test.each([ + ['clipboard.read', 'readClipboard'], + ['clipboard.write', 'writeClipboard'], + ['appSwitcher', 'appSwitcher'], + ['back', 'back'], + ['home', 'home'], + ['orientation', 'setOrientation'], +] as const)( + 'an unsupported %s override is refused at admission, not after binding', + async (operation, factKey) => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + capabilities: capabilities('android', { [operation]: 'unsupported' }), + getInteractor: () => ({}) as unknown as Interactor, + }); + + const facts = await owner.inspectFacts(device); + + expect(facts.operations[factKey]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); + // The refusal carries the capability map author's own wording. + const fact = facts.operations[factKey]; + expect(fact.available === false && String(fact.hint)).toContain(operation); + }, +); + +test('a reachable provider with no overrides still admits its declared operations', async () => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + capabilities: capabilities(), + getInteractor: () => ({}) as unknown as Interactor, + }); + + const facts = await owner.inspectFacts(device); + + // `partial` counts as supported, matching `capabilitySupported` in the interactor. + for (const key of ['readClipboard', 'writeClipboard', 'appSwitcher', 'back', 'home'] as const) { + expect(facts.operations[key].available).toBe(true); + } +}); diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 0a77e61736..6fb504337c 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -70,6 +70,12 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { WebDriverDeploymentRuntime } from './runtime-deployment.ts'; import { bindWebDriverApplicationLifecycle } from './lifecycle.ts'; +import { + capabilitySupported, + unsupportedCapabilityMessage, + type CloudWebDriverOperation, + type CloudWebDriverProviderCapabilities, +} from './capabilities.ts'; type WebDriverPlatformDeploymentRuntime = Pick< WebDriverDeploymentRuntime, @@ -312,6 +318,13 @@ export type WebDriverPlatformRuntimeOptions = Readonly<{ ownsDevice(device: DeviceInfo): boolean; isSessionActive?(device: DeviceInfo): boolean; deployment?: WebDriverPlatformDeploymentRuntime; + /** + * The provider's declared capability map — the same one `webdriver-interactor.ts` refuses + * against at call time. Fact generation reads it so an operation this provider declares + * unsupported is stated unavailable up front instead of admitted and then thrown out of + * (ADR 0019 §2: no stubs that throw `unsupported` after binding). + */ + capabilities: CloudWebDriverProviderCapabilities; screenshotAvailable?: boolean; snapshotAvailable?: boolean; getInteractor?(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; @@ -452,6 +465,32 @@ function interactorCell( return reachable ? available : whenUnreachable; } +/** + * Reachability AND the provider's own declaration. `webdriver-interactor.ts` refuses at call time + * through `capabilitySupported`, so admission reads the same predicate and the same map: a + * provider configured with `capabilityOverrides: { 'clipboard.read': 'unsupported' }` now refuses + * at admission, where the caller can see it in `capabilities`, instead of binding and throwing. + * + * The refusal carries the provider's own note, so the message a caller sees is the one the + * capability map author wrote. + */ +function declaredCapabilityCell( + params: Readonly<{ + reachable: boolean; + capabilities: CloudWebDriverProviderCapabilities; + operation: CloudWebDriverOperation; + whenUnreachable: RuntimeOperationUnavailability; + }>, +): RuntimeOperationFact { + if (!params.reachable) return params.whenUnreachable; + if (capabilitySupported(params.capabilities, params.operation)) return available; + return Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: unsupportedCapabilityMessage(params.capabilities, params.operation), + } as const); +} + function webDriverFacts( options: Omit, device: DeviceInfo, @@ -538,6 +577,17 @@ function webDriverFacts( reachable && options.snapshotAvailable !== false ? available : snapshotUnavailable; const screenshotCell = reachable && options.screenshotAvailable !== false ? available : screenshotUnavailable; + // One binding of the shared predicate, so every keyed operation below reads the same way. + const declared = ( + operation: CloudWebDriverOperation, + whenUnreachable: RuntimeOperationUnavailability, + ) => + declaredCapabilityCell({ + reachable, + capabilities: options.capabilities, + operation, + whenUnreachable, + }); return Object.freeze({ device: unavailable.device, operations: { @@ -557,14 +607,14 @@ function webDriverFacts( // Focus rides the same provider interactor the captures do, so it needs the same // reachability and nothing more: this provider drives touch wherever it can drive a capture. ...focusRuntimeOperationFacts({ focus: interactorCell(reachable, focusUnavailable) }), - ...typeTextRuntimeOperationFacts({ type: interactorCell(reachable, typeUnavailable) }), + ...typeTextRuntimeOperationFacts({ type: declared('type', typeUnavailable) }), ...touchRuntimeOperationFacts({ - tap: interactorCell(reachable, focusUnavailable), + tap: declared('tap', focusUnavailable), tapRef: focusUnavailable, - longPress: interactorCell(reachable, focusUnavailable), + longPress: declared('longPress', focusUnavailable), hover: focusUnavailable, hoverRef: focusUnavailable, - fill: interactorCell(reachable, typeUnavailable), + fill: declared('fill', typeUnavailable), fillRef: typeUnavailable, tapElementSelector: focusUnavailable, }), @@ -579,13 +629,13 @@ function webDriverFacts( targetAuthoredDrag: interactorCell(reachable, gestureUnavailable), viewport: interactorCell(reachable, gestureUnavailable), }), - ...scrollRuntimeOperationFacts({ scroll: interactorCell(reachable, scrollUnavailable) }), + ...scrollRuntimeOperationFacts({ scroll: declared('scroll', scrollUnavailable) }), // `back`/`home`/`orientation` ride the same reachable interactor; `tvRemote` always throws // unsupported in this interactor regardless of reachability (no capability declares it). - ...backRuntimeOperationFacts({ back: interactorCell(reachable, backUnavailable) }), - ...homeRuntimeOperationFacts({ home: interactorCell(reachable, homeUnavailable) }), + ...backRuntimeOperationFacts({ back: declared('back', backUnavailable) }), + ...homeRuntimeOperationFacts({ home: declared('home', homeUnavailable) }), ...orientationRuntimeOperationFacts({ - orientation: interactorCell(reachable, orientationUnavailable), + orientation: declared('orientation', orientationUnavailable), }), ...tvRemoteRuntimeOperationFacts({ tvRemote: tvRemoteUnavailable }), ...keyboardRuntimeOperationFacts({ @@ -602,11 +652,11 @@ function webDriverFacts( // Appium — which does expose the clipboard extension. The refusal moves to where it can be // true: the interactor, per session. ...clipboardRuntimeOperationFacts({ - read: interactorCell(reachable, clipboardUnavailable), - write: interactorCell(reachable, clipboardUnavailable), + read: declared('clipboard.read', clipboardUnavailable), + write: declared('clipboard.write', clipboardUnavailable), }), ...appSwitcherRuntimeOperationFacts({ - appSwitcher: interactorCell(reachable, appSwitcherUnavailable), + appSwitcher: declared('appSwitcher', appSwitcherUnavailable), }), // The deep link opens through the same reachable interactor `open` every lifecycle command // drives on this provider. diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index d7977ce9fd..2daaba237c 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -165,6 +165,7 @@ class CloudWebDriverRuntimeImplementation implements CloudWebDriverRuntime { ownsDevice: (device) => this.ownsDevice(device), isSessionActive: (device) => this.sessions.findSessionForDevice(device) !== undefined, deployment: this.deployment, + capabilities: this.capabilities, snapshotAvailable: this.capabilities.operations.snapshot.support !== 'unsupported', screenshotAvailable: this.capabilities.operations.screenshot.support !== 'unsupported', getInteractor: (device) => this.getInteractor(device), diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 90c7c7e8e0..b463cf9009 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -49,6 +49,7 @@ const ALL = [kernel, contracts]; const CONTRACT_EXPORTS = [ '@agent-device/contracts/alert-contract', '@agent-device/contracts/alert-runtime', + '@agent-device/contracts/android-clipboard-support', '@agent-device/contracts/android-input-ownership', '@agent-device/contracts/android-snapshot-quality', '@agent-device/contracts/android-system-chrome', diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index f33ced2859..4e2ad0a8e6 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -477,7 +477,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'], }, }, - extensions: [snapshotRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('snapshot')], }, { rule: 'R33 diff-runtime-cutover', @@ -511,7 +511,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'], }, }, - extensions: [diffRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('diff')], }, { rule: 'R35 find-runtime-cutover', @@ -1108,7 +1108,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ writeClipboard: ['executeClipboardWrite'], }, }, - extensions: [clipboardRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('clipboard')], }, { rule: 'R56 app-switcher-runtime-cutover', @@ -1134,7 +1134,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ operations: ['appSwitcher'], operationOwners: { appSwitcher: ['executeAppSwitcher'] }, }, - extensions: [appSwitcherRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('app-switcher')], }, { rule: 'R57 trigger-app-event-runtime-cutover', @@ -1157,7 +1157,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ operations: ['triggerAppEvent'], operationOwners: { triggerAppEvent: ['executeAppEvent'] }, }, - extensions: [appEventRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('trigger-app-event')], }, { rule: 'R58 settings-runtime-cutover', @@ -1194,7 +1194,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ operations: ['setSetting'], operationOwners: { setSetting: ['executeSetSetting'] }, }, - extensions: [settingsRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('settings')], }, { rule: 'R59 alert-runtime-cutover', @@ -1234,7 +1234,7 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ dismissAlert: ['executeDismissAlert'], }, }, - extensions: [alertRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('alert')], }, { rule: 'R61 react-native-runtime-cutover', @@ -1259,56 +1259,19 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ operations: ['tapPoint'], operationOwners: { tapPoint: ['createTapTouchExecutor'] }, }, - extensions: [reactNativeRetiredDispatchProjectionProof], + extensions: [retiredDispatchProjectionProof('react-native')], }, ]; -function reactNativeRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'react-native'); -} - -function alertRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'alert'); -} - -function settingsRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'settings'); -} - -function appEventRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'trigger-app-event'); -} - -function appSwitcherRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'app-switcher'); -} - -function clipboardRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'clipboard'); -} - -function snapshotRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'snapshot'); -} - -function diffRetiredDispatchProjectionProof( - sources: ReadonlyMap, -): UnruledViolation[] { - return retiredDispatchProjectionViolations(sources, 'diff'); +/** + * Every migrated row proves the same thing about its own command: the retired dispatch projection + * is gone. One factory carries the command, so a new row states its name once in the row literal + * instead of adding a ninth identically-shaped wrapper below. + */ +function retiredDispatchProjectionProof( + command: string, +): (sources: ReadonlyMap) => UnruledViolation[] { + return (sources) => retiredDispatchProjectionViolations(sources, command); } /** The record mechanics policy predates the row model and reports `path: message`. */ diff --git a/src/platforms/android/adb.ts b/src/platforms/android/adb.ts index 4aa86a8f32..8236af1f08 100644 --- a/src/platforms/android/adb.ts +++ b/src/platforms/android/adb.ts @@ -15,9 +15,4 @@ export async function runAndroidAdb( return await resolveAndroidAdbExecutor(device)(args, options); } -export function isClipboardShellUnsupported(stdout: string, stderr: string): boolean { - const haystack = `${stdout}\n${stderr}`.toLowerCase(); - return ( - haystack.includes('no shell command implementation') || haystack.includes('unknown command') - ); -} +export { isAndroidClipboardShellUnsupported as isClipboardShellUnsupported } from '@agent-device/contracts/android-clipboard-support'; From e3b13c86a3867301d14469192f328fb28bbbd697 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 07:13:07 +0000 Subject: [PATCH 10/16] refactor(layering): split the Wave 6 cutover rows into a sibling module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review P2 on #2021. `runtime-command-cutover-table.ts` had reached 1,325 lines, past the point where one read covers it. Wave 6's eight rows move to `runtime-command-cutover-table-wave6.ts` and are spread back in, leaving the table at 1,095 lines. The split is by wave because that is how these rows are retired: a wave's rows are deleted together once the ADR declares its commands' migrations closed, and deleting a whole file is a cleaner end than excising a run of literals from the middle of a larger one. `retiredDispatchProjectionProof` moves to the shared extensions module, since both tables now use it — the main table for `snapshot`/`diff`, the sibling for its own eight. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/platform-android/src/runtime.ts | 10 +- .../runtime-command-cutover-extensions.ts | 12 ++ .../runtime-command-cutover-table-wave6.ts | 198 ++++++++++++++++++ .../layering/runtime-command-cutover-table.ts | 198 +----------------- src/__tests__/eager-closure-budgets.ts | 1 + 5 files changed, 218 insertions(+), 201 deletions(-) create mode 100644 scripts/layering/runtime-command-cutover-table-wave6.ts diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 986bdc8c9e..24147e6e44 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -236,21 +236,19 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor * the device is up, and admission would otherwise pay an adb round trip per request. */ const clipboardShell = new Map>(); - const clipboardShellSupported = async (device: DeviceInfo): Promise => { + const clipboardFact = async (device: DeviceInfo): Promise => { + if (device.kind === 'simulator') return clipboardShellUnavailable; let probe = clipboardShell.get(device.id); if (!probe) { probe = probeAndroidClipboardShell(host, device); clipboardShell.set(device.id, probe); } - return await probe; + return (await probe) ? available : clipboardShellUnavailable; }; const inspectFacts = async (device: Parameters[0]) => { const logs = await appLogs.inspectFacts(device); const deployment = androidAppDeploymentFacts(device); - const clipboardCell = - device.kind === 'simulator' || !(await clipboardShellSupported(device)) - ? clipboardShellUnavailable - : available; + const clipboardCell = await clipboardFact(device); return Object.freeze({ device: logs.device, operations: { diff --git a/scripts/layering/runtime-command-cutover-extensions.ts b/scripts/layering/runtime-command-cutover-extensions.ts index ac1fe61e2a..e7a6e8037f 100644 --- a/scripts/layering/runtime-command-cutover-extensions.ts +++ b/scripts/layering/runtime-command-cutover-extensions.ts @@ -1,3 +1,4 @@ +import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts'; import { parseSync } from 'oxc-parser'; import { propertyName, visitAst, type ProductionSource } from './cutover-policy-ast.ts'; import { lineOf } from './runtime-command-cutover-ast.ts'; @@ -11,6 +12,17 @@ export { runtimeLifecycleRouteBindingViolations, } from './runtime-command-cutover-lifecycle.ts'; +/** + * Every migrated row proves the same thing about its own command: the retired dispatch projection + * is gone. One factory carries the command, so a row states its name once in the row literal + * rather than adding another identically-shaped wrapper beside the table. + */ +export function retiredDispatchProjectionProof( + command: string, +): (sources: ReadonlyMap) => UnruledViolation[] { + return (sources) => retiredDispatchProjectionViolations(sources, command); +} + type AstNode = Record; const DEVICES_HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts'; diff --git a/scripts/layering/runtime-command-cutover-table-wave6.ts b/scripts/layering/runtime-command-cutover-table-wave6.ts new file mode 100644 index 0000000000..256383bb3f --- /dev/null +++ b/scripts/layering/runtime-command-cutover-table-wave6.ts @@ -0,0 +1,198 @@ +import type { MigratedCommandCutover } from './runtime-command-cutover-model.ts'; +import { retiredDispatchProjectionProof } from './runtime-command-cutover-extensions.ts'; + +/** + * Wave 6's rows (#1739): the seven named command units that left `platformExecution: legacy` for + * request-bound runtimes, plus `react-native`. + * + * They live beside `runtime-command-cutover-table.ts` rather than inside it because that file had + * grown past the point where one read covers it. The split is by wave, which is how the tracker + * retires these rows: a wave's rows are deleted together once this ADR declares its commands' + * migrations closed, and a whole-file deletion is a cleaner end than excising a run of literals + * from the middle of a larger table. + */ +export const WAVE_6_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ + { + rule: 'R55 clipboard-runtime-cutover', + command: 'clipboard', + subject: 'device clipboard', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its `core/dispatch.ts` handler. The daemon route function was + // renamed to `handleSessionClipboardCommand` when it moved out of the over-budget + // `handlers/session.ts`, so this name is now genuinely absent from production rather than + // shadowed by a surviving namesake. + routeNames: ['handleClipboardCommand'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy clipboard support or hint closure', + }, + runtimeTypeNames: ['ClipboardRuntimeOperations'], + operations: { names: ['readClipboard', 'writeClipboard'] }, + singularExecution: { + // `clipboard` is action-selected (R35's lesson): the session route resolves exactly one of + // the two operations per request, binds once, and never both together. + routes: ['resolveBoundClipboardRuntime'], + operations: ['readClipboard', 'writeClipboard'], + operationOwners: { + readClipboard: ['executeClipboardRead'], + writeClipboard: ['executeClipboardWrite'], + }, + }, + extensions: [retiredDispatchProjectionProof('clipboard')], + }, + { + rule: 'R56 app-switcher-runtime-cutover', + command: 'app-switcher', + subject: 'app switcher reveal', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm; `app-switcher` had no dedicated named handler function to retire + // (its legacy body lived inline in the `DISPATCH_HANDLERS` literal). It also leaves the + // HarmonyOS overlay that granted it a capability bucket the descriptor never listed. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy app-switcher support or hint closure', + }, + runtimeTypeNames: ['AppSwitcherRuntimeOperations'], + operations: { names: ['appSwitcher'] }, + singularExecution: { + routes: ['dispatchGenericCommand'], + operations: ['appSwitcher'], + operationOwners: { appSwitcher: ['executeAppSwitcher'] }, + }, + extensions: [retiredDispatchProjectionProof('app-switcher')], + }, + { + rule: 'R57 trigger-app-event-runtime-cutover', + command: 'trigger-app-event', + subject: 'app-event delivery', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The dispatch-table arm and its `core/dispatch.ts` handler, plus the session route's + // last capability-gate-then-`dispatchCommand` thunk: with `trigger-app-event` bound, every + // leaf on that route supplies a bind-and-execute thunk instead. The daemon route function + // is `handleAppEventCommand` now, so the retired name is genuinely absent rather than + // shadowed by a surviving namesake. + routeNames: ['handleTriggerAppEventCommand', 'legacySessionDispatchExecute'], + }, + runtimeTypeNames: ['AppEventRuntimeOperations'], + operations: { names: ['triggerAppEvent'] }, + singularExecution: { + routes: ['resolveBoundAppEventRuntime'], + operations: ['triggerAppEvent'], + operationOwners: { triggerAppEvent: ['executeAppEvent'] }, + }, + extensions: [retiredDispatchProjectionProof('trigger-app-event')], + }, + { + rule: 'R58 settings-runtime-cutover', + command: 'settings', + subject: 'device settings mutation', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // `settings` was the last `DISPATCH_HANDLERS` arm, so this row retires the legacy command + // dispatcher whole — its table, its three entry points, its name enumerator, and the + // request-router fallback that reached for it when no runtime route claimed a command. + // Every one of these names is now absent from production rather than shadowed. + routeNames: [ + 'dispatchCommand', + 'dispatchWithInteractor', + 'dispatchKnownCommand', + 'DISPATCH_HANDLERS', + 'listRegisteredDispatchCommandNames', + 'executeGenericPlatformCommand', + ], + // Settings also leaves the HarmonyOS overlay that granted it a bucket membership the + // descriptor never listed; the set still exists for `perf` and must no longer name it. + staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy settings support or hint closure', + }, + runtimeTypeNames: ['SettingsRuntimeOperations'], + operations: { names: ['setSetting'] }, + singularExecution: { + routes: ['handleSettingsCommand'], + operations: ['setSetting'], + operationOwners: { setSetting: ['executeSetSetting'] }, + }, + extensions: [retiredDispatchProjectionProof('settings')], + }, + { + rule: 'R59 alert-runtime-cutover', + command: 'alert', + subject: 'native alert handling', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // `alert` never had a dispatch-table arm: its daemon route called the Apple runner, the + // macOS helper and the Android alert module directly, and owned their poll and retry + // windows itself. Those four names are what R59 retires, and with the Apple closure gone + // the per-AppleOS capability table lost its last reader and went too. + routeNames: [ + 'handleNativeAlertCommand', + 'waitForNativeAlert', + 'handleNativeAlertAction', + 'supportsAlertSurface', + ], + modulePaths: ['src/platforms/apple/capabilities.ts'], + }, + admissionMember: { + forms: ['computed-property'], + files: ['src/platforms/apple/plugin.ts'], + message: 'Apple plugin retains a legacy alert support or hint closure', + }, + runtimeTypeNames: ['AlertRuntimeOperations'], + operations: { names: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] }, + singularExecution: { + // Action-selected (R35's lesson): the snapshot route resolves exactly one of the four legs + // per request, binds once, and never two together. + routes: ['resolveBoundAlertRuntime'], + operations: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'], + operationOwners: { + readAlert: ['executeReadAlert'], + awaitAlert: ['executeAwaitAlert'], + acceptAlert: ['executeAcceptAlert'], + dismissAlert: ['executeDismissAlert'], + }, + }, + extensions: [retiredDispatchProjectionProof('alert')], + }, + { + rule: 'R61 react-native-runtime-cutover', + command: 'react-native', + subject: 'React Native overlay dismissal', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + // The command's device work moved onto a bound `tapPoint` with R48; what R61 retires is the + // capability gate that still stood in front of it (proved by this table's own admission + // check) and the resolve-then-execute shape that gate implied. The dismissal function was + // renamed to `executeReactNativeOverlayDismiss` to record that: it no longer resolves + // anything, so the old name is genuinely absent rather than shadowed by a namesake. + routeNames: ['dismissReactNativeOverlayTarget'], + }, + runtimeTypeNames: ['BoundTouchRuntime'], + operations: { names: ['tapPoint'] }, + singularExecution: { + // Admission happens once, before the observing capture; the dismissal reuses that binding + // rather than admitting a second time after the overlay is known. + routes: ['executeReactNativeOverlayDismiss'], + operations: ['tapPoint'], + operationOwners: { tapPoint: ['createTapTouchExecutor'] }, + }, + extensions: [retiredDispatchProjectionProof('react-native')], + }, +]; diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index 4e2ad0a8e6..25e27b0827 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -9,9 +9,10 @@ import { prepareLifecycleRouteBindingViolations, runtimeLifecycleRouteBindingViolations, sourceExecutedUsingDeclarationViolations, + retiredDispatchProjectionProof, } from './runtime-command-cutover-extensions.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; -import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts'; +import { WAVE_6_COMMAND_CUTOVERS } from './runtime-command-cutover-table-wave6.ts'; /** * One row per migrated command (ADR 0019 §8). A new command unit adds a row here; the @@ -1078,202 +1079,9 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, }, }, - { - rule: 'R55 clipboard-runtime-cutover', - command: 'clipboard', - subject: 'device clipboard', - tier: 'request-scoped', - execution: 'device-runtime', - legacyRetirement: { - // The dispatch-table arm and its `core/dispatch.ts` handler. The daemon route function was - // renamed to `handleSessionClipboardCommand` when it moved out of the over-budget - // `handlers/session.ts`, so this name is now genuinely absent from production rather than - // shadowed by a surviving namesake. - routeNames: ['handleClipboardCommand'], - }, - admissionMember: { - forms: ['computed-property'], - files: ['src/platforms/apple/plugin.ts'], - message: 'Apple plugin retains a legacy clipboard support or hint closure', - }, - runtimeTypeNames: ['ClipboardRuntimeOperations'], - operations: { names: ['readClipboard', 'writeClipboard'] }, - singularExecution: { - // `clipboard` is action-selected (R35's lesson): the session route resolves exactly one of - // the two operations per request, binds once, and never both together. - routes: ['resolveBoundClipboardRuntime'], - operations: ['readClipboard', 'writeClipboard'], - operationOwners: { - readClipboard: ['executeClipboardRead'], - writeClipboard: ['executeClipboardWrite'], - }, - }, - extensions: [retiredDispatchProjectionProof('clipboard')], - }, - { - rule: 'R56 app-switcher-runtime-cutover', - command: 'app-switcher', - subject: 'app switcher reveal', - tier: 'request-scoped', - execution: 'device-runtime', - legacyRetirement: { - // The dispatch-table arm; `app-switcher` had no dedicated named handler function to retire - // (its legacy body lived inline in the `DISPATCH_HANDLERS` literal). It also leaves the - // HarmonyOS overlay that granted it a capability bucket the descriptor never listed. - staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], - }, - admissionMember: { - forms: ['computed-property'], - files: ['src/platforms/apple/plugin.ts'], - message: 'Apple plugin retains a legacy app-switcher support or hint closure', - }, - runtimeTypeNames: ['AppSwitcherRuntimeOperations'], - operations: { names: ['appSwitcher'] }, - singularExecution: { - routes: ['dispatchGenericCommand'], - operations: ['appSwitcher'], - operationOwners: { appSwitcher: ['executeAppSwitcher'] }, - }, - extensions: [retiredDispatchProjectionProof('app-switcher')], - }, - { - rule: 'R57 trigger-app-event-runtime-cutover', - command: 'trigger-app-event', - subject: 'app-event delivery', - tier: 'request-scoped', - execution: 'device-runtime', - legacyRetirement: { - // The dispatch-table arm and its `core/dispatch.ts` handler, plus the session route's - // last capability-gate-then-`dispatchCommand` thunk: with `trigger-app-event` bound, every - // leaf on that route supplies a bind-and-execute thunk instead. The daemon route function - // is `handleAppEventCommand` now, so the retired name is genuinely absent rather than - // shadowed by a surviving namesake. - routeNames: ['handleTriggerAppEventCommand', 'legacySessionDispatchExecute'], - }, - runtimeTypeNames: ['AppEventRuntimeOperations'], - operations: { names: ['triggerAppEvent'] }, - singularExecution: { - routes: ['resolveBoundAppEventRuntime'], - operations: ['triggerAppEvent'], - operationOwners: { triggerAppEvent: ['executeAppEvent'] }, - }, - extensions: [retiredDispatchProjectionProof('trigger-app-event')], - }, - { - rule: 'R58 settings-runtime-cutover', - command: 'settings', - subject: 'device settings mutation', - tier: 'request-scoped', - execution: 'device-runtime', - legacyRetirement: { - // `settings` was the last `DISPATCH_HANDLERS` arm, so this row retires the legacy command - // dispatcher whole — its table, its three entry points, its name enumerator, and the - // request-router fallback that reached for it when no runtime route claimed a command. - // Every one of these names is now absent from production rather than shadowed. - routeNames: [ - 'dispatchCommand', - 'dispatchWithInteractor', - 'dispatchKnownCommand', - 'DISPATCH_HANDLERS', - 'listRegisteredDispatchCommandNames', - 'executeGenericPlatformCommand', - ], - // Settings also leaves the HarmonyOS overlay that granted it a bucket membership the - // descriptor never listed; the set still exists for `perf` and must no longer name it. - staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS'], - }, - admissionMember: { - forms: ['computed-property'], - files: ['src/platforms/apple/plugin.ts'], - message: 'Apple plugin retains a legacy settings support or hint closure', - }, - runtimeTypeNames: ['SettingsRuntimeOperations'], - operations: { names: ['setSetting'] }, - singularExecution: { - routes: ['handleSettingsCommand'], - operations: ['setSetting'], - operationOwners: { setSetting: ['executeSetSetting'] }, - }, - extensions: [retiredDispatchProjectionProof('settings')], - }, - { - rule: 'R59 alert-runtime-cutover', - command: 'alert', - subject: 'native alert handling', - tier: 'request-scoped', - execution: 'device-runtime', - legacyRetirement: { - // `alert` never had a dispatch-table arm: its daemon route called the Apple runner, the - // macOS helper and the Android alert module directly, and owned their poll and retry - // windows itself. Those four names are what R59 retires, and with the Apple closure gone - // the per-AppleOS capability table lost its last reader and went too. - routeNames: [ - 'handleNativeAlertCommand', - 'waitForNativeAlert', - 'handleNativeAlertAction', - 'supportsAlertSurface', - ], - modulePaths: ['src/platforms/apple/capabilities.ts'], - }, - admissionMember: { - forms: ['computed-property'], - files: ['src/platforms/apple/plugin.ts'], - message: 'Apple plugin retains a legacy alert support or hint closure', - }, - runtimeTypeNames: ['AlertRuntimeOperations'], - operations: { names: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] }, - singularExecution: { - // Action-selected (R35's lesson): the snapshot route resolves exactly one of the four legs - // per request, binds once, and never two together. - routes: ['resolveBoundAlertRuntime'], - operations: ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'], - operationOwners: { - readAlert: ['executeReadAlert'], - awaitAlert: ['executeAwaitAlert'], - acceptAlert: ['executeAcceptAlert'], - dismissAlert: ['executeDismissAlert'], - }, - }, - extensions: [retiredDispatchProjectionProof('alert')], - }, - { - rule: 'R61 react-native-runtime-cutover', - command: 'react-native', - subject: 'React Native overlay dismissal', - tier: 'request-scoped', - execution: 'device-runtime', - legacyRetirement: { - // The command's device work moved onto a bound `tapPoint` with R48; what R61 retires is the - // capability gate that still stood in front of it (proved by this table's own admission - // check) and the resolve-then-execute shape that gate implied. The dismissal function was - // renamed to `executeReactNativeOverlayDismiss` to record that: it no longer resolves - // anything, so the old name is genuinely absent rather than shadowed by a namesake. - routeNames: ['dismissReactNativeOverlayTarget'], - }, - runtimeTypeNames: ['BoundTouchRuntime'], - operations: { names: ['tapPoint'] }, - singularExecution: { - // Admission happens once, before the observing capture; the dismissal reuses that binding - // rather than admitting a second time after the overlay is known. - routes: ['executeReactNativeOverlayDismiss'], - operations: ['tapPoint'], - operationOwners: { tapPoint: ['createTapTouchExecutor'] }, - }, - extensions: [retiredDispatchProjectionProof('react-native')], - }, + ...WAVE_6_COMMAND_CUTOVERS, ]; -/** - * Every migrated row proves the same thing about its own command: the retired dispatch projection - * is gone. One factory carries the command, so a new row states its name once in the row literal - * instead of adding a ninth identically-shaped wrapper below. - */ -function retiredDispatchProjectionProof( - command: string, -): (sources: ReadonlyMap) => UnruledViolation[] { - return (sources) => retiredDispatchProjectionViolations(sources, command); -} - /** The record mechanics policy predates the row model and reports `path: message`. */ function recordDaemonMechanicsProof(sources: ReadonlyMap): UnruledViolation[] { const production = [...sources].map(([path, source]) => ({ path, source })); diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index b29a81dc08..83aae142d6 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -114,6 +114,7 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ // --- @agent-device/contracts --- 'packages/contracts/src/alert-contract.ts': 1, + 'packages/contracts/src/android-clipboard-support.ts': 1, 'packages/contracts/src/android-input-ownership.ts': 1, 'packages/contracts/src/android-snapshot-quality.ts': 1, 'packages/contracts/src/android-system-chrome.ts': 1, From 8fde5189d9ca2781cf241e080aeff6aed210281e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:56:09 +0000 Subject: [PATCH 11/16] fix(android): never fabricate clipboard availability from a failed probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review blocker on #2021. The probe I added had a `catch { return true }`, then cached that result by device id for the runtime owner's lifetime. A transient adb offline or timeout therefore made `capabilities` advertise the clipboard on a build with no clipboard shell — recreating the exact lie the fix was for, and pinning it for the rest of the session. A test locked the behavior in. Support is now a typed verdict with three states, because "we could not ask" is not "it works": `supported | unsupported | probe-failed`. Only a definitive answer is cached; `probe-failed` refuses conservatively with a hint saying support could not be determined, and is deliberately not remembered, so the next inspection asks again. The same change repairs the ownership boundary. Turning raw adb stdout/stderr into a verdict is Android tool knowledge, so it belongs to the Android owner, not to shared vocabulary — `@agent-device/contracts/android-clipboard-support` now carries the typed union alone. The parser returns to `src/platforms/android/adb.ts` and runs in exactly one place, behind a new `AndroidToolHost.probeClipboardShellSupport` that hands owners the verdict. That also settles which Android home owns it: R13 lets only `src/platform-runtime.ts` import `@agent-device/platform-android`, so a parser shared between the package and the root leaf cannot live in the package either. Tests now cover the failure path the previous ones locked the wrong way: a failed probe refuses instead of admitting, its refusal says it could not determine support rather than claiming the build lacks it, and it is not cached — a second inspection re-probes and admits once the device answers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- .../src/android-clipboard-support.ts | 24 ++-- .../contracts/src/platform-runtime-host.ts | 17 +++ .../src/deployment/native.test.ts | 1 + packages/platform-android/src/runtime.test.ts | 104 ++++++++++++------ packages/platform-android/src/runtime.ts | 68 ++++++------ src/platform-runtime-android-tool-host.ts | 20 ++++ src/platforms/android/adb.ts | 15 ++- 7 files changed, 166 insertions(+), 83 deletions(-) diff --git a/packages/contracts/src/android-clipboard-support.ts b/packages/contracts/src/android-clipboard-support.ts index f07c966560..81b293c5bd 100644 --- a/packages/contracts/src/android-clipboard-support.ts +++ b/packages/contracts/src/android-clipboard-support.ts @@ -1,19 +1,13 @@ /** - * Whether an adb `cmd clipboard` invocation was refused because this Android build ships no shell - * implementation for the clipboard service, rather than because the call itself failed. + * What an Android build's clipboard shell service answered when the owner asked. * - * Shared so admission and execution decide identically: `packages/platform-android` probes with it - * when generating facts, and the root clipboard leaf re-checks with it as defense in depth. Two - * copies of this predicate could drift into a device that `capabilities` advertises and execution - * refuses, which is exactly the split ADR 0019 §2 forbids. + * Three states, not two, because "we could not ask" is not "it works". `cmd clipboard` has no + * shell implementation on every build, and admission has to distinguish a build that said so from + * a probe that never got an answer — equating unknown with supported is how `capabilities` comes + * to advertise a clipboard that execution then refuses. * - * It reads adb's own output because adb reports this condition in no other way: there is no exit - * code or structured field that distinguishes "service has no shell command" from any other - * non-zero result. + * Contracts carry the typed verdict only. Turning raw adb output into it is Android tool + * knowledge and stays with the Android owner (ADR 0019: platform output parsing belongs to the + * owning family, never to shared vocabulary). */ -export function isAndroidClipboardShellUnsupported(stdout: string, stderr: string): boolean { - const haystack = `${stdout}\n${stderr}`.toLowerCase(); - return ( - haystack.includes('no shell command implementation') || haystack.includes('unknown command') - ); -} +export type AndroidClipboardShellSupport = 'supported' | 'unsupported' | 'probe-failed'; diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts index b2bb8c06a2..8951e3afc7 100644 --- a/packages/contracts/src/platform-runtime-host.ts +++ b/packages/contracts/src/platform-runtime-host.ts @@ -1,5 +1,6 @@ import type { DeviceInfo, Platform } from '@agent-device/kernel/device'; import type { JsonObject, JsonValue } from './json.ts'; +import type { AndroidClipboardShellSupport } from './android-clipboard-support.ts'; export type HostCommandRequest = Readonly<{ executable: string; @@ -68,6 +69,22 @@ export type AppleToolHost = Readonly<{ /** Device-scoped Android transport selected by root composition; packages own all adb arguments. */ export type AndroidToolHost = Readonly<{ + /** + * Asks this device whether its clipboard service answers shell commands, as typed evidence. + * + * A host method rather than a `runAdb` call the caller classifies itself: normalizing adb's + * output is Android tool knowledge, and it happens once here rather than in every owner that + * needs the verdict. + * + * Optional, and its absence is not permission to assume support: a host that cannot probe + * leaves the owner unable to establish the capability, so the clipboard is refused rather than + * admitted. Only fabricated availability is forbidden — a refusal on incomplete information is + * the safe answer. + */ + probeClipboardShellSupport?( + device: DeviceInfo, + signal?: AbortSignal, + ): Promise; runAdb( device: DeviceInfo, args: readonly string[], diff --git a/packages/platform-android/src/deployment/native.test.ts b/packages/platform-android/src/deployment/native.test.ts index d5bc2459a7..fce1035405 100644 --- a/packages/platform-android/src/deployment/native.test.ts +++ b/packages/platform-android/src/deployment/native.test.ts @@ -28,6 +28,7 @@ function hostFixture(options: { bundletool?: boolean; bundletoolJar?: string } = }, androidDeployment: { bundletoolJar: options.bundletoolJar }, androidTools: { + probeClipboardShellSupport: async () => 'supported' as const, runAdb: async (_device, args, commandOptions, signal) => await run({ executable: 'adb', args, ...commandOptions }, signal), installPackage: async (_device, packagePath, options, signal) => diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index ef87ac727d..c3ab55885e 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import type { DeviceBinding, PlatformRuntimeHost, @@ -32,6 +33,7 @@ test.each([ stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}', })); const host = { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, commands: { which: async () => 'tool', run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }), @@ -156,6 +158,7 @@ test.each([ test('rejects the non-discovered Android simulator cell for appstate', async () => { const runtimeDevice = { ...device, kind: 'simulator' as const }; const host = { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, localInteractors: { resolve: async () => ({}) }, appState: { @@ -197,17 +200,13 @@ test('rejects the non-discovered Android simulator cell for appstate', async () }); function androidNavigationHostFixture( - runAdb?: ( - device: DeviceInfo, - args: readonly string[], - ) => Promise<{ - stdout: string; - stderr: string; - exitCode: number; - }>, + probeClipboardShellSupport: () => Promise = async () => 'supported', ) { return { - androidTools: { runAdb: runAdb ?? (async () => ({ stdout: '', stderr: '', exitCode: 0 })) }, + androidTools: { + probeClipboardShellSupport, + runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, appInventory: { apple: { listApps: async () => [] }, @@ -417,6 +416,7 @@ test.each([ 'classifies the Android %s lifecycle denominator against the legacy dispatch cell', async (_name, runtimeDevice, legacy) => { const host = { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, appInventory: { apple: { listApps: async () => [] }, @@ -568,6 +568,7 @@ test('binds only the Android gesture tiers the target admitted', async () => { function gestureHost(): PlatformRuntimeHost { return { + androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, appInventory: { apple: { listApps: async () => [] }, @@ -583,12 +584,9 @@ function gestureHost(): PlatformRuntimeHost { // clipboard halves on every real Android kind, `capabilities` advertised `clipboard`, and // `clipboard read` then failed with `UNSUPPORTED_OPERATION` from the leaf. Admission now probes // the same condition the leaf checks, so a build with no clipboard shell command refuses up front. -test.each([ - ['no shell command implementation', 'cmd: No shell command implementation.'], - ['unknown command', 'Unknown command: clipboard'], -])('refuses both clipboard halves when adb reports %s', async (_name, stdout) => { +test('refuses both clipboard halves when the build reports no clipboard shell', async () => { const binding = await createAndroidPlatformRuntime( - androidNavigationHostFixture(async () => ({ stdout, stderr: '', exitCode: 0 })), + androidNavigationHostFixture(async () => 'unsupported'), ).bind({ device: { ...device, id: 'android-no-clipboard-shell', kind: 'device' }, intent: { kind: 'ordinary' }, @@ -611,29 +609,29 @@ test.each([ expect(binding.facts.operations.appSwitcher).toEqual({ available: true }); }); -test('probes the clipboard shell once per device, not once per inspection', async () => { - const runAdb = vi.fn(async (_device: DeviceInfo, _args: readonly string[]) => ({ - stdout: '', - stderr: '', - exitCode: 0, - })); - const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(runAdb)); - const target = { ...device, id: 'android-probe-cache', kind: 'device' as const }; +test.each([['supported'], ['unsupported']] as const)( + 'caches a definitive %s verdict instead of re-probing per inspection', + async (verdict) => { + const probe = vi.fn(async () => verdict); + const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe)); + const target = { ...device, id: `android-probe-cache-${verdict}`, kind: 'device' as const }; - await runtime.inspectFacts(target); - await runtime.inspectFacts(target); + await runtime.inspectFacts(target); + await runtime.inspectFacts(target); - const clipboardProbes = runAdb.mock.calls.filter((call) => call[1].includes('clipboard')); - expect(clipboardProbes).toHaveLength(1); -}); + expect(probe).toHaveBeenCalledTimes(1); + }, +); -test('an adb failure leaves the clipboard admitted rather than inventing an unsupported build', async () => { +// The failure path is the one that recreates the defect if it guesses. A probe that never got an +// answer must not report the clipboard available — execution would then refuse the very capability +// `capabilities` advertised — and must not be remembered, or one transport blip decides the +// question for the owner's whole life. +test('a failed probe refuses rather than fabricating availability', async () => { const binding = await createAndroidPlatformRuntime( - androidNavigationHostFixture(async () => { - throw new Error('adb: device offline'); - }), + androidNavigationHostFixture(async () => 'probe-failed'), ).bind({ - device: { ...device, id: 'android-adb-offline', kind: 'device' }, + device: { ...device, id: 'android-probe-failed', kind: 'device' }, intent: { kind: 'ordinary' }, scope: { signal: new AbortController().signal, @@ -642,8 +640,42 @@ test('an adb failure leaves the clipboard admitted rather than inventing an unsu }, }); - // The probe is definitive in one direction only: adb naming the condition means unsupported, a - // probe that cannot run means unknown. Reporting unknown as unsupported would hide a working - // clipboard behind a transport hiccup. - expect(binding.facts.operations.readClipboard).toEqual({ available: true }); + for (const key of ['readClipboard', 'writeClipboard'] as const) { + expect(binding.facts.operations[key]).toMatchObject({ available: false }); + expect(binding.operations[key]).toBeUndefined(); + } + // The refusal says it could not determine support, not that the build lacks it. + const fact = binding.facts.operations.readClipboard; + expect(fact.available === false && String(fact.hint)).toMatch(/could not determine/i); +}); + +test('a failed probe is not cached, so the next inspection asks again', async () => { + const probe = vi + .fn<() => Promise>() + .mockResolvedValueOnce('probe-failed') + .mockResolvedValue('supported'); + const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe)); + const target = { ...device, id: 'android-probe-retry', kind: 'device' as const }; + + const first = await runtime.inspectFacts(target); + const second = await runtime.inspectFacts(target); + + expect(first.operations.readClipboard.available).toBe(false); + expect(second.operations.readClipboard.available).toBe(true); + expect(probe).toHaveBeenCalledTimes(2); +}); + +test('a host with no clipboard probe refuses rather than assuming support', async () => { + const host = androidNavigationHostFixture(); + const withoutProbe = { ...host, androidTools: {} } as unknown as PlatformRuntimeHost; + + const facts = await createAndroidPlatformRuntime(withoutProbe).inspectFacts({ + ...device, + id: 'android-no-probe', + kind: 'device', + }); + + // Absence of a probe is absence of evidence, not evidence of support. + expect(facts.operations.readClipboard.available).toBe(false); + expect(facts.operations.writeClipboard.available).toBe(false); }); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 24147e6e44..82615087f3 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -50,7 +50,7 @@ import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts'; import { ensureAndroidReady } from './readiness/runtime.ts'; import { readAndroidAppState } from './app-state.ts'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; -import { isAndroidClipboardShellUnsupported } from '@agent-device/contracts/android-clipboard-support'; +import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import { androidAppDeploymentFacts, createAndroidAppDeploymentOperations, @@ -210,6 +210,33 @@ const clipboardShellUnavailable = Object.freeze({ hint: 'This Android build ships no shell implementation for the clipboard service, so adb cannot read or write the clipboard on it.', } as const); +/** + * The probe could not reach the device, so this owner does not know whether the build supports a + * shell clipboard. Refusing is the conservative answer and the only honest one: reporting + * available would hand the caller a capability execution may immediately reject, which is the + * exact failure fact-based admission exists to prevent. Deliberately not cached — the next + * inspection asks again. + */ +const clipboardShellUnknown = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'Could not determine whether this Android build supports a shell clipboard: the adb probe did not complete. Retry once the device is reachable.', +} as const); + +/** + * `probe-failed` covers both ways this owner can end up without an answer: the probe ran and could + * not reach the device, or the host exposes no probe at all. Neither is evidence of support, and + * both refuse rather than guess. + */ +async function probeClipboardShellSupport( + host: PlatformRuntimeHost, + device: DeviceInfo, +): Promise { + const probe = host.androidTools?.probeClipboardShellSupport; + if (!probe) return 'probe-failed'; + return await probe.call(host.androidTools, device); +} + const tvRemoteUnavailable = Object.freeze({ available: false, reason: 'unsupported-device-kind', @@ -235,15 +262,17 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor * Cached per device for this owner's lifetime: a build's shell command set cannot change while * the device is up, and admission would otherwise pay an adb round trip per request. */ - const clipboardShell = new Map>(); + const clipboardShell = new Map(); const clipboardFact = async (device: DeviceInfo): Promise => { if (device.kind === 'simulator') return clipboardShellUnavailable; - let probe = clipboardShell.get(device.id); - if (!probe) { - probe = probeAndroidClipboardShell(host, device); - clipboardShell.set(device.id, probe); - } - return (await probe) ? available : clipboardShellUnavailable; + const support = + clipboardShell.get(device.id) ?? (await probeClipboardShellSupport(host, device)); + // Only a definitive answer is worth keeping: a build's shell command set cannot change while + // the device is up, but a failed probe says nothing about the build and must not become a + // verdict this owner repeats for the rest of its life. + if (support !== 'probe-failed') clipboardShell.set(device.id, support); + if (support === 'supported') return available; + return support === 'unsupported' ? clipboardShellUnavailable : clipboardShellUnknown; }; const inspectFacts = async (device: Parameters[0]) => { const logs = await appLogs.inspectFacts(device); @@ -461,26 +490,3 @@ function androidInteractionOperations( }), }; } - -/** - * Asks the device, once, whether its clipboard service answers shell commands at all. - * - * Definitive only in one direction. adb naming the condition means unsupported; a probe that - * cannot run (offline transport, adb failure) says nothing about shell support, so it reports - * supported and leaves the real transport error to the operation that actually runs. - */ -async function probeAndroidClipboardShell( - host: PlatformRuntimeHost, - device: DeviceInfo, -): Promise { - try { - const result = await host.androidTools.runAdb( - device, - ['shell', 'cmd', 'clipboard', 'get', 'text'], - { allowFailure: true }, - ); - return !isAndroidClipboardShellUnsupported(result.stdout, result.stderr); - } catch { - return true; - } -} diff --git a/src/platform-runtime-android-tool-host.ts b/src/platform-runtime-android-tool-host.ts index b64cbcabbc..cdc4db75d6 100644 --- a/src/platform-runtime-android-tool-host.ts +++ b/src/platform-runtime-android-tool-host.ts @@ -3,6 +3,26 @@ import type { AndroidToolHost } from '@agent-device/contracts/platform'; /** Provider-aware Android transport. Command semantics and arguments stay package-owned. */ export function createAndroidToolHost(): AndroidToolHost { return Object.freeze({ + /** + * Definitive in both directions where adb answers, and honest when it does not: a transport + * failure reports `probe-failed` rather than a guess, so admission can refuse instead of + * fabricating availability the operation would then reject. + */ + probeClipboardShellSupport: async (device, signal) => { + try { + const { runAndroidAdb, isClipboardShellUnsupported } = + await import('./platforms/android/adb.ts'); + const result = await runAndroidAdb(device, ['shell', 'cmd', 'clipboard', 'get', 'text'], { + allowFailure: true, + signal, + }); + return isClipboardShellUnsupported(result.stdout, result.stderr) + ? 'unsupported' + : 'supported'; + } catch { + return 'probe-failed'; + } + }, runAdb: async (device, args, options, signal) => { const { runAndroidAdb } = await import('./platforms/android/adb.ts'); const result = await runAndroidAdb(device, [...args], { diff --git a/src/platforms/android/adb.ts b/src/platforms/android/adb.ts index 8236af1f08..00d705aa3a 100644 --- a/src/platforms/android/adb.ts +++ b/src/platforms/android/adb.ts @@ -15,4 +15,17 @@ export async function runAndroidAdb( return await resolveAndroidAdbExecutor(device)(args, options); } -export { isAndroidClipboardShellUnsupported as isClipboardShellUnsupported } from '@agent-device/contracts/android-clipboard-support'; +/** + * Whether an adb `cmd clipboard` invocation was refused because this build ships no shell + * implementation for the clipboard service, rather than because the call itself failed. + * + * adb reports this condition in its output and nowhere else — no exit code or structured field + * separates "service has no shell command" from any other non-zero result — so this is the one + * place that reads that prose, and it hands every caller a typed answer instead. + */ +export function isClipboardShellUnsupported(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}`.toLowerCase(); + return ( + haystack.includes('no shell command implementation') || haystack.includes('unknown command') + ); +} From d3635c76113f5f464673143146d70f44f11520ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 12:24:14 +0000 Subject: [PATCH 12/16] refactor(contracts): declare each interactor operation once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review P1 on #2021. `interactor-operation-catalog.ts` declared the same operation set three times — a name tuple, a complete local binder map, and a complete provider binder map — and each facet carried a mirrored `bindLocal…Interactor`/`bindProvider…Interactor` pair whose only difference was which interactor source to use and which label a refusal names. There is now one row per operation, carrying its facts key, its provider refusal label, and the facet's own executor. The local/provider split lives in the two adapters, which differ by exactly the thing that differs: the interactor source. Adding an operation is adding one row. Deleted: the parallel tuple, both binder maps, 32 mirrored wrappers across ten facet modules, and the per-facet `Local…`/`Provider…InteractorResolver` aliases that existed only to be re-exported. Kept: every facet's typed executor, now exported as its binding surface. Net −563 production lines in `packages/contracts`. Two consumers moved onto the catalog's public entry point rather than keeping a private path to a single operation: the app-event delivery test and the provider scenario fixture, whose two hand-bound keyboard legs are now whichever legs its facts admit. Each facet's tests spell out the composition the retired wrappers performed, so every assertion still exercises one executor reached through one source. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- packages/contracts/src/alert-runtime.test.ts | 43 ++- packages/contracts/src/alert-runtime.ts | 89 +----- .../contracts/src/app-event-runtime.test.ts | 26 +- packages/contracts/src/app-event-runtime.ts | 37 +-- .../src/app-switcher-runtime.test.ts | 26 +- .../contracts/src/app-switcher-runtime.ts | 37 +-- packages/contracts/src/back-runtime.test.ts | 24 +- packages/contracts/src/back-runtime.ts | 34 +-- .../contracts/src/clipboard-runtime.test.ts | 38 ++- packages/contracts/src/clipboard-runtime.ts | 62 +--- packages/contracts/src/facades/platform.ts | 42 +-- packages/contracts/src/home-runtime.test.ts | 24 +- packages/contracts/src/home-runtime.ts | 34 +-- .../src/interactor-operation-catalog.ts | 286 +++++++----------- .../contracts/src/keyboard-runtime.test.ts | 57 +++- packages/contracts/src/keyboard-runtime.ts | 101 +------ .../contracts/src/orientation-runtime.test.ts | 25 +- packages/contracts/src/orientation-runtime.ts | 37 +-- .../contracts/src/settings-runtime.test.ts | 22 +- packages/contracts/src/settings-runtime.ts | 37 +-- .../contracts/src/tv-remote-runtime.test.ts | 24 +- packages/contracts/src/tv-remote-runtime.ts | 37 +-- src/__tests__/eager-closure-budgets.ts | 20 +- src/core/__tests__/app-event-delivery.test.ts | 9 +- .../provider-device-runtime.fixtures.ts | 12 +- 25 files changed, 385 insertions(+), 798 deletions(-) diff --git a/packages/contracts/src/alert-runtime.test.ts b/packages/contracts/src/alert-runtime.test.ts index f00488825c..a24cd6ec96 100644 --- a/packages/contracts/src/alert-runtime.test.ts +++ b/packages/contracts/src/alert-runtime.test.ts @@ -1,12 +1,6 @@ import { expect, test, vi } from 'vitest'; -import { - alertRuntimeOperationFacts, - bindLocalAlertAcceptInteractor, - bindLocalAlertDismissInteractor, - bindLocalAlertReadInteractor, - bindLocalAlertWaitInteractor, - bindProviderAlertAcceptInteractor, -} from './alert-runtime.ts'; +import { alertRuntimeOperationFacts, bindAlertLeg } from './alert-runtime.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; import type { AlertInteractorOptions, Interactor } from './interactor-types.ts'; const device = { @@ -18,6 +12,39 @@ const device = { booted: true, } as const; +// The composition the interactor catalog performs, spelled out so each assertion below +// still exercises one facet executor reached through one interactor source. +const bindLocalAlertReadInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('readAlert', params.signal, localInteractorSource(params)); +const bindLocalAlertWaitInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('awaitAlert', params.signal, localInteractorSource(params)); +const bindLocalAlertAcceptInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('acceptAlert', params.signal, localInteractorSource(params)); +const bindLocalAlertDismissInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindAlertLeg('dismissAlert', params.signal, localInteractorSource(params)); +const bindProviderAlertAcceptInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindAlertLeg( + 'acceptAlert', + params.signal, + providerInteractorSource({ ...params, operation: 'alert accept' }), + ); + test('builds the exact alert operation fact catalog', () => { const read = { available: true } as const; const wait = { available: false, reason: 'owner-capability-missing' } as const; diff --git a/packages/contracts/src/alert-runtime.ts b/packages/contracts/src/alert-runtime.ts index 9913b5486f..59773a58e8 100644 --- a/packages/contracts/src/alert-runtime.ts +++ b/packages/contracts/src/alert-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { AlertInteractorOptions, Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SessionSurface } from './session-surface.ts'; @@ -111,7 +104,7 @@ function alertInteractorOptions(input: AlertRuntimeInput): AlertInteractorOption type AlertLeg = 'readAlert' | 'awaitAlert' | 'acceptAlert' | 'dismissAlert'; -function bindAlertLeg( +export function bindAlertLeg( leg: Leg, signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, @@ -123,83 +116,3 @@ function bindAlertLeg( }, }) as Readonly Promise>>>; } - -export type LocalAlertInteractorResolver = LocalInteractorOperationResolver; -export type ProviderAlertInteractorResolver = ProviderInteractorOperationResolver; - -type LocalAlertBinding = Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalAlertInteractorResolver; -}>; - -type ProviderAlertBinding = Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderAlertInteractorResolver; -}>; - -export function bindLocalAlertReadInteractor( - params: LocalAlertBinding, -): AlertReadRuntimeOperations { - return bindAlertLeg('readAlert', params.signal, localInteractorSource(params)); -} - -export function bindLocalAlertWaitInteractor( - params: LocalAlertBinding, -): AlertWaitRuntimeOperations { - return bindAlertLeg('awaitAlert', params.signal, localInteractorSource(params)); -} - -export function bindLocalAlertAcceptInteractor( - params: LocalAlertBinding, -): AlertAcceptRuntimeOperations { - return bindAlertLeg('acceptAlert', params.signal, localInteractorSource(params)); -} - -export function bindLocalAlertDismissInteractor( - params: LocalAlertBinding, -): AlertDismissRuntimeOperations { - return bindAlertLeg('dismissAlert', params.signal, localInteractorSource(params)); -} - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderAlertReadInteractor( - params: ProviderAlertBinding, -): AlertReadRuntimeOperations { - return bindAlertLeg( - 'readAlert', - params.signal, - providerInteractorSource({ ...params, operation: 'alert get' }), - ); -} - -export function bindProviderAlertWaitInteractor( - params: ProviderAlertBinding, -): AlertWaitRuntimeOperations { - return bindAlertLeg( - 'awaitAlert', - params.signal, - providerInteractorSource({ ...params, operation: 'alert wait' }), - ); -} - -export function bindProviderAlertAcceptInteractor( - params: ProviderAlertBinding, -): AlertAcceptRuntimeOperations { - return bindAlertLeg( - 'acceptAlert', - params.signal, - providerInteractorSource({ ...params, operation: 'alert accept' }), - ); -} - -export function bindProviderAlertDismissInteractor( - params: ProviderAlertBinding, -): AlertDismissRuntimeOperations { - return bindAlertLeg( - 'dismissAlert', - params.signal, - providerInteractorSource({ ...params, operation: 'alert dismiss' }), - ); -} diff --git a/packages/contracts/src/app-event-runtime.test.ts b/packages/contracts/src/app-event-runtime.test.ts index 188cf42f12..1d481549e9 100644 --- a/packages/contracts/src/app-event-runtime.test.ts +++ b/packages/contracts/src/app-event-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - appEventRuntimeOperationFacts, - bindLocalAppEventInteractor, - bindProviderAppEventInteractor, -} from './app-event-runtime.ts'; +import { appEventRuntimeOperationFacts, bindAppEvent } from './app-event-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,19 @@ const device = { booted: true, } as const; +const bindAppEventLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindAppEvent(params.signal, localInteractorSource(params)); +const bindAppEventProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => + bindAppEvent( + params.signal, + providerInteractorSource({ ...params, operation: 'trigger-app-event' }), + ); + test('builds the exact app-event operation fact catalog', () => { const triggerAppEvent = { available: true } as const; expect(appEventRuntimeOperationFacts({ triggerAppEvent })).toEqual({ triggerAppEvent }); @@ -26,7 +36,7 @@ test('a local binding opens the resolved event URL against the session app', asy const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalAppEventInteractor({ device, signal, resolveInteractor }); + const operations = bindAppEventLocal({ device, signal, resolveInteractor }); await operations.triggerAppEvent({ eventUrl: 'myapp://agent-device/event?name=checkout', options: { appBundleId: 'com.example.app' }, @@ -45,7 +55,7 @@ test('a local binding opens the resolved event URL against the session app', asy }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderAppEventInteractor({ + const operations = bindAppEventProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -63,7 +73,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const open = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); - const operations = bindLocalAppEventInteractor({ + const operations = bindAppEventLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/app-event-runtime.ts b/packages/contracts/src/app-event-runtime.ts index 8993b9c374..20b4d2a6dd 100644 --- a/packages/contracts/src/app-event-runtime.ts +++ b/packages/contracts/src/app-event-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -46,7 +39,7 @@ export function appEventRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the delivery itself. */ -function bindAppEvent( +export function bindAppEvent( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): AppEventRuntimeOperations { @@ -62,31 +55,3 @@ function bindAppEvent( }, }); } - -export type LocalAppEventInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalAppEventInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalAppEventInteractorResolver; - }>, -): AppEventRuntimeOperations { - return bindAppEvent(params.signal, localInteractorSource(params)); -} - -export type ProviderAppEventInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderAppEventInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderAppEventInteractorResolver; - }>, -): AppEventRuntimeOperations { - return bindAppEvent( - params.signal, - providerInteractorSource({ ...params, operation: 'trigger-app-event' }), - ); -} diff --git a/packages/contracts/src/app-switcher-runtime.test.ts b/packages/contracts/src/app-switcher-runtime.test.ts index 9222d5eeb5..d44edae9b3 100644 --- a/packages/contracts/src/app-switcher-runtime.test.ts +++ b/packages/contracts/src/app-switcher-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - appSwitcherRuntimeOperationFacts, - bindLocalAppSwitcherInteractor, - bindProviderAppSwitcherInteractor, -} from './app-switcher-runtime.ts'; +import { appSwitcherRuntimeOperationFacts, bindAppSwitcher } from './app-switcher-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,19 @@ const device = { booted: true, } as const; +const bindAppSwitcherLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindAppSwitcher(params.signal, localInteractorSource(params)); +const bindAppSwitcherProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => + bindAppSwitcher( + params.signal, + providerInteractorSource({ ...params, operation: 'app-switcher' }), + ); + test('builds the exact app-switcher operation fact catalog', () => { const appSwitcher = { available: true } as const; expect(appSwitcherRuntimeOperationFacts({ appSwitcher })).toEqual({ appSwitcher }); @@ -24,7 +34,7 @@ test('a local binding drives the interactor with the request runner context', as const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalAppSwitcherInteractor({ device, signal, resolveInteractor }); + const operations = bindAppSwitcherLocal({ device, signal, resolveInteractor }); await operations.appSwitcher({ options: { appBundleId: 'com.example.app' }, execution: { logPath: '/tmp/daemon.log', requestId: 'switcher-1' }, @@ -40,7 +50,7 @@ test('a local binding drives the interactor with the request runner context', as }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderAppSwitcherInteractor({ + const operations = bindAppSwitcherProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -58,7 +68,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const appSwitcher = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); - const operations = bindLocalAppSwitcherInteractor({ + const operations = bindAppSwitcherLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/app-switcher-runtime.ts b/packages/contracts/src/app-switcher-runtime.ts index a1cadd1188..0852ab5183 100644 --- a/packages/contracts/src/app-switcher-runtime.ts +++ b/packages/contracts/src/app-switcher-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -39,7 +32,7 @@ export function appSwitcherRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the reveal itself. */ -function bindAppSwitcher( +export function bindAppSwitcher( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): AppSwitcherRuntimeOperations { @@ -55,31 +48,3 @@ function bindAppSwitcher( }, }); } - -export type LocalAppSwitcherInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalAppSwitcherInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalAppSwitcherInteractorResolver; - }>, -): AppSwitcherRuntimeOperations { - return bindAppSwitcher(params.signal, localInteractorSource(params)); -} - -export type ProviderAppSwitcherInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderAppSwitcherInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderAppSwitcherInteractorResolver; - }>, -): AppSwitcherRuntimeOperations { - return bindAppSwitcher( - params.signal, - providerInteractorSource({ ...params, operation: 'app-switcher' }), - ); -} diff --git a/packages/contracts/src/back-runtime.test.ts b/packages/contracts/src/back-runtime.test.ts index 2a33277ec3..9c83e0639e 100644 --- a/packages/contracts/src/back-runtime.test.ts +++ b/packages/contracts/src/back-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalBackInteractor, - bindProviderBackInteractor, - backRuntimeOperationFacts, -} from './back-runtime.ts'; +import { bindBack, backRuntimeOperationFacts } from './back-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,15 @@ const device = { booted: true, } as const; +const bindBackLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindBack(params.signal, localInteractorSource(params)); +const bindBackProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindBack(params.signal, providerInteractorSource({ ...params, operation: 'back' })); + test('builds the exact back operation fact catalog', () => { const back = { available: true } as const; expect(backRuntimeOperationFacts({ back })).toEqual({ back }); @@ -24,7 +30,7 @@ test('a local binding drives the interactor with the requested mode', async () = const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalBackInteractor({ device, signal, resolveInteractor }); + const operations = bindBackLocal({ device, signal, resolveInteractor }); await operations.back({ mode: 'system', options: { appBundleId: 'com.example.app' }, @@ -45,7 +51,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ back }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderBackInteractor({ device, signal, resolveInteractor }); + const operations = bindBackProvider({ device, signal, resolveInteractor }); await operations.back({ execution: { requestId: 'back-2' } }); expect(resolveInteractor).toHaveBeenCalledWith({ @@ -57,7 +63,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderBackInteractor({ + const operations = bindBackProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -75,7 +81,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const back = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); - const operations = bindLocalBackInteractor({ + const operations = bindBackLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/back-runtime.ts b/packages/contracts/src/back-runtime.ts index 10ea2e65d7..04a5a6e472 100644 --- a/packages/contracts/src/back-runtime.ts +++ b/packages/contracts/src/back-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { BackMode, Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -43,7 +36,7 @@ export function backRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the navigation itself. */ -function bindBack( +export function bindBack( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): BackRuntimeOperations { @@ -59,28 +52,3 @@ function bindBack( }, }); } - -export type LocalBackInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalBackInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalBackInteractorResolver; - }>, -): BackRuntimeOperations { - return bindBack(params.signal, localInteractorSource(params)); -} - -export type ProviderBackInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderBackInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderBackInteractorResolver; - }>, -): BackRuntimeOperations { - return bindBack(params.signal, providerInteractorSource({ ...params, operation: 'back' })); -} diff --git a/packages/contracts/src/clipboard-runtime.test.ts b/packages/contracts/src/clipboard-runtime.test.ts index 4756347d14..a681602ba7 100644 --- a/packages/contracts/src/clipboard-runtime.test.ts +++ b/packages/contracts/src/clipboard-runtime.test.ts @@ -1,12 +1,11 @@ import { expect, test, vi } from 'vitest'; import { - bindLocalClipboardReadInteractor, - bindLocalClipboardWriteInteractor, - bindProviderClipboardReadInteractor, - bindProviderClipboardWriteInteractor, + bindClipboardRead, + bindClipboardWrite, clipboardRuntimeOperationFacts, } from './clipboard-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -16,6 +15,37 @@ const device = { booted: true, } as const; +// The composition the interactor catalog performs, spelled out so each assertion below +// still exercises one facet executor reached through one interactor source. +const bindLocalClipboardReadInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindClipboardRead(params.signal, localInteractorSource(params)); +const bindLocalClipboardWriteInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindClipboardWrite(params.signal, localInteractorSource(params)); +const bindProviderClipboardReadInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindClipboardRead( + params.signal, + providerInteractorSource({ ...params, operation: 'clipboard read' }), + ); +const bindProviderClipboardWriteInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindClipboardWrite( + params.signal, + providerInteractorSource({ ...params, operation: 'clipboard write' }), + ); + test('builds the exact clipboard operation fact catalog', () => { const read = { available: true } as const; const write = { available: false, reason: 'owner-capability-missing' } as const; diff --git a/packages/contracts/src/clipboard-runtime.ts b/packages/contracts/src/clipboard-runtime.ts index 1dc003a2ac..dcbb033981 100644 --- a/packages/contracts/src/clipboard-runtime.ts +++ b/packages/contracts/src/clipboard-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -76,7 +69,7 @@ async function resolveClipboardInteractor( }); } -function bindClipboardRead( +export function bindClipboardRead( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): ClipboardReadRuntimeOperations { @@ -88,7 +81,7 @@ function bindClipboardRead( }); } -function bindClipboardWrite( +export function bindClipboardWrite( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): ClipboardWriteRuntimeOperations { @@ -99,54 +92,3 @@ function bindClipboardWrite( }, }); } - -export type LocalClipboardInteractorResolver = LocalInteractorOperationResolver; -export type ProviderClipboardInteractorResolver = ProviderInteractorOperationResolver; - -export function bindLocalClipboardReadInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalClipboardInteractorResolver; - }>, -): ClipboardReadRuntimeOperations { - return bindClipboardRead(params.signal, localInteractorSource(params)); -} - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderClipboardReadInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderClipboardInteractorResolver; - }>, -): ClipboardReadRuntimeOperations { - return bindClipboardRead( - params.signal, - providerInteractorSource({ ...params, operation: 'clipboard read' }), - ); -} - -export function bindLocalClipboardWriteInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalClipboardInteractorResolver; - }>, -): ClipboardWriteRuntimeOperations { - return bindClipboardWrite(params.signal, localInteractorSource(params)); -} - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderClipboardWriteInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderClipboardInteractorResolver; - }>, -): ClipboardWriteRuntimeOperations { - return bindClipboardWrite( - params.signal, - providerInteractorSource({ ...params, operation: 'clipboard write' }), - ); -} diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index a64b29d804..85b3f9f38e 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -384,62 +384,34 @@ export type { TypeTextRuntimeOperationFacts, TypeTextRuntimeOperations, } from '../type-text-runtime.ts'; -export { - bindLocalBackInteractor, - bindProviderBackInteractor, - backRuntimeOperationFacts, -} from '../back-runtime.ts'; +export { backRuntimeOperationFacts, bindBack } from '../back-runtime.ts'; export type { BackInput, BackRuntimeOperationFacts, BackRuntimeOperations, - LocalBackInteractorResolver, - ProviderBackInteractorResolver, } from '../back-runtime.ts'; -export { - bindLocalHomeInteractor, - bindProviderHomeInteractor, - homeRuntimeOperationFacts, -} from '../home-runtime.ts'; +export { bindHome, homeRuntimeOperationFacts } from '../home-runtime.ts'; export type { HomeInput, HomeRuntimeOperationFacts, HomeRuntimeOperations, - LocalHomeInteractorResolver, - ProviderHomeInteractorResolver, } from '../home-runtime.ts'; -export { - bindLocalOrientationInteractor, - bindProviderOrientationInteractor, - orientationRuntimeOperationFacts, -} from '../orientation-runtime.ts'; +export { bindOrientation, orientationRuntimeOperationFacts } from '../orientation-runtime.ts'; export type { - LocalOrientationInteractorResolver, OrientationRuntimeOperationFacts, OrientationRuntimeOperations, - ProviderOrientationInteractorResolver, SetOrientationInput, SetOrientationResult, } from '../orientation-runtime.ts'; -export { - bindLocalTvRemoteInteractor, - bindProviderTvRemoteInteractor, - tvRemoteRuntimeOperationFacts, -} from '../tv-remote-runtime.ts'; +export { bindTvRemote, tvRemoteRuntimeOperationFacts } from '../tv-remote-runtime.ts'; export type { - LocalTvRemoteInteractorResolver, - ProviderTvRemoteInteractorResolver, TvRemoteInput, TvRemoteRuntimeOperationFacts, TvRemoteRuntimeOperations, } from '../tv-remote-runtime.ts'; export { - bindLocalKeyboardStatusInteractor, - bindProviderKeyboardStatusInteractor, - bindLocalKeyboardDismissInteractor, - bindProviderKeyboardDismissInteractor, - bindLocalKeyboardEnterInteractor, - bindProviderKeyboardEnterInteractor, + KEYBOARD_ACTION_LABELS, + bindKeyboardAction, keyboardRuntimeOperationFacts, } from '../keyboard-runtime.ts'; export type { @@ -452,8 +424,6 @@ export type { KeyboardRuntimeOperations, KeyboardStatusResult, KeyboardStatusRuntimeOperations, - LocalKeyboardInteractorResolver, - ProviderKeyboardInteractorResolver, } from '../keyboard-runtime.ts'; export { APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS } from '../apple-multitouch-support.ts'; export { viewportRuntimeOperationFacts } from '../viewport-runtime.ts'; diff --git a/packages/contracts/src/home-runtime.test.ts b/packages/contracts/src/home-runtime.test.ts index ac95afce8d..e1860561ef 100644 --- a/packages/contracts/src/home-runtime.test.ts +++ b/packages/contracts/src/home-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalHomeInteractor, - bindProviderHomeInteractor, - homeRuntimeOperationFacts, -} from './home-runtime.ts'; +import { bindHome, homeRuntimeOperationFacts } from './home-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,15 @@ const device = { booted: true, } as const; +const bindHomeLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindHome(params.signal, localInteractorSource(params)); +const bindHomeProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindHome(params.signal, providerInteractorSource({ ...params, operation: 'home' })); + test('builds the exact home operation fact catalog', () => { const home = { available: true } as const; expect(homeRuntimeOperationFacts({ home })).toEqual({ home }); @@ -24,7 +30,7 @@ test('a local binding drives the interactor with no arguments', async () => { const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalHomeInteractor({ device, signal, resolveInteractor }); + const operations = bindHomeLocal({ device, signal, resolveInteractor }); await operations.home({ options: { appBundleId: 'com.example.app' }, execution: { logPath: '/tmp/daemon.log', requestId: 'home-1' }, @@ -44,7 +50,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ home }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderHomeInteractor({ device, signal, resolveInteractor }); + const operations = bindHomeProvider({ device, signal, resolveInteractor }); await operations.home({ execution: { requestId: 'home-2' } }); expect(resolveInteractor).toHaveBeenCalledWith({ @@ -56,7 +62,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderHomeInteractor({ + const operations = bindHomeProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -74,7 +80,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const home = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); - const operations = bindLocalHomeInteractor({ + const operations = bindHomeLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/home-runtime.ts b/packages/contracts/src/home-runtime.ts index e34d9bde28..006bd2b810 100644 --- a/packages/contracts/src/home-runtime.ts +++ b/packages/contracts/src/home-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -36,7 +29,7 @@ export function homeRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the navigation itself. */ -function bindHome( +export function bindHome( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): HomeRuntimeOperations { @@ -52,28 +45,3 @@ function bindHome( }, }); } - -export type LocalHomeInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalHomeInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalHomeInteractorResolver; - }>, -): HomeRuntimeOperations { - return bindHome(params.signal, localInteractorSource(params)); -} - -export type ProviderHomeInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderHomeInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderHomeInteractorResolver; - }>, -): HomeRuntimeOperations { - return bindHome(params.signal, providerInteractorSource({ ...params, operation: 'home' })); -} diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index badb0922e4..b787dd3b9c 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -1,145 +1,104 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { bindLocalBackInteractor, bindProviderBackInteractor } from './back-runtime.ts'; +import { bindAlertLeg } from './alert-runtime.ts'; +import { bindAppEvent } from './app-event-runtime.ts'; +import { bindAppSwitcher } from './app-switcher-runtime.ts'; +import { bindBack } from './back-runtime.ts'; +import { bindClipboardRead, bindClipboardWrite } from './clipboard-runtime.ts'; +import { bindHome } from './home-runtime.ts'; +import { KEYBOARD_ACTION_LABELS, bindKeyboardAction } from './keyboard-runtime.ts'; +import { bindOrientation } from './orientation-runtime.ts'; +import { bindSetSetting } from './settings-runtime.ts'; +import { bindTvRemote } from './tv-remote-runtime.ts'; import { - bindLocalAppEventInteractor, - bindProviderAppEventInteractor, -} from './app-event-runtime.ts'; -import { - bindLocalAppSwitcherInteractor, - bindProviderAppSwitcherInteractor, -} from './app-switcher-runtime.ts'; -import { - bindLocalClipboardReadInteractor, - bindLocalClipboardWriteInteractor, - bindProviderClipboardReadInteractor, - bindProviderClipboardWriteInteractor, -} from './clipboard-runtime.ts'; -import { bindLocalHomeInteractor, bindProviderHomeInteractor } from './home-runtime.ts'; -import { - bindLocalKeyboardDismissInteractor, - bindLocalKeyboardEnterInteractor, - bindLocalKeyboardStatusInteractor, - bindProviderKeyboardDismissInteractor, - bindProviderKeyboardEnterInteractor, - bindProviderKeyboardStatusInteractor, -} from './keyboard-runtime.ts'; -import { - bindLocalOrientationInteractor, - bindProviderOrientationInteractor, -} from './orientation-runtime.ts'; -import type { - LocalInteractorOperationResolver, - ProviderInteractorOperationResolver, + localInteractorSource, + providerInteractorSource, + type LocalInteractorOperationResolver, + type ProviderInteractorOperationResolver, } from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; -import { bindLocalSettingsInteractor, bindProviderSettingsInteractor } from './settings-runtime.ts'; -import { - bindLocalAlertAcceptInteractor, - bindLocalAlertDismissInteractor, - bindLocalAlertReadInteractor, - bindLocalAlertWaitInteractor, - bindProviderAlertAcceptInteractor, - bindProviderAlertDismissInteractor, - bindProviderAlertReadInteractor, - bindProviderAlertWaitInteractor, -} from './alert-runtime.ts'; -import { - bindLocalTvRemoteInteractor, - bindProviderTvRemoteInteractor, -} from './tv-remote-runtime.ts'; /** - * Every operation whose whole binding is "one fact, one bind call, no owner mechanics in between" - * — the navigation/keyboard leaves and the system-surface leaves that joined them in Wave 6. This - * tuple is the single canonical declaration: the {@link CatalogInteractorOperation} union type is - * derived from it below, and `LOCAL_BINDERS`/`PROVIDER_BINDERS`'s - * `Record` types are then checked against that derived union — so a - * member can never be added to one and silently missing from another. Not caller-supplied: - * `facts[operation]` is the only thing that decides whether an operation binds — a key this tuple - * names but the caller's facts never define is simply never available - * (`facts[operation]?.available` reads `undefined`), which is how a caller passing a narrower, - * dedicated facts object (e.g. Limrun's keyboard-less navigation facts) opts a subset out. + * How a facet turns one resolved interactor into its own typed operations. Every catalog member + * shares this shape, which is what lets one adapter drive all of them: the facet owns what the + * operation *does*, and this module owns only which interactor it reaches and how a refusal reads. */ -const CATALOG_INTERACTOR_OPERATIONS = [ - 'back', - 'home', - 'setOrientation', - 'tvRemote', - 'keyboardStatus', - 'keyboardDismiss', - 'keyboardEnter', - 'readClipboard', - 'writeClipboard', - 'appSwitcher', - 'triggerAppEvent', - 'setSetting', - 'readAlert', - 'awaitAlert', - 'acceptAlert', - 'dismissAlert', -] as const; - -export type CatalogInteractorOperation = (typeof CATALOG_INTERACTOR_OPERATIONS)[number]; +type InteractorOperationBinder = ( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +) => Partial; -type LocalBinderParams = Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalInteractorOperationResolver; -}>; -type ProviderBinderParams = Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderInteractorOperationResolver; +type InteractorOperationDefinition = Readonly<{ + /** The facts key that admits it, and the operations key it binds. */ + operation: keyof PlatformRuntimeOperations; + /** How a provider fail-closed refusal names this operation to the caller. */ + label: string; + bind: InteractorOperationBinder; }>; -const LOCAL_BINDERS: Readonly< - Record< - CatalogInteractorOperation, - (params: LocalBinderParams) => Partial - > -> = Object.freeze({ - back: bindLocalBackInteractor, - home: bindLocalHomeInteractor, - setOrientation: bindLocalOrientationInteractor, - tvRemote: bindLocalTvRemoteInteractor, - keyboardStatus: bindLocalKeyboardStatusInteractor, - keyboardDismiss: bindLocalKeyboardDismissInteractor, - keyboardEnter: bindLocalKeyboardEnterInteractor, - readClipboard: bindLocalClipboardReadInteractor, - writeClipboard: bindLocalClipboardWriteInteractor, - appSwitcher: bindLocalAppSwitcherInteractor, - triggerAppEvent: bindLocalAppEventInteractor, - setSetting: bindLocalSettingsInteractor, - readAlert: bindLocalAlertReadInteractor, - awaitAlert: bindLocalAlertWaitInteractor, - acceptAlert: bindLocalAlertAcceptInteractor, - dismissAlert: bindLocalAlertDismissInteractor, -}); +/** + * Every operation whose whole binding is "one fact, one bind call, no owner mechanics in between" + * — the navigation/keyboard leaves and the system-surface leaves that joined them in Wave 6. + * + * One row per operation, declared once. It used to take three parallel declarations — a name + * tuple, a local binder map, and a provider binder map — plus a mirrored + * `bindLocal…Interactor`/`bindProvider…Interactor` pair in each facet whose only difference was + * which interactor source to use and which label to name in a refusal. Both of those now live + * here, in the two adapters below, so adding an operation is adding one row. + * + * Not caller-supplied: `facts[operation]` alone decides whether an operation binds, so a row whose + * key the caller's facts never define is simply never available — which is how an owner with a + * narrower, dedicated facts object (Limrun's keyboard-less navigation facts) opts a subset out. + */ +const INTERACTOR_OPERATIONS = [ + { operation: 'back', label: 'back', bind: bindBack }, + { operation: 'home', label: 'home', bind: bindHome }, + { operation: 'setOrientation', label: 'orientation', bind: bindOrientation }, + { operation: 'tvRemote', label: 'tv-remote', bind: bindTvRemote }, + { + operation: 'keyboardStatus', + label: KEYBOARD_ACTION_LABELS.keyboardStatus, + bind: (signal, resolve) => bindKeyboardAction('keyboardStatus', signal, resolve), + }, + { + operation: 'keyboardDismiss', + label: KEYBOARD_ACTION_LABELS.keyboardDismiss, + bind: (signal, resolve) => bindKeyboardAction('keyboardDismiss', signal, resolve), + }, + { + operation: 'keyboardEnter', + label: KEYBOARD_ACTION_LABELS.keyboardEnter, + bind: (signal, resolve) => bindKeyboardAction('keyboardEnter', signal, resolve), + }, + { operation: 'readClipboard', label: 'clipboard read', bind: bindClipboardRead }, + { operation: 'writeClipboard', label: 'clipboard write', bind: bindClipboardWrite }, + { operation: 'appSwitcher', label: 'app-switcher', bind: bindAppSwitcher }, + { operation: 'triggerAppEvent', label: 'trigger-app-event', bind: bindAppEvent }, + { operation: 'setSetting', label: 'settings', bind: bindSetSetting }, + { + operation: 'readAlert', + label: 'alert get', + bind: (signal, resolve) => bindAlertLeg('readAlert', signal, resolve), + }, + { + operation: 'awaitAlert', + label: 'alert wait', + bind: (signal, resolve) => bindAlertLeg('awaitAlert', signal, resolve), + }, + { + operation: 'acceptAlert', + label: 'alert accept', + bind: (signal, resolve) => bindAlertLeg('acceptAlert', signal, resolve), + }, + { + operation: 'dismissAlert', + label: 'alert dismiss', + bind: (signal, resolve) => bindAlertLeg('dismissAlert', signal, resolve), + }, +] as const satisfies readonly InteractorOperationDefinition[]; -const PROVIDER_BINDERS: Readonly< - Record< - CatalogInteractorOperation, - (params: ProviderBinderParams) => Partial - > -> = Object.freeze({ - back: bindProviderBackInteractor, - home: bindProviderHomeInteractor, - setOrientation: bindProviderOrientationInteractor, - tvRemote: bindProviderTvRemoteInteractor, - keyboardStatus: bindProviderKeyboardStatusInteractor, - keyboardDismiss: bindProviderKeyboardDismissInteractor, - keyboardEnter: bindProviderKeyboardEnterInteractor, - readClipboard: bindProviderClipboardReadInteractor, - writeClipboard: bindProviderClipboardWriteInteractor, - appSwitcher: bindProviderAppSwitcherInteractor, - triggerAppEvent: bindProviderAppEventInteractor, - setSetting: bindProviderSettingsInteractor, - readAlert: bindProviderAlertReadInteractor, - awaitAlert: bindProviderAlertWaitInteractor, - acceptAlert: bindProviderAlertAcceptInteractor, - dismissAlert: bindProviderAlertDismissInteractor, -}); +export type CatalogInteractorOperation = (typeof INTERACTOR_OPERATIONS)[number]['operation']; /** * The operation-facts slice every caller already holds: an owner's full `RuntimeFacts.operations` @@ -152,31 +111,28 @@ type CatalogOperationFacts = Readonly< Partial> >; -/** Walks every catalog operation against one binder table, binding each the facts admitted. */ +type CatalogBindParams = Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: Resolver; + facts: CatalogOperationFacts; +}>; + +/** + * Walks the catalog once, binding each operation the facts admitted through whichever interactor + * source the caller's entry point supplies. The source is the only thing that differs between a + * local owner and a provider. + */ function bindAdmittedInteractorOperations( - binders: Readonly< - Record< - CatalogInteractorOperation, - (params: { - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: Resolver; - }) => Partial - > - >, - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: Resolver; - facts: CatalogOperationFacts; - }>, + params: CatalogBindParams, + source: ( + definition: InteractorOperationDefinition, + ) => (runner: RunnerContext) => Promise, ): Partial { - const { device, signal, resolveInteractor, facts } = params; const bound: Partial = {}; - for (const operation of CATALOG_INTERACTOR_OPERATIONS) { - if (facts[operation]?.available) { - Object.assign(bound, binders[operation]({ device, signal, resolveInteractor })); - } + for (const definition of INTERACTOR_OPERATIONS) { + if (!params.facts[definition.operation]?.available) continue; + Object.assign(bound, definition.bind(params.signal, source(definition))); } return bound; } @@ -187,28 +143,20 @@ function bindAdmittedInteractorOperations( * ternary that read them. */ export function bindAdmittedLocalInteractorOperations( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalInteractorOperationResolver; - facts: CatalogOperationFacts; - }>, + params: CatalogBindParams, ): Partial { - return bindAdmittedInteractorOperations(LOCAL_BINDERS, params); + return bindAdmittedInteractorOperations(params, () => localInteractorSource(params)); } /** - * Binds whichever provider operations the owner's own facts admitted. Provider bindings fail - * closed when their exact owner no longer exposes its interactor (see the individual - * `bindProvider…Interactor` functions this table dispatches to). + * Binds whichever provider operations the owner's own facts admitted, failing closed when the + * exact owner no longer exposes its interactor. Each refusal names the operation the caller asked + * for, which is what the per-operation `label` carries. */ export function bindAdmittedProviderInteractorOperations( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderInteractorOperationResolver; - facts: CatalogOperationFacts; - }>, + params: CatalogBindParams, ): Partial { - return bindAdmittedInteractorOperations(PROVIDER_BINDERS, params); + return bindAdmittedInteractorOperations(params, (definition) => + providerInteractorSource({ ...params, operation: definition.label }), + ); } diff --git a/packages/contracts/src/keyboard-runtime.test.ts b/packages/contracts/src/keyboard-runtime.test.ts index c1876a9053..f34dc01703 100644 --- a/packages/contracts/src/keyboard-runtime.test.ts +++ b/packages/contracts/src/keyboard-runtime.test.ts @@ -1,14 +1,11 @@ import { expect, test, vi } from 'vitest'; import { - bindLocalKeyboardDismissInteractor, - bindLocalKeyboardEnterInteractor, - bindLocalKeyboardStatusInteractor, - bindProviderKeyboardDismissInteractor, - bindProviderKeyboardEnterInteractor, - bindProviderKeyboardStatusInteractor, + KEYBOARD_ACTION_LABELS, + bindKeyboardAction, keyboardRuntimeOperationFacts, } from './keyboard-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -18,6 +15,54 @@ const device = { booted: true, } as const; +// The composition the interactor catalog performs, spelled out so each assertion below +// still exercises one facet executor reached through one interactor source. +const bindLocalKeyboardStatusInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindKeyboardAction('keyboardStatus', params.signal, localInteractorSource(params)); +const bindLocalKeyboardDismissInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindKeyboardAction('keyboardDismiss', params.signal, localInteractorSource(params)); +const bindLocalKeyboardEnterInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => bindKeyboardAction('keyboardEnter', params.signal, localInteractorSource(params)); +const bindProviderKeyboardStatusInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindKeyboardAction( + 'keyboardStatus', + params.signal, + providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardStatus }), + ); +const bindProviderKeyboardDismissInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindKeyboardAction( + 'keyboardDismiss', + params.signal, + providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardDismiss }), + ); +const bindProviderKeyboardEnterInteractor = (params: { + device: typeof device; + signal: AbortSignal; + resolveInteractor: any; +}) => + bindKeyboardAction( + 'keyboardEnter', + params.signal, + providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardEnter }), + ); + test('builds the exact keyboard operation fact catalog', () => { const status = { available: true } as const; const dismiss = { available: false, reason: 'unsupported-platform-leaf' } as const; diff --git a/packages/contracts/src/keyboard-runtime.ts b/packages/contracts/src/keyboard-runtime.ts index f621a6a156..6f96627a05 100644 --- a/packages/contracts/src/keyboard-runtime.ts +++ b/packages/contracts/src/keyboard-runtime.ts @@ -1,11 +1,4 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, KeyboardDismissResult, @@ -100,7 +93,7 @@ async function resolveKeyboardInteractor( }); } -const KEYBOARD_ACTION_LABELS = { +export const KEYBOARD_ACTION_LABELS = { keyboardStatus: 'keyboard status', keyboardDismiss: 'keyboard dismiss', keyboardEnter: 'keyboard enter', @@ -111,7 +104,7 @@ const KEYBOARD_ACTION_LABELS = { * differ only by which `Interactor` method they call and what it returns — both read off `key` * itself, so one generic body replaces three copies that differed by nothing else. */ -function bindKeyboardAction( +export function bindKeyboardAction( key: Key, signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, @@ -123,93 +116,3 @@ function bindKeyboardAction( }; return Object.freeze({ [key]: action }) as Pick; } - -export type LocalKeyboardInteractorResolver = LocalInteractorOperationResolver; -export type ProviderKeyboardInteractorResolver = ProviderInteractorOperationResolver; - -function bindLocalKeyboardAction( - key: Key, - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): Pick { - return bindKeyboardAction(key, params.signal, localInteractorSource(params)); -} - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -function bindProviderKeyboardAction( - key: Key, - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): Pick { - return bindKeyboardAction( - key, - params.signal, - providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS[key] }), - ); -} - -export function bindLocalKeyboardStatusInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): KeyboardStatusRuntimeOperations { - return bindLocalKeyboardAction('keyboardStatus', params); -} - -export function bindProviderKeyboardStatusInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): KeyboardStatusRuntimeOperations { - return bindProviderKeyboardAction('keyboardStatus', params); -} - -export function bindLocalKeyboardDismissInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): KeyboardDismissRuntimeOperations { - return bindLocalKeyboardAction('keyboardDismiss', params); -} - -export function bindProviderKeyboardDismissInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): KeyboardDismissRuntimeOperations { - return bindProviderKeyboardAction('keyboardDismiss', params); -} - -export function bindLocalKeyboardEnterInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalKeyboardInteractorResolver; - }>, -): KeyboardEnterRuntimeOperations { - return bindLocalKeyboardAction('keyboardEnter', params); -} - -export function bindProviderKeyboardEnterInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderKeyboardInteractorResolver; - }>, -): KeyboardEnterRuntimeOperations { - return bindProviderKeyboardAction('keyboardEnter', params); -} diff --git a/packages/contracts/src/orientation-runtime.test.ts b/packages/contracts/src/orientation-runtime.test.ts index 1c1b6445ab..893c37718a 100644 --- a/packages/contracts/src/orientation-runtime.test.ts +++ b/packages/contracts/src/orientation-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalOrientationInteractor, - bindProviderOrientationInteractor, - orientationRuntimeOperationFacts, -} from './orientation-runtime.ts'; +import { bindOrientation, orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -14,6 +11,16 @@ const device = { booted: true, } as const; +const bindOrientationLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindOrientation(params.signal, localInteractorSource(params)); +const bindOrientationProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => + bindOrientation(params.signal, providerInteractorSource({ ...params, operation: 'orientation' })); + test('builds the exact orientation operation fact catalog', () => { const orientation = { available: true } as const; expect(orientationRuntimeOperationFacts({ orientation })).toEqual({ @@ -26,7 +33,7 @@ test('a local binding drives the interactor with the requested rotation and retu const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalOrientationInteractor({ device, signal, resolveInteractor }); + const operations = bindOrientationLocal({ device, signal, resolveInteractor }); const result = await operations.setOrientation({ rotation: 'landscape-left', options: { appBundleId: 'com.example.app' }, @@ -48,7 +55,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ setOrientation }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderOrientationInteractor({ device, signal, resolveInteractor }); + const operations = bindOrientationProvider({ device, signal, resolveInteractor }); await operations.setOrientation({ rotation: 'portrait', execution: { requestId: 'orientation-2' }, @@ -63,7 +70,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderOrientationInteractor({ + const operations = bindOrientationProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -81,7 +88,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const setOrientation = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); - const operations = bindLocalOrientationInteractor({ + const operations = bindOrientationLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/orientation-runtime.ts b/packages/contracts/src/orientation-runtime.ts index 601c17627a..93ed61c606 100644 --- a/packages/contracts/src/orientation-runtime.ts +++ b/packages/contracts/src/orientation-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { DeviceRotation } from './device-rotation.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; @@ -46,7 +39,7 @@ export function orientationRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the rotation itself. */ -function bindOrientation( +export function bindOrientation( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): OrientationRuntimeOperations { @@ -62,31 +55,3 @@ function bindOrientation( }, }); } - -export type LocalOrientationInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalOrientationInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalOrientationInteractorResolver; - }>, -): OrientationRuntimeOperations { - return bindOrientation(params.signal, localInteractorSource(params)); -} - -export type ProviderOrientationInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderOrientationInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderOrientationInteractorResolver; - }>, -): OrientationRuntimeOperations { - return bindOrientation( - params.signal, - providerInteractorSource({ ...params, operation: 'orientation' }), - ); -} diff --git a/packages/contracts/src/settings-runtime.test.ts b/packages/contracts/src/settings-runtime.test.ts index 3529aff4b9..686473f2ed 100644 --- a/packages/contracts/src/settings-runtime.test.ts +++ b/packages/contracts/src/settings-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalSettingsInteractor, - bindProviderSettingsInteractor, - settingsRuntimeOperationFacts, -} from './settings-runtime.ts'; +import { bindSetSetting, settingsRuntimeOperationFacts } from './settings-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'apple', @@ -15,6 +12,15 @@ const device = { booted: true, } as const; +const bindSetSettingLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindSetSetting(params.signal, localInteractorSource(params)); +const bindSetSettingProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindSetSetting(params.signal, providerInteractorSource({ ...params, operation: 'settings' })); + test('builds the exact settings operation fact catalog', () => { const setSetting = { available: true } as const; expect(settingsRuntimeOperationFacts({ setSetting })).toEqual({ setSetting }); @@ -27,7 +33,7 @@ test('a local binding forwards the neutral mutation to its owner', async () => { const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalSettingsInteractor({ device, signal, resolveInteractor }); + const operations = bindSetSettingLocal({ device, signal, resolveInteractor }); const result = await operations.setSetting({ setting: 'location', state: 'set', @@ -50,7 +56,7 @@ test('a local binding forwards the neutral mutation to its owner', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderSettingsInteractor({ + const operations = bindSetSettingProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -70,7 +76,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const setSetting = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); - const operations = bindLocalSettingsInteractor({ + const operations = bindSetSettingLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/settings-runtime.ts b/packages/contracts/src/settings-runtime.ts index 227e7fe31b..aee9386126 100644 --- a/packages/contracts/src/settings-runtime.ts +++ b/packages/contracts/src/settings-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SettingOptions } from './settings.ts'; @@ -49,7 +42,7 @@ export function settingsRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the mutation itself. */ -function bindSetSetting( +export function bindSetSetting( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): SettingsRuntimeOperations { @@ -70,31 +63,3 @@ function bindSetSetting( }, }); } - -export type LocalSettingsInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalSettingsInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalSettingsInteractorResolver; - }>, -): SettingsRuntimeOperations { - return bindSetSetting(params.signal, localInteractorSource(params)); -} - -export type ProviderSettingsInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderSettingsInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderSettingsInteractorResolver; - }>, -): SettingsRuntimeOperations { - return bindSetSetting( - params.signal, - providerInteractorSource({ ...params, operation: 'settings' }), - ); -} diff --git a/packages/contracts/src/tv-remote-runtime.test.ts b/packages/contracts/src/tv-remote-runtime.test.ts index 367b290134..4b7e005048 100644 --- a/packages/contracts/src/tv-remote-runtime.test.ts +++ b/packages/contracts/src/tv-remote-runtime.test.ts @@ -1,10 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { - bindLocalTvRemoteInteractor, - bindProviderTvRemoteInteractor, - tvRemoteRuntimeOperationFacts, -} from './tv-remote-runtime.ts'; +import { bindTvRemote, tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'vega', @@ -14,6 +11,15 @@ const device = { booted: true, } as const; +const bindTvRemoteLocal = ( + params: Parameters[0] & { signal: AbortSignal }, +) => bindTvRemote(params.signal, localInteractorSource(params)); +const bindTvRemoteProvider = ( + params: Parameters[0] extends infer P + ? Omit & { signal: AbortSignal } + : never, +) => bindTvRemote(params.signal, providerInteractorSource({ ...params, operation: 'tv-remote' })); + test('builds the exact tv-remote operation fact catalog', () => { const tvRemote = { available: true } as const; expect(tvRemoteRuntimeOperationFacts({ tvRemote })).toEqual({ tvRemote }); @@ -24,7 +30,7 @@ test('a local binding drives the interactor with the button and duration', async const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalTvRemoteInteractor({ device, signal, resolveInteractor }); + const operations = bindTvRemoteLocal({ device, signal, resolveInteractor }); await operations.tvRemote({ button: 'down', durationMs: 250, @@ -48,7 +54,7 @@ test('a provider binding drives its own resolved interactor', async () => { const resolveInteractor = vi.fn(() => ({ tvRemote }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindProviderTvRemoteInteractor({ device, signal, resolveInteractor }); + const operations = bindTvRemoteProvider({ device, signal, resolveInteractor }); await operations.tvRemote({ button: 'select', execution: { requestId: 'tv-remote-2' } }); expect(resolveInteractor).toHaveBeenCalledWith({ @@ -60,7 +66,7 @@ test('a provider binding drives its own resolved interactor', async () => { }); test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderTvRemoteInteractor({ + const operations = bindTvRemoteProvider({ device, signal: new AbortController().signal, resolveInteractor: () => undefined, @@ -78,7 +84,7 @@ test('an already-cancelled request never resolves an interactor', async () => { const tvRemote = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); - const operations = bindLocalTvRemoteInteractor({ + const operations = bindTvRemoteLocal({ device, signal: controller.signal, resolveInteractor, diff --git a/packages/contracts/src/tv-remote-runtime.ts b/packages/contracts/src/tv-remote-runtime.ts index 8112bdd75e..b5801525e7 100644 --- a/packages/contracts/src/tv-remote-runtime.ts +++ b/packages/contracts/src/tv-remote-runtime.ts @@ -1,10 +1,3 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - localInteractorSource, - providerInteractorSource, - type LocalInteractorOperationResolver, - type ProviderInteractorOperationResolver, -} from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import type { TvRemoteButton } from './tv-remote.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; @@ -47,7 +40,7 @@ export function tvRemoteRuntimeOperationFacts( * owner is already chosen by the time a binder is called, so each entry point supplies its own * resolution and this holds only what both share: the runner context and the button press itself. */ -function bindTvRemote( +export function bindTvRemote( signal: AbortSignal, resolveInteractor: (runner: RunnerContext) => Promise, ): TvRemoteRuntimeOperations { @@ -63,31 +56,3 @@ function bindTvRemote( }, }); } - -export type LocalTvRemoteInteractorResolver = LocalInteractorOperationResolver; - -export function bindLocalTvRemoteInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: LocalTvRemoteInteractorResolver; - }>, -): TvRemoteRuntimeOperations { - return bindTvRemote(params.signal, localInteractorSource(params)); -} - -export type ProviderTvRemoteInteractorResolver = ProviderInteractorOperationResolver; - -/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ -export function bindProviderTvRemoteInteractor( - params: Readonly<{ - device: DeviceInfo; - signal: AbortSignal; - resolveInteractor: ProviderTvRemoteInteractorResolver; - }>, -): TvRemoteRuntimeOperations { - return bindTvRemote( - params.signal, - providerInteractorSource({ ...params, operation: 'tv-remote' }), - ); -} diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index 83aae142d6..a2fb69b7fb 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -243,23 +243,23 @@ export const FACADE_BUDGETS: Readonly> = Object.freeze({ 'packages/xml/src/index.ts': 3, // Added by #1993 (device-inventory context moved out of core). - 'packages/contracts/src/back-runtime.ts': 4, + 'packages/contracts/src/back-runtime.ts': 1, // Added by Wave 6 R55/R56/R57/R58/R59: the clipboard, app-switcher, app-event, settings and // alert facets, // plus the local interaction set Android and Linux used to hold a byte-identical copy of each. // The set is its own module rather than part of the interactor catalog so the catalog's closure // stays leaf-thin -- every module the set pulls in is one its two consumers already evaluate. - 'packages/contracts/src/alert-runtime.ts': 4, - 'packages/contracts/src/app-event-runtime.ts': 4, - 'packages/contracts/src/app-switcher-runtime.ts': 4, - 'packages/contracts/src/clipboard-runtime.ts': 4, - 'packages/contracts/src/settings-runtime.ts': 4, + 'packages/contracts/src/alert-runtime.ts': 1, + 'packages/contracts/src/app-event-runtime.ts': 1, + 'packages/contracts/src/app-switcher-runtime.ts': 1, + 'packages/contracts/src/clipboard-runtime.ts': 1, + 'packages/contracts/src/settings-runtime.ts': 1, 'packages/contracts/src/local-interactor-operation-set.ts': 26, - 'packages/contracts/src/home-runtime.ts': 4, + 'packages/contracts/src/home-runtime.ts': 1, 'packages/contracts/src/interactor-operation-catalog.ts': 14, - 'packages/contracts/src/keyboard-runtime.ts': 4, - 'packages/contracts/src/orientation-runtime.ts': 4, - 'packages/contracts/src/tv-remote-runtime.ts': 4, + 'packages/contracts/src/keyboard-runtime.ts': 3, + 'packages/contracts/src/orientation-runtime.ts': 1, + 'packages/contracts/src/tv-remote-runtime.ts': 1, }); /** diff --git a/src/core/__tests__/app-event-delivery.test.ts b/src/core/__tests__/app-event-delivery.test.ts index 2db2a18362..65baea6dbd 100644 --- a/src/core/__tests__/app-event-delivery.test.ts +++ b/src/core/__tests__/app-event-delivery.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { bindLocalAppEventInteractor } from '@agent-device/contracts/app-event-runtime'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { parseTriggerAppEventArgs, resolveAppEventUrl } from '../app-events.ts'; import { getInteractor } from '../interactors.ts'; @@ -28,12 +28,13 @@ async function triggerAppEvent( ): Promise<{ event: string; eventUrl: string; transport: 'deep-link' }> { const { eventName, payload } = parseTriggerAppEventArgs(positionals); const eventUrl = resolveAppEventUrl(device, eventName, payload); - const operations = bindLocalAppEventInteractor({ + const operations = bindAdmittedLocalInteractorOperations({ device, signal: new AbortController().signal, - resolveInteractor: async (owned, runner) => await getInteractor(owned, runner), + resolveInteractor: async (owned: DeviceInfo, runner) => await getInteractor(owned, runner), + facts: { triggerAppEvent: { available: true } }, }); - await operations.triggerAppEvent({ eventUrl, options }); + await operations.triggerAppEvent?.({ eventUrl, options }); return { event: eventName, eventUrl, transport: 'deep-link' }; } diff --git a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts index 8f79668f88..3634557e12 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts @@ -15,8 +15,6 @@ import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, bindProviderApplicationLifecycleInteractor, - bindProviderKeyboardDismissInteractor, - bindProviderKeyboardEnterInteractor, bindProviderSnapshotInteractor, bindProviderTouchInteractor, invokeApplicationClose, @@ -34,6 +32,7 @@ import { type RuntimeFacts, touchRuntimeOperationFacts, } from '@agent-device/contracts/platform'; +import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../src/__tests__/test-utils/runtime-operation-facts.ts'; import type { DaemonRequest } from '../../../src/daemon/types.ts'; import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; @@ -157,15 +156,12 @@ async function bindProviderScenarioPlatformRuntime( signal: request.scope.signal, resolveInteractor: (runner) => runtime.getInteractor(request.device, runner), }), - ...bindProviderKeyboardDismissInteractor({ - device: request.device, - signal: request.scope.signal, - resolveInteractor: (runner) => runtime.getInteractor(request.device, runner), - }), - ...bindProviderKeyboardEnterInteractor({ + // The interactor catalog binds every keyboard leg this provider's facts admit. + ...bindAdmittedProviderInteractorOperations({ device: request.device, signal: request.scope.signal, resolveInteractor: (runner) => runtime.getInteractor(request.device, runner), + facts: facts.operations, }), ...bindProviderTouchInteractor({ device: request.device, From efd761ca5f84b51a46223d16624ce9bd0fe9d1ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 12:57:30 +0000 Subject: [PATCH 13/16] fix(android): let only a clean adb exit prove clipboard support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review P1 on #2021. The typed verdict landed one layer too high. The adapter probe runs `adb shell cmd clipboard get text` with `allowFailure`, so a non-zero exit comes back as an ordinary result rather than a throw — and the only thing standing between that result and `supported` was the missing-shell prose check. A device that had gone offline, was unauthorized, timed out, or failed for any other reason produced none of that prose, so it fell through to `supported` and was then cached by device id for the runtime owner's lifetime. The `catch` I added guarded the one path adb almost never takes. Each adb outcome now proves only what it can: - `exitCode === 0` is the sole evidence of support, because it is the only result that shows the command ran. - The recognized missing-shell prose is the sole evidence of absence, and is read before the exit code — adb reports that condition non-zero, so checking the code first would turn every honest `unsupported` into a refusal. - Everything else — non-zero without that prose, and the transport throw — is `probe-failed`, which admission refuses and the cache does not remember. The package tests mocked the typed verdict, so they sat downstream of the bug and could not see it. The regression is therefore at the adapter, over the raw adb result: four planted reds (offline, unauthorized, device-not-found, generic failure) that all returned `supported` before this change, plus the two definitive verdicts and the ordering case that keeps `unsupported` reachable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- ...rm-runtime-android-clipboard-probe.test.ts | 74 +++++++++++++++++++ src/platform-runtime-android-tool-host.ts | 15 ++-- 2 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/platform-runtime-android-clipboard-probe.test.ts diff --git a/src/__tests__/platform-runtime-android-clipboard-probe.test.ts b/src/__tests__/platform-runtime-android-clipboard-probe.test.ts new file mode 100644 index 0000000000..cd2858a9cc --- /dev/null +++ b/src/__tests__/platform-runtime-android-clipboard-probe.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createAndroidToolHost } from '../platform-runtime-android-tool-host.ts'; + +const runAndroidAdb = vi.hoisted(() => vi.fn()); + +vi.mock('../platforms/android/adb.ts', async (importOriginal) => ({ + ...(await importOriginal()), + runAndroidAdb, +})); + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel 9 Pro XL', + kind: 'emulator', + booted: true, +}; + +function adbResult(exitCode: number, stdout = '', stderr = '') { + return { exitCode, stdout, stderr, stdoutBuffer: Buffer.from(stdout) }; +} + +async function probe() { + const host = createAndroidToolHost(); + return await host.probeClipboardShellSupport?.(device); +} + +// The probe runs with `allowFailure`, so every adb outcome short of a transport throw arrives as an +// ordinary result. Anything the adapter reports as `supported` is cached for the runtime owner's +// lifetime, so a wrong admission here is not a single bad answer -- it advertises a clipboard the +// build may not have until the daemon restarts. +describe('android clipboard shell probe: what each adb result is allowed to prove', () => { + test('a clean exit is the only thing that proves support', async () => { + runAndroidAdb.mockResolvedValueOnce(adbResult(0, 'clipboard contents')); + await expect(probe()).resolves.toBe('supported'); + }); + + test('an empty clipboard on a clean exit still proves support', async () => { + runAndroidAdb.mockResolvedValueOnce(adbResult(0, '')); + await expect(probe()).resolves.toBe('supported'); + }); + + test('the missing-shell prose proves the build ships no clipboard command', async () => { + runAndroidAdb.mockResolvedValueOnce( + adbResult(255, '', 'Error: no shell command implementation.'), + ); + await expect(probe()).resolves.toBe('unsupported'); + }); + + // The planted red: before the fix every non-zero result that lacked the missing-shell prose fell + // through to `supported`, so an offline device advertised a working clipboard. + test.each([ + ['a device that dropped off the bridge', 'error: device offline'], + ['an unauthorized device', 'error: device unauthorized.'], + ['a bridge that never found the device', "error: device '(null)' not found"], + ['a generic adb failure', 'error: closed'], + ])('%s refuses rather than admitting support', async (_case, stderr) => { + runAndroidAdb.mockResolvedValueOnce(adbResult(1, '', stderr)); + await expect(probe()).resolves.toBe('probe-failed'); + }); + + test('a transport throw refuses rather than admitting support', async () => { + runAndroidAdb.mockRejectedValueOnce(new Error('spawn adb ENOENT')); + await expect(probe()).resolves.toBe('probe-failed'); + }); + + // Recognized absence outranks the exit code: adb reports the missing shell command non-zero, so + // reading the code first would turn every honest `unsupported` into a refusal. + test('missing-shell prose on a non-zero exit still reads as unsupported', async () => { + runAndroidAdb.mockResolvedValueOnce(adbResult(1, '', 'Unknown command: clipboard')); + await expect(probe()).resolves.toBe('unsupported'); + }); +}); diff --git a/src/platform-runtime-android-tool-host.ts b/src/platform-runtime-android-tool-host.ts index cdc4db75d6..46ed073a52 100644 --- a/src/platform-runtime-android-tool-host.ts +++ b/src/platform-runtime-android-tool-host.ts @@ -4,9 +4,13 @@ import type { AndroidToolHost } from '@agent-device/contracts/platform'; export function createAndroidToolHost(): AndroidToolHost { return Object.freeze({ /** - * Definitive in both directions where adb answers, and honest when it does not: a transport - * failure reports `probe-failed` rather than a guess, so admission can refuse instead of - * fabricating availability the operation would then reject. + * Definitive in both directions only where adb actually answers, and honest everywhere else. + * The probe runs with `allowFailure`, so a device that is offline, unauthorized, timed out or + * otherwise broken comes back as an ordinary non-zero result rather than a throw: only a clean + * exit proves the clipboard shell command exists, and only the recognized missing-shell prose + * proves it does not. Every other result -- non-zero without that prose, or a transport throw + * -- is `probe-failed`, so admission refuses instead of caching availability the operation + * would then reject. */ probeClipboardShellSupport: async (device, signal) => { try { @@ -16,9 +20,8 @@ export function createAndroidToolHost(): AndroidToolHost { allowFailure: true, signal, }); - return isClipboardShellUnsupported(result.stdout, result.stderr) - ? 'unsupported' - : 'supported'; + if (isClipboardShellUnsupported(result.stdout, result.stderr)) return 'unsupported'; + return result.exitCode === 0 ? 'supported' : 'probe-failed'; } catch { return 'probe-failed'; } From 59d1853df2144377d38ae6f629624c41958fcacc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:22:43 +0000 Subject: [PATCH 14/16] fix(android): never read adb's refusal prose out of the clipboard's contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review P1 on #2021, and a second instance of the same bug it names. The previous fix read `isClipboardShellUnsupported(stdout, stderr)` before the exit code. On a *successful* `cmd clipboard get text`, stdout is the clipboard's contents — arbitrary user text. Anyone who had copied "unknown command" or "no shell command implementation" (from a terminal, a bug report, this repo) had their own working clipboard classified `unsupported`, and the runtime owner cached that for its lifetime. Ordering prose ahead of the exit code to keep `unsupported` reachable traded one wrong admission for another. The exit code is decisive on its own when it is zero, so it goes first. Only a call that failed can carry prose about the call itself, which makes the missing- shell phrases meaningful on non-zero exits alone: if (result.exitCode === 0) return 'supported'; return isClipboardShellUnsupported(...) ? 'unsupported' : 'probe-failed'; `isClipboardShellUnsupported` now states that precondition, because reading it on a successful call is exactly the mistake to prevent. The same defect was already shipped in the helper's other caller. `runAndroidClipboardShellCommand` in `src/platforms/android/device-input-state.ts` has checked the prose before the exit code since #1950, so `clipboard read` on a clipboard holding either phrase threw `UNSUPPORTED_OPERATION` — telling the user their device does not support a clipboard it had just read correctly. It is not this wave's code and not reachable from the migration, but it is the same helper misused the same way, and documenting a precondition while leaving a caller that violates it invites the next regression. Repaired here, with the failure ordering otherwise unchanged: a non-zero exit still reports missing-shell as `UNSUPPORTED_OPERATION` and anything else as the adb result error. Both repairs are pinned by regressions that fail against the code they replace: four exit-0 cases at the adapter (verified red against the ordering this commit removes), and three at `readAndroidClipboardWithAdb` (verified red against `origin/main`) covering contents that look like a refusal, a genuine missing command, and an unrelated non-zero failure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- ...rm-runtime-android-clipboard-probe.test.ts | 24 +++++++++++--- src/platform-runtime-android-tool-host.ts | 19 +++++++---- .../__tests__/device-input-state.test.ts | 33 +++++++++++++++++++ src/platforms/android/adb.ts | 4 +++ src/platforms/android/device-input-state.ts | 9 ++--- 5 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/__tests__/platform-runtime-android-clipboard-probe.test.ts b/src/__tests__/platform-runtime-android-clipboard-probe.test.ts index cd2858a9cc..a025863edf 100644 --- a/src/__tests__/platform-runtime-android-clipboard-probe.test.ts +++ b/src/__tests__/platform-runtime-android-clipboard-probe.test.ts @@ -65,10 +65,26 @@ describe('android clipboard shell probe: what each adb result is allowed to prov await expect(probe()).resolves.toBe('probe-failed'); }); - // Recognized absence outranks the exit code: adb reports the missing shell command non-zero, so - // reading the code first would turn every honest `unsupported` into a refusal. - test('missing-shell prose on a non-zero exit still reads as unsupported', async () => { - runAndroidAdb.mockResolvedValueOnce(adbResult(1, '', 'Unknown command: clipboard')); + // adb reports the missing shell command non-zero, so the prose is only ever evidence about a + // call that failed -- which is exactly why it must not be read on a call that succeeded. + test.each([ + ['stderr', '', 'Unknown command: clipboard'], + ['stdout', 'No shell command implementation.', ''], + ])('missing-shell prose on %s of a non-zero exit reads as unsupported', async (_c, out, err) => { + runAndroidAdb.mockResolvedValueOnce(adbResult(1, out, err)); await expect(probe()).resolves.toBe('unsupported'); }); + + // The second planted red: on a clean exit stdout is the clipboard's *contents*, so reading the + // prose first let a user who had copied one of these phrases -- from a terminal, a bug report, + // this very file -- brand their own working clipboard `unsupported` for the owner's lifetime. + test.each([ + ['unknown command'], + ['no shell command implementation'], + ['adb said: Unknown command: clipboard'], + ['No shell command implementation.'], + ])('a clipboard holding %j is still supported', async (contents) => { + runAndroidAdb.mockResolvedValueOnce(adbResult(0, contents)); + await expect(probe()).resolves.toBe('supported'); + }); }); diff --git a/src/platform-runtime-android-tool-host.ts b/src/platform-runtime-android-tool-host.ts index 46ed073a52..ae2a9ee5b9 100644 --- a/src/platform-runtime-android-tool-host.ts +++ b/src/platform-runtime-android-tool-host.ts @@ -6,11 +6,14 @@ export function createAndroidToolHost(): AndroidToolHost { /** * Definitive in both directions only where adb actually answers, and honest everywhere else. * The probe runs with `allowFailure`, so a device that is offline, unauthorized, timed out or - * otherwise broken comes back as an ordinary non-zero result rather than a throw: only a clean - * exit proves the clipboard shell command exists, and only the recognized missing-shell prose - * proves it does not. Every other result -- non-zero without that prose, or a transport throw - * -- is `probe-failed`, so admission refuses instead of caching availability the operation - * would then reject. + * otherwise broken comes back as an ordinary non-zero result rather than a throw. + * + * The exit code is read first and settles the answer on its own when it is zero, because on a + * clean exit `stdout` is the clipboard's *contents* -- attacker-free but arbitrary user text, + * which may well quote an error. Only a failed call can carry prose about the call itself, so + * the missing-shell phrases are interpreted on non-zero exits alone. Every remaining result -- + * non-zero without that prose, or a transport throw -- is `probe-failed`, so admission refuses + * instead of caching a verdict the operation would then contradict. */ probeClipboardShellSupport: async (device, signal) => { try { @@ -20,8 +23,10 @@ export function createAndroidToolHost(): AndroidToolHost { allowFailure: true, signal, }); - if (isClipboardShellUnsupported(result.stdout, result.stderr)) return 'unsupported'; - return result.exitCode === 0 ? 'supported' : 'probe-failed'; + if (result.exitCode === 0) return 'supported'; + return isClipboardShellUnsupported(result.stdout, result.stderr) + ? 'unsupported' + : 'probe-failed'; } catch { return 'probe-failed'; } diff --git a/src/platforms/android/__tests__/device-input-state.test.ts b/src/platforms/android/__tests__/device-input-state.test.ts index c9afb65f77..8f47d0aa3c 100644 --- a/src/platforms/android/__tests__/device-input-state.test.ts +++ b/src/platforms/android/__tests__/device-input-state.test.ts @@ -7,6 +7,7 @@ import { dismissAndroidKeyboard, getAndroidKeyboardState, getAndroidKeyboardStatusWithAdb, + readAndroidClipboardWithAdb, writeAndroidClipboardWithAdb, } from '../device-input-state.ts'; import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; @@ -232,6 +233,38 @@ test('writeAndroidClipboardWithAdb leaves safe text unquoted', async () => { assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', 'android-otp']]); }); +// A successful `clipboard get text` puts the clipboard's *contents* on stdout, so the missing-shell +// prose is only ever evidence about a call that failed. Reading it on a clean exit turned a user who +// had copied one of these phrases into a device that "does not support" its own working clipboard. +test('readAndroidClipboardWithAdb returns contents that read like an adb refusal', async () => { + for (const contents of ['Unknown command: clipboard', 'No shell command implementation.']) { + const adb: AndroidAdbExecutor = async () => ({ stdout: contents, stderr: '', exitCode: 0 }); + assert.equal(await readAndroidClipboardWithAdb(adb), contents); + } +}); + +test('readAndroidClipboardWithAdb still reports a genuine missing shell command', async () => { + const adb: AndroidAdbExecutor = async () => ({ + stdout: '', + stderr: 'Unknown command: clipboard', + exitCode: 255, + }); + + await assertRejectsAppError(() => readAndroidClipboardWithAdb(adb), { + code: 'UNSUPPORTED_OPERATION', + }); +}); + +test('readAndroidClipboardWithAdb reports a non-zero failure that names no missing command', async () => { + const adb: AndroidAdbExecutor = async () => ({ + stdout: '', + stderr: 'error: device offline', + exitCode: 1, + }); + + await assert.rejects(() => readAndroidClipboardWithAdb(adb)); +}); + test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => { await withFakeAdb( (args) => { diff --git a/src/platforms/android/adb.ts b/src/platforms/android/adb.ts index 00d705aa3a..fac14c2097 100644 --- a/src/platforms/android/adb.ts +++ b/src/platforms/android/adb.ts @@ -22,6 +22,10 @@ export async function runAndroidAdb( * adb reports this condition in its output and nowhere else — no exit code or structured field * separates "service has no shell command" from any other non-zero result — so this is the one * place that reads that prose, and it hands every caller a typed answer instead. + * + * Only ever ask this about a call that *failed*. A successful `clipboard get text` returns the + * clipboard's contents on stdout, which is arbitrary user text and may quote these very phrases; + * callers must settle a zero exit as success before reaching for this. */ export function isClipboardShellUnsupported(stdout: string, stderr: string): boolean { const haystack = `${stdout}\n${stderr}`.toLowerCase(); diff --git a/src/platforms/android/device-input-state.ts b/src/platforms/android/device-input-state.ts index 362cc8bb66..45bade05a3 100644 --- a/src/platforms/android/device-input-state.ts +++ b/src/platforms/android/device-input-state.ts @@ -320,16 +320,17 @@ async function runAndroidClipboardShellCommand( operation: 'read' | 'write', ): Promise { const result = await adb(args, { allowFailure: true }); + // A clean exit settles it before the prose is consulted at all: on a successful read `stdout` is + // the clipboard's contents, and a user who has copied one of the missing-shell phrases must not + // have their own text mistaken for adb refusing the command. + if (result.exitCode === 0) return result.stdout; if (isClipboardShellUnsupported(result.stdout, result.stderr)) { throw new AppError( 'UNSUPPORTED_OPERATION', `Android shell clipboard ${operation} is not supported on this device.`, ); } - if (result.exitCode !== 0) { - throw androidAdbResultError(`Failed to ${operation} Android clipboard text`, result); - } - return result.stdout; + throw androidAdbResultError(`Failed to ${operation} Android clipboard text`, result); } function normalizeAndroidClipboardText(stdout: string): string { From d81c86c144b23493677db75ccd43787aa1e5c2b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:07:15 +0000 Subject: [PATCH 15/16] fix(cli): bring the workflow help card back under its size budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Coverage (2)` has been red on `main` and on every PR branched from it since #2020, which replaced three short Bootstrap lines with one longer line carrying the new selection semantics. It updated the content matcher for that line but not the size assertion beside it, so the card went to 9003 bytes against the `< 9000` both `cli-help.test.ts` and `cli-help-topics.test.ts` enforce. Nothing #2020 added is removed here — all of it is pinned by the matcher it shipped, and it is the sentence agents most need. The bytes come back from a clumsy repetition elsewhere in the card, where "settle" named itself twice in one clause: ... only when you did not settle, settle reported not settled, or ... ... only when you did not settle, it reported not settled, or ... which reads better short and puts the card at 8999. That is one byte inside the budget, which is the real finding: the card has no slack left, and the next sentence anyone adds re-opens this. The durable fix is a base-owner call between raising the budget and moving a block down into its sub-topic — the mechanism the card already uses, and which its own test documents. Flagged on #2021 rather than decided here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- src/cli-schema/cli-help.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index 2da57dc224..9e5bc550c1 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -143,7 +143,7 @@ Snapshots and refs: snapshot reads visible state; snapshot -i gets current interactive refs only -- the fast path before an interaction. Default text is agent-facing and token-efficient; --raw/--json only for the full provider tree. Legend: @e12 [button] label="Add to cart" enabled hittable -> press @e12. [off-screen below] -> scroll down (a hint, not a ref). Refs stay valid until you press/click/fill/type/scroll/back/wait-for-async-UI, or otherwise change app state; open/--relaunch clears the stored snapshot outright. - Prefer --settle and continue from its settled diff when it shows the next target; refresh with snapshot -i only when you did not settle, settle reported not settled, or its output lacks what you need. A known selector/label after a mutation is often enough, since interaction commands refresh state internally. + Prefer --settle and continue from its settled diff when it shows the next target; refresh with snapshot -i only when you did not settle, it reported not settled, or its output lacks what you need. A known selector/label after a mutation is often enough, since interaction commands refresh state internally. Truncated preview: snapshot -s @e12 (the current concrete ref), not get text. Missing target in a list: scroll down/up (not bottom/top unless the task wants the edge), then snapshot -i. TV/D-pad focus: help tv. Selectors: From ad02543fbf8cb2a5fc7e49f7e7de6a08bfdc5f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:26:25 +0000 Subject: [PATCH 16/16] test(cli): raise the workflow help-card budget to 9100 The card is a curated agent-facing reference, and #2020 grew it for a good reason: the selection semantics it added are what an agent needs to predict which device a bare `open` picks. Holding that content to a limit set before it existed just moves the cost onto whoever writes the next sentence. 9100 is headroom, not a target. The previous commit left the card at 8999 of 9000 -- one byte -- which is not a state anyone should have to work in, and I had already established there is no slack left to reclaim: no trailing whitespace, and the only repeated runs are the deliberate column alignment in the Escalate footer. Trimming further would have meant deleting content the tests pin as load-bearing. This is explicitly interim. The card is ~9KB of dense prose in one string, and the real answer is to move a block down into its owning sub-topic -- the mechanism the card already uses and its own test documents ("Deep content moved out of the compact card, not deleted"). Raising the ceiling buys room to do that deliberately instead of under a red CI. Both enforcement sites move together, since they measure the same card through different surfaces: `cli-help.test.ts` reads it through the CLI, and `cli-help-topics.test.ts` through `usageForCommand`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019RpfS12XApXqasuAWZJaEX --- src/__tests__/cli-help.test.ts | 2 +- src/cli-schema/cli-help-topics.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index 1a9d9a3a19..f3ffea0dc6 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -75,7 +75,7 @@ test('help workflow prints the compact workflow card with a version header and s assert.equal(result.calls.length, 0); assert.match(result.stdout, /^agent-device \S+ — workflow/); assert.ok( - Buffer.byteLength(result.stdout, 'utf8') < 9000, + Buffer.byteLength(result.stdout, 'utf8') < 9100, `help workflow should stay close to the compact-card size target, was ${Buffer.byteLength(result.stdout, 'utf8')} bytes`, ); assert.match(result.stdout, /open -> snapshot -i -> settle -> verify -> close loop/); diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index fcd23401a6..c8a76d91cc 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -119,7 +119,7 @@ test('usageForCommand resolves workflow help topic', async () => { if (help === null) throw new Error('Expected workflow help text'); assert.match(help, /^agent-device \S+ — workflow/); assert.ok( - Buffer.byteLength(help, 'utf8') < 9000, + Buffer.byteLength(help, 'utf8') < 9100, `workflow help topic should stay close to the compact-card size target, was ${Buffer.byteLength(help, 'utf8')} bytes`, ); assert.match(help, /open -> snapshot -i -> settle -> verify -> close loop/);