Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -625,14 +625,25 @@ extension RunnerTests {
)
)
}
if let durationMs = command.durationMs,
durationMs.isFinite == false || durationMs < 0 || durationMs > 10000
{
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "scroll durationMs must be between 0 and 10000"
)
)
}
return executeDragGesture(
activeApp: activeApp,
x: frame.minX + plan.x1,
y: frame.minY + plan.y1,
x2: frame.minX + plan.x2,
y2: frame.minY + plan.y2,
durationMs: nil,
synthesized: false,
durationMs: command.durationMs,
synthesized: command.durationMs != nil,
message: "scrolled"
)
case .desktopScroll:
Expand Down Expand Up @@ -988,8 +999,7 @@ extension RunnerTests {
/// Shared drag execution for `.drag` and the fused `.scroll`. Mirrors the original `.drag` body
/// exactly: keyboardAvoidingDragPoints -> resolvedDragVisualizationFrame -> synthesized branch
/// (16-10000ms clamp) or non-synthesized dragAt with coordinateDragHoldDuration ->
/// gestureResponse(.drag). `.scroll` always passes synthesized: false, pinning the same
/// non-synthesized drag path scroll's drag used today.
/// gestureResponse(.drag). `.scroll` uses the synthesized path only when a duration is requested.
private func executeDragGesture(
activeApp: XCUIApplication,
x: Double,
Expand Down
13 changes: 10 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
prepareDaemonCommandRequest,
type DaemonCommandName,
} from './commands/command-projection.ts';
import { buildRequestFlags } from './commands/command-flags.ts';
import { throwDaemonError } from './daemon-error.ts';
import {
buildFlags,
buildMeta,
normalizeDeployResult,
normalizeDevice,
Expand Down Expand Up @@ -45,6 +45,7 @@ import type {
} from './client-types.ts';
import { readSerializedSnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts';
import { readSnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
import type { CommandFlags } from './core/dispatch-context.ts';

export function createAgentDeviceClient(
config: AgentDeviceClientConfig = {},
Expand All @@ -56,13 +57,14 @@ export function createAgentDeviceClient(
command: string,
positionals: string[] = [],
options: InternalRequestOptions = {},
metadataFlags?: Partial<CommandFlags>,
): Promise<Record<string, unknown>> => {
const merged = mergeClientOptions(config, options);
const response = await transport({
session: resolveSessionName(merged.session),
command,
positionals,
flags: buildFlags(merged),
flags: buildRequestFlags(merged, metadataFlags),
runtime: merged.runtime,
meta: buildMeta(merged),
});
Expand All @@ -83,7 +85,12 @@ export function createAgentDeviceClient(
options: InternalRequestOptions = {},
): Promise<T> => {
const request = prepareDaemonCommandRequest(command, options);
return (await execute(request.command, request.positionals, request.options)) as T;
return (await execute(
request.command,
request.positionals,
request.options,
request.metadataFlags,
)) as T;
};

const resolveRequestSession = (options: InternalRequestOptions = {}) =>
Expand Down
23 changes: 23 additions & 0 deletions src/commands/__tests__/command-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';

import type { InternalRequestOptions } from '../../client-types.ts';
import { findCommandMetadata } from '../command-metadata.ts';
import { readMetadataCommandFlags } from '../command-flags.ts';

test('readMetadataCommandFlags projects CLI-backed command fields and skips positionals', () => {
const metadata = findCommandMetadata('scroll');
assert.ok(metadata);

const flags = readMetadataCommandFlags(metadata, {
direction: 'down',
amount: 0.4,
pixels: 200,
durationMs: 50,
} as InternalRequestOptions);

assert.deepEqual(flags, {
pixels: 200,
durationMs: 50,
});
});
4 changes: 2 additions & 2 deletions src/commands/batch/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import {
STRUCTURED_BATCH_COMMAND_NAMES,
readStructuredBatchCommandName,
} from '../../batch-policy.ts';
import { buildFlags } from '../../client-normalizers.ts';
import type { DaemonBatchStep } from '../../core/batch.ts';
import { AppError } from '../../utils/errors.ts';
import { request } from '../cli-grammar/common.ts';
import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts';
import { buildRequestFlags } from '../command-flags.ts';
import type { DaemonCommandName } from '../command-projection.ts';

const batchCommandNames = STRUCTURED_BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[];
Expand Down Expand Up @@ -57,7 +57,7 @@ function readBatchDaemonStep(
return {
command: prepared.command,
positionals: prepared.positionals,
flags: buildFlags(prepared.options),
flags: buildRequestFlags(prepared.options, prepared.metadataFlags),
runtime: runtime ?? prepared.options.runtime,
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/commands/cli-grammar/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { InteractionTarget, InternalRequestOptions } from '../../client-types.ts';
import type { CommandFlags } from '../../core/dispatch-context.ts';
import type { CliFlags } from '../../utils/cli-flags.ts';
import type { ClickButton } from '../../core/click-button.ts';
import type { DecodedFillTarget } from '../../core/interaction-positionals.ts';
Expand All @@ -8,6 +9,7 @@ export type DaemonCommandRequest = {
command: string;
positionals: string[];
options: InternalRequestOptions;
metadataFlags?: Partial<CommandFlags>;
};

type PointInput = {
Expand Down
40 changes: 40 additions & 0 deletions src/commands/command-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { buildFlags } from '../client-normalizers.ts';
import type { CommandFlags } from '../core/dispatch-context.ts';
import { getFlagDefinitions } from '../utils/cli-flags.ts';
import type { InternalRequestOptions } from '../client-types.ts';
import type { CommandMetadata } from './command-contract.ts';

const CLI_FLAG_KEYS: ReadonlySet<string> = new Set(
getFlagDefinitions().map((definition) => definition.key),
);

export function buildRequestFlags(
options: InternalRequestOptions,
metadataFlags: Partial<CommandFlags> | undefined,
): CommandFlags {
return {
...buildFlags(options),
...metadataFlags,
};
}

export function readMetadataCommandFlags(
metadata: Pick<CommandMetadata<string, unknown>, 'inputSchema'>,
options: InternalRequestOptions,
): Partial<CommandFlags> {
const properties = metadata.inputSchema.properties;
if (!properties) return {};

const flags: Record<string, unknown> = {};
const record = options as Record<string, unknown>;
for (const key of Object.keys(properties)) {
if (!CLI_FLAG_KEYS.has(key)) continue;
const value = record[key];
if (isMetadataFlagValue(value)) flags[key] = value;
}
return flags as Partial<CommandFlags>;
}

function isMetadataFlagValue(value: unknown): value is boolean | number | string {
return typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string';
}
22 changes: 20 additions & 2 deletions src/commands/command-projection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createBatchDaemonWriter, type BatchCommandName } from './batch/index.ts';
import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts';
import { findCommandMetadata } from './command-metadata.ts';
import { readMetadataCommandFlags } from './command-flags.ts';
import { listCommandFamilyDaemonWriters } from './family/registry.ts';
import { AppError } from '../utils/errors.ts';

Expand All @@ -27,7 +28,11 @@ function prepareBatchDaemonCommandRequest(
throw new Error(`Missing command metadata for batch command: ${command}`);
}
try {
return writer(metadata.readInput(input) as CommandInput);
return prepareRequestWithMetadataFlags(
writer,
metadata,
metadata.readInput(input) as CommandInput,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new AppError(
Expand All @@ -47,5 +52,18 @@ export function prepareDaemonCommandRequest(
if (!writer) {
throw new Error(`Missing daemon writer for command: ${command}`);
}
return writer(input);
const metadata = findCommandMetadata(command);
return prepareRequestWithMetadataFlags(writer, metadata, input);
}

function prepareRequestWithMetadataFlags(
writer: DaemonWriter,
metadata: ReturnType<typeof findCommandMetadata>,
input: CommandInput,
): DaemonCommandRequest {
const request = writer(input);
return {
...request,
...(metadata ? { metadataFlags: readMetadataCommandFlags(metadata, request.options) } : {}),
};
}
4 changes: 2 additions & 2 deletions src/commands/interaction/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import {
} from '../command-input.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';
import { CLICK_BUTTONS } from '../../core/click-button.ts';
import { SCROLL_DURATION_MAX_MS } from '../../core/scroll-command.ts';
import {
SCROLL_DURATION_MAX_MS,
SCROLL_DIRECTIONS,
SWIPE_PATTERNS,
SWIPE_PRESETS,
Expand Down Expand Up @@ -115,7 +115,7 @@ const scrollFields = {
direction: requiredField(enumField(SCROLL_INPUT_DIRECTIONS)),
amount: numberField('Platform scroll amount.'),
pixels: integerField('Pixel scroll amount.', { min: 0 }),
durationMs: integerField('Desktop scroll duration in milliseconds.', {
durationMs: integerField('Scroll duration in milliseconds when the backend supports pacing.', {
min: 0,
max: SCROLL_DURATION_MAX_MS,
}),
Expand Down
38 changes: 9 additions & 29 deletions src/commands/interaction/runtime/gestures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { centerOfRect } from '../../../utils/snapshot.ts';
import {
buildSwipePresetGesturePlan,
parseSwipePreset,
SCROLL_DURATION_MAX_MS,
type GestureReferenceFrame,
type ScrollDirection,
type SwipePreset,
} from '../../../core/scroll-gesture.ts';
import {
assertExclusiveScrollDistanceInputs,
honoredScrollDurationMs,
normalizeScrollDurationMs,
} from '../../../core/scroll-command.ts';
import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts';
import { requireIntInRange } from '../../../utils/validation.ts';
import { successText } from '../../../utils/success-text.ts';
Expand Down Expand Up @@ -186,14 +190,11 @@ export const scrollCommand: RuntimeCommand<ScrollCommandOptions, ScrollCommandRe
const target = resolveScrollDirection(options.direction);
const amount = normalizeOptionalPositiveNumber(options.amount, 'scroll amount');
const pixels = normalizeOptionalPositiveInteger(options.pixels, 'scroll pixels');
const durationMs = normalizeOptionalNonNegativeInteger(
options.durationMs,
'scroll durationMs',
SCROLL_DURATION_MAX_MS,
const durationMs = normalizeScrollDurationMs(options.durationMs);
assertExclusiveScrollDistanceInputs(
{ amount, pixels },
'scroll accepts either amount or pixels, not both',
);
if (amount !== undefined && pixels !== undefined) {
throw new AppError('INVALID_ARGS', 'scroll accepts either amount or pixels, not both');
}

const resolved = await resolveScrollTarget(runtime, options);
const backendTarget =
Expand Down Expand Up @@ -241,12 +242,6 @@ export const scrollCommand: RuntimeCommand<ScrollCommandOptions, ScrollCommandRe
};
};

function honoredScrollDurationMs(
backendResult: Record<string, unknown> | undefined,
): number | undefined {
return typeof backendResult?.durationMs === 'number' ? backendResult.durationMs : undefined;
}

export const swipeCommand: RuntimeCommand<SwipeCommandOptions, SwipeCommandResult> = async (
runtime,
options,
Expand Down Expand Up @@ -534,21 +529,6 @@ function normalizeOptionalPositiveInteger(
return value;
}

function normalizeOptionalNonNegativeInteger(
value: number | undefined,
field: string,
max?: number,
): number | undefined {
if (value === undefined) return undefined;
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
throw new AppError('INVALID_ARGS', `${field} must be a non-negative integer`);
}
if (max !== undefined && value > max) {
throw new AppError('INVALID_ARGS', `${field} must be at most ${max}`);
}
return value;
}

function resolveSnapshotViewport(nodes: SnapshotState['nodes']): Rect {
const visibleRects = nodes
.filter((node) => isNodeVisibleInEffectiveViewport(node, nodes))
Expand Down
Loading
Loading