@loadmill/droid-cua 2.2.2 → 2.4.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 (32) hide show
  1. package/README.md +69 -0
  2. package/build/index.js +177 -24
  3. package/build/src/cli/headless-debug.js +55 -0
  4. package/build/src/cli/headless-execution-config.js +203 -0
  5. package/build/src/cli/ink-shell.js +8 -2
  6. package/build/src/commands/help.js +13 -1
  7. package/build/src/commands/run.js +30 -1
  8. package/build/src/core/app-context.js +57 -0
  9. package/build/src/core/execution-engine.js +151 -20
  10. package/build/src/core/prompts.js +3 -247
  11. package/build/src/device/android/actions.js +2 -2
  12. package/build/src/device/assertions.js +4 -23
  13. package/build/src/device/cloud/browserstack/adapter.js +1 -0
  14. package/build/src/device/cloud/lambdatest/adapter.js +402 -0
  15. package/build/src/device/cloud/registry.js +2 -1
  16. package/build/src/device/interface.js +1 -1
  17. package/build/src/device/ios/actions.js +8 -2
  18. package/build/src/device/loadmill.js +4 -3
  19. package/build/src/device/openai.js +32 -26
  20. package/build/src/integrations/loadmill/interpreter.js +3 -56
  21. package/build/src/modes/design-mode-ink.js +12 -17
  22. package/build/src/modes/design-mode.js +12 -17
  23. package/build/src/modes/execution-mode.js +32 -22
  24. package/build/src/prompts/base.js +139 -0
  25. package/build/src/prompts/design.js +115 -0
  26. package/build/src/prompts/editor.js +19 -0
  27. package/build/src/prompts/execution.js +182 -0
  28. package/build/src/prompts/loadmill.js +60 -0
  29. package/build/src/utils/console-output.js +35 -0
  30. package/build/src/utils/run-screenshot-recorder.js +98 -0
  31. package/build/src/utils/structured-debug-log-manager.js +325 -0
  32. package/package.json +2 -1
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Assertion handling for script validation
3
3
  */
4
+ import { printCliOutput } from "../utils/console-output.js";
5
+ export { buildAssertionSystemPrompt } from "../prompts/execution.js";
4
6
  export function isAssertion(userInput) {
5
7
  const trimmed = userInput.trim();
6
8
  const lower = trimmed.toLowerCase();
@@ -19,27 +21,6 @@ export function extractAssertionPrompt(userInput) {
19
21
  }
20
22
  return trimmed;
21
23
  }
22
- export function buildAssertionSystemPrompt(baseSystemPrompt, assertionPrompt) {
23
- return `${baseSystemPrompt}
24
-
25
- ASSERTION MODE:
26
- You are now validating an assertion. The user has provided an assertion statement that you must verify.
27
-
28
- Your task:
29
- 1. Take screenshots and perform LIMITED actions if needed to validate the assertion.
30
- 2. Determine if the assertion is TRUE or FALSE based on the current state.
31
- 3. You MUST respond with a clear verdict in this exact format:
32
- - If the assertion is true, include the text: "ASSERTION RESULT: PASS"
33
- - If the assertion is false or cannot be confidently validated, include: "ASSERTION RESULT: FAIL"
34
- 4. After the verdict, provide a brief explanation (1-2 sentences) of why it passed or failed.
35
-
36
- The assertion to validate is: "${assertionPrompt}"
37
-
38
- Remember:
39
- - If you cannot confidently validate the assertion, treat it as FAIL.
40
- - You must include either "ASSERTION RESULT: PASS" or "ASSERTION RESULT: FAIL" in your response.
41
- - Be thorough but efficient. Only take the actions necessary to validate the assertion.`;
42
- }
43
24
  export function checkAssertionResult(transcript) {
44
25
  const transcriptText = transcript.join("\n");
45
26
  const hasPassed = transcriptText.includes("ASSERTION RESULT: PASS");
@@ -56,7 +37,7 @@ export function extractFailureDetails(transcript) {
56
37
  }
57
38
  export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context, stepContext = null) {
58
39
  const details = extractFailureDetails(transcript);
59
- const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
40
+ const addOutput = context?.addOutput || printCliOutput;
60
41
  const meta = {
61
42
  eventType: 'assertion_result',
62
43
  runId: context?.runId,
@@ -81,7 +62,7 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
81
62
  // Interactive mode: caller should clear remaining instructions
82
63
  }
83
64
  export function handleAssertionSuccess(assertionPrompt, context = null, stepContext = null) {
84
- const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
65
+ const addOutput = context?.addOutput || printCliOutput;
85
66
  addOutput({
86
67
  type: 'success',
87
68
  text: `✓ Assertion passed: ${assertionPrompt}`,
@@ -195,6 +195,7 @@ function readAppStatusEntry(payload) {
195
195
  }
196
196
  return entries;
197
197
  }
198
+ /** @type {import("../adapter").CloudProviderAdapter} */
198
199
  export const browserStackAdapter = {
199
200
  id: "browserstack",
200
201
  displayName: "BrowserStack",
@@ -0,0 +1,402 @@
1
+ import { readFile } from "node:fs/promises";
2
+ const HUB_URL = "https://mobile-hub.lambdatest.com/wd/hub";
3
+ const CONCURRENCY_URL = "https://mobile-api.lambdatest.com/mobile-automation/api/v1/org/concurrency";
4
+ const DEVICE_REGIONS = ["us", "ap", "eu"];
5
+ const DEVICE_LIST_URL = "https://mobile-api.lambdatest.com/mobile-automation/api/v1/list";
6
+ const APP_UPLOAD_URL = "https://manual-api.lambdatest.com/app/upload/realDevice";
7
+ const APP_DATA_URL = "https://manual-api.lambdatest.com/app/data";
8
+ function notImplemented(methodName) {
9
+ throw new Error(`LambdaTest adapter stub: ${methodName} is not implemented yet.`);
10
+ }
11
+ function normalizeString(value) {
12
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
13
+ }
14
+ function normalizeRemoteAppRef(value) {
15
+ const normalized = normalizeString(value);
16
+ return normalized?.startsWith("lt://") ? normalized : undefined;
17
+ }
18
+ function normalizePlanLabel(value) {
19
+ if (typeof value === "string") {
20
+ return value.trim() || undefined;
21
+ }
22
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
23
+ return (normalizePlanLabel(value.plan) ??
24
+ normalizePlanLabel(value.name) ??
25
+ normalizePlanLabel(value.label) ??
26
+ normalizePlanLabel(value.tier) ??
27
+ normalizePlanLabel(value.type));
28
+ }
29
+ return undefined;
30
+ }
31
+ function readPlanLabel(payload) {
32
+ const candidates = [
33
+ payload.plan,
34
+ payload.plan_name,
35
+ payload.planName,
36
+ payload.plan_type,
37
+ payload.account_plan,
38
+ payload.accountPlan,
39
+ payload.tier,
40
+ payload.tier_name,
41
+ payload.subscription,
42
+ payload.organization_plan
43
+ ];
44
+ for (const candidate of candidates) {
45
+ const normalized = normalizePlanLabel(candidate);
46
+ if (normalized) {
47
+ return normalized;
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+ function readFiniteNumber(value) {
53
+ if (typeof value === "number" && Number.isFinite(value)) {
54
+ return value;
55
+ }
56
+ if (typeof value === "string" && value.trim().length > 0) {
57
+ const parsed = Number(value);
58
+ if (Number.isFinite(parsed)) {
59
+ return parsed;
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ function readParallelLimit(payload) {
65
+ const candidates = [
66
+ payload.max_concurrency,
67
+ payload.maxConcurrency,
68
+ payload.parallel_sessions,
69
+ payload.parallelSessions,
70
+ payload.parallel_limit,
71
+ payload.parallelLimit,
72
+ payload.concurrency,
73
+ payload.max_sessions,
74
+ payload.maxSessions
75
+ ];
76
+ for (const candidate of candidates) {
77
+ const normalized = readFiniteNumber(candidate);
78
+ if (normalized !== undefined) {
79
+ return normalized;
80
+ }
81
+ }
82
+ return undefined;
83
+ }
84
+ function readSummary(payload, plan, parallelLimit) {
85
+ const message = normalizeString(payload.message) ??
86
+ normalizeString(payload.summary) ??
87
+ normalizeString(payload.status_message) ??
88
+ normalizeString(payload.statusMessage);
89
+ if (message) {
90
+ return message;
91
+ }
92
+ if (plan && parallelLimit !== undefined) {
93
+ return `${plan} account with ${parallelLimit} parallel sessions available.`;
94
+ }
95
+ if (parallelLimit !== undefined) {
96
+ return `LambdaTest credentials validated. ${parallelLimit} parallel sessions available.`;
97
+ }
98
+ return undefined;
99
+ }
100
+ function normalizePlatform(value) {
101
+ if (typeof value !== "string") {
102
+ return null;
103
+ }
104
+ const normalized = value.trim().toLowerCase();
105
+ if (normalized === "android") {
106
+ return "android";
107
+ }
108
+ if (normalized === "ios" || normalized === "iphone" || normalized === "ipad") {
109
+ return "ios";
110
+ }
111
+ return null;
112
+ }
113
+ function sortOsVersionsDescending(values) {
114
+ return [...values].sort((left, right) => right.localeCompare(left, undefined, { numeric: true, sensitivity: "base" }));
115
+ }
116
+ function unwrapDevicePayload(payload) {
117
+ if (Array.isArray(payload)) {
118
+ return payload;
119
+ }
120
+ if (typeof payload !== "object" || payload === null) {
121
+ return [];
122
+ }
123
+ const candidates = [payload.devices, payload.data, payload.results, payload.list];
124
+ for (const candidate of candidates) {
125
+ if (Array.isArray(candidate)) {
126
+ return candidate;
127
+ }
128
+ }
129
+ return [];
130
+ }
131
+ async function requestConcurrency(creds) {
132
+ const response = await fetch(CONCURRENCY_URL, {
133
+ method: "GET",
134
+ headers: {
135
+ Authorization: lambdaTestAdapter.getAuthHeader(creds)
136
+ }
137
+ });
138
+ if (response.status === 401 || response.status === 403) {
139
+ throw new Error("LambdaTest rejected these credentials. Check the username and access key and try again.");
140
+ }
141
+ if (!response.ok) {
142
+ throw new Error(`LambdaTest validation failed with status ${response.status}.`);
143
+ }
144
+ const payload = await response.json();
145
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
146
+ throw new Error("LambdaTest returned an unexpected validation response.");
147
+ }
148
+ const plan = readPlanLabel(payload);
149
+ const parallelLimit = readParallelLimit(payload);
150
+ const summary = readSummary(payload, plan, parallelLimit);
151
+ return {
152
+ plan,
153
+ parallelLimit,
154
+ summary
155
+ };
156
+ }
157
+ async function requestDevicesForRegion(creds, region) {
158
+ const response = await fetch(`${DEVICE_LIST_URL}?region=${encodeURIComponent(region)}`, {
159
+ method: "GET",
160
+ headers: {
161
+ Authorization: lambdaTestAdapter.getAuthHeader(creds)
162
+ }
163
+ });
164
+ if (response.status === 401 || response.status === 403) {
165
+ throw new Error("LambdaTest rejected these credentials. Reconnect LambdaTest and try refreshing devices again.");
166
+ }
167
+ if (!response.ok) {
168
+ throw new Error(`LambdaTest device catalog failed with status ${response.status}.`);
169
+ }
170
+ return unwrapDevicePayload(await response.json());
171
+ }
172
+ async function requestDevices(creds) {
173
+ const regionPayloads = await Promise.all(DEVICE_REGIONS.map((region) => requestDevicesForRegion(creds, region)));
174
+ const deduped = new Map();
175
+ for (const payload of regionPayloads.flat()) {
176
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
177
+ continue;
178
+ }
179
+ const platform = normalizePlatform(payload.platform) ??
180
+ normalizePlatform(payload.platformName) ??
181
+ normalizePlatform(payload.os) ??
182
+ normalizePlatform(payload.os_type) ??
183
+ normalizePlatform(payload.osType);
184
+ const deviceName = normalizeString(payload.device_name) ??
185
+ normalizeString(payload.deviceName) ??
186
+ normalizeString(payload.device) ??
187
+ normalizeString(payload.name);
188
+ const osVersion = normalizeString(payload.platform_version) ??
189
+ normalizeString(payload.platformVersion) ??
190
+ normalizeString(payload.os_version) ??
191
+ normalizeString(payload.osVersion) ??
192
+ normalizeString(payload.version);
193
+ if (!platform || !deviceName || !osVersion) {
194
+ continue;
195
+ }
196
+ const key = `${platform}::${deviceName}::${osVersion}`;
197
+ if (!deduped.has(key)) {
198
+ deduped.set(key, {
199
+ id: key,
200
+ name: deviceName,
201
+ deviceName,
202
+ platform,
203
+ osVersion
204
+ });
205
+ }
206
+ }
207
+ return [...deduped.values()].sort((left, right) => {
208
+ if (left.platform !== right.platform) {
209
+ return left.platform.localeCompare(right.platform);
210
+ }
211
+ if (left.name !== right.name) {
212
+ return left.name.localeCompare(right.name);
213
+ }
214
+ return sortOsVersionsDescending([left.osVersion ?? "", right.osVersion ?? ""])[0] === (left.osVersion ?? "") ? -1 : 1;
215
+ });
216
+ }
217
+ function readUploadedAppEntries(payload) {
218
+ if (Array.isArray(payload)) {
219
+ return payload;
220
+ }
221
+ if (typeof payload !== "object" || payload === null) {
222
+ return [];
223
+ }
224
+ const candidates = [payload.data, payload.apps, payload.files, payload.results];
225
+ for (const candidate of candidates) {
226
+ if (Array.isArray(candidate)) {
227
+ return candidate;
228
+ }
229
+ }
230
+ return [];
231
+ }
232
+ async function uploadRealDeviceApp(creds, localPath) {
233
+ const fileContents = await readFile(localPath);
234
+ const fileName = localPath.split(/[\\/]/).pop() ?? "app";
235
+ const form = new FormData();
236
+ form.append("appFile", new Blob([new Uint8Array(fileContents)]), fileName);
237
+ const response = await fetch(APP_UPLOAD_URL, {
238
+ method: "POST",
239
+ headers: {
240
+ Authorization: lambdaTestAdapter.getAuthHeader(creds)
241
+ },
242
+ body: form
243
+ });
244
+ if (response.status === 401 || response.status === 403) {
245
+ throw new Error("LambdaTest rejected these credentials. Reconnect LambdaTest and try again.");
246
+ }
247
+ if (!response.ok) {
248
+ throw new Error(`LambdaTest app upload failed with status ${response.status}.`);
249
+ }
250
+ const payload = await response.json();
251
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
252
+ throw new Error("LambdaTest returned an unexpected app upload response.");
253
+ }
254
+ const remotePath = normalizeRemoteAppRef(payload.app_url) ??
255
+ normalizeRemoteAppRef(payload.appUrl) ??
256
+ normalizeRemoteAppRef(payload.appURL) ??
257
+ normalizeRemoteAppRef(payload.value) ??
258
+ normalizeRemoteAppRef(payload.id);
259
+ if (!remotePath) {
260
+ throw new Error("LambdaTest did not return a valid app reference.");
261
+ }
262
+ const id = normalizeString(payload.app_id) ??
263
+ normalizeString(payload.appId) ??
264
+ normalizeString(payload.id) ??
265
+ remotePath;
266
+ return {
267
+ id,
268
+ remotePath
269
+ };
270
+ }
271
+ async function fetchUploadedApps(creds, platformType) {
272
+ const response = await fetch(`${APP_DATA_URL}?type=${encodeURIComponent(platformType)}&level=user`, {
273
+ method: "GET",
274
+ headers: {
275
+ Authorization: lambdaTestAdapter.getAuthHeader(creds)
276
+ }
277
+ });
278
+ if (response.status === 401 || response.status === 403) {
279
+ throw new Error("LambdaTest rejected these credentials. Reconnect LambdaTest and try again.");
280
+ }
281
+ if (!response.ok) {
282
+ throw new Error(`LambdaTest uploaded-app lookup failed with status ${response.status}.`);
283
+ }
284
+ return readUploadedAppEntries(await response.json());
285
+ }
286
+ async function lookupUploadedApp(creds, ref) {
287
+ const lists = await Promise.all(["android", "ios"].map((platformType) => fetchUploadedApps(creds, platformType)));
288
+ for (const item of lists.flat()) {
289
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
290
+ continue;
291
+ }
292
+ const remotePath = normalizeRemoteAppRef(item.app_url) ??
293
+ normalizeRemoteAppRef(item.appUrl) ??
294
+ normalizeRemoteAppRef(item.appURL) ??
295
+ normalizeRemoteAppRef(item.url) ??
296
+ normalizeRemoteAppRef(item.value);
297
+ if (!remotePath) {
298
+ continue;
299
+ }
300
+ const customId = normalizeString(item.app_id) ??
301
+ normalizeString(item.appId) ??
302
+ normalizeString(item.id) ??
303
+ normalizeString(item.name);
304
+ if (remotePath === ref.remotePath || (customId && customId === ref.id)) {
305
+ return item;
306
+ }
307
+ }
308
+ return null;
309
+ }
310
+ /** @type {import("../adapter").CloudProviderAdapter} */
311
+ export const lambdaTestAdapter = {
312
+ id: "lambdatest",
313
+ displayName: "LambdaTest",
314
+ async validateCredentials(creds) {
315
+ try {
316
+ const account = await requestConcurrency(creds);
317
+ return {
318
+ ok: true,
319
+ message: account.summary ?? "LambdaTest credentials validated successfully.",
320
+ account
321
+ };
322
+ }
323
+ catch (error) {
324
+ const message = error instanceof Error ? error.message : "Failed to validate LambdaTest credentials.";
325
+ if (/ENOTFOUND|fetch failed|network|timed out|ECONN/i.test(message)) {
326
+ return {
327
+ ok: false,
328
+ message: "Could not reach LambdaTest. Check your network connection and try again."
329
+ };
330
+ }
331
+ return {
332
+ ok: false,
333
+ message
334
+ };
335
+ }
336
+ },
337
+ async getAccountInfo(creds) {
338
+ return requestConcurrency(creds);
339
+ },
340
+ async getAvailableDevices(creds) {
341
+ return requestDevices(creds);
342
+ },
343
+ async uploadApp(creds, localPath) {
344
+ return uploadRealDeviceApp(creds, localPath);
345
+ },
346
+ async getAppStatus(creds, ref) {
347
+ const match = await lookupUploadedApp(creds, ref);
348
+ if (!match) {
349
+ return {
350
+ status: "missing",
351
+ message: "Uploaded app reference is missing or has expired on LambdaTest."
352
+ };
353
+ }
354
+ return {
355
+ status: "uploaded",
356
+ message: "Uploaded app reference is still available on LambdaTest."
357
+ };
358
+ },
359
+ async deleteApp(_creds, _ref) {
360
+ return notImplemented("deleteApp");
361
+ },
362
+ buildCapabilities(opts) {
363
+ const ltOptions = {
364
+ platformName: opts.platform,
365
+ deviceName: opts.deviceName,
366
+ platformVersion: opts.osVersion,
367
+ app: opts.app,
368
+ isRealMobile: true,
369
+ w3c: true,
370
+ build: opts.buildName ?? "droid-cua",
371
+ name: opts.sessionName ?? `${opts.deviceName} ${opts.platform === "ios" ? "iOS" : "Android"} ${opts.osVersion ?? ""}`.trim(),
372
+ video: true,
373
+ console: true
374
+ };
375
+ if (opts.platform === "android") {
376
+ ltOptions.visual = true;
377
+ ltOptions.devicelog = true;
378
+ }
379
+ else {
380
+ ltOptions.network = false;
381
+ }
382
+ return {
383
+ "lt:options": ltOptions
384
+ };
385
+ },
386
+ getHubUrl() {
387
+ return HUB_URL;
388
+ },
389
+ getAuthHeader(creds) {
390
+ const username = typeof creds.username === "string" ? creds.username : "";
391
+ const accessKey = typeof creds.accessKey === "string" ? creds.accessKey : "";
392
+ return `Basic ${Buffer.from(`${username}:${accessKey}`).toString("base64")}`;
393
+ },
394
+ async getSessionArtifacts(_creds, sessionId) {
395
+ return {
396
+ dashboardUrl: `https://automation.lambdatest.com/logs/?sessionID=${encodeURIComponent(sessionId)}`
397
+ };
398
+ },
399
+ async setSessionStatus(_creds, _sessionId, _status) {
400
+ return notImplemented("setSessionStatus");
401
+ }
402
+ };
@@ -1,5 +1,6 @@
1
1
  import { browserStackAdapter } from "./browserstack/adapter.js";
2
- const availableAdapters = [browserStackAdapter];
2
+ import { lambdaTestAdapter } from "./lambdatest/adapter.js";
3
+ const availableAdapters = [browserStackAdapter, lambdaTestAdapter];
3
4
  export function listCloudProviderAdapters() {
4
5
  return availableAdapters;
5
6
  }
@@ -47,7 +47,7 @@ export const SUPPORTED_ACTIONS = [
47
47
  'type', // Enter text
48
48
  'scroll', // Scroll by (scroll_x, scroll_y)
49
49
  'drag', // Drag from start to end via path
50
- 'keypress', // Press a single mobile-safe key (ESC/ESCAPE maps to home)
50
+ 'keypress', // Press a single mobile-safe key (Android ESC/ESCAPE maps to Back; iOS ignores ESC/ESCAPE)
51
51
  'wait', // Wait for UI to settle
52
52
  'screenshot' // Capture screen (handled by engine, not backend)
53
53
  ];
@@ -16,7 +16,10 @@ function normalizeMobileKeypress(keys = []) {
16
16
  throw new Error(`Unsupported mobile key chord: ${keys.join(", ")}. Use taps and text entry instead.`);
17
17
  }
18
18
  const key = String(keys[0]).trim().toUpperCase();
19
- if (key === "ESC" || key === "ESCAPE" || key === "HOME") {
19
+ if (key === "ESC" || key === "ESCAPE") {
20
+ return { kind: "noop", originalKey: keys[0], label: "Ignored ESC key" };
21
+ }
22
+ if (key === "HOME") {
20
23
  return { kind: "button", originalKey: keys[0], mapped: "home" };
21
24
  }
22
25
  if (key === "ENTER" || key === "RETURN") {
@@ -125,7 +128,10 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
125
128
  }
126
129
  case "keypress": {
127
130
  const normalized = normalizeMobileKeypress(action.keys);
128
- if (normalized.kind === "button") {
131
+ if (normalized.kind === "noop") {
132
+ addOutput({ type: "info", text: `Ignoring keypress: ${normalized.originalKey}`, ...meta({ keys: [normalized.originalKey], ignored: true }) });
133
+ }
134
+ else if (normalized.kind === "button") {
129
135
  addOutput({ type: "action", text: "Pressing Home button", ...meta({ keys: [normalized.originalKey], mapped: normalized.mapped }) });
130
136
  await appium.pressButton(session.sessionId, normalized.mapped);
131
137
  }
@@ -2,6 +2,7 @@
2
2
  * Loadmill instruction handling for script execution
3
3
  */
4
4
  import { executeLoadmillCommand } from "../integrations/loadmill/index.js";
5
+ import { printCliOutput } from "../utils/console-output.js";
5
6
  function getLoadmillSiteBaseUrl() {
6
7
  const rawBaseUrl = process.env.LOADMILL_BASE_URL || "https://app.loadmill.com/api";
7
8
  return rawBaseUrl.replace(/\/api\/?$/, "");
@@ -43,7 +44,7 @@ export function extractLoadmillCommand(userInput) {
43
44
  * @returns {Promise<{success: boolean, error?: string}>}
44
45
  */
45
46
  export async function executeLoadmillInstruction(command, isHeadlessMode, context, stepContext = null) {
46
- const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
47
+ const addOutput = context?.addOutput || printCliOutput;
47
48
  const meta = {
48
49
  runId: context?.runId,
49
50
  stepId: stepContext?.stepId,
@@ -82,7 +83,7 @@ export async function executeLoadmillInstruction(command, isHeadlessMode, contex
82
83
  * @returns {Promise<{success: boolean, error?: string}>}
83
84
  */
84
85
  export async function handleLoadmillFailure(command, error, isHeadlessMode, context, stepContext = null, suiteRunId = null) {
85
- const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
86
+ const addOutput = context?.addOutput || printCliOutput;
86
87
  const meta = {
87
88
  runId: context?.runId,
88
89
  stepId: stepContext?.stepId,
@@ -146,7 +147,7 @@ export async function handleLoadmillFailure(command, error, isHeadlessMode, cont
146
147
  * @param {Object|null} stepContext - Optional step context metadata
147
148
  */
148
149
  export function handleLoadmillSuccess(command, result, context, stepContext = null) {
149
- const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
150
+ const addOutput = context?.addOutput || printCliOutput;
150
151
  const meta = {
151
152
  runId: context?.runId,
152
153
  stepId: stepContext?.stepId,
@@ -1,4 +1,6 @@
1
1
  import OpenAI from "openai";
2
+ import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
3
+ import { buildAppContextCompactionInput } from "../prompts/execution.js";
2
4
  import { logger } from "../utils/logger.js";
3
5
  import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
4
6
  let openai = null;
@@ -96,6 +98,20 @@ function mapCuaError(err, cuaModel) {
96
98
  }
97
99
  return err;
98
100
  }
101
+ export function isNonRetryableCuaError(err) {
102
+ const status = Number(err?.status);
103
+ const code = typeof err?.code === "string" ? err.code.toLowerCase() : "";
104
+ const type = typeof err?.type === "string" ? err.type.toLowerCase() : "";
105
+ const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
106
+ if ([401, 403, 404].includes(status)) {
107
+ return true;
108
+ }
109
+ return (code.includes("model_not_found") ||
110
+ code.includes("permission") ||
111
+ type.includes("permission") ||
112
+ message.includes("does not have access to computer-use-preview") ||
113
+ message.includes("switch to gpt-5.4 in settings > cua model"));
114
+ }
99
115
  function getOpenAI() {
100
116
  if (!openai) {
101
117
  openai = new OpenAI({
@@ -115,27 +131,26 @@ export async function reviseTestScript(originalScript, revisionRequest) {
115
131
  model: "gpt-4o",
116
132
  messages: [{
117
133
  role: "system",
118
- content: `You are editing a test script based on user feedback.
119
-
120
- Current test script:
121
- ${originalScript}
122
-
123
- User's revision request:
124
- ${revisionRequest}
125
-
126
- Apply the user's changes and output the revised test script.
127
-
128
- FORMAT RULES:
129
- - One simple instruction per line (NO numbers, NO bullets)
130
- - Use imperative commands: "Open X", "Click Y", "Type Z"
131
- - Include "assert: <condition>" lines to validate expected behavior
132
- - End with "exit"
133
-
134
- Output only the revised test script, nothing else.`
134
+ content: buildTestRevisionSystemPrompt(originalScript, revisionRequest)
135
135
  }]
136
136
  });
137
137
  return response.choices[0].message.content.trim();
138
138
  }
139
+ export async function compactAppContext({ contextDocument, taskDescription, tokenBudget }) {
140
+ const response = await getOpenAI().responses.create({
141
+ model: "gpt-5.4",
142
+ temperature: 0,
143
+ input: buildAppContextCompactionInput({
144
+ contextDocument,
145
+ taskDescription,
146
+ tokenBudget,
147
+ })
148
+ });
149
+ return {
150
+ briefing: typeof response.output_text === "string" ? response.output_text.trim() : "",
151
+ outputTokens: typeof response.usage?.output_tokens === "number" ? response.usage.output_tokens : null,
152
+ };
153
+ }
139
154
  export async function sendCUARequest({ messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
140
155
  const cuaModel = getSelectedCuaModel();
141
156
  const includeInitialScreenshot = cuaModel === "computer-use-preview" && !previousResponseId && !callId;
@@ -174,15 +189,6 @@ export async function sendCUARequest({ messages, screenshotBase64, previousRespo
174
189
  }
175
190
  catch (err) {
176
191
  cuaDebugTracer.onError(trace, err);
177
- const responseError = err?.error && typeof err.error === "object" ? err.error : null;
178
- const requestIdFromHeaders = err?.headers && typeof err.headers === "object"
179
- ? err.headers["x-request-id"] || err.headers["request-id"] || null
180
- : null;
181
- err.localRequestId = trace.localRequestId;
182
- err.request_id = err?.request_id || responseError?.request_id || requestIdFromHeaders || null;
183
- err.previous_response_id = previousResponseId || null;
184
- err.last_response_output_types = trace.lastResponseMeta?.outputTypes ?? [];
185
- err.request_config_hash = trace.requestConfigHash;
186
192
  throw mapCuaError(err, cuaModel);
187
193
  }
188
194
  }