diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 43ebe1a20c..7f453e6224 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1209,7 +1209,14 @@ export const RAW_COMMAND_DESCRIPTORS = [ { name: 'record', deviceClaimPolicy: 'require-owner', - ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), + ...(ownerFilesEnabled + ? { + ownerFiles: [ + 'src/commands/recording/index.ts', + 'src/commands/recording/output-schemas.ts', + ] as const, + } + : {}), catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: true, @@ -1227,7 +1234,14 @@ export const RAW_COMMAND_DESCRIPTORS = [ { name: 'trace', deviceClaimPolicy: 'require-owner', - ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), + ...(ownerFilesEnabled + ? { + ownerFiles: [ + 'src/commands/recording/index.ts', + 'src/commands/recording/output-schemas.ts', + ] as const, + } + : {}), catalog: { group: 'public' }, frameworkTier: 'extended', recordsSessionAction: true, diff --git a/src/commands/recording/output-schemas.ts b/src/commands/recording/output-schemas.ts new file mode 100644 index 0000000000..657e3dc35a --- /dev/null +++ b/src/commands/recording/output-schemas.ts @@ -0,0 +1,97 @@ +import { NATIVE_PATH_DISPOSITION_VALUES } from '@agent-device/contracts/recording-native-path'; +import { RECORDER_OBSERVATION_VALUES } from '@agent-device/contracts/recording-stop-observation'; +import type { CommandResultMap } from '@agent-device/command-registry/command-result'; +import type { JsonSchema } from '../command-contract.ts'; +import { + booleanSchema, + constSchema, + enumSchema, + looseObjectSchema, + numberSchema, + objectSchema, + stringSchema, +} from '../command-input.ts'; + +const artifactSchema = objectSchema( + { + field: stringSchema(), + artifactType: stringSchema(), + path: stringSchema(), + localPath: stringSchema(), + fileName: stringSchema(), + }, + ['field'], +); + +/** + * 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 keep validating. Neither command + * carries the post-action observation trait (#1652): both fire-and-report a recording or trace + * lifecycle change, not an interaction, so no settle-graft copy applies here. + */ +export const RECORDING_COMMAND_OUTPUT_SCHEMAS = { + // packages/contracts/src/recording.ts + record: { + type: 'object', + oneOf: [ + objectSchema( + { + recording: constSchema('started'), + outPath: stringSchema(), + sessionStateDir: stringSchema(), + recordingBackend: stringSchema(), + recordingScope: stringSchema(), + recordOnlySession: booleanSchema(), + activeSessionApp: looseObjectSchema(), + showTouches: booleanSchema(), + }, + ['recording', 'outPath', 'sessionStateDir', 'showTouches'], + ), + objectSchema( + { + recording: constSchema('stopped'), + outPath: stringSchema(), + telemetryPath: stringSchema(), + artifacts: { type: 'array', items: artifactSchema }, + recordingBackend: stringSchema(), + recordingScope: stringSchema(), + recordOnlySession: booleanSchema(), + activeSessionApp: looseObjectSchema(), + durationMs: numberSchema(), + capturedDurationMs: numberSchema(), + recorder: enumSchema( + RECORDER_OBSERVATION_VALUES, + 'What the recorder was observed doing when the recording was stopped: confirmed, or lost when the session holding it died. ADR 0024 reserves unconfirmed for the step that gains the probe.', + ), + nativePathDisposition: enumSchema( + NATIVE_PATH_DISPOSITION_VALUES, + 'What became of the artifact path the recorder writes to: retirable while it still owes a removal, retired once that removal was verified. ADR 0024 reserves pending.', + ), + showTouches: booleanSchema(), + warning: stringSchema(), + overlayWarning: stringSchema(), + chunks: { type: 'array', items: looseObjectSchema() }, + }, + ['recording', 'outPath', 'artifacts', 'durationMs', 'showTouches'], + ), + ], + }, + trace: { + type: 'object', + oneOf: [ + objectSchema({ trace: constSchema('started'), outPath: stringSchema() }, [ + 'trace', + 'outPath', + ]), + objectSchema( + { + trace: constSchema('stopped'), + outPath: stringSchema(), + artifacts: { type: 'array', items: artifactSchema }, + }, + ['trace', 'outPath', 'artifacts'], + ), + ], + }, +} satisfies Pick, 'record' | 'trace'>; diff --git a/src/mcp/__tests__/command-tools-recording-schemas.test.ts b/src/mcp/__tests__/command-tools-recording-schemas.test.ts index 5bcfdf9f84..9c54d35c54 100644 --- a/src/mcp/__tests__/command-tools-recording-schemas.test.ts +++ b/src/mcp/__tests__/command-tools-recording-schemas.test.ts @@ -1,8 +1,36 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; +import { ownerFilesForCommand } from '@agent-device/command-registry/owner-files'; +import { RECORDING_COMMAND_OUTPUT_SCHEMAS } from '../../commands/recording/output-schemas.ts'; import { COMMAND_OUTPUT_SCHEMAS } from '../command-output-schemas.ts'; import { validateAgainstSchema } from './output-schema-validator.ts'; +const RECORDING_COMMANDS = Object.keys(RECORDING_COMMAND_OUTPUT_SCHEMAS) as Array< + keyof typeof RECORDING_COMMAND_OUTPUT_SCHEMAS +>; + +// Neither record nor trace carries the post-action observation trait (#1652): both fire-and-report +// a recording or trace lifecycle change, not an interaction, so the composed map never copies +// either entry and reference equality must hold for both. +test('MCP recording family output schemas are the family module entries, not copies', () => { + for (const command of RECORDING_COMMANDS) { + assert.equal( + COMMAND_OUTPUT_SCHEMAS[command], + RECORDING_COMMAND_OUTPUT_SCHEMAS[command], + `${command} is not reference-equal to the recording module's own schema object`, + ); + } +}); + +test('the projected family map declares exactly the commands its module owns', () => { + for (const command of RECORDING_COMMANDS) { + assert.ok( + ownerFilesForCommand(command).includes('src/commands/recording/output-schemas.ts'), + `${command} projects its output schema from this module but does not name it as its owner`, + ); + } +}); + // `record stop` answers two independent questions (ADR 0024): whether a playable export exists, and // whether its recorder stopped. Only the second one's words are additive properties here — the // stopped branch's required list is what it was before either fact was reported. diff --git a/src/mcp/__tests__/command-tools-replay-schemas.test.ts b/src/mcp/__tests__/command-tools-replay-schemas.test.ts index 2425c1d6cc..06afdb0eeb 100644 --- a/src/mcp/__tests__/command-tools-replay-schemas.test.ts +++ b/src/mcp/__tests__/command-tools-replay-schemas.test.ts @@ -6,6 +6,7 @@ import { commandSupportsSettleObservation } from '@agent-device/command-registry import { DEVICE_MANAGEMENT_COMMAND_OUTPUT_SCHEMAS } from '../../commands/management/device.ts'; import { PUSH_MANAGEMENT_COMMAND_OUTPUT_SCHEMAS } from '../../commands/management/push.ts'; import { INTERACTION_COMMAND_OUTPUT_SCHEMAS } from '../../commands/interaction/index.ts'; +import { RECORDING_COMMAND_OUTPUT_SCHEMAS } from '../../commands/recording/output-schemas.ts'; 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'; @@ -77,6 +78,7 @@ const PROJECTED_FAMILIES = [ { name: 'interaction', schemas: INTERACTION_COMMAND_OUTPUT_SCHEMAS }, { name: 'management-device', schemas: DEVICE_MANAGEMENT_COMMAND_OUTPUT_SCHEMAS }, { name: 'management-push', schemas: PUSH_MANAGEMENT_COMMAND_OUTPUT_SCHEMAS }, + { name: 'recording', schemas: RECORDING_COMMAND_OUTPUT_SCHEMAS }, ] as const; test('projected output-schema families claim disjoint commands and survive the composition intact', () => { diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index b7060fc7e1..b81b411f84 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -17,10 +17,9 @@ import { INTERACTION_COMMAND_OUTPUT_SCHEMAS, postActionSurfaceChangeSchema, } from '../commands/interaction/index.ts'; +import { RECORDING_COMMAND_OUTPUT_SCHEMAS } from '../commands/recording/output-schemas.ts'; import { REPLAY_COMMAND_OUTPUT_SCHEMAS } from '../commands/replay/index.ts'; 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_KINDS, DEVICE_TARGETS, PUBLIC_PLATFORMS } from '@agent-device/kernel/device'; /** @@ -52,17 +51,6 @@ import { DEVICE_KINDS, DEVICE_TARGETS, PUBLIC_PLATFORMS } from '@agent-device/ke * trait (#1652). */ -const artifactSchema = objectSchema( - { - field: stringSchema(), - artifactType: stringSchema(), - path: stringSchema(), - localPath: stringSchema(), - fileName: stringSchema(), - }, - ['field'], -); - // SettleObservation (packages/contracts/src/interaction.ts) — opt-in `--settle` settled // diff observation (#1101). const settleObservationSchema: JsonSchema = objectSchema( @@ -330,70 +318,6 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { ['mode', 'baselineInitialized', 'summary', 'lines'], ), - // packages/contracts/src/recording.ts - record: { - type: 'object', - oneOf: [ - objectSchema( - { - recording: constSchema('started'), - outPath: stringSchema(), - sessionStateDir: stringSchema(), - recordingBackend: stringSchema(), - recordingScope: stringSchema(), - recordOnlySession: booleanSchema(), - activeSessionApp: looseObjectSchema(), - showTouches: booleanSchema(), - }, - ['recording', 'outPath', 'sessionStateDir', 'showTouches'], - ), - objectSchema( - { - recording: constSchema('stopped'), - outPath: stringSchema(), - telemetryPath: stringSchema(), - artifacts: { type: 'array', items: artifactSchema }, - recordingBackend: stringSchema(), - recordingScope: stringSchema(), - recordOnlySession: booleanSchema(), - activeSessionApp: looseObjectSchema(), - durationMs: numberSchema(), - capturedDurationMs: numberSchema(), - recorder: enumSchema( - RECORDER_OBSERVATION_VALUES, - 'What the recorder was observed doing when the recording was stopped: confirmed, or lost when the session holding it died. ADR 0024 reserves unconfirmed for the step that gains the probe.', - ), - nativePathDisposition: enumSchema( - NATIVE_PATH_DISPOSITION_VALUES, - 'What became of the artifact path the recorder writes to: retirable while it still owes a removal, retired once that removal was verified. ADR 0024 reserves pending.', - ), - showTouches: booleanSchema(), - warning: stringSchema(), - overlayWarning: stringSchema(), - chunks: { type: 'array', items: looseObjectSchema() }, - }, - ['recording', 'outPath', 'artifacts', 'durationMs', 'showTouches'], - ), - ], - }, - trace: { - type: 'object', - oneOf: [ - objectSchema({ trace: constSchema('started'), outPath: stringSchema() }, [ - 'trace', - 'outPath', - ]), - objectSchema( - { - trace: constSchema('stopped'), - outPath: stringSchema(), - artifacts: { type: 'array', items: artifactSchema }, - }, - ['trace', 'outPath', 'artifacts'], - ), - ], - }, - // A family that owns its commands authors their advertised response shape beside the // command surface and projects it here. This spread stays last: a hand-written entry for // a projected command then fails as TS2783 instead of quietly overriding the family's, @@ -401,6 +325,7 @@ const BASE_COMMAND_OUTPUT_SCHEMAS = { ...DEVICE_MANAGEMENT_COMMAND_OUTPUT_SCHEMAS, ...PUSH_MANAGEMENT_COMMAND_OUTPUT_SCHEMAS, ...INTERACTION_COMMAND_OUTPUT_SCHEMAS, + ...RECORDING_COMMAND_OUTPUT_SCHEMAS, ...REPLAY_COMMAND_OUTPUT_SCHEMAS, ...SYSTEM_COMMAND_OUTPUT_SCHEMAS, } satisfies Record;