@loadmill/droid-cua 2.38.0 → 2.40.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
 
@@ -239,7 +244,7 @@ backward compatibility.
239
244
  Example headless config:
240
245
  ```json
241
246
  {
242
- "cuaModel": "gpt-5.4",
247
+ "cuaModel": "gpt-6-sol",
243
248
  "promptCustomizations": {
244
249
  "basePromptInstructions": "",
245
250
  "designModeInstructions": "",
@@ -290,14 +295,18 @@ Supported CLI options include:
290
295
  - `--secrets`
291
296
  - `--record`
292
297
  - `--report`
298
+ - `--report-metadata <path>`
293
299
  - `--no-cloud-report`
294
300
  - `--debug`
295
301
 
296
302
  `--cua-model` accepts `loadmill-smart`, `loadmill-pulse`, `loadmill-beacon`,
297
- `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
298
- mode and require `OPENAI_API_KEY`.
303
+ `gpt-6-sol`, and `gpt-6-luna`. OpenAI models use direct OpenAI mode and require
304
+ `OPENAI_API_KEY`. Saved direct OpenAI selections of GPT-5.6 Terra, GPT-5.6 Luna,
305
+ or GPT-5.4 move to GPT-6 Sol, GPT-6 Luna, or GPT-6 Sol, respectively.
299
306
 
300
- 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.
307
+ 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: Droid shows a system notice for three seconds before taking control, brings the selected window to the foreground, then sends physical pointer and keyboard input at the model's coordinates. The three-second delay is fixed; once the test starts, use Stop in Droid to end it. Avoid using the mouse or keyboard during the run. When the run ends, Droid leaves the target window in front and shows another system notice that control has finished, including when the run fails or is stopped. CLI runs also leave the target in front. 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. If Windows returns an all-black window capture, Droid brings the target back to the foreground and crops the visible desktop to the window's visible frame, excluding its invisible resize border. The crop is resized only after extraction to preserve window detail. The fallback logs a warning and requires the target to remain unobstructed on the primary display; off-screen portions appear black. With accessibility input, bring the target to the foreground yourself when requested.
308
+
309
+ 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.
301
310
 
302
311
  Config and precedence rules:
303
312
  - Use `--config <file>` to supply prompt-affecting settings for headless runs.
@@ -325,8 +334,11 @@ HTML report (`--report`):
325
334
  - 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.
326
335
  - A multi-path selection writes the individual test reports plus one aggregate `Selected tests` report with a child result and timeline for every selected test. When `--report <file.html>` is used, that exact path is used for the aggregate report.
327
336
  - In the desktop app the same report is available from a run's **Share → Download HTML report** button.
328
- - 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.
337
+ - Generated HTML reports opt out of Cloudflare email obfuscation so ordinary email values remain visible in hosted reports. Secret-typing values are omitted from typing logs, but may still appear in screenshots.
338
+ - Loadmill flow entries link **Test started** to the flow's test-suite run results, including in reports generated by CI.
339
+ - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders portrait mobile runs as square replays and landscape mobile, web, and desktop runs as 16:9 replays. Landscape Android and iOS screenshots keep a device frame sized to the captured screen instead of browser chrome. Rendering happens locally, then the app 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.
329
340
  - 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.
341
+ - Use `--report-metadata <path>` when automation needs the exact published Loadmill run ID and last uploaded screenshot object name. Droid clears that destination when the invocation starts and writes it only after report publication succeeds, including for published failure reports.
330
342
 
331
343
  ```sh
332
344
  droid-cua run tests/login.dcua --avd adb:emulator-5554 --report ./reports
package/build/index.js CHANGED
@@ -22,6 +22,7 @@ import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-con
22
22
  import { artifactKindsForPool, resolveHeadlessTarget, scopeArtifactKindsToPool, shouldWaitForLoadmillCloudCompletion, } from "./src/cli/headless-target.js";
23
23
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
24
24
  import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
25
+ import { clearLoadmillReportMetadata } from "./src/cli/loadmill-report-metadata.js";
25
26
  import { assertRunCommandPosition, getHeadlessInstructionSelectors, resolveHeadlessInstructionSelection, } from "./src/cli/headless-selection.js";
26
27
  import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
27
28
  import { buildLoadmillCloudDeviceConnectSummaryLines } from "./src/cli/loadmill-cloud-device-connect-summary.js";
@@ -72,6 +73,11 @@ if (args._[0] === "skill") {
72
73
  process.exit(1);
73
74
  }
74
75
  }
76
+ const reportMetadataPath = typeof args["report-metadata"] === "string"
77
+ && args["report-metadata"].trim().length > 0
78
+ ? args["report-metadata"].trim()
79
+ : null;
80
+ await clearLoadmillReportMetadata(reportMetadataPath);
75
81
  const targetConfig = resolveHeadlessTarget(args);
76
82
  let avdName = targetConfig.avdName;
77
83
  let platform = targetConfig.platform || null; // 'ios' or 'android'
@@ -98,6 +104,10 @@ const reportConfig = {
98
104
  enabled: args.report === true || (typeof args.report === "string" && args.report.trim().length > 0),
99
105
  path: typeof args.report === "string" && args.report.trim().length > 0 ? args.report.trim() : null,
100
106
  cloudEnabled: args["no-cloud-report"] !== true,
107
+ metadataPath: reportMetadataPath,
108
+ lloydJobId: typeof process.env.LLOYD_JOB_ID === "string" && process.env.LLOYD_JOB_ID.trim().length > 0
109
+ ? process.env.LLOYD_JOB_ID.trim()
110
+ : null,
101
111
  };
102
112
  const screenshotDir = path.join("droid-cua-recording-" + Date.now());
103
113
  if (recordScreenshots)
@@ -527,6 +537,7 @@ async function main() {
527
537
  app: targetConfig.desktopApp,
528
538
  windowId: targetConfig.desktopWindow,
529
539
  inputMode: targetConfig.desktopInput,
540
+ focusOnConnect: true,
530
541
  excludePids: [process.pid],
531
542
  });
532
543
  targetId = desktopController.deviceId;
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import path from "path";
2
- import { mkdir } from "fs/promises";
2
+ import { mkdir, writeFile } from "fs/promises";
3
3
  import { getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
4
4
  import { handleModelAction as handleBrowserModelAction } from "../browser/actions.js";
5
5
  import { ExecutionEngine } from "../core/execution-engine.js";
@@ -13,6 +13,7 @@ import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-
13
13
  import { printCliOutput } from "../utils/console-output.js";
14
14
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
15
15
  import { loadHeadlessInstructions } from "./headless-instructions.js";
16
+ import { clearLoadmillReportMetadata } from "./loadmill-report-metadata.js";
16
17
  import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
17
18
  import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
18
19
  import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
@@ -36,6 +37,24 @@ function logPendingScreenshotUploads(uploadManager, uploadSession, subject) {
36
37
  console.log(`${subject} finished. Waiting for ${pendingCount} screenshot ${uploads} to finish...`);
37
38
  }
38
39
  }
40
+ function loadmillReportMetadata(savedRunId, publication) {
41
+ if (!savedRunId || !publication?.published) {
42
+ return null;
43
+ }
44
+ return {
45
+ runId: savedRunId,
46
+ ...(publication.lastScreenshotObjectName
47
+ ? { lastScreenshotObjectName: publication.lastScreenshotObjectName }
48
+ : {}),
49
+ };
50
+ }
51
+ async function writeLoadmillReportMetadata(filePath, metadata) {
52
+ if (!filePath || !metadata) {
53
+ return;
54
+ }
55
+ await mkdir(path.dirname(path.resolve(filePath)), { recursive: true });
56
+ await writeFile(filePath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
57
+ }
39
58
  function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun) {
40
59
  if (!baseScreenshotDir || !isBatchRun) {
41
60
  return baseScreenshotDir;
@@ -320,9 +339,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
320
339
  const cloudModel = cloudReportEnabled
321
340
  ? buildCliRunReportModel({ ...reportInput, events: cloudEvents })
322
341
  : null;
342
+ let reportPublication = null;
323
343
  if (cloudModel && reportToLoadmill && reportedRun?.savedRunId) {
324
344
  logPendingScreenshotUploads(uploadManager, cloudUploadSession, "Test");
325
- await uploadManager.finish(cloudUploadSession, {
345
+ reportPublication = await uploadManager.finish(cloudUploadSession, {
326
346
  savedRunId: reportedRun.savedRunId,
327
347
  html: renderCliCloudReport(cloudModel, reportedRun.savedRunId),
328
348
  });
@@ -339,6 +359,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
339
359
  retries: stats.retryCount ?? 0,
340
360
  localReportModel: localReport?.model ?? null,
341
361
  reportModel: cloudModel,
362
+ loadmillReport: loadmillReportMetadata(reportedRun?.savedRunId, reportPublication),
342
363
  };
343
364
  }
344
365
  catch (error) {
@@ -418,9 +439,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
418
439
  const cloudModel = cloudReportEnabled
419
440
  ? buildCliRunReportModel({ ...failedReportInput, events: cloudEvents })
420
441
  : null;
442
+ let reportPublication = null;
421
443
  if (cloudModel && reportToLoadmill && reportedRun?.savedRunId) {
422
444
  logPendingScreenshotUploads(uploadManager, cloudUploadSession, "Test");
423
- await uploadManager.finish(cloudUploadSession, {
445
+ reportPublication = await uploadManager.finish(cloudUploadSession, {
424
446
  savedRunId: reportedRun.savedRunId,
425
447
  html: renderCliCloudReport(cloudModel, reportedRun.savedRunId),
426
448
  });
@@ -439,17 +461,21 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
439
461
  retries: stats.retryCount ?? 0,
440
462
  localReportModel: localReport?.model ?? null,
441
463
  reportModel: cloudModel,
464
+ loadmillReport: loadmillReportMetadata(reportedRun?.savedRunId, reportPublication),
442
465
  };
443
466
  }
467
+ await writeLoadmillReportMetadata(report?.metadataPath, loadmillReportMetadata(reportedRun?.savedRunId, reportPublication));
444
468
  throw error;
445
469
  }
446
470
  }
447
471
  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, }) {
472
+ await clearLoadmillReportMetadata(report?.metadataPath);
448
473
  const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
449
474
  const runConfiguration = buildDroidRunConfiguration({
450
475
  droidVersion,
451
476
  runtimeConfig: runtimeContext.config,
452
477
  aimAssistMode: headlessConfig.aimAssistMode,
478
+ lloydJobId: report?.lloydJobId,
453
479
  });
454
480
  const isBatchRun = ["directory", "selection", "scenario"].includes(instructionsTarget?.type);
455
481
  const cloudReportEnabled = report?.cloudEnabled !== false &&
@@ -470,6 +496,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
470
496
  console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
471
497
  }
472
498
  let failures = 0;
499
+ let reportMetadata = null;
473
500
  const batchStartedAt = Date.now();
474
501
  const aggregateStats = {
475
502
  durationMs: 0,
@@ -530,6 +557,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
530
557
  if (result.reportModel) {
531
558
  aggregateReportModels.push(result.reportModel);
532
559
  }
560
+ reportMetadata = result.loadmillReport ?? reportMetadata;
533
561
  if (result.localReportModel) {
534
562
  aggregateLocalReportModels.push(result.localReportModel);
535
563
  }
@@ -642,12 +670,14 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
642
670
  childModels: aggregateReportModels,
643
671
  });
644
672
  logPendingScreenshotUploads(uploadManager, cloudUploadSession, "Run");
645
- await uploadManager.finish(cloudUploadSession, {
673
+ const publication = await uploadManager.finish(cloudUploadSession, {
646
674
  savedRunId: reportedRun.savedRunId,
647
675
  html: renderCliCloudReport(aggregateReport.model, reportedRun.savedRunId),
648
676
  });
677
+ reportMetadata = loadmillReportMetadata(reportedRun.savedRunId, publication);
649
678
  }
650
679
  }
680
+ await writeLoadmillReportMetadata(report?.metadataPath, reportMetadata);
651
681
  return {
652
682
  exitCode: failures === 0 ? 0 : 1,
653
683
  failures
@@ -0,0 +1,7 @@
1
+ import { rm } from "fs/promises";
2
+ export async function clearLoadmillReportMetadata(filePath) {
3
+ if (!filePath) {
4
+ return;
5
+ }
6
+ await rm(filePath, { force: true });
7
+ }
@@ -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") {
@@ -54,6 +54,7 @@ export async function handleHelp(args, session, context) {
54
54
  addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
55
55
  addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
56
56
  addOutput({ type: 'info', text: ' --no-cloud-report Skip uploading the HTML report and screenshots to Loadmill' });
57
+ addOutput({ type: 'info', text: ' --report-metadata <path> Write uploaded Loadmill report metadata as JSON' });
57
58
  addOutput({ type: 'info', text: ' --debug Enable structured JSONL debug artifacts' });
58
59
  addOutput({ type: 'info', text: '' });
59
60
  addOutput({ type: 'info', text: 'Interactive commands:' });
@@ -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
  }
@@ -255,12 +255,36 @@ function renderCloudCard(events) {
255
255
  }
256
256
  function renderLoadmillCard(events) {
257
257
  const lines = events.map((event) => event.text.replace(/^\[Loadmill\]\s*/, "").trim());
258
+ const runId = events.map((event) => event.payload?.loadmillSuiteRunId)
259
+ .find((value) => typeof value === "string" && value.trim()) ??
260
+ lines.map((line) => line.match(/Test started \(ID:\s*([^)]+)\)/i)?.[1]?.trim() ||
261
+ line.match(/Run ID:\s*(.+)$/i)?.[1]?.trim()).find(Boolean);
262
+ const baseUrl = events.map((event) => event.payload?.loadmillBaseUrl)
263
+ .find((value) => typeof value === "string" && value.trim()) ?? "https://app.loadmill.com";
264
+ let runUrl = null;
265
+ if (runId) {
266
+ try {
267
+ const url = new URL(`${baseUrl.trim().replace(/\/$/, "")}/app/api-tests/test-suite-runs/${encodeURIComponent(runId.trim())}`);
268
+ if (url.protocol === "https:" || url.protocol === "http:")
269
+ runUrl = url.href;
270
+ }
271
+ catch {
272
+ // Keep the original log text when the recorded base URL is invalid.
273
+ }
274
+ }
275
+ const renderedLines = lines.map((line) => {
276
+ const started = line.match(/^Test started \(ID:\s*([^)]+)\)\.(.*)$/i);
277
+ const text = started && runUrl
278
+ ? `<a href="${escapeHtml(runUrl)}" target="_blank" rel="noreferrer">Test started</a>${renderLinkedText(started[2])}`
279
+ : renderLinkedText(line);
280
+ return `<div>${text}</div>`;
281
+ });
258
282
  const failed = events.some((event) => /\[Loadmill\]\s*FAILED|^Error:/i.test(event.text));
259
283
  const passed = !failed && events.some((event) => /\bpassed\b/i.test(event.text));
260
284
  const status = failed ? "failed" : passed ? "passed" : "running";
261
285
  return `<details class="dc-special dc-loadmill"${passed ? "" : " open"}>
262
286
  <summary><span class="dc-special-title">${ICONS.loadmill}Loadmill Flow</span><span class="dc-pill dc-pill-${status}">${status}</span></summary>
263
- <div class="dc-special-body">${lines.map((line) => `<div>${renderLinkedText(line)}</div>`).join("")}</div>
287
+ <div class="dc-special-body">${renderedLines.join("")}</div>
264
288
  </details>`;
265
289
  }
266
290
  function renderTimeline(events, shots, warnings = []) {
@@ -464,10 +488,12 @@ ${STYLES}
464
488
  </style>
465
489
  </head>
466
490
  <body>
491
+ <!--email_off-->
467
492
  <div class="dc-wrap" id="dc-top">
468
493
  <div class="dc-header"><div class="dc-title">Run Details: ${escapeHtml(heading)}</div><span class="dc-pill dc-pill-${escapeHtml(model.status)}">${escapeHtml(runStatusLabel(model.status))}</span></div>
469
494
  ${body}
470
495
  </div>${lightboxes ? `\n${lightboxes}` : ""}${script ? `\n${script}` : ""}
496
+ <!--/email_off-->
471
497
  </body>
472
498
  </html>`;
473
499
  }