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/__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/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 81a99d984..6b3f77a35 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -14,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; @@ -125,6 +129,22 @@ const SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY: CommandTimeoutPolicy = { onTimeout: 'preserve-daemon', }; +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 CommandResponseDataTransform; + +const FILL_INTERACTION_RESPONSE_DATA_TRANSFORM = { + fields: { + delayMs: { defaultValue: 0 }, + }, +} as const satisfies CommandResponseDataTransform; + function interactionTimeoutPolicy(command: string): CommandTimeoutPolicy { return resolvePostActionObservationSupport(command) !== undefined ? SETTLE_FLAG_PRESERVE_DAEMON_TIMEOUT_POLICY @@ -516,6 +536,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), + responseDataTransform: TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM, batchable: true, }, { @@ -524,6 +545,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), + responseDataTransform: FILL_INTERACTION_RESPONSE_DATA_TRANSFORM, batchable: true, }, { @@ -540,6 +562,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), + responseDataTransform: TOUCH_INTERACTION_RESPONSE_DATA_TRANSFORM, batchable: true, }, { @@ -775,6 +798,15 @@ const TIMEOUT_POLICY_BY_COMMAND: ReadonlyMap = new commandDescriptors.map((descriptor) => [descriptor.name, descriptor.timeoutPolicy]), ); +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, ): PostActionObservationSupport | undefined { @@ -800,3 +832,30 @@ 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 resolveCommandResponseDataTransform( + command: string | undefined, +): CommandResponseDataTransform | undefined { + 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/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index 1a741917e..855d68951 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -2,6 +2,15 @@ import type { CommandCapability } from '../capabilities.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 @@ -81,6 +90,10 @@ export type CommandTimeoutPolicy = { * commands that support `--settle`/`--verify`; consumed by * command surfaces and timeout policy instead of repeated * command-name lists. + * - `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 @@ -95,4 +108,5 @@ export type CommandDescriptor = { mcpExposed: boolean; timeoutPolicy: CommandTimeoutPolicy; postActionObservation?: PostActionObservationSupport; + 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..678a2c5fd --- /dev/null +++ b/src/core/interaction-response-data-transform.ts @@ -0,0 +1,52 @@ +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; + data: Record | undefined; +}): Record { + return applyResponseDataFieldTransforms({ + fields: readCommandResponseDataTransformFields(params.command), + input: params.input ?? {}, + data: params.data, + controlledFields: controlledResponseDataFieldNames, + }); +} + +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; + controlledFields: ReadonlySet; +}): Record { + 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)) { + delete transformed[key]; + continue; + } + transformed[key] = value; + } + return Object.fromEntries(Object.entries(transformed).filter(([, value]) => value !== undefined)); +} diff --git a/src/daemon/handlers/interaction-touch-response.ts b/src/daemon/handlers/interaction-touch-response.ts index 305069e33..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,6 +26,7 @@ export type InteractionResponseSource = | { kind: 'runtime'; result: InteractionRuntimeResult; + publicData?: Record; } | { // Direct iOS selector dispatch: no runtime result exists, only the raw @@ -33,13 +34,14 @@ export type InteractionResponseSource = kind: 'runner-payload'; targetKind: InteractionRuntimeResult['kind']; data: 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; }; @@ -84,7 +86,7 @@ export function buildInteractionResponseData(params: { extra: commonExtra, }); const responseData = buildTouchPayload({ - data: sanitizeWireBackendData(source.data), + data: source.publicData, 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.publicData, 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..db2a804b2 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 { + transformInteractionResponseData, + type InteractionResponseDataTransformCommand, +} from '../../core/interaction-response-data-transform.ts'; +import { isApplePlatform, publicPlatformString } from '../../kernel/device.ts'; +import { normalizeAppleRunnerResultForResponse } from '../../platforms/apple/core/runner/runner-result-response-normalization.ts'; import { buttonTag, getClickButtonValidationError, @@ -188,6 +193,12 @@ async function dispatchTargetedTouchViaRuntime( session, result, staleRefsWarning, + publicData: transformTouchResponseData({ + 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; + publicData?: Record; extra: Record; }): Promise { - const { params: handlerParams, session, result, extra } = params; + const { params: handlerParams, session, result, publicData, 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, publicData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, @@ -382,8 +394,14 @@ async function dispatchDirectIosSelectorInteraction(params: { })) ?? {}; const actionFinishedAt = Date.now(); const point = readPointFromDirectSelectorTapResult(data); + const publicData = transformTouchResponseData({ + session, + command: readInteractionResponseDataTransformCommand(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, publicData, point }, referenceFrame: readReferenceFrameFromDirectSelectorTapResult(data), extra: { ...extra, @@ -424,6 +442,33 @@ async function dispatchDirectIosSelectorInteraction(params: { } } +function transformTouchResponseData(params: { + session: SessionState; + command?: InteractionResponseDataTransformCommand; + flags: CommandFlags | undefined; + data: Record | undefined; +}): Record | undefined { + const base = isApplePlatform(params.session.device.platform) + ? normalizeAppleRunnerResultForResponse(params.data) + : params.data; + if (!params.command) return base; + return transformInteractionResponseData({ + command: params.command, + input: params.flags as Record | undefined, + data: base, + }); +} + +function readInteractionResponseDataTransformCommand( + requestCommand: string, + dispatchCommand: 'press' | 'fill', +): InteractionResponseDataTransformCommand { + 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, + publicData: transformTouchResponseData({ + 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..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,15 +188,20 @@ 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, 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({ + fill: interactionResponseDataSchema({ properties: { text: stringSchema('Text submitted to the field.'), delayMs: numberSchema('Delay between typed characters in milliseconds.'), @@ -205,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-response-normalization.test.ts b/src/platforms/apple/core/__tests__/runner-result-response-normalization.test.ts new file mode 100644 index 000000000..23f7ee74f --- /dev/null +++ b/src/platforms/apple/core/__tests__/runner-result-response-normalization.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest'; +import { normalizeAppleRunnerResultForResponse } from '../runner/runner-result-response-normalization.ts'; + +describe('normalizeAppleRunnerResultForResponse', () => { + test('removes runner diagnostics while preserving public fields', () => { + expect( + normalizeAppleRunnerResultForResponse({ + 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-response-normalization.ts b/src/platforms/apple/core/runner/runner-result-response-normalization.ts new file mode 100644 index 000000000..41094540c --- /dev/null +++ b/src/platforms/apple/core/runner/runner-result-response-normalization.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 normalizeAppleRunnerResultForResponse( + data: Record | undefined, +): Record | undefined { + if (!data) return undefined; + return Object.fromEntries( + Object.entries(data).filter(([key]) => !appleRunnerDiagnosticResultFields.has(key)), + ); +}