From 02a089bbb08a54749fb21bc4b98da9e531e6d647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 12:03:51 +0200 Subject: [PATCH 1/3] refactor(mcp): project system family output schemas from their owning module Moves the 10 hand-authored MCP outputSchema entries for back, home, orientation, app-switcher, fold, action-button, tv-remote, clipboard, appstate, and keyboard out of command-output-schemas.ts into a frozen SYSTEM_COMMAND_OUTPUT_SCHEMAS map exported from src/commands/system/index.ts, following the #2810 projection seam. Registers the family in PROJECTED_FAMILIES and moves its shape-assertion tests into a colocated command-tools-system-schemas.test.ts, including a reference-equality proof that accounts for the one entry (back) the settle-observation derivation pass copies rather than passing through untouched. Part of #2819. --- src/commands/system/index.ts | 162 ++++++++++++++++ .../command-tools-appstate-schema.test.ts | 42 ---- .../command-tools-navigation-schemas.test.ts | 84 -------- .../command-tools-replay-schemas.test.ts | 29 ++- .../command-tools-system-schemas.test.ts | 182 ++++++++++++++++++ src/mcp/command-output-schemas.ts | 141 +------------- 6 files changed, 369 insertions(+), 271 deletions(-) delete mode 100644 src/mcp/__tests__/command-tools-appstate-schema.test.ts delete mode 100644 src/mcp/__tests__/command-tools-navigation-schemas.test.ts create mode 100644 src/mcp/__tests__/command-tools-system-schemas.test.ts diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 25fe92323f..8b88de2a5c 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -1,4 +1,5 @@ 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, @@ -11,6 +12,7 @@ import { FOLD_POSE_USAGE, parseDeviceRotation, } from '@agent-device/contracts/device'; +import { FOLD_SCREEN_COORDINATE_SPACE } from '@agent-device/contracts/fold-runtime'; import { type BackMode, BACK_MODES } from '@agent-device/contracts/back-mode'; import { TV_REMOTE_BUTTONS, @@ -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,11 @@ import { stringField, jsonSchemaField, readFieldInput, + booleanSchema, + enumSchema, + numberSchema, + objectSchema, + stringSchema, } from '../command-input.ts'; import { compactRecord } from '../input-readers.ts'; import { @@ -64,6 +74,158 @@ 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; +function constSchema(value: string): JsonSchema { + return { type: 'string', const: value }; +} + +/** + * 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..93e01ea2a0 --- /dev/null +++ b/src/mcp/__tests__/command-tools-system-schemas.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { 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('the projected family map declares exactly the commands its module owns', () => { + 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`, + ); + } +}); + +// `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..5999ababaa 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -11,14 +11,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'; /** @@ -477,70 +472,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 +606,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 +732,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); From 3d89d2171b93b6aafb5300ceaf1fa9cc0c7028f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 20:10:53 +0200 Subject: [PATCH 2/3] fix(contracts): keep the fold coordinate-space constant off the CLI closure FOLD_SCREEN_COORDINATE_SPACE lived in fold-runtime.ts, a module the CLI closure did not otherwise load. Importing it directly from src/commands/system/index.ts pulled fold-runtime.ts (and its own dependents) into cli.ts's eager import graph, tripping the eager-closure-budgets gate (295 -> 296 modules). Move the constant's declaration to device-rotation.ts, which the same file already loads via the @agent-device/contracts/device facade for DEVICE_ROTATIONS/FOLD_POSES, and re-export it from the device facade so importers reach it there. fold-runtime.ts keeps publishing FOLD_SCREEN_COORDINATE_SPACE (an external, released API on this subpath) as a literal rather than a re-export, so importing it does not eagerly load device-rotation.ts and its own eager closure does not grow either; a type-only import of the new FoldScreenCoordinateSpace alias still makes any drift between the two a compile error. Its runtime value consumer (platform-apple's fold pose runtime) now takes the constant from the device facade it already imports. --- packages/contracts/src/device-rotation.ts | 4 ++++ packages/contracts/src/facades/device.ts | 2 ++ packages/contracts/src/fold-runtime.ts | 15 ++++++++++----- packages/platform-apple/src/foldable/pose.ts | 7 ++----- 4 files changed, 18 insertions(+), 10 deletions(-) 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'; From bc830f5bb95057815e346ea847cbcc406fae6336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 20:11:02 +0200 Subject: [PATCH 3/3] fix(mcp): share one constSchema and prove ownership both ways constSchema was hand-copied into src/commands/system/index.ts, drifting from the identical private helper in command-output-schemas.ts. Export it once from command-input.ts, next to the other schema-primitive builders, and import it at both sites. The ownership test for the projected family map only checked that each of the module's own entries names the module as owner; it did not check the inverse, so a system-family command the registry attributes to this module but missing from SYSTEM_COMMAND_OUTPUT_SCHEMAS would still pass. Compare the two sets directly. --- src/commands/command-input.ts | 5 +++++ src/commands/system/index.ts | 7 ++----- .../command-tools-system-schemas.test.ts | 19 +++++++++++++++++-- src/mcp/command-output-schemas.ts | 5 +---- 4 files changed, 25 insertions(+), 11 deletions(-) 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 8b88de2a5c..bf25b9e8c3 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -3,6 +3,7 @@ import type { CommandResultMap } from '@agent-device/command-registry/command-re import type { ClipboardCommandOptions } from '@agent-device/contracts/client'; import { type FoldKeyframe, + FOLD_SCREEN_COORDINATE_SPACE, MAX_FOLD_DURATION_MS, MAX_FOLD_KEYFRAMES, parseFoldInput, @@ -12,7 +13,6 @@ import { FOLD_POSE_USAGE, parseDeviceRotation, } from '@agent-device/contracts/device'; -import { FOLD_SCREEN_COORDINATE_SPACE } from '@agent-device/contracts/fold-runtime'; import { type BackMode, BACK_MODES } from '@agent-device/contracts/back-mode'; import { TV_REMOTE_BUTTONS, @@ -41,6 +41,7 @@ import { jsonSchemaField, readFieldInput, booleanSchema, + constSchema, enumSchema, numberSchema, objectSchema, @@ -74,10 +75,6 @@ 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; -function constSchema(value: string): JsonSchema { - return { type: 'string', const: value }; -} - /** * 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 diff --git a/src/mcp/__tests__/command-tools-system-schemas.test.ts b/src/mcp/__tests__/command-tools-system-schemas.test.ts index 93e01ea2a0..a483aefb90 100644 --- a/src/mcp/__tests__/command-tools-system-schemas.test.ts +++ b/src/mcp/__tests__/command-tools-system-schemas.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { ownerFilesForCommand } from '@agent-device/command-registry/owner-files'; +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'; @@ -39,13 +42,25 @@ test('a settle-derived system entry still grafts onto the module object, not a f ); }); -test('the projected family map declares exactly the commands its module owns', () => { +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 diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 5999ababaa..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, @@ -47,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 } : {}) }; }