@loadmill/droid-cua 2.33.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,7 @@ Desktop app support:
79
79
 
80
80
  **All platforms:**
81
81
  - Loadmill sign-in for the Loadmill models, or an OpenAI API key for the OpenAI models
82
+ - Node.js 20 or newer for CLI use
82
83
 
83
84
  **Android:**
84
85
  - Android Debug Bridge (ADB)
@@ -180,6 +181,15 @@ droid-cua --avd adb:emulator-5554 --instructions tests
180
181
  # Headless iOS simulator run
181
182
  droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
182
183
 
184
+ # List running desktop applications and their window IDs (macOS or Windows)
185
+ droid-cua --target desktop --list-desktop-apps
186
+
187
+ # Control one already-running desktop application window
188
+ droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-window 123 --instructions tests/editor.dcua
189
+
190
+ # Explicitly use cursor-free accessibility input instead of the default physical input
191
+ droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-input accessibility --instructions tests/editor.dcua
192
+
183
193
  # Headless run with prompt-parity config
184
194
  droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --config ci/droid-cua.json
185
195
 
@@ -246,6 +256,11 @@ Supported CLI options include:
246
256
  - `--app`
247
257
  - `--list-devices`
248
258
  - `--browser`
259
+ - `--target` (`mobile`, `web`, or `desktop`)
260
+ - `--desktop-app` (desktop application ID or exact name)
261
+ - `--desktop-window` (required when the application has multiple visible windows)
262
+ - `--desktop-input` (`physical` by default; use `accessibility` for cursor-free input)
263
+ - `--list-desktop-apps`
249
264
  - `--instructions`
250
265
  - `--config`
251
266
  - `--cua-model`
@@ -268,6 +283,8 @@ Supported CLI options include:
268
283
  `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
269
284
  mode and require `OPENAI_API_KEY`.
270
285
 
286
+ Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use visual input by default: they capture the selected window and send physical pointer and keyboard input at the model's coordinates. Use `--desktop-input accessibility` for cursor-free CLI input instead. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. Windows targets must run at the same integrity level as Droid; a non-elevated Droid process cannot inject input into an elevated application.
287
+
271
288
  Config and precedence rules:
272
289
  - Use `--config <file>` to supply prompt-affecting settings for headless runs.
273
290
  - CLI flags override config file values.
@@ -293,7 +310,7 @@ HTML report (`--report`):
293
310
  - Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
294
311
  - With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
295
312
  - In the desktop app the same report is available from a run's **Share → Download HTML report** button.
296
- - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 browser replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
313
+ - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 web and desktop replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
297
314
  - Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. The Loadmill run record also includes the resolved Droid version, model, provider, Agent Pace, Aim Assist, Context Optimization, and target environment. Use `--no-cloud-report` to disable artifact upload; the Droid run record is still reported.
298
315
 
299
316
  ```sh
@@ -0,0 +1,5 @@
1
+ # Third-Party Notices
2
+
3
+ Desktop control uses `@zavora-ai/computer-use-mcp` version 7.1.0.
4
+
5
+ Copyright (c) Zavora AI contributors. Licensed under the MIT License. The dependency's complete license text is distributed with its npm package.
package/build/index.js CHANGED
@@ -43,6 +43,7 @@ import { resolveRunPool } from "./src/device/cloud/loadmill-cloud/run-pool.js";
43
43
  import { ARTIFACT_READY_STATUSES } from "./src/device/cloud/loadmill-cloud/run-status.js";
44
44
  import { formatDuration } from "./src/core/report/run-log-model.js";
45
45
  import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
46
+ import { createDesktopController, listDesktopApplications } from "./src/desktop/computer-use.js";
46
47
  dotenv.config();
47
48
  process.once("SIGTERM", async () => {
48
49
  process.exitCode = 143;
@@ -310,8 +311,20 @@ async function main() {
310
311
  console.log(`Run with: --device-name "<name>" --os-version "<osVersion>"`);
311
312
  process.exit(0);
312
313
  }
314
+ if (targetConfig.listDesktopApps) {
315
+ const applications = await listDesktopApplications({ excludePids: [process.pid] });
316
+ for (const application of applications) {
317
+ console.log(`${application.displayName} — ${application.bundleId} (pid ${application.pid})`);
318
+ for (const window of application.windows) {
319
+ console.log(` ${window.windowId}: ${window.title || "Untitled"}`);
320
+ }
321
+ }
322
+ console.log(`\n${applications.length} desktop applications with visible windows.`);
323
+ process.exit(0);
324
+ }
313
325
  const isHeadlessInstructionsRun = Boolean(instructionsFile);
314
326
  const isBrowserTarget = targetConfig.target === "web";
327
+ const isDesktopTarget = targetConfig.target === "desktop";
315
328
  const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
316
329
  const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
317
330
  const isLoadmillCloudTarget = targetConfig.deviceSource === "loadmill-cloud";
@@ -326,6 +339,7 @@ async function main() {
326
339
  let loadmillCloudBrowserSessionConnected = false;
327
340
  let loadmillCloudBrowserTeardown = null;
328
341
  let loadmillCloudStatusLine = null;
342
+ let desktopController = null;
329
343
  const headlessDebug = createHeadlessDebugArtifacts({
330
344
  cwd: process.cwd(),
331
345
  enabled: isHeadlessInstructionsRun && debugMode
@@ -338,6 +352,9 @@ async function main() {
338
352
  if (isBrowserTarget && !isHeadlessInstructionsRun) {
339
353
  throw new Error("--target web is only supported with --instructions headless execution.");
340
354
  }
355
+ if (isDesktopTarget && !isHeadlessInstructionsRun) {
356
+ throw new Error("--target desktop is only supported with --instructions headless execution.");
357
+ }
341
358
  if (isCloudTarget && !isHeadlessInstructionsRun) {
342
359
  throw new Error(`--device-source ${targetConfig.deviceSource} is only supported with --instructions headless execution.`);
343
360
  }
@@ -398,7 +415,7 @@ async function main() {
398
415
  }
399
416
  }
400
417
  // If no device specified, show interactive selection menu
401
- if (!isBrowserTarget && !isCloudTarget && !avdName && !platform) {
418
+ if (!isBrowserTarget && !isDesktopTarget && !isCloudTarget && !avdName && !platform) {
402
419
  const selection = await selectDevice();
403
420
  platform = selection.platform;
404
421
  avdName = selection.deviceName;
@@ -506,6 +523,24 @@ async function main() {
506
523
  console.log(`Starting URL: ${deviceInfo.currentUrl}`);
507
524
  }
508
525
  }
526
+ else if (isDesktopTarget) {
527
+ desktopController = await createDesktopController({
528
+ app: targetConfig.desktopApp,
529
+ windowId: targetConfig.desktopWindow,
530
+ inputMode: targetConfig.desktopInput,
531
+ excludePids: [process.pid],
532
+ });
533
+ targetId = desktopController.deviceId;
534
+ deviceInfo = desktopController.deviceInfo;
535
+ console.log(`Attached to desktop window: ${deviceInfo.device_name}`);
536
+ console.log(`Model sees window: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
537
+ console.log(`Desktop input: ${deviceInfo.desktopInputMode === "physical" ? "physical OS pointer" : "cursor-free accessibility"}`);
538
+ process.once("SIGINT", async () => {
539
+ console.log("\nStopping desktop run...");
540
+ await desktopController?.close().catch(() => { });
541
+ process.exit(130);
542
+ });
543
+ }
509
544
  else if (isLambdaTestTarget) {
510
545
  lambdaTestCredentials = resolveLambdaTestCredentials(process.env);
511
546
  const lambdaTestBuildName = createLambdaTestCliBuildName();
@@ -787,8 +822,10 @@ async function main() {
787
822
  recordScreenshots,
788
823
  screenshotDir,
789
824
  isBrowserTarget,
825
+ isDesktopTarget,
826
+ desktopController,
790
827
  isCloudTarget,
791
- runTarget: { isBrowserTarget, isCloudTarget },
828
+ runTarget: { isBrowserTarget, isDesktopTarget, isCloudTarget },
792
829
  report: reportConfig
793
830
  });
794
831
  exitCode = result.exitCode;
@@ -845,6 +882,10 @@ async function main() {
845
882
  console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
846
883
  }
847
884
  }
885
+ if (desktopController) {
886
+ await desktopController.close();
887
+ desktopController = null;
888
+ }
848
889
  }
849
890
  if (exitCode !== null) {
850
891
  process.exit(exitCode);
@@ -83,9 +83,10 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
83
83
  });
84
84
  }
85
85
  }
86
- export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
87
- const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
86
+ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
87
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
88
88
  const isBrowserRunTarget = Boolean(resolvedRunTarget.isBrowserTarget);
89
+ const isDesktopRunTarget = Boolean(resolvedRunTarget.isDesktopTarget);
89
90
  const isCloudRunTarget = Boolean(resolvedRunTarget.isCloudTarget);
90
91
  const sendCUARequest = async (requestArgs) => {
91
92
  const response = await runtimeContext.openAi.sendCUARequest({
@@ -101,7 +102,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
101
102
  toolCapabilities: {
102
103
  assert: true,
103
104
  navigate: isBrowserRunTarget,
104
- closeKeyboard: !isBrowserRunTarget,
105
+ closeKeyboard: !isBrowserRunTarget && !isDesktopRunTarget,
105
106
  loadmill: true,
106
107
  },
107
108
  secretsByKey: secrets?.secretsByKey || {},
@@ -130,6 +131,11 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
130
131
  engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
131
132
  engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
132
133
  }
134
+ else if (isDesktopRunTarget) {
135
+ engineOptions.getScreenshotAsBase64 = desktopController.getScreenshotAsBase64;
136
+ engineOptions.handleModelAction = desktopController.handleModelAction;
137
+ engineOptions.getCurrentPlatform = () => "desktop";
138
+ }
133
139
  if (isCloudRunTarget) {
134
140
  if (isBrowserRunTarget) {
135
141
  engineOptions.connectToBrowser = createRefuseReconnectConnectToBrowser();
@@ -142,7 +148,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
142
148
  }
143
149
  return engineOptions;
144
150
  }
145
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
151
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
146
152
  let executionMode = null;
147
153
  let loadmillSessionUser = null;
148
154
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
@@ -218,6 +224,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
218
224
  recordScreenshots,
219
225
  screenshotDir,
220
226
  isBrowserTarget,
227
+ isDesktopTarget,
228
+ desktopController,
221
229
  isCloudTarget,
222
230
  runTarget,
223
231
  onCuaResponse: (response) => {
@@ -428,8 +436,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
428
436
  throw error;
429
437
  }
430
438
  }
431
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
432
- const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
439
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
440
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
433
441
  const runConfiguration = buildDroidRunConfiguration({
434
442
  droidVersion,
435
443
  runtimeConfig: runtimeContext.config,
@@ -489,6 +497,8 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
489
497
  recordScreenshots,
490
498
  screenshotDir: testScreenshotDir,
491
499
  isBrowserTarget: resolvedRunTarget.isBrowserTarget,
500
+ isDesktopTarget: resolvedRunTarget.isDesktopTarget,
501
+ desktopController,
492
502
  isCloudTarget: resolvedRunTarget.isCloudTarget,
493
503
  runTarget: resolvedRunTarget,
494
504
  report,
@@ -4,13 +4,13 @@ function normalizeTarget(value) {
4
4
  return "mobile";
5
5
  }
6
6
  if (typeof value !== "string") {
7
- throw new Error("--target must be one of: mobile, web.");
7
+ throw new Error("--target must be one of: mobile, web, desktop.");
8
8
  }
9
9
  const normalized = value.trim().toLowerCase();
10
- if (normalized === "mobile" || normalized === "web") {
10
+ if (normalized === "mobile" || normalized === "web" || normalized === "desktop") {
11
11
  return normalized;
12
12
  }
13
- throw new Error("--target must be one of: mobile, web.");
13
+ throw new Error("--target must be one of: mobile, web, desktop.");
14
14
  }
15
15
  function normalizeSessionMode(value) {
16
16
  if (value == null || value === "") {
@@ -251,6 +251,53 @@ export function resolveHeadlessTarget(args = {}) {
251
251
  listDevices: false,
252
252
  };
253
253
  }
254
+ if (target === "desktop") {
255
+ if (avdName || platform || args.browser != null || args["session-mode"] != null) {
256
+ throw new Error("--avd, --platform, --browser, and --session-mode cannot be used with --target desktop.");
257
+ }
258
+ if (deviceSource !== "local") {
259
+ throw new Error("--device-source can only be used with --target mobile.");
260
+ }
261
+ assertNoCloudFlags(args);
262
+ if (args["list-devices"] != null) {
263
+ throw new Error("--list-devices can only be used with --target mobile.");
264
+ }
265
+ const listDesktopApps = args["list-desktop-apps"] != null;
266
+ const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
267
+ const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
268
+ const desktopWindow = rawWindow == null ? null : Number(rawWindow);
269
+ const desktopInput = hasNonEmptyString(args["desktop-input"]) ? args["desktop-input"].trim().toLowerCase() : "physical";
270
+ if (!listDesktopApps && !desktopApp) {
271
+ throw new Error("--desktop-app is required with --target desktop.");
272
+ }
273
+ if (rawWindow != null && (!Number.isInteger(desktopWindow) || desktopWindow < 0)) {
274
+ throw new Error("--desktop-window must be a non-negative integer window ID.");
275
+ }
276
+ if (desktopInput !== "accessibility" && desktopInput !== "physical") {
277
+ throw new Error('--desktop-input must be "accessibility" or "physical".');
278
+ }
279
+ return {
280
+ target,
281
+ sessionMode: null,
282
+ browserFamily: null,
283
+ deviceSource: "local",
284
+ avdName: null,
285
+ platform: null,
286
+ deviceName: null,
287
+ osVersion: null,
288
+ appPath: null,
289
+ cloudSessionId: null,
290
+ artifacts: null,
291
+ listDevices: false,
292
+ listDesktopApps,
293
+ desktopApp,
294
+ desktopWindow,
295
+ desktopInput,
296
+ };
297
+ }
298
+ if (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null) {
299
+ throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
300
+ }
254
301
  if (args["session-mode"] != null) {
255
302
  throw new Error("--session-mode can only be used with --target web.");
256
303
  }
@@ -19,7 +19,11 @@ export async function handleHelp(args, session, context) {
19
19
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
20
20
  addOutput({ type: 'info', text: '' });
21
21
  addOutput({ type: 'info', text: 'Options:' });
22
- addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default) or web' });
22
+ addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
23
+ addOutput({ type: 'info', text: ' --desktop-app <id> Running desktop app ID or exact name' });
24
+ addOutput({ type: 'info', text: ' --desktop-window <id> Window ID when the app has multiple windows' });
25
+ addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default) or cursor-free accessibility' });
26
+ addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
23
27
  addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
24
28
  addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
25
29
  addOutput({ type: 'info', text: ' --device-source <src> Mobile source: local (default) or lambdatest' });
@@ -19,6 +19,7 @@ import { handleAssertionToolCall } from "./tool-handlers/assert.js";
19
19
  import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
20
20
  import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
21
21
  import { handleTypeSensitiveTextToolCall } from "./tool-handlers/type-sensitive-text.js";
22
+ import { handleTypeSecretSlowlyToolCall, handleTypeTextSlowlyToolCall, } from "./tool-handlers/type-text-slowly.js";
22
23
  import { buildDebugContext, buildTextToolOutputItem, } from "./tool-handlers/utils.js";
23
24
  const HOLO_RESPONSE_ID_PREFIX = "resp_holo_";
24
25
  function isHoloResponseId(responseId) {
@@ -334,6 +335,10 @@ export class ExecutionEngine {
334
335
  return await handleLoadmillToolCall(handlerArgs);
335
336
  case "type_sensitive_text":
336
337
  return await handleTypeSensitiveTextToolCall(handlerArgs);
338
+ case "type_text_slowly":
339
+ return await handleTypeTextSlowlyToolCall(handlerArgs);
340
+ case "type_secret_slowly":
341
+ return await handleTypeSecretSlowlyToolCall(handlerArgs);
337
342
  case "test_design_complete":
338
343
  return await handleTestDesignCompleteToolCall(handlerArgs);
339
344
  default:
@@ -13,7 +13,11 @@ export class Session {
13
13
  this.browserName = targetInfo?.browserName || null;
14
14
  this.currentUrl = targetInfo?.currentUrl || null;
15
15
  this.sessionMode = targetInfo?.sessionMode || null;
16
- this.targetKind = targetInfo?.platform === "browser" ? "browser" : "mobile";
16
+ this.targetKind = targetInfo?.platform === "browser"
17
+ ? "browser"
18
+ : targetInfo?.platform === "desktop"
19
+ ? "desktop"
20
+ : "mobile";
17
21
  this.messages = [];
18
22
  this.previousResponseId = null;
19
23
  this.transcript = [];
@@ -1,13 +1,13 @@
1
1
  import path from "path";
2
2
  import { resolveEffectiveHelperModel } from "./runtime-config.js";
3
3
  import { buildTestDataImportMessages } from "../prompts/test-data-import.js";
4
- export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt"];
4
+ export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt", ".json", ".xml", ".md"];
5
5
  export function isSupportedTestDataImportPath(filePath) {
6
6
  return TEST_DATA_IMPORT_EXTENSIONS.includes(path.extname(String(filePath ?? "")).toLowerCase());
7
7
  }
8
8
  export function assertSupportedTestDataImportPath(filePath) {
9
9
  if (!isSupportedTestDataImportPath(filePath)) {
10
- throw new Error("Test data import supports CSV and TXT files only.");
10
+ throw new Error("Choose a CSV, TXT, JSON, XML, or Markdown file.");
11
11
  }
12
12
  }
13
13
  function parseTestDataImportContent(content) {
@@ -11,6 +11,9 @@ function parseDismissalPoint(argumentsText, deviceInfo = {}) {
11
11
  const hasX = Object.prototype.hasOwnProperty.call(parsed.value, "x");
12
12
  const hasY = Object.prototype.hasOwnProperty.call(parsed.value, "y");
13
13
  if (!hasX && !hasY) {
14
+ if (deviceInfo.platform?.trim().toLowerCase() === "ios") {
15
+ return { ok: false, message: "Invalid close_keyboard arguments: x and y are required on iOS." };
16
+ }
14
17
  return { ok: true, value: null };
15
18
  }
16
19
  if (!hasX || !hasY || !Number.isFinite(parsed.value.x) || !Number.isFinite(parsed.value.y)) {
@@ -0,0 +1,155 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CAPTURE_SOURCES } from "../capture-sources.js";
3
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
4
+ import { buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, parseJsonArguments, parseStringToolArgument, } from "./utils.js";
5
+ const SUPPORTED_TEXT_PATTERN = /^[\x20-\x7e]+$/;
6
+ export async function handleTypeTextSlowlyToolCall({ engine, callId, argumentsText, context = null, stepContext = null, }) {
7
+ if (engine.session?.deviceInfo?.platform !== "ios") {
8
+ return continueWithMessage(callId, "type_text_slowly is only supported for iOS runs.");
9
+ }
10
+ const parsed = parseLiteralText(argumentsText);
11
+ if (!parsed.ok) {
12
+ return continueWithMessage(callId, parsed.message);
13
+ }
14
+ return runSlowTyping({
15
+ engine,
16
+ callId,
17
+ context,
18
+ stepContext,
19
+ action: { type: "type", text: parsed.text, typingMode: "slow" },
20
+ resultText: "Typed text slowly.",
21
+ });
22
+ }
23
+ export async function handleTypeSecretSlowlyToolCall({ engine, callId, argumentsText, context = null, stepContext = null, }) {
24
+ if (engine.session?.deviceInfo?.platform !== "ios") {
25
+ return continueWithMessage(callId, "type_secret_slowly is only supported for iOS runs.");
26
+ }
27
+ const parsed = parseStringToolArgument(argumentsText, "secretKey", "type_secret_slowly");
28
+ if (!parsed.ok) {
29
+ return continueWithMessage(callId, parsed.message);
30
+ }
31
+ const resolved = resolveSecretAction(engine, parsed.value);
32
+ if (!resolved.ok) {
33
+ return continueWithMessage(callId, resolved.message);
34
+ }
35
+ return runSlowTyping({
36
+ engine,
37
+ callId,
38
+ context,
39
+ stepContext,
40
+ action: resolved.action,
41
+ resultText: `Typed secret slowly: ${parsed.value}.`,
42
+ });
43
+ }
44
+ async function runSlowTyping({ engine, callId, context, stepContext, action, resultText }) {
45
+ const typed = await executeSlowTyping(engine, action, context, stepContext);
46
+ if (!typed) {
47
+ return { kind: "fail", error: "Slow iOS typing failed." };
48
+ }
49
+ const { screenshotBase64, currentScreenshot } = await capturePostTypingScreenshot(engine, callId, context, stepContext);
50
+ return {
51
+ kind: "continue",
52
+ outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({
53
+ text: resultText,
54
+ imageBase64: screenshotBase64,
55
+ })),
56
+ currentScreenshot,
57
+ executedActions: [action],
58
+ };
59
+ }
60
+ function continueWithMessage(callId, message) {
61
+ return {
62
+ kind: "continue",
63
+ outputItem: buildTextToolOutputItem(callId, message),
64
+ };
65
+ }
66
+ function parseLiteralText(argumentsText) {
67
+ const parsed = parseJsonArguments(argumentsText);
68
+ const text = parsed.ok ? parsed.value?.text : null;
69
+ if (typeof text !== "string" || text.length === 0) {
70
+ return {
71
+ ok: false,
72
+ message: 'Invalid type_text_slowly arguments: "text" must be a non-empty string.',
73
+ };
74
+ }
75
+ if (!SUPPORTED_TEXT_PATTERN.test(text)) {
76
+ return { ok: false, message: "type_text_slowly currently supports printable ASCII text only." };
77
+ }
78
+ return { ok: true, text };
79
+ }
80
+ function resolveSecretAction(engine, secretKey) {
81
+ const secretsByKey = engine.secretsByKey && typeof engine.secretsByKey === "object" ? engine.secretsByKey : {};
82
+ if (!Object.prototype.hasOwnProperty.call(secretsByKey, secretKey)) {
83
+ return { ok: false, message: `Secret key not found: ${secretKey}` };
84
+ }
85
+ const text = String(secretsByKey[secretKey] ?? "");
86
+ if (!SUPPORTED_TEXT_PATTERN.test(text)) {
87
+ return {
88
+ ok: false,
89
+ message: `Secret ${secretKey} cannot be typed slowly because its value is empty or contains unsupported characters.`,
90
+ };
91
+ }
92
+ return {
93
+ ok: true,
94
+ action: {
95
+ type: "type",
96
+ text,
97
+ sensitive: true,
98
+ sensitiveKey: secretKey,
99
+ typingMode: "slow",
100
+ },
101
+ };
102
+ }
103
+ async function executeSlowTyping(engine, action, context, stepContext) {
104
+ try {
105
+ const result = await engine.handleModelAction(engine.session.deviceId, action, engine.session.deviceInfo.scale, {
106
+ ...context,
107
+ stepId: stepContext?.stepId,
108
+ instructionIndex: stepContext?.instructionIndex,
109
+ eventId: randomUUID(),
110
+ });
111
+ return result?.success !== false;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ async function capturePostTypingScreenshot(engine, callId, context, stepContext) {
118
+ const screenshotBase64 = await engine.getScreenshotAsBase64(engine.session.deviceId, engine.session.deviceInfo);
119
+ const screenshotEventId = randomUUID();
120
+ emitDesktopDebug("device.screenshot", "device", {
121
+ runId: context?.runId,
122
+ stepId: stepContext?.stepId,
123
+ instructionIndex: stepContext?.instructionIndex,
124
+ }, {
125
+ source: "post_action",
126
+ callId,
127
+ width: engine.session.deviceInfo?.scaled_width,
128
+ height: engine.session.deviceInfo?.scaled_height,
129
+ base64Length: screenshotBase64.length,
130
+ });
131
+ const currentScreenshot = await engine.recordScreenshot(screenshotBase64, {
132
+ runId: context?.runId,
133
+ sessionId: context?.sessionId,
134
+ stepId: stepContext?.stepId,
135
+ instructionIndex: stepContext?.instructionIndex,
136
+ eventId: screenshotEventId,
137
+ captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
138
+ });
139
+ context?.addOutput?.({
140
+ type: "info",
141
+ text: "Capturing screen",
142
+ eventType: "screenshot_captured",
143
+ actionType: "screenshot",
144
+ runId: context?.runId,
145
+ stepId: stepContext?.stepId,
146
+ instructionIndex: stepContext?.instructionIndex,
147
+ payload: {
148
+ eventId: screenshotEventId,
149
+ captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
150
+ source: "post_action",
151
+ ...(currentScreenshot?.screenshotPath ? { screenshotPath: currentScreenshot.screenshotPath } : {}),
152
+ },
153
+ });
154
+ return { screenshotBase64, currentScreenshot };
155
+ }