@loadmill/droid-cua 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -2
  3. package/build/index.js +55 -20
  4. package/build/src/browser/actions.js +9 -0
  5. package/build/src/browser/connection.js +37 -0
  6. package/build/src/browser/factory.js +22 -0
  7. package/build/src/browser/interface.js +14 -0
  8. package/build/src/browser/navigation.js +11 -0
  9. package/build/src/browser/normalize.js +36 -0
  10. package/build/src/browser/playwright/actions.js +199 -0
  11. package/build/src/browser/playwright/connection.js +196 -0
  12. package/build/src/browser/playwright/installed-browser.js +131 -0
  13. package/build/src/browser/playwright/session-state.js +19 -0
  14. package/build/src/browser/playwright/url.js +79 -0
  15. package/build/src/cli/headless-execution-config.js +59 -3
  16. package/build/src/cli/headless-instructions.js +12 -0
  17. package/build/src/cli/headless-target.js +64 -0
  18. package/build/src/commands/help.js +10 -2
  19. package/build/src/core/capture-sources.js +76 -0
  20. package/build/src/core/context-optimization.js +36 -0
  21. package/build/src/core/execution-engine.js +45 -12
  22. package/build/src/core/prompts.js +1 -1
  23. package/build/src/core/session.js +18 -4
  24. package/build/src/device/action-event-meta.js +18 -0
  25. package/build/src/device/android/actions.js +6 -13
  26. package/build/src/device/cloud/actions.js +9 -14
  27. package/build/src/device/cloud/connection.js +8 -0
  28. package/build/src/device/ios/actions.js +6 -13
  29. package/build/src/device/ios/appium-server.js +4 -3
  30. package/build/src/device/ios/connection.js +8 -0
  31. package/build/src/device/ios/node-cli-env.js +28 -0
  32. package/build/src/device/openai.js +47 -2
  33. package/build/src/modes/execution-mode.js +113 -21
  34. package/build/src/prompts/base.js +95 -21
  35. package/build/src/prompts/design.js +6 -11
  36. package/build/src/prompts/execution.js +26 -4
  37. package/build/src/utils/run-screenshot-recorder.js +1 -1
  38. package/package.json +7 -1
@@ -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 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 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: "preview-click",
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: buildPreviewOutputText({
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: "action-click-annotation"
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 ? "call-output-explicit-action" : "call-output-post-action";
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 === "computer-use-preview" ? "computer-use-preview" : "gpt-5.4";
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 device info, message history, transcript, and response chaining
3
+ * Includes target info, message history, transcript, and response chaining
4
4
  */
5
5
  export class Session {
6
- constructor(deviceId, deviceInfo) {
7
- this.deviceId = deviceId;
8
- this.deviceInfo = 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
- eventType: 'tool_call',
38
- actionType: action?.type,
39
- runId: context?.runId,
40
- stepId: context?.stepId,
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
- eventType: "tool_call",
52
- actionType: action?.type,
53
- runId: context?.runId,
54
- stepId: context?.stepId,
55
- instructionIndex: context?.instructionIndex,
56
- payload: {
57
- eventId: context?.eventId,
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
- eventType: "tool_call",
49
- actionType: action?.type,
50
- runId: context?.runId,
51
- stepId: context?.stepId,
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
- "/opt/homebrew/bin/appium",
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 === "computer-use-preview" ? "computer-use-preview" : "gpt-5.4";
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) {