@loadmill/droid-cua 2.21.0 → 2.23.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.
Files changed (34) hide show
  1. package/build/index.js +31 -1
  2. package/build/src/browser/playwright/actions.js +65 -1
  3. package/build/src/cli/headless-runner.js +174 -12
  4. package/build/src/commands/help.js +1 -1
  5. package/build/src/core/cua-models.js +11 -4
  6. package/build/src/core/droid-cua-run-usage.js +106 -0
  7. package/build/src/core/droid-cua-segment-events.js +32 -0
  8. package/build/src/core/execution-engine.js +334 -5
  9. package/build/src/core/prompts.js +1 -1
  10. package/build/src/core/report/render-html-report.js +8 -3
  11. package/build/src/core/report/run-log-model.js +42 -0
  12. package/build/src/core/runtime-config.js +24 -39
  13. package/build/src/core/session.js +6 -0
  14. package/build/src/core/tool-handlers/loadmill.js +3 -0
  15. package/build/src/core/tool-handlers/test-design-complete.js +1 -0
  16. package/build/src/core/tool-handlers/type-sensitive-text.js +1 -0
  17. package/build/src/device/android/actions.js +6 -2
  18. package/build/src/device/android/connection.js +58 -23
  19. package/build/src/device/assertions.js +15 -8
  20. package/build/src/device/ios/connection.js +36 -30
  21. package/build/src/device/openai.js +68 -2
  22. package/build/src/device/scroll-gesture.js +22 -9
  23. package/build/src/integrations/loadmill/client.js +62 -0
  24. package/build/src/integrations/loadmill/run-reporter.js +113 -0
  25. package/build/src/integrations/loadmill/session-user.js +39 -0
  26. package/build/src/integrations/segment/droid-cua-segment.js +64 -0
  27. package/build/src/modes/design-mode-ink.js +10 -1
  28. package/build/src/modes/design-mode.js +7 -1
  29. package/build/src/modes/execution-mode.js +84 -8
  30. package/build/src/prompts/design.js +21 -1
  31. package/build/src/utils/console-output.js +5 -0
  32. package/build/src/utils/cua-debug-tracer.js +50 -7
  33. package/package.json +2 -1
  34. package/build/src/core/cua-backends.js +0 -11
package/build/index.js CHANGED
@@ -15,7 +15,7 @@ import { selectDevice } from "./src/cli/device-selector.js";
15
15
  import { resolveExecutionAppContext } from "./src/core/app-context.js";
16
16
  import { resolveExecutionTestData } from "./src/core/test-data.js";
17
17
  import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
18
- import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
18
+ import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
19
19
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
20
20
  import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
21
21
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
@@ -71,6 +71,33 @@ function createCliRuntimeContext(configOverrides = {}) {
71
71
  loadmillClient,
72
72
  };
73
73
  }
74
+ async function assertCliRuntimeCredentials({ llmProviderMode }) {
75
+ const credentials = resolveRuntimeCredentialsFromEnv(process.env);
76
+ assertRuntimeCredentialsForProvider({
77
+ llmProviderMode,
78
+ credentials,
79
+ });
80
+ if (llmProviderMode !== "loadmill") {
81
+ return;
82
+ }
83
+ const loadmillClient = createLoadmillApiClient({
84
+ token: credentials.loadmillApiToken,
85
+ baseUrl: credentials.loadmillBaseUrl,
86
+ });
87
+ let tokenValidation = null;
88
+ try {
89
+ tokenValidation = await loadmillClient.validateApiToken();
90
+ }
91
+ catch {
92
+ throw new Error("Could not validate LOADMILL_API_TOKEN.");
93
+ }
94
+ if (tokenValidation?.reason === "invalid") {
95
+ throw new Error("LOADMILL_API_TOKEN is invalid.");
96
+ }
97
+ if (!tokenValidation?.valid) {
98
+ throw new Error("Could not validate LOADMILL_API_TOKEN.");
99
+ }
100
+ }
74
101
  function createLambdaTestCliBuildName() {
75
102
  return `loadmill-droid-cua-cli-${new Date().toISOString().replace(/[:.]/g, "-")}`;
76
103
  }
@@ -184,6 +211,9 @@ async function main() {
184
211
  let preloadedSecrets = null;
185
212
  if (isHeadlessInstructionsRun) {
186
213
  preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
214
+ await assertCliRuntimeCredentials({
215
+ llmProviderMode: preloadedHeadlessConfig.llmProviderMode,
216
+ });
187
217
  preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
188
218
  preloadedSecrets = await resolveExecutionSecrets({
189
219
  secretsPath: getDefaultSecretsPath(process.cwd()),
@@ -3,7 +3,8 @@ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js
3
3
  import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
4
4
  import { buildActionEventMeta } from "../../device/action-event-meta.js";
5
5
  import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../../device/sensitive-action.js";
6
- import { getActiveBrowserPage } from "./session-state.js";
6
+ import { normalizeAutomationUrl } from "./url.js";
7
+ import { getActiveBrowserSession, getActiveBrowserPage } from "./session-state.js";
7
8
  const KEY_ALIASES = {
8
9
  CTRL: "Control",
9
10
  CONTROL: "Control",
@@ -58,8 +59,47 @@ function buildMeta(context, action, payload = {}) {
58
59
  payload,
59
60
  });
60
61
  }
62
+ function handleNavigationTimeout(error, description) {
63
+ if (!(error instanceof Error) || error.name !== "TimeoutError") {
64
+ throw error;
65
+ }
66
+ console.warn(`Page load did not finish within the timeout for ${description}. Continuing with the current page state.`);
67
+ }
68
+ async function gotoWithTimeoutTolerance(page, url) {
69
+ try {
70
+ await page.goto(url, { waitUntil: "load" });
71
+ }
72
+ catch (error) {
73
+ handleNavigationTimeout(error, url);
74
+ }
75
+ }
76
+ async function goBackWithTimeoutTolerance(page) {
77
+ try {
78
+ await page.goBack({ waitUntil: "load" });
79
+ }
80
+ catch (error) {
81
+ handleNavigationTimeout(error, "browser history back");
82
+ }
83
+ }
84
+ async function goForwardWithTimeoutTolerance(page) {
85
+ try {
86
+ await page.goForward({ waitUntil: "load" });
87
+ }
88
+ catch (error) {
89
+ handleNavigationTimeout(error, "browser history forward");
90
+ }
91
+ }
92
+ function updateSessionUrlFromPage(session, page) {
93
+ const currentUrl = typeof page.url === "function" ? page.url() : null;
94
+ if (!currentUrl) {
95
+ return;
96
+ }
97
+ session.pageUrls?.set?.(page, currentUrl);
98
+ session.url = currentUrl;
99
+ }
61
100
  export async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
62
101
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
102
+ const session = getActiveBrowserSession(browserSessionId);
63
103
  const page = getActiveBrowserPage(browserSessionId);
64
104
  try {
65
105
  emitDesktopDebug("browser.action.execute", "device", {
@@ -166,6 +206,30 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
166
206
  await page.waitForTimeout(timeoutMs);
167
207
  break;
168
208
  }
209
+ case "goto": {
210
+ const normalizedUrl = normalizeAutomationUrl(action.url);
211
+ addOutput({
212
+ type: "action",
213
+ text: `Navigating to ${normalizedUrl}`,
214
+ ...buildMeta(context, action, { url: normalizedUrl }),
215
+ });
216
+ await gotoWithTimeoutTolerance(page, normalizedUrl);
217
+ session.pageUrls?.set?.(page, normalizedUrl);
218
+ session.url = normalizedUrl;
219
+ break;
220
+ }
221
+ case "back": {
222
+ addOutput({ type: "action", text: "Going back", ...buildMeta(context, action) });
223
+ await goBackWithTimeoutTolerance(page);
224
+ updateSessionUrlFromPage(session, page);
225
+ break;
226
+ }
227
+ case "forward": {
228
+ addOutput({ type: "action", text: "Going forward", ...buildMeta(context, action) });
229
+ await goForwardWithTimeoutTolerance(page);
230
+ updateSessionUrlFromPage(session, page);
231
+ break;
232
+ }
169
233
  default:
170
234
  addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
171
235
  }
@@ -4,7 +4,10 @@ import { getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64
4
4
  import { handleModelAction as handleBrowserModelAction } from "../browser/actions.js";
5
5
  import { ExecutionEngine } from "../core/execution-engine.js";
6
6
  import { buildExecutionModePrompt } from "../core/prompts.js";
7
+ import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
7
8
  import { ExecutionMode } from "../modes/execution-mode.js";
9
+ import { reportDroidRunCompletion } from "../integrations/loadmill/run-reporter.js";
10
+ import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
8
11
  import { printCliOutput } from "../utils/console-output.js";
9
12
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
10
13
  import { loadHeadlessInstructions } from "./headless-instructions.js";
@@ -27,6 +30,18 @@ function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun)
27
30
  }
28
31
  return path.join(baseScreenshotDir, sanitizePathSegment(testName));
29
32
  }
33
+ function toDroidCuaSegmentRunStatus(success, error = null) {
34
+ if (success) {
35
+ return "pass";
36
+ }
37
+ if (error === "Stopped by user") {
38
+ return "skipped";
39
+ }
40
+ if (typeof error === "string" && error.startsWith("Assertion failed:")) {
41
+ return "failure";
42
+ }
43
+ return "error";
44
+ }
30
45
  function emitCliAppContextDebug(runId, appContext, appContextText) {
31
46
  if (appContext.status === "loaded" || appContext.status === "empty") {
32
47
  emitDesktopDebug("app_context.loaded", "execution", { runId }, {
@@ -56,7 +71,17 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
56
71
  });
57
72
  }
58
73
  }
59
- function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null }) {
74
+ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, onCuaResponse = null, secrets = null }) {
75
+ const sendCUARequest = async (requestArgs) => {
76
+ const response = await runtimeContext.openAi.sendCUARequest({
77
+ ...requestArgs,
78
+ aimAssistMode: headlessConfig.aimAssistMode,
79
+ });
80
+ if (typeof onCuaResponse === "function") {
81
+ onCuaResponse(response);
82
+ }
83
+ return response;
84
+ };
60
85
  const executionEngineOptions = {
61
86
  toolCapabilities: {
62
87
  assert: true,
@@ -72,10 +97,7 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
72
97
  recordScreenshots: true,
73
98
  screenshotRecorder,
74
99
  stepDelayMs: runtimeContext.config.stepDelayMs,
75
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
76
- ...requestArgs,
77
- aimAssistMode: headlessConfig.aimAssistMode,
78
- }),
100
+ sendCUARequest,
79
101
  reviseTestScript: runtimeContext.openAi.reviseTestScript,
80
102
  ...executionEngineOptions,
81
103
  }
@@ -83,10 +105,7 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
83
105
  recordScreenshots,
84
106
  screenshotDir,
85
107
  stepDelayMs: runtimeContext.config.stepDelayMs,
86
- sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
87
- ...requestArgs,
88
- aimAssistMode: headlessConfig.aimAssistMode,
89
- }),
108
+ sendCUARequest,
90
109
  reviseTestScript: runtimeContext.openAi.reviseTestScript,
91
110
  ...executionEngineOptions,
92
111
  };
@@ -102,14 +121,38 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
102
121
  }
103
122
  return engineOptions;
104
123
  }
105
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false }) {
124
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true }) {
106
125
  let executionMode = null;
126
+ let loadmillSessionUser = null;
127
+ let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
128
+ const buildSegmentUsageProperties = () => ({
129
+ model: runUsageTotals.model,
130
+ llmProviderMode: runtimeContext.config.llmProviderMode,
131
+ estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals),
132
+ tokensUsed: runUsageTotals.totalTokens,
133
+ });
134
+ const reportSegmentRunCompleted = async (input) => {
135
+ await reportDroidCuaCiTestRunCompleted({
136
+ ...input,
137
+ runId,
138
+ platform: deviceInfo.platform,
139
+ userId: loadmillSessionUser?.id ?? null,
140
+ teamId: loadmillSessionUser?.teamId ?? null,
141
+ ...buildSegmentUsageProperties(),
142
+ });
143
+ };
107
144
  const reportEnabled = Boolean(report?.enabled);
108
145
  const collectedEvents = [];
109
146
  const addOutput = reportEnabled
110
147
  ? createCapturingAddOutput(printCliOutput, collectedEvents)
111
148
  : printCliOutput;
112
149
  try {
150
+ try {
151
+ loadmillSessionUser = await runtimeContext.loadmillClient?.getCurrentUser?.();
152
+ }
153
+ catch {
154
+ loadmillSessionUser = null;
155
+ }
113
156
  await headlessDebug.startExecutionSession(runId, {
114
157
  testName,
115
158
  ...(testDataContext ? { testDataUsed: true } : {}),
@@ -147,6 +190,9 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
147
190
  screenshotDir,
148
191
  isBrowserTarget,
149
192
  isCloudTarget,
193
+ onCuaResponse: (response) => {
194
+ runUsageTotals = addOpenAiResponseToDroidCuaRunUsage(runUsageTotals, response);
195
+ },
150
196
  secrets
151
197
  });
152
198
  const engine = new ExecutionEngine(session, engineOptions);
@@ -174,6 +220,37 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
174
220
  assertionsFailed: stats.assertionsFailed ?? 0,
175
221
  retries: stats.retryCount ?? 0
176
222
  });
223
+ await reportSegmentRunCompleted({
224
+ status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null),
225
+ durationMs,
226
+ numOfSteps: instructions.length,
227
+ });
228
+ if (reportToLoadmill) {
229
+ await reportDroidRunCompletion({
230
+ loadmillClient: runtimeContext.loadmillClient,
231
+ source: "ci",
232
+ runId,
233
+ projectName: path.basename(path.dirname(testPath)) || null,
234
+ testName,
235
+ runType: "test",
236
+ testCases: 1,
237
+ success: Boolean(result.success),
238
+ error: result.error ?? null,
239
+ completedAt: new Date().toISOString(),
240
+ durationMs,
241
+ platform: deviceInfo.platform,
242
+ deviceSource: deviceInfo.source,
243
+ deviceName: deviceInfo.device_name,
244
+ osVersion: deviceInfo.os_version,
245
+ instructionsTotal: instructions.length,
246
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
247
+ actionsTotal: stats.actionCount ?? 0,
248
+ assertionsPassed: stats.assertionsPassed ?? 0,
249
+ assertionsFailed: stats.assertionsFailed ?? 0,
250
+ retries: stats.retryCount ?? 0,
251
+ onWarning: (message) => console.warn(`Warning: ${message}`),
252
+ });
253
+ }
177
254
  if (!result.success) {
178
255
  console.error(`\nTest failed: ${result.error}`);
179
256
  }
@@ -199,7 +276,14 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
199
276
  }
200
277
  return {
201
278
  success: Boolean(result.success),
202
- error: result.error ?? null
279
+ error: result.error ?? null,
280
+ durationMs,
281
+ instructionsTotal: instructions.length,
282
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
283
+ actionsTotal: stats.actionCount ?? 0,
284
+ assertionsPassed: stats.assertionsPassed ?? 0,
285
+ assertionsFailed: stats.assertionsFailed ?? 0,
286
+ retries: stats.retryCount ?? 0
203
287
  };
204
288
  }
205
289
  catch (error) {
@@ -221,6 +305,37 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
221
305
  }
222
306
  : { reason: "start_failed" })
223
307
  });
308
+ await reportSegmentRunCompleted({
309
+ status: "error",
310
+ durationMs,
311
+ numOfSteps: instructions.length,
312
+ });
313
+ if (reportToLoadmill) {
314
+ await reportDroidRunCompletion({
315
+ loadmillClient: runtimeContext.loadmillClient,
316
+ source: "ci",
317
+ runId,
318
+ projectName: path.basename(path.dirname(testPath)) || null,
319
+ testName,
320
+ runType: "test",
321
+ testCases: 1,
322
+ success: false,
323
+ error: message,
324
+ completedAt: new Date().toISOString(),
325
+ durationMs,
326
+ platform: deviceInfo.platform,
327
+ deviceSource: deviceInfo.source,
328
+ deviceName: deviceInfo.device_name,
329
+ osVersion: deviceInfo.os_version,
330
+ instructionsTotal: instructions.length,
331
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
332
+ actionsTotal: stats.actionCount ?? 0,
333
+ assertionsPassed: stats.assertionsPassed ?? 0,
334
+ assertionsFailed: stats.assertionsFailed ?? 0,
335
+ retries: stats.retryCount ?? 0,
336
+ onWarning: (warningMessage) => console.warn(`Warning: ${warningMessage}`),
337
+ });
338
+ }
224
339
  throw error;
225
340
  }
226
341
  }
@@ -233,6 +348,16 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
233
348
  console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
234
349
  }
235
350
  let failures = 0;
351
+ const batchStartedAt = Date.now();
352
+ const aggregateStats = {
353
+ durationMs: 0,
354
+ instructionsTotal: 0,
355
+ instructionsCompleted: 0,
356
+ actionsTotal: 0,
357
+ assertionsPassed: 0,
358
+ assertionsFailed: 0,
359
+ retries: 0
360
+ };
236
361
  for (const [index, run] of runs.entries()) {
237
362
  const runId = createHeadlessRunId(index);
238
363
  const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
@@ -262,8 +387,16 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
262
387
  isBrowserTarget,
263
388
  isCloudTarget,
264
389
  report,
265
- isBatchRun
390
+ isBatchRun,
391
+ reportToLoadmill: !isBatchRun
266
392
  });
393
+ aggregateStats.durationMs += result.durationMs ?? 0;
394
+ aggregateStats.instructionsTotal += result.instructionsTotal ?? 0;
395
+ aggregateStats.instructionsCompleted += result.instructionsCompleted ?? 0;
396
+ aggregateStats.actionsTotal += result.actionsTotal ?? 0;
397
+ aggregateStats.assertionsPassed += result.assertionsPassed ?? 0;
398
+ aggregateStats.assertionsFailed += result.assertionsFailed ?? 0;
399
+ aggregateStats.retries += result.retries ?? 0;
267
400
  if (!result.success) {
268
401
  failures += 1;
269
402
  }
@@ -285,6 +418,35 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
285
418
  const passed = runs.length - failures;
286
419
  console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
287
420
  }
421
+ if (isBatchRun) {
422
+ const isScenario = instructionsTarget?.type === "scenario";
423
+ const testName = isScenario ? "Scenario run" : path.basename(instructionsTarget.path);
424
+ await reportDroidRunCompletion({
425
+ loadmillClient: runtimeContext.loadmillClient,
426
+ source: "ci",
427
+ runId: createHeadlessRunId(),
428
+ projectName: isScenario ? path.basename(process.cwd()) : path.basename(instructionsTarget.path),
429
+ testName,
430
+ runType: "project",
431
+ testCases: runs.length,
432
+ outcome: failures > 0 ? "failure" : "pass",
433
+ success: failures === 0,
434
+ error: failures > 0 ? `${failures} of ${runs.length} test case${runs.length === 1 ? "" : "s"} failed` : null,
435
+ completedAt: new Date().toISOString(),
436
+ durationMs: Math.max(aggregateStats.durationMs, Date.now() - batchStartedAt),
437
+ platform: deviceInfo.platform,
438
+ deviceSource: deviceInfo.source,
439
+ deviceName: deviceInfo.device_name,
440
+ osVersion: deviceInfo.os_version,
441
+ instructionsTotal: aggregateStats.instructionsTotal,
442
+ instructionsCompleted: aggregateStats.instructionsCompleted,
443
+ actionsTotal: aggregateStats.actionsTotal,
444
+ assertionsPassed: aggregateStats.assertionsPassed,
445
+ assertionsFailed: aggregateStats.assertionsFailed,
446
+ retries: aggregateStats.retries,
447
+ onWarning: (message) => console.warn(`Warning: ${message}`),
448
+ });
449
+ }
288
450
  return {
289
451
  exitCode: failures === 0 ? 0 : 1,
290
452
  failures
@@ -32,7 +32,7 @@ export async function handleHelp(args, session, context) {
32
32
  addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
33
33
  addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
34
34
  addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
35
- addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, or gpt-5.4-mini' });
35
+ addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, loadmill-beacon, or gpt-5.4-mini' });
36
36
  addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
37
37
  addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
38
38
  addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
@@ -2,15 +2,22 @@ export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
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 OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
5
- export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
6
- export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
5
+ export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL, "loadmill-beacon"];
6
+ export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "loadmill-beacon", "gpt-5.4-mini"];
7
7
  export const CUA_MODEL_OPTIONS = Object.freeze([
8
8
  Object.freeze({
9
9
  id: "loadmill-smart",
10
10
  providerMode: "loadmill",
11
11
  providerLabel: "Loadmill",
12
- name: "Loadmill Smart",
13
- label: "Loadmill Smart (recommended)",
12
+ name: "Loadmill - Smart",
13
+ label: "Loadmill - Smart (recommended)",
14
+ }),
15
+ Object.freeze({
16
+ id: "loadmill-beacon",
17
+ providerMode: "loadmill",
18
+ providerLabel: "Loadmill",
19
+ name: "Loadmill - Beacon",
20
+ label: "Loadmill - Beacon",
14
21
  }),
15
22
  Object.freeze({
16
23
  id: "gpt-5.4",
@@ -0,0 +1,106 @@
1
+ const TOKENS_PER_MILLION = 1_000_000;
2
+ const OPENAI_GPT_5_4_PRICING = Object.freeze({
3
+ inputPerMillionUsd: 2.5,
4
+ });
5
+ const OPENAI_GPT_5_4_MINI_PRICING = Object.freeze({
6
+ inputPerMillionUsd: 0.75,
7
+ });
8
+ const MODEL_PRICING = Object.freeze({
9
+ "gpt-5.4": Object.freeze({
10
+ ...OPENAI_GPT_5_4_PRICING,
11
+ }),
12
+ "gpt-5.4-mini": Object.freeze({
13
+ ...OPENAI_GPT_5_4_MINI_PRICING,
14
+ }),
15
+ // Loadmill Smart currently routes to GPT-5.4 behind the Loadmill LLM gateway.
16
+ // Keep this explicit so pricing can be updated here if the backing model changes.
17
+ "loadmill-smart": Object.freeze({
18
+ ...OPENAI_GPT_5_4_PRICING,
19
+ }),
20
+ });
21
+ function finiteNumber(value) {
22
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
23
+ }
24
+ function trimToNull(value) {
25
+ if (typeof value !== "string") {
26
+ return null;
27
+ }
28
+ const trimmed = value.trim();
29
+ return trimmed || null;
30
+ }
31
+ export function normalizeDroidCuaCostModel(model) {
32
+ if (typeof model !== "string") {
33
+ return null;
34
+ }
35
+ if (model === "gpt-5.4-mini" || model.startsWith("gpt-5.4-mini-")) {
36
+ return "gpt-5.4-mini";
37
+ }
38
+ if (model === "gpt-5.4" || model.startsWith("gpt-5.4-")) {
39
+ return "gpt-5.4";
40
+ }
41
+ if (model === "loadmill-smart" || model === "smart") {
42
+ return "loadmill-smart";
43
+ }
44
+ return null;
45
+ }
46
+ export function createDroidCuaRunUsageTotals({ model = null } = {}) {
47
+ const normalizedModel = trimToNull(model);
48
+ return {
49
+ model: normalizedModel,
50
+ costModel: normalizeDroidCuaCostModel(normalizedModel),
51
+ inputTokens: 0,
52
+ cachedInputTokens: 0,
53
+ outputTokens: 0,
54
+ reasoningOutputTokens: 0,
55
+ totalTokens: 0,
56
+ };
57
+ }
58
+ export function setDroidCuaRunUsageModel(totals, model) {
59
+ const normalizedModel = trimToNull(model);
60
+ if (!normalizedModel) {
61
+ return totals;
62
+ }
63
+ return {
64
+ ...totals,
65
+ model: totals.model ?? normalizedModel,
66
+ costModel: totals.costModel ?? normalizeDroidCuaCostModel(normalizedModel),
67
+ };
68
+ }
69
+ export function addOpenAiResponseToDroidCuaRunUsage(totals, response) {
70
+ const record = response && typeof response === "object" ? response : {};
71
+ const usage = record.usage && typeof record.usage === "object" ? record.usage : null;
72
+ if (!usage) {
73
+ return totals;
74
+ }
75
+ const inputDetails = usage.input_tokens_details && typeof usage.input_tokens_details === "object"
76
+ ? usage.input_tokens_details
77
+ : {};
78
+ const outputDetails = usage.output_tokens_details && typeof usage.output_tokens_details === "object"
79
+ ? usage.output_tokens_details
80
+ : {};
81
+ const observedModel = trimToNull(record.model);
82
+ const inputTokens = finiteNumber(usage.input_tokens);
83
+ const outputTokens = finiteNumber(usage.output_tokens);
84
+ const totalTokens = finiteNumber(usage.total_tokens) || inputTokens + outputTokens;
85
+ return {
86
+ model: totals.model ?? observedModel,
87
+ costModel: totals.costModel ?? normalizeDroidCuaCostModel(observedModel),
88
+ inputTokens: totals.inputTokens + inputTokens,
89
+ cachedInputTokens: totals.cachedInputTokens + finiteNumber(inputDetails.cached_tokens),
90
+ outputTokens: totals.outputTokens + outputTokens,
91
+ reasoningOutputTokens: totals.reasoningOutputTokens + finiteNumber(outputDetails.reasoning_tokens),
92
+ totalTokens: totals.totalTokens + totalTokens,
93
+ };
94
+ }
95
+ export function estimateDroidCuaRunCostUsd(totals) {
96
+ const costModel = totals.costModel ?? normalizeDroidCuaCostModel(totals.model);
97
+ if (!costModel) {
98
+ return null;
99
+ }
100
+ const pricing = MODEL_PRICING[costModel];
101
+ if (!pricing) {
102
+ return null;
103
+ }
104
+ const cost = (totals.inputTokens / TOKENS_PER_MILLION) * pricing.inputPerMillionUsd;
105
+ return Number(cost.toFixed(8));
106
+ }
@@ -0,0 +1,32 @@
1
+ export const DroidCuaSegmentEvent = Object.freeze({
2
+ TEST_RUN_COMPLETED: "Droid CUA test run completed",
3
+ });
4
+ const VALID_TEST_RUN_STATUSES = new Set(["pass", "failure", "error", "skipped"]);
5
+ function finiteNumberOrNull(value) {
6
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
7
+ }
8
+ export function normalizeDroidCuaTestRunStatus(value) {
9
+ return VALID_TEST_RUN_STATUSES.has(value) ? value : "error";
10
+ }
11
+ export function normalizeDroidCuaTestRunDevice(platform) {
12
+ if (platform === "ios")
13
+ return "ios";
14
+ if (platform === "android")
15
+ return "android";
16
+ return "web";
17
+ }
18
+ export function buildDroidCuaTestRunCompletedProperties(input = {}) {
19
+ const estimatedCostUsd = finiteNumberOrNull(input.estimatedCostUsd);
20
+ const tokensUsed = finiteNumberOrNull(input.tokensUsed);
21
+ return {
22
+ status: normalizeDroidCuaTestRunStatus(input.status),
23
+ durationMs: Math.max(0, Math.floor(finiteNumberOrNull(input.durationMs) ?? 0)),
24
+ numOfSteps: Math.max(0, Math.floor(finiteNumberOrNull(input.numOfSteps) ?? 0)),
25
+ device: normalizeDroidCuaTestRunDevice(input.platform),
26
+ ...(input.teamId ? { teamId: input.teamId } : {}),
27
+ ...(input.model ? { model: input.model } : {}),
28
+ ...(input.llmProviderMode ? { llmProviderMode: input.llmProviderMode } : {}),
29
+ ...(estimatedCostUsd != null ? { estimatedCostUsd } : {}),
30
+ ...(tokensUsed != null ? { tokensUsed } : {}),
31
+ };
32
+ }