@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.
Files changed (50) hide show
  1. package/README.md +7 -4
  2. package/build/index.js +131 -74
  3. package/build/src/browser/actions.js +9 -0
  4. package/build/src/browser/connection.js +37 -0
  5. package/build/src/browser/factory.js +22 -0
  6. package/build/src/browser/interface.js +14 -0
  7. package/build/src/browser/navigation.js +11 -0
  8. package/build/src/browser/normalize.js +36 -0
  9. package/build/src/browser/playwright/actions.js +199 -0
  10. package/build/src/browser/playwright/connection.js +207 -0
  11. package/build/src/browser/playwright/installed-browser.js +131 -0
  12. package/build/src/browser/playwright/session-state.js +19 -0
  13. package/build/src/browser/playwright/url.js +79 -0
  14. package/build/src/cli/headless-execution-config.js +4 -36
  15. package/build/src/cli/headless-target.js +64 -0
  16. package/build/src/cli/ink-shell.js +5 -2
  17. package/build/src/commands/help.js +9 -4
  18. package/build/src/commands/loadmill.js +10 -1
  19. package/build/src/commands/run.js +17 -28
  20. package/build/src/core/app-context.js +57 -33
  21. package/build/src/core/capture-sources.js +76 -0
  22. package/build/src/core/context-optimization.js +1 -1
  23. package/build/src/core/cua-models.js +9 -0
  24. package/build/src/core/execution-engine.js +43 -19
  25. package/build/src/core/prompts.js +1 -1
  26. package/build/src/core/runtime-config.js +96 -0
  27. package/build/src/core/session.js +18 -4
  28. package/build/src/device/action-event-meta.js +18 -0
  29. package/build/src/device/android/actions.js +6 -13
  30. package/build/src/device/cloud/actions.js +9 -14
  31. package/build/src/device/cloud/connection.js +8 -0
  32. package/build/src/device/ios/actions.js +6 -13
  33. package/build/src/device/ios/connection.js +8 -0
  34. package/build/src/device/loadmill.js +15 -9
  35. package/build/src/device/openai.js +67 -139
  36. package/build/src/integrations/loadmill/client.js +149 -131
  37. package/build/src/integrations/loadmill/executor.js +17 -11
  38. package/build/src/integrations/loadmill/index.js +1 -1
  39. package/build/src/integrations/loadmill/interpreter.js +4 -14
  40. package/build/src/modes/design-mode-ink.js +4 -3
  41. package/build/src/modes/design-mode.js +4 -3
  42. package/build/src/modes/execution-mode.js +139 -21
  43. package/build/src/prompts/base.js +92 -20
  44. package/build/src/prompts/design.js +6 -11
  45. package/build/src/prompts/execution.js +33 -109
  46. package/build/src/utils/console-output.js +8 -0
  47. package/build/src/utils/cua-debug-tracer.js +0 -1
  48. package/build/src/utils/run-screenshot-recorder.js +1 -1
  49. package/build/src/utils/structured-debug-log-manager.js +4 -1
  50. 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
- const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
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" || !VALID_CUA_MODELS.has(value)) {
24
- throw new Error(`${label} must be one of: ${VALID_CUA_MODEL_LIST.join(", ")}.`);
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
- const rawValue = process.env.OPENAI_CUA_MODEL;
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, appContextBudget?: number }} [options]
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
- appContextBudget: options.appContextBudget
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 device testing CLI' });
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, gpt-5.4-mini, or computer-use-preview' });
24
- addOutput({ type: 'info', text: ' --context <file> Optional app context file used to brief execution runs' });
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 { buildAppContextBriefing } from "../core/app-context.js";
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
- let appContextBriefing = '';
76
- if (context.appContextPath) {
77
- try {
78
- const result = await buildAppContextBriefing({
79
- contextPath: context.appContextPath,
80
- taskText,
81
- budget: context.appContextBudget,
82
- });
83
- appContextBriefing = result.briefing;
84
- if (appContextBriefing) {
85
- addOutput({ type: 'info', text: `Loaded app context briefing from: ${result.contextPath}` });
86
- }
87
- }
88
- catch (error) {
89
- const message = error instanceof Error ? error.message : 'Unknown app context error';
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, {}, appContextBriefing, { strictMode: Boolean(context.engine?.strictMode) });
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
- export async function buildAppContextBriefing({ contextPath, taskText, budget = DEFAULT_APP_CONTEXT_BUDGET, }) {
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 { briefing: "", contextPath: null };
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
- const normalizedBudget = normalizeAppContextBudget(budget);
43
- if (normalizedBudget === 0) {
44
- return { briefing: "", contextPath };
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 = 30000;
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
- const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
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: "preview-click",
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: 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
- }),
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: "action-click-annotation"
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 ? "call-output-explicit-action" : "call-output-post-action";
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";