@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.
- package/LICENSE +1 -1
- package/README.md +11 -2
- package/build/index.js +55 -20
- 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-execution-config.js +59 -3
- package/build/src/cli/headless-instructions.js +12 -0
- package/build/src/cli/headless-target.js +64 -0
- package/build/src/commands/help.js +10 -2
- package/build/src/core/capture-sources.js +76 -0
- package/build/src/core/context-optimization.js +36 -0
- package/build/src/core/execution-engine.js +45 -12
- 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/appium-server.js +4 -3
- package/build/src/device/ios/connection.js +8 -0
- package/build/src/device/ios/node-cli-env.js +28 -0
- package/build/src/device/openai.js +47 -2
- package/build/src/modes/execution-mode.js +113 -21
- package/build/src/prompts/base.js +95 -21
- 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 +7 -1
|
@@ -10,20 +10,26 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export async function handleHelp(args, session, context) {
|
|
12
12
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
13
|
-
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile
|
|
13
|
+
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile and web testing CLI' });
|
|
14
14
|
addOutput({ type: 'info', text: '' });
|
|
15
15
|
addOutput({ type: 'info', text: 'Usage:' });
|
|
16
16
|
addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
|
|
17
|
+
addOutput({ type: 'info', text: ' droid-cua --target web --url <starting-url> --instructions <file> [options]' });
|
|
17
18
|
addOutput({ type: 'info', text: '' });
|
|
18
19
|
addOutput({ type: 'info', text: 'Options:' });
|
|
20
|
+
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default) or web' });
|
|
19
21
|
addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
|
|
20
22
|
addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
|
|
21
23
|
addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
|
|
24
|
+
addOutput({ type: 'info', text: ' --url <value> Required starting URL for --target web headless runs' });
|
|
25
|
+
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
22
26
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
23
|
-
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4 or computer-use-preview' });
|
|
27
|
+
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, gpt-5.4-mini, or computer-use-preview' });
|
|
24
28
|
addOutput({ type: 'info', text: ' --context <file> Optional app context file used to brief execution runs' });
|
|
25
29
|
addOutput({ type: 'info', text: ' --app-context-budget Headless app context token budget override' });
|
|
26
30
|
addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
|
|
31
|
+
addOutput({ type: 'info', text: ' --context-optimization-threshold <tokens> Headless Context Optimization compaction threshold' });
|
|
32
|
+
addOutput({ type: 'info', text: ' --no-context-optimization Disable Context Optimization for headless execution' });
|
|
27
33
|
addOutput({ type: 'info', text: ' --base-prompt <text> Headless base prompt customization text' });
|
|
28
34
|
addOutput({ type: 'info', text: ' --execution-prompt <text> Headless execution prompt customization text' });
|
|
29
35
|
addOutput({ type: 'info', text: ' --base-prompt-file Headless base prompt customization file' });
|
|
@@ -50,6 +56,7 @@ export async function handleHelp(args, session, context) {
|
|
|
50
56
|
addOutput({ type: 'info', text: 'Platform Support:' });
|
|
51
57
|
addOutput({ type: 'info', text: ' Android: Uses ADB to communicate with Android devices (physical or emulator)' });
|
|
52
58
|
addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
|
|
59
|
+
addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
|
|
53
60
|
addOutput({ type: 'info', text: '' });
|
|
54
61
|
addOutput({ type: 'info', text: 'Platform Detection:' });
|
|
55
62
|
addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
|
|
@@ -69,6 +76,7 @@ export async function handleHelp(args, session, context) {
|
|
|
69
76
|
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
70
77
|
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
71
78
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
79
|
+
addOutput({ type: 'info', text: ' droid-cua --target web --url docs.example.com --instructions tests/search.dcua' });
|
|
72
80
|
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
73
81
|
addOutput({ type: 'info', text: ' /list (see all tests)' });
|
|
74
82
|
addOutput({ type: 'info', text: ' /view login-test (view test contents)' });
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Single source of truth for screenshot/click-annotation provenance. Producers
|
|
2
|
+
// stamp `captureSource` on every event; consumers (renderer log filter, main
|
|
3
|
+
// result-artifact router, debug recorder) read it through classifyCaptureSource.
|
|
4
|
+
//
|
|
5
|
+
// classifyCaptureSource returns three orthogonal axes — each consumer reads only
|
|
6
|
+
// what it needs:
|
|
7
|
+
// - mediaKind: how the renderer should display the event ('hidden-debug'
|
|
8
|
+
// hides it from the timeline, 'screenshot' / 'click-annotation' put it in
|
|
9
|
+
// the gallery, null leaves it as plain text).
|
|
10
|
+
// - persistsToResults: whether the main process copies the artifact into the
|
|
11
|
+
// run's user-visible result directory (vs. debug-only).
|
|
12
|
+
// - visibility: whether the event represents user-facing action ('user'),
|
|
13
|
+
// internal preview/debug noise ('debug'), or has no associated media
|
|
14
|
+
// ('none'). Distinct from mediaKind because INSTRUCTION_INPUT and
|
|
15
|
+
// DESIGN_INPUT are real user inputs but don't appear in the gallery.
|
|
16
|
+
export const CAPTURE_SOURCES = Object.freeze({
|
|
17
|
+
PREVIEW_CLICK: "preview-click",
|
|
18
|
+
ACTION_CLICK_ANNOTATION: "action-click-annotation",
|
|
19
|
+
CALL_OUTPUT_POST_ACTION: "call-output-post-action",
|
|
20
|
+
CALL_OUTPUT_EXPLICIT_ACTION: "call-output-explicit-action",
|
|
21
|
+
INSTRUCTION_INPUT: "instruction-input",
|
|
22
|
+
INSTRUCTION_INPUT_ASSERTION: "instruction-input-assertion",
|
|
23
|
+
DESIGN_INPUT: "design-input",
|
|
24
|
+
});
|
|
25
|
+
const HIDDEN_DEBUG_MEDIA_KINDS = new Set([
|
|
26
|
+
CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
27
|
+
]);
|
|
28
|
+
const CLICK_ANNOTATION_MEDIA_KINDS = new Set([
|
|
29
|
+
CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
30
|
+
]);
|
|
31
|
+
const SCREENSHOT_MEDIA_KINDS = new Set([
|
|
32
|
+
CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
33
|
+
CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION,
|
|
34
|
+
CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
|
|
35
|
+
]);
|
|
36
|
+
const USER_VISIBLE_RESULT_CAPTURE_SOURCES = new Set([
|
|
37
|
+
CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
|
|
38
|
+
CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION,
|
|
39
|
+
CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
|
|
40
|
+
CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
41
|
+
]);
|
|
42
|
+
export function classifyCaptureSource(source) {
|
|
43
|
+
if (typeof source !== "string" || !source.trim()) {
|
|
44
|
+
return {
|
|
45
|
+
mediaKind: null,
|
|
46
|
+
persistsToResults: false,
|
|
47
|
+
visibility: "none",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (HIDDEN_DEBUG_MEDIA_KINDS.has(source)) {
|
|
51
|
+
return {
|
|
52
|
+
mediaKind: "hidden-debug",
|
|
53
|
+
persistsToResults: false,
|
|
54
|
+
visibility: "debug",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (CLICK_ANNOTATION_MEDIA_KINDS.has(source)) {
|
|
58
|
+
return {
|
|
59
|
+
mediaKind: "click-annotation",
|
|
60
|
+
persistsToResults: true,
|
|
61
|
+
visibility: "user",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (SCREENSHOT_MEDIA_KINDS.has(source)) {
|
|
65
|
+
return {
|
|
66
|
+
mediaKind: "screenshot",
|
|
67
|
+
persistsToResults: true,
|
|
68
|
+
visibility: "user",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
mediaKind: null,
|
|
73
|
+
persistsToResults: USER_VISIBLE_RESULT_CAPTURE_SOURCES.has(source),
|
|
74
|
+
visibility: source === CAPTURE_SOURCES.INSTRUCTION_INPUT || source === CAPTURE_SOURCES.DESIGN_INPUT ? "user" : "none",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const DEFAULT_CONTEXT_OPTIMIZATION_ENABLED = true;
|
|
2
|
+
export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 30000;
|
|
3
|
+
export const MIN_CONTEXT_OPTIMIZATION_THRESHOLD = 10000;
|
|
4
|
+
export const MAX_CONTEXT_OPTIMIZATION_THRESHOLD = 900000;
|
|
5
|
+
export const CONTEXT_OPTIMIZATION_ENABLED_ENV = "DROID_CUA_CONTEXT_OPTIMIZATION_ENABLED";
|
|
6
|
+
export const CONTEXT_OPTIMIZATION_THRESHOLD_ENV = "DROID_CUA_CONTEXT_OPTIMIZATION_THRESHOLD";
|
|
7
|
+
export function normalizeContextOptimizationEnabled(value) {
|
|
8
|
+
if (typeof value !== "boolean") {
|
|
9
|
+
return DEFAULT_CONTEXT_OPTIMIZATION_ENABLED;
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export function normalizeContextOptimizationThreshold(value) {
|
|
14
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
15
|
+
return DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD;
|
|
16
|
+
}
|
|
17
|
+
const normalized = Math.round(value);
|
|
18
|
+
if (normalized < MIN_CONTEXT_OPTIMIZATION_THRESHOLD) {
|
|
19
|
+
return MIN_CONTEXT_OPTIMIZATION_THRESHOLD;
|
|
20
|
+
}
|
|
21
|
+
if (normalized > MAX_CONTEXT_OPTIMIZATION_THRESHOLD) {
|
|
22
|
+
return MAX_CONTEXT_OPTIMIZATION_THRESHOLD;
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
export function readContextOptimizationEnv(env = process.env) {
|
|
27
|
+
const rawEnabled = env[CONTEXT_OPTIMIZATION_ENABLED_ENV];
|
|
28
|
+
const enabled = rawEnabled == null
|
|
29
|
+
? DEFAULT_CONTEXT_OPTIMIZATION_ENABLED
|
|
30
|
+
: !["0", "false", "off", "no"].includes(String(rawEnabled).trim().toLowerCase());
|
|
31
|
+
const rawThreshold = env[CONTEXT_OPTIMIZATION_THRESHOLD_ENV];
|
|
32
|
+
const threshold = rawThreshold == null || String(rawThreshold).trim() === ""
|
|
33
|
+
? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD
|
|
34
|
+
: normalizeContextOptimizationThreshold(Number(rawThreshold));
|
|
35
|
+
return { enabled, threshold };
|
|
36
|
+
}
|
|
@@ -8,6 +8,8 @@ import { emitDesktopDebug } from "../utils/desktop-debug.js";
|
|
|
8
8
|
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
9
9
|
import { printCliOutput } from "../utils/console-output.js";
|
|
10
10
|
import { annotateScreenshotArtifact, createAnnotatedScreenshotFromBase64 } from "../utils/run-screenshot-recorder.js";
|
|
11
|
+
import { CAPTURE_SOURCES } from "./capture-sources.js";
|
|
12
|
+
const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
|
|
11
13
|
function extractActionableOutputItems(items) {
|
|
12
14
|
const entries = [];
|
|
13
15
|
for (const item of items) {
|
|
@@ -318,23 +320,51 @@ export class ExecutionEngine {
|
|
|
318
320
|
}));
|
|
319
321
|
}
|
|
320
322
|
const previewEventId = randomUUID();
|
|
321
|
-
await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
|
|
323
|
+
const previewScreenshotResult = await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
|
|
322
324
|
runId: context?.runId,
|
|
323
325
|
sessionId: context?.sessionId,
|
|
324
326
|
stepId: stepContext?.stepId,
|
|
325
327
|
instructionIndex: stepContext?.instructionIndex,
|
|
326
328
|
callId,
|
|
327
329
|
eventId: previewEventId,
|
|
328
|
-
captureSource:
|
|
330
|
+
captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
329
331
|
});
|
|
332
|
+
const previewText = buildPreviewOutputText({
|
|
333
|
+
requestedX: previewResult.marker.requestedX,
|
|
334
|
+
requestedY: previewResult.marker.requestedY,
|
|
335
|
+
renderedX: previewResult.marker.x,
|
|
336
|
+
renderedY: previewResult.marker.y,
|
|
337
|
+
wasClamped: previewResult.marker.wasClamped,
|
|
338
|
+
});
|
|
339
|
+
const addOutput = context?.addOutput || null;
|
|
340
|
+
if (addOutput) {
|
|
341
|
+
const previewScreenshotPath = screenshotPathFromRecorderResult(previewScreenshotResult);
|
|
342
|
+
const previewScreenshotDataUrl = context?.includeScreenshotDataUrl
|
|
343
|
+
? `data:image/png;base64,${previewResult.screenshotBase64}`
|
|
344
|
+
: null;
|
|
345
|
+
addOutput({
|
|
346
|
+
type: "action",
|
|
347
|
+
text: "Click",
|
|
348
|
+
eventType: "tool_call",
|
|
349
|
+
runId: context?.runId,
|
|
350
|
+
stepId: stepContext?.stepId,
|
|
351
|
+
instructionIndex: stepContext?.instructionIndex,
|
|
352
|
+
payload: {
|
|
353
|
+
platform: this.session.deviceInfo?.platform,
|
|
354
|
+
toolName: "preview_click",
|
|
355
|
+
captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
356
|
+
requestedX: previewResult.marker.requestedX,
|
|
357
|
+
requestedY: previewResult.marker.requestedY,
|
|
358
|
+
renderedX: previewResult.marker.x,
|
|
359
|
+
renderedY: previewResult.marker.y,
|
|
360
|
+
wasClamped: previewResult.marker.wasClamped,
|
|
361
|
+
...(previewScreenshotPath ? { screenshotPath: previewScreenshotPath } : {}),
|
|
362
|
+
...(previewScreenshotDataUrl ? { screenshotDataUrl: previewScreenshotDataUrl } : {})
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
330
366
|
return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
|
|
331
|
-
text:
|
|
332
|
-
requestedX: previewResult.marker.requestedX,
|
|
333
|
-
requestedY: previewResult.marker.requestedY,
|
|
334
|
-
renderedX: previewResult.marker.x,
|
|
335
|
-
renderedY: previewResult.marker.y,
|
|
336
|
-
wasClamped: previewResult.marker.wasClamped,
|
|
337
|
-
}),
|
|
367
|
+
text: previewText,
|
|
338
368
|
annotatedScreenshotBase64: previewResult.screenshotBase64,
|
|
339
369
|
}));
|
|
340
370
|
}
|
|
@@ -464,7 +494,7 @@ export class ExecutionEngine {
|
|
|
464
494
|
stepId: stepContext?.stepId,
|
|
465
495
|
instructionIndex: stepContext?.instructionIndex,
|
|
466
496
|
eventId: actionEventId,
|
|
467
|
-
captureSource:
|
|
497
|
+
captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
|
|
468
498
|
});
|
|
469
499
|
const annotatedScreenshotPath = screenshotPathFromRecorderResult(annotatedScreenshotResult);
|
|
470
500
|
const annotatedScreenshotDataUrl = screenshotDataUrlFromRecorderResult(annotatedScreenshotResult);
|
|
@@ -474,6 +504,7 @@ export class ExecutionEngine {
|
|
|
474
504
|
stepId: stepContext?.stepId,
|
|
475
505
|
instructionIndex: stepContext?.instructionIndex,
|
|
476
506
|
eventId: actionEventId,
|
|
507
|
+
captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
477
508
|
...(annotatedScreenshotPath ? { screenshotPath: annotatedScreenshotPath } : {}),
|
|
478
509
|
...(context?.includeScreenshotDataUrl && annotatedScreenshotDataUrl ? { screenshotDataUrl: annotatedScreenshotDataUrl } : {})
|
|
479
510
|
});
|
|
@@ -504,7 +535,7 @@ export class ExecutionEngine {
|
|
|
504
535
|
base64Length: screenshotBase64.length
|
|
505
536
|
});
|
|
506
537
|
const screenshotEventId = randomUUID();
|
|
507
|
-
const captureSource = sawExplicitScreenshotAction ?
|
|
538
|
+
const captureSource = sawExplicitScreenshotAction ? CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION : CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION;
|
|
508
539
|
const screenshotResult = await this.recordScreenshot(screenshotBase64, {
|
|
509
540
|
runId: context?.runId,
|
|
510
541
|
sessionId: context?.sessionId,
|
|
@@ -548,7 +579,9 @@ export class ExecutionEngine {
|
|
|
548
579
|
note: runtimeNote
|
|
549
580
|
});
|
|
550
581
|
}
|
|
551
|
-
const selectedCuaModel = process.env.OPENAI_CUA_MODEL
|
|
582
|
+
const selectedCuaModel = VALID_CUA_MODELS.has(process.env.OPENAI_CUA_MODEL)
|
|
583
|
+
? process.env.OPENAI_CUA_MODEL
|
|
584
|
+
: "gpt-5.4";
|
|
552
585
|
const input = [{
|
|
553
586
|
type: "computer_call_output",
|
|
554
587
|
call_id,
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { buildBaseSystemPrompt } from "../prompts/base.js";
|
|
2
2
|
export { buildDesignModePrompt, buildDesignRecoveryPrompt } from "../prompts/design.js";
|
|
3
|
-
export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
|
|
3
|
+
export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session manages the state for a single CLI session
|
|
3
|
-
* Includes
|
|
3
|
+
* Includes target info, message history, transcript, and response chaining
|
|
4
4
|
*/
|
|
5
5
|
export class Session {
|
|
6
|
-
constructor(
|
|
7
|
-
this.deviceId =
|
|
8
|
-
this.deviceInfo =
|
|
6
|
+
constructor(targetSessionId, targetInfo) {
|
|
7
|
+
this.deviceId = targetSessionId;
|
|
8
|
+
this.deviceInfo = targetInfo;
|
|
9
|
+
this.deviceName = targetInfo?.device_name || null;
|
|
10
|
+
this.browserSessionId = targetInfo?.platform === "browser" ? targetSessionId : null;
|
|
11
|
+
this.browserInfo = targetInfo?.platform === "browser" ? targetInfo : null;
|
|
12
|
+
this.browserName = targetInfo?.browserName || null;
|
|
13
|
+
this.currentUrl = targetInfo?.currentUrl || null;
|
|
14
|
+
this.sessionMode = targetInfo?.sessionMode || null;
|
|
15
|
+
this.targetKind = targetInfo?.platform === "browser" ? "browser" : "mobile";
|
|
9
16
|
this.messages = [];
|
|
10
17
|
this.previousResponseId = null;
|
|
11
18
|
this.transcript = [];
|
|
@@ -54,4 +61,11 @@ export class Session {
|
|
|
54
61
|
updateResponseId(responseId) {
|
|
55
62
|
this.previousResponseId = responseId;
|
|
56
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Replace the active system prompt for the next turn and start a fresh response chain.
|
|
66
|
+
*/
|
|
67
|
+
installRecoverySystemPrompt(prompt) {
|
|
68
|
+
this.messages = [{ role: "system", content: prompt }];
|
|
69
|
+
this.updateResponseId(null);
|
|
70
|
+
}
|
|
57
71
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function buildActionEventMeta({ context, action, platform, payload = {}, extraPayload = {} }) {
|
|
2
|
+
return {
|
|
3
|
+
eventType: "tool_call",
|
|
4
|
+
actionType: action?.type,
|
|
5
|
+
runId: context?.runId,
|
|
6
|
+
stepId: context?.stepId,
|
|
7
|
+
instructionIndex: context?.instructionIndex,
|
|
8
|
+
payload: {
|
|
9
|
+
eventId: context?.eventId,
|
|
10
|
+
screenshotPath: context?.screenshotPath,
|
|
11
|
+
screenshotDataUrl: context?.screenshotDataUrl,
|
|
12
|
+
captureSource: context?.captureSource,
|
|
13
|
+
platform,
|
|
14
|
+
...extraPayload,
|
|
15
|
+
...payload,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
2
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
|
+
import { buildActionEventMeta } from "../action-event-meta.js";
|
|
4
5
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
5
6
|
import { execAdb } from "./tools.js";
|
|
6
7
|
function adbShell(deviceId, command) {
|
|
@@ -33,19 +34,11 @@ function normalizeMobileKeypress(keys = []) {
|
|
|
33
34
|
}
|
|
34
35
|
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
35
36
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
36
|
-
const meta = (payload = {}) => ({
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
instructionIndex: context?.instructionIndex,
|
|
42
|
-
payload: {
|
|
43
|
-
eventId: context?.eventId,
|
|
44
|
-
screenshotPath: context?.screenshotPath,
|
|
45
|
-
screenshotDataUrl: context?.screenshotDataUrl,
|
|
46
|
-
platform: 'android',
|
|
47
|
-
...payload
|
|
48
|
-
}
|
|
37
|
+
const meta = (payload = {}) => buildActionEventMeta({
|
|
38
|
+
context,
|
|
39
|
+
action,
|
|
40
|
+
platform: 'android',
|
|
41
|
+
payload
|
|
49
42
|
});
|
|
50
43
|
try {
|
|
51
44
|
const { x, y, x1, y1, x2, y2, text, keys, path } = action;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
2
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
|
+
import { buildActionEventMeta } from "../action-event-meta.js";
|
|
4
5
|
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
5
6
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
6
7
|
function normalizeMobileKeypress(platform, keys = []) {
|
|
@@ -47,20 +48,14 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
47
48
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
48
49
|
const session = getActiveSession();
|
|
49
50
|
const platform = session?.platform;
|
|
50
|
-
const meta = (payload = {}) => ({
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
screenshotPath: context?.screenshotPath,
|
|
59
|
-
screenshotDataUrl: context?.screenshotDataUrl,
|
|
60
|
-
platform,
|
|
61
|
-
source: session?.providerId,
|
|
62
|
-
...payload
|
|
63
|
-
}
|
|
51
|
+
const meta = (payload = {}) => buildActionEventMeta({
|
|
52
|
+
context,
|
|
53
|
+
action,
|
|
54
|
+
platform,
|
|
55
|
+
extraPayload: {
|
|
56
|
+
source: session?.providerId
|
|
57
|
+
},
|
|
58
|
+
payload
|
|
64
59
|
});
|
|
65
60
|
if (!session || !platform) {
|
|
66
61
|
throw new Error("No active cloud session");
|
|
@@ -103,6 +103,14 @@ export function getActiveSession() {
|
|
|
103
103
|
export function getDevicePixelRatio() {
|
|
104
104
|
return activeSession?.devicePixelRatio || 1;
|
|
105
105
|
}
|
|
106
|
+
// Test-only — do not call from production code. Used by unit tests to install
|
|
107
|
+
// a fake session without going through the real cloud-provider connect flow.
|
|
108
|
+
export function setActiveSessionForTests(session) {
|
|
109
|
+
activeSession = session;
|
|
110
|
+
}
|
|
111
|
+
export function clearActiveSessionForTests() {
|
|
112
|
+
activeSession = null;
|
|
113
|
+
}
|
|
106
114
|
export async function disconnect() {
|
|
107
115
|
if (!activeSession) {
|
|
108
116
|
return;
|
|
@@ -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");
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
8
|
import { existsSync, readdirSync } from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
+
import { COMMON_NODE_BIN_DIRS, makeNodeCliCommandEnv } from "./node-cli-env.js";
|
|
10
11
|
let appiumProcess = null;
|
|
11
12
|
let cleanupRegistered = false;
|
|
12
13
|
function getAppiumBaseUrl() {
|
|
@@ -35,8 +36,7 @@ export function resolveAppiumCommand() {
|
|
|
35
36
|
return explicit;
|
|
36
37
|
}
|
|
37
38
|
const commonCandidates = [
|
|
38
|
-
"
|
|
39
|
-
"/usr/local/bin/appium",
|
|
39
|
+
...COMMON_NODE_BIN_DIRS.map((dir) => path.join(dir, "appium")),
|
|
40
40
|
...collectNvmAppiumCandidates()
|
|
41
41
|
];
|
|
42
42
|
for (const candidate of commonCandidates) {
|
|
@@ -50,7 +50,8 @@ async function spawnAppiumProcess(command) {
|
|
|
50
50
|
return await new Promise((resolve, reject) => {
|
|
51
51
|
const child = spawn(command, [], {
|
|
52
52
|
detached: true,
|
|
53
|
-
stdio: "ignore"
|
|
53
|
+
stdio: "ignore",
|
|
54
|
+
env: makeNodeCliCommandEnv(command)
|
|
54
55
|
});
|
|
55
56
|
child.once("error", reject);
|
|
56
57
|
child.once("spawn", () => resolve(child));
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
export const COMMON_NODE_BIN_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin"];
|
|
3
|
+
function prependPathEntries(entries, originalPath) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const next = [];
|
|
6
|
+
const allEntries = [
|
|
7
|
+
...entries,
|
|
8
|
+
...(originalPath
|
|
9
|
+
?.split(path.delimiter)
|
|
10
|
+
.map((entry) => entry.trim())
|
|
11
|
+
.filter(Boolean) ?? [])
|
|
12
|
+
];
|
|
13
|
+
for (const entry of allEntries) {
|
|
14
|
+
if (seen.has(entry))
|
|
15
|
+
continue;
|
|
16
|
+
seen.add(entry);
|
|
17
|
+
next.push(entry);
|
|
18
|
+
}
|
|
19
|
+
return next.join(path.delimiter);
|
|
20
|
+
}
|
|
21
|
+
export function makeNodeCliCommandEnv(command) {
|
|
22
|
+
const binDirs = path.isAbsolute(command) ? [path.dirname(command), ...COMMON_NODE_BIN_DIRS] : COMMON_NODE_BIN_DIRS;
|
|
23
|
+
return {
|
|
24
|
+
...process.env,
|
|
25
|
+
// Finder-launched apps may miss Homebrew paths; npm/Appium shims use "#!/usr/bin/env node".
|
|
26
|
+
PATH: prependPathEntries(binDirs, process.env.PATH)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { readContextOptimizationEnv } from "../core/context-optimization.js";
|
|
2
3
|
import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
|
|
3
4
|
import { buildAppContextCompactionInput } from "../prompts/execution.js";
|
|
4
5
|
import { logger } from "../utils/logger.js";
|
|
5
6
|
import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
|
|
6
7
|
let openai = null;
|
|
7
8
|
const cuaDebugTracer = new CuaDebugTracer(logger);
|
|
9
|
+
const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
|
|
8
10
|
function getSelectedCuaModel() {
|
|
9
|
-
return process.env.OPENAI_CUA_MODEL
|
|
11
|
+
return VALID_CUA_MODELS.has(process.env.OPENAI_CUA_MODEL) ? process.env.OPENAI_CUA_MODEL : "gpt-5.4";
|
|
10
12
|
}
|
|
11
13
|
function normalizeMessageContent(content) {
|
|
12
14
|
if (Array.isArray(content)) {
|
|
@@ -88,7 +90,18 @@ export function buildCuaInput({ messages, screenshotBase64, previousResponseId,
|
|
|
88
90
|
}
|
|
89
91
|
return input;
|
|
90
92
|
}
|
|
93
|
+
function buildContextManagement(cuaModel) {
|
|
94
|
+
if (cuaModel === "computer-use-preview") {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const { enabled, threshold } = readContextOptimizationEnv();
|
|
98
|
+
if (!enabled) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return [{ type: "compaction", compact_threshold: threshold }];
|
|
102
|
+
}
|
|
91
103
|
export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
|
|
104
|
+
const contextManagement = buildContextManagement(cuaModel);
|
|
92
105
|
if (cuaModel === "computer-use-preview") {
|
|
93
106
|
return {
|
|
94
107
|
model: cuaModel,
|
|
@@ -111,6 +124,7 @@ export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo
|
|
|
111
124
|
return {
|
|
112
125
|
model: cuaModel,
|
|
113
126
|
reasoning: { effort: "medium" },
|
|
127
|
+
...(contextManagement ? { context_management: contextManagement } : {}),
|
|
114
128
|
previous_response_id: previousResponseId || undefined,
|
|
115
129
|
input,
|
|
116
130
|
store: true,
|
|
@@ -137,12 +151,41 @@ function shouldMapPreviewAccessError(err) {
|
|
|
137
151
|
return (message.includes("computer-use-preview") &&
|
|
138
152
|
(message.includes("access") || message.includes("permission") || message.includes("not found") || message.includes("unsupported")));
|
|
139
153
|
}
|
|
154
|
+
export function shouldMapMiniCompatibilityError(err) {
|
|
155
|
+
const status = Number(err?.status);
|
|
156
|
+
const code = typeof err?.code === "string" ? err.code.toLowerCase() : "";
|
|
157
|
+
const type = typeof err?.type === "string" ? err.type.toLowerCase() : "";
|
|
158
|
+
const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
|
|
159
|
+
const mentionsMiniModel = message.includes("gpt-5.4-mini");
|
|
160
|
+
const mentionsComputerTool = message.includes("computer") || message.includes("tool");
|
|
161
|
+
const mentionsUnsupported = (message.includes("unsupported") ||
|
|
162
|
+
message.includes("not supported") ||
|
|
163
|
+
message.includes("does not support"));
|
|
164
|
+
if (code.includes("model_not_found")) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (status === 404) {
|
|
168
|
+
return mentionsMiniModel && (message.includes("not found") || mentionsUnsupported);
|
|
169
|
+
}
|
|
170
|
+
if (code.includes("unsupported")) {
|
|
171
|
+
return mentionsMiniModel || mentionsComputerTool;
|
|
172
|
+
}
|
|
173
|
+
if (!type.includes("invalid")) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return mentionsUnsupported && (mentionsMiniModel || mentionsComputerTool);
|
|
177
|
+
}
|
|
140
178
|
function mapCuaError(err, cuaModel) {
|
|
141
179
|
if (cuaModel === "computer-use-preview" && shouldMapPreviewAccessError(err)) {
|
|
142
180
|
const mapped = new Error("OpenAI API key does not have access to computer-use-preview. Switch to gpt-5.4 in Settings > CUA Model.");
|
|
143
181
|
mapped.cause = err;
|
|
144
182
|
return mapped;
|
|
145
183
|
}
|
|
184
|
+
if (cuaModel === "gpt-5.4-mini" && shouldMapMiniCompatibilityError(err)) {
|
|
185
|
+
const mapped = new Error("OpenAI rejected gpt-5.4-mini for computer-use execution. Switch to gpt-5.4 or computer-use-preview in Settings > CUA Model.");
|
|
186
|
+
mapped.cause = err;
|
|
187
|
+
return mapped;
|
|
188
|
+
}
|
|
146
189
|
return err;
|
|
147
190
|
}
|
|
148
191
|
export function isNonRetryableCuaError(err) {
|
|
@@ -157,7 +200,9 @@ export function isNonRetryableCuaError(err) {
|
|
|
157
200
|
code.includes("permission") ||
|
|
158
201
|
type.includes("permission") ||
|
|
159
202
|
message.includes("does not have access to computer-use-preview") ||
|
|
160
|
-
message.includes("switch to gpt-5.4 in settings > cua model")
|
|
203
|
+
message.includes("switch to gpt-5.4 in settings > cua model") ||
|
|
204
|
+
message.includes("openai rejected gpt-5.4-mini for computer-use execution") ||
|
|
205
|
+
message.includes("switch to gpt-5.4 or computer-use-preview in settings > cua model"));
|
|
161
206
|
}
|
|
162
207
|
function getOpenAI() {
|
|
163
208
|
if (!openai) {
|