Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/contracts/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export type RuntimeOperationUnavailability = Readonly<{
| 'unsupported-platform-leaf'
| 'unsupported-device-kind'
| 'unsupported-device-backend'
| 'unsupported-device-scope'
| 'unsupported-provider-mode'
| 'owner-capability-missing';
hint?: string;
Expand Down Expand Up @@ -406,6 +407,7 @@ function isRuntimeOperationUnavailabilityReason(
value === 'unsupported-platform-leaf' ||
value === 'unsupported-device-kind' ||
value === 'unsupported-device-backend' ||
value === 'unsupported-device-scope' ||
value === 'unsupported-provider-mode' ||
value === 'owner-capability-missing'
);
Expand Down
103 changes: 103 additions & 0 deletions packages/platform-apple/src/foldable/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { beforeEach, expect, test, vi } from 'vitest';

vi.mock('./pose.ts', () => ({ setAppleFoldPose: vi.fn() }));

import {
localRuntimeOwner,
narrowDeviceBinding,
type DeviceBinding,
type RuntimeFacts,
} from '@agent-device/contracts/platform-runtime';
import {
foldRuntimeUse,
type PlatformRuntimeOperations,
} from '@agent-device/contracts/platform-runtime-operations';
import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device';

import { appleFoldableFacts, createAppleFoldableOperations } from './runtime.ts';
import { setAppleFoldPose } from './pose.ts';

const mockPose = vi.mocked(setAppleFoldPose);

const duo: DeviceInfo = {
platform: 'apple',
appleOs: 'ios',
id: '4F879835-4AB3-4046-B033-5AB769209DD4',
name: 'iPhone Duo',
kind: 'simulator',
target: 'mobile',
booted: true,
};
const scopedDuo: DeviceInfo = { ...duo, simulatorSetPath: '/tmp/scoped-set' };

/** A full owner binding whose fold cell is derived only from the fact under test. */
function foldBinding(device: DeviceInfo): DeviceBinding<PlatformRuntimeOperations> {
const { setFoldPose } = appleFoldableFacts(device);
return {
device,
owner: localRuntimeOwner('apple'),
facts: {
device: { ...deviceShape(device), providerMode: 'local' },
operations: { setFoldPose } as RuntimeFacts<PlatformRuntimeOperations>['operations'],
},
operations: createAppleFoldableOperations({ device, signal: new AbortController().signal }),
[Symbol.asyncDispose]: async () => {},
};
}

function thrownBy<T>(run: () => T): unknown {
try {
run();
} catch (error) {
return error;
}
throw new Error('expected the routed fold binding to refuse');
}

beforeEach(() => {
mockPose.mockReset();
});

test('the default-set iPhone Duo admits setFoldPose and binds the pose operation', () => {
expect(appleFoldableFacts(duo).setFoldPose).toEqual({ available: true });
expect(
createAppleFoldableOperations({ device: duo, signal: new AbortController().signal }),
).toHaveProperty('setFoldPose', expect.any(Function));
});

test('a scoped simulator set refuses setFoldPose with the typed unsupported-device-scope fact', () => {
expect(appleFoldableFacts(scopedDuo).setFoldPose).toMatchObject({
available: false,
reason: 'unsupported-device-scope',
hint: expect.stringContaining('/tmp/scoped-set'),
});
});

test('a scoped simulator set binds no pose operation and never reaches a hinge', () => {
const operations = createAppleFoldableOperations({
device: scopedDuo,
signal: new AbortController().signal,
});
expect(operations).not.toHaveProperty('setFoldPose');
expect(mockPose).not.toHaveBeenCalled();
});

test('the narrowed fold use refuses a scoped simulator set with the typed fact reason and hint', () => {
const refusal = thrownBy(() => narrowDeviceBinding(foldBinding(scopedDuo), foldRuntimeUse)) as {
code?: string;
details?: { reason?: string; hint?: string };
};
expect(refusal.code).toBe('UNSUPPORTED_OPERATION');
expect(refusal.details?.reason).toBe('unsupported-device-scope');
expect(refusal.details?.hint).toContain('/tmp/scoped-set');
});

test('a scoped set never outranks the existing kind and OS refusals', () => {
expect(appleFoldableFacts({ ...scopedDuo, kind: 'device' }).setFoldPose).toMatchObject({
available: false,
reason: 'unsupported-device-kind',
});
expect(
appleFoldableFacts({ ...scopedDuo, appleOs: 'tvos', target: 'tv' }).setFoldPose,
).toMatchObject({ available: false, reason: 'unsupported-platform-leaf' });
});
21 changes: 19 additions & 2 deletions packages/platform-apple/src/foldable/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,33 @@ const foldOsUnavailable = Object.freeze({
hint: 'fold poses the hinge of a foldable iPhone; tvOS, macOS, watchOS and visionOS simulators have no hinge.',
} as const);

/**
* A fold inside a scoped simulator set cannot verify its pose: the HID send honours `--set`, but
* `devicectl device info displays` and `devicectl device motion hinge-angle` take only `--device`
* and resolve a scoped simulator as `not found`, so ADR 0025's post-dispatch readback is impossible.
* The refusal is the owning fact, before any display probe or HID effect; the default set is unaffected.
*/
function foldScopeUnavailable(simulatorSetPath: string) {
return {
available: false,
reason: 'unsupported-device-scope',
hint: `fold cannot resolve a simulator scoped to the set at "${simulatorSetPath}": CoreDevice's display inventory and hinge-angle readback need a simulator in the default set, so the pose could not be verified. Run fold without --ios-simulator-device-set.`,
} as const;
}

/**
* The simulator leaf that can carry a hinge: iPhone and iPad. Which *model* inside it actually
* folds is not in `DeviceInfo`, so the operation answers that from CoreDevice's display table and
* refuses a single-panel simulator with a typed `UNSUPPORTED_OPERATION`, the way the runner
* answers for the Action Button hardware.
* answers for the Action Button hardware. A simulator scoped to a non-default set is refused too;
* see {@link foldScopeUnavailable}.
*/
function appleFoldFact(device: DeviceInfo): RuntimeOperationFact {
if (device.kind !== 'simulator') return foldKindUnavailable;
const os = resolveDeviceAppleOs(device);
return os === 'ios' || os === 'ipados' ? available : foldOsUnavailable;
if (os !== 'ios' && os !== 'ipados') return foldOsUnavailable;
if (device.simulatorSetPath) return foldScopeUnavailable(device.simulatorSetPath);
return available;
}

/** The foldable cell: `setFoldPose`. */
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-apple/src/foldable/simulator-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ test('streams all keyframes in one process with a duration-derived timeout', asy
expect(dispatches).toBe(1);
});

// Transport-level only: this pins that a pose dispatch routes through runSimctlForDevice, which
// targets the UDID inside its scoped set. It is NOT end-to-end scoped-fold support — `appleFoldFact`
// refuses `unsupported-device-scope` at runtime admission before this dispatch is ever reached.
test('HID dispatch addresses the UDID inside its scoped simulator set', async () => {
const dispatches: string[][] = [];
await withAppleToolProvider(
Expand Down
22 changes: 22 additions & 0 deletions packages/platform-apple/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,28 @@ test.each(Object.entries(leaves))(
},
);

// A simulator scoped to a non-default set is refused by the owner's own fact, before any display
// probe or HID effect: CoreDevice's display inventory and hinge-angle readback cannot resolve it,
// so ADR 0025's post-dispatch verification is impossible. The default-set iOS leaf stays admitted.
test('refuses the fold fact for a scoped simulator set and binds no pose operation', async () => {
const scoped = appleDevice({ simulatorSetPath: '/tmp/scoped-set' });
const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({
device: scoped,
intent: { kind: 'ordinary' },
scope: {
signal: new AbortController().signal,
diagnostics: { emit: () => {} },
progress: { report: () => {} },
},
});
expect(binding.facts.operations.setFoldPose).toMatchObject({
available: false,
reason: 'unsupported-device-scope',
hint: expect.stringContaining('/tmp/scoped-set'),
});
expect(binding.operations.setFoldPose).toBeUndefined();
});

/**
* The Action Button is a physical control on iPhone and iPad leaves only. visionOS is the leaf that
* separates this from `orientation`'s mobile-input reading: a headset has a Digital Crown and no
Expand Down
2 changes: 1 addition & 1 deletion src/commands/schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ Screens are handled for you:

Changing the pose:
agent-device fold closed | half-open | open
fold sends a private HID hinge event inside the selected simulator and then reads the hinge angle back from CoreDevice until it agrees: closed is 0 degrees, open is 180, and half-open is any angle between them (requested at 130 degrees). An angle in that interval only proves the category, so half-open is reported once two consecutive readings both fall inside it and agree within 0.5 degrees. The response reports the verified pose, the hinge angle, and the panel the device now lights with its native panel point size, marked coordinateSpace "native-panel". That point size is the panel's own geometry, not the next snapshot's viewport, so it cannot place a tap: the active app window can differ (a 669x951 inner panel hosts a 951x669 window). A hinge whose last reading is some other pose fails with COMMAND_FAILED and reason fold-pose-unverified, naming the angle CoreDevice still reports; a hinge seen half-open but never at rest fails with reason fold-pose-unsettled, naming the observed and previous angles. A single-panel simulator fails with UNSUPPORTED_OPERATION.
fold sends a private HID hinge event inside the selected simulator and then reads the hinge angle back from CoreDevice until it agrees: closed is 0 degrees, open is 180, and half-open is any angle between them (requested at 130 degrees). An angle in that interval only proves the category, so half-open is reported once two consecutive readings both fall inside it and agree within 0.5 degrees. The response reports the verified pose, the hinge angle, and the panel the device now lights with its native panel point size, marked coordinateSpace "native-panel". That point size is the panel's own geometry, not the next snapshot's viewport, so it cannot place a tap: the active app window can differ (a 669x951 inner panel hosts a 951x669 window). A hinge whose last reading is some other pose fails with COMMAND_FAILED and reason fold-pose-unverified, naming the angle CoreDevice still reports; a hinge seen half-open but never at rest fails with reason fold-pose-unsettled, naming the observed and previous angles. A single-panel simulator fails with UNSUPPORTED_OPERATION. A simulator scoped to a non-default set with --ios-simulator-device-set is refused before any hinge is touched, with UNSUPPORTED_OPERATION and reason unsupported-device-scope: the HID send honors the set, but CoreDevice's display inventory and hinge-angle readback resolve a scoped simulator as not found, so the pose could not be verified. Run fold without --ios-simulator-device-set (in the default set).
Expect a fold to take 10-16 seconds: each hinge read is a five-second devicectl stream, and half-open waits for the hinge to stop moving. Re-snapshot after every fold; refs and coordinates from before it are stale, and the command's message says so.
Timed motion: fold --keyframes '[{"atMs":0,"angle":0},{"atMs":5000,"angle":180}]'. Use 2–64 frames starting at 0ms, increasing integer timestamps up to 60000ms, and angles from 0 to 180. The last timestamp sets motion duration, excluding setup and verification. Equal angles hold; cancellation stops motion. See the fold examples in the command and Node API documentation for trajectories.

Expand Down
2 changes: 1 addition & 1 deletion src/commands/system/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const homeCommandDescription =
'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.';
const orientationCommandDescription = 'Set device orientation on iOS and Android';
const foldCommandDescription =
'Fold or unfold a foldable iPhone simulator (iPhone Duo) into the closed, half-open, or open pose, or follow timestamped angle keyframes, by sending a simulator HID hinge event, then read the hinge angle back from CoreDevice to confirm it. A pose change moves the app to a different panel with a different point size, so every ref and coordinate from before it is stale: re-snapshot after this command. Taps, long presses, and scrolling target the app window on its current panel in closed, half-open, and open poses. Simulator-only; requires the iOS simulator SDK; Device Hub and host Accessibility permission are not required.';
'Fold or unfold a foldable iPhone simulator (iPhone Duo) into the closed, half-open, or open pose, or follow timestamped angle keyframes, by sending a simulator HID hinge event, then read the hinge angle back from CoreDevice to confirm it. A pose change moves the app to a different panel with a different point size, so every ref and coordinate from before it is stale: re-snapshot after this command. Taps, long presses, and scrolling target the app window on its current panel in closed, half-open, and open poses. Simulator-only; requires the iOS simulator SDK; Device Hub and host Accessibility permission are not required. A simulator scoped to a non-default simulator set is refused with UNSUPPORTED_OPERATION and reason unsupported-device-scope; run fold against a simulator in the default set.';
const appSwitcherCommandDescription =
'Open the device app switcher to inspect or change foreground apps. This changes the visible system UI and may move focus away from the current app.';
const keyboardCommandDescription =
Expand Down
39 changes: 36 additions & 3 deletions src/daemon/__tests__/fold-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
foldRuntimeUse,
type PlatformRuntimeOperations,
} from '@agent-device/contracts/platform-runtime-operations';
import { deviceShape } from '@agent-device/kernel/device';
import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device';
import { makeSession } from '../../__tests__/test-utils/session-factories.ts';
import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts';
import type { GenericPlatformExecutionParams } from '../request-generic-dispatch.ts';
Expand Down Expand Up @@ -60,13 +60,14 @@ function runtimeHarness(
pose: 'open',
hingeAngleDegrees: 180,
})),
device: DeviceInfo = testDevice,
) {
const facts: RuntimeFacts<PlatformRuntimeOperations> = {
device: { ...deviceShape(testDevice), providerMode: 'local' },
device: { ...deviceShape(device), providerMode: 'local' },
operations: { setFoldPose: fact } as RuntimeFacts<PlatformRuntimeOperations>['operations'],
};
const binding = {
device: testDevice,
device,
owner: localRuntimeOwner('apple'),
facts,
operations: { setFoldPose },
Expand Down Expand Up @@ -162,6 +163,38 @@ test('rejects an unavailable exact-owner fact before binding', async () => {
});
});

// The scoped-set refusal is a real route outcome, not just a narrowed binding: a scoped session's
// `setFoldPose` fact refuses on admission, so the wire error carries the typed reason and the fact's
// own hint, and the device is never bound or posed. The hint is a sentinel the route could never
// compose, so the assertion proves propagation without re-typing production's wording.
test('refuses a scoped simulator set on the route with the typed reason, never binding', async () => {
const scopedDevice: DeviceInfo = { ...testDevice, simulatorSetPath: '/tmp/scoped-set' };
const scopeRefusal = {
available: false,
reason: 'unsupported-device-scope',
hint: 'SCOPED-SET-HINT-SENTINEL',
} as const;
const harness = runtimeHarness(scopeRefusal, vi.fn(), scopedDevice);

const resolved = await resolveBoundFoldRuntime({
device: scopedDevice,
positionals: ['half-open'],
inspectFacts: harness.inspectFacts,
bindDevice: harness.bindDevice,
});

expect(resolved.ok).toBe(false);
if (resolved.ok || resolved.response.ok) throw new Error('the scoped-set fact admitted fold');
expect(resolved.response.error).toMatchObject({
code: 'UNSUPPORTED_OPERATION',
message: 'fold is not supported on this device',
hint: 'SCOPED-SET-HINT-SENTINEL',
details: { reason: 'unsupported-device-scope' },
});
expect(harness.bindDevice).not.toHaveBeenCalled();
expect(harness.setFoldPose).not.toHaveBeenCalled();
});

test('passes the validated timed intent to the admitted fold owner', async () => {
const setFoldPose = vi.fn(async (): Promise<SetFoldPoseResult> => ({
pose: 'half-open',
Expand Down
1 change: 1 addition & 0 deletions src/daemon/__tests__/keyboard-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ test('android status is refused on iOS with the retired in-handler hint', async
code: 'UNSUPPORTED_OPERATION',
message: 'keyboard status is not supported on this device',
hint: unavailable.hint,
details: { reason: unavailable.reason },
},
},
});
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/__tests__/runtime-binding-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,15 @@ export async function refuseUnavailableExactOwnerFact(
return resolved.response;
}

/** The wording `admitRuntimeOperations` gives every single-use route it refuses. */
/** The wording and shape `admitRuntimeOperations` gives every single-use route it refuses. */
function unsupportedOperationRefusal(
command: ConformedRuntimeCommand,
unavailable: RuntimeOperationUnavailability,
): DaemonFailureResponse['error'] {
return {
code: 'UNSUPPORTED_OPERATION',
message: `${command} is not supported on this device`,
details: { reason: unavailable.reason },
...(unavailable.hint ? { hint: unavailable.hint } : {}),
};
}
Expand Down
25 changes: 19 additions & 6 deletions src/daemon/runtime-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ export type RuntimeAdmissionRequest = RuntimeAdmissionBindings &

export type { RuntimeAdmissionBindings };

/**
* The one refusal the daemon reports when a device's exact runtime owner did not admit an
* operation. Both seams — this generic route and the request-scoped session handlers — build their
* `UNSUPPORTED_OPERATION` here, so the `<command> is not supported on this device` sentence, the
* typed `details.reason`, and the hint have a single owner and one wire shape.
*/
export function unsupportedOperationResponse(
Comment thread
thymikee marked this conversation as resolved.
command: string,
unavailable: RuntimeOperationUnavailability,
): DaemonFailureResponse {
return errorResponse(
'UNSUPPORTED_OPERATION',
`${command} is not supported on this device`,
{ reason: unavailable.reason },
unavailable.hint ? { hint: unavailable.hint } : undefined,
);
}

/**
* The one facts-admission seam every migrated command route shares. It performs exactly one
* side-effect-free inspection and hands back the binding gateway only once the exact device cell
Expand All @@ -57,12 +75,7 @@ export async function admitRuntimeOperations(
if (fact.available) continue;
const response = request.unavailableResponse
? request.unavailableResponse(fact)
: errorResponse(
'UNSUPPORTED_OPERATION',
`${request.command} is not supported on this device`,
undefined,
fact.hint ? { hint: fact.hint } : undefined,
);
: unsupportedOperationResponse(request.command, fact);
return { type: 'response', response };
}
return { type: 'admitted', bind: requireDeviceBinding(request.bindDevice) };
Expand Down
Loading
Loading