From 2fa4b7eaebefb0c688e0c66527592aa3daa83095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 10 Sep 2026 16:07:34 +0200 Subject: [PATCH 1/2] refactor(contracts): make gesture capability facts additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive capability facts on the gesture family (#2443, family 2). gestureRuntimeOperationFacts took one fact per tier, and it had five: plan, directionalFling, multiTouch, targetAuthoredDrag, viewport. The tiers split exactly where an owner's mechanics split — no tier is one every owner serves, and several owners serve none — so every tier is optional now and `unsupported` names the denial an omitted tier reports. The type still refuses a call that names no denial at all: omission is a classified refusal, never an unclassified tier and never an implied success, and an owner cannot leave the family blank. An owner states a tier only to say something the family denial does not. Web refuses every tier with one cell and names only the drag tier whose wording came from a retired closure. Vega names the two tiers whose refusals came from separate closures. Limrun's session-less and iOS branches name one denial each, where each wrote five. The owners that serve gestures keep naming what they serve: the win is the next tier, which an owner can now ignore entirely. Fact values are unchanged: every owner's gesture cells are byte-identical, and the family's own test pins an omitted tier reporting the stated denial verbatim, an owner that names nothing answering with the exhaustive shape, and the freeze. --- .../contracts/src/gesture-runtime.test.ts | 39 +++++++++++++++-- packages/contracts/src/gesture-runtime.ts | 42 +++++++++++++------ .../src/platform-runtime-unavailable.ts | 8 +--- packages/platform-android/src/runtime.ts | 1 + packages/platform-apple/src/gesture-facts.ts | 1 + packages/platform-harmonyos/src/runtime.ts | 1 + packages/platform-linux/src/runtime.ts | 1 + packages/platform-vega/src/runtime.ts | 4 +- packages/platform-web/src/runtime.ts | 5 +-- .../src/interaction-operations.ts | 24 ++++------- .../src/platform-runtime.ts | 1 + .../test-utils/runtime-operation-facts.ts | 8 +--- .../handlers/__tests__/install-source.test.ts | 8 +--- 13 files changed, 83 insertions(+), 60 deletions(-) diff --git a/packages/contracts/src/gesture-runtime.test.ts b/packages/contracts/src/gesture-runtime.test.ts index edec2d87e2..e2f9affea0 100644 --- a/packages/contracts/src/gesture-runtime.test.ts +++ b/packages/contracts/src/gesture-runtime.test.ts @@ -19,6 +19,7 @@ const available = { available: true } as const; const unavailable = { available: false, reason: 'unsupported-platform-leaf' } as const; const allAvailable = gestureRuntimeOperationFacts({ + unsupported: unavailable, plan: available, directionalFling: available, multiTouch: available, @@ -43,9 +44,10 @@ const plan: GesturePlan = { ], }; -test('builds the exact gesture operation fact catalog', () => { +test('builds the exact gesture operation fact catalog for an owner that names every tier', () => { expect( gestureRuntimeOperationFacts({ + unsupported: unavailable, plan: available, directionalFling: unavailable, multiTouch: unavailable, @@ -61,6 +63,37 @@ test('builds the exact gesture operation fact catalog', () => { }); }); +test('a tier the owner never names reports the denial the owner stated for the family, verbatim — omission is a classified refusal, never an unclassified tier and never an implied success', () => { + const denial = { + available: false, + reason: 'unsupported-device-kind', + hint: 'Gestures are supported on HarmonyOS emulators and physical devices.', + } as const; + + expect(gestureRuntimeOperationFacts({ unsupported: denial, plan: available })).toEqual({ + performGesturePlan: available, + performDirectionalFlingPlan: denial, + performMultiTouchGesturePlan: denial, + performTargetAuthoredDrag: denial, + gestureViewport: denial, + }); +}); + +test('an owner serving no gesture tier names the family denial once and still answers with the exhaustive shape', () => { + const denial = { available: false, reason: 'unsupported-platform-leaf' } as const; + + const facts = gestureRuntimeOperationFacts({ unsupported: denial }); + + expect(facts).toEqual({ + performGesturePlan: denial, + performDirectionalFlingPlan: denial, + performMultiTouchGesturePlan: denial, + performTargetAuthoredDrag: denial, + gestureViewport: denial, + }); + expect(Object.isFrozen(facts)).toBe(true); +}); + test('a local binding executes the plan through the owner interactor', async () => { const performGesture = vi.fn(async () => ({ backend: 'adb' })); const resolveInteractor = vi.fn(async () => ({ performGesture }) as unknown as Interactor); @@ -110,11 +143,9 @@ test('a binding exposes only the tiers its owner facts admitted', () => { device, signal: new AbortController().signal, facts: gestureRuntimeOperationFacts({ + unsupported: unavailable, plan: available, - directionalFling: unavailable, - multiTouch: unavailable, targetAuthoredDrag: unavailable, - viewport: unavailable, }), resolveInteractor: async () => ({ performGesture: async () => ({}) }) as unknown as Interactor, }); diff --git a/packages/contracts/src/gesture-runtime.ts b/packages/contracts/src/gesture-runtime.ts index eded5e3225..589cf9b0f9 100644 --- a/packages/contracts/src/gesture-runtime.ts +++ b/packages/contracts/src/gesture-runtime.ts @@ -8,7 +8,7 @@ import { type ProviderInteractorOperationResolver, } from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts'; import { invalidRuntimeContract } from './runtime-contract-error.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -70,22 +70,38 @@ export type GestureRuntimeOperationFacts = Readonly<{ gestureViewport: RuntimeOperationFact; }>; +/** + * What an owner declares about the gesture family. Every tier is a per-owner claim — no tier is + * one every owner serves, and several owners serve none — so every tier is optional and + * `unsupported` names the denial an omitted tier reports. The tiers split exactly where an owner's + * mechanics split, so an owner states a tier only to say something the family denial does not. + * + * Omission is a classified denial, never an unclassified tier and never an implied success: the + * type refuses a call that does not carry `unsupported`, so no owner can leave the family blank. + * `unsupported` must refuse the family rather than one tier of it, because whatever the owner + * leaves unnamed reports that cell verbatim. + */ +export type GestureRuntimeOperationFactsInput = Readonly<{ + unsupported: RuntimeOperationUnavailability; + plan?: RuntimeOperationFact; + directionalFling?: RuntimeOperationFact; + multiTouch?: RuntimeOperationFact; + targetAuthoredDrag?: RuntimeOperationFact; + viewport?: RuntimeOperationFact; +}>; + /** Builds the exhaustive owner claims for the five gesture requirements. */ export function gestureRuntimeOperationFacts( - input: Readonly<{ - plan: RuntimeOperationFact; - directionalFling: RuntimeOperationFact; - multiTouch: RuntimeOperationFact; - targetAuthoredDrag: RuntimeOperationFact; - viewport: RuntimeOperationFact; - }>, + input: GestureRuntimeOperationFactsInput, ): GestureRuntimeOperationFacts { + const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact => + fact ?? input.unsupported; return Object.freeze({ - performGesturePlan: input.plan, - performDirectionalFlingPlan: input.directionalFling, - performMultiTouchGesturePlan: input.multiTouch, - performTargetAuthoredDrag: input.targetAuthoredDrag, - gestureViewport: input.viewport, + performGesturePlan: declared(input.plan), + performDirectionalFlingPlan: declared(input.directionalFling), + performMultiTouchGesturePlan: declared(input.multiTouch), + performTargetAuthoredDrag: declared(input.targetAuthoredDrag), + gestureViewport: declared(input.viewport), }); } diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index b4fca03902..9735b0575e 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -224,13 +224,7 @@ export function createUnavailablePlatformRuntimeFacts( }), ...viewportRuntimeOperationFacts({ setViewport: frozen.viewport }), ...focusRuntimeOperationFacts({ focus: frozen.focus }), - ...gestureRuntimeOperationFacts({ - plan: frozen.gesture, - directionalFling: frozen.gesture, - multiTouch: frozen.gesture, - targetAuthoredDrag: frozen.gesture, - viewport: frozen.gesture, - }), + ...gestureRuntimeOperationFacts({ unsupported: frozen.gesture }), ...scrollRuntimeOperationFacts({ scroll: frozen.scroll }), ...typeTextRuntimeOperationFacts({ type: frozen.typeText }), ...touchRuntimeOperationFacts({ diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index ae4dfa12d5..0e0ed64cba 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -319,6 +319,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), ...focusRuntimeOperationFacts({ focus: androidTouchFact(device) }), ...gestureRuntimeOperationFacts({ + unsupported: gestureKindUnavailable, plan: androidGestureFact(device), directionalFling: androidGestureFact(device), multiTouch: androidTouchTargetFact(device, androidTvMultiTouchUnavailable), diff --git a/packages/platform-apple/src/gesture-facts.ts b/packages/platform-apple/src/gesture-facts.ts index c740c81f1e..399c3c7f9f 100644 --- a/packages/platform-apple/src/gesture-facts.ts +++ b/packages/platform-apple/src/gesture-facts.ts @@ -38,6 +38,7 @@ function unsupportedAppleDeviceKind(hint: string) { export function appleGestureAndScrollFacts(device: DeviceInfo) { return { ...gestureRuntimeOperationFacts({ + unsupported: gestureKindUnavailable, plan: appleGesturePlanFact(device), directionalFling: appleGesturePlanFact(device), multiTouch: appleMultiTouchGestureFact(device), diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 9424eee648..5e2412f51b 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -252,6 +252,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor // Gestures share focus's HDC-driven kind cell; only the two tiers HDC cannot synthesize // are refused. ...gestureRuntimeOperationFacts({ + unsupported: gestureKindUnavailable, plan: harmonyGestureFact(device), directionalFling: harmonyGestureFact(device), multiTouch: multiTouchUnavailable, diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index ab5c30efc3..91f668cc6f 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -247,6 +247,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // every cell — a direction-authored fling's speed semantics, two-contact synthesis, and // target-authored drag timing. ...gestureRuntimeOperationFacts({ + unsupported: gestureKindUnavailable, plan: linuxDesktopFact(device, gestureKindUnavailable), directionalFling: directionalFlingUnavailable, multiTouch: multiTouchUnavailable, diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 100fa1ea85..279f51f8c3 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -232,11 +232,9 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts // one; the rest had no retired closure and now refuse at admission rather than inside the // Vega interactor. ...gestureRuntimeOperationFacts({ - plan: gestureUnavailable, - directionalFling: gestureUnavailable, + unsupported: gestureUnavailable, multiTouch: multiTouchUnavailable, targetAuthoredDrag: targetAuthoredDragUnavailable, - viewport: gestureUnavailable, }), }, }); diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 1ed2ee4131..0c6b8207da 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -399,11 +399,8 @@ function webRuntimeFacts( // outright. Drag is the exception it checked FIRST, by naming the phases an adapter needs. ...scrollRuntimeOperationFacts({ scroll: browserDevice }), ...gestureRuntimeOperationFacts({ - plan: gestureUnavailable, - directionalFling: gestureUnavailable, - multiTouch: gestureUnavailable, + unsupported: gestureUnavailable, targetAuthoredDrag: targetAuthoredDragUnavailable, - viewport: gestureUnavailable, }), ...viewportRuntimeOperationFacts({ setViewport: browserDevice }), // The web backend has no point-addressed read: `get` answers from the captured DOM tree, diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index e46cdfd472..f8b9b91c52 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -54,6 +54,11 @@ const iosGestureUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose portable gesture execution yet.', } as const); +/** A gesture tier this provider mode never exposes, whatever the session. */ +const gestureUnsupportedProviderMode = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', +} as const); const androidTvMultiTouchUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -74,26 +79,15 @@ function limrunGestureFacts( device: DeviceInfo, cell: RuntimeOperationUnavailability | typeof available, ): GestureRuntimeOperationFacts { - if (cell !== available) { - return gestureRuntimeOperationFacts({ - plan: cell, - directionalFling: cell, - multiTouch: cell, - targetAuthoredDrag: cell, - viewport: cell, - }); + if (!cell.available) { + return gestureRuntimeOperationFacts({ unsupported: cell }); } if (device.platform !== 'android') { - return gestureRuntimeOperationFacts({ - plan: iosGestureUnavailable, - directionalFling: iosGestureUnavailable, - multiTouch: iosGestureUnavailable, - targetAuthoredDrag: iosGestureUnavailable, - viewport: iosGestureUnavailable, - }); + return gestureRuntimeOperationFacts({ unsupported: iosGestureUnavailable }); } const tv = device.target === 'tv'; return gestureRuntimeOperationFacts({ + unsupported: gestureUnsupportedProviderMode, plan: available, directionalFling: available, multiTouch: tv ? androidTvMultiTouchUnavailable : available, diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 45c6068da4..2e82d2952a 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -628,6 +628,7 @@ function webDriverFacts( // ever owns physical devices, and two-finger synthesis on a physical iOS device was refused // before this migration exactly as it is refused here. ...gestureRuntimeOperationFacts({ + unsupported: gestureUnavailable, plan: interactorCell(reachable, gestureUnavailable), directionalFling: interactorCell(reachable, gestureUnavailable), multiTouch: webDriverMultiTouchCell(device, reachable), diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 80999ed3f3..fb3b415be4 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -52,13 +52,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre longPress: unavailable, fill: unavailable, }), - ...gestureRuntimeOperationFacts({ - plan: unavailable, - directionalFling: unavailable, - multiTouch: unavailable, - targetAuthoredDrag: unavailable, - viewport: unavailable, - }), + ...gestureRuntimeOperationFacts({ unsupported: unavailable }), ...scrollRuntimeOperationFacts({ scroll: unavailable }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: unavailable }), back: unavailable, diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 31da7ffa42..338364d7dd 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -369,13 +369,7 @@ function sourceRuntimeFacts( longPress: unavailable, fill: unavailable, }), - ...gestureRuntimeOperationFacts({ - plan: unavailable, - directionalFling: unavailable, - multiTouch: unavailable, - targetAuthoredDrag: unavailable, - viewport: unavailable, - }), + ...gestureRuntimeOperationFacts({ unsupported: unavailable }), ...scrollRuntimeOperationFacts({ scroll: unavailable }), readTextAtPoint: unavailable, back: unavailable, From 3b4b6158963ba44b5104d0b1645f24750f7bc80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 10 Sep 2026 16:13:13 +0200 Subject: [PATCH 2/2] fix(platform-apple): name the gesture family denial by leaf Review follow-up on the gesture family. Apple refuses gestures for two reasons depending on the leaf: watchOS and visionOS have no gesture surface at all, while every other leaf without a touch kind is the wrong device kind. One constant cannot be truthful for both, and the family denial is what the first unnamed tier will report, so it now follows the leaf. The Limrun gesture call collapsed to one source for the session-less branch, so pin the reason and hint it reports there rather than availability alone, and cover the directional-fling tier the loop had skipped. --- packages/platform-apple/src/gesture-facts.ts | 14 ++++++++++++-- .../provider-limrun/src/app-log-runtime.test.ts | 7 ++++++- .../provider-limrun/src/interaction-operations.ts | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/platform-apple/src/gesture-facts.ts b/packages/platform-apple/src/gesture-facts.ts index 399c3c7f9f..93e651f196 100644 --- a/packages/platform-apple/src/gesture-facts.ts +++ b/packages/platform-apple/src/gesture-facts.ts @@ -4,7 +4,10 @@ import { TARGET_AUTHORED_DRAG_UNSUPPORTED_HINT, } from '@agent-device/contracts/gesture-admission'; import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime'; -import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import type { + RuntimeOperationFact, + RuntimeOperationUnavailability, +} from '@agent-device/contracts/platform-runtime'; import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device'; @@ -38,7 +41,7 @@ function unsupportedAppleDeviceKind(hint: string) { export function appleGestureAndScrollFacts(device: DeviceInfo) { return { ...gestureRuntimeOperationFacts({ - unsupported: gestureKindUnavailable, + unsupported: appleGestureFamilyUnavailable(device), plan: appleGesturePlanFact(device), directionalFling: appleGesturePlanFact(device), multiTouch: appleMultiTouchGestureFact(device), @@ -49,6 +52,13 @@ export function appleGestureAndScrollFacts(device: DeviceInfo) { }; } +/** The reason this leaf refuses gestures it does not name, before any tier is consulted. */ +function appleGestureFamilyUnavailable(device: DeviceInfo): RuntimeOperationUnavailability { + return device.appleOs === 'watchos' || device.appleOs === 'visionos' + ? gestureLeafUnavailable + : gestureKindUnavailable; +} + function appleGesturePlanFact(device: DeviceInfo): RuntimeOperationFact { if (device.appleOs === 'watchos' || device.appleOs === 'visionos') return gestureLeafUnavailable; return appleTouchKind(device) ? available : gestureKindUnavailable; diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 198745d405..5b450e85d3 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -481,11 +481,16 @@ test('closes every Limrun gesture and scroll cell without a live session', async const facts = await owner.inspectFacts(limrunAndroid); for (const operation of [ 'performGesturePlan', + 'performDirectionalFlingPlan', 'performMultiTouchGesturePlan', 'performTargetAuthoredDrag', 'gestureViewport', 'scrollDirection', ] as const) { - expect(facts.operations[operation].available).toBe(false); + expect(facts.operations[operation]).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + hint: 'Limrun requires a matching live provider session for this device.', + }); } }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index f8b9b91c52..80343e8811 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -54,7 +54,7 @@ const iosGestureUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose portable gesture execution yet.', } as const); -/** A gesture tier this provider mode never exposes, whatever the session. */ +/** What an unnamed gesture tier reports on a live Limrun session. */ const gestureUnsupportedProviderMode = Object.freeze({ available: false, reason: 'unsupported-provider-mode',