@loadmill/droid-cua 2.18.0 → 2.21.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.
@@ -37,18 +37,25 @@ function normalizeBrowser(value) {
37
37
  }
38
38
  throw new Error("--browser must be one of: chrome, edge.");
39
39
  }
40
+ const CLOUD_SOURCE_DISPLAY_NAMES = {
41
+ lambdatest: "LambdaTest",
42
+ "aws-device-farm": "AWS Device Farm",
43
+ };
44
+ function isCloudDeviceSource(deviceSource) {
45
+ return Object.hasOwn(CLOUD_SOURCE_DISPLAY_NAMES, deviceSource);
46
+ }
40
47
  function normalizeDeviceSource(value) {
41
48
  if (value == null || value === "") {
42
49
  return "local";
43
50
  }
44
51
  if (typeof value !== "string") {
45
- throw new Error("--device-source must be one of: local, lambdatest.");
52
+ throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
46
53
  }
47
54
  const normalized = value.trim().toLowerCase();
48
- if (normalized === "local" || normalized === "lambdatest") {
55
+ if (normalized === "local" || isCloudDeviceSource(normalized)) {
49
56
  return normalized;
50
57
  }
51
- throw new Error("--device-source must be one of: local, lambdatest.");
58
+ throw new Error("--device-source must be one of: local, lambdatest, aws-device-farm.");
52
59
  }
53
60
  function normalizeMobilePlatform(value, label) {
54
61
  if (!hasNonEmptyString(value)) {
@@ -72,31 +79,38 @@ function readNonEmptyCliValue(value) {
72
79
  }
73
80
  return null;
74
81
  }
75
- function assertNoLambdaTestFlags(args) {
76
- const lambdaTestFlags = [
82
+ function assertNoCloudFlags(args) {
83
+ const cloudFlags = [
77
84
  ["device-name", "--device-name"],
78
85
  ["os-version", "--os-version"],
79
86
  ["app", "--app"],
80
87
  ];
81
- for (const [key, label] of lambdaTestFlags) {
88
+ for (const [key, label] of cloudFlags) {
82
89
  if (args[key] != null) {
83
- throw new Error(`${label} can only be used with --device-source lambdatest.`);
90
+ throw new Error(`${label} can only be used with a cloud --device-source.`);
84
91
  }
85
92
  }
86
93
  }
87
- function normalizeLambdaTestAppPath(value) {
94
+ function normalizeCloudAppPath(value, platform, deviceSource) {
88
95
  if (!hasNonEmptyString(value)) {
89
96
  return null;
90
97
  }
98
+ const displayName = CLOUD_SOURCE_DISPLAY_NAMES[deviceSource];
91
99
  const appPath = value.trim();
92
100
  const lower = appPath.toLowerCase();
93
- if (lower.endsWith(".apk") || lower.endsWith(".ipa")) {
94
- return appPath;
95
- }
96
101
  if (lower.endsWith(".app")) {
97
- throw new Error("LambdaTest real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.");
102
+ throw new Error(`${displayName} real-device sessions require an .apk for Android or an .ipa for iOS. .app bundles are not supported.`);
103
+ }
104
+ if (!lower.endsWith(".apk") && !lower.endsWith(".ipa")) {
105
+ throw new Error(`--app must point to an .apk or .ipa file for ${displayName} runs.`);
106
+ }
107
+ if (platform === "android" && !lower.endsWith(".apk")) {
108
+ throw new Error(`${displayName} Android sessions require an .apk build.`);
109
+ }
110
+ if (platform === "ios" && !lower.endsWith(".ipa")) {
111
+ throw new Error(`${displayName} iOS sessions require an .ipa build.`);
98
112
  }
99
- throw new Error("--app must point to an .apk or .ipa file for LambdaTest runs.");
113
+ return appPath;
100
114
  }
101
115
  export function resolveHeadlessTarget(args = {}) {
102
116
  const target = normalizeTarget(args.target);
@@ -104,6 +118,9 @@ export function resolveHeadlessTarget(args = {}) {
104
118
  const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
105
119
  const platform = normalizeMobilePlatform(args.platform, "--platform");
106
120
  const deviceSource = normalizeDeviceSource(args["device-source"]);
121
+ if (args["list-devices"] != null && deviceSource !== "aws-device-farm") {
122
+ throw new Error("--list-devices can only be used with --device-source aws-device-farm.");
123
+ }
107
124
  if (target === "web") {
108
125
  if (avdName || platform) {
109
126
  throw new Error("--avd and --platform cannot be used with --target web.");
@@ -111,7 +128,7 @@ export function resolveHeadlessTarget(args = {}) {
111
128
  if (deviceSource !== "local") {
112
129
  throw new Error("--device-source can only be used with --target mobile.");
113
130
  }
114
- assertNoLambdaTestFlags(args);
131
+ assertNoCloudFlags(args);
115
132
  return {
116
133
  target,
117
134
  sessionMode,
@@ -122,6 +139,7 @@ export function resolveHeadlessTarget(args = {}) {
122
139
  deviceName: null,
123
140
  osVersion: null,
124
141
  appPath: null,
142
+ listDevices: false,
125
143
  };
126
144
  }
127
145
  if (args["session-mode"] != null) {
@@ -130,27 +148,42 @@ export function resolveHeadlessTarget(args = {}) {
130
148
  if (args.browser != null) {
131
149
  throw new Error("--browser can only be used with --target web.");
132
150
  }
151
+ // --list-devices only needs credentials — no device/app flags required.
152
+ if (args["list-devices"] != null) {
153
+ return {
154
+ target,
155
+ sessionMode: null,
156
+ browserFamily: null,
157
+ deviceSource,
158
+ avdName: null,
159
+ platform: null,
160
+ deviceName: null,
161
+ osVersion: null,
162
+ appPath: null,
163
+ listDevices: true,
164
+ };
165
+ }
133
166
  if (deviceSource === "local") {
134
- assertNoLambdaTestFlags(args);
167
+ assertNoCloudFlags(args);
135
168
  }
136
- if (deviceSource === "lambdatest") {
169
+ if (isCloudDeviceSource(deviceSource)) {
137
170
  const deviceName = hasNonEmptyString(args["device-name"]) ? args["device-name"].trim() : null;
138
171
  const osVersion = readNonEmptyCliValue(args["os-version"]);
139
- const appPath = normalizeLambdaTestAppPath(args.app);
172
+ const appPath = normalizeCloudAppPath(args.app, platform, deviceSource);
140
173
  if (avdName) {
141
- throw new Error("--avd cannot be used with --device-source lambdatest.");
174
+ throw new Error(`--avd cannot be used with --device-source ${deviceSource}.`);
142
175
  }
143
176
  if (!platform) {
144
- throw new Error("--platform is required with --device-source lambdatest.");
177
+ throw new Error(`--platform is required with --device-source ${deviceSource}.`);
145
178
  }
146
179
  if (!deviceName) {
147
- throw new Error("--device-name is required with --device-source lambdatest.");
180
+ throw new Error(`--device-name is required with --device-source ${deviceSource}.`);
148
181
  }
149
182
  if (!osVersion) {
150
- throw new Error("--os-version is required with --device-source lambdatest.");
183
+ throw new Error(`--os-version is required with --device-source ${deviceSource}.`);
151
184
  }
152
185
  if (!appPath) {
153
- throw new Error("--app is required with --device-source lambdatest.");
186
+ throw new Error(`--app is required with --device-source ${deviceSource}.`);
154
187
  }
155
188
  return {
156
189
  target,
@@ -162,6 +195,7 @@ export function resolveHeadlessTarget(args = {}) {
162
195
  deviceName,
163
196
  osVersion,
164
197
  appPath,
198
+ listDevices: false,
165
199
  };
166
200
  }
167
201
  return {
@@ -174,5 +208,6 @@ export function resolveHeadlessTarget(args = {}) {
174
208
  deviceName: null,
175
209
  osVersion: null,
176
210
  appPath: null,
211
+ listDevices: false,
177
212
  };
178
213
  }
@@ -2,7 +2,7 @@
2
2
  * Loadmill command handler
3
3
  */
4
4
  import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
5
- import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
5
+ import { createResolvedLlmClient, resolveEffectiveHelperModel, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
6
6
  /**
7
7
  * Handle /loadmill command
8
8
  * @param {string} args - Command arguments (natural language command)
@@ -60,10 +60,17 @@ export async function handleLoadmill(args, session, context) {
60
60
  credentials: runtimeCredentials,
61
61
  clientName: "cli",
62
62
  });
63
+ const helperModel = runtimeConfig.llmProviderMode === "openai"
64
+ ? resolveEffectiveHelperModel({
65
+ llmProviderMode: runtimeConfig.llmProviderMode,
66
+ cuaModel: runtimeConfig.cuaModel,
67
+ })
68
+ : null;
63
69
  // Execute the command
64
70
  const result = await executeLoadmillCommand(command, {
65
71
  loadmillClient,
66
72
  openAiClient,
73
+ helperModel,
67
74
  onProgress: ({ message }) => {
68
75
  addOutput({ type: 'info', text: `[Loadmill] ${message}` });
69
76
  }
@@ -185,6 +185,19 @@ export class ExecutionEngine {
185
185
  if (shouldStop()) {
186
186
  return { stopped: true, sawExplicitScreenshotAction };
187
187
  }
188
+ if (context?.waitIfPaused) {
189
+ const pauseResult = await context.waitIfPaused({
190
+ boundary: "before_action",
191
+ refreshScreenOnResume: true,
192
+ stepContext,
193
+ });
194
+ if (pauseResult?.status === "stopped" || shouldStop()) {
195
+ return { stopped: true, sawExplicitScreenshotAction };
196
+ }
197
+ if (pauseResult?.refreshScreen) {
198
+ return { refreshModelState: true, sawExplicitScreenshotAction };
199
+ }
200
+ }
188
201
  if (action.type === "screenshot") {
189
202
  sawExplicitScreenshotAction = true;
190
203
  continue;
@@ -223,6 +236,17 @@ export class ExecutionEngine {
223
236
  }
224
237
  return { stopped: false, sawExplicitScreenshotAction };
225
238
  }
239
+ async waitIfPaused(context = null, stepContext = null, boundary = "turn_boundary", refreshScreenOnResume = true) {
240
+ if (!context?.waitIfPaused) {
241
+ return { status: "running", refreshScreen: false };
242
+ }
243
+ const result = await context.waitIfPaused({
244
+ boundary,
245
+ refreshScreenOnResume,
246
+ stepContext,
247
+ });
248
+ return result ?? { status: "running", refreshScreen: false };
249
+ }
226
250
  async buildActionFollowUp(callId, pendingSafetyChecks, sawExplicitScreenshotAction, addOutput, eventMeta, context = null, stepContext = null) {
227
251
  const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
228
252
  emitDesktopDebug("device.screenshot", "device", {
@@ -358,11 +382,21 @@ export class ExecutionEngine {
358
382
  instructionIndex: stepContext?.instructionIndex,
359
383
  ...extra
360
384
  });
385
+ let refreshBeforeNextComputerAction = false;
361
386
  while (true) {
362
387
  // Check for interruption before processing next batch of actions
363
388
  if (shouldStop()) {
389
+ summary.stopped = true;
390
+ return finalize();
391
+ }
392
+ const pauseResult = await this.waitIfPaused(context, stepContext, "before_model_output", true);
393
+ if (pauseResult.status === "stopped" || shouldStop()) {
394
+ summary.stopped = true;
364
395
  return finalize();
365
396
  }
397
+ if (pauseResult.refreshScreen) {
398
+ refreshBeforeNextComputerAction = true;
399
+ }
366
400
  const items = response.output || [];
367
401
  const actionableItems = extractActionableOutputItems(items);
368
402
  const pendingSafetyChecks = collectPendingSafetyChecks(items);
@@ -385,8 +419,17 @@ export class ExecutionEngine {
385
419
  currentScreenshot = toolResult.currentScreenshot;
386
420
  }
387
421
  if (shouldStop()) {
422
+ summary.stopped = true;
423
+ return finalize();
424
+ }
425
+ const toolPauseResult = await this.waitIfPaused(context, stepContext, "after_tool_call", true);
426
+ if (toolPauseResult.status === "stopped" || shouldStop()) {
427
+ summary.stopped = true;
388
428
  return finalize();
389
429
  }
430
+ if (toolPauseResult.refreshScreen) {
431
+ refreshBeforeNextComputerAction = true;
432
+ }
390
433
  if (toolResult.kind === "fail") {
391
434
  summary.failed = true;
392
435
  summary.error = toolResult.error;
@@ -419,14 +462,39 @@ export class ExecutionEngine {
419
462
  continue;
420
463
  }
421
464
  const { call_id, actions } = actionableItem;
465
+ if (refreshBeforeNextComputerAction) {
466
+ const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
467
+ currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
468
+ response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
469
+ newResponseId = response.id;
470
+ refreshBeforeNextComputerAction = false;
471
+ continue;
472
+ }
422
473
  const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, trackAction);
423
474
  if (actionBatchResult.stopped) {
475
+ summary.stopped = true;
424
476
  return finalize();
425
477
  }
478
+ if (actionBatchResult.refreshModelState) {
479
+ const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
480
+ currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
481
+ response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
482
+ newResponseId = response.id;
483
+ continue;
484
+ }
426
485
  const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, actionBatchResult.sawExplicitScreenshotAction, addOutput, eventMeta, context, stepContext);
427
486
  if (shouldStop()) {
487
+ summary.stopped = true;
428
488
  return finalize();
429
489
  }
490
+ const followUpPauseResult = await this.waitIfPaused(context, stepContext, "after_action_follow_up", true);
491
+ if (followUpPauseResult.status === "stopped" || shouldStop()) {
492
+ summary.stopped = true;
493
+ return finalize();
494
+ }
495
+ if (followUpPauseResult.refreshScreen) {
496
+ refreshBeforeNextComputerAction = true;
497
+ }
430
498
  currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
431
499
  response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
432
500
  newResponseId = response.id;
@@ -32,6 +32,12 @@ export function mediaKindOf(line) {
32
32
  return "none";
33
33
  }
34
34
  export function isHiddenExecutionLogEvent(line) {
35
+ if (line.eventType === "run_paused")
36
+ return true;
37
+ // Connecting markers drive renderer state (isConnecting) but carry no
38
+ // user-facing content beyond the cloud progress lines already shown.
39
+ if (line.eventType === "run_connecting" || line.eventType === "run_connected")
40
+ return true;
35
41
  return mediaKindOf(line) === "hidden-debug";
36
42
  }
37
43
  export function isGalleryMediaEvent(line) {