From e3d37371e940a34cdae8e1c707e85b57be3370aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 08:43:15 +0200 Subject: [PATCH 1/4] refactor(commands): retire the navigation-only type projection `commands/system/navigation-projection.ts` built the five navigation client methods out of a phantom-typed registry: a `unique symbol` brand carrying Options/Result/required-ness, two conditional types to read them back, and a mapped type keyed on `clientMethod`. Nothing else ever used the concept, so the machinery existed to derive five signatures that fit in five lines. Those five now say what they mean. `BackCommandOptions`, `HomeCommandOptions`, `OrientationCommandOptions`, `AppSwitcherCommandOptions` and `TvRemoteCommandOptions` join their siblings in `packages/contracts/src/client-system.ts`, and `AgentDeviceCommandClient` declares all 14 methods in one object type. `back` keeps the `--settle` triple (#1638), and `orientation`/`tv-remote` keep their required options parameter. The five MCP output schemas move to `mcp/command-output-schemas.ts` beside the other handwritten ones, byte-identical. With the projection gone, `defineExecutableCommand`'s third overload, `ExecutableCommandProjection`, `AnyCommandDefinition.projection`, `ProjectedCommandOutputSchemas`/`projectCommandOutputSchemas` and the family's `clientCommandMethods` table have no users either. Removing the table also removes the `as unknown as` cast the client used to build eight system methods from it; the client now writes all eight out, typed. That closes the `commands/system` -> `client` inversion the client-types header called the one remaining one. Public API: the five method signatures are unchanged (structural comparison of the built `dist/src/index.d.ts` before and after: empty diff). `HomeCommandOptions` is a new published name for the shape `home` already took. Tests seen red before green: - `src/__tests__/client-system-commands.test.ts` (new): wired `home` to the `app-switcher` daemon command, saw it fail, restored. - `src/mcp/__tests__/command-tools.test.ts`: dropped `durationMs` from the inlined `tv-remote` schema, saw the dispatch-shape assertion fail, restored. - `src/commands/system/index.test.ts`: made `home`'s options parameter required, saw `expectTypeOf` fail under `pnpm typecheck`, restored. --- packages/contracts/src/client-system.ts | 22 +++ packages/contracts/src/facades/client.ts | 5 + src/__tests__/client-system-commands.test.ts | 82 +++++++++ src/agent-device-client.ts | 38 ++--- src/client/client-types.ts | 37 ++--- src/commands/command-contract.ts | 29 +--- src/commands/family/types.ts | 41 +---- .../post-action-observation-client-options.ts | 4 +- src/commands/system/index.test.ts | 36 +--- src/commands/system/index.ts | 29 +--- src/commands/system/navigation-projection.ts | 155 ------------------ src/mcp/__tests__/command-tools.test.ts | 88 ++++++++-- src/mcp/command-output-schemas.ts | 42 ++++- 13 files changed, 259 insertions(+), 349 deletions(-) create mode 100644 src/__tests__/client-system-commands.test.ts delete mode 100644 src/commands/system/navigation-projection.ts diff --git a/packages/contracts/src/client-system.ts b/packages/contracts/src/client-system.ts index cd3cecc36c..e31f21ab05 100644 --- a/packages/contracts/src/client-system.ts +++ b/packages/contracts/src/client-system.ts @@ -1,8 +1,12 @@ // The public API vocabulary for the system and diagnostic commands (wait, alert, keyboard, clipboard, doctor…). import type { AlertAction } from './alert-contract.ts'; +import type { BackMode } from './back-mode.ts'; import type { SelectorSnapshotCommandOptions } from './client-capture.ts'; import type { DeviceCommandBaseOptions } from './client-connection.ts'; +import type { SettleCommandOptions } from './client-gesture.ts'; +import type { DeviceRotation } from './device-rotation.ts'; +import type { TvRemoteButton } from './tv-remote.ts'; export type WaitCommandTarget = | { @@ -75,6 +79,24 @@ export type AlertCommandOptions = DeviceCommandBaseOptions & { export type AppStateCommandOptions = DeviceCommandBaseOptions; +/** #1638: `back` carries the shared `--settle` triple, and its result may carry the settled diff. */ +export type BackCommandOptions = DeviceCommandBaseOptions & { + mode?: BackMode; +} & SettleCommandOptions; + +export type HomeCommandOptions = DeviceCommandBaseOptions; + +export type OrientationCommandOptions = DeviceCommandBaseOptions & { + orientation: DeviceRotation; +}; + +export type AppSwitcherCommandOptions = DeviceCommandBaseOptions; + +export type TvRemoteCommandOptions = DeviceCommandBaseOptions & { + button: TvRemoteButton; + durationMs?: number; +}; + export type KeyboardCommandOptions = DeviceCommandBaseOptions & { action?: 'status' | 'dismiss' | 'enter' | 'return'; }; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index 51c964ad08..f425b375e0 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -112,11 +112,16 @@ export type { PermissionTarget, SettingsUpdateOptions } from '../client-settings export type { AlertCommandOptions, AppStateCommandOptions, + AppSwitcherCommandOptions, + BackCommandOptions, ClipboardCommandOptions, DoctorCommandOptions, + HomeCommandOptions, KeyboardCommandOptions, + OrientationCommandOptions, PrepareCommandOptions, ReactNativeCommandOptions, + TvRemoteCommandOptions, ViewportCommandOptions, WaitCommandOptions, WaitCommandTarget, diff --git a/src/__tests__/client-system-commands.test.ts b/src/__tests__/client-system-commands.test.ts new file mode 100644 index 0000000000..bce4a69aca --- /dev/null +++ b/src/__tests__/client-system-commands.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createTransport } from './client-transport-fixture.ts'; + +// Every system command the Node client exposes as its own method, with the +// daemon command name and positionals that method must produce. The client +// declares these one by one, so this table is what catches a method wired to +// the wrong command or losing an argument on the way to the daemon. +const SYSTEM_COMMAND_CALLS: readonly { + method: string; + invoke: (client: ReturnType) => Promise; + command: string; + positionals: string[]; + flags?: Record; +}[] = [ + { + method: 'appState', + invoke: async (client) => await client.command.appState(), + command: 'appstate', + positionals: [], + }, + { + method: 'back', + invoke: async (client) => await client.command.back({ mode: 'system' }), + command: 'back', + positionals: [], + flags: { backMode: 'system' }, + }, + { + method: 'home', + invoke: async (client) => await client.command.home(), + command: 'home', + positionals: [], + }, + { + method: 'orientation', + invoke: async (client) => await client.command.orientation({ orientation: 'landscape-left' }), + command: 'orientation', + positionals: ['landscape-left'], + }, + { + method: 'appSwitcher', + invoke: async (client) => await client.command.appSwitcher(), + command: 'app-switcher', + positionals: [], + }, + { + method: 'keyboard', + invoke: async (client) => await client.command.keyboard({ action: 'dismiss' }), + command: 'keyboard', + positionals: ['dismiss'], + }, + { + method: 'clipboard', + invoke: async (client) => await client.command.clipboard({ action: 'write', text: 'hi' }), + command: 'clipboard', + positionals: ['write', 'hi'], + }, + { + method: 'tvRemote', + invoke: async (client) => await client.command.tvRemote({ button: 'select' }), + command: 'tv-remote', + positionals: ['select'], + }, +]; + +for (const call of SYSTEM_COMMAND_CALLS) { + test(`client.command.${call.method} sends the ${call.command} daemon command`, async () => { + const setup = createTransport(async () => ({ ok: true, data: {} })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await call.invoke(client); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, call.command); + assert.deepEqual(setup.calls[0]?.positionals, call.positionals); + for (const [flag, value] of Object.entries(call.flags ?? {})) { + assert.deepEqual(setup.calls[0]?.flags?.[flag], value); + } + }); +} diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 5835fc6c83..3ae0f6dc41 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -54,19 +54,13 @@ import { readSnapshotNodes, resolveSessionName, } from './client/client-normalizers.ts'; -import type { - AgentDeviceClient, - AgentDeviceCommandClient, - MetroPrepareResult, -} from './client/client-types.ts'; +import type { AgentDeviceClient, MetroPrepareResult } from './client/client-types.ts'; import { INTERNAL_COMMANDS } from './command-catalog.ts'; import { buildRequestFlags } from './commands/command-flags.ts'; import { prepareDaemonCommandRequest, type DaemonCommandName, } from './commands/command-projection.ts'; -import { systemCommandFamily } from './commands/system/index.ts'; -import type { ProjectedNavigationCommandClient } from './commands/system/navigation-projection.ts'; import type { CommandResult } from './core/command-descriptor/command-result.ts'; import { sendToDaemon } from './daemon/client/daemon-client.ts'; import { resolveDaemonPaths } from './daemon/config.ts'; @@ -81,9 +75,6 @@ import { isRecord } from '@agent-device/kernel/record'; import { createLeaseClient } from './client/lease-client.ts'; import { normalizeScreenshotCaptureResult } from './client/screenshot-result.ts'; -type ProjectedSystemCommandClient = ProjectedNavigationCommandClient & - Pick; - export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, deps: { transport?: AgentDeviceDaemonTransport } = {}, @@ -142,13 +133,25 @@ export function createAgentDeviceClient( const resolveRequestSession = (options: InternalRequestOptions = {}) => resolveSessionName(mergeClientOptions(config, options).session); - const projectedSystemCommands = buildProjectedSystemCommandClient(executeCommand); return { command: { wait: async (options) => await executeCommand>('wait', options), alert: async (options = {}) => await executeCommand('alert', options), - ...projectedSystemCommands, + appState: async (options = {}) => + await executeCommand>('appstate', options), + back: async (options = {}) => await executeCommand>('back', options), + home: async (options = {}) => await executeCommand>('home', options), + orientation: async (options) => + await executeCommand>('orientation', options), + appSwitcher: async (options = {}) => + await executeCommand>('app-switcher', options), + keyboard: async (options = {}) => + await executeCommand>('keyboard', options), + clipboard: async (options) => + await executeCommand>('clipboard', options), + tvRemote: async (options) => + await executeCommand>('tv-remote', options), reactNative: async (options) => await executeCommand('react-native', options), doctor: async (options = {}) => await executeCommand>('doctor', options), @@ -545,17 +548,6 @@ function optionalSnapshotResponseFields( }; } -function buildProjectedSystemCommandClient( - executeCommand: (command: DaemonCommandName, options?: InternalRequestOptions) => Promise, -): ProjectedSystemCommandClient { - const methods: Record Promise> = {}; - for (const [method, command] of Object.entries(systemCommandFamily.clientCommandMethods ?? {})) { - methods[method] = async (options = {}) => - await executeCommand>(command as DaemonCommandName, options); - } - return methods as unknown as ProjectedSystemCommandClient; -} - function readObject(value: unknown): Record | undefined { return isRecord(value) ? value : undefined; } diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 5975ea9211..2d80556de9 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -7,9 +7,8 @@ // both surfaces need has to sit below both. // // What is still declared HERE is the `AgentDeviceClient` facade plus the shapes that are themselves -// stated in terms of a HIGHER-ranked zone: `commands/system/navigation-projection.ts` (the projected -// navigation client) and `core/` (`CommandResult`, `BatchRunResult`). Declaring those in contracts/ -// would trade 28 commands->client inversions for contracts->commands and contracts->core ones — the +// stated in terms of a HIGHER-ranked zone: `core/` (`CommandResult`, `BatchRunResult`). Declaring +// those in contracts/ would trade 28 commands->client inversions for contracts->core ones — the // foundation depending on the layers above it, which is worse. They can move once their upstream // declarations do; see docs/dependency-graph-findings.md §0, which also explains why the facade's // own 4 remaining inversions are a position rather than debt. @@ -69,8 +68,10 @@ import type { AppOpenResult, AppPushOptions, AppStateCommandOptions, + AppSwitcherCommandOptions, AppTriggerEventOptions, AudioOptions, + BackCommandOptions, BatchRunOptions, CaptureDiffOptions, CaptureScreenshotOptions, @@ -82,7 +83,6 @@ import type { CloudArtifactsOptions, CommandRequestResult, DeviceBootOptions, - DeviceCommandBaseOptions, DeviceShutdownOptions, DoctorCommandOptions, DragOptions, @@ -92,6 +92,7 @@ import type { FlingOptions, FocusOptions, GetOptions, + HomeCommandOptions, HoverOptions, IsOptions, KeyboardCommandOptions, @@ -105,6 +106,7 @@ import type { MaterializationReleaseOptions, MaterializationReleaseResult, NetworkOptions, + OrientationCommandOptions, PanOptions, PerfOptions, PinchOptions, @@ -124,6 +126,7 @@ import type { SwipeOptions, TraceOptions, TransformGestureOptions, + TvRemoteCommandOptions, TypeTextOptions, ViewportCommandOptions, WaitCommandOptions, @@ -135,11 +138,6 @@ import type { MetroReloadResult, } from '@agent-device/contracts/remote'; -import type { - NavigationCommandOptions, - ProjectedNavigationCommandClient, -} from '../commands/system/navigation-projection.ts'; - import type { BatchRunResult } from '../core/batch.ts'; import type { @@ -162,18 +160,12 @@ export type { export type { RecordingCommandResult, TraceCommandResult } from '@agent-device/contracts/recording'; export type { ReplayCommandResult, ReplaySuiteResult } from '@agent-device/contracts/replay'; -export type BackCommandOptions = DeviceCommandBaseOptions & NavigationCommandOptions<'back'>; - -export type OrientationCommandOptions = DeviceCommandBaseOptions & - NavigationCommandOptions<'orientation'>; - -export type AppSwitcherCommandOptions = DeviceCommandBaseOptions & - NavigationCommandOptions<'app-switcher'>; - -export type TvRemoteCommandOptions = DeviceCommandBaseOptions & - NavigationCommandOptions<'tv-remote'>; - -type NonNavigationCommandClient = { +export type AgentDeviceCommandClient = { + back: (options?: BackCommandOptions) => Promise>; + home: (options?: HomeCommandOptions) => Promise>; + orientation: (options: OrientationCommandOptions) => Promise>; + appSwitcher: (options?: AppSwitcherCommandOptions) => Promise>; + tvRemote: (options: TvRemoteCommandOptions) => Promise>; wait: (options: WaitCommandOptions) => Promise>; alert: (options?: AlertCommandOptions) => Promise; appState: (options?: AppStateCommandOptions) => Promise>; @@ -189,9 +181,6 @@ type NonNavigationCommandClient = { viewport: (options: ViewportCommandOptions) => Promise>; }; -export type AgentDeviceCommandClient = ProjectedNavigationCommandClient & - NonNavigationCommandClient; - export type AgentDeviceClient = { command: AgentDeviceCommandClient; devices: { diff --git a/src/commands/command-contract.ts b/src/commands/command-contract.ts index cad22ee752..ad3e16d732 100644 --- a/src/commands/command-contract.ts +++ b/src/commands/command-contract.ts @@ -59,11 +59,6 @@ export type ExecutableCommandContract = Comm invoke: (client: AgentDeviceClient, input: unknown) => Promise; }; -export type ExecutableCommandProjection = { - clientMethod: ClientMethod; - outputSchema: JsonSchema; -}; - export type CliOutput = { data: unknown; jsonData?: unknown; @@ -80,32 +75,10 @@ export function defineCommandMetadata( export function defineExecutableCommand( metadata: CommandMetadata, run: (client: AgentDeviceClient, input: Input) => Promise, -): ExecutableCommandContract; - -export function defineExecutableCommand< - Name extends string, - Input, - Result, - const ClientMethod extends string, ->( - metadata: CommandMetadata, - run: (client: AgentDeviceClient, input: Input) => Promise, - projection: ExecutableCommandProjection, -): ExecutableCommandContract & { - projection: ExecutableCommandProjection; -}; - -export function defineExecutableCommand( - metadata: CommandMetadata, - run: (client: AgentDeviceClient, input: Input) => Promise, - projection?: ExecutableCommandProjection, -): ExecutableCommandContract & { - projection?: ExecutableCommandProjection; -} { +): ExecutableCommandContract { return { ...metadata, run, invoke: async (client, input) => await run(client, metadata.readInput(input)), - ...(projection ? { projection } : {}), }; } diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts index 530e24c93d..fef05a097f 100644 --- a/src/commands/family/types.ts +++ b/src/commands/family/types.ts @@ -1,11 +1,7 @@ import type { AgentDeviceClient } from '../../client/client-types.ts'; import type { CommandSchema, CommandSchemaOverride } from '../../cli-schema/types.ts'; import type { AnyDaemonWriter, CliReader } from '../cli-grammar/types.ts'; -import type { - CommandMetadata, - ExecutableCommandProjection, - JsonSchema, -} from '../command-contract.ts'; +import type { CommandMetadata, JsonSchema } from '../command-contract.ts'; import type { CliOutputFormatter } from '../output-common.ts'; import { resolveFacetText, type FacetCommandText } from '../command-text.ts'; @@ -17,7 +13,6 @@ export type AnyCommandDefinition = { mcpDetail?: string; inputSchema: JsonSchema; invoke: (client: AgentDeviceClient, input: unknown) => Promise; - projection?: ExecutableCommandProjection; }; export type CommandFamilyFacet = { @@ -25,7 +20,6 @@ export type CommandFamilyFacet = { clientSurface?: boolean; metadata: readonly AnyCommandMetadata[]; definitions: readonly AnyCommandDefinition[]; - clientCommandMethods?: Readonly>; cliSchemas?: Readonly>>; cliReaders: Readonly>; daemonWriters?: Readonly>; @@ -41,7 +35,6 @@ export type CommandFacetInput = { metadata: AnyCommandMetadata; definition: AnyCommandDefinition; cliSchema?: CommandSchemaOverride; - clientMethod?: string; cliReader: CliReader; daemonWriter?: AnyDaemonWriter; cliOutputFormatter?: CliOutputFormatter; @@ -66,15 +59,6 @@ type CommandFacetDefinitions = { type CommandFacetName = TCommands[number]['name']; -export type ProjectedCommandOutputSchemas = { - [ - TDefinition in Extract< - TDefinitions[number], - { projection: ExecutableCommandProjection } - > as TDefinition['name'] - ]: JsonSchema; -}; - export function defineCommandFacet< const TCommandName extends string, const TCommand extends CommandFacetInput, @@ -96,17 +80,12 @@ export function defineCommandFamilyFromFacets< const TCommands extends readonly CommandFacet[], >(family: { name: TFamilyName; clientSurface?: boolean; commands: TCommands }) { const cliSchemas: Record = {}; - const clientCommandMethods: Record = {}; const cliReaders: Record = {}; const daemonWriters: Record = {}; const cliOutputFormatters: Record = {}; for (const command of family.commands) { addRecordEntry(cliSchemas, 'CLI schema', command.name, command.cliSchema); - const clientMethod = command.definition.projection?.clientMethod ?? command.clientMethod; - if (clientMethod) { - addRecordEntry(clientCommandMethods, 'client command method', clientMethod, command.name); - } addRecordEntry(cliReaders, 'CLI reader', command.name, command.cliReader); if (command.daemonWriter) { addRecordEntry(daemonWriters, 'daemon writer', command.name, command.daemonWriter); @@ -128,7 +107,6 @@ export function defineCommandFamilyFromFacets< definitions: family.commands.map( (command) => command.definition, ) as CommandFacetDefinitions, - clientCommandMethods: clientCommandMethods as Record>, cliSchemas: cliSchemas as Partial, CommandSchema>>, cliReaders: cliReaders as Record, CliReader>, daemonWriters, @@ -141,23 +119,6 @@ export function defineCommandFamilyFromFacets< }; } -export function projectCommandOutputSchemas< - const TDefinitions extends readonly AnyCommandDefinition[], ->(definitions: TDefinitions): ProjectedCommandOutputSchemas { - const schemas: Record = {}; - for (const definition of definitions) { - if (definition.projection) { - addRecordEntry( - schemas, - 'command output schema', - definition.name, - definition.projection.outputSchema, - ); - } - } - return schemas as ProjectedCommandOutputSchemas; -} - function addRecordEntry( record: Record, label: string, diff --git a/src/commands/post-action-observation-client-options.ts b/src/commands/post-action-observation-client-options.ts index f8de62d70e..79932ebc88 100644 --- a/src/commands/post-action-observation-client-options.ts +++ b/src/commands/post-action-observation-client-options.ts @@ -1,4 +1,5 @@ import type { + BackCommandOptions, ClickOptions, FillOptions, HoverOptions, @@ -8,7 +9,6 @@ import type { SettleCommandOptions, } from '@agent-device/contracts/client'; import type { PostActionObservationCommandName } from '../core/command-descriptor/post-action-observation.ts'; -import type { NavigationCommandOptions } from './system/navigation-projection.ts'; /** * Compile-time completeness for the contracts half of the `--settle` surface @@ -28,7 +28,7 @@ const SETTLE_CAPABLE_CLIENT_OPTION_TYPES = { hover: {} as HoverOptions, fill: {} as FillOptions, scroll: {} as ScrollOptions, - back: {} as NavigationCommandOptions<'back'>, + back: {} as BackCommandOptions, } as const satisfies Record; export type SettleCapableClientOptionCommands = diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index c9de0864b1..863668d320 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -3,6 +3,7 @@ import type { AgentDeviceCommandClient, AppSwitcherCommandOptions, BackCommandOptions, + HomeCommandOptions, OrientationCommandOptions, TvRemoteCommandOptions, } from '../../client/client-types.ts'; @@ -26,7 +27,6 @@ import { orientationDaemonWriter, tvRemoteCliReader, tvRemoteDaemonWriter, - systemCommandFamily, } from './index.ts'; import { systemCliOutputFormatters } from './output.ts'; @@ -44,10 +44,13 @@ function expectInvalidArgs(fn: () => unknown, messageFragment: string) { } describe('system command interface', () => { - test('navigation executable contracts project the public client signatures', () => { + test('navigation commands declare the public client signatures', () => { expectTypeOf().toEqualTypeOf< (options?: BackCommandOptions) => Promise> >(); + expectTypeOf().toEqualTypeOf< + (options?: HomeCommandOptions) => Promise> + >(); expectTypeOf().toEqualTypeOf< (options: OrientationCommandOptions) => Promise> >(); @@ -59,35 +62,6 @@ describe('system command interface', () => { >(); }); - test('system command family projects Node client command methods', () => { - expect(systemCommandFamily.clientCommandMethods).toEqual({ - appState: 'appstate', - back: 'back', - home: 'home', - orientation: 'orientation', - appSwitcher: 'app-switcher', - keyboard: 'keyboard', - clipboard: 'clipboard', - tvRemote: 'tv-remote', - }); - }); - - test('navigation executable contracts own their MCP output schemas', () => { - expect( - Object.fromEntries( - systemCommandFamily.definitions.flatMap((definition) => - 'projection' in definition ? [[definition.name, definition.projection.clientMethod]] : [], - ), - ), - ).toEqual({ - back: 'back', - home: 'home', - orientation: 'orientation', - 'app-switcher': 'appSwitcher', - 'tv-remote': 'tvRemote', - }); - }); - test('parameterless readers project common selection flags through', () => { for (const reader of [appStateCliReader, homeCliReader, appSwitcherCliReader]) { expect(reader([], flags({ platform: 'ios' }))).toEqual({ diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 7acd8e461d..96bf9d4ee7 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -20,17 +20,12 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { enumField, integerField, requiredField, stringField } from '../command-input.ts'; import { compactRecord } from '../input-readers.ts'; -import { - defineCommandFacet, - defineCommandFamilyFromFacets, - projectCommandOutputSchemas, -} from '../family/types.ts'; +import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { postActionObservationCliFlags, postActionObservationFields, } from '../post-action-observation-grammar.ts'; -import { NAVIGATION_COMMAND_PROJECTIONS } from './navigation-projection.ts'; import { systemCliOutputFormatters } from './output.ts'; const APPSTATE_COMMAND_NAME = 'appstate'; @@ -128,28 +123,22 @@ const appStateCommandDefinition = defineExecutableCommand( (client, input) => client.command.appState(input), ); -const backCommandDefinition = defineExecutableCommand( - backCommandMetadata, - (client, input) => client.command.back(input), - NAVIGATION_COMMAND_PROJECTIONS.back, +const backCommandDefinition = defineExecutableCommand(backCommandMetadata, (client, input) => + client.command.back(input), ); -const homeCommandDefinition = defineExecutableCommand( - homeCommandMetadata, - (client, input) => client.command.home(input), - NAVIGATION_COMMAND_PROJECTIONS.home, +const homeCommandDefinition = defineExecutableCommand(homeCommandMetadata, (client, input) => + client.command.home(input), ); const orientationCommandDefinition = defineExecutableCommand( orientationCommandMetadata, (client, input) => client.command.orientation(input), - NAVIGATION_COMMAND_PROJECTIONS.orientation, ); const appSwitcherCommandDefinition = defineExecutableCommand( appSwitcherCommandMetadata, (client, input) => client.command.appSwitcher(input), - NAVIGATION_COMMAND_PROJECTIONS['app-switcher'], ); const keyboardCommandDefinition = defineExecutableCommand( @@ -165,7 +154,6 @@ const clipboardCommandDefinition = defineExecutableCommand( const tvRemoteCommandDefinition = defineExecutableCommand( tvRemoteCommandMetadata, (client, input) => client.command.tvRemote(input), - NAVIGATION_COMMAND_PROJECTIONS['tv-remote'], ); const appStateCliSchema = {} as const satisfies CommandSchemaOverride; @@ -267,7 +255,6 @@ const appStateCommandFacet = defineCommandFacet({ }, metadata: appStateCommandMetadata, definition: appStateCommandDefinition, - clientMethod: 'appState', cliSchema: appStateCliSchema, cliReader: appStateCliReader, daemonWriter: appStateDaemonWriter, @@ -333,7 +320,6 @@ const keyboardCommandFacet = defineCommandFacet({ }, metadata: keyboardCommandMetadata, definition: keyboardCommandDefinition, - clientMethod: 'keyboard', cliSchema: keyboardCliSchema, cliReader: keyboardCliReader, daemonWriter: keyboardDaemonWriter, @@ -347,7 +333,6 @@ const clipboardCommandFacet = defineCommandFacet({ }, metadata: clipboardCommandMetadata, definition: clipboardCommandDefinition, - clientMethod: 'clipboard', cliSchema: clipboardCliSchema, cliReader: clipboardCliReader, daemonWriter: clipboardDaemonWriter, @@ -382,10 +367,6 @@ export const systemCommandFamily = defineCommandFamilyFromFacets({ ], }); -export const projectedSystemCommandOutputSchemas = projectCommandOutputSchemas( - systemCommandFamily.definitions, -); - function readBackMode(value: unknown): BackMode | undefined { return value === 'in-app' || value === 'system' ? value : undefined; } diff --git a/src/commands/system/navigation-projection.ts b/src/commands/system/navigation-projection.ts deleted file mode 100644 index 1a426d9349..0000000000 --- a/src/commands/system/navigation-projection.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { DEVICE_ROTATIONS, type DeviceRotation } from '@agent-device/contracts/device'; -import { BACK_MODES, type BackMode } from '@agent-device/contracts/back-mode'; -import type { - AppSwitcherCommandResult, - BackCommandResult, - HomeCommandResult, - OrientationCommandResult, - TvRemoteCommandResult, -} from '@agent-device/contracts/navigation'; -import { TV_REMOTE_BUTTONS, type TvRemoteButton } from '@agent-device/contracts/tv-remote'; -import type { SettleCommandOptions } from '@agent-device/contracts/client'; -import type { ExecutableCommandProjection } from '../command-contract.ts'; - -declare const navigationCommandProjectionType: unique symbol; - -type NavigationCommandProjection< - Options, - Result, - Required extends boolean, - ClientMethod extends string, -> = ExecutableCommandProjection & { - readonly [navigationCommandProjectionType]?: { - options: Options; - result: Result; - required: Required; - }; -}; - -function defineNavigationCommandProjection< - Options, - Result, - Required extends boolean, - const ClientMethod extends string, ->( - projection: ExecutableCommandProjection, -): NavigationCommandProjection { - return projection; -} - -export const NAVIGATION_COMMAND_PROJECTIONS = { - // #1638: `back` carries the settle observation trait, so its options include - // the shared `--settle` triple and its result may carry the settled diff. - // The output schema stays the closed dispatch shape here; the opt-in - // observation property is grafted on where `settleObservationSchema` lives - // (src/mcp/command-output-schemas.ts), which this layer must not import. - back: defineNavigationCommandProjection< - { mode?: BackMode } & SettleCommandOptions, - BackCommandResult, - false, - 'back' - >({ - clientMethod: 'back', - outputSchema: { - type: 'object', - properties: { - action: { type: 'string', const: 'back' }, - mode: { type: 'string', enum: BACK_MODES }, - message: { type: 'string' }, - }, - required: ['action', 'mode', 'message'], - }, - }), - home: defineNavigationCommandProjection, HomeCommandResult, false, 'home'>({ - clientMethod: 'home', - outputSchema: { - type: 'object', - properties: { - action: { type: 'string', const: 'home' }, - message: { type: 'string' }, - }, - required: ['action', 'message'], - }, - }), - orientation: defineNavigationCommandProjection< - { orientation: DeviceRotation }, - OrientationCommandResult, - true, - 'orientation' - >({ - clientMethod: 'orientation', - outputSchema: { - type: 'object', - properties: { - action: { type: 'string', const: 'orientation' }, - orientation: { type: 'string', enum: DEVICE_ROTATIONS }, - message: { type: 'string' }, - }, - required: ['action', 'orientation', 'message'], - }, - }), - 'app-switcher': defineNavigationCommandProjection< - Record, - AppSwitcherCommandResult, - false, - 'appSwitcher' - >({ - clientMethod: 'appSwitcher', - outputSchema: { - type: 'object', - properties: { - action: { type: 'string', const: 'app-switcher' }, - message: { type: 'string' }, - }, - required: ['action', 'message'], - }, - }), - 'tv-remote': defineNavigationCommandProjection< - { button: TvRemoteButton; durationMs?: number }, - TvRemoteCommandResult, - true, - 'tvRemote' - >({ - clientMethod: 'tvRemote', - outputSchema: { - type: 'object', - properties: { - action: { type: 'string', const: 'tv-remote' }, - button: { type: 'string', enum: TV_REMOTE_BUTTONS }, - durationMs: { type: 'number' }, - message: { type: 'string' }, - }, - required: ['action', 'button', 'message'], - }, - }), -} as const; - -export type NavigationCommandName = keyof typeof NAVIGATION_COMMAND_PROJECTIONS; - -export type NavigationCommandOptions = - (typeof NAVIGATION_COMMAND_PROJECTIONS)[Name] extends NavigationCommandProjection< - infer Options, - unknown, - boolean, - string - > - ? Options - : never; - -type ProjectedNavigationCommandMethod = - Projection extends NavigationCommandProjection< - infer Options, - infer Result, - infer Required, - string - > - ? Required extends true - ? (options: BaseOptions & Options) => Promise - : (options?: BaseOptions & Options) => Promise - : never; - -export type ProjectedNavigationCommandClient = { - [ - Name in NavigationCommandName as (typeof NAVIGATION_COMMAND_PROJECTIONS)[Name]['clientMethod'] - ]: ProjectedNavigationCommandMethod; -}; diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index 23c8440f28..07543f3bcf 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -8,7 +8,6 @@ import { } from '../../core/command-descriptor/registry.ts'; import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { NAVIGATION_COMMAND_PROJECTIONS } from '../../commands/system/navigation-projection.ts'; import { validateAgainstSchema } from './output-schema-validator.ts'; test('MCP command tool executor hides client creation behind an execution adapter', async () => { @@ -448,23 +447,76 @@ test('MCP tv remote outputSchema advertises button values', () => { ); }); -test('MCP navigation output schemas are projected from the canonical executable contracts', () => { - for (const [name, projection] of Object.entries(NAVIGATION_COMMAND_PROJECTIONS)) { - const schema = COMMAND_OUTPUT_SCHEMAS[name as keyof typeof COMMAND_OUTPUT_SCHEMAS]; - if (!commandSupportsSettleObservation(name)) { - assert.equal(schema, projection.outputSchema, `${name}: must be the projection itself`); - continue; - } - // #1638: a settle-capable navigation command adds exactly ONE property on - // top of its projected dispatch shape — the opt-in `--settle` observation, - // grafted where `settleObservationSchema` lives because the projection - // layer sits below the MCP schema module. Everything else must still come - // from the projection verbatim. - const observed = schema as { properties?: Record; required?: unknown }; - const { settle, ...projectedProperties } = observed.properties ?? {}; - assert.ok(settle, `${name}: settle-capable schema must advertise the observation`); - assert.deepEqual(projectedProperties, projection.outputSchema?.properties); - assert.deepEqual(observed.required, projection.outputSchema?.required); +// 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' }, + }, + 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.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 7e6e70fdd9..91e7be1f82 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -1,9 +1,11 @@ import type { JsonSchema } from '../commands/command-contract.ts'; -import { projectedSystemCommandOutputSchemas } from '../commands/system/index.ts'; import type { CommandResultMap } from '../core/command-descriptor/command-result.ts'; import { commandSupportsSettleObservation } from '../core/command-descriptor/registry.ts'; import { booleanSchema, looseObjectSchema, stringSchema } from '../commands/command-input.ts'; +import { BACK_MODES } from '@agent-device/contracts/back-mode'; +import { DEVICE_ROTATIONS } from '@agent-device/contracts/device'; 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'; /** @@ -471,9 +473,41 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { ['width', 'height', 'message'], ), - // packages/contracts/src/navigation.ts, projected from executable command contracts. - // The `back` settle observation is grafted by the derivation pass below. - ...projectedSystemCommandOutputSchemas, + // 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(), + }, + ['action', 'orientation', 'message'], + ), + 'app-switcher': objectSchema({ action: constSchema('app-switcher'), 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( From 92cbd44ee4e22d763255180fc0ebad2868c2422f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 09:03:38 +0200 Subject: [PATCH 2/4] test(mcp): pin the closed top-level shape of the navigation output schemas Retiring the projection replaced an identity assert (`schema === projection.outputSchema`) with a deep-equal over properties/required, which no longer rejected an extra top-level key such as a stray `description` or `additionalProperties`. The loop now also asserts the key set is exactly type/properties/required, so the closed shape is pinned by a test again rather than by object identity. Seen red once by giving the `app-switcher` schema a description argument, which adds a top-level `description` key: the new assert failed with `+ "description"`. Green after removing it. The `deriveSettleObservationSchemas` docstring cited that deleted identity assert as the reason for copying. The press/click shared-object half is the real reason and is all that remains. --- src/mcp/__tests__/command-tools.test.ts | 5 +++++ src/mcp/command-output-schemas.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mcp/__tests__/command-tools.test.ts b/src/mcp/__tests__/command-tools.test.ts index 07543f3bcf..b6f2fc4ddd 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -508,6 +508,11 @@ test('MCP navigation output schemas advertise the closed dispatch shapes', () => 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( diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 91e7be1f82..de49bc584f 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -335,8 +335,7 @@ function withSettleObservation(schema: JsonSchema): JsonSchema { * properties per schema. The base map below carries no settle property * anywhere; this pass grafts it onto exactly the trait-capable entries. * Copies only — press and click share one base schema object, so an in-place - * graft would leak across them, and non-trait entries must stay the SAME - * object identity their projection tests pin. + * graft would leak across them. */ function deriveSettleObservationSchemas( schemas: Record, From d85f1573d9b851f667e6c81e82b4f981fa36b2a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 09:03:45 +0200 Subject: [PATCH 3/4] chore(gates): drop the retired projection from the R6 inversion rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The R6 baseline numbers are unchanged (5 inversions, commands -> client still 3): retiring the projection removed a client -> commands edge, which the ratchet does not count. What changed is the ARGUMENT next to those numbers. The commands/mcp -> client bullet justified itself with a zone-level cycle (client-types.ts imported ProjectedNavigationCommandClient back out of commands/system/); that cycle no longer exists, so the bullet now rests only on the port argument that was always the second half of it. docs/dependency-graph-findings.md §0/§0b/§1 carried the same claim and the same 'move the navigation-projection types out of commands/' follow-up, now recorded as answered by deletion. The blocked-shapes table in §1 now reads eight-at-the-time / three-still-blocked, matching the struck navigation row directly under it. --- docs/dependency-graph-findings.md | 40 ++++++++++++++++--------------- scripts/layering/check.ts | 13 +++++----- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/docs/dependency-graph-findings.md b/docs/dependency-graph-findings.md index 894f123a06..8259257979 100644 --- a/docs/dependency-graph-findings.md +++ b/docs/dependency-graph-findings.md @@ -114,12 +114,12 @@ narrow name replaced both. **The remaining 5 are positions, not debt** — each for a mechanical reason, not an appeal to an ADR: - **4 × `AgentDeviceClient`** (`commands/command-contract.ts`, `commands/command-surface.ts`, - `commands/family/types.ts`, `mcp/command-tools.ts`). The facade cannot move below `commands/` - because it is *built from* the command surface: `client/client-types.ts` imports - `ProjectedNavigationCommandClient` from `commands/system/navigation-projection.ts`. That is a real - zone-level type cycle, and breaking it means deciding where the projection registry belongs — a - design call, not a file move. A narrower port does not exist either: 4 files *name* the facade, - but 26 call sites use methods across 13 of its namespaces, so any port would re-declare it. + `commands/family/types.ts`, `mcp/command-tools.ts`). The zone-level type cycle this bullet used to + cite is gone: retiring the navigation projection left `client/client-types.ts` with no import from + `commands/` at all, and the facade now declares its 14 command methods directly. What keeps the + facade above `commands/` is the remaining argument: a narrower port does not exist — 4 files + *name* the facade, but 26 call sites use methods across 13 of its namespaces, so any port would + re-declare it. - **1 × `DaemonCommandRoute`** (`commands/command-explain.ts`). The union lives in core so descriptors can name a route without importing the daemon, and the handler table covers it with `satisfies Record`. `command-explain.ts` still type-imports the re-export @@ -183,9 +183,10 @@ duplicate the public API shape — a second source of truth for it — or derive carry the same dependency. Those four files are therefore the minimum number of naming sites, not an accident: they are the -choke point. Accepted as a position, argued at `TYPE_INVERSION_BASELINE`. The remaining option is -the one that was always the real question — whether `NAVIGATION_COMMAND_PROJECTIONS` belongs in -`commands/` — and that is a design decision about the command surface, not a dependency cleanup. +choke point. Accepted as a position, argued at `TYPE_INVERSION_BASELINE`. The option this section +used to hold open — moving `NAVIGATION_COMMAND_PROJECTIONS` out of `commands/` — was answered by +deleting it: five direct signatures replaced the registry, so there is no longer a projection +registry whose home is in question. ## 1. The two remaining type-inversion clusters @@ -201,13 +202,13 @@ changed). `index.d.ts` in fact got *smaller* — 1,726 → 1,682 lines — becau `main` duplicated into it (the Metro option/result shapes, `ScrollInputDirection`) now resolve through a shared chunk once the vocabulary sits below both its consumers. -The mutual coupling this section already warned about is what set the floor. Eight shapes could NOT -move down, because each is stated in terms of a HIGHER-ranked zone: +The mutual coupling this section already warned about is what set the floor. Eight shapes could not +move down at the time; three still cannot, because each is stated in terms of a HIGHER-ranked zone: | Shape(s) | Blocked by | |---|---| | `ScrollOptions` | `ScrollInputDirection` (`commands/interaction/runtime/gestures.ts`) | -| `BackCommandOptions`, `OrientationCommandOptions`, `AppSwitcherCommandOptions`, `TvRemoteCommandOptions`, `AgentDeviceCommandClient` | `NavigationCommandOptions` / `ProjectedNavigationCommandClient` (`commands/system/navigation-projection.ts`) | +| ~~`BackCommandOptions`, `OrientationCommandOptions`, `AppSwitcherCommandOptions`, `TvRemoteCommandOptions`, `AgentDeviceCommandClient`~~ | ~~`NavigationCommandOptions` / `ProjectedNavigationCommandClient` (`commands/system/navigation-projection.ts`)~~ — unblocked: the projection was retired, the four Options types (plus a new `HomeCommandOptions`) now live in `contracts/client-system.ts`, and the facade declares its methods directly | | `MetroPrepareResult`, `MetroReloadResult` | `PrepareMetroRuntimeResult` / `ReloadMetroResult` (`metro/client-metro.ts`) | Declaring those in `contracts/` would have traded 28 `commands -> client` inversions for @@ -226,11 +227,11 @@ Two keystone moves made the other 84 shapes movable, and both are worth noting a `SessionRuntimeHints` — the same type, three zones lower. **Remaining `commands -> client` (5) needs the upstream declarations to come down first**: move -`ScrollInputDirection` and the navigation-projection types out of `commands/`, and the Metro -prepare/reload result payloads out of `metro/`. Each is small; the sequencing is the point. The -`mcp -> client` edge is different in kind — it is the `AgentDeviceClient` facade itself, i.e. the -question of whether a command surface should know the client type. That is a design decision, not a -misplaced declaration. +`ScrollInputDirection` out of `commands/`, and the Metro prepare/reload result payloads out of +`metro/`. The navigation-projection leg of this list is done. Each is small; the sequencing is the +point. The `mcp -> client` edge is different in kind — it is the `AgentDeviceClient` facade itself, +i.e. the question of whether a command surface should know the client type. That is a design +decision, not a misplaced declaration. **5 + 1 edges → `daemon/daemon-command-registry.ts` and `daemon/types.ts`.** `core`'s descriptor registry composes the ADR 0003 daemon facet, whose shape the daemon declares. ADR 0003's @@ -460,8 +461,9 @@ implementation-pattern checks so reintroduction fails closed. completed by #1435. Eliminate the four remaining external production importers with caller-specific public contracts or daemon-owned adapters; keep `DaemonRequest` private. 2. ~~**Split `client/client-types.ts`** (§1).~~ Done — 42 → 18 total inversions. The follow-up is - the upstream moves that unblock the last 5 (§1): `ScrollInputDirection` and the - navigation-projection types out of `commands/`, Metro result payloads out of `metro/`. + the upstream moves that unblock the last 5 (§1): `ScrollInputDirection` out of `commands/`, + Metro result payloads out of `metro/`. The navigation-projection move is done — the projection + was retired rather than relocated. 3. **Retire platform branches into plugin facets** (§5b), highest-count files first. 4. **Share the remaining duplicated validators** (§6), following the `checkIsArgs` shape. 5. Optional: give `daemon/handlers/` the directory structure its filenames already imply (§5). diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index c9a95d40e1..4559616bbb 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -253,13 +253,12 @@ function checkBackEdges(edges: readonly ResolvedImportEdge[]): LayeringViolation // architectural position rather than a misplaced declaration: // // commands/mcp -> client (4) `AgentDeviceClient`, used as an opaque handle ("the client this -// command runs against"). It cannot move below `commands/` because -// the facade is BUILT from the command surface's own projection -// registry: AgentDeviceClient -> AgentDeviceCommandClient -> -// ProjectedNavigationCommandClient -> NAVIGATION_COMMAND_PROJECTIONS -// in commands/system/. That is a genuine zone-level cycle, and -// breaking it means deciding where the projection registry belongs — -// a design call, not a file move. R5 is zero here: nothing imports +// command runs against"). The facade no longer reaches back into +// commands/ — the navigation projection it was once built from is +// retired — so this is no longer a zone-level cycle, just a port +// that would have to cover the whole facade: 4 files NAME it, but 26 +// call sites use methods across 13 of its namespaces, so any port +// would re-declare the public API. R5 is zero here: nothing imports // the client at runtime, only its type. // // commands -> daemon-server (1) `DaemonCommandRoute` is declared in core so descriptors can From 335506be6c52d090c32b80d41f3bcb7a0b4eb6cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 18:39:53 +0200 Subject: [PATCH 4/4] test(mcp): split the navigation schema tests out of command-tools.test.ts --- .../command-tools-navigation-schemas.test.ts | 82 ++++++++++++++++++ src/mcp/__tests__/command-tools.test.ts | 83 +------------------ 2 files changed, 83 insertions(+), 82 deletions(-) create mode 100644 src/mcp/__tests__/command-tools-navigation-schemas.test.ts diff --git a/src/mcp/__tests__/command-tools-navigation-schemas.test.ts b/src/mcp/__tests__/command-tools-navigation-schemas.test.ts new file mode 100644 index 0000000000..99f4e6feda --- /dev/null +++ b/src/mcp/__tests__/command-tools-navigation-schemas.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { commandSupportsSettleObservation } from '../../core/command-descriptor/registry.ts'; +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' }, + }, + 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.test.ts b/src/mcp/__tests__/command-tools.test.ts index b6f2fc4ddd..bbe2d0796a 100644 --- a/src/mcp/__tests__/command-tools.test.ts +++ b/src/mcp/__tests__/command-tools.test.ts @@ -2,10 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { AgentDeviceClient } from '../../client/client-types.ts'; import { createCommandToolExecutor, listCommandTools } from '../command-tools.ts'; -import { - commandSupportsSettleObservation, - resolveCommandRecordsSessionAction, -} from '../../core/command-descriptor/registry.ts'; +import { resolveCommandRecordsSessionAction } from '../../core/command-descriptor/registry.ts'; import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; import { AppError } from '@agent-device/kernel/errors'; import { validateAgainstSchema } from './output-schema-validator.ts'; @@ -447,84 +444,6 @@ test('MCP tv remote outputSchema advertises button values', () => { ); }); -// 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' }, - }, - 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); - } -}); - test('MCP newly typed outputSchemas advertise public contract keys', () => { const tools = listCommandTools();