@loadmill/droid-cua 2.6.0 → 2.8.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 (38) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -2
  3. package/build/index.js +55 -20
  4. package/build/src/browser/actions.js +9 -0
  5. package/build/src/browser/connection.js +37 -0
  6. package/build/src/browser/factory.js +22 -0
  7. package/build/src/browser/interface.js +14 -0
  8. package/build/src/browser/navigation.js +11 -0
  9. package/build/src/browser/normalize.js +36 -0
  10. package/build/src/browser/playwright/actions.js +199 -0
  11. package/build/src/browser/playwright/connection.js +196 -0
  12. package/build/src/browser/playwright/installed-browser.js +131 -0
  13. package/build/src/browser/playwright/session-state.js +19 -0
  14. package/build/src/browser/playwright/url.js +79 -0
  15. package/build/src/cli/headless-execution-config.js +59 -3
  16. package/build/src/cli/headless-instructions.js +12 -0
  17. package/build/src/cli/headless-target.js +64 -0
  18. package/build/src/commands/help.js +10 -2
  19. package/build/src/core/capture-sources.js +76 -0
  20. package/build/src/core/context-optimization.js +36 -0
  21. package/build/src/core/execution-engine.js +45 -12
  22. package/build/src/core/prompts.js +1 -1
  23. package/build/src/core/session.js +18 -4
  24. package/build/src/device/action-event-meta.js +18 -0
  25. package/build/src/device/android/actions.js +6 -13
  26. package/build/src/device/cloud/actions.js +9 -14
  27. package/build/src/device/cloud/connection.js +8 -0
  28. package/build/src/device/ios/actions.js +6 -13
  29. package/build/src/device/ios/appium-server.js +4 -3
  30. package/build/src/device/ios/connection.js +8 -0
  31. package/build/src/device/ios/node-cli-env.js +28 -0
  32. package/build/src/device/openai.js +47 -2
  33. package/build/src/modes/execution-mode.js +113 -21
  34. package/build/src/prompts/base.js +95 -21
  35. package/build/src/prompts/design.js +6 -11
  36. package/build/src/prompts/execution.js +26 -4
  37. package/build/src/utils/run-screenshot-recorder.js +1 -1
  38. package/package.json +7 -1
@@ -1,7 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { CAPTURE_SOURCES } from "../core/capture-sources.js";
2
3
  import { getScreenshotAsBase64, connectToDevice, getDeviceInfo, getCurrentPlatform } from "../device/connection.js";
4
+ import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, navigateToUrl as navigateBrowserToUrl, } from "../browser/connection.js";
5
+ import { normalizeBrowserInfoForSession } from "../browser/normalize.js";
6
+ import { extractNavigateTarget, isNavigateInstruction } from "../browser/navigation.js";
3
7
  import { sendCUARequest } from "../device/openai.js";
4
- import { buildExecutionRecoveryPrompt } from "../core/prompts.js";
8
+ import { buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt } from "../core/prompts.js";
5
9
  import { isAssertion, extractAssertionPrompt, buildAssertionSystemPrompt, checkAssertionResult, handleAssertionFailure, handleAssertionSuccess, } from "../device/assertions.js";
6
10
  import { isLoadmillInstruction, extractLoadmillCommand, executeLoadmillInstruction, } from "../device/loadmill.js";
7
11
  import { logger } from "../utils/logger.js";
@@ -52,6 +56,16 @@ export class ExecutionMode {
52
56
  assertionsPassed: 0,
53
57
  assertionsFailed: 0,
54
58
  };
59
+ this.isBrowserSession = session?.targetKind === "browser" || session?.deviceInfo?.platform === "browser";
60
+ this.getScreenshotAsBase64 = executionEngine.getScreenshotAsBase64 || getScreenshotAsBase64;
61
+ this.sendCUARequest = executionEngine.sendCUARequest || sendCUARequest;
62
+ this.connectToDevice = executionEngine.connectToDevice || connectToDevice;
63
+ this.getDeviceInfo = executionEngine.getDeviceInfo || getDeviceInfo;
64
+ this.getCurrentPlatform =
65
+ executionEngine.getCurrentPlatform || (this.isBrowserSession ? getBrowserCurrentPlatform : getCurrentPlatform);
66
+ this.connectToBrowser = executionEngine.connectToBrowser || connectToBrowser;
67
+ this.getBrowserInfo = executionEngine.getBrowserInfo || getBrowserInfo;
68
+ this.navigateToUrl = executionEngine.navigateToUrl || navigateBrowserToUrl;
55
69
  }
56
70
  /**
57
71
  * Format duration in human-readable format (Xm Ys)
@@ -200,6 +214,68 @@ export class ExecutionMode {
200
214
  async executeInstruction(instruction, context, retryCount = 0, stepContext = null) {
201
215
  const MAX_RETRIES = 10;
202
216
  const addOutput = context.addOutput || printCliOutput;
217
+ if (isNavigateInstruction(instruction)) {
218
+ if (!this.isBrowserSession) {
219
+ const message = "navigate: instructions are only supported for web runs.";
220
+ this.emit(addOutput, "error", message, context, stepContext, {
221
+ eventType: "error",
222
+ payload: {
223
+ instructionType: "navigate",
224
+ message
225
+ }
226
+ });
227
+ return { success: false, error: message };
228
+ }
229
+ const navigationTarget = extractNavigateTarget(instruction);
230
+ this.session.addToTranscript(`[Navigate] ${navigationTarget}`);
231
+ this.emit(addOutput, "system", `Navigating to ${navigationTarget}...`, context, stepContext, {
232
+ eventType: "system_message",
233
+ payload: {
234
+ instructionType: "navigate",
235
+ target: navigationTarget,
236
+ }
237
+ });
238
+ try {
239
+ const normalizedUrl = await this.navigateToUrl(this.session.browserSessionId, navigationTarget);
240
+ const browserInfo = normalizeBrowserInfoForSession(await this.getBrowserInfo(this.session.browserSessionId));
241
+ this.session.browserInfo = browserInfo;
242
+ this.session.deviceInfo = browserInfo;
243
+ this.session.currentUrl = browserInfo.currentUrl || normalizedUrl;
244
+ this.session.browserName = browserInfo.browserName || this.session.currentUrl;
245
+ this.session.deviceName = this.session.browserName;
246
+ const transcriptContext = this.session.getTranscriptText();
247
+ const navigationRecoveryPrompt = buildExecutionNavigationRecoveryPrompt({
248
+ basePrompt: this.initialSystemText,
249
+ transcript: transcriptContext,
250
+ navigatedUrl: this.session.currentUrl,
251
+ });
252
+ this.session.installRecoverySystemPrompt(navigationRecoveryPrompt);
253
+ this.emit(addOutput, "success", `Navigation complete: ${normalizedUrl}`, context, stepContext, {
254
+ eventType: "system_message",
255
+ payload: {
256
+ instructionType: "navigate",
257
+ url: normalizedUrl,
258
+ }
259
+ });
260
+ return { success: true };
261
+ }
262
+ catch (err) {
263
+ const message = err instanceof Error ? err.message : "Navigation failed.";
264
+ logger.error("Execution navigation error", {
265
+ instruction,
266
+ retryCount,
267
+ message,
268
+ });
269
+ this.emit(addOutput, "error", `Navigation failed: ${message}`, context, stepContext, {
270
+ eventType: "error",
271
+ payload: {
272
+ instructionType: "navigate",
273
+ message,
274
+ }
275
+ });
276
+ return { success: false, error: message };
277
+ }
278
+ }
203
279
  // ── Check for Loadmill instruction ──
204
280
  if (isLoadmillInstruction(instruction)) {
205
281
  const loadmillCommand = extractLoadmillCommand(instruction);
@@ -230,13 +306,13 @@ export class ExecutionMode {
230
306
  this.session.addMessage("user", instruction);
231
307
  }
232
308
  try {
233
- const screenshotBase64 = await getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
309
+ const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
234
310
  const screenshotResult = await this.engine.recordScreenshot?.(screenshotBase64, {
235
311
  runId: context?.runId,
236
312
  stepId: stepContext?.stepId,
237
313
  instructionIndex: stepContext?.instructionIndex,
238
314
  ...(assertionEventId ? { eventId: assertionEventId } : {}),
239
- captureSource: isAssertionStep ? "instruction-input-assertion" : "instruction-input"
315
+ captureSource: isAssertionStep ? CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION : CAPTURE_SOURCES.INSTRUCTION_INPUT
240
316
  });
241
317
  const assertionScreenshotPath = isAssertionStep ? screenshotPathFromRecorderResult(screenshotResult) : null;
242
318
  const assertionScreenshotDataUrl = isAssertionStep
@@ -259,7 +335,7 @@ export class ExecutionMode {
259
335
  messages: this.session.messages,
260
336
  previousResponseId: this.session.previousResponseId
261
337
  });
262
- const response = await sendCUARequest({
338
+ const response = await this.sendCUARequest({
263
339
  messages: messagesToSend,
264
340
  screenshotBase64,
265
341
  previousResponseId: previousResponseIdToSend,
@@ -364,35 +440,52 @@ export class ExecutionMode {
364
440
  attemptsExhausted: retryCount,
365
441
  maxRetries: MAX_RETRIES
366
442
  });
367
- this.emit(addOutput, 'error', `Failed after ${MAX_RETRIES} retries. Device may be disconnected.`, context, stepContext, {
443
+ this.emit(addOutput, 'error', `Failed after ${MAX_RETRIES} retries. ${this.isBrowserSession ? "Browser session" : "Device"} may be disconnected.`, context, stepContext, {
368
444
  eventType: 'error',
369
445
  payload: {
370
- message: `Failed after ${MAX_RETRIES} retries. Device may be disconnected.`,
446
+ message: `Failed after ${MAX_RETRIES} retries. ${this.isBrowserSession ? "Browser session" : "Device"} may be disconnected.`,
371
447
  attempt: retryCount,
372
448
  maxRetries: MAX_RETRIES
373
449
  }
374
450
  });
375
- // Attempt to reconnect to the device
376
- this.emit(addOutput, 'info', 'Attempting to reconnect to device...', context, stepContext, {
451
+ // Attempt to reconnect to the active target.
452
+ this.emit(addOutput, 'info', `Attempting to reconnect to ${this.isBrowserSession ? "browser" : "device"}...`, context, stepContext, {
377
453
  eventType: 'system_message'
378
454
  });
379
455
  try {
380
- const platform = getCurrentPlatform();
381
- const deviceName = this.session.deviceName || undefined;
382
- const deviceId = await connectToDevice(deviceName, platform);
383
- const deviceInfo = await getDeviceInfo(deviceId);
384
- // Update session with new connection
385
- this.session.deviceId = deviceId;
386
- this.session.deviceInfo = deviceInfo;
456
+ if (this.isBrowserSession) {
457
+ const browserSessionId = await this.connectToBrowser({
458
+ url: this.session.currentUrl || undefined,
459
+ headless: this.isHeadlessMode,
460
+ sessionMode: this.session.sessionMode || "persistent",
461
+ });
462
+ const browserInfo = normalizeBrowserInfoForSession(await this.getBrowserInfo(browserSessionId));
463
+ this.session.browserSessionId = browserSessionId;
464
+ this.session.deviceId = browserSessionId;
465
+ this.session.browserInfo = browserInfo;
466
+ this.session.deviceInfo = browserInfo;
467
+ this.session.currentUrl = browserInfo.currentUrl || null;
468
+ this.session.browserName = browserInfo.browserName || this.session.currentUrl;
469
+ this.session.deviceName = this.session.browserName;
470
+ }
471
+ else {
472
+ const platform = this.getCurrentPlatform();
473
+ const deviceName = this.session.deviceName || undefined;
474
+ const deviceId = await this.connectToDevice(deviceName, platform);
475
+ const deviceInfo = await this.getDeviceInfo(deviceId);
476
+ this.session.deviceId = deviceId;
477
+ this.session.deviceInfo = deviceInfo;
478
+ this.session.deviceName = deviceInfo?.device_name || this.session.deviceName;
479
+ }
387
480
  emitDesktopDebug("reconnect.attempt", "device", {
388
481
  runId: context?.runId,
389
482
  stepId: stepContext?.stepId,
390
483
  instructionIndex: stepContext?.instructionIndex
391
484
  }, {
392
485
  stage: "success",
393
- deviceId
486
+ deviceId: this.session.deviceId
394
487
  });
395
- this.emit(addOutput, 'success', 'Reconnected to device. Resuming...', context, stepContext, {
488
+ this.emit(addOutput, 'success', `Reconnected to ${this.isBrowserSession ? "browser" : "device"}. Resuming...`, context, stepContext, {
396
489
  eventType: 'system_message'
397
490
  });
398
491
  // Reset retry count and try again
@@ -408,13 +501,13 @@ export class ExecutionMode {
408
501
  message: reconnectErr.message
409
502
  });
410
503
  logger.error('Failed to reconnect to device', { error: reconnectErr.message });
411
- this.emit(addOutput, 'error', `Could not reconnect to device: ${reconnectErr.message}`, context, stepContext, {
504
+ this.emit(addOutput, 'error', `Could not reconnect to ${this.isBrowserSession ? "browser" : "device"}: ${reconnectErr.message}`, context, stepContext, {
412
505
  eventType: 'error',
413
506
  payload: {
414
507
  message: reconnectErr.message
415
508
  }
416
509
  });
417
- return { success: false, error: 'Device disconnected and reconnection failed' };
510
+ return { success: false, error: `${this.isBrowserSession ? "Browser" : "Device"} disconnected and reconnection failed` };
418
511
  }
419
512
  }
420
513
  this.emit(addOutput, 'info', 'Thinking...', context, stepContext, {
@@ -452,8 +545,7 @@ export class ExecutionMode {
452
545
  transcript: transcriptContext
453
546
  });
454
547
  // Replace the system message with the enhanced one
455
- this.session.messages = [{ role: "system", content: recoverySystemPrompt }];
456
- this.session.updateResponseId(undefined);
548
+ this.session.installRecoverySystemPrompt(recoverySystemPrompt);
457
549
  // Wait a bit before retrying
458
550
  await new Promise(resolve => setTimeout(resolve, 1000));
459
551
  // Retry the same instruction with incremented counter
@@ -33,13 +33,20 @@ function buildStrictModeRuntimeSection(runtimeOptions = {}) {
33
33
  }
34
34
  return `STRICT MODE - Observation-First Runtime:
35
35
  - The runtime may execute only the first meaningful action from any multi-step action chain you propose.
36
- - After that first action, it will re-observe the device and continue from a fresh screenshot.
36
+ - After that first action, it will re-observe the ${runtimeOptions?.platform === "browser" ? "browser" : "device"} and continue from a fresh screenshot.
37
37
  - If you propose multiple steps, assume only the first may happen before the next turn.
38
38
  - Do not assume later proposed actions succeeded unless the next screenshot confirms them.`;
39
39
  }
40
- function describeControlledDevice(deviceInfo = {}) {
40
+ function isBrowserTarget(deviceInfo = {}) {
41
+ return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "browser";
42
+ }
43
+ function describeControlledTarget(deviceInfo = {}) {
41
44
  const platform = typeof deviceInfo.platform === "string" ? deviceInfo.platform.trim().toLowerCase() : "";
42
45
  const deviceName = typeof deviceInfo.device_name === "string" ? deviceInfo.device_name.trim() : "";
46
+ const browserName = typeof deviceInfo.browserName === "string" ? deviceInfo.browserName.trim() : "";
47
+ if (platform === "browser") {
48
+ return browserName ? `a browser session (${browserName})` : "a browser session";
49
+ }
43
50
  if (platform === "ios") {
44
51
  return deviceName ? `an iOS simulator (${deviceName})` : "an iOS device";
45
52
  }
@@ -48,10 +55,83 @@ function describeControlledDevice(deviceInfo = {}) {
48
55
  }
49
56
  return "a mobile device";
50
57
  }
51
- function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
52
- const controlledDevice = describeControlledDevice(deviceInfo);
53
- const prompt = `
54
- You are controlling ${controlledDevice} in a sandboxed testing environment.
58
+ function buildBrowserBaseSystemPrompt(deviceInfo) {
59
+ return `
60
+ You are controlling ${describeControlledTarget(deviceInfo)} in a sandboxed testing environment.
61
+ Follow the user's instructions to interact with the browser.
62
+
63
+ You can interact with any part of the visible browser window, including page content and browser chrome that is visible.
64
+
65
+ The screen you see is ${deviceInfo.scaled_width} x ${deviceInfo.scaled_height} pixels.
66
+ Pixel (0,0) is at the top-left corner.
67
+
68
+ When aiming for visual targets:
69
+ - Reason carefully about the approximate pixel position.
70
+ - Before every click action, call \`preview_click\` with the exact coordinates you intend to click.
71
+ - Review the returned annotated screenshot before clicking.
72
+ - Click if the marker is clearly inside the intended clickable target.
73
+ - Adjust and preview again only if the marker is outside the target, on the edge, or closer to a neighboring target than the intended one.
74
+ - If you preview more than once, the next click should use the most recent accepted preview coordinates unless the screen changed.
75
+ - If the click does not cause the expected UI change, treat it as a likely misclick and try a meaningfully different coordinate.
76
+ - Click precisely based on your visual estimate.
77
+
78
+ Available actions: click, scroll, type, keypress, wait, screenshot.
79
+
80
+ CRITICAL - Desktop Browser Input Constraints:
81
+ - This is a desktop browser environment. Standard desktop keys and modifier chords are allowed when they are clearly useful.
82
+ - Prefer clicking visible controls over keyboard shortcuts when both are equally reliable.
83
+ - Use keypress chords only when they directly help the task, such as Enter to submit, Escape to dismiss a modal, or Control/Meta shortcuts for focused text fields.
84
+ - Do not rely on shortcuts that would switch away from the browser or operating system context.
85
+
86
+ CRITICAL - Automatic Timing:
87
+ - After EVERY action (click, type, keypress, scroll), there is an automatic 500ms delay
88
+ - This 500ms is sufficient for normal UI updates and animations
89
+ - DO NOT add 'wait' actions unnecessarily - trust the automatic delay
90
+
91
+ CRITICAL - Mutating Actions:
92
+ - Mutating actions change app state. Examples: submit, create, save, confirm, approve, reject, login, logout, send, place order, initiate transfer
93
+ - Before clicking a mutating action button, dismiss any open overlay or on-screen keyboard if it blocks the target and is not needed
94
+ - After performing a mutating action once, do NOT repeat the same mutating action unless the UI clearly shows the first attempt failed or had no effect
95
+ - Treat visible state change as success. Examples: form fields clear, submit button returns to normal, status changes, list refreshes, new row appears, success message appears, screen changes
96
+ - For form submissions specifically, if the relevant fields clear and the action button returns to its normal idle state, treat that as success even if the new row or confirmation is not obvious yet
97
+ - If the UI shows signs that the mutating action succeeded, stop acting for that instruction
98
+
99
+ Use explicit 'wait' action ONLY in these specific cases:
100
+ 1. After pressing ENTER that triggers navigation (search, URL, form submit)
101
+ 2. After clicking links or controls that trigger page navigation
102
+ 3. After actions that trigger heavy loading, a full-page refresh, or a visible URL change
103
+
104
+ Rule of thumb: Wait for page navigation and page loads. Everything else has automatic timing.
105
+
106
+ Perform the user's requested actions within the current view.
107
+
108
+ If unsure about visual elements, take a screenshot to improve your reasoning.
109
+ If unsure about the user's intent, make the best decision you can based on context and continue automatically.
110
+
111
+ CRITICAL - Never Ask Questions:
112
+ - NEVER ask the user for confirmation, clarification, or next steps
113
+ - NEVER ask questions like "Should I...", "Would you like...", "Do you want me to..."
114
+ - NEVER wait for user guidance - make autonomous decisions
115
+ - If stuck, try alternative approaches (go back, try different UI element, restart app)
116
+ - ONLY stop when the task is complete or you've exhausted reasonable approaches
117
+
118
+ Act decisively to complete the task.
119
+
120
+ Stop acting once the task appears complete.
121
+ Only complete the current instruction. Do not proceed beyond the current step unless asked.
122
+
123
+ Browser-Specific Notes:
124
+ - You are operating a desktop browser, not a phone UI
125
+ - Direct URL navigation is handled by runner-owned \`navigate:\` lines when available; do not try to reach the browser address bar or fake URL entry with keyboard shortcuts
126
+ - ESC dismisses many overlays and dialogs
127
+ - Cookie or consent banners may block the main task and may need dismissal first
128
+ - Vertical scrolling is the default way to discover content below the fold
129
+ - CTRL/CMD/ALT/OPTION/SHIFT may be used when they are the most reliable path
130
+ `;
131
+ }
132
+ function buildMobileBaseSystemPrompt(deviceInfo) {
133
+ return `
134
+ You are controlling ${describeControlledTarget(deviceInfo)} in a sandboxed testing environment.
55
135
  Follow the user's instructions to interact with the device.
56
136
 
57
137
  The device screen has been scaled down for display.
@@ -64,7 +144,9 @@ function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
64
144
  - Reason carefully about the approximate pixel position.
65
145
  - Before every click action, call \`preview_click\` with the exact coordinates you intend to click.
66
146
  - Review the returned annotated screenshot before clicking.
67
- - Only click if the marker is clearly inside the intended target and not on its edge, border, text, or nearby empty space.
147
+ - Click if the marker is clearly inside the intended tappable control. Text inside a button, field, row, or bottom bar is okay.
148
+ - Adjust and preview again only if the marker is outside the target, on the target border/edge, or closer to a neighboring target than the intended one.
149
+ - If you preview more than once, the next click should use the most recent accepted preview coordinates unless the screen changed.
68
150
  - If the click does not cause the expected UI change, treat it as a likely misclick and try a meaningfully different coordinate.
69
151
  - Click precisely based on your visual estimate.
70
152
 
@@ -101,18 +183,6 @@ function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
101
183
  3. After clicking links that open new apps or pages
102
184
  4. After actions that trigger heavy loading (camera, maps, etc.)
103
185
 
104
- When you MUST wait:
105
- - Click app icon from home → wait → Continue
106
- - Type in search box → Press ENTER → wait → Continue
107
- - Click link that opens new page/app → wait → Continue
108
- - Open camera/maps/heavy feature → wait → Continue
109
-
110
- When you should NOT wait (automatic 500ms handles it):
111
- - Clicking UI buttons within a running app (click button - no wait needed)
112
- - Typing in text fields (type text - no wait needed)
113
- - Scrolling (scroll - no wait needed)
114
- - Clicking tabs or menu items within an app (click - no wait needed)
115
-
116
186
  Rule of thumb: Wait for app launches and navigation. Everything else has automatic timing.
117
187
 
118
188
  Perform the user's requested actions within the current view.
@@ -138,6 +208,10 @@ function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
138
208
  - On iOS, ESC has no effect; use visible on-screen controls instead
139
209
  - Never use CTRL, CMD, ALT, OPTION, or SHIFT in a keypress action
140
210
  `;
141
- return prompt;
142
211
  }
143
- export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledDevice, buildStrictModeRuntimeSection, };
212
+ function buildBaseSystemPrompt(deviceInfo) {
213
+ return isBrowserTarget(deviceInfo)
214
+ ? buildBrowserBaseSystemPrompt(deviceInfo)
215
+ : buildMobileBaseSystemPrompt(deviceInfo);
216
+ }
217
+ export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledTarget, isBrowserTarget, buildStrictModeRuntimeSection, };
@@ -1,17 +1,18 @@
1
- import { appendCustomSections, buildBaseSystemPrompt, buildStrictModeRuntimeSection } from "./base.js";
1
+ import { appendCustomSections, buildBaseSystemPrompt, buildStrictModeRuntimeSection, isBrowserTarget } from "./base.js";
2
2
  export function buildDesignModePrompt(deviceInfo, customInstructions = {}, runtimeOptions = {}) {
3
3
  const designCustomText = typeof customInstructions.designModeInstructions === "string" ? customInstructions.designModeInstructions.trim() : "";
4
4
  const basePrompt = buildBaseSystemPrompt(deviceInfo, customInstructions);
5
5
  const strictModeSection = buildStrictModeRuntimeSection(runtimeOptions);
6
+ const browserTarget = isBrowserTarget(deviceInfo);
6
7
  const prompt = `${basePrompt}
7
8
 
8
9
  DESIGN MODE:
9
- You are helping design a test script for an Android app.
10
+ You are helping design a test script for ${browserTarget ? "a browser workflow" : "a mobile app"}.
10
11
  Some tests intentionally validate negative outcomes (errors, failures, rejected inputs). These are expected and should be treated as successful progress when they match the test goal.
11
12
 
12
13
  Your task:
13
14
  1. Understand what the user wants to test from their initial instruction
14
- 2. Explore the app autonomously to understand the flows
15
+ 2. Explore the ${browserTarget ? "website" : "app"} autonomously to understand the flows
15
16
  3. Take screenshots and interact as needed to discover the UI and behavior
16
17
  4. Once you've successfully completed the user's requested flow, immediately generate the test script
17
18
 
@@ -71,16 +72,10 @@ Suggested test name: short-kebab-case-name
71
72
  \`\`\`
72
73
 
73
74
  CORRECT Example:
74
- Suggested test name: calculator-addition
75
+ Suggested test name: ${browserTarget ? "docs-search" : "calculator-addition"}
75
76
 
76
77
  \`\`\`
77
- Open Calculator app
78
- assert: Calculator app is visible
79
- Type "2"
80
- Click the plus button
81
- Type "3"
82
- Click the equals button
83
- assert: result shows 5
78
+ ${browserTarget ? "navigate: https://docs.example.com\nType \"pricing\" in the search field\nPress Enter\nwait\nClick \"Pricing\"\nassert: Pricing page is visible" : "Open Calculator app\nassert: Calculator app is visible\nType \"2\"\nClick the plus button\nType \"3\"\nClick the equals button\nassert: result shows 5"}
84
79
  exit
85
80
  \`\`\`
86
81
 
@@ -1,4 +1,4 @@
1
- import { appendCustomSections, buildBaseSystemPrompt, buildStrictModeRuntimeSection } from "./base.js";
1
+ import { appendCustomSections, buildBaseSystemPrompt, buildStrictModeRuntimeSection, isBrowserTarget } from "./base.js";
2
2
  export function buildAppContextSection(briefing) {
3
3
  const text = typeof briefing === "string" ? briefing.trim() : "";
4
4
  if (!text) {
@@ -15,6 +15,7 @@ export function buildExecutionModePrompt(deviceInfo, customInstructions = {}, ap
15
15
  const basePrompt = buildBaseSystemPrompt(deviceInfo, customInstructions);
16
16
  const appContextSection = buildAppContextSection(appContextBriefing);
17
17
  const strictModeSection = buildStrictModeRuntimeSection(runtimeOptions);
18
+ const browserTarget = isBrowserTarget(deviceInfo);
18
19
  const prompt = `${basePrompt}
19
20
 
20
21
  EXECUTION MODE - Critical Behavior:
@@ -27,12 +28,12 @@ CRITICAL RULES:
27
28
  - DO NOT say "Let me know if you need help" or similar phrases
28
29
  - Just execute the action silently and stop immediately
29
30
  - Only generate text if the action FAILED or cannot be completed
30
- - Never emit desktop keyboard shortcuts or modifier combos; mobile execution only supports mobile-safe single-key presses
31
+ - ${browserTarget ? "Use browser-appropriate keyboard input only when it is the most reliable path" : "Never emit desktop keyboard shortcuts or modifier combos; mobile execution only supports mobile-safe single-key presses"}
31
32
  - Never repeat the same mutating action with the same apparent intent unless the UI clearly shows failure or no state change
32
33
  - If a submit/create/approve/reject/login action appears to succeed, stop instead of trying to reconfirm by doing it again
33
34
  - For form submissions, cleared fields plus a reset action button are strong success signals; stop even if the created item is not yet obvious in the visible list
34
35
  - If target is not visible, perform bounded off-screen discovery first:
35
- 1. Scroll the screen in the likely direction to reveal hidden controls
36
+ 1. Scroll ${browserTarget ? "vertically" : "the screen"} in the likely direction to reveal hidden controls
36
37
  2. If still missing, do one minimal fallback (e.g., close overlay or go back once), then retry
37
38
 
38
39
  Your process:
@@ -61,6 +62,27 @@ ${transcript}
61
62
 
62
63
  [IMPORTANT: Resume execution silently. Do NOT narrate or explain. Just execute the next instruction.]`;
63
64
  }
65
+ export function buildExecutionNavigationRecoveryPrompt({ basePrompt, transcript, navigatedUrl }) {
66
+ const normalizedTranscript = typeof transcript === "string" ? transcript.trim() : "";
67
+ const normalizedUrl = typeof navigatedUrl === "string" ? navigatedUrl.trim() : "";
68
+ if (!normalizedTranscript && !normalizedUrl) {
69
+ return basePrompt;
70
+ }
71
+ const transitionLine = normalizedUrl
72
+ ? `[RUNNER NAVIGATION - The runtime navigated directly to ${normalizedUrl}. The page content has changed.]`
73
+ : "[RUNNER NAVIGATION - The runtime navigated directly. The page content has changed.]";
74
+ const transcriptSection = normalizedTranscript
75
+ ? `
76
+
77
+ [PRIOR TEST CONTEXT - Steps completed before this navigation:]
78
+ ${normalizedTranscript}`
79
+ : "";
80
+ return `${basePrompt}
81
+
82
+ ${transitionLine}${transcriptSection}
83
+
84
+ [IMPORTANT: Continue the current test from this new page. Use the prior context above, but rely on the latest screenshot for the current UI.]`;
85
+ }
64
86
  export function buildAssertionSystemPrompt(baseSystemPrompt, assertionPrompt) {
65
87
  return `${baseSystemPrompt}
66
88
 
@@ -88,7 +110,7 @@ export function buildAppContextCompactionInput({ contextDocument, taskDescriptio
88
110
  role: "system",
89
111
  content: [{
90
112
  type: "input_text",
91
- text: `You are compressing an app context document for a mobile testing agent.
113
+ text: `You are compressing an app context document for an app testing agent.
92
114
 
93
115
  You will receive:
94
116
  1. A context document
@@ -126,7 +126,7 @@ async function renderAnnotatedScreenshot(image, marker = {}) {
126
126
  return null;
127
127
  }
128
128
  const placement = buildMarkerPlacement(marker, width, height);
129
- const radius = Math.max(14, Math.round(Math.min(width, height) * 0.055));
129
+ const radius = Math.max(16, Math.min(22, Math.round(Math.min(width, height) * 0.05)));
130
130
  const overlay = Buffer.from(buildClickMarkerSvg({ width, height, x: placement.x, y: placement.y, radius, marker }));
131
131
  const outputBuffer = await image
132
132
  .composite([{ input: overlay }])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -15,6 +15,11 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "start": "tsx index.js",
18
+ "bench": "npm run bench:preflight && npm run bench:run && npm run bench:report",
19
+ "bench:preflight": "node scripts/benchmark-preflight.js",
20
+ "bench:run": "tsx index.js --platform android --instructions benchmarks/benchmark.dcua --context benchmarks/context.md --debug",
21
+ "bench:report": "node scripts/benchmark-report.js",
22
+ "execution:profile": "node scripts/execution-profile.js",
18
23
  "test": "node --test unit-tests/*.test.js",
19
24
  "build": "tsc",
20
25
  "clean": "rm -rf build",
@@ -56,6 +61,7 @@
56
61
  "ink-text-input": "^5.0.1",
57
62
  "minimist": "^1.2.8",
58
63
  "openai": "^4.87.3",
64
+ "playwright-core": "^1.55.1",
59
65
  "react": "^18.3.1",
60
66
  "sharp": "^0.34.1"
61
67
  },