@loadmill/droid-cua 2.29.0 → 2.30.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 +4 -4
  2. package/build/index.js +142 -10
  3. package/build/src/browser/cloud-browser-catalog.js +27 -0
  4. package/build/src/browser/factory.js +33 -10
  5. package/build/src/browser/gesture-dispatch.js +281 -0
  6. package/build/src/browser/interface.js +3 -0
  7. package/build/src/browser/normalize.js +3 -1
  8. package/build/src/browser/playwright/actions.js +5 -271
  9. package/build/src/browser/webdriver/actions.js +10 -0
  10. package/build/src/browser/webdriver/client.js +83 -0
  11. package/build/src/browser/webdriver/connection.js +300 -0
  12. package/build/src/browser/webdriver/page-facade.js +123 -0
  13. package/build/src/browser/webdriver/session-state.js +35 -0
  14. package/build/src/cli/headless-execution-config.js +1 -1
  15. package/build/src/cli/headless-runner.js +26 -13
  16. package/build/src/cli/headless-target.js +87 -16
  17. package/build/src/cli/headless-test-source-label.js +4 -0
  18. package/build/src/cli/loadmill-cloud-device-connect-summary.js +6 -0
  19. package/build/src/cli/loadmill-cloud-session.js +26 -0
  20. package/build/src/core/context-optimization.js +1 -1
  21. package/build/src/core/execution-engine.js +9 -0
  22. package/build/src/core/runtime-config.js +2 -2
  23. package/build/src/desktop-shared/connection-predicates.js +52 -0
  24. package/build/src/device/cloud/loadmill-cloud/adapter.js +17 -12
  25. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +13 -7
  26. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +22 -5
  27. package/build/src/device/cloud/loadmill-cloud/errors.js +25 -0
  28. package/build/src/device/cloud/loadmill-cloud/run-pool.js +41 -0
  29. package/build/src/device/cloud/loadmill-cloud/run-status.js +4 -0
  30. package/package.json +1 -1
  31. package/skill/droid-cua/SKILL.md +10 -3
  32. package/skill/droid-cua/agents/openai.yaml +2 -2
  33. package/skill/droid-cua/references/authoring-tests.md +36 -6
  34. package/skill/droid-cua/references/iterative-improvement.md +143 -0
  35. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +19 -0
  36. package/skill/droid-cua/references/project-and-auth.md +2 -0
  37. package/skill/droid-cua/references/running-and-healing.md +32 -14
@@ -0,0 +1,123 @@
1
+ const W3C_KEY_CODES = {
2
+ Backspace: "",
3
+ Tab: "",
4
+ Enter: "",
5
+ Shift: "",
6
+ Control: "",
7
+ Alt: "",
8
+ Escape: "",
9
+ Space: "",
10
+ PageUp: "",
11
+ PageDown: "",
12
+ End: "",
13
+ Home: "",
14
+ ArrowLeft: "",
15
+ ArrowUp: "",
16
+ ArrowRight: "",
17
+ ArrowDown: "",
18
+ Delete: "",
19
+ Meta: "",
20
+ };
21
+ const SCROLL_BY_SCRIPT = "window.scrollBy(arguments[0], arguments[1]);";
22
+ function keyCodeFor(name) {
23
+ return W3C_KEY_CODES[name] ?? name;
24
+ }
25
+ function pointerSource(steps) {
26
+ return [{ type: "pointer", id: "mouse", parameters: { pointerType: "mouse" }, actions: steps }];
27
+ }
28
+ function keySource(steps) {
29
+ return [{ type: "key", id: "keyboard", actions: steps }];
30
+ }
31
+ /**
32
+ * A Playwright-`page`-shaped facade over a `WebDriverClient`, exposing only
33
+ * the surface the shared gesture dispatch drives and encoding every gesture
34
+ * to its exact W3C JSON body.
35
+ *
36
+ * @param {{ client: import("./client.js").WebDriverClient, scrollMode?: "wheel"|"script" }} options
37
+ */
38
+ export function createWebDriverPageFacade({ client, scrollMode = "wheel" }) {
39
+ return {
40
+ mouse: {
41
+ async click(x, y) {
42
+ await client.performActions(pointerSource([
43
+ { type: "pointerMove", duration: 0, x, y },
44
+ { type: "pointerDown", button: 0 },
45
+ { type: "pause", duration: 100 },
46
+ { type: "pointerUp", button: 0 },
47
+ ]));
48
+ },
49
+ async dblclick(x, y) {
50
+ await client.performActions(pointerSource([
51
+ { type: "pointerMove", duration: 0, x, y },
52
+ { type: "pointerDown", button: 0 },
53
+ { type: "pointerUp", button: 0 },
54
+ { type: "pointerDown", button: 0 },
55
+ { type: "pointerUp", button: 0 },
56
+ ]));
57
+ },
58
+ async move(x, y) {
59
+ await client.performActions(pointerSource([{ type: "pointerMove", duration: 0, x, y }]));
60
+ },
61
+ async down() {
62
+ await client.performActions(pointerSource([{ type: "pointerDown", button: 0 }]));
63
+ },
64
+ async up() {
65
+ await client.performActions(pointerSource([{ type: "pointerUp", button: 0 }]));
66
+ },
67
+ async wheel(deltaX, deltaY) {
68
+ if (scrollMode === "script") {
69
+ await client.executeScript(SCROLL_BY_SCRIPT, [deltaX, deltaY]);
70
+ return;
71
+ }
72
+ await client.performActions([
73
+ {
74
+ type: "wheel",
75
+ id: "wheel",
76
+ actions: [{ type: "scroll", x: 0, y: 0, deltaX, deltaY, duration: 0 }],
77
+ },
78
+ ]);
79
+ },
80
+ },
81
+ keyboard: {
82
+ async down(key) {
83
+ await client.performActions(keySource([{ type: "keyDown", value: key }]));
84
+ },
85
+ async up(key) {
86
+ await client.performActions(keySource([{ type: "keyUp", value: key }]));
87
+ },
88
+ async press(shortcut) {
89
+ const codes = String(shortcut).split("+").map(keyCodeFor);
90
+ const downs = codes.map((value) => ({ type: "keyDown", value }));
91
+ const ups = [...codes].reverse().map((value) => ({ type: "keyUp", value }));
92
+ await client.performActions(keySource([...downs, ...ups]));
93
+ },
94
+ async type(text) {
95
+ const steps = [];
96
+ for (const char of String(text)) {
97
+ steps.push({ type: "keyDown", value: char });
98
+ steps.push({ type: "keyUp", value: char });
99
+ }
100
+ await client.performActions(keySource(steps));
101
+ },
102
+ },
103
+ async goto(url) {
104
+ await client.navigateTo(url);
105
+ },
106
+ async goBack() {
107
+ await client.goBack();
108
+ },
109
+ async goForward() {
110
+ await client.goForward();
111
+ },
112
+ async url() {
113
+ return await client.executeScript("return window.location.href;", []);
114
+ },
115
+ async screenshot() {
116
+ const base64 = await client.getScreenshot();
117
+ return Buffer.from(base64, "base64");
118
+ },
119
+ async waitForTimeout(ms) {
120
+ await new Promise((resolve) => setTimeout(resolve, ms));
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,35 @@
1
+ let activeSession = null;
2
+ export function setActiveWebDriverSession(session) {
3
+ activeSession = session;
4
+ }
5
+ export function clearActiveWebDriverSession() {
6
+ activeSession = null;
7
+ }
8
+ export function getActiveWebDriverSession(browserSessionId = null) {
9
+ if (!activeSession) {
10
+ throw new Error("Not attached to a WebDriver session. Call connectToBrowser first.");
11
+ }
12
+ if (browserSessionId && activeSession.sessionId !== browserSessionId) {
13
+ throw new Error("Requested WebDriver session is no longer active.");
14
+ }
15
+ return activeSession;
16
+ }
17
+ export function getActiveWebDriverPage(browserSessionId = null) {
18
+ return getActiveWebDriverSession(browserSessionId).page;
19
+ }
20
+ export function reportRunningOnFirstUse(browserSessionId = null) {
21
+ let session = null;
22
+ try {
23
+ session = getActiveWebDriverSession(browserSessionId);
24
+ }
25
+ catch {
26
+ return;
27
+ }
28
+ if (!session.cloud || session.cloud.runningReported) {
29
+ return;
30
+ }
31
+ session.cloud.runningReported = true;
32
+ session.cloud.client.reportStatus(session.cloud.sessionId, "RUNNING").catch((error) => {
33
+ console.error(`[loadmill-cloud] failed to report RUNNING status: ${error.message}`);
34
+ });
35
+ }
@@ -224,7 +224,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
224
224
  llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
225
225
  cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
226
226
  aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
227
- explainMode: fileConfig?.explainMode ?? false,
227
+ explainMode: fileConfig?.explainMode ?? true,
228
228
  promptCustomizations,
229
229
  appContextEnabled: fileConfig?.appContextEnabled ?? true,
230
230
  appContextPath: fileConfig?.appContextPath || null,
@@ -12,7 +12,9 @@ import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-
12
12
  import { printCliOutput } from "../utils/console-output.js";
13
13
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
14
14
  import { loadHeadlessInstructions } from "./headless-instructions.js";
15
+ import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
15
16
  import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
17
+ import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
16
18
  function sanitizePathSegment(value) {
17
19
  return String(value || "test")
18
20
  .replace(/\.dcua$/i, "")
@@ -80,7 +82,10 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
80
82
  });
81
83
  }
82
84
  }
83
- function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, onCuaResponse = null, secrets = null }) {
85
+ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
86
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
87
+ const isBrowserRunTarget = Boolean(resolvedRunTarget.isBrowserTarget);
88
+ const isCloudRunTarget = Boolean(resolvedRunTarget.isCloudTarget);
84
89
  const sendCUARequest = async (requestArgs) => {
85
90
  const response = await runtimeContext.openAi.sendCUARequest({
86
91
  ...requestArgs,
@@ -94,8 +99,8 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
94
99
  const executionEngineOptions = {
95
100
  toolCapabilities: {
96
101
  assert: true,
97
- navigate: isBrowserTarget,
98
- closeKeyboard: !isBrowserTarget,
102
+ navigate: isBrowserRunTarget,
103
+ closeKeyboard: !isBrowserRunTarget,
99
104
  loadmill: true,
100
105
  },
101
106
  secretsByKey: secrets?.secretsByKey || {},
@@ -119,19 +124,24 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
119
124
  reviseTestScript: runtimeContext.openAi.reviseTestScript,
120
125
  ...executionEngineOptions,
121
126
  };
122
- if (isBrowserTarget) {
127
+ if (isBrowserRunTarget) {
123
128
  engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
124
129
  engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
125
130
  engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
126
131
  }
127
- else if (isCloudTarget) {
128
- engineOptions.connectToDevice = async () => {
129
- throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
130
- };
132
+ if (isCloudRunTarget) {
133
+ if (isBrowserRunTarget) {
134
+ engineOptions.connectToBrowser = createRefuseReconnectConnectToBrowser();
135
+ }
136
+ else {
137
+ engineOptions.connectToDevice = async () => {
138
+ throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
139
+ };
140
+ }
131
141
  }
132
142
  return engineOptions;
133
143
  }
134
- 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, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
144
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
135
145
  let executionMode = null;
136
146
  let loadmillSessionUser = null;
137
147
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
@@ -181,7 +191,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
181
191
  if (currentLogFilePath) {
182
192
  console.log(`Debug logging enabled: ${currentLogFilePath}`);
183
193
  }
184
- console.log(`\nRunning test from: ${testPath}\n`);
194
+ console.log(`\nRunning test from: ${formatHeadlessTestSourceLabel(testPath)}\n`);
185
195
  const appContextText = appContext.text;
186
196
  emitCliAppContextDebug(runId, appContext, appContextText);
187
197
  session.updateResponseId(undefined);
@@ -208,6 +218,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
208
218
  screenshotDir,
209
219
  isBrowserTarget,
210
220
  isCloudTarget,
221
+ runTarget,
211
222
  onCuaResponse: (response) => {
212
223
  runUsageTotals = addOpenAiResponseToDroidCuaRunUsage(runUsageTotals, response);
213
224
  },
@@ -414,7 +425,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
414
425
  throw error;
415
426
  }
416
427
  }
417
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
428
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
429
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
418
430
  const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
419
431
  const cloudReportEnabled = report?.cloudEnabled !== false &&
420
432
  Boolean(runtimeContext.loadmillClient?.getApiToken?.());
@@ -468,8 +480,9 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
468
480
  secrets,
469
481
  recordScreenshots,
470
482
  screenshotDir: testScreenshotDir,
471
- isBrowserTarget,
472
- isCloudTarget,
483
+ isBrowserTarget: resolvedRunTarget.isBrowserTarget,
484
+ isCloudTarget: resolvedRunTarget.isCloudTarget,
485
+ runTarget: resolvedRunTarget,
473
486
  report,
474
487
  isBatchRun,
475
488
  reportToLoadmill: !isBatchRun,
@@ -1,3 +1,4 @@
1
+ import { CLOUD_BROWSER_FAMILIES, CLOUD_BROWSER_VERSION_ALIASES, LOCAL_BROWSER_FAMILIES, } from "../browser/cloud-browser-catalog.js";
1
2
  function normalizeTarget(value) {
2
3
  if (value == null || value === "") {
3
4
  return "mobile";
@@ -24,18 +25,31 @@ function normalizeSessionMode(value) {
24
25
  }
25
26
  throw new Error("--session-mode must be one of: persistent, fresh.");
26
27
  }
27
- function normalizeBrowser(value) {
28
+ function normalizeBrowser(value, allowedFamilies) {
28
29
  if (value == null || value === "") {
29
30
  return "chrome";
30
31
  }
31
32
  if (typeof value !== "string") {
32
- throw new Error("--browser must be one of: chrome, edge.");
33
+ throw new Error(`--browser must be one of: ${allowedFamilies.join(", ")}.`);
33
34
  }
34
35
  const normalized = value.trim().toLowerCase();
35
- if (normalized === "chrome" || normalized === "edge") {
36
+ if (allowedFamilies.includes(normalized)) {
36
37
  return normalized;
37
38
  }
38
- throw new Error("--browser must be one of: chrome, edge.");
39
+ throw new Error(`--browser must be one of: ${allowedFamilies.join(", ")}.`);
40
+ }
41
+ function normalizeCloudBrowserVersion(value) {
42
+ if (value == null || value === "") {
43
+ return "latest";
44
+ }
45
+ if (typeof value !== "string") {
46
+ throw new Error(`--browser-version must be one of: ${CLOUD_BROWSER_VERSION_ALIASES.join(", ")}.`);
47
+ }
48
+ const normalized = value.trim().toLowerCase();
49
+ if (CLOUD_BROWSER_VERSION_ALIASES.includes(normalized)) {
50
+ return normalized;
51
+ }
52
+ throw new Error(`--browser-version must be one of: ${CLOUD_BROWSER_VERSION_ALIASES.join(", ")}.`);
39
53
  }
40
54
  const CLOUD_SOURCE_DISPLAY_NAMES = {
41
55
  lambdatest: "LambdaTest",
@@ -80,25 +94,48 @@ function readNonEmptyCliValue(value) {
80
94
  }
81
95
  return null;
82
96
  }
83
- const ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
84
- function normalizeArtifactKinds(value) {
97
+ export const MOBILE_ARTIFACT_KINDS = ["video", "deviceLog", "appiumLog"];
98
+ export const WEB_ARTIFACT_KINDS = ["video", "seleniumLog"];
99
+ const UNRESOLVED_POOL_ARTIFACT_KINDS = [...MOBILE_ARTIFACT_KINDS, "seleniumLog"];
100
+ const ARTIFACT_KINDS_BY_POOL = {
101
+ mobile: MOBILE_ARTIFACT_KINDS,
102
+ web: WEB_ARTIFACT_KINDS,
103
+ };
104
+ export function artifactKindsForPool(pool) {
105
+ const kinds = ARTIFACT_KINDS_BY_POOL[pool];
106
+ if (!kinds) {
107
+ throw new Error(`Unknown Loadmill Cloud pool: ${pool}. Expected "mobile" or "web".`);
108
+ }
109
+ return [...kinds];
110
+ }
111
+ export function scopeArtifactKindsToPool(kinds, pool) {
112
+ const allowed = artifactKindsForPool(pool);
113
+ return kinds.filter((kind) => allowed.includes(kind));
114
+ }
115
+ function allowedArtifactKindsForTarget(target, poolKnown) {
116
+ if (!poolKnown) {
117
+ return UNRESOLVED_POOL_ARTIFACT_KINDS;
118
+ }
119
+ return target === "web" ? WEB_ARTIFACT_KINDS : MOBILE_ARTIFACT_KINDS;
120
+ }
121
+ function normalizeArtifactKinds(value, allowedKinds) {
85
122
  if (value == null || value === "") {
86
123
  return null;
87
124
  }
88
125
  if (typeof value !== "string") {
89
- throw new Error(`--artifacts must be a comma-separated list of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
126
+ throw new Error(`--artifacts must be a comma-separated list of: ${allowedKinds.join(", ")}, or all.`);
90
127
  }
91
128
  const raw = value.trim();
92
129
  if (!raw) {
93
130
  return null;
94
131
  }
95
132
  if (raw.toLowerCase() === "all") {
96
- return [...ARTIFACT_KINDS];
133
+ return [...allowedKinds];
97
134
  }
98
135
  const kinds = [...new Set(raw.split(",").map((kind) => kind.trim()).filter(Boolean))];
99
- const invalid = kinds.filter((kind) => !ARTIFACT_KINDS.includes(kind));
136
+ const invalid = kinds.filter((kind) => !allowedKinds.includes(kind));
100
137
  if (invalid.length > 0) {
101
- throw new Error(`--artifacts has invalid kind(s): ${invalid.join(", ")}. Must be one of: ${ARTIFACT_KINDS.join(", ")}, or all.`);
138
+ throw new Error(`--artifacts has invalid kind(s): ${invalid.join(", ")}. Must be one of: ${allowedKinds.join(", ")}, or all.`);
102
139
  }
103
140
  return kinds;
104
141
  }
@@ -144,9 +181,10 @@ function normalizeCloudAppPath(value, platform, deviceSource) {
144
181
  export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
145
182
  return Array.isArray(targetConfig?.artifacts) && targetConfig.artifacts.length > 0;
146
183
  }
184
+ const MOBILE_ONLY_CLOUD_SOURCES = new Set(["lambdatest", "aws-device-farm"]);
147
185
  export function resolveHeadlessTarget(args = {}) {
148
186
  const target = normalizeTarget(args.target);
149
- const sessionMode = normalizeSessionMode(args["session-mode"]);
187
+ const targetGiven = hasNonEmptyString(args.target);
150
188
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
151
189
  const platform = normalizeMobilePlatform(args.platform, "--platform");
152
190
  // The mtc_ prefix on --cloud-session-id is loadmill-cloud's; when no
@@ -164,14 +202,44 @@ export function resolveHeadlessTarget(args = {}) {
164
202
  if (avdName || platform) {
165
203
  throw new Error("--avd and --platform cannot be used with --target web.");
166
204
  }
167
- if (deviceSource !== "local") {
168
- throw new Error("--device-source can only be used with --target mobile.");
205
+ if (MOBILE_ONLY_CLOUD_SOURCES.has(deviceSource)) {
206
+ throw new Error(`--device-source ${deviceSource} can only be used with --target mobile.`);
207
+ }
208
+ if (hasNonEmptyString(args["device-name"])) {
209
+ throw new Error("--device-name cannot be used with --target web.");
210
+ }
211
+ if (readNonEmptyCliValue(args["os-version"]) != null) {
212
+ throw new Error("--os-version cannot be used with --target web.");
213
+ }
214
+ if (hasNonEmptyString(args.app)) {
215
+ throw new Error("--app cannot be used with --target web.");
216
+ }
217
+ if (deviceSource === "loadmill-cloud") {
218
+ if (args["session-mode"] != null) {
219
+ throw new Error("--session-mode can only be used with --device-source local.");
220
+ }
221
+ return {
222
+ target,
223
+ sessionMode: null,
224
+ browserFamily: normalizeBrowser(args.browser, CLOUD_BROWSER_FAMILIES),
225
+ browserVersion: normalizeCloudBrowserVersion(args["browser-version"]),
226
+ deviceSource: "loadmill-cloud",
227
+ avdName: null,
228
+ platform: null,
229
+ deviceName: null,
230
+ osVersion: null,
231
+ appPath: null,
232
+ cloudSessionId: readNonEmptyCliValue(args["cloud-session-id"]),
233
+ artifacts: normalizeArtifactKinds(args.artifacts, WEB_ARTIFACT_KINDS),
234
+ listDevices: false,
235
+ };
169
236
  }
170
237
  assertNoCloudFlags(args);
171
238
  return {
172
239
  target,
173
- sessionMode,
174
- browserFamily: normalizeBrowser(args.browser),
240
+ sessionMode: normalizeSessionMode(args["session-mode"]),
241
+ browserFamily: normalizeBrowser(args.browser, LOCAL_BROWSER_FAMILIES),
242
+ browserVersion: null,
175
243
  deviceSource: "local",
176
244
  avdName: null,
177
245
  platform: null,
@@ -195,6 +263,7 @@ export function resolveHeadlessTarget(args = {}) {
195
263
  target,
196
264
  sessionMode: null,
197
265
  browserFamily: null,
266
+ browserVersion: null,
198
267
  deviceSource,
199
268
  avdName: null,
200
269
  platform: null,
@@ -215,7 +284,7 @@ export function resolveHeadlessTarget(args = {}) {
215
284
  const cloudSessionId = readNonEmptyCliValue(args["cloud-session-id"]);
216
285
  const isReconnect = deviceSource === "loadmill-cloud" && Boolean(cloudSessionId);
217
286
  const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
218
- const artifacts = normalizeArtifactKinds(args.artifacts);
287
+ const artifacts = normalizeArtifactKinds(args.artifacts, allowedArtifactKindsForTarget(target, targetGiven || !isReconnect));
219
288
  if (artifacts && deviceSource !== "loadmill-cloud") {
220
289
  throw new Error("--artifacts can only be used with --device-source loadmill-cloud.");
221
290
  }
@@ -241,6 +310,7 @@ export function resolveHeadlessTarget(args = {}) {
241
310
  target,
242
311
  sessionMode: null,
243
312
  browserFamily: null,
313
+ browserVersion: null,
244
314
  deviceSource,
245
315
  avdName: null,
246
316
  platform,
@@ -256,6 +326,7 @@ export function resolveHeadlessTarget(args = {}) {
256
326
  target,
257
327
  sessionMode: null,
258
328
  browserFamily: null,
329
+ browserVersion: null,
259
330
  deviceSource,
260
331
  avdName,
261
332
  platform,
@@ -0,0 +1,4 @@
1
+ const INLINE_SOURCE_PATTERN = /^\/dev\/(fd\/\d+|stdin)$/;
2
+ export function formatHeadlessTestSourceLabel(sourcePath) {
3
+ return INLINE_SOURCE_PATTERN.test(sourcePath) ? "(inline instructions)" : sourcePath;
4
+ }
@@ -0,0 +1,6 @@
1
+ export function buildLoadmillCloudDeviceConnectSummaryLines({ deviceInfo }) {
2
+ return [
3
+ `Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`,
4
+ `Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`,
5
+ ];
6
+ }
@@ -0,0 +1,26 @@
1
+ export async function withLoadmillCloudSession({ credentials, connect, teardown, run = null }) {
2
+ const targetId = await connect(credentials);
3
+ if (typeof run !== "function") {
4
+ return targetId;
5
+ }
6
+ try {
7
+ return await run(targetId);
8
+ }
9
+ finally {
10
+ await teardown();
11
+ }
12
+ }
13
+ export function createBrowserSessionTeardown({ disconnect, releaseSession }) {
14
+ return async () => {
15
+ try {
16
+ await disconnect();
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ console.error(`[loadmill-cloud] browser disconnect failed: ${message}`);
21
+ }
22
+ finally {
23
+ await releaseSession();
24
+ }
25
+ };
26
+ }
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_CONTEXT_OPTIMIZATION_ENABLED = true;
2
- export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 30000;
2
+ export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 60000;
3
3
  export const MIN_CONTEXT_OPTIMIZATION_THRESHOLD = 10000;
4
4
  export const MAX_CONTEXT_OPTIMIZATION_THRESHOLD = 900000;
5
5
  export const CONTEXT_OPTIMIZATION_ENABLED_ENV = "DROID_CUA_CONTEXT_OPTIMIZATION_ENABLED";
@@ -503,6 +503,15 @@ export class ExecutionEngine {
503
503
  }
504
504
  emitResponseOutputItems(items, addOutput, eventMeta, options = {}) {
505
505
  for (const item of items) {
506
+ if (item.type === "compaction") {
507
+ addOutput({
508
+ type: "info",
509
+ text: "Optimized context",
510
+ eventType: "system_message",
511
+ ...eventMeta({ payload: { event: "context.optimized" } }),
512
+ });
513
+ continue;
514
+ }
506
515
  if (item.type === "reasoning") {
507
516
  for (const entry of item.summary) {
508
517
  if (entry.type !== "summary_text")
@@ -123,7 +123,7 @@ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToke
123
123
  loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
124
124
  };
125
125
  }
126
- export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = false, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
126
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, explainMode = true, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
127
127
  const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
128
128
  ? cuaModel
129
129
  : resolveCuaModel(cuaModel);
@@ -133,7 +133,7 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null,
133
133
  ? "loadmill"
134
134
  : normalizedProviderMode,
135
135
  cuaModel: normalizedCuaModel,
136
- explainMode: explainMode === true,
136
+ explainMode: explainMode !== false,
137
137
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
138
138
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
139
139
  contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
@@ -0,0 +1,52 @@
1
+ const CLOUD_CONNECTION_SOURCES = new Set(["browserstack", "lambdatest", "aws-device-farm", "loadmill-cloud"]);
2
+ /**
3
+ * True when a desktop connection needs the cloud-session lifecycle
4
+ * (provisioning, heartbeat, release) regardless of whether it targets a
5
+ * device or a browser.
6
+ *
7
+ * @param {{ source?: string | null }} connection
8
+ * @returns {boolean}
9
+ */
10
+ export function needsCloudSessionManagement(connection) {
11
+ return CLOUD_CONNECTION_SOURCES.has(connection?.source);
12
+ }
13
+ /**
14
+ * True when a desktop connection should route through the browser engine
15
+ * hooks (screenshot/action/platform) instead of the device ones.
16
+ *
17
+ * @param {{ platform?: string | null }} connection
18
+ * @returns {boolean}
19
+ */
20
+ export function usesBrowserEngineHooks(connection) {
21
+ return connection?.platform === "browser";
22
+ }
23
+ /**
24
+ * True when a desktop connection needs the Android tool-environment
25
+ * refresh, which only applies to Android device connections.
26
+ *
27
+ * @param {{ platform?: string | null }} connection
28
+ * @returns {boolean}
29
+ */
30
+ export function needsAndroidToolEnvRefresh(connection) {
31
+ return connection?.platform === "android";
32
+ }
33
+ /**
34
+ * The status/outcome pair a desktop provisioning failure must report so the
35
+ * remote Loadmill classification and the local JUnit history agree.
36
+ *
37
+ * @returns {{ status: "error", outcome: "error" }}
38
+ */
39
+ export function resolveProvisioningFailureReporting() {
40
+ return { status: "error", outcome: "error" };
41
+ }
42
+ /**
43
+ * Builds a `connectToBrowser` engine hook that refuses to relaunch a
44
+ * browser mid-run instead of silently starting a local one.
45
+ *
46
+ * @returns {() => Promise<never>}
47
+ */
48
+ export function createRefuseReconnectConnectToBrowser() {
49
+ return async () => {
50
+ throw new Error("Cloud session disconnected; automatic CLI reconnect is not supported yet.");
51
+ };
52
+ }