@loadmill/droid-cua 2.25.0 → 2.27.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 (37) hide show
  1. package/README.md +13 -5
  2. package/build/index.js +210 -2
  3. package/build/src/cli/cloud-status-line.js +58 -26
  4. package/build/src/cli/headless-execution-config.js +11 -0
  5. package/build/src/cli/headless-report.js +105 -15
  6. package/build/src/cli/headless-runner.js +125 -24
  7. package/build/src/cli/headless-target.js +63 -7
  8. package/build/src/cli/loadmill-cloud-credentials.js +11 -0
  9. package/build/src/cli/loadmill-cloud-usage.js +18 -0
  10. package/build/src/commands/help.js +5 -0
  11. package/build/src/core/cua-models.js +7 -0
  12. package/build/src/core/droid-cua-run-usage.js +8 -2
  13. package/build/src/core/execution-engine.js +6 -2
  14. package/build/src/core/report/render-html-report.js +69 -12
  15. package/build/src/core/runtime-config.js +10 -4
  16. package/build/src/device/assertions.js +3 -0
  17. package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
  18. package/build/src/device/cloud/connection.js +18 -6
  19. package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
  20. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
  21. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
  22. package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
  23. package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
  24. package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
  25. package/build/src/device/cloud/progress.js +29 -0
  26. package/build/src/device/cloud/registry.js +2 -1
  27. package/build/src/device/connection.js +3 -0
  28. package/build/src/device/openai.js +1 -0
  29. package/build/src/integrations/loadmill/client.js +14 -0
  30. package/build/src/integrations/loadmill/report-upload-manager.js +221 -0
  31. package/build/src/integrations/loadmill/run-reporter.js +6 -2
  32. package/build/src/modes/execution-mode.js +2 -1
  33. package/build/src/prompts/design.js +9 -1
  34. package/build/src/prompts/execution.js +10 -7
  35. package/build/src/utils/console-output.js +3 -0
  36. package/package.json +1 -1
  37. package/skill/droid-cua/references/running-and-healing.md +3 -3
@@ -18,10 +18,13 @@ const MODEL_PRICING = Object.freeze({
18
18
  "gpt-5.4": Object.freeze({
19
19
  ...OPENAI_GPT_5_4_PRICING,
20
20
  }),
21
- // Loadmill Smart currently routes to GPT-5.4 behind the Loadmill LLM gateway.
21
+ // Loadmill Smart currently routes to GPT-5.6 Terra behind the Loadmill LLM gateway.
22
22
  // Keep this explicit so pricing can be updated here if the backing model changes.
23
23
  "loadmill-smart": Object.freeze({
24
- ...OPENAI_GPT_5_4_PRICING,
24
+ ...OPENAI_GPT_5_6_TERRA_PRICING,
25
+ }),
26
+ "loadmill-pulse": Object.freeze({
27
+ ...OPENAI_GPT_5_6_LUNA_PRICING,
25
28
  }),
26
29
  });
27
30
  function finiteNumber(value) {
@@ -50,6 +53,9 @@ export function normalizeDroidCuaCostModel(model) {
50
53
  if (model === "loadmill-smart" || model === "smart") {
51
54
  return "loadmill-smart";
52
55
  }
56
+ if (model === "loadmill-pulse" || model === "pulse") {
57
+ return "loadmill-pulse";
58
+ }
53
59
  return null;
54
60
  }
55
61
  export function createDroidCuaRunUsageTotals({ model = null } = {}) {
@@ -251,6 +251,7 @@ export class ExecutionEngine {
251
251
  this.navigateToUrl = options.navigateToUrl || navigateBrowserToUrl;
252
252
  this.closeCurrentTab = options.closeCurrentTab || closeCurrentBrowserTab;
253
253
  this.executeLoadmillInstruction = options.executeLoadmillInstruction || executeLoadmillInstruction;
254
+ this.connectToDevice = options.connectToDevice || null;
254
255
  this.toolCapabilities = options.toolCapabilities || {};
255
256
  this.secretsByKey = options.secretsByKey && typeof options.secretsByKey === "object" ? options.secretsByKey : {};
256
257
  this.baseSystemPrompt = options.baseSystemPrompt || session?.systemPrompt || null;
@@ -525,7 +526,7 @@ export class ExecutionEngine {
525
526
  type: "assistant",
526
527
  text: textPart.text,
527
528
  eventType: "assistant_message",
528
- ...eventMeta(),
529
+ ...eventMeta(options.explainMode ? { payload: { explainMode: true } } : {}),
529
530
  });
530
531
  }
531
532
  this.session.addToTranscript(`[Assistant] ${textPart.text}`);
@@ -688,7 +689,10 @@ export class ExecutionEngine {
688
689
  const items = response.output || [];
689
690
  const actionableItems = extractActionableOutputItems(items);
690
691
  const pendingSafetyChecks = collectPendingSafetyChecks(items);
691
- this.emitResponseOutputItems(items, addOutput, eventMeta, options);
692
+ this.emitResponseOutputItems(items, addOutput, eventMeta, {
693
+ ...options,
694
+ explainMode: options.explainMode === true || context?.runtimeConfig?.explainMode === true,
695
+ });
692
696
  if (actionableItems.length === 0) {
693
697
  // No actions = turn complete
694
698
  break;
@@ -83,13 +83,60 @@ function buildVisibleEvents(events) {
83
83
  // full-size lightbox overlay can be rendered. The href links to the CSS `:target`
84
84
  // overlay, so click-to-enlarge works even with JavaScript disabled.
85
85
  function screenshotImg(event, shots) {
86
- const url = event.payload?.screenshotDataUrl;
87
- if (typeof url !== "string" || !url.startsWith("data:"))
86
+ const url = event.payload?.screenshotDataUrl ?? event.payload?.screenshotUrl;
87
+ const isAllowedUrl = typeof url === "string" &&
88
+ (url.startsWith("data:") || url.startsWith("/api/droid-cua/runs/"));
89
+ if (!isAllowedUrl)
88
90
  return "";
89
91
  const index = shots.length + 1;
90
92
  shots.push({ index, url });
91
93
  return `<a class="dc-shot-link" href="#dc-shot-${index}" aria-label="Open screenshot ${index}"><img class="dc-shot" src="${escapeHtml(url)}" alt="Screenshot ${index}" /></a>`;
92
94
  }
95
+ function icon(name, shapes) {
96
+ return `<svg class="dc-icon" data-icon="${name}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${shapes}</svg>`;
97
+ }
98
+ const ICONS = {
99
+ arrowUpDown: icon("arrow-up-down", `<path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/>`),
100
+ botMessageSquare: icon("bot-message-square", `<path d="M12 6V2H8"/><path d="m8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z"/><path d="M2 12h2"/><path d="M9 11v2"/><path d="M15 11v2"/><path d="M20 12h2"/>`),
101
+ camera: icon("camera", `<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>`),
102
+ compass: icon("compass", `<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>`),
103
+ flag: icon("flag", `<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>`),
104
+ keyboard: icon("keyboard", `<path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/>`),
105
+ keyboardOff: icon("keyboard-off", `<path d="M20 4a2 2 0 0 1 2 2"/><path d="M22 6v10.41"/><path d="M7 16h9"/><path d="M9.69 4H20"/><path d="M14 8h.01"/><path d="M18 8h.01"/><path d="m2 2 20 20"/><path d="M20 20H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2"/><path d="M6 8h.01"/><path d="M8 12h.01"/>`),
106
+ keyRound: icon("key-round", `<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>`),
107
+ mousePointer: icon("mouse-pointer-2", `<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/>`),
108
+ moveDiagonal: icon("move-diagonal-2", `<path d="M19 13v6h-6"/><path d="M5 11V5h6"/><path d="m5 5 14 14"/>`),
109
+ rotateCcw: icon("rotate-ccw", `<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>`),
110
+ squareX: icon("square-x", `<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>`),
111
+ timer: icon("timer", `<line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/>`),
112
+ };
113
+ function actionIcon(event) {
114
+ const lowerText = event.text.toLowerCase();
115
+ const toolName = typeof event.payload?.toolName === "string" ? event.payload.toolName : null;
116
+ const isSensitiveTyping = Boolean(event.payload?.sensitive) || lowerText.includes("typing secret");
117
+ if (toolName === "navigate")
118
+ return ICONS.compass;
119
+ if (toolName === "close_current_tab")
120
+ return ICONS.squareX;
121
+ if (toolName === "close_keyboard")
122
+ return ICONS.keyboardOff;
123
+ if (isSensitiveTyping)
124
+ return ICONS.keyRound;
125
+ if (event.actionType === "type" || event.actionType === "keypress" || lowerText.includes("typing"))
126
+ return ICONS.keyboard;
127
+ if (event.actionType === "scroll" || lowerText.includes("scroll"))
128
+ return ICONS.arrowUpDown;
129
+ if (event.actionType === "drag" || lowerText.includes("drag"))
130
+ return ICONS.moveDiagonal;
131
+ if (event.actionType === "screenshot" || lowerText.includes("capturing screen"))
132
+ return ICONS.camera;
133
+ if (event.actionType === "wait" || lowerText.includes("waiting"))
134
+ return ICONS.timer;
135
+ return ICONS.mousePointer;
136
+ }
137
+ function renderAgentText(text) {
138
+ return `<div class="dc-line dc-agent-line">${ICONS.botMessageSquare}<span>${escapeHtml(text)}</span></div>`;
139
+ }
93
140
  // ---------- per-event timeline rendering (mirrors ExecutionLogItem.tsx) ----------
94
141
  function renderEvent(event, shots) {
95
142
  if (isHiddenExecutionLogEvent(event))
@@ -123,10 +170,16 @@ function renderEvent(event, shots) {
123
170
  if (isAssistant) {
124
171
  const stepCompletion = stepCompletionData(event);
125
172
  if (stepCompletion) {
173
+ if (event.payload?.explainMode === true) {
174
+ return `<div class="dc-line">${ICONS.flag}<span>Step complete</span></div>\n${renderAgentText(stepCompletion.reason)}`;
175
+ }
126
176
  const tooltip = stepCompletionReasonTooltip(stepCompletion);
127
- return `<div class="dc-line"${tooltip ? ` title="${escapeHtml(tooltip)}"` : ""}><span aria-hidden="true">&#9873;</span><span>Step complete</span></div>`;
177
+ return `<div class="dc-line"${tooltip ? ` title="${escapeHtml(tooltip)}"` : ""}>${ICONS.flag}<span>Step complete</span></div>`;
128
178
  }
129
- return `<div class="dc-assistant">${escapeHtml(event.text)}</div>`;
179
+ return renderAgentText(event.text);
180
+ }
181
+ if (event.eventType === "reasoning" || event.kind === "reasoning") {
182
+ return renderAgentText(event.text);
130
183
  }
131
184
  const assertion = assertionData(event);
132
185
  if (assertion) {
@@ -138,13 +191,13 @@ function renderEvent(event, shots) {
138
191
  </div>`;
139
192
  }
140
193
  if (event.eventType === "retry") {
141
- return `<div class="dc-line dc-retry">${escapeHtml(event.text)}</div>`;
194
+ return `<div class="dc-line dc-retry">${ICONS.rotateCcw}<span>${escapeHtml(event.text)}</span></div>`;
142
195
  }
143
196
  if (event.kind === "action") {
144
- return `<div class="dc-line">${escapeHtml(event.text)}${screenshotImg(event, shots)}</div>`;
197
+ return `<div class="dc-line">${actionIcon(event)}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
145
198
  }
146
199
  if (event.eventType === "screenshot_captured") {
147
- return `<div class="dc-line">${escapeHtml(event.text)}${screenshotImg(event, shots)}</div>`;
200
+ return `<div class="dc-line">${ICONS.camera}<span>${escapeHtml(event.text)}</span>${screenshotImg(event, shots)}</div>`;
148
201
  }
149
202
  return `<div class="dc-line dc-${escapeHtml(event.kind)}">${escapeHtml(event.text)}</div>`;
150
203
  }
@@ -243,13 +296,16 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
243
296
  .dc-row { display: flex; }
244
297
  .dc-row-right { justify-content: flex-end; }
245
298
  .dc-bubble { display: inline-block; max-width: 70%; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 16px; border-bottom-right-radius: 6px; padding: 8px 14px; color: #0f172a; }
246
- .dc-assistant { max-width: 82%; color: #334155; }
247
299
  .dc-line { display: flex; align-items: center; gap: 8px; color: #475569; }
300
+ .dc-agent-line { align-items: flex-start; }
301
+ .dc-agent-line .dc-icon { margin-top: 4px; }
302
+ .dc-icon { width: 14px; height: 14px; flex: 0 0 auto; color: #94a3b8; }
248
303
  .dc-retry { color: #b45309; }
249
304
  .dc-error { color: #b91c1c; }
250
305
  .dc-warning { color: #b45309; }
251
- .dc-shot-link { display: inline-flex; margin-left: 8px; vertical-align: middle; }
252
- .dc-shot { height: 34px; width: auto; border: 1px solid #e2e8f0; border-radius: 4px; cursor: zoom-in; transition: border-color .12s; }
306
+ .dc-shot-link { display: inline-flex; align-items: center; justify-content: center; position: relative; min-width: 34px; min-height: 36px; margin-left: 8px; vertical-align: middle; }
307
+ .dc-shot-link::before, .dc-lightbox:target::before { content: ""; position: absolute; width: 14px; height: 14px; border: 2px solid #cbd5e1; border-top-color: #475569; border-radius: 50%; animation: dc-spin .7s linear infinite; }
308
+ .dc-shot { position: relative; z-index: 1; height: 34px; width: auto; border: 1px solid #e2e8f0; border-radius: 4px; cursor: zoom-in; transition: border-color .12s; }
253
309
  .dc-shot-link:hover .dc-shot, .dc-shot-link:focus-visible .dc-shot { border-color: #94a3b8; }
254
310
  .dc-lightbox { display: none; position: fixed; inset: 0; z-index: 1000; align-items: center; justify-content: center; padding: 32px; background: rgba(15, 23, 42, .82); }
255
311
  .dc-lightbox:target { display: flex; }
@@ -275,9 +331,10 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
275
331
  .dc-pill-error, .dc-pill-errored, .dc-pill-amber { border-color: #fcd34d; background: #fef3c7; color: #b45309; }
276
332
  .dc-pill-stopped { border-color: #fdba74; background: #ffedd5; color: #c2410c; }
277
333
  .dc-pill-skipped { border-color: #cbd5e1; background: #f1f5f9; color: #334155; }
334
+ @keyframes dc-spin { to { transform: rotate(360deg); } }
278
335
  `.trim();
279
336
  // ---------- entry point ----------
280
- export function renderHtmlReport(model, { title } = {}) {
337
+ export function renderHtmlReport(model, { title, includeScripts = true } = {}) {
281
338
  const docTitle = title ?? `Run Report: ${model.testName ?? model.suiteName ?? "Test run"}`;
282
339
  const heading = model.testName ?? model.suiteName ?? "Test run";
283
340
  // Screenshots are collected across the whole document (top-level + child timelines)
@@ -301,7 +358,7 @@ ${renderTimeline(model.events, shots)}
301
358
  </div>`;
302
359
  }
303
360
  const lightboxes = renderLightboxes(shots);
304
- const script = renderLightboxScript(shots.length);
361
+ const script = includeScripts ? renderLightboxScript(shots.length) : "";
305
362
  return `<!DOCTYPE html>
306
363
  <html lang="en">
307
364
  <head>
@@ -7,6 +7,7 @@ export const LOADMILL_MODEL_ALIASES = Object.freeze({
7
7
  DEFAULT_AGENT_MODEL: "default-agent-model",
8
8
  FAST: "fast",
9
9
  SMART: "smart",
10
+ PULSE: "pulse",
10
11
  BEACON: "beacon",
11
12
  });
12
13
  const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
@@ -90,9 +91,13 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
90
91
  }
91
92
  export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
92
93
  const normalizedCuaModel = resolveCuaModel(cuaModel);
93
- return normalizedCuaModel === "loadmill-beacon"
94
- ? LOADMILL_MODEL_ALIASES.BEACON
95
- : LOADMILL_MODEL_ALIASES.SMART;
94
+ if (normalizedCuaModel === "loadmill-pulse") {
95
+ return LOADMILL_MODEL_ALIASES.PULSE;
96
+ }
97
+ if (normalizedCuaModel === "loadmill-beacon") {
98
+ return LOADMILL_MODEL_ALIASES.BEACON;
99
+ }
100
+ return LOADMILL_MODEL_ALIASES.SMART;
96
101
  }
97
102
  export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
98
103
  const providerMode = resolveLlmProviderMode(llmProviderMode);
@@ -118,7 +123,7 @@ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToke
118
123
  loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
119
124
  };
120
125
  }
121
- export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
126
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = false, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
122
127
  const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
123
128
  ? cuaModel
124
129
  : resolveCuaModel(cuaModel);
@@ -128,6 +133,7 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null,
128
133
  ? "loadmill"
129
134
  : normalizedProviderMode,
130
135
  cuaModel: normalizedCuaModel,
136
+ explainMode: explainMode === true,
131
137
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
132
138
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
133
139
  contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
@@ -2,6 +2,7 @@
2
2
  * Assertion handling for script validation
3
3
  */
4
4
  import { printCliOutput } from "../utils/console-output.js";
5
+ import { CAPTURE_SOURCES } from "../core/capture-sources.js";
5
6
  export { buildAssertionSystemPrompt } from "../prompts/execution.js";
6
7
  const ASSERTION_PREFIXES = ["assert", "verify", "validate", "ensure"];
7
8
  function hasAssertionPrefix(lower) {
@@ -54,6 +55,7 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
54
55
  assertion: assertionPrompt,
55
56
  passed: false,
56
57
  details,
58
+ captureSource: CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
57
59
  ...eventPayload
58
60
  }
59
61
  };
@@ -77,6 +79,7 @@ export function handleAssertionSuccess(assertionPrompt, context = null, stepCont
77
79
  payload: {
78
80
  assertion: assertionPrompt,
79
81
  passed: true,
82
+ captureSource: CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
80
83
  ...eventPayload
81
84
  }
82
85
  });
@@ -7,7 +7,7 @@ import { reportCloudProgress } from "../progress.js";
7
7
  export const DEFAULT_UPLOAD_CACHE_PATH = path.join(os.tmpdir(), "droid-cua", "aws-device-farm-upload-cache.json");
8
8
  // Stream the binary through the hash rather than buffering the whole .apk/.ipa
9
9
  // (tens–hundreds of MB) in one allocation just to checksum it.
10
- async function hashFile(filePath) {
10
+ export async function hashFile(filePath) {
11
11
  const hash = createHash("sha256");
12
12
  for await (const chunk of createReadStream(filePath)) {
13
13
  hash.update(chunk);
@@ -4,7 +4,7 @@ import { CloudAppiumClient } from "./cloud-appium-client.js";
4
4
  import { reportCloudPhase } from "./progress.js";
5
5
  let activeSession = null;
6
6
  function buildCloudCapabilities(options) {
7
- const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName } = options;
7
+ const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName, cloudSessionId, artifacts } = options;
8
8
  const adapter = getCloudProviderAdapter(providerId);
9
9
  if (!adapter) {
10
10
  throw new Error(`Cloud provider "${providerId}" is not registered.`);
@@ -20,7 +20,9 @@ function buildCloudCapabilities(options) {
20
20
  accessKey: credentials?.accessKey,
21
21
  projectName: "droid-cua",
22
22
  buildName: buildName || `desktop-${new Date().toISOString().slice(0, 10)}`,
23
- sessionName: sessionName || `${deviceName} (${platform})`
23
+ sessionName: sessionName || `${deviceName} (${platform})`,
24
+ cloudSessionId,
25
+ artifacts
24
26
  }
25
27
  };
26
28
  }
@@ -29,11 +31,19 @@ export async function connectToDevice(options, logOptions = {}) {
29
31
  if (!options || typeof options !== "object") {
30
32
  throw new Error("Cloud device connection requires a provider payload.");
31
33
  }
32
- const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName } = options;
33
- if (!providerId || !platform || !deviceName || !osVersion || !appRef) {
34
+ const { providerId, platform, deviceName, osVersion, appRef, credentials, buildName, sessionName, cloudSessionId, artifacts } = options;
35
+ const isReconnect = providerId === "loadmill-cloud" && Boolean(cloudSessionId);
36
+ if (!providerId ||
37
+ (!isReconnect && (!platform || !deviceName || !osVersion)) ||
38
+ (!appRef && !isReconnect)) {
34
39
  throw new Error("Cloud device connection is missing required session details.");
35
40
  }
36
- if (!credentials?.username || !credentials?.accessKey) {
41
+ if (providerId === "loadmill-cloud") {
42
+ if (!credentials?.bearerToken) {
43
+ throw new Error("Loadmill Cloud connection is missing a Loadmill API token.");
44
+ }
45
+ }
46
+ else if (!credentials?.username || !credentials?.accessKey) {
37
47
  throw new Error("Cloud device connection is missing provider credentials.");
38
48
  }
39
49
  const { adapter, capabilities } = buildCloudCapabilities({
@@ -44,7 +54,9 @@ export async function connectToDevice(options, logOptions = {}) {
44
54
  appRef,
45
55
  credentials,
46
56
  buildName,
47
- sessionName
57
+ sessionName,
58
+ cloudSessionId,
59
+ artifacts
48
60
  });
49
61
  // Optional AbortSignal (Stop/Pause during provisioning). It rides inside the
50
62
  // cloud-options object — see the facade in src/device/connection.js — so no