@loadmill/droid-cua 2.7.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.
- package/build/index.js +48 -14
- package/build/src/browser/actions.js +9 -0
- package/build/src/browser/connection.js +37 -0
- package/build/src/browser/factory.js +22 -0
- package/build/src/browser/interface.js +14 -0
- package/build/src/browser/navigation.js +11 -0
- package/build/src/browser/normalize.js +36 -0
- package/build/src/browser/playwright/actions.js +199 -0
- package/build/src/browser/playwright/connection.js +196 -0
- package/build/src/browser/playwright/installed-browser.js +131 -0
- package/build/src/browser/playwright/session-state.js +19 -0
- package/build/src/browser/playwright/url.js +79 -0
- package/build/src/cli/headless-target.js +64 -0
- package/build/src/commands/help.js +7 -1
- package/build/src/core/capture-sources.js +76 -0
- package/build/src/core/execution-engine.js +41 -11
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/session.js +18 -4
- package/build/src/device/action-event-meta.js +18 -0
- package/build/src/device/android/actions.js +6 -13
- package/build/src/device/cloud/actions.js +9 -14
- package/build/src/device/cloud/connection.js +8 -0
- package/build/src/device/ios/actions.js +6 -13
- package/build/src/device/ios/connection.js +8 -0
- package/build/src/modes/execution-mode.js +113 -21
- package/build/src/prompts/base.js +92 -20
- package/build/src/prompts/design.js +6 -11
- package/build/src/prompts/execution.js +26 -4
- package/build/src/utils/run-screenshot-recorder.js +1 -1
- package/package.json +2 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Executes CUA model actions on iOS Simulator via Appium.
|
|
5
5
|
*/
|
|
6
6
|
import * as appium from "./appium-client.js";
|
|
7
|
+
import { buildActionEventMeta } from "../action-event-meta.js";
|
|
7
8
|
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
8
9
|
import { logger } from "../../utils/logger.js";
|
|
9
10
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
@@ -44,19 +45,11 @@ function normalizeMobileKeypress(keys = []) {
|
|
|
44
45
|
export async function handleModelAction(simulatorId, action, scale = 1.0, context = null) {
|
|
45
46
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
46
47
|
const session = getActiveSession();
|
|
47
|
-
const meta = (payload = {}) => ({
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
instructionIndex: context?.instructionIndex,
|
|
53
|
-
payload: {
|
|
54
|
-
eventId: context?.eventId,
|
|
55
|
-
screenshotPath: context?.screenshotPath,
|
|
56
|
-
screenshotDataUrl: context?.screenshotDataUrl,
|
|
57
|
-
platform: "ios",
|
|
58
|
-
...payload
|
|
59
|
-
}
|
|
48
|
+
const meta = (payload = {}) => buildActionEventMeta({
|
|
49
|
+
context,
|
|
50
|
+
action,
|
|
51
|
+
platform: "ios",
|
|
52
|
+
payload
|
|
60
53
|
});
|
|
61
54
|
if (!session) {
|
|
62
55
|
throw new Error("No active iOS session");
|
|
@@ -299,6 +299,14 @@ export function getActiveSession() {
|
|
|
299
299
|
export function getDevicePixelRatio() {
|
|
300
300
|
return activeSession?.devicePixelRatio || 3;
|
|
301
301
|
}
|
|
302
|
+
// Test-only — do not call from production code. Used by unit tests to install
|
|
303
|
+
// a fake session without going through the real Appium/simulator boot flow.
|
|
304
|
+
export function setActiveSessionForTests(session) {
|
|
305
|
+
activeSession = session;
|
|
306
|
+
}
|
|
307
|
+
export function clearActiveSessionForTests() {
|
|
308
|
+
activeSession = null;
|
|
309
|
+
}
|
|
302
310
|
/**
|
|
303
311
|
* Disconnect and cleanup
|
|
304
312
|
*/
|
|
@@ -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 ?
|
|
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
|
|
376
|
-
this.emit(addOutput, 'info',
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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',
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
@@ -103,18 +183,6 @@ function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
|
|
|
103
183
|
3. After clicking links that open new apps or pages
|
|
104
184
|
4. After actions that trigger heavy loading (camera, maps, etc.)
|
|
105
185
|
|
|
106
|
-
When you MUST wait:
|
|
107
|
-
- Click app icon from home → wait → Continue
|
|
108
|
-
- Type in search box → Press ENTER → wait → Continue
|
|
109
|
-
- Click link that opens new page/app → wait → Continue
|
|
110
|
-
- Open camera/maps/heavy feature → wait → Continue
|
|
111
|
-
|
|
112
|
-
When you should NOT wait (automatic 500ms handles it):
|
|
113
|
-
- Clicking UI buttons within a running app (click button - no wait needed)
|
|
114
|
-
- Typing in text fields (type text - no wait needed)
|
|
115
|
-
- Scrolling (scroll - no wait needed)
|
|
116
|
-
- Clicking tabs or menu items within an app (click - no wait needed)
|
|
117
|
-
|
|
118
186
|
Rule of thumb: Wait for app launches and navigation. Everything else has automatic timing.
|
|
119
187
|
|
|
120
188
|
Perform the user's requested actions within the current view.
|
|
@@ -140,6 +208,10 @@ function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
|
|
|
140
208
|
- On iOS, ESC has no effect; use visible on-screen controls instead
|
|
141
209
|
- Never use CTRL, CMD, ALT, OPTION, or SHIFT in a keypress action
|
|
142
210
|
`;
|
|
143
|
-
return prompt;
|
|
144
211
|
}
|
|
145
|
-
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"ink-text-input": "^5.0.1",
|
|
62
62
|
"minimist": "^1.2.8",
|
|
63
63
|
"openai": "^4.87.3",
|
|
64
|
+
"playwright-core": "^1.55.1",
|
|
64
65
|
"react": "^18.3.1",
|
|
65
66
|
"sharp": "^0.34.1"
|
|
66
67
|
},
|