@loadmill/droid-cua 2.7.0 → 2.9.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/README.md +7 -4
- package/build/index.js +131 -74
- 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 +207 -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 +4 -36
- package/build/src/cli/headless-target.js +64 -0
- package/build/src/cli/ink-shell.js +5 -2
- package/build/src/commands/help.js +9 -4
- package/build/src/commands/loadmill.js +10 -1
- package/build/src/commands/run.js +17 -28
- package/build/src/core/app-context.js +57 -33
- package/build/src/core/capture-sources.js +76 -0
- package/build/src/core/context-optimization.js +1 -1
- package/build/src/core/cua-models.js +9 -0
- package/build/src/core/execution-engine.js +43 -19
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/runtime-config.js +96 -0
- 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/device/loadmill.js +15 -9
- package/build/src/device/openai.js +67 -139
- package/build/src/integrations/loadmill/client.js +149 -131
- package/build/src/integrations/loadmill/executor.js +17 -11
- package/build/src/integrations/loadmill/index.js +1 -1
- package/build/src/integrations/loadmill/interpreter.js +4 -14
- package/build/src/modes/design-mode-ink.js +4 -3
- package/build/src/modes/design-mode.js +4 -3
- package/build/src/modes/execution-mode.js +139 -21
- package/build/src/prompts/base.js +92 -20
- package/build/src/prompts/design.js +6 -11
- package/build/src/prompts/execution.js +33 -109
- package/build/src/utils/console-output.js +8 -0
- package/build/src/utils/cua-debug-tracer.js +0 -1
- package/build/src/utils/run-screenshot-recorder.js +1 -1
- package/build/src/utils/structured-debug-log-manager.js +4 -1
- package/package.json +2 -1
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { readFile } from "fs/promises";
|
|
3
|
-
import { DEFAULT_APP_CONTEXT_BUDGET, MAX_APP_CONTEXT_BUDGET, MIN_APP_CONTEXT_BUDGET, } from "../core/app-context.js";
|
|
4
3
|
import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
|
|
5
|
-
|
|
6
|
-
const VALID_CUA_MODEL_LIST = ["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"];
|
|
4
|
+
import { CUA_MODELS, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
|
|
7
5
|
function createEmptyPromptCustomizations() {
|
|
8
6
|
return {
|
|
9
7
|
basePromptInstructions: "",
|
|
@@ -20,17 +18,13 @@ function assertNonEmptyString(value, label) {
|
|
|
20
18
|
}
|
|
21
19
|
}
|
|
22
20
|
function validateCuaModel(value, label) {
|
|
23
|
-
if (typeof value !== "string" || !
|
|
24
|
-
throw new Error(`${label} must be one of: ${
|
|
21
|
+
if (typeof value !== "string" || !isValidCuaModel(value)) {
|
|
22
|
+
throw new Error(`${label} must be one of: ${CUA_MODELS.join(", ")}.`);
|
|
25
23
|
}
|
|
26
24
|
return value;
|
|
27
25
|
}
|
|
28
26
|
function readEnvDefaultCuaModel() {
|
|
29
|
-
|
|
30
|
-
if (typeof rawValue !== "string" || rawValue.trim().length === 0) {
|
|
31
|
-
return "gpt-5.4";
|
|
32
|
-
}
|
|
33
|
-
return VALID_CUA_MODELS.has(rawValue) ? rawValue : "gpt-5.4";
|
|
27
|
+
return resolveCuaModel(process.env.OPENAI_CUA_MODEL);
|
|
34
28
|
}
|
|
35
29
|
function parseBooleanFlagState(args, key) {
|
|
36
30
|
return {
|
|
@@ -38,24 +32,6 @@ function parseBooleanFlagState(args, key) {
|
|
|
38
32
|
disabled: args[`no-${key}`] === true || args[key] === false,
|
|
39
33
|
};
|
|
40
34
|
}
|
|
41
|
-
function parseBudgetValue(rawValue, label) {
|
|
42
|
-
const numericValue = (() => {
|
|
43
|
-
if (typeof rawValue === "number") {
|
|
44
|
-
return rawValue;
|
|
45
|
-
}
|
|
46
|
-
if (typeof rawValue === "string" && /^-?\d+$/.test(rawValue.trim())) {
|
|
47
|
-
return Number.parseInt(rawValue, 10);
|
|
48
|
-
}
|
|
49
|
-
return Number.NaN;
|
|
50
|
-
})();
|
|
51
|
-
if (!Number.isInteger(numericValue)) {
|
|
52
|
-
throw new Error(`${label} must be an integer between ${MIN_APP_CONTEXT_BUDGET} and ${MAX_APP_CONTEXT_BUDGET}.`);
|
|
53
|
-
}
|
|
54
|
-
if (numericValue < MIN_APP_CONTEXT_BUDGET || numericValue > MAX_APP_CONTEXT_BUDGET) {
|
|
55
|
-
throw new Error(`${label} must be between ${MIN_APP_CONTEXT_BUDGET} and ${MAX_APP_CONTEXT_BUDGET}.`);
|
|
56
|
-
}
|
|
57
|
-
return numericValue;
|
|
58
|
-
}
|
|
59
35
|
function parseContextOptimizationThreshold(rawValue, label) {
|
|
60
36
|
const numericValue = (() => {
|
|
61
37
|
if (typeof rawValue === "number") {
|
|
@@ -133,7 +109,6 @@ async function loadConfigFromFile(configPath) {
|
|
|
133
109
|
cuaModel: undefined,
|
|
134
110
|
promptCustomizations: createEmptyPromptCustomizations(),
|
|
135
111
|
appContextEnabled: undefined,
|
|
136
|
-
appContextBudget: undefined,
|
|
137
112
|
appContextPath: undefined,
|
|
138
113
|
contextOptimizationEnabled: undefined,
|
|
139
114
|
contextOptimizationThreshold: undefined,
|
|
@@ -151,9 +126,6 @@ async function loadConfigFromFile(configPath) {
|
|
|
151
126
|
}
|
|
152
127
|
normalized.appContextEnabled = rawConfig.appContextEnabled;
|
|
153
128
|
}
|
|
154
|
-
if ("appContextBudget" in rawConfig) {
|
|
155
|
-
normalized.appContextBudget = parseBudgetValue(rawConfig.appContextBudget, "config.appContextBudget");
|
|
156
|
-
}
|
|
157
129
|
if ("appContextPath" in rawConfig && rawConfig.appContextPath != null) {
|
|
158
130
|
assertNonEmptyString(rawConfig.appContextPath, "config.appContextPath");
|
|
159
131
|
normalized.appContextPath = path.resolve(configDir, rawConfig.appContextPath);
|
|
@@ -210,7 +182,6 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
210
182
|
cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
|
|
211
183
|
promptCustomizations,
|
|
212
184
|
appContextEnabled: fileConfig?.appContextEnabled ?? true,
|
|
213
|
-
appContextBudget: fileConfig?.appContextBudget ?? DEFAULT_APP_CONTEXT_BUDGET,
|
|
214
185
|
appContextPath: fileConfig?.appContextPath || null,
|
|
215
186
|
contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
|
|
216
187
|
contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
|
|
@@ -219,9 +190,6 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
|
|
|
219
190
|
if (typeof args["cua-model"] === "string") {
|
|
220
191
|
resolved.cuaModel = validateCuaModel(args["cua-model"], "--cua-model");
|
|
221
192
|
}
|
|
222
|
-
if (args["app-context-budget"] != null) {
|
|
223
|
-
resolved.appContextBudget = parseBudgetValue(args["app-context-budget"], "--app-context-budget");
|
|
224
|
-
}
|
|
225
193
|
if (hasContextOptimizationThresholdFlag) {
|
|
226
194
|
resolved.contextOptimizationEnabled = true;
|
|
227
195
|
resolved.contextOptimizationThreshold = parseContextOptimizationThreshold(args["context-optimization-threshold"], "--context-optimization-threshold");
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function normalizeTarget(value) {
|
|
2
|
+
if (value == null || value === "") {
|
|
3
|
+
return "mobile";
|
|
4
|
+
}
|
|
5
|
+
if (typeof value !== "string") {
|
|
6
|
+
throw new Error("--target must be one of: mobile, web.");
|
|
7
|
+
}
|
|
8
|
+
const normalized = value.trim().toLowerCase();
|
|
9
|
+
if (normalized === "mobile" || normalized === "web") {
|
|
10
|
+
return normalized;
|
|
11
|
+
}
|
|
12
|
+
throw new Error("--target must be one of: mobile, web.");
|
|
13
|
+
}
|
|
14
|
+
function normalizeSessionMode(value) {
|
|
15
|
+
if (value == null || value === "") {
|
|
16
|
+
return "persistent";
|
|
17
|
+
}
|
|
18
|
+
if (typeof value !== "string") {
|
|
19
|
+
throw new Error("--session-mode must be one of: persistent, fresh.");
|
|
20
|
+
}
|
|
21
|
+
const normalized = value.trim().toLowerCase();
|
|
22
|
+
if (normalized === "persistent" || normalized === "fresh") {
|
|
23
|
+
return normalized;
|
|
24
|
+
}
|
|
25
|
+
throw new Error("--session-mode must be one of: persistent, fresh.");
|
|
26
|
+
}
|
|
27
|
+
function hasNonEmptyString(value) {
|
|
28
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
29
|
+
}
|
|
30
|
+
export function resolveHeadlessTarget(args = {}) {
|
|
31
|
+
const target = normalizeTarget(args.target);
|
|
32
|
+
const sessionMode = normalizeSessionMode(args["session-mode"]);
|
|
33
|
+
const url = hasNonEmptyString(args.url) ? args.url.trim() : null;
|
|
34
|
+
const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
|
|
35
|
+
const platform = hasNonEmptyString(args.platform) ? args.platform.trim() : null;
|
|
36
|
+
if (target === "web") {
|
|
37
|
+
if (!url) {
|
|
38
|
+
throw new Error("--target web requires --url.");
|
|
39
|
+
}
|
|
40
|
+
if (avdName || platform) {
|
|
41
|
+
throw new Error("--avd and --platform cannot be used with --target web.");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
target,
|
|
45
|
+
url,
|
|
46
|
+
sessionMode,
|
|
47
|
+
avdName: null,
|
|
48
|
+
platform: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (hasNonEmptyString(args.url)) {
|
|
52
|
+
throw new Error("--url can only be used with --target web.");
|
|
53
|
+
}
|
|
54
|
+
if (args["session-mode"] != null) {
|
|
55
|
+
throw new Error("--session-mode can only be used with --target web.");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
target,
|
|
59
|
+
url: null,
|
|
60
|
+
sessionMode: null,
|
|
61
|
+
avdName,
|
|
62
|
+
platform,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -7,7 +7,7 @@ import { routeCommand } from '../commands/index.js';
|
|
|
7
7
|
* Start the Ink-based conversational shell
|
|
8
8
|
* @param {Object} session - Session object with device info
|
|
9
9
|
* @param {Object} executionEngine - Execution engine instance
|
|
10
|
-
* @param {{ appContextPath?: string | null,
|
|
10
|
+
* @param {{ appContextPath?: string | null, runtimeContext?: Object | null }} [options]
|
|
11
11
|
* @returns {Promise<void>}
|
|
12
12
|
*/
|
|
13
13
|
export async function startInkShell(session, executionEngine, options = {}) {
|
|
@@ -34,7 +34,10 @@ export async function startInkShell(session, executionEngine, options = {}) {
|
|
|
34
34
|
...context,
|
|
35
35
|
engine: executionEngine,
|
|
36
36
|
appContextPath: options.appContextPath ?? null,
|
|
37
|
-
|
|
37
|
+
runtimeContext: options.runtimeContext ?? null,
|
|
38
|
+
openAiClient: options.runtimeContext?.openAiClient ?? null,
|
|
39
|
+
loadmillClient: options.runtimeContext?.loadmillClient ?? null,
|
|
40
|
+
runtimeCredentials: options.runtimeContext?.credentials ?? null
|
|
38
41
|
});
|
|
39
42
|
if (!shouldContinue) {
|
|
40
43
|
shouldExit = true;
|
|
@@ -10,19 +10,22 @@
|
|
|
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
|
|
24
|
-
addOutput({ type: 'info', text: ' --context <file> Optional app context file
|
|
25
|
-
addOutput({ type: 'info', text: ' --app-context-budget Headless app context token budget override' });
|
|
27
|
+
addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4 or gpt-5.4-mini' });
|
|
28
|
+
addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
|
|
26
29
|
addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
|
|
27
30
|
addOutput({ type: 'info', text: ' --context-optimization-threshold <tokens> Headless Context Optimization compaction threshold' });
|
|
28
31
|
addOutput({ type: 'info', text: ' --no-context-optimization Disable Context Optimization for headless execution' });
|
|
@@ -52,6 +55,7 @@ export async function handleHelp(args, session, context) {
|
|
|
52
55
|
addOutput({ type: 'info', text: 'Platform Support:' });
|
|
53
56
|
addOutput({ type: 'info', text: ' Android: Uses ADB to communicate with Android devices (physical or emulator)' });
|
|
54
57
|
addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
|
|
58
|
+
addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
|
|
55
59
|
addOutput({ type: 'info', text: '' });
|
|
56
60
|
addOutput({ type: 'info', text: 'Platform Detection:' });
|
|
57
61
|
addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
|
|
@@ -71,6 +75,7 @@ export async function handleHelp(args, session, context) {
|
|
|
71
75
|
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
72
76
|
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
73
77
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
78
|
+
addOutput({ type: 'info', text: ' droid-cua --target web --url docs.example.com --instructions tests/search.dcua' });
|
|
74
79
|
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
75
80
|
addOutput({ type: 'info', text: ' /list (see all tests)' });
|
|
76
81
|
addOutput({ type: 'info', text: ' /view login-test (view test contents)' });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Loadmill command handler
|
|
3
3
|
*/
|
|
4
|
-
import { executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
4
|
+
import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
|
|
5
|
+
import { createOpenAiClient, resolveRuntimeCredentialsFromEnv } from "../core/runtime-config.js";
|
|
5
6
|
/**
|
|
6
7
|
* Handle /loadmill command
|
|
7
8
|
* @param {string} args - Command arguments (natural language command)
|
|
@@ -47,8 +48,16 @@ export async function handleLoadmill(args, session, context) {
|
|
|
47
48
|
if (context.setAgentWorking) {
|
|
48
49
|
context.setAgentWorking(true, 'Running Loadmill flow...');
|
|
49
50
|
}
|
|
51
|
+
const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
|
|
52
|
+
const loadmillClient = context.loadmillClient || createLoadmillApiClient({
|
|
53
|
+
token: runtimeCredentials.loadmillApiToken,
|
|
54
|
+
baseUrl: runtimeCredentials.loadmillBaseUrl,
|
|
55
|
+
});
|
|
56
|
+
const openAiClient = context.openAiClient || createOpenAiClient(runtimeCredentials.openAiApiKey);
|
|
50
57
|
// Execute the command
|
|
51
58
|
const result = await executeLoadmillCommand(command, {
|
|
59
|
+
loadmillClient,
|
|
60
|
+
openAiClient,
|
|
52
61
|
onProgress: ({ message }) => {
|
|
53
62
|
addOutput({ type: 'info', text: `[Loadmill] ${message}` });
|
|
54
63
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { loadTest, listTests, testExists } from "../test-store/test-manager.js";
|
|
5
5
|
import { ExecutionMode } from "../modes/execution-mode.js";
|
|
6
6
|
import { buildExecutionModePrompt } from "../core/prompts.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveExecutionAppContext } from "../core/app-context.js";
|
|
8
8
|
import { logger } from "../utils/logger.js";
|
|
9
9
|
/**
|
|
10
10
|
* Handle /run command
|
|
@@ -51,7 +51,6 @@ export async function handleRun(args, session, context) {
|
|
|
51
51
|
// Load test instructions
|
|
52
52
|
addOutput({ type: 'system', text: `Loading test: ${testName}` });
|
|
53
53
|
const instructions = await loadTest(testName);
|
|
54
|
-
const taskText = instructions.join("\n");
|
|
55
54
|
addOutput({ type: 'info', text: `Loaded ${instructions.length} instructions` });
|
|
56
55
|
addOutput({ type: 'info', text: '' });
|
|
57
56
|
// Disable free-form input during execution (only allow commands like /exit)
|
|
@@ -72,34 +71,24 @@ export async function handleRun(args, session, context) {
|
|
|
72
71
|
// Each test instruction should execute in isolation
|
|
73
72
|
session.updateResponseId(undefined);
|
|
74
73
|
session.clearMessages();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
logger.error('CLI app context compaction failed', {
|
|
91
|
-
contextPath: context.appContextPath,
|
|
92
|
-
testName,
|
|
93
|
-
message,
|
|
94
|
-
});
|
|
95
|
-
addOutput({
|
|
96
|
-
type: 'warning',
|
|
97
|
-
text: `Warning: could not load app context from ${context.appContextPath}. Running without briefing.`,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
74
|
+
const appContext = await resolveExecutionAppContext({ contextPath: context.appContextPath });
|
|
75
|
+
const appContextText = appContext.text;
|
|
76
|
+
if (appContext.status === 'loaded' && appContext.contextPath) {
|
|
77
|
+
addOutput({ type: 'info', text: `Loaded app context from: ${appContext.contextPath}` });
|
|
78
|
+
}
|
|
79
|
+
else if (appContext.status === 'failed') {
|
|
80
|
+
logger.error('CLI app context load failed', {
|
|
81
|
+
contextPath: appContext.contextPath,
|
|
82
|
+
testName,
|
|
83
|
+
message: appContext.message,
|
|
84
|
+
});
|
|
85
|
+
addOutput({
|
|
86
|
+
type: 'warning',
|
|
87
|
+
text: `Warning: could not load app context from ${appContext.contextPath}. Running without it.`,
|
|
88
|
+
});
|
|
100
89
|
}
|
|
101
90
|
// Set execution mode system prompt (replaces any design mode prompt)
|
|
102
|
-
const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {},
|
|
91
|
+
const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, { strictMode: Boolean(context.engine?.strictMode) });
|
|
103
92
|
session.setSystemPrompt(executionPrompt);
|
|
104
93
|
// Create execution mode
|
|
105
94
|
const executionMode = new ExecutionMode(session, context.engine, instructions);
|
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { access, readFile } from "fs/promises";
|
|
3
3
|
import { constants as fsConstants } from "fs";
|
|
4
|
-
import { compactAppContext } from "../device/openai.js";
|
|
5
4
|
export const APP_CONTEXT_FILENAME = "context.md";
|
|
6
|
-
export const DEFAULT_APP_CONTEXT_BUDGET = 300;
|
|
7
|
-
export const MIN_APP_CONTEXT_BUDGET = 100;
|
|
8
|
-
export const MAX_APP_CONTEXT_BUDGET = 2000;
|
|
9
|
-
export function normalizeAppContextBudget(value) {
|
|
10
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
11
|
-
return DEFAULT_APP_CONTEXT_BUDGET;
|
|
12
|
-
}
|
|
13
|
-
const normalized = Math.round(value);
|
|
14
|
-
if (normalized < MIN_APP_CONTEXT_BUDGET) {
|
|
15
|
-
return MIN_APP_CONTEXT_BUDGET;
|
|
16
|
-
}
|
|
17
|
-
if (normalized > MAX_APP_CONTEXT_BUDGET) {
|
|
18
|
-
return MAX_APP_CONTEXT_BUDGET;
|
|
19
|
-
}
|
|
20
|
-
return normalized;
|
|
21
|
-
}
|
|
22
5
|
export function getDefaultProjectContextPath(projectPath) {
|
|
23
6
|
return path.join(projectPath, APP_CONTEXT_FILENAME);
|
|
24
7
|
}
|
|
@@ -35,23 +18,64 @@ export async function appContextFileExists(filePath) {
|
|
|
35
18
|
return false;
|
|
36
19
|
}
|
|
37
20
|
}
|
|
38
|
-
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ contextPath?: string | null }} [options]
|
|
23
|
+
*/
|
|
24
|
+
export async function loadAppContext({ contextPath }) {
|
|
25
|
+
if (!contextPath) {
|
|
26
|
+
return { text: "", contextPath: null };
|
|
27
|
+
}
|
|
28
|
+
const text = await readAppContextFile(contextPath);
|
|
29
|
+
return { text, contextPath };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* @param {{
|
|
33
|
+
* enabled?: boolean,
|
|
34
|
+
* contextPath?: string | null,
|
|
35
|
+
* missingStatus?: string,
|
|
36
|
+
* missingFileStatus?: string | null,
|
|
37
|
+
* }} [options]
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveExecutionAppContext({ enabled = true, contextPath = null, missingStatus = "missing", missingFileStatus = null, } = {}) {
|
|
40
|
+
if (!enabled) {
|
|
41
|
+
return {
|
|
42
|
+
status: "disabled",
|
|
43
|
+
text: "",
|
|
44
|
+
contextPath: null,
|
|
45
|
+
message: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
39
48
|
if (!contextPath) {
|
|
40
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
status: missingStatus,
|
|
51
|
+
text: "",
|
|
52
|
+
contextPath: null,
|
|
53
|
+
message: null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const result = await loadAppContext({ contextPath });
|
|
58
|
+
return {
|
|
59
|
+
status: result.text.trim().length > 0 ? "loaded" : "empty",
|
|
60
|
+
text: result.text,
|
|
61
|
+
contextPath: result.contextPath,
|
|
62
|
+
message: null,
|
|
63
|
+
};
|
|
41
64
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (missingFileStatus && error && typeof error === "object" && error.code === "ENOENT") {
|
|
67
|
+
return {
|
|
68
|
+
status: missingFileStatus,
|
|
69
|
+
text: "",
|
|
70
|
+
contextPath,
|
|
71
|
+
message: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
status: "failed",
|
|
76
|
+
text: "",
|
|
77
|
+
contextPath,
|
|
78
|
+
message: error instanceof Error ? error.message : "Unknown app context error",
|
|
79
|
+
};
|
|
45
80
|
}
|
|
46
|
-
const rawContext = await readAppContextFile(contextPath);
|
|
47
|
-
const result = await compactAppContext({
|
|
48
|
-
contextDocument: rawContext,
|
|
49
|
-
taskDescription: taskText,
|
|
50
|
-
tokenBudget: normalizedBudget,
|
|
51
|
-
});
|
|
52
|
-
return {
|
|
53
|
-
briefing: result.briefing,
|
|
54
|
-
outputTokens: result.outputTokens,
|
|
55
|
-
contextPath,
|
|
56
|
-
};
|
|
57
81
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const DEFAULT_CONTEXT_OPTIMIZATION_ENABLED = true;
|
|
2
|
-
export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD =
|
|
2
|
+
export const DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD = 20000;
|
|
3
3
|
export const MIN_CONTEXT_OPTIMIZATION_THRESHOLD = 10000;
|
|
4
4
|
export const MAX_CONTEXT_OPTIMIZATION_THRESHOLD = 900000;
|
|
5
5
|
export const CONTEXT_OPTIMIZATION_ENABLED_ENV = "DROID_CUA_CONTEXT_OPTIMIZATION_ENABLED";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
|
|
2
|
+
export const DEFAULT_CUA_MODEL = "gpt-5.4";
|
|
3
|
+
const VALID_CUA_MODELS = new Set(CUA_MODELS);
|
|
4
|
+
export function isValidCuaModel(value) {
|
|
5
|
+
return VALID_CUA_MODELS.has(value);
|
|
6
|
+
}
|
|
7
|
+
export function resolveCuaModel(value) {
|
|
8
|
+
return isValidCuaModel(value) ? value : DEFAULT_CUA_MODEL;
|
|
9
|
+
}
|
|
@@ -8,7 +8,7 @@ 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
|
-
|
|
11
|
+
import { CAPTURE_SOURCES } from "./capture-sources.js";
|
|
12
12
|
function extractActionableOutputItems(items) {
|
|
13
13
|
const entries = [];
|
|
14
14
|
for (const item of items) {
|
|
@@ -188,13 +188,14 @@ export class ExecutionEngine {
|
|
|
188
188
|
this.screenshotDir = options.screenshotDir || null;
|
|
189
189
|
this.screenshotRecorder = options.screenshotRecorder || null;
|
|
190
190
|
this.strictMode = options.strictMode === true;
|
|
191
|
-
this.stepDelayMs = getConfiguredStepDelayMs();
|
|
191
|
+
this.stepDelayMs = options.stepDelayMs ?? getConfiguredStepDelayMs();
|
|
192
192
|
this.reportedScreenshotWriteError = false;
|
|
193
193
|
this.reportedScreenshotAnnotateError = false;
|
|
194
194
|
this.lastRecordedScreenshotPaths = [];
|
|
195
195
|
this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
|
|
196
196
|
this.handleModelAction = options.handleModelAction || handleModelAction;
|
|
197
197
|
this.sendCUARequest = options.sendCUARequest || sendCUARequest;
|
|
198
|
+
this.reviseTestScript = options.reviseTestScript || null;
|
|
198
199
|
this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
|
|
199
200
|
}
|
|
200
201
|
async saveScreenshotArtifact(screenshotBase64, metadata = {}, options = {}) {
|
|
@@ -319,23 +320,51 @@ export class ExecutionEngine {
|
|
|
319
320
|
}));
|
|
320
321
|
}
|
|
321
322
|
const previewEventId = randomUUID();
|
|
322
|
-
await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
|
|
323
|
+
const previewScreenshotResult = await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
|
|
323
324
|
runId: context?.runId,
|
|
324
325
|
sessionId: context?.sessionId,
|
|
325
326
|
stepId: stepContext?.stepId,
|
|
326
327
|
instructionIndex: stepContext?.instructionIndex,
|
|
327
328
|
callId,
|
|
328
329
|
eventId: previewEventId,
|
|
329
|
-
captureSource:
|
|
330
|
+
captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
|
|
330
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
|
+
}
|
|
331
366
|
return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
|
|
332
|
-
text:
|
|
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
|
-
}),
|
|
367
|
+
text: previewText,
|
|
339
368
|
annotatedScreenshotBase64: previewResult.screenshotBase64,
|
|
340
369
|
}));
|
|
341
370
|
}
|
|
@@ -465,7 +494,7 @@ export class ExecutionEngine {
|
|
|
465
494
|
stepId: stepContext?.stepId,
|
|
466
495
|
instructionIndex: stepContext?.instructionIndex,
|
|
467
496
|
eventId: actionEventId,
|
|
468
|
-
captureSource:
|
|
497
|
+
captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION
|
|
469
498
|
});
|
|
470
499
|
const annotatedScreenshotPath = screenshotPathFromRecorderResult(annotatedScreenshotResult);
|
|
471
500
|
const annotatedScreenshotDataUrl = screenshotDataUrlFromRecorderResult(annotatedScreenshotResult);
|
|
@@ -475,6 +504,7 @@ export class ExecutionEngine {
|
|
|
475
504
|
stepId: stepContext?.stepId,
|
|
476
505
|
instructionIndex: stepContext?.instructionIndex,
|
|
477
506
|
eventId: actionEventId,
|
|
507
|
+
captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
|
|
478
508
|
...(annotatedScreenshotPath ? { screenshotPath: annotatedScreenshotPath } : {}),
|
|
479
509
|
...(context?.includeScreenshotDataUrl && annotatedScreenshotDataUrl ? { screenshotDataUrl: annotatedScreenshotDataUrl } : {})
|
|
480
510
|
});
|
|
@@ -505,7 +535,7 @@ export class ExecutionEngine {
|
|
|
505
535
|
base64Length: screenshotBase64.length
|
|
506
536
|
});
|
|
507
537
|
const screenshotEventId = randomUUID();
|
|
508
|
-
const captureSource = sawExplicitScreenshotAction ?
|
|
538
|
+
const captureSource = sawExplicitScreenshotAction ? CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION : CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION;
|
|
509
539
|
const screenshotResult = await this.recordScreenshot(screenshotBase64, {
|
|
510
540
|
runId: context?.runId,
|
|
511
541
|
sessionId: context?.sessionId,
|
|
@@ -549,9 +579,6 @@ export class ExecutionEngine {
|
|
|
549
579
|
note: runtimeNote
|
|
550
580
|
});
|
|
551
581
|
}
|
|
552
|
-
const selectedCuaModel = VALID_CUA_MODELS.has(process.env.OPENAI_CUA_MODEL)
|
|
553
|
-
? process.env.OPENAI_CUA_MODEL
|
|
554
|
-
: "gpt-5.4";
|
|
555
582
|
const input = [{
|
|
556
583
|
type: "computer_call_output",
|
|
557
584
|
call_id,
|
|
@@ -559,9 +586,6 @@ export class ExecutionEngine {
|
|
|
559
586
|
type: "computer_screenshot",
|
|
560
587
|
image_url: `data:image/png;base64,${screenshotBase64}`,
|
|
561
588
|
},
|
|
562
|
-
...(selectedCuaModel === "computer-use-preview"
|
|
563
|
-
? { current_url: this.getCurrentPlatform() === "ios" ? "ios://simulator" : "android://device" }
|
|
564
|
-
: {}),
|
|
565
589
|
...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
|
|
566
590
|
}];
|
|
567
591
|
if (runtimeNote) {
|
|
@@ -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";
|