diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index b3e7abc3bc..935dd307e4 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -1,5 +1,9 @@ import { AppError } from '@agent-device/kernel/errors'; +/** Single source of truth for the discriminator the Apple owner sets and the MCP schema advertises. */ +export const FOLD_SCREEN_COORDINATE_SPACE = 'native-panel' as const; +export type FoldScreenCoordinateSpace = typeof FOLD_SCREEN_COORDINATE_SPACE; + export const DEVICE_ROTATIONS = [ 'portrait', 'portrait-upside-down', diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index d35b88114e..e20ece56ba 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -31,6 +31,7 @@ export type { export { DEVICE_ROTATIONS, DEVICE_ROTATION_SURFACE_INDEX, + FOLD_SCREEN_COORDINATE_SPACE, MAX_FOLD_DURATION_MS, MAX_FOLD_KEYFRAMES, parseFoldInput, @@ -47,6 +48,7 @@ export type { DeviceRotation, FoldPose, FoldKeyframe, + FoldScreenCoordinateSpace, SetFoldPoseInput, } from '../device-rotation.ts'; export type { BootCommandResult, ShutdownCommandResult } from '../device.ts'; diff --git a/packages/contracts/src/fold-runtime.ts b/packages/contracts/src/fold-runtime.ts index 603c91cb05..84598b127b 100644 --- a/packages/contracts/src/fold-runtime.ts +++ b/packages/contracts/src/fold-runtime.ts @@ -1,13 +1,18 @@ -import type { FoldPose, SetFoldPoseInput } from './device-rotation.ts'; +import type { FoldPose, FoldScreenCoordinateSpace, SetFoldPoseInput } from './device-rotation.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; -/** Single source of truth for the discriminator the Apple owner sets and the MCP schema advertises. */ -export const FOLD_SCREEN_COORDINATE_SPACE = 'native-panel' as const; +/** + * Mirrors `device-rotation.ts`'s `FOLD_SCREEN_COORDINATE_SPACE` (the single source of truth) as a + * literal rather than a re-export, so importing this published subpath does not also eagerly load + * `device-rotation.ts`. The `FoldScreenCoordinateSpace` type import above still makes any drift + * between the two a compile error. + */ +export const FOLD_SCREEN_COORDINATE_SPACE: FoldScreenCoordinateSpace = 'native-panel'; /** * The panel the device lights after the pose settled, in that panel's own native points: its pixel * size divided by its point scale, never rotated. `coordinateSpace` is always - * {@link FOLD_SCREEN_COORDINATE_SPACE}, and these numbers are NOT snapshot coordinates — the active + * {@link FoldScreenCoordinateSpace}, and these numbers are NOT snapshot coordinates — the active * app window can differ from the panel (iPhone Duo: a 669x951 inner panel hosts a 951x669 app * window), so they cannot place a tap. A caller that needs the app viewport must take a fresh * snapshot. @@ -16,7 +21,7 @@ export type FoldScreenReport = Readonly<{ /** The CoreDevice display name of the panel the device now lights. */ display: string; /** Marks these dimensions as the panel's native points, never a snapshot's app viewport. */ - coordinateSpace: typeof FOLD_SCREEN_COORDINATE_SPACE; + coordinateSpace: FoldScreenCoordinateSpace; widthPt: number; heightPt: number; }>; diff --git a/packages/platform-apple/src/foldable/pose.ts b/packages/platform-apple/src/foldable/pose.ts index 16b32a3f61..e9f1f059b1 100644 --- a/packages/platform-apple/src/foldable/pose.ts +++ b/packages/platform-apple/src/foldable/pose.ts @@ -1,13 +1,10 @@ import { type SetFoldPoseInput, foldPoseForHingeAngle, + FOLD_SCREEN_COORDINATE_SPACE, type FoldPose, } from '@agent-device/contracts/device'; -import { - FOLD_SCREEN_COORDINATE_SPACE, - type FoldScreenReport, - type SetFoldPoseResult, -} from '@agent-device/contracts/fold-runtime'; +import type { FoldScreenReport, SetFoldPoseResult } from '@agent-device/contracts/fold-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts index 77e66e90a7..5f240d912b 100644 --- a/src/commands/command-input.ts +++ b/src/commands/command-input.ts @@ -106,6 +106,11 @@ export function enumSchema(values: readonly string[], description?: string): Jso return { type: 'string', enum: values, ...(description ? { description } : {}) }; } +/** A single-valued string schema, for a discriminant field an output shape always fixes. */ +export function constSchema(value: string): JsonSchema { + return { type: 'string', const: value }; +} + export function stringSchema(description?: string): JsonSchema { return { type: 'string', ...(description ? { description } : {}) }; } diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 25fe92323f..bf25b9e8c3 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -1,7 +1,9 @@ import { FOLD_FLAGS } from '@agent-device/command-registry/flag-groups'; +import type { CommandResultMap } from '@agent-device/command-registry/command-result'; import type { ClipboardCommandOptions } from '@agent-device/contracts/client'; import { type FoldKeyframe, + FOLD_SCREEN_COORDINATE_SPACE, MAX_FOLD_DURATION_MS, MAX_FOLD_KEYFRAMES, parseFoldInput, @@ -18,6 +20,8 @@ import { parseTvRemoteButton, tvRemoteDurationMode, } from '@agent-device/contracts/tv-remote'; +import { APPLE_APPLICATION_STATES } from '@agent-device/kernel/snapshot'; +import { SESSION_SURFACES } from '@agent-device/contracts/session'; import { AppError } from '@agent-device/kernel/errors'; import type { CommandSchemaOverride } from '@agent-device/command-registry/command-schema'; import { @@ -28,6 +32,7 @@ import { requiredDaemonString, } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import type { JsonSchema } from '../command-contract.ts'; import { enumField, integerField, @@ -35,6 +40,12 @@ import { stringField, jsonSchemaField, readFieldInput, + booleanSchema, + constSchema, + enumSchema, + numberSchema, + objectSchema, + stringSchema, } from '../command-input.ts'; import { compactRecord } from '../input-readers.ts'; import { @@ -64,6 +75,154 @@ const TV_REMOTE_LONGPRESS_PRESET_MS = 500; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss', 'enter', 'return'] as const; +/** + * This family's advertised MCP `outputSchema`s, keyed by daemon command name and projected into + * the command map by `src/mcp/command-output-schemas.ts`. Non-strict like every other entry: no + * `additionalProperties: false`, so additive response fields such as `settle`/`cost` keep + * validating. `back`'s settle observation is grafted separately by the trait derivation pass. + */ +export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { + back: objectSchema( + { + action: constSchema('back'), + mode: enumSchema(BACK_MODES), + message: stringSchema(), + }, + ['action', 'mode', 'message'], + ), + home: objectSchema({ action: constSchema('home'), message: stringSchema() }, [ + 'action', + 'message', + ]), + orientation: objectSchema( + { + action: constSchema('orientation'), + orientation: enumSchema(DEVICE_ROTATIONS), + message: stringSchema(), + confirmed: booleanSchema(), + warning: stringSchema(), + }, + ['action', 'orientation', 'message'], + ), + 'app-switcher': objectSchema({ action: constSchema('app-switcher'), message: stringSchema() }, [ + 'action', + 'message', + ]), + fold: objectSchema( + { + action: constSchema('fold'), + pose: enumSchema(FOLD_POSES), + hingeAngleDegrees: numberSchema('Hinge angle CoreDevice read back after the pose settled.'), + screen: objectSchema( + { + display: stringSchema('CoreDevice name of the panel the device now lights.'), + coordinateSpace: constSchema(FOLD_SCREEN_COORDINATE_SPACE), + widthPt: numberSchema( + 'Panel width in native panel points (pixels divided by point scale), NOT snapshot coordinates; take a fresh snapshot to place a tap.', + ), + heightPt: numberSchema( + 'Panel height in native panel points (pixels divided by point scale), NOT snapshot coordinates; take a fresh snapshot to place a tap.', + ), + }, + ['display', 'coordinateSpace', 'widthPt', 'heightPt'], + ), + message: stringSchema(), + }, + ['action', 'pose', 'hingeAngleDegrees', 'message'], + ), + 'action-button': objectSchema({ action: constSchema('action-button'), message: stringSchema() }, [ + 'action', + 'message', + ]), + 'tv-remote': objectSchema( + { + action: constSchema('tv-remote'), + button: enumSchema(TV_REMOTE_BUTTONS), + durationMs: numberSchema(), + message: stringSchema(), + }, + ['action', 'button', 'message'], + ), + // packages/contracts/src/clipboard.ts — discriminated union on `action`. + clipboard: { + type: 'object', + oneOf: [ + objectSchema({ action: constSchema('read'), text: stringSchema() }, ['action', 'text']), + objectSchema( + { action: constSchema('write'), textLength: numberSchema(), message: stringSchema() }, + ['action', 'textLength', 'message'], + ), + ], + }, + // packages/contracts/src/app-state.ts — discriminated union on `platform`. + appstate: { + type: 'object', + oneOf: [ + objectSchema( + { + platform: enumSchema(['ios', 'macos']), + appName: stringSchema(), + appBundleId: stringSchema(), + source: enumSchema( + ['session', 'runner'], + 'runner when a live runner read the session app state; session when the record alone answered.', + ), + state: enumSchema( + APPLE_APPLICATION_STATES, + 'The session app XCUIApplication state as a live runner reads it; absent with source session.', + ), + surface: enumSchema(SESSION_SURFACES), + device_udid: stringSchema('iOS only — the session device UDID.'), + ios_simulator_device_set: { + type: ['string', 'null'], + description: 'iOS only — the simulator set path, or null when unknown.', + }, + }, + ['platform', 'appName', 'source', 'surface'], + ), + objectSchema( + { + platform: constSchema('android'), + package: stringSchema(), + activity: stringSchema(), + }, + ['platform', 'package', 'activity'], + ), + ], + }, + // packages/contracts/src/keyboard.ts — flat closed shape; `platform`/`action` always present. + keyboard: objectSchema( + { + platform: enumSchema(['android', 'ios']), + action: enumSchema(['status', 'dismiss', 'enter']), + visible: booleanSchema(), + wasVisible: booleanSchema(), + dismissed: booleanSchema(), + attempts: numberSchema(), + inputType: stringSchema(), + type: enumSchema(['text', 'number', 'email', 'phone', 'password', 'datetime', 'unknown']), + inputMethodPackage: stringSchema(), + focusedPackage: stringSchema(), + focusedResourceId: stringSchema(), + inputOwner: enumSchema(['app', 'ime', 'unknown']), + message: stringSchema(), + }, + ['platform', 'action'], + ), +} satisfies Pick< + Record, + | 'back' + | 'home' + | 'orientation' + | 'app-switcher' + | 'fold' + | 'action-button' + | 'tv-remote' + | 'clipboard' + | 'appstate' + | 'keyboard' +>; + const appStateCommandDescription = 'Show foreground app/activity (Android; iOS answers per command)'; const backCommandDescription = diff --git a/src/mcp/__tests__/command-tools-appstate-schema.test.ts b/src/mcp/__tests__/command-tools-appstate-schema.test.ts deleted file mode 100644 index 4b520454c2..0000000000 --- a/src/mcp/__tests__/command-tools-appstate-schema.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; -import { validateAgainstSchema } from './output-schema-validator.ts'; - -// `appstate` on iOS answers from the session record, and from a live runner when one can read the -// session app's XCUIApplication state; the schema must accept both answers and reject a state word -// the contract does not declare. -const IOS_SESSION_ANSWER: Readonly> = { - platform: 'ios', - appName: 'Benchmark', - appBundleId: 'dev.e2e.benchmark', - source: 'session', - surface: 'app', - device_udid: '279A81EC-B61A-4BE2-9F71-6A40FB8D2F9A', - ios_simulator_device_set: null, -}; - -test('MCP appstate schema accepts the runner-read iOS answer beside the session-only one', () => { - assert.deepEqual(validateAgainstSchema(IOS_SESSION_ANSWER, COMMAND_OUTPUT_SCHEMAS.appstate), []); - assert.deepEqual( - validateAgainstSchema( - { ...IOS_SESSION_ANSWER, source: 'runner', state: 'runningBackgroundSuspended' }, - COMMAND_OUTPUT_SCHEMAS.appstate, - ), - [], - ); - assert.notDeepEqual( - validateAgainstSchema( - { ...IOS_SESSION_ANSWER, source: 'runner', state: 'sleeping' }, - COMMAND_OUTPUT_SCHEMAS.appstate, - ), - [], - ); - assert.deepEqual( - validateAgainstSchema( - { platform: 'android', package: 'com.example.app', activity: '.MainActivity' }, - COMMAND_OUTPUT_SCHEMAS.appstate, - ), - [], - ); -}); diff --git a/src/mcp/__tests__/command-tools-navigation-schemas.test.ts b/src/mcp/__tests__/command-tools-navigation-schemas.test.ts deleted file mode 100644 index 29bd43e02a..0000000000 --- a/src/mcp/__tests__/command-tools-navigation-schemas.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { commandSupportsSettleObservation } from '@agent-device/command-registry/registry'; -import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; - -// The closed dispatch shape each navigation command's runtime returns -// (packages/contracts/src/navigation.ts). `back` is the settle-capable one, so -// its published schema is this shape PLUS the opt-in `--settle` observation and -// nothing else; the other four must match verbatim. -const NAVIGATION_DISPATCH_SHAPES: Readonly< - Record; required: readonly string[] }> -> = { - back: { - properties: { - action: { type: 'string', const: 'back' }, - mode: { type: 'string', enum: ['in-app', 'system'] }, - message: { type: 'string' }, - }, - required: ['action', 'mode', 'message'], - }, - home: { - properties: { - action: { type: 'string', const: 'home' }, - message: { type: 'string' }, - }, - required: ['action', 'message'], - }, - orientation: { - properties: { - action: { type: 'string', const: 'orientation' }, - orientation: { - type: 'string', - enum: ['portrait', 'portrait-upside-down', 'landscape-left', 'landscape-right'], - }, - message: { type: 'string' }, - confirmed: { type: 'boolean' }, - warning: { type: 'string' }, - }, - required: ['action', 'orientation', 'message'], - }, - 'app-switcher': { - properties: { - action: { type: 'string', const: 'app-switcher' }, - message: { type: 'string' }, - }, - required: ['action', 'message'], - }, - 'tv-remote': { - properties: { - action: { type: 'string', const: 'tv-remote' }, - button: { - type: 'string', - enum: ['up', 'down', 'left', 'right', 'select', 'menu', 'home', 'back'], - }, - durationMs: { type: 'number' }, - message: { type: 'string' }, - }, - required: ['action', 'button', 'message'], - }, -}; - -test('MCP navigation output schemas advertise the closed dispatch shapes', () => { - for (const [name, dispatchShape] of Object.entries(NAVIGATION_DISPATCH_SHAPES)) { - const schema = COMMAND_OUTPUT_SCHEMAS[name as keyof typeof COMMAND_OUTPUT_SCHEMAS] as { - type?: unknown; - properties?: Record; - required?: unknown; - }; - assert.deepEqual( - Object.keys(schema).sort(), - ['properties', 'required', 'type'], - `${name}: must advertise exactly type/properties/required at the top level`, - ); - assert.equal(schema.type, 'object', `${name}: must advertise an object schema`); - const { settle, ...dispatchProperties } = schema.properties ?? {}; - assert.equal( - Boolean(settle), - commandSupportsSettleObservation(name), - `${name}: settle property must track the post-action observation trait`, - ); - assert.deepEqual(dispatchProperties, dispatchShape.properties); - assert.deepEqual(schema.required, dispatchShape.required); - } -}); diff --git a/src/mcp/__tests__/command-tools-replay-schemas.test.ts b/src/mcp/__tests__/command-tools-replay-schemas.test.ts index 1cf320aafd..4456fa9b12 100644 --- a/src/mcp/__tests__/command-tools-replay-schemas.test.ts +++ b/src/mcp/__tests__/command-tools-replay-schemas.test.ts @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { ReplayCommandResult, ReplaySuiteResult } from '@agent-device/contracts/replay'; import { ownerFilesForCommand } from '@agent-device/command-registry/owner-files'; +import { commandSupportsSettleObservation } from '@agent-device/command-registry/registry'; import { REPLAY_COMMAND_OUTPUT_SCHEMAS } from '../../commands/replay/index.ts'; +import { SYSTEM_COMMAND_OUTPUT_SCHEMAS } from '../../commands/system/index.ts'; import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; import { validateAgainstSchema } from './output-schema-validator.ts'; @@ -66,7 +68,10 @@ test('the projected family map declares exactly the commands its module owns', ( * stays the compiler's; this owns the shadowing half. Every family the seam spreads is listed here, * so registering a new family is the migration step. */ -const PROJECTED_FAMILIES = [{ name: 'replay', schemas: REPLAY_COMMAND_OUTPUT_SCHEMAS }] as const; +const PROJECTED_FAMILIES = [ + { name: 'replay', schemas: REPLAY_COMMAND_OUTPUT_SCHEMAS }, + { name: 'system', schemas: SYSTEM_COMMAND_OUTPUT_SCHEMAS }, +] as const; test('projected output-schema families claim disjoint commands and survive the composition intact', () => { const claimedBy = new Map(); @@ -83,11 +88,23 @@ test('projected output-schema families claim disjoint commands and survive the c } for (const family of PROJECTED_FAMILIES) { for (const [command, schema] of Object.entries(family.schemas)) { - assert.equal( - COMMAND_OUTPUT_SCHEMAS[command as keyof typeof COMMAND_OUTPUT_SCHEMAS], - schema, - `${family.name}.${command} is not the schema the map publishes`, - ); + const published = COMMAND_OUTPUT_SCHEMAS[command as keyof typeof COMMAND_OUTPUT_SCHEMAS]; + // A command with the post-action observation trait (#1652) is grafted onto a COPY by + // deriveSettleObservationSchemas, so it is deliberately not reference-equal here; a + // trait-free command must survive the spread untouched. + if (commandSupportsSettleObservation(command)) { + assert.notEqual( + published, + schema, + `${family.name}.${command} carries the settle trait but was not copied by the derivation pass`, + ); + } else { + assert.equal( + published, + schema, + `${family.name}.${command} is not the schema the map publishes`, + ); + } } } }); diff --git a/src/mcp/__tests__/command-tools-system-schemas.test.ts b/src/mcp/__tests__/command-tools-system-schemas.test.ts new file mode 100644 index 0000000000..a483aefb90 --- /dev/null +++ b/src/mcp/__tests__/command-tools-system-schemas.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + COMMAND_OWNER_FILES, + ownerFilesForCommand, +} from '@agent-device/command-registry/owner-files'; +import { commandSupportsSettleObservation } from '@agent-device/command-registry/registry'; +import { SYSTEM_COMMAND_OUTPUT_SCHEMAS } from '../../commands/system/index.ts'; +import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; +import { validateAgainstSchema } from './output-schema-validator.ts'; + +const SYSTEM_COMMANDS = Object.keys(SYSTEM_COMMAND_OUTPUT_SCHEMAS) as Array< + keyof typeof SYSTEM_COMMAND_OUTPUT_SCHEMAS +>; + +// `back` is the one entry in this family with a post-action observation trait (#1652): the +// composed map grafts a `settle` property onto a COPY, so it is not reference-equal to the +// module's own object. Every other entry in the family carries no such trait and must survive +// the spread untouched. +const SETTLE_DERIVED_COMMANDS = new Set(['back']); + +test('MCP system family output schemas are the family module entries, not copies', () => { + for (const command of SYSTEM_COMMANDS) { + if (SETTLE_DERIVED_COMMANDS.has(command)) continue; + assert.equal( + COMMAND_OUTPUT_SCHEMAS[command], + SYSTEM_COMMAND_OUTPUT_SCHEMAS[command], + `${command} is not reference-equal to the system module's own schema object`, + ); + } +}); + +test('a settle-derived system entry still grafts onto the module object, not a foreign copy', () => { + const moduleBack = SYSTEM_COMMAND_OUTPUT_SCHEMAS.back; + const derivedBack = COMMAND_OUTPUT_SCHEMAS.back; + assert.notEqual(derivedBack, moduleBack, 'the derivation pass must copy, never mutate in place'); + assert.equal(derivedBack.required, moduleBack.required, 'required list is not re-derived'); + assert.equal( + derivedBack.properties?.mode, + moduleBack.properties?.mode, + 'a field object nested under the derived copy must still be the module property, not a rebuild', + ); +}); + +test('every system-owned command claims this module, and the module claims nothing else', () => { + for (const command of SYSTEM_COMMANDS) { + assert.ok( + ownerFilesForCommand(command).includes('src/commands/system/index.ts'), + `${command} projects its output schema from this module but does not name it as its owner`, + ); + } + + const commandsOwnedByThisModule = ( + Object.entries(COMMAND_OWNER_FILES) as Array<[string, readonly string[]]> + ) + .filter(([, ownerFiles]) => ownerFiles.includes('src/commands/system/index.ts')) + .map(([command]) => command) + .sort(); + assert.deepEqual( + commandsOwnedByThisModule, + [...SYSTEM_COMMANDS].sort(), + 'a command the registry attributes to this module is missing from SYSTEM_COMMAND_OUTPUT_SCHEMAS (or vice versa)', + ); +}); + +// `appstate` on iOS answers from the session record, and from a live runner when one can read the +// session app's XCUIApplication state; the schema must accept both answers and reject a state word +// the contract does not declare. +const IOS_SESSION_ANSWER: Readonly> = { + platform: 'ios', + appName: 'Benchmark', + appBundleId: 'dev.e2e.benchmark', + source: 'session', + surface: 'app', + device_udid: '279A81EC-B61A-4BE2-9F71-6A40FB8D2F9A', + ios_simulator_device_set: null, +}; + +test('MCP appstate schema accepts the runner-read iOS answer beside the session-only one', () => { + assert.deepEqual(validateAgainstSchema(IOS_SESSION_ANSWER, COMMAND_OUTPUT_SCHEMAS.appstate), []); + assert.deepEqual( + validateAgainstSchema( + { ...IOS_SESSION_ANSWER, source: 'runner', state: 'runningBackgroundSuspended' }, + COMMAND_OUTPUT_SCHEMAS.appstate, + ), + [], + ); + assert.notDeepEqual( + validateAgainstSchema( + { ...IOS_SESSION_ANSWER, source: 'runner', state: 'sleeping' }, + COMMAND_OUTPUT_SCHEMAS.appstate, + ), + [], + ); + assert.deepEqual( + validateAgainstSchema( + { platform: 'android', package: 'com.example.app', activity: '.MainActivity' }, + COMMAND_OUTPUT_SCHEMAS.appstate, + ), + [], + ); +}); + +test('MCP keyboard outputSchema validates a full result and refuses a dropped required action', () => { + const KEYBOARD_RESULT = { + platform: 'android', + action: 'status', + visible: true, + inputType: 'text', + }; + assert.deepEqual(validateAgainstSchema(KEYBOARD_RESULT, COMMAND_OUTPUT_SCHEMAS.keyboard), []); + + const { action: _dropped, ...withoutAction } = KEYBOARD_RESULT; + assert.deepEqual(validateAgainstSchema(withoutAction, COMMAND_OUTPUT_SCHEMAS.keyboard), [ + '$.action: missing required property', + ]); +}); + +// The closed dispatch shape each navigation command's runtime returns +// (packages/contracts/src/navigation.ts). `back` is the settle-capable one, so +// its published schema is this shape PLUS the opt-in `--settle` observation and +// nothing else; the other four must match verbatim. +const NAVIGATION_DISPATCH_SHAPES: Readonly< + Record; required: readonly string[] }> +> = { + back: { + properties: { + action: { type: 'string', const: 'back' }, + mode: { type: 'string', enum: ['in-app', 'system'] }, + message: { type: 'string' }, + }, + required: ['action', 'mode', 'message'], + }, + home: { + properties: { + action: { type: 'string', const: 'home' }, + message: { type: 'string' }, + }, + required: ['action', 'message'], + }, + orientation: { + properties: { + action: { type: 'string', const: 'orientation' }, + orientation: { + type: 'string', + enum: ['portrait', 'portrait-upside-down', 'landscape-left', 'landscape-right'], + }, + message: { type: 'string' }, + confirmed: { type: 'boolean' }, + warning: { type: 'string' }, + }, + required: ['action', 'orientation', 'message'], + }, + 'app-switcher': { + properties: { + action: { type: 'string', const: 'app-switcher' }, + message: { type: 'string' }, + }, + required: ['action', 'message'], + }, + 'tv-remote': { + properties: { + action: { type: 'string', const: 'tv-remote' }, + button: { + type: 'string', + enum: ['up', 'down', 'left', 'right', 'select', 'menu', 'home', 'back'], + }, + durationMs: { type: 'number' }, + message: { type: 'string' }, + }, + required: ['action', 'button', 'message'], + }, +}; + +test('MCP navigation output schemas advertise the closed dispatch shapes', () => { + for (const [name, dispatchShape] of Object.entries(NAVIGATION_DISPATCH_SHAPES)) { + const schema = COMMAND_OUTPUT_SCHEMAS[name as keyof typeof COMMAND_OUTPUT_SCHEMAS] as { + type?: unknown; + properties?: Record; + required?: unknown; + }; + assert.deepEqual( + Object.keys(schema).sort(), + ['properties', 'required', 'type'], + `${name}: must advertise exactly type/properties/required at the top level`, + ); + assert.equal(schema.type, 'object', `${name}: must advertise an object schema`); + const { settle, ...dispatchProperties } = schema.properties ?? {}; + assert.equal( + Boolean(settle), + commandSupportsSettleObservation(name), + `${name}: settle property must track the post-action observation trait`, + ); + assert.deepEqual(dispatchProperties, dispatchShape.properties); + assert.deepEqual(schema.required, dispatchShape.required); + } +}); diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 742a5835db..b0d8d6b988 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -3,6 +3,7 @@ import type { CommandResultMap } from '@agent-device/command-registry/command-re import { commandSupportsSettleObservation } from '@agent-device/command-registry/registry'; import { booleanSchema, + constSchema, enumSchema, looseObjectSchema, numberSchema, @@ -11,14 +12,9 @@ import { stringSchema, } from '../commands/command-input.ts'; import { REPLAY_COMMAND_OUTPUT_SCHEMAS } from '../commands/replay/index.ts'; -import { BACK_MODES } from '@agent-device/contracts/back-mode'; -import { APPLE_APPLICATION_STATES } from '@agent-device/kernel/snapshot'; +import { SYSTEM_COMMAND_OUTPUT_SCHEMAS } from '../commands/system/index.ts'; import { NATIVE_PATH_DISPOSITION_VALUES } from '@agent-device/contracts/recording-native-path'; import { RECORDER_OBSERVATION_VALUES } from '@agent-device/contracts/recording-stop-observation'; -import { DEVICE_ROTATIONS, FOLD_POSES } from '@agent-device/contracts/device'; -import { FOLD_SCREEN_COORDINATE_SPACE } from '@agent-device/contracts/fold-runtime'; -import { SESSION_SURFACES } from '@agent-device/contracts/session'; -import { TV_REMOTE_BUTTONS } from '@agent-device/contracts/tv-remote'; import { DEVICE_TARGETS, PUBLIC_PLATFORMS } from '@agent-device/kernel/device'; /** @@ -52,10 +48,6 @@ import { DEVICE_TARGETS, PUBLIC_PLATFORMS } from '@agent-device/kernel/device'; export const DEVICE_KINDS = ['simulator', 'emulator', 'device'] as const; -function constSchema(value: string): JsonSchema { - return { type: 'string', const: value }; -} - function nullableStringSchema(description?: string): JsonSchema { return { type: ['string', 'null'], ...(description ? { description } : {}) }; } @@ -477,70 +469,6 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { ['width', 'height', 'message'], ), - // packages/contracts/src/navigation.ts. `back`'s settle observation is grafted - // by the derivation pass below. - back: objectSchema( - { - action: constSchema('back'), - mode: enumSchema(BACK_MODES), - message: stringSchema(), - }, - ['action', 'mode', 'message'], - ), - home: objectSchema({ action: constSchema('home'), message: stringSchema() }, [ - 'action', - 'message', - ]), - orientation: objectSchema( - { - action: constSchema('orientation'), - orientation: enumSchema(DEVICE_ROTATIONS), - message: stringSchema(), - confirmed: booleanSchema(), - warning: stringSchema(), - }, - ['action', 'orientation', 'message'], - ), - 'app-switcher': objectSchema({ action: constSchema('app-switcher'), message: stringSchema() }, [ - 'action', - 'message', - ]), - fold: objectSchema( - { - action: constSchema('fold'), - pose: enumSchema(FOLD_POSES), - hingeAngleDegrees: numberSchema('Hinge angle CoreDevice read back after the pose settled.'), - screen: objectSchema( - { - display: stringSchema('CoreDevice name of the panel the device now lights.'), - coordinateSpace: constSchema(FOLD_SCREEN_COORDINATE_SPACE), - widthPt: numberSchema( - 'Panel width in native panel points (pixels divided by point scale), NOT snapshot coordinates; take a fresh snapshot to place a tap.', - ), - heightPt: numberSchema( - 'Panel height in native panel points (pixels divided by point scale), NOT snapshot coordinates; take a fresh snapshot to place a tap.', - ), - }, - ['display', 'coordinateSpace', 'widthPt', 'heightPt'], - ), - message: stringSchema(), - }, - ['action', 'pose', 'hingeAngleDegrees', 'message'], - ), - 'action-button': objectSchema({ action: constSchema('action-button'), message: stringSchema() }, [ - 'action', - 'message', - ]), - 'tv-remote': objectSchema( - { - action: constSchema('tv-remote'), - button: enumSchema(TV_REMOTE_BUTTONS), - durationMs: numberSchema(), - message: stringSchema(), - }, - ['action', 'button', 'message'], - ), - // packages/contracts/src/wait.ts — compact public daemon projection. wait: objectSchema( { @@ -675,75 +603,6 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { ['event', 'eventUrl', 'transport', 'message'], ), - // packages/contracts/src/clipboard.ts — discriminated union on `action`. - clipboard: { - type: 'object', - oneOf: [ - objectSchema({ action: constSchema('read'), text: stringSchema() }, ['action', 'text']), - objectSchema( - { action: constSchema('write'), textLength: numberSchema(), message: stringSchema() }, - ['action', 'textLength', 'message'], - ), - ], - }, - - // packages/contracts/src/app-state.ts — discriminated union on `platform`. - appstate: { - type: 'object', - oneOf: [ - objectSchema( - { - platform: enumSchema(['ios', 'macos']), - appName: stringSchema(), - appBundleId: stringSchema(), - source: enumSchema( - ['session', 'runner'], - 'runner when a live runner read the session app state; session when the record alone answered.', - ), - state: enumSchema( - APPLE_APPLICATION_STATES, - 'The session app XCUIApplication state as a live runner reads it; absent with source session.', - ), - surface: enumSchema(SESSION_SURFACES), - device_udid: stringSchema('iOS only — the session device UDID.'), - ios_simulator_device_set: { - type: ['string', 'null'], - description: 'iOS only — the simulator set path, or null when unknown.', - }, - }, - ['platform', 'appName', 'source', 'surface'], - ), - objectSchema( - { - platform: constSchema('android'), - package: stringSchema(), - activity: stringSchema(), - }, - ['platform', 'package', 'activity'], - ), - ], - }, - - // packages/contracts/src/keyboard.ts — flat closed shape; `platform`/`action` always present. - keyboard: objectSchema( - { - platform: enumSchema(['android', 'ios']), - action: enumSchema(['status', 'dismiss', 'enter']), - visible: booleanSchema(), - wasVisible: booleanSchema(), - dismissed: booleanSchema(), - attempts: numberSchema(), - inputType: stringSchema(), - type: enumSchema(['text', 'number', 'email', 'phone', 'password', 'datetime', 'unknown']), - inputMethodPackage: stringSchema(), - focusedPackage: stringSchema(), - focusedResourceId: stringSchema(), - inputOwner: enumSchema(['app', 'ime', 'unknown']), - message: stringSchema(), - }, - ['platform', 'action'], - ), - // packages/contracts/src/doctor.ts doctor: objectSchema( { @@ -870,6 +729,7 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { // a projected command then fails as TS2783 instead of quietly overriding the family's, // and this map's `satisfies` still refuses a missing `CommandResultMap` key. ...REPLAY_COMMAND_OUTPUT_SCHEMAS, + ...SYSTEM_COMMAND_OUTPUT_SCHEMAS, } satisfies Record; export const COMMAND_OUTPUT_SCHEMAS = deriveSettleObservationSchemas(BASE_COMMAND_OUTPUT_SCHEMAS);