From 555ee94bc090107c6d937bfc67f297fc054d7832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 7 Jul 2026 17:56:13 +0200 Subject: [PATCH 1/4] fix: derive interaction wire projection --- src/commands/command-input.ts | 41 +++++++++-- src/commands/interaction/metadata.ts | 6 +- .../interaction-wire-projection.test.ts | 72 ++++++++++++++++++ src/core/interaction-wire-projection.ts | 65 +++++++++++++++++ .../handlers/interaction-touch-response.ts | 37 +--------- src/daemon/handlers/interaction-touch.ts | 73 +++++++++++++++++-- src/mcp/command-output-schemas.ts | 5 ++ .../runner-result-projection.test.ts | 22 ++++++ .../core/runner/runner-result-projection.ts | 17 +++++ 9 files changed, 293 insertions(+), 45 deletions(-) create mode 100644 src/core/__tests__/interaction-wire-projection.test.ts create mode 100644 src/core/interaction-wire-projection.ts create mode 100644 src/platforms/apple/core/__tests__/runner-result-projection.test.ts create mode 100644 src/platforms/apple/core/runner/runner-result-projection.ts diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts index 5ce3fe042..7ac7e9306 100644 --- a/src/commands/command-input.ts +++ b/src/commands/command-input.ts @@ -10,6 +10,10 @@ import { type DeviceTarget, type PlatformSelector, } from '../kernel/device.ts'; +import { + INTERACTION_REPEAT_WIRE_ECHO_FIELDS, + type WireEchoOptions, +} from '../core/interaction-wire-projection.ts'; import type { JsonSchema } from './command-contract.ts'; import { AppError } from '../kernel/errors.ts'; @@ -127,6 +131,7 @@ export type CommandField = { schema: JsonSchema; required: boolean; read: FieldReader; + wireEcho?: WireEchoOptions; }; export type CommandFieldMap = Record>; @@ -154,6 +159,19 @@ export function requiredField( }; } +export function wireEchoField( + field: CommandField, + options: WireEchoOptions = {}, +): CommandField { + return { + ...field, + wireEcho: { + mode: options.mode ?? 'always', + ...(Object.hasOwn(options, 'defaultValue') ? { defaultValue: options.defaultValue } : {}), + }, + }; +} + export function stringField(description?: string): CommandField { return optionalField(stringSchema(description), optionalString); } @@ -231,11 +249,24 @@ export function selectorSnapshotFields() { export function repeatedFields() { return { - count: integerField('Number of press/click repetitions.', { min: 1 }), - intervalMs: integerField('Delay between repeated press/click actions.', { min: 0 }), - holdMs: integerField('Hold duration for each action.', { min: 0 }), - jitterPx: integerField('Randomization radius in pixels.', { min: 0 }), - doubleTap: booleanField('Request a double-tap action.'), + count: wireEchoField(integerField('Number of press/click repetitions.', { min: 1 }), { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.count, + }), + intervalMs: wireEchoField( + integerField('Delay between repeated press/click actions.', { min: 0 }), + { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.intervalMs, + }, + ), + holdMs: wireEchoField(integerField('Hold duration for each action.', { min: 0 }), { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.holdMs, + }), + jitterPx: wireEchoField(integerField('Randomization radius in pixels.', { min: 0 }), { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.jitterPx, + }), + doubleTap: wireEchoField(booleanField('Request a double-tap action.'), { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.doubleTap, + }), }; } diff --git a/src/commands/interaction/metadata.ts b/src/commands/interaction/metadata.ts index 757184d4c..97db7b3c4 100644 --- a/src/commands/interaction/metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -20,6 +20,7 @@ import { requiredNumber, selectorSnapshotFields, stringField, + wireEchoField, type CommandFieldMap, type CommonCommandInput, type InferCommandInput, @@ -42,6 +43,7 @@ import { commandSupportsVerifyEvidence, } from '../../core/command-descriptor/registry.ts'; import type { PostActionObservationSupportFor } from '../../core/command-descriptor/post-action-observation.ts'; +import { FILL_DELAY_WIRE_ECHO } from '../../core/interaction-wire-projection.ts'; const FIND_ACTION_VALUES = [ 'click', @@ -120,7 +122,9 @@ const pressFields = { const fillFields = { target: requiredField(interactionTargetField()), text: requiredField(stringField('Text to enter into the target.')), - delayMs: integerField('Delay between typed characters.', { min: 0 }), + delayMs: wireEchoField(integerField('Delay between typed characters.', { min: 0 }), { + ...FILL_DELAY_WIRE_ECHO, + }), ...selectorSnapshotFields(), ...postActionObservationFields('fill'), }; diff --git a/src/core/__tests__/interaction-wire-projection.test.ts b/src/core/__tests__/interaction-wire-projection.test.ts new file mode 100644 index 000000000..2c10974b0 --- /dev/null +++ b/src/core/__tests__/interaction-wire-projection.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'vitest'; +import { + interactionWireEchoFromInput, + projectInteractionWireData, +} from '../interaction-wire-projection.ts'; + +describe('interaction wire projection', () => { + test('omits default press repeat values', () => { + expect( + interactionWireEchoFromInput('press', { + count: 1, + intervalMs: 0, + holdMs: 0, + jitterPx: 0, + doubleTap: false, + }), + ).toEqual({}); + }); + + test('echoes non-default press repeat values', () => { + expect( + interactionWireEchoFromInput('press', { + count: 2, + intervalMs: 25, + holdMs: 10, + jitterPx: 1, + doubleTap: true, + }), + ).toEqual({ + count: 2, + intervalMs: 25, + holdMs: 10, + jitterPx: 1, + doubleTap: true, + }); + }); + + test('echoes the normalized fill delay default', () => { + expect(interactionWireEchoFromInput('fill', {})).toEqual({ delayMs: 0 }); + }); + + test('preserves backend fields while deriving command echo defaults', () => { + expect( + projectInteractionWireData( + 'press', + {}, + { + count: 1, + videoPath: '/tmp/demo.mp4', + }, + ), + ).toEqual({ videoPath: '/tmp/demo.mp4' }); + }); + + test('removes default touch repeat echoes from fill backend data', () => { + expect( + projectInteractionWireData( + 'fill', + {}, + { + count: 1, + delayMs: 0, + doubleTap: false, + holdMs: 0, + intervalMs: 0, + jitterPx: 0, + text: 'Hello', + }, + ), + ).toEqual({ delayMs: 0, text: 'Hello' }); + }); +}); diff --git a/src/core/interaction-wire-projection.ts b/src/core/interaction-wire-projection.ts new file mode 100644 index 000000000..fc8160f72 --- /dev/null +++ b/src/core/interaction-wire-projection.ts @@ -0,0 +1,65 @@ +export type WireEchoMode = 'always' | 'omit-default'; + +export type WireEchoOptions = { + defaultValue?: unknown; + mode?: WireEchoMode; +}; + +export const INTERACTION_REPEAT_WIRE_ECHO_FIELDS = { + count: { defaultValue: 1, mode: 'omit-default' }, + intervalMs: { defaultValue: 0, mode: 'omit-default' }, + holdMs: { defaultValue: 0, mode: 'omit-default' }, + jitterPx: { defaultValue: 0, mode: 'omit-default' }, + doubleTap: { defaultValue: false, mode: 'omit-default' }, +} as const satisfies Record; + +export const FILL_DELAY_WIRE_ECHO = { + defaultValue: 0, +} as const satisfies WireEchoOptions; + +const TOUCH_WIRE_ECHO_FIELDS = { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS, +} as const satisfies Record; + +const INTERACTION_WIRE_ECHO_FIELDS = { + click: TOUCH_WIRE_ECHO_FIELDS, + press: TOUCH_WIRE_ECHO_FIELDS, + fill: { + ...TOUCH_WIRE_ECHO_FIELDS, + delayMs: FILL_DELAY_WIRE_ECHO, + }, +} as const satisfies Record>; + +export type InteractionWireEchoCommand = keyof typeof INTERACTION_WIRE_ECHO_FIELDS; + +export function interactionWireEchoFromInput( + command: InteractionWireEchoCommand, + input: Record | undefined, +): Record { + return projectWireEchoSpecsFromInput(INTERACTION_WIRE_ECHO_FIELDS[command], input ?? {}); +} + +export function projectInteractionWireData( + command: InteractionWireEchoCommand, + input: Record | undefined, + base: Record | undefined, +): Record { + return projectWireEchoSpecsFromInput(INTERACTION_WIRE_ECHO_FIELDS[command], input ?? {}, base); +} + +function projectWireEchoSpecsFromInput( + specs: Record, + input: Record, + base: Record = {}, +): Record { + const projected = { ...base }; + for (const [key, spec] of Object.entries(specs)) { + const value = input[key] === undefined ? spec.defaultValue : input[key]; + if (value === undefined || (spec.mode === 'omit-default' && value === spec.defaultValue)) { + delete projected[key]; + continue; + } + projected[key] = value; + } + return Object.fromEntries(Object.entries(projected).filter(([, value]) => value !== undefined)); +} diff --git a/src/daemon/handlers/interaction-touch-response.ts b/src/daemon/handlers/interaction-touch-response.ts index 305069e33..9371771e5 100644 --- a/src/daemon/handlers/interaction-touch-response.ts +++ b/src/daemon/handlers/interaction-touch-response.ts @@ -26,6 +26,7 @@ export type InteractionResponseSource = | { kind: 'runtime'; result: InteractionRuntimeResult; + wireData?: Record; } | { // Direct iOS selector dispatch: no runtime result exists, only the raw @@ -33,6 +34,7 @@ export type InteractionResponseSource = kind: 'runner-payload'; targetKind: InteractionRuntimeResult['kind']; data: Record; + wireData?: Record; point: { x: number; y: number }; }; @@ -84,7 +86,7 @@ export function buildInteractionResponseData(params: { extra: commonExtra, }); const responseData = buildTouchPayload({ - data: sanitizeWireBackendData(source.data), + data: source.wireData, fallbackX: source.point.x, fallbackY: source.point.y, referenceFrame, @@ -109,7 +111,7 @@ export function buildInteractionResponseData(params: { extra: commonExtra, }); const responseData = buildTouchPayload({ - data: sanitizeWireBackendData(result.backendResult), + data: source.wireData, fallbackX: result.point?.x, fallbackY: result.point?.y, referenceFrame, @@ -169,37 +171,6 @@ function stripUndefinedFields(data: Record): Record value !== undefined)); } -function sanitizeWireBackendData( - data: Record | undefined, -): Record | undefined { - if (!data) return undefined; - const sanitized = Object.fromEntries( - Object.entries(data).filter(([key, value]) => shouldKeepWireBackendField(key, value)), - ); - return Object.keys(sanitized).length > 0 ? sanitized : undefined; -} - -const OMITTED_WIRE_BACKEND_FIELDS = new Set([ - 'gestureStartUptimeMs', - 'gestureEndUptimeMs', - 'currentUptimeMs', - 'sequenceResults', -]); - -const DEFAULT_WIRE_BACKEND_FIELD_VALUES = new Map([ - ['count', 1], - ['intervalMs', 0], - ['holdMs', 0], - ['jitterPx', 0], - ['doubleTap', false], -]); - -function shouldKeepWireBackendField(key: string, value: unknown): boolean { - if (OMITTED_WIRE_BACKEND_FIELDS.has(key)) return false; - if (!DEFAULT_WIRE_BACKEND_FIELD_VALUES.has(key)) return true; - return value !== DEFAULT_WIRE_BACKEND_FIELD_VALUES.get(key); -} - function buildTouchMessage( extra: Record | undefined, x: number | undefined, diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 8bf6193f0..850d88a44 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -1,5 +1,10 @@ import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts'; -import { publicPlatformString } from '../../kernel/device.ts'; +import { + projectInteractionWireData, + type InteractionWireEchoCommand, +} from '../../core/interaction-wire-projection.ts'; +import { isApplePlatform, publicPlatformString } from '../../kernel/device.ts'; +import { projectAppleRunnerWireResult } from '../../platforms/apple/core/runner/runner-result-projection.ts'; import { buttonTag, getClickButtonValidationError, @@ -188,6 +193,12 @@ async function dispatchTargetedTouchViaRuntime( session, result, staleRefsWarning, + wireData: projectTouchWireData({ + session, + command: command === 'longpress' ? undefined : command, + flags: req.flags, + data: result.backendResult, + }), extra: command === 'longpress' ? { @@ -248,9 +259,10 @@ async function buildTargetedTouchResponsePayloads(params: { session: SessionState; result: TargetedTouchResult; staleRefsWarning: string | undefined; + wireData?: Record; extra: Record; }): Promise { - const { params: handlerParams, session, result, extra } = params; + const { params: handlerParams, session, result, wireData, extra } = params; const referenceFrame = result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ @@ -262,7 +274,7 @@ async function buildTargetedTouchResponsePayloads(params: { }) : readSnapshotNodesReferenceFrame(session.snapshot?.nodes ?? []); return buildInteractionResponseData({ - source: { kind: 'runtime', result }, + source: { kind: 'runtime', result, wireData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, @@ -382,8 +394,14 @@ async function dispatchDirectIosSelectorInteraction(params: { })) ?? {}; const actionFinishedAt = Date.now(); const point = readPointFromDirectSelectorTapResult(data); + const wireData = projectTouchWireData({ + session, + command: readInteractionWireEchoCommand(handlerParams.req.command, command), + flags: handlerParams.req.flags, + data, + }); const { result, responseData } = buildInteractionResponseData({ - source: { kind: 'runner-payload', targetKind: 'selector', data, point }, + source: { kind: 'runner-payload', targetKind: 'selector', data, wireData, point }, referenceFrame: readReferenceFrameFromDirectSelectorTapResult(data), extra: { ...extra, @@ -424,6 +442,33 @@ async function dispatchDirectIosSelectorInteraction(params: { } } +function projectTouchWireData(params: { + session: SessionState; + command?: InteractionWireEchoCommand; + flags: CommandFlags | undefined; + data: Record | undefined; +}): Record | undefined { + const base = isApplePlatform(params.session.device.platform) + ? projectAppleRunnerWireResult(params.data) + : params.data; + if (!params.command) return base; + return projectInteractionWireData( + params.command, + params.flags as Record | undefined, + base, + ); +} + +function readInteractionWireEchoCommand( + requestCommand: string, + dispatchCommand: 'press' | 'fill', +): InteractionWireEchoCommand { + if (requestCommand === 'click' || requestCommand === 'press' || requestCommand === 'fill') { + return requestCommand; + } + return dispatchCommand; +} + function directIosSelectorFallbackDetails( selector: DirectIosSelectorTarget, data: Record, @@ -503,7 +548,13 @@ async function dispatchFillViaRuntime( settle: readSettleRequest(req.flags), }), buildPayloads: (result) => - buildFillResponsePayloads({ session, result, text: parsedTarget.text, staleRefsWarning }), + buildFillResponsePayloads({ + session, + result, + text: parsedTarget.text, + flags: req.flags, + staleRefsWarning, + }), }); } @@ -550,6 +601,7 @@ function buildFillResponsePayloads(params: { session: SessionState; result: FillCommandResult; text: string; + flags: CommandFlags | undefined; staleRefsWarning: string | undefined; }): InteractionResponsePayloads { const { session, result } = params; @@ -558,7 +610,16 @@ function buildFillResponsePayloads(params: { ? undefined : readSnapshotNodesReferenceFrame(session.snapshot?.nodes ?? []); return buildInteractionResponseData({ - source: { kind: 'runtime', result }, + source: { + kind: 'runtime', + result, + wireData: projectTouchWireData({ + session, + command: 'fill', + flags: params.flags, + data: result.backendResult, + }), + }, referenceFrame, extra: { text: params.text }, staleRefsWarning: params.staleRefsWarning, diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index e77a4e3a2..39bd57330 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -194,6 +194,11 @@ export const COMMAND_OUTPUT_SCHEMAS = { evidence: interactionEvidenceSchema, settle: settleObservationSchema, button: enumSchema(['secondary', 'middle']), + count: numberSchema('Number of press/click repetitions.'), + intervalMs: numberSchema('Delay between repeated press/click actions.'), + holdMs: numberSchema('Hold duration for each action.'), + jitterPx: numberSchema('Randomization radius in pixels.'), + doubleTap: booleanSchema('Whether the command requested a double-tap action.'), }, }), fill: interactionWireResultSchema({ diff --git a/src/platforms/apple/core/__tests__/runner-result-projection.test.ts b/src/platforms/apple/core/__tests__/runner-result-projection.test.ts new file mode 100644 index 000000000..1378514ba --- /dev/null +++ b/src/platforms/apple/core/__tests__/runner-result-projection.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest'; +import { projectAppleRunnerWireResult } from '../runner/runner-result-projection.ts'; + +describe('projectAppleRunnerWireResult', () => { + test('removes runner diagnostics while preserving public fields', () => { + expect( + projectAppleRunnerWireResult({ + completedSteps: 2, + count: 1, + currentUptimeMs: 123, + gestureEndUptimeMs: 456, + gestureStartUptimeMs: 100, + sequenceResults: [{ ok: true }], + videoPath: '/tmp/demo.mp4', + }), + ).toEqual({ + completedSteps: 2, + count: 1, + videoPath: '/tmp/demo.mp4', + }); + }); +}); diff --git a/src/platforms/apple/core/runner/runner-result-projection.ts b/src/platforms/apple/core/runner/runner-result-projection.ts new file mode 100644 index 000000000..8e8765be6 --- /dev/null +++ b/src/platforms/apple/core/runner/runner-result-projection.ts @@ -0,0 +1,17 @@ +const APPLE_RUNNER_DIAGNOSTIC_RESULT_FIELDS = [ + 'currentUptimeMs', + 'gestureEndUptimeMs', + 'gestureStartUptimeMs', + 'sequenceResults', +] as const; + +const appleRunnerDiagnosticResultFields = new Set(APPLE_RUNNER_DIAGNOSTIC_RESULT_FIELDS); + +export function projectAppleRunnerWireResult( + data: Record | undefined, +): Record | undefined { + if (!data) return undefined; + return Object.fromEntries( + Object.entries(data).filter(([key]) => !appleRunnerDiagnosticResultFields.has(key)), + ); +} From 37b9ead04c8322711b20979c528446c807e8aa00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 11:20:03 +0200 Subject: [PATCH 2/4] fix: derive wire projection from command descriptors --- src/commands/command-input.ts | 41 ++-------------- src/commands/interaction/metadata.ts | 6 +-- .../interaction-wire-projection.test.ts | 13 +++++ src/core/command-descriptor/registry.ts | 34 +++++++++++++ src/core/command-descriptor/types.ts | 5 ++ src/core/interaction-wire-echo.ts | 22 +++++++++ src/core/interaction-wire-projection.ts | 49 ++++++------------- 7 files changed, 95 insertions(+), 75 deletions(-) create mode 100644 src/core/interaction-wire-echo.ts diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts index 7ac7e9306..5ce3fe042 100644 --- a/src/commands/command-input.ts +++ b/src/commands/command-input.ts @@ -10,10 +10,6 @@ import { type DeviceTarget, type PlatformSelector, } from '../kernel/device.ts'; -import { - INTERACTION_REPEAT_WIRE_ECHO_FIELDS, - type WireEchoOptions, -} from '../core/interaction-wire-projection.ts'; import type { JsonSchema } from './command-contract.ts'; import { AppError } from '../kernel/errors.ts'; @@ -131,7 +127,6 @@ export type CommandField = { schema: JsonSchema; required: boolean; read: FieldReader; - wireEcho?: WireEchoOptions; }; export type CommandFieldMap = Record>; @@ -159,19 +154,6 @@ export function requiredField( }; } -export function wireEchoField( - field: CommandField, - options: WireEchoOptions = {}, -): CommandField { - return { - ...field, - wireEcho: { - mode: options.mode ?? 'always', - ...(Object.hasOwn(options, 'defaultValue') ? { defaultValue: options.defaultValue } : {}), - }, - }; -} - export function stringField(description?: string): CommandField { return optionalField(stringSchema(description), optionalString); } @@ -249,24 +231,11 @@ export function selectorSnapshotFields() { export function repeatedFields() { return { - count: wireEchoField(integerField('Number of press/click repetitions.', { min: 1 }), { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.count, - }), - intervalMs: wireEchoField( - integerField('Delay between repeated press/click actions.', { min: 0 }), - { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.intervalMs, - }, - ), - holdMs: wireEchoField(integerField('Hold duration for each action.', { min: 0 }), { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.holdMs, - }), - jitterPx: wireEchoField(integerField('Randomization radius in pixels.', { min: 0 }), { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.jitterPx, - }), - doubleTap: wireEchoField(booleanField('Request a double-tap action.'), { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS.doubleTap, - }), + count: integerField('Number of press/click repetitions.', { min: 1 }), + intervalMs: integerField('Delay between repeated press/click actions.', { min: 0 }), + holdMs: integerField('Hold duration for each action.', { min: 0 }), + jitterPx: integerField('Randomization radius in pixels.', { min: 0 }), + doubleTap: booleanField('Request a double-tap action.'), }; } diff --git a/src/commands/interaction/metadata.ts b/src/commands/interaction/metadata.ts index 97db7b3c4..757184d4c 100644 --- a/src/commands/interaction/metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -20,7 +20,6 @@ import { requiredNumber, selectorSnapshotFields, stringField, - wireEchoField, type CommandFieldMap, type CommonCommandInput, type InferCommandInput, @@ -43,7 +42,6 @@ import { commandSupportsVerifyEvidence, } from '../../core/command-descriptor/registry.ts'; import type { PostActionObservationSupportFor } from '../../core/command-descriptor/post-action-observation.ts'; -import { FILL_DELAY_WIRE_ECHO } from '../../core/interaction-wire-projection.ts'; const FIND_ACTION_VALUES = [ 'click', @@ -122,9 +120,7 @@ const pressFields = { const fillFields = { target: requiredField(interactionTargetField()), text: requiredField(stringField('Text to enter into the target.')), - delayMs: wireEchoField(integerField('Delay between typed characters.', { min: 0 }), { - ...FILL_DELAY_WIRE_ECHO, - }), + delayMs: integerField('Delay between typed characters.', { min: 0 }), ...selectorSnapshotFields(), ...postActionObservationFields('fill'), }; diff --git a/src/core/__tests__/interaction-wire-projection.test.ts b/src/core/__tests__/interaction-wire-projection.test.ts index 2c10974b0..a7faae855 100644 --- a/src/core/__tests__/interaction-wire-projection.test.ts +++ b/src/core/__tests__/interaction-wire-projection.test.ts @@ -3,8 +3,21 @@ import { interactionWireEchoFromInput, projectInteractionWireData, } from '../interaction-wire-projection.ts'; +import { resolveCommandWireProjection } from '../command-descriptor/registry.ts'; describe('interaction wire projection', () => { + test('reads command-owned wire echo specs from descriptors', () => { + expect(resolveCommandWireProjection('press')?.wireEcho).toEqual({ + count: { defaultValue: 1, mode: 'omit-default' }, + intervalMs: { defaultValue: 0, mode: 'omit-default' }, + holdMs: { defaultValue: 0, mode: 'omit-default' }, + jitterPx: { defaultValue: 0, mode: 'omit-default' }, + doubleTap: { defaultValue: false, mode: 'omit-default' }, + }); + expect(resolveCommandWireProjection('fill')?.wireEcho.delayMs).toEqual({ defaultValue: 0 }); + expect(resolveCommandWireProjection('longpress')).toBeUndefined(); + }); + test('omits default press repeat values', () => { expect( interactionWireEchoFromInput('press', { diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 81a99d984..4b80d776b 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -7,6 +7,11 @@ import { import type { CommandCapability } from '../capabilities.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; import { resolveWaitBudgetMs } from '../wait-positionals.ts'; +import { + FILL_DELAY_WIRE_ECHO, + INTERACTION_REPEAT_WIRE_ECHO_FIELDS, + type CommandWireProjection, +} from '../interaction-wire-echo.ts'; import { DEFAULT_TIMEOUT_POLICY, INSTALL_REQUEST_TIMEOUT_MS, @@ -125,6 +130,19 @@ const SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY: CommandTimeoutPolicy = { onTimeout: 'preserve-daemon', }; +const TOUCH_INTERACTION_WIRE_PROJECTION = { + wireEcho: { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS, + }, +} as const satisfies CommandWireProjection; + +const FILL_INTERACTION_WIRE_PROJECTION = { + wireEcho: { + ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS, + delayMs: FILL_DELAY_WIRE_ECHO, + }, +} as const satisfies CommandWireProjection; + function interactionTimeoutPolicy(command: string): CommandTimeoutPolicy { return resolvePostActionObservationSupport(command) !== undefined ? SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY @@ -516,6 +534,7 @@ const RAW_COMMAND_DESCRIPTORS = [ capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy(PUBLIC_COMMANDS.click), postActionObservation: postActionObservation(PUBLIC_COMMANDS.click), + wireProjection: TOUCH_INTERACTION_WIRE_PROJECTION, batchable: true, }, { @@ -524,6 +543,7 @@ const RAW_COMMAND_DESCRIPTORS = [ capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy(PUBLIC_COMMANDS.fill), postActionObservation: postActionObservation(PUBLIC_COMMANDS.fill), + wireProjection: FILL_INTERACTION_WIRE_PROJECTION, batchable: true, }, { @@ -540,6 +560,7 @@ const RAW_COMMAND_DESCRIPTORS = [ capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy(PUBLIC_COMMANDS.press), postActionObservation: postActionObservation(PUBLIC_COMMANDS.press), + wireProjection: TOUCH_INTERACTION_WIRE_PROJECTION, batchable: true, }, { @@ -775,6 +796,12 @@ const TIMEOUT_POLICY_BY_COMMAND: ReadonlyMap = new commandDescriptors.map((descriptor) => [descriptor.name, descriptor.timeoutPolicy]), ); +const WIRE_PROJECTION_BY_COMMAND: ReadonlyMap = new Map( + Array.from(COMMAND_DESCRIPTOR_BY_NAME.values()).flatMap((descriptor) => + descriptor.wireProjection ? [[descriptor.name, descriptor.wireProjection] as const] : [], + ), +); + export function resolveCommandPostActionObservationSupport( command: string | undefined, ): PostActionObservationSupport | undefined { @@ -800,3 +827,10 @@ export function resolveCommandTimeoutPolicy(command: string | undefined): Comman if (command === undefined) return DEFAULT_TIMEOUT_POLICY; return TIMEOUT_POLICY_BY_COMMAND.get(command) ?? DEFAULT_TIMEOUT_POLICY; } + +export function resolveCommandWireProjection( + command: string | undefined, +): CommandWireProjection | undefined { + if (command === undefined) return undefined; + return WIRE_PROJECTION_BY_COMMAND.get(command); +} diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index 1a741917e..ad312e51d 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -1,4 +1,5 @@ import type { CommandCapability } from '../capabilities.ts'; +import type { CommandWireProjection } from '../interaction-wire-echo.ts'; import type { DaemonCommandDescriptor } from '../../daemon/daemon-command-registry.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; @@ -81,6 +82,9 @@ export type CommandTimeoutPolicy = { * commands that support `--settle`/`--verify`; consumed by * command surfaces and timeout policy instead of repeated * command-name lists. + * - `wireProjection` — optional response projection metadata for command-owned + * echoes in daemon responses. This keeps response shaping on + * the same descriptor surface as other command traits. * * The registry started dormant (proven byte-equal to the hand tables by the * parity tests) and is now the live source: the daemon registry, capability @@ -95,4 +99,5 @@ export type CommandDescriptor = { mcpExposed: boolean; timeoutPolicy: CommandTimeoutPolicy; postActionObservation?: PostActionObservationSupport; + wireProjection?: CommandWireProjection; }; diff --git a/src/core/interaction-wire-echo.ts b/src/core/interaction-wire-echo.ts new file mode 100644 index 000000000..9cea2b2f8 --- /dev/null +++ b/src/core/interaction-wire-echo.ts @@ -0,0 +1,22 @@ +export type WireEchoMode = 'always' | 'omit-default'; + +export type WireEchoOptions = { + defaultValue?: unknown; + mode?: WireEchoMode; +}; + +export type CommandWireProjection = { + wireEcho: Record; +}; + +export const INTERACTION_REPEAT_WIRE_ECHO_FIELDS = { + count: { defaultValue: 1, mode: 'omit-default' }, + intervalMs: { defaultValue: 0, mode: 'omit-default' }, + holdMs: { defaultValue: 0, mode: 'omit-default' }, + jitterPx: { defaultValue: 0, mode: 'omit-default' }, + doubleTap: { defaultValue: false, mode: 'omit-default' }, +} as const satisfies Record; + +export const FILL_DELAY_WIRE_ECHO = { + defaultValue: 0, +} as const satisfies WireEchoOptions; diff --git a/src/core/interaction-wire-projection.ts b/src/core/interaction-wire-projection.ts index fc8160f72..a0c319eab 100644 --- a/src/core/interaction-wire-projection.ts +++ b/src/core/interaction-wire-projection.ts @@ -1,42 +1,13 @@ -export type WireEchoMode = 'always' | 'omit-default'; +import { resolveCommandWireProjection } from './command-descriptor/registry.ts'; +import type { WireEchoOptions } from './interaction-wire-echo.ts'; -export type WireEchoOptions = { - defaultValue?: unknown; - mode?: WireEchoMode; -}; - -export const INTERACTION_REPEAT_WIRE_ECHO_FIELDS = { - count: { defaultValue: 1, mode: 'omit-default' }, - intervalMs: { defaultValue: 0, mode: 'omit-default' }, - holdMs: { defaultValue: 0, mode: 'omit-default' }, - jitterPx: { defaultValue: 0, mode: 'omit-default' }, - doubleTap: { defaultValue: false, mode: 'omit-default' }, -} as const satisfies Record; - -export const FILL_DELAY_WIRE_ECHO = { - defaultValue: 0, -} as const satisfies WireEchoOptions; - -const TOUCH_WIRE_ECHO_FIELDS = { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS, -} as const satisfies Record; - -const INTERACTION_WIRE_ECHO_FIELDS = { - click: TOUCH_WIRE_ECHO_FIELDS, - press: TOUCH_WIRE_ECHO_FIELDS, - fill: { - ...TOUCH_WIRE_ECHO_FIELDS, - delayMs: FILL_DELAY_WIRE_ECHO, - }, -} as const satisfies Record>; - -export type InteractionWireEchoCommand = keyof typeof INTERACTION_WIRE_ECHO_FIELDS; +export type InteractionWireEchoCommand = 'click' | 'press' | 'fill'; export function interactionWireEchoFromInput( command: InteractionWireEchoCommand, input: Record | undefined, ): Record { - return projectWireEchoSpecsFromInput(INTERACTION_WIRE_ECHO_FIELDS[command], input ?? {}); + return projectWireEchoSpecsFromInput(readCommandWireEchoSpecs(command), input ?? {}); } export function projectInteractionWireData( @@ -44,7 +15,17 @@ export function projectInteractionWireData( input: Record | undefined, base: Record | undefined, ): Record { - return projectWireEchoSpecsFromInput(INTERACTION_WIRE_ECHO_FIELDS[command], input ?? {}, base); + return projectWireEchoSpecsFromInput(readCommandWireEchoSpecs(command), input ?? {}, base); +} + +function readCommandWireEchoSpecs( + command: InteractionWireEchoCommand, +): Record { + const projection = resolveCommandWireProjection(command); + if (!projection) { + throw new Error(`Missing wire projection descriptor for ${command}`); + } + return projection.wireEcho; } function projectWireEchoSpecsFromInput( From 9a91f47528907c42342d795119bd885dc0060b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 12:07:42 +0200 Subject: [PATCH 3/4] refactor: clarify response data transform naming --- ...nteraction-response-data-transform.test.ts | 96 +++++++++++++++++++ .../interaction-wire-projection.test.ts | 85 ---------------- src/core/command-descriptor/registry.ts | 58 ++++++----- src/core/command-descriptor/types.ts | 19 +++- .../interaction-response-data-transform.ts | 43 +++++++++ src/core/interaction-wire-echo.ts | 22 ----- src/core/interaction-wire-projection.ts | 46 --------- .../handlers/interaction-touch-response.ts | 14 +-- src/daemon/handlers/interaction-touch.ts | 44 ++++----- src/mcp/command-output-schemas.ts | 12 +-- ...ner-result-response-normalization.test.ts} | 6 +- ...> runner-result-response-normalization.ts} | 2 +- 12 files changed, 224 insertions(+), 223 deletions(-) create mode 100644 src/core/__tests__/interaction-response-data-transform.test.ts delete mode 100644 src/core/__tests__/interaction-wire-projection.test.ts create mode 100644 src/core/interaction-response-data-transform.ts delete mode 100644 src/core/interaction-wire-echo.ts delete mode 100644 src/core/interaction-wire-projection.ts rename src/platforms/apple/core/__tests__/{runner-result-projection.test.ts => runner-result-response-normalization.test.ts} (69%) rename src/platforms/apple/core/runner/{runner-result-projection.ts => runner-result-response-normalization.ts} (89%) diff --git a/src/core/__tests__/interaction-response-data-transform.test.ts b/src/core/__tests__/interaction-response-data-transform.test.ts new file mode 100644 index 000000000..439b1ad15 --- /dev/null +++ b/src/core/__tests__/interaction-response-data-transform.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'vitest'; +import { resolveCommandResponseDataTransform } from '../command-descriptor/registry.ts'; +import { transformInteractionResponseData } from '../interaction-response-data-transform.ts'; + +describe('interaction response data transform', () => { + test('reads command-owned response data transform from descriptors', () => { + expect(resolveCommandResponseDataTransform('press')?.fields).toEqual({ + count: { defaultValue: 1, omitDefault: true }, + intervalMs: { defaultValue: 0, omitDefault: true }, + holdMs: { defaultValue: 0, omitDefault: true }, + jitterPx: { defaultValue: 0, omitDefault: true }, + doubleTap: { defaultValue: false, omitDefault: true }, + }); + expect(resolveCommandResponseDataTransform('fill')?.fields.delayMs).toEqual({ + defaultValue: 0, + }); + expect(resolveCommandResponseDataTransform('longpress')).toBeUndefined(); + }); + + test('omits default press repeat values', () => { + expect( + transformInteractionResponseData({ + command: 'press', + input: { + count: 1, + intervalMs: 0, + holdMs: 0, + jitterPx: 0, + doubleTap: false, + }, + data: undefined, + }), + ).toEqual({}); + }); + + test('keeps non-default press repeat values', () => { + expect( + transformInteractionResponseData({ + command: 'press', + input: { + count: 2, + intervalMs: 25, + holdMs: 10, + jitterPx: 1, + doubleTap: true, + }, + data: undefined, + }), + ).toEqual({ + count: 2, + intervalMs: 25, + holdMs: 10, + jitterPx: 1, + doubleTap: true, + }); + }); + + test('keeps the normalized fill delay default', () => { + expect( + transformInteractionResponseData({ command: 'fill', input: {}, data: undefined }), + ).toEqual({ + delayMs: 0, + }); + }); + + test('preserves backend fields while applying command defaults', () => { + expect( + transformInteractionResponseData({ + command: 'press', + input: {}, + data: { + count: 1, + videoPath: '/tmp/demo.mp4', + }, + }), + ).toEqual({ videoPath: '/tmp/demo.mp4' }); + }); + + test('removes default touch repeat fields from fill backend data', () => { + expect( + transformInteractionResponseData({ + command: 'fill', + input: {}, + data: { + count: 1, + delayMs: 0, + doubleTap: false, + holdMs: 0, + intervalMs: 0, + jitterPx: 0, + text: 'Hello', + }, + }), + ).toEqual({ delayMs: 0, text: 'Hello' }); + }); +}); diff --git a/src/core/__tests__/interaction-wire-projection.test.ts b/src/core/__tests__/interaction-wire-projection.test.ts deleted file mode 100644 index a7faae855..000000000 --- a/src/core/__tests__/interaction-wire-projection.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - interactionWireEchoFromInput, - projectInteractionWireData, -} from '../interaction-wire-projection.ts'; -import { resolveCommandWireProjection } from '../command-descriptor/registry.ts'; - -describe('interaction wire projection', () => { - test('reads command-owned wire echo specs from descriptors', () => { - expect(resolveCommandWireProjection('press')?.wireEcho).toEqual({ - count: { defaultValue: 1, mode: 'omit-default' }, - intervalMs: { defaultValue: 0, mode: 'omit-default' }, - holdMs: { defaultValue: 0, mode: 'omit-default' }, - jitterPx: { defaultValue: 0, mode: 'omit-default' }, - doubleTap: { defaultValue: false, mode: 'omit-default' }, - }); - expect(resolveCommandWireProjection('fill')?.wireEcho.delayMs).toEqual({ defaultValue: 0 }); - expect(resolveCommandWireProjection('longpress')).toBeUndefined(); - }); - - test('omits default press repeat values', () => { - expect( - interactionWireEchoFromInput('press', { - count: 1, - intervalMs: 0, - holdMs: 0, - jitterPx: 0, - doubleTap: false, - }), - ).toEqual({}); - }); - - test('echoes non-default press repeat values', () => { - expect( - interactionWireEchoFromInput('press', { - count: 2, - intervalMs: 25, - holdMs: 10, - jitterPx: 1, - doubleTap: true, - }), - ).toEqual({ - count: 2, - intervalMs: 25, - holdMs: 10, - jitterPx: 1, - doubleTap: true, - }); - }); - - test('echoes the normalized fill delay default', () => { - expect(interactionWireEchoFromInput('fill', {})).toEqual({ delayMs: 0 }); - }); - - test('preserves backend fields while deriving command echo defaults', () => { - expect( - projectInteractionWireData( - 'press', - {}, - { - count: 1, - videoPath: '/tmp/demo.mp4', - }, - ), - ).toEqual({ videoPath: '/tmp/demo.mp4' }); - }); - - test('removes default touch repeat echoes from fill backend data', () => { - expect( - projectInteractionWireData( - 'fill', - {}, - { - count: 1, - delayMs: 0, - doubleTap: false, - holdMs: 0, - intervalMs: 0, - jitterPx: 0, - text: 'Hello', - }, - ), - ).toEqual({ delayMs: 0, text: 'Hello' }); - }); -}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 4b80d776b..d84fff891 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -7,11 +7,6 @@ import { import type { CommandCapability } from '../capabilities.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; import { resolveWaitBudgetMs } from '../wait-positionals.ts'; -import { - FILL_DELAY_WIRE_ECHO, - INTERACTION_REPEAT_WIRE_ECHO_FIELDS, - type CommandWireProjection, -} from '../interaction-wire-echo.ts'; import { DEFAULT_TIMEOUT_POLICY, INSTALL_REQUEST_TIMEOUT_MS, @@ -19,7 +14,11 @@ import { } from './timeout-policy.ts'; import { resolvePostActionObservationSupport } from './post-action-observation.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; -import type { CommandDescriptor, CommandTimeoutPolicy } from './types.ts'; +import type { + CommandDescriptor, + CommandResponseDataTransform, + CommandTimeoutPolicy, +} from './types.ts'; type RawCommandDescriptor = Omit & { mcpExposed?: boolean; @@ -130,18 +129,22 @@ const SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY: CommandTimeoutPolicy = { onTimeout: 'preserve-daemon', }; -const TOUCH_INTERACTION_WIRE_PROJECTION = { - wireEcho: { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS, +const TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM = { + fields: { + count: { defaultValue: 1, omitDefault: true }, + intervalMs: { defaultValue: 0, omitDefault: true }, + holdMs: { defaultValue: 0, omitDefault: true }, + jitterPx: { defaultValue: 0, omitDefault: true }, + doubleTap: { defaultValue: false, omitDefault: true }, }, -} as const satisfies CommandWireProjection; +} as const satisfies CommandResponseDataTransform; -const FILL_INTERACTION_WIRE_PROJECTION = { - wireEcho: { - ...INTERACTION_REPEAT_WIRE_ECHO_FIELDS, - delayMs: FILL_DELAY_WIRE_ECHO, +const FILL_INTERACTION_RESPONSE_DATA_TRANSFORM = { + fields: { + ...TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM.fields, + delayMs: { defaultValue: 0 }, }, -} as const satisfies CommandWireProjection; +} as const satisfies CommandResponseDataTransform; function interactionTimeoutPolicy(command: string): CommandTimeoutPolicy { return resolvePostActionObservationSupport(command) !== undefined @@ -534,7 +537,7 @@ const RAW_COMMAND_DESCRIPTORS = [ capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy(PUBLIC_COMMANDS.click), postActionObservation: postActionObservation(PUBLIC_COMMANDS.click), - wireProjection: TOUCH_INTERACTION_WIRE_PROJECTION, + responseDataTransform: TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM, batchable: true, }, { @@ -543,7 +546,7 @@ const RAW_COMMAND_DESCRIPTORS = [ capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy(PUBLIC_COMMANDS.fill), postActionObservation: postActionObservation(PUBLIC_COMMANDS.fill), - wireProjection: FILL_INTERACTION_WIRE_PROJECTION, + responseDataTransform: FILL_INTERACTION_RESPONSE_DATA_TRANSFORM, batchable: true, }, { @@ -560,7 +563,7 @@ const RAW_COMMAND_DESCRIPTORS = [ capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, timeoutPolicy: interactionTimeoutPolicy(PUBLIC_COMMANDS.press), postActionObservation: postActionObservation(PUBLIC_COMMANDS.press), - wireProjection: TOUCH_INTERACTION_WIRE_PROJECTION, + responseDataTransform: TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM, batchable: true, }, { @@ -796,11 +799,14 @@ const TIMEOUT_POLICY_BY_COMMAND: ReadonlyMap = new commandDescriptors.map((descriptor) => [descriptor.name, descriptor.timeoutPolicy]), ); -const WIRE_PROJECTION_BY_COMMAND: ReadonlyMap = new Map( - Array.from(COMMAND_DESCRIPTOR_BY_NAME.values()).flatMap((descriptor) => - descriptor.wireProjection ? [[descriptor.name, descriptor.wireProjection] as const] : [], - ), -); +const RESPONSE_DATA_TRANSFORM_BY_COMMAND: ReadonlyMap = + new Map( + Array.from(COMMAND_DESCRIPTOR_BY_NAME.values()).flatMap((descriptor) => + descriptor.responseDataTransform + ? [[descriptor.name, descriptor.responseDataTransform] as const] + : [], + ), + ); export function resolveCommandPostActionObservationSupport( command: string | undefined, @@ -828,9 +834,9 @@ export function resolveCommandTimeoutPolicy(command: string | undefined): Comman return TIMEOUT_POLICY_BY_COMMAND.get(command) ?? DEFAULT_TIMEOUT_POLICY; } -export function resolveCommandWireProjection( +export function resolveCommandResponseDataTransform( command: string | undefined, -): CommandWireProjection | undefined { +): CommandResponseDataTransform | undefined { if (command === undefined) return undefined; - return WIRE_PROJECTION_BY_COMMAND.get(command); + return RESPONSE_DATA_TRANSFORM_BY_COMMAND.get(command); } diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index ad312e51d..855d68951 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -1,8 +1,16 @@ import type { CommandCapability } from '../capabilities.ts'; -import type { CommandWireProjection } from '../interaction-wire-echo.ts'; import type { DaemonCommandDescriptor } from '../../daemon/daemon-command-registry.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; +export type ResponseDataFieldTransform = { + defaultValue?: unknown; + omitDefault?: boolean; +}; + +export type CommandResponseDataTransform = { + fields: Record; +}; + /** * The daemon route + request-policy traits for a command, minus the `command` * key (which is carried at the descriptor top level as `name`). This reuses the @@ -82,9 +90,10 @@ export type CommandTimeoutPolicy = { * commands that support `--settle`/`--verify`; consumed by * command surfaces and timeout policy instead of repeated * command-name lists. - * - `wireProjection` — optional response projection metadata for command-owned - * echoes in daemon responses. This keeps response shaping on - * the same descriptor surface as other command traits. + * - `responseDataTransform` — optional public response data shaping rules for + * command-owned fields in daemon responses. This keeps + * response shaping on the same descriptor surface as other + * command traits. * * The registry started dormant (proven byte-equal to the hand tables by the * parity tests) and is now the live source: the daemon registry, capability @@ -99,5 +108,5 @@ export type CommandDescriptor = { mcpExposed: boolean; timeoutPolicy: CommandTimeoutPolicy; postActionObservation?: PostActionObservationSupport; - wireProjection?: CommandWireProjection; + responseDataTransform?: CommandResponseDataTransform; }; diff --git a/src/core/interaction-response-data-transform.ts b/src/core/interaction-response-data-transform.ts new file mode 100644 index 000000000..972964963 --- /dev/null +++ b/src/core/interaction-response-data-transform.ts @@ -0,0 +1,43 @@ +import { resolveCommandResponseDataTransform } from './command-descriptor/registry.ts'; +import type { ResponseDataFieldTransform } from './command-descriptor/types.ts'; + +export type InteractionResponseDataTransformCommand = 'click' | 'press' | 'fill'; + +export function transformInteractionResponseData(params: { + command: InteractionResponseDataTransformCommand; + input: Record | undefined; + data: Record | undefined; +}): Record { + return applyResponseDataFieldTransforms({ + fields: readCommandResponseDataTransformFields(params.command), + input: params.input ?? {}, + data: params.data, + }); +} + +function readCommandResponseDataTransformFields( + command: InteractionResponseDataTransformCommand, +): Record { + const transform = resolveCommandResponseDataTransform(command); + if (!transform) { + throw new Error(`Missing response data transform descriptor for ${command}`); + } + return transform.fields; +} + +function applyResponseDataFieldTransforms(params: { + fields: Record; + input: Record; + data: Record | undefined; +}): Record { + const transformed = { ...(params.data ?? {}) }; + for (const [key, field] of Object.entries(params.fields)) { + const value = params.input[key] === undefined ? field.defaultValue : params.input[key]; + if (value === undefined || (field.omitDefault === true && value === field.defaultValue)) { + delete transformed[key]; + continue; + } + transformed[key] = value; + } + return Object.fromEntries(Object.entries(transformed).filter(([, value]) => value !== undefined)); +} diff --git a/src/core/interaction-wire-echo.ts b/src/core/interaction-wire-echo.ts deleted file mode 100644 index 9cea2b2f8..000000000 --- a/src/core/interaction-wire-echo.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type WireEchoMode = 'always' | 'omit-default'; - -export type WireEchoOptions = { - defaultValue?: unknown; - mode?: WireEchoMode; -}; - -export type CommandWireProjection = { - wireEcho: Record; -}; - -export const INTERACTION_REPEAT_WIRE_ECHO_FIELDS = { - count: { defaultValue: 1, mode: 'omit-default' }, - intervalMs: { defaultValue: 0, mode: 'omit-default' }, - holdMs: { defaultValue: 0, mode: 'omit-default' }, - jitterPx: { defaultValue: 0, mode: 'omit-default' }, - doubleTap: { defaultValue: false, mode: 'omit-default' }, -} as const satisfies Record; - -export const FILL_DELAY_WIRE_ECHO = { - defaultValue: 0, -} as const satisfies WireEchoOptions; diff --git a/src/core/interaction-wire-projection.ts b/src/core/interaction-wire-projection.ts deleted file mode 100644 index a0c319eab..000000000 --- a/src/core/interaction-wire-projection.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { resolveCommandWireProjection } from './command-descriptor/registry.ts'; -import type { WireEchoOptions } from './interaction-wire-echo.ts'; - -export type InteractionWireEchoCommand = 'click' | 'press' | 'fill'; - -export function interactionWireEchoFromInput( - command: InteractionWireEchoCommand, - input: Record | undefined, -): Record { - return projectWireEchoSpecsFromInput(readCommandWireEchoSpecs(command), input ?? {}); -} - -export function projectInteractionWireData( - command: InteractionWireEchoCommand, - input: Record | undefined, - base: Record | undefined, -): Record { - return projectWireEchoSpecsFromInput(readCommandWireEchoSpecs(command), input ?? {}, base); -} - -function readCommandWireEchoSpecs( - command: InteractionWireEchoCommand, -): Record { - const projection = resolveCommandWireProjection(command); - if (!projection) { - throw new Error(`Missing wire projection descriptor for ${command}`); - } - return projection.wireEcho; -} - -function projectWireEchoSpecsFromInput( - specs: Record, - input: Record, - base: Record = {}, -): Record { - const projected = { ...base }; - for (const [key, spec] of Object.entries(specs)) { - const value = input[key] === undefined ? spec.defaultValue : input[key]; - if (value === undefined || (spec.mode === 'omit-default' && value === spec.defaultValue)) { - delete projected[key]; - continue; - } - projected[key] = value; - } - return Object.fromEntries(Object.entries(projected).filter(([, value]) => value !== undefined)); -} diff --git a/src/daemon/handlers/interaction-touch-response.ts b/src/daemon/handlers/interaction-touch-response.ts index 9371771e5..3bdb03227 100644 --- a/src/daemon/handlers/interaction-touch-response.ts +++ b/src/daemon/handlers/interaction-touch-response.ts @@ -11,8 +11,8 @@ import { interactionResultExtra } from './interaction-touch-targets.ts'; /** * The single construction site for interaction response payloads (ADR 0011 * Layer 2). Every press/click/fill/longpress dispatch branch builds its - * `result` (session history + touch visualization) and `responseData` (wire) - * payloads here, so identity extras (ref/refLabel/selectorChain/ + * `result` (session history + touch visualization) and `responseData` (public + * response) payloads here, so identity extras (ref/refLabel/selectorChain/ * targetHittable/hint/evidence) are composed in exactly one place — the class * of bug where a hand-rolled branch dropped a field (fill @ref dropped * `evidence`, #1064 review) cannot recur. A guard test fails any interaction @@ -26,7 +26,7 @@ export type InteractionResponseSource = | { kind: 'runtime'; result: InteractionRuntimeResult; - wireData?: Record; + publicData?: Record; } | { // Direct iOS selector dispatch: no runtime result exists, only the raw @@ -34,14 +34,14 @@ export type InteractionResponseSource = kind: 'runner-payload'; targetKind: InteractionRuntimeResult['kind']; data: Record; - wireData?: Record; + publicData?: Record; point: { x: number; y: number }; }; export type InteractionResponsePayloads = { /** Recorded in session history and used for touch visualization. */ result: Record; - /** The wire payload returned to the client. */ + /** The public payload returned to the client. */ responseData: Record; }; @@ -86,7 +86,7 @@ export function buildInteractionResponseData(params: { extra: commonExtra, }); const responseData = buildTouchPayload({ - data: source.wireData, + data: source.publicData, fallbackX: source.point.x, fallbackY: source.point.y, referenceFrame, @@ -111,7 +111,7 @@ export function buildInteractionResponseData(params: { extra: commonExtra, }); const responseData = buildTouchPayload({ - data: source.wireData, + data: source.publicData, fallbackX: result.point?.x, fallbackY: result.point?.y, referenceFrame, diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 850d88a44..db2a804b2 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -1,10 +1,10 @@ import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts'; import { - projectInteractionWireData, - type InteractionWireEchoCommand, -} from '../../core/interaction-wire-projection.ts'; + transformInteractionResponseData, + type InteractionResponseDataTransformCommand, +} from '../../core/interaction-response-data-transform.ts'; import { isApplePlatform, publicPlatformString } from '../../kernel/device.ts'; -import { projectAppleRunnerWireResult } from '../../platforms/apple/core/runner/runner-result-projection.ts'; +import { normalizeAppleRunnerResultForResponse } from '../../platforms/apple/core/runner/runner-result-response-normalization.ts'; import { buttonTag, getClickButtonValidationError, @@ -193,7 +193,7 @@ async function dispatchTargetedTouchViaRuntime( session, result, staleRefsWarning, - wireData: projectTouchWireData({ + publicData: transformTouchResponseData({ session, command: command === 'longpress' ? undefined : command, flags: req.flags, @@ -259,10 +259,10 @@ async function buildTargetedTouchResponsePayloads(params: { session: SessionState; result: TargetedTouchResult; staleRefsWarning: string | undefined; - wireData?: Record; + publicData?: Record; extra: Record; }): Promise { - const { params: handlerParams, session, result, wireData, extra } = params; + const { params: handlerParams, session, result, publicData, extra } = params; const referenceFrame = result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ @@ -274,7 +274,7 @@ async function buildTargetedTouchResponsePayloads(params: { }) : readSnapshotNodesReferenceFrame(session.snapshot?.nodes ?? []); return buildInteractionResponseData({ - source: { kind: 'runtime', result, wireData }, + source: { kind: 'runtime', result, publicData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, @@ -394,14 +394,14 @@ async function dispatchDirectIosSelectorInteraction(params: { })) ?? {}; const actionFinishedAt = Date.now(); const point = readPointFromDirectSelectorTapResult(data); - const wireData = projectTouchWireData({ + const publicData = transformTouchResponseData({ session, - command: readInteractionWireEchoCommand(handlerParams.req.command, command), + command: readInteractionResponseDataTransformCommand(handlerParams.req.command, command), flags: handlerParams.req.flags, data, }); const { result, responseData } = buildInteractionResponseData({ - source: { kind: 'runner-payload', targetKind: 'selector', data, wireData, point }, + source: { kind: 'runner-payload', targetKind: 'selector', data, publicData, point }, referenceFrame: readReferenceFrameFromDirectSelectorTapResult(data), extra: { ...extra, @@ -442,27 +442,27 @@ async function dispatchDirectIosSelectorInteraction(params: { } } -function projectTouchWireData(params: { +function transformTouchResponseData(params: { session: SessionState; - command?: InteractionWireEchoCommand; + command?: InteractionResponseDataTransformCommand; flags: CommandFlags | undefined; data: Record | undefined; }): Record | undefined { const base = isApplePlatform(params.session.device.platform) - ? projectAppleRunnerWireResult(params.data) + ? normalizeAppleRunnerResultForResponse(params.data) : params.data; if (!params.command) return base; - return projectInteractionWireData( - params.command, - params.flags as Record | undefined, - base, - ); + return transformInteractionResponseData({ + command: params.command, + input: params.flags as Record | undefined, + data: base, + }); } -function readInteractionWireEchoCommand( +function readInteractionResponseDataTransformCommand( requestCommand: string, dispatchCommand: 'press' | 'fill', -): InteractionWireEchoCommand { +): InteractionResponseDataTransformCommand { if (requestCommand === 'click' || requestCommand === 'press' || requestCommand === 'fill') { return requestCommand; } @@ -613,7 +613,7 @@ function buildFillResponsePayloads(params: { source: { kind: 'runtime', result, - wireData: projectTouchWireData({ + publicData: transformTouchResponseData({ session, command: 'fill', flags: params.flags, diff --git a/src/mcp/command-output-schemas.ts b/src/mcp/command-output-schemas.ts index 39bd57330..3dcfebf1e 100644 --- a/src/mcp/command-output-schemas.ts +++ b/src/mcp/command-output-schemas.ts @@ -64,12 +64,12 @@ type InteractionExtra = { }; /** - * Canonical interaction wire response built by buildInteractionResponseData: + * Canonical interaction response data built by buildInteractionResponseData: * shared target/coordinate/evidence fields plus per-command extras. The runtime * result still has richer internal node/backend data; this schema documents the * JSON payload returned to clients. */ -function interactionWireResultSchema(extra: InteractionExtra = {}): JsonSchema { +function interactionResponseDataSchema(extra: InteractionExtra = {}): JsonSchema { const extraProperties = extra.properties ?? {}; const extraRequired = extra.required ?? []; return objectSchema( @@ -188,8 +188,8 @@ const targetShutdownResultSchema: JsonSchema = objectSchema( ); export const COMMAND_OUTPUT_SCHEMAS = { - // buildInteractionResponseData wire payloads for interaction commands. - press: interactionWireResultSchema({ + // buildInteractionResponseData public payloads for interaction commands. + press: interactionResponseDataSchema({ properties: { evidence: interactionEvidenceSchema, settle: settleObservationSchema, @@ -201,7 +201,7 @@ export const COMMAND_OUTPUT_SCHEMAS = { doubleTap: booleanSchema('Whether the command requested a double-tap action.'), }, }), - fill: interactionWireResultSchema({ + fill: interactionResponseDataSchema({ properties: { text: stringSchema('Text submitted to the field.'), delayMs: numberSchema('Delay between typed characters in milliseconds.'), @@ -210,7 +210,7 @@ export const COMMAND_OUTPUT_SCHEMAS = { }, required: ['text'], }), - longpress: interactionWireResultSchema({ + longpress: interactionResponseDataSchema({ properties: { durationMs: numberSchema(), settle: settleObservationSchema, diff --git a/src/platforms/apple/core/__tests__/runner-result-projection.test.ts b/src/platforms/apple/core/__tests__/runner-result-response-normalization.test.ts similarity index 69% rename from src/platforms/apple/core/__tests__/runner-result-projection.test.ts rename to src/platforms/apple/core/__tests__/runner-result-response-normalization.test.ts index 1378514ba..23f7ee74f 100644 --- a/src/platforms/apple/core/__tests__/runner-result-projection.test.ts +++ b/src/platforms/apple/core/__tests__/runner-result-response-normalization.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { projectAppleRunnerWireResult } from '../runner/runner-result-projection.ts'; +import { normalizeAppleRunnerResultForResponse } from '../runner/runner-result-response-normalization.ts'; -describe('projectAppleRunnerWireResult', () => { +describe('normalizeAppleRunnerResultForResponse', () => { test('removes runner diagnostics while preserving public fields', () => { expect( - projectAppleRunnerWireResult({ + normalizeAppleRunnerResultForResponse({ completedSteps: 2, count: 1, currentUptimeMs: 123, diff --git a/src/platforms/apple/core/runner/runner-result-projection.ts b/src/platforms/apple/core/runner/runner-result-response-normalization.ts similarity index 89% rename from src/platforms/apple/core/runner/runner-result-projection.ts rename to src/platforms/apple/core/runner/runner-result-response-normalization.ts index 8e8765be6..41094540c 100644 --- a/src/platforms/apple/core/runner/runner-result-projection.ts +++ b/src/platforms/apple/core/runner/runner-result-response-normalization.ts @@ -7,7 +7,7 @@ const APPLE_RUNNER_DIAGNOSTIC_RESULT_FIELDS = [ const appleRunnerDiagnosticResultFields = new Set(APPLE_RUNNER_DIAGNOSTIC_RESULT_FIELDS); -export function projectAppleRunnerWireResult( +export function normalizeAppleRunnerResultForResponse( data: Record | undefined, ): Record | undefined { if (!data) return undefined; From af046a67b992079f47481e371dff41999def08bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 8 Jul 2026 12:57:17 +0200 Subject: [PATCH 4/4] test: guard response transform field ownership --- .../command-surface-metadata.test.ts | 24 ++++++++++++++++++- src/core/command-descriptor/registry.ts | 21 +++++++++++++++- .../interaction-response-data-transform.ts | 13 ++++++++-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index 186e6a240..4dc491d95 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { listMcpExposedCommandNames } from '../../core/command-descriptor/registry.ts'; +import { + listCommandResponseDataTransforms, + listMcpExposedCommandNames, +} from '../../core/command-descriptor/registry.ts'; import { listCommandMetadata, listCommandMetadataNames, @@ -49,6 +52,25 @@ test('common command input accepts web platform selector', () => { assert.equal(input.platform, 'web'); }); +test('command response data transforms reference command input fields', () => { + const metadataByName = new Map[number]>( + listCommandMetadata().map((metadata) => [metadata.name, metadata] as const), + ); + + for (const { command, transform } of listCommandResponseDataTransforms()) { + const metadata = metadataByName.get(command); + assert.ok(metadata, `${command} response data transform must have command metadata`); + + const inputFields = new Set(Object.keys(metadata.inputSchema.properties ?? {})); + for (const field of Object.keys(transform.fields)) { + assert.ok( + inputFields.has(field), + `${command} response data transform field ${field} must be declared by command input metadata`, + ); + } + } +}); + test('command family facets expose one complete metadata and executable surface', () => { const familyNames = commandFamilies.map((family) => family.name); assert.deepEqual(familyNames, [...new Set(familyNames)], 'command family names must be unique'); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index d84fff891..6b3f77a35 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -141,7 +141,6 @@ const TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM = { const FILL_INTERACTION_RESPONSE_DATA_TRANSFORM = { fields: { - ...TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM.fields, delayMs: { defaultValue: 0 }, }, } as const satisfies CommandResponseDataTransform; @@ -840,3 +839,23 @@ export function resolveCommandResponseDataTransform( if (command === undefined) return undefined; return RESPONSE_DATA_TRANSFORM_BY_COMMAND.get(command); } + +export function listCommandResponseDataTransforms(): Array<{ + command: string; + transform: CommandResponseDataTransform; +}> { + return Array.from(RESPONSE_DATA_TRANSFORM_BY_COMMAND, ([command, transform]) => ({ + command, + transform, + })); +} + +export function listCommandResponseDataTransformFieldNames(): string[] { + return [ + ...new Set( + Array.from(RESPONSE_DATA_TRANSFORM_BY_COMMAND.values()).flatMap((transform) => + Object.keys(transform.fields), + ), + ), + ].sort(); +} diff --git a/src/core/interaction-response-data-transform.ts b/src/core/interaction-response-data-transform.ts index 972964963..678a2c5fd 100644 --- a/src/core/interaction-response-data-transform.ts +++ b/src/core/interaction-response-data-transform.ts @@ -1,8 +1,13 @@ -import { resolveCommandResponseDataTransform } from './command-descriptor/registry.ts'; +import { + listCommandResponseDataTransformFieldNames, + resolveCommandResponseDataTransform, +} from './command-descriptor/registry.ts'; import type { ResponseDataFieldTransform } from './command-descriptor/types.ts'; export type InteractionResponseDataTransformCommand = 'click' | 'press' | 'fill'; +const controlledResponseDataFieldNames = new Set(listCommandResponseDataTransformFieldNames()); + export function transformInteractionResponseData(params: { command: InteractionResponseDataTransformCommand; input: Record | undefined; @@ -12,6 +17,7 @@ export function transformInteractionResponseData(params: { fields: readCommandResponseDataTransformFields(params.command), input: params.input ?? {}, data: params.data, + controlledFields: controlledResponseDataFieldNames, }); } @@ -29,8 +35,11 @@ function applyResponseDataFieldTransforms(params: { fields: Record; input: Record; data: Record | undefined; + controlledFields: ReadonlySet; }): Record { - const transformed = { ...(params.data ?? {}) }; + const transformed = Object.fromEntries( + Object.entries(params.data ?? {}).filter(([key]) => !params.controlledFields.has(key)), + ); for (const [key, field] of Object.entries(params.fields)) { const value = params.input[key] === undefined ? field.defaultValue : params.input[key]; if (value === undefined || (field.omitDefault === true && value === field.defaultValue)) {