@loadmill/droid-cua 2.39.0 → 2.41.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
@@ -158,8 +158,13 @@ droid-cua skill install codex
158
158
  droid-cua skill install codex --local
159
159
  ```
160
160
 
161
- Use `droid-cua skill status <agent>` to inspect an installation and
162
- `droid-cua skill uninstall <agent>` to remove it. Installed skill
161
+ Use `droid-cua skill status <agent>` to see the installed and bundled skill
162
+ versions and installation path; add `--local` to inspect a project installation.
163
+ The skill version is stored in `SKILL.md` under `metadata.version`, independently
164
+ of the CLI package version. Older copies without version metadata show `unknown`.
165
+ The `modified` status means the files differ from the bundled copy, which can
166
+ indicate an older release or local edits even when version labels match.
167
+ Use `droid-cua skill uninstall <agent>` to remove an installation. Installed skill
163
168
  files are never overwritten or removed after local modification unless `--force`
164
169
  is supplied. After upgrading the npm package, update an installed skill explicitly:
165
170
 
@@ -188,6 +193,9 @@ droid-cua run tests/login.dcua --platform ios --avd "iPhone 16"
188
193
  # List running desktop applications and their window IDs (macOS or Windows)
189
194
  droid-cua --target desktop --list-desktop-apps
190
195
 
196
+ # Control the full primary display across applications
197
+ droid-cua --target desktop --instructions tests/launcher.dcua
198
+
191
199
  # Control one already-running desktop application window
192
200
  droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-window 123 --instructions tests/editor.dcua
193
201
 
@@ -239,7 +247,7 @@ backward compatibility.
239
247
  Example headless config:
240
248
  ```json
241
249
  {
242
- "cuaModel": "gpt-5.4",
250
+ "cuaModel": "gpt-6-sol",
243
251
  "promptCustomizations": {
244
252
  "basePromptInstructions": "",
245
253
  "designModeInstructions": "",
@@ -271,8 +279,8 @@ Supported CLI options include:
271
279
  - `--list-devices`
272
280
  - `--browser`
273
281
  - `--target` (`mobile`, `web`, or `desktop`)
274
- - `--desktop-app` (desktop application ID or exact name)
275
- - `--desktop-window` (required when the application has multiple visible windows)
282
+ - `--desktop-app` (limit desktop control to an application ID or exact name; omit for full desktop)
283
+ - `--desktop-window` (select a window within `--desktop-app`)
276
284
  - `--desktop-input` (`physical` by default; use `accessibility` for cursor-free input)
277
285
  - `--list-desktop-apps`
278
286
  - `--instructions`
@@ -295,10 +303,21 @@ Supported CLI options include:
295
303
  - `--debug`
296
304
 
297
305
  `--cua-model` accepts `loadmill-smart`, `loadmill-pulse`, `loadmill-beacon`,
298
- `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
299
- mode and require `OPENAI_API_KEY`.
306
+ `gpt-6-sol`, and `gpt-6-luna`. OpenAI models use direct OpenAI mode and require
307
+ `OPENAI_API_KEY`. Saved direct OpenAI selections of GPT-5.6 Terra, GPT-5.6 Luna,
308
+ or GPT-5.4 move to GPT-6 Sol, GPT-6 Luna, or GPT-6 Sol, respectively.
309
+
310
+ 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 physical pointer and keyboard input by default. Droid shows a system notice for three seconds before taking control and a completion notice when the run finishes, fails, or is stopped. The delay is fixed; use Stop in Droid once the run starts. Avoid using the mouse or keyboard during the run. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. On Windows, Droid must run at the same integrity level as the target application. System notices and automatic minimization apply only to runs started in the desktop app.
311
+
312
+ CLI runs with `--target desktop` control the full primary display by default, with no application flag needed. In the desktop app, choose **Full desktop**, the first option in the Application dropdown. This mode captures and controls the primary display across applications, including apps launched during the test; no target application needs to be running when connecting. Keep all test windows on the primary display. Full desktop requires physical input. Droid minimizes itself after the three-second notice and remains minimized when the run ends; open Droid to review the result or use Stop. Existing saved application selections are preserved.
313
+
314
+ Use `--desktop-app <id-or-exact-name>` or select an application in Droid to limit control to that application's windows. Application mode brings the selected window forward and leaves it there when the run ends. Add `--desktop-window <id>` to select a particular window, or `--desktop-input accessibility` for cursor-free CLI input; both options require `--desktop-app`. Before each run and screenshot, Droid refreshes window selection within the original application so it can follow a recreated login window. If replacement is ambiguous, select a window again. If a captured window disappears or moves before input, Droid requests a fresh screenshot instead of transferring its coordinates to a replacement. Focus errors report the activation reason; they do not establish an elevation problem.
315
+
316
+ If Windows returns an all-black window capture, Droid brings the target forward and crops the visible desktop to the window's visible frame, excluding its invisible resize border. The fallback logs a warning, crops before resizing to preserve detail, and requires the target to remain unobstructed on the primary display; off-screen portions appear black. With accessibility input, bring the target forward yourself when requested. Full desktop reports capture failures directly, without this window capture fallback.
317
+
318
+ Desktop **Test design** uses the same notices once you submit your instructions and exploration starts. Full desktop minimizes Droid at that point. Droid sends the completion notice when the generated test is ready, exploration is stopped, or recovery ends in an error. It does not automatically bring itself forward; open Droid whenever you need to give guidance, use Stop, or review the generated test. Opening Test design alone does not take desktop control. When a controller rejects an action, Droid skips the remaining actions in that batch and asks the model to continue from a fresh screenshot with the failure reason. Rejected actions are not counted as completed.
300
319
 
301
- 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.
320
+ The system notices are shown only for desktop control runs started in the desktop app. On macOS, they may not appear in the unsigned Electron dev app; verify them in a signed packaged build.
302
321
 
303
322
  Config and precedence rules:
304
323
  - Use `--config <file>` to supply prompt-affecting settings for headless runs.
package/build/index.js CHANGED
@@ -534,15 +534,17 @@ async function main() {
534
534
  }
535
535
  else if (isDesktopTarget) {
536
536
  desktopController = await createDesktopController({
537
+ scope: targetConfig.desktopScope,
537
538
  app: targetConfig.desktopApp,
538
539
  windowId: targetConfig.desktopWindow,
539
540
  inputMode: targetConfig.desktopInput,
541
+ focusOnConnect: true,
540
542
  excludePids: [process.pid],
541
543
  });
542
544
  targetId = desktopController.deviceId;
543
545
  deviceInfo = desktopController.deviceInfo;
544
- console.log(`Attached to desktop window: ${deviceInfo.device_name}`);
545
- console.log(`Model sees window: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
546
+ console.log(`Attached to desktop target: ${deviceInfo.device_name}`);
547
+ console.log(`Model sees desktop target: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
546
548
  console.log(`Desktop input: ${deviceInfo.desktopInputMode === "physical" ? "physical OS pointer" : "cursor-free accessibility"}`);
547
549
  process.once("SIGINT", async () => {
548
550
  console.log("\nStopping desktop run...");
@@ -2,7 +2,7 @@ import path from "path";
2
2
  import { readFile } from "fs/promises";
3
3
  import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
4
4
  import { AIM_ASSIST_MODES, DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
5
- import { CUA_MODELS, DEFAULT_OPENAI_CUA_MODEL, isOpenAiCuaModel, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
5
+ import { CUA_MODELS, DEFAULT_OPENAI_CUA_MODEL, isOpenAiCuaModel, isValidCuaModel, resolveCuaModel, upgradeLegacyCuaModel } from "../core/cua-models.js";
6
6
  import { LLM_PROVIDER_MODES, isValidLlmProviderMode, resolveLlmProviderMode } from "../core/runtime-config.js";
7
7
  import { parseSecretsOverrideString } from "../core/secrets.js";
8
8
  function createEmptyPromptCustomizations() {
@@ -21,10 +21,11 @@ function assertNonEmptyString(value, label) {
21
21
  }
22
22
  }
23
23
  function validateCuaModel(value, label) {
24
- if (typeof value !== "string" || !isValidCuaModel(value)) {
24
+ const upgraded = typeof value === "string" ? upgradeLegacyCuaModel(value) : value;
25
+ if (!isValidCuaModel(upgraded)) {
25
26
  throw new Error(`${label} must be one of: ${CUA_MODELS.join(", ")}.`);
26
27
  }
27
- return value;
28
+ return upgraded;
28
29
  }
29
30
  function validateAimAssistMode(value, label) {
30
31
  if (typeof value !== "string" || !AIM_ASSIST_MODES.includes(value)) {
@@ -44,8 +45,8 @@ function readEnvDefaultCuaModel() {
44
45
  // OpenAI model, stay on the OpenAI default rather than crossing over to the
45
46
  // global (Loadmill) default.
46
47
  if (typeof envValue === "string" && envValue.trim()) {
47
- const trimmed = envValue.trim();
48
- return isOpenAiCuaModel(trimmed) ? trimmed : DEFAULT_OPENAI_CUA_MODEL;
48
+ const upgraded = upgradeLegacyCuaModel(envValue.trim());
49
+ return isOpenAiCuaModel(upgraded) ? upgraded : DEFAULT_OPENAI_CUA_MODEL;
49
50
  }
50
51
  return resolveCuaModel(envValue);
51
52
  }
@@ -184,6 +184,9 @@ export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
184
184
  const MOBILE_ONLY_CLOUD_SOURCES = new Set(["lambdatest", "aws-device-farm"]);
185
185
  export function resolveHeadlessTarget(args = {}) {
186
186
  const target = normalizeTarget(args.target);
187
+ if (target !== "desktop" && (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null)) {
188
+ throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
189
+ }
187
190
  const targetGiven = hasNonEmptyString(args.target);
188
191
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
189
192
  const platform = normalizeMobilePlatform(args.platform, "--platform");
@@ -264,18 +267,25 @@ export function resolveHeadlessTarget(args = {}) {
264
267
  }
265
268
  const listDesktopApps = args["list-desktop-apps"] != null;
266
269
  const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
270
+ if (args["desktop-app"] != null && !desktopApp) {
271
+ throw new Error("--desktop-app requires an application ID or exact name.");
272
+ }
267
273
  const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
268
274
  const desktopWindow = rawWindow == null ? null : Number(rawWindow);
269
275
  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.");
276
+ if (args["desktop-window"] != null && !desktopApp) {
277
+ throw new Error("--desktop-window requires --desktop-app.");
272
278
  }
273
- if (rawWindow != null && (!Number.isInteger(desktopWindow) || desktopWindow < 0)) {
279
+ if (args["desktop-window"] != null && (rawWindow == null || !Number.isInteger(desktopWindow) || desktopWindow < 0)) {
274
280
  throw new Error("--desktop-window must be a non-negative integer window ID.");
275
281
  }
276
- if (desktopInput !== "accessibility" && desktopInput !== "physical") {
282
+ if ((args["desktop-input"] != null && !hasNonEmptyString(args["desktop-input"])) ||
283
+ (desktopInput !== "accessibility" && desktopInput !== "physical")) {
277
284
  throw new Error('--desktop-input must be "accessibility" or "physical".');
278
285
  }
286
+ if (desktopInput === "accessibility" && !desktopApp) {
287
+ throw new Error("--desktop-input accessibility requires --desktop-app. Full desktop uses physical input only.");
288
+ }
279
289
  return {
280
290
  target,
281
291
  sessionMode: null,
@@ -290,14 +300,12 @@ export function resolveHeadlessTarget(args = {}) {
290
300
  artifacts: null,
291
301
  listDevices: false,
292
302
  listDesktopApps,
303
+ desktopScope: desktopApp ? "window" : "desktop",
293
304
  desktopApp,
294
305
  desktopWindow,
295
306
  desktopInput,
296
307
  };
297
308
  }
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
- }
301
309
  if (args["session-mode"] != null) {
302
310
  throw new Error("--session-mode can only be used with --target web.");
303
311
  }
@@ -12,7 +12,7 @@ const SUBCOMMANDS = new Set(["install", "status", "uninstall"]);
12
12
  const ALLOWED_OPTIONS = new Set(["_", "local", "force", "h", "help"]);
13
13
  const COMMAND_DESCRIPTIONS = Object.freeze({
14
14
  install: "Install Droid CUA skill for a coding agent",
15
- status: "Show Droid CUA skill status for a coding agent",
15
+ status: "Show installation status and installed/bundled skill versions",
16
16
  uninstall: "Uninstall Droid CUA skill for a coding agent",
17
17
  });
18
18
  function skillCommandHelp(subcommand) {
@@ -53,7 +53,7 @@ function skillRootHelp() {
53
53
  "",
54
54
  "Commands:",
55
55
  " install Install the skill",
56
- " status Show installation status",
56
+ " status Show installation status and skill versions",
57
57
  " uninstall Uninstall the skill",
58
58
  ].join("\n");
59
59
  }
@@ -103,6 +103,18 @@ async function directoryMatches(sourcePath, destinationPath) {
103
103
  ]);
104
104
  return JSON.stringify(sourceSnapshot) === JSON.stringify(destinationSnapshot);
105
105
  }
106
+ // Read the bundled skill's block-style metadata.version scalar without a YAML dependency.
107
+ async function readSkillVersion(skillPath) {
108
+ if (await pathType(skillPath) !== "directory")
109
+ return null;
110
+ if (await pathType(path.join(skillPath, "SKILL.md")) !== "file")
111
+ return null;
112
+ const markdown = await readFile(path.join(skillPath, "SKILL.md"), "utf8");
113
+ const frontmatter = markdown.replace(/\r\n/g, "\n").match(/^---\n([\s\S]*?)\n---(?:\n|$)/)?.[1];
114
+ const metadata = frontmatter?.match(/^metadata:[ \t]*\n((?:[ \t]+[^\n]*(?:\n|$))*)/m)?.[1];
115
+ const value = metadata?.match(/^ version:[ \t]*(?:"([^"\n]+)"|'([^'\n]+)'|([^\s#]+))[ \t]*(?:#.*)?$/m);
116
+ return value ? (value[1] ?? value[2] ?? value[3]) : null;
117
+ }
106
118
  async function getInstallationState(sourcePath, destinationPath) {
107
119
  if (await pathType(destinationPath) === "missing")
108
120
  return "not-installed";
@@ -214,7 +226,12 @@ export async function runSkillCommand({ args, cwd = process.cwd(), home = os.hom
214
226
  }
215
227
  for (const destination of states) {
216
228
  if (command.subcommand === "status") {
217
- output.log(`${destination.displayName}: ${stateLabel(destination.state)} at ${destination.path}`);
229
+ const [installedVersion, bundledVersion] = await Promise.all([
230
+ readSkillVersion(destination.path),
231
+ readSkillVersion(bundledSkillPath),
232
+ ]);
233
+ const installedLabel = destination.state === "not-installed" ? "not installed" : installedVersion ?? "unknown";
234
+ output.log(`${destination.displayName}: ${stateLabel(destination.state)} at ${destination.path}\nInstalled skill version: ${installedLabel}\nBundled skill version: ${bundledVersion ?? "unknown"}`);
218
235
  }
219
236
  else if (command.subcommand === "install") {
220
237
  if (destination.state === "installed") {
@@ -11,19 +11,21 @@ import { CUA_MODELS } from '../core/cua-models.js';
11
11
  */
12
12
  export async function handleHelp(args, session, context) {
13
13
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
14
- addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile and web testing CLI' });
14
+ addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile, web, and desktop testing CLI' });
15
15
  addOutput({ type: 'info', text: '' });
16
16
  addOutput({ type: 'info', text: 'Usage:' });
17
17
  addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
18
18
  addOutput({ type: 'info', text: ' droid-cua run <file-or-directory...> [options]' });
19
19
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
20
+ addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions <file> [options]' });
20
21
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
21
22
  addOutput({ type: 'info', text: '' });
22
23
  addOutput({ type: 'info', text: 'Options:' });
23
24
  addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
24
- addOutput({ type: 'info', text: ' --desktop-app <id> Running desktop app ID or exact name' });
25
- addOutput({ type: 'info', text: ' --desktop-window <id> Window ID when the app has multiple windows' });
26
- addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default) or cursor-free accessibility' });
25
+ addOutput({ type: 'info', text: ' Desktop controls the primary display by default' });
26
+ addOutput({ type: 'info', text: ' --desktop-app <id> Limit desktop control to a running app ID or exact name' });
27
+ addOutput({ type: 'info', text: ' --desktop-window <id> Select a window within --desktop-app' });
28
+ addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default); accessibility requires --desktop-app' });
27
29
  addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
28
30
  addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
29
31
  addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
@@ -76,6 +78,7 @@ export async function handleHelp(args, session, context) {
76
78
  addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
77
79
  addOutput({ type: 'info', text: ' LambdaTest: Uses real cloud devices for headless mobile runs' });
78
80
  addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
81
+ addOutput({ type: 'info', text: ' Desktop: Controls the primary display or a selected application on macOS and Windows' });
79
82
  addOutput({ type: 'info', text: '' });
80
83
  addOutput({ type: 'info', text: 'Platform Detection:' });
81
84
  addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
@@ -98,6 +101,7 @@ export async function handleHelp(args, session, context) {
98
101
  addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
99
102
  addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
100
103
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
104
+ addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions tests/launcher.dcua' });
101
105
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
102
106
  addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
103
107
  addOutput({ type: 'info', text: ' /list (see all tests)' });
@@ -1,4 +1,4 @@
1
- export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
1
+ export const DEFAULT_OPENAI_CUA_MODEL = "gpt-6-sol";
2
2
  export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
3
3
  export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
4
4
  export const CUA_CONVERSATION_POLICIES = Object.freeze({
@@ -28,27 +28,28 @@ export const CUA_MODEL_OPTIONS = Object.freeze([
28
28
  label: "Loadmill - Beacon",
29
29
  }),
30
30
  Object.freeze({
31
- id: "gpt-5.6-terra",
31
+ id: "gpt-6-sol",
32
32
  providerMode: "openai",
33
33
  providerLabel: "OpenAI",
34
- name: "GPT-5.6 Terra",
35
- label: "OpenAI GPT-5.6 Terra",
34
+ name: "GPT-6 Sol",
35
+ label: "OpenAI GPT-6 Sol",
36
36
  }),
37
37
  Object.freeze({
38
- id: "gpt-5.6-luna",
38
+ id: "gpt-6-luna",
39
39
  providerMode: "openai",
40
40
  providerLabel: "OpenAI",
41
- name: "GPT-5.6 Luna",
42
- label: "OpenAI GPT-5.6 Luna",
43
- }),
44
- Object.freeze({
45
- id: "gpt-5.4",
46
- providerMode: "openai",
47
- providerLabel: "OpenAI",
48
- name: "GPT-5.4",
49
- label: "OpenAI GPT-5.4",
41
+ name: "GPT-6 Luna",
42
+ label: "OpenAI GPT-6 Luna",
50
43
  }),
51
44
  ]);
45
+ const LEGACY_OPENAI_CUA_MODELS = Object.freeze({
46
+ "gpt-5.6-terra": "gpt-6-sol",
47
+ "gpt-5.6-luna": "gpt-6-luna",
48
+ "gpt-5.4": "gpt-6-sol",
49
+ });
50
+ export function upgradeLegacyCuaModel(value) {
51
+ return LEGACY_OPENAI_CUA_MODELS[value] ?? value;
52
+ }
52
53
  export const CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS.map((option) => option.id));
53
54
  export const OPENAI_CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS
54
55
  .filter((option) => option.providerMode === "openai")
@@ -70,7 +71,8 @@ export function isLoadmillCuaModel(value) {
70
71
  return LOADMILL_CUA_MODEL_SET.has(value);
71
72
  }
72
73
  export function resolveCuaModel(value) {
73
- return isValidCuaModel(value) ? value : DEFAULT_CUA_MODEL;
74
+ const upgraded = upgradeLegacyCuaModel(value);
75
+ return isValidCuaModel(upgraded) ? upgraded : DEFAULT_CUA_MODEL;
74
76
  }
75
77
  export function resolveCuaModelProviderMode(value) {
76
78
  const normalized = resolveCuaModel(value);
@@ -3,29 +3,26 @@ const OPENAI_GPT_5_4_PRICING = Object.freeze({
3
3
  inputPerMillionUsd: 2.5,
4
4
  });
5
5
  const OPENAI_GPT_5_6_TERRA_PRICING = Object.freeze({
6
- inputPerMillionUsd: 2.5,
6
+ inputPerMillionUsd: 2,
7
7
  });
8
8
  const OPENAI_GPT_5_6_LUNA_PRICING = Object.freeze({
9
- inputPerMillionUsd: 1,
9
+ inputPerMillionUsd: 0.2,
10
+ });
11
+ const OPENAI_GPT_6_SOL_PRICING = Object.freeze({
12
+ inputPerMillionUsd: 2,
13
+ });
14
+ const OPENAI_GPT_6_LUNA_PRICING = Object.freeze({
15
+ inputPerMillionUsd: 0.1,
10
16
  });
11
17
  const MODEL_PRICING = Object.freeze({
12
- "gpt-5.6-terra": Object.freeze({
13
- ...OPENAI_GPT_5_6_TERRA_PRICING,
14
- }),
15
- "gpt-5.6-luna": Object.freeze({
16
- ...OPENAI_GPT_5_6_LUNA_PRICING,
17
- }),
18
- "gpt-5.4": Object.freeze({
19
- ...OPENAI_GPT_5_4_PRICING,
20
- }),
21
- // Loadmill Smart currently routes to GPT-5.6 Terra behind the Loadmill LLM gateway.
22
- // Keep this explicit so pricing can be updated here if the backing model changes.
23
- "loadmill-smart": Object.freeze({
24
- ...OPENAI_GPT_5_6_TERRA_PRICING,
25
- }),
26
- "loadmill-pulse": Object.freeze({
27
- ...OPENAI_GPT_5_6_LUNA_PRICING,
28
- }),
18
+ "gpt-6-sol": OPENAI_GPT_6_SOL_PRICING,
19
+ "gpt-6-luna": OPENAI_GPT_6_LUNA_PRICING,
20
+ "gpt-5.6-terra": OPENAI_GPT_5_6_TERRA_PRICING,
21
+ "gpt-5.6-luna": OPENAI_GPT_5_6_LUNA_PRICING,
22
+ "gpt-5.4": OPENAI_GPT_5_4_PRICING,
23
+ // Loadmill Smart and Pulse route to GPT-6 Sol and Luna, respectively.
24
+ "loadmill-smart": OPENAI_GPT_6_SOL_PRICING,
25
+ "loadmill-pulse": OPENAI_GPT_6_LUNA_PRICING,
29
26
  });
30
27
  function finiteNumber(value) {
31
28
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
@@ -41,6 +38,12 @@ export function normalizeDroidCuaCostModel(model) {
41
38
  if (typeof model !== "string") {
42
39
  return null;
43
40
  }
41
+ if (model === "gpt-6-sol" || model.startsWith("gpt-6-sol-")) {
42
+ return "gpt-6-sol";
43
+ }
44
+ if (model === "gpt-6-luna" || model.startsWith("gpt-6-luna-")) {
45
+ return "gpt-6-luna";
46
+ }
44
47
  if (model === "gpt-5.6-terra" || model.startsWith("gpt-5.6-terra-")) {
45
48
  return "gpt-5.6-terra";
46
49
  }
@@ -408,7 +408,7 @@ export class ExecutionEngine {
408
408
  // The Desktop recorder returns one from disk; for runs without a recorder (e.g. a
409
409
  // plain CLI `--report`) render the same marker in-memory from the raw screenshot.
410
410
  const annotatedDataUrl = await this.resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context);
411
- await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
411
+ const actionResult = await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
412
412
  ...context,
413
413
  shouldStop,
414
414
  stepId: stepContext?.stepId,
@@ -430,6 +430,13 @@ export class ExecutionEngine {
430
430
  ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
431
431
  ...(context?.includeScreenshotDataUrl && annotatedDataUrl ? { screenshotDataUrl: annotatedDataUrl } : {})
432
432
  });
433
+ if (actionResult?.success === false) {
434
+ if (shouldStop())
435
+ return { stopped: true, sawExplicitScreenshotAction };
436
+ const actionError = `${action.type} failed: ${actionResult.error || "The action could not be completed."}`;
437
+ this.session.addToTranscript?.(`[Action Error] ${actionError}`);
438
+ return { refreshModelState: true, actionError, sawExplicitScreenshotAction };
439
+ }
433
440
  if (trackAction) {
434
441
  trackAction(action);
435
442
  }
@@ -823,6 +830,9 @@ export class ExecutionEngine {
823
830
  }
824
831
  if (actionBatchResult.refreshModelState) {
825
832
  const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
833
+ if (actionBatchResult.actionError) {
834
+ followUp.input.push({ role: "user", content: `${actionBatchResult.actionError} The remaining actions were skipped. Use the fresh screenshot to decide what to do next.` });
835
+ }
826
836
  currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
827
837
  response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
828
838
  newResponseId = response.id;