@loadmill/droid-cua 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { access } from "node:fs/promises";
4
+ import { constants as fsConstants } from "node:fs";
5
+ export const CHROME_INSTALL_URL = "https://www.google.com/chrome/";
6
+ export const EDGE_INSTALL_URL = "https://www.microsoft.com/edge/download";
7
+ function readEnvValue(env, ...keys) {
8
+ for (const key of keys) {
9
+ const value = env?.[key];
10
+ if (typeof value === "string" && value.trim()) {
11
+ return value.trim();
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+ function normalizeWindowsBaseDirs(env, homeDir) {
17
+ const programFiles = readEnvValue(env, "PROGRAMFILES", "ProgramFiles") || "C:\\Program Files";
18
+ const programFilesX86 = readEnvValue(env, "PROGRAMFILES(X86)") || "C:\\Program Files (x86)";
19
+ const localAppData = readEnvValue(env, "LOCALAPPDATA")
20
+ || (homeDir ? path.join(homeDir, "AppData", "Local") : null)
21
+ || "C:\\Users\\Default\\AppData\\Local";
22
+ return { programFiles, programFilesX86, localAppData };
23
+ }
24
+ function uniquePaths(paths = []) {
25
+ return [...new Set(paths.filter((value) => typeof value === "string" && value.trim().length > 0))];
26
+ }
27
+ export function listInstalledBrowserCandidates({ platform = process.platform, env = process.env, homeDir = os.homedir(), } = {}) {
28
+ if (platform === "darwin") {
29
+ const appRoots = uniquePaths([
30
+ "/Applications",
31
+ homeDir ? path.join(homeDir, "Applications") : null,
32
+ ]);
33
+ return [
34
+ {
35
+ browserFamily: "chrome",
36
+ browserName: "Google Chrome",
37
+ candidates: appRoots.map((root) => path.join(root, "Google Chrome.app", "Contents", "MacOS", "Google Chrome")),
38
+ },
39
+ {
40
+ browserFamily: "edge",
41
+ browserName: "Microsoft Edge",
42
+ candidates: appRoots.map((root) => path.join(root, "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge")),
43
+ },
44
+ ];
45
+ }
46
+ if (platform === "win32") {
47
+ const { programFiles, programFilesX86, localAppData } = normalizeWindowsBaseDirs(env, homeDir);
48
+ return [
49
+ {
50
+ browserFamily: "chrome",
51
+ browserName: "Google Chrome",
52
+ candidates: uniquePaths([
53
+ path.win32.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
54
+ path.win32.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
55
+ path.win32.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
56
+ ]),
57
+ },
58
+ {
59
+ browserFamily: "edge",
60
+ browserName: "Microsoft Edge",
61
+ candidates: uniquePaths([
62
+ path.win32.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
63
+ path.win32.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
64
+ path.win32.join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
65
+ ]),
66
+ },
67
+ ];
68
+ }
69
+ return [];
70
+ }
71
+ async function pathExistsAndIsRunnable(filePath, platform, accessFn) {
72
+ const accessMode = platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK;
73
+ try {
74
+ await accessFn(filePath, accessMode);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function buildMissingBrowserMessage(platform) {
82
+ if (platform !== "darwin" && platform !== "win32") {
83
+ return "Installed browser detection is currently supported only on macOS and Windows. Install Google Chrome or Microsoft Edge on a supported machine.";
84
+ }
85
+ return `No supported browser was found. Install Google Chrome or Microsoft Edge and try again. Chrome: ${CHROME_INSTALL_URL} Edge: ${EDGE_INSTALL_URL}`;
86
+ }
87
+ export async function resolveInstalledBrowser({ platform = process.platform, env = process.env, homeDir = os.homedir(), accessFn = access, } = {}) {
88
+ const browserGroups = listInstalledBrowserCandidates({ platform, env, homeDir });
89
+ for (const browser of browserGroups) {
90
+ for (const executablePath of browser.candidates) {
91
+ if (await pathExistsAndIsRunnable(executablePath, platform, accessFn)) {
92
+ return {
93
+ browserFamily: browser.browserFamily,
94
+ browserName: browser.browserName,
95
+ executablePath,
96
+ };
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ export async function getInstalledBrowserAvailability(options = {}) {
103
+ const resolved = await resolveInstalledBrowser(options);
104
+ if (!resolved) {
105
+ return {
106
+ available: false,
107
+ browserFamily: null,
108
+ browserName: null,
109
+ executablePath: null,
110
+ message: buildMissingBrowserMessage(options.platform ?? process.platform),
111
+ };
112
+ }
113
+ return {
114
+ available: true,
115
+ browserFamily: resolved.browserFamily,
116
+ browserName: resolved.browserName,
117
+ executablePath: resolved.executablePath,
118
+ message: null,
119
+ };
120
+ }
121
+ export async function requireInstalledBrowser(options = {}) {
122
+ const availability = await getInstalledBrowserAvailability(options);
123
+ if (!availability.available || !availability.browserName || !availability.executablePath) {
124
+ throw new Error(availability.message || buildMissingBrowserMessage(options.platform ?? process.platform));
125
+ }
126
+ return {
127
+ browserFamily: availability.browserFamily,
128
+ browserName: availability.browserName,
129
+ executablePath: availability.executablePath,
130
+ };
131
+ }
@@ -0,0 +1,19 @@
1
+ let activeSession = null;
2
+ export function setActiveBrowserSession(session) {
3
+ activeSession = session;
4
+ }
5
+ export function clearActiveBrowserSession() {
6
+ activeSession = null;
7
+ }
8
+ export function getActiveBrowserSession(browserSessionId = null) {
9
+ if (!activeSession) {
10
+ throw new Error("Not connected to a browser session. Call connectToBrowser first.");
11
+ }
12
+ if (browserSessionId && activeSession.sessionId !== browserSessionId) {
13
+ throw new Error("Requested browser session is no longer active.");
14
+ }
15
+ return activeSession;
16
+ }
17
+ export function getActiveBrowserPage(browserSessionId = null) {
18
+ return getActiveBrowserSession(browserSessionId).page;
19
+ }
@@ -0,0 +1,79 @@
1
+ const HTTP_SCHEME_RE = /^https?:\/\//i;
2
+ const SCHEME_RE = /^[a-z][a-z\d+\-.]*:\/\//i;
3
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1)(:\d+)?$/i;
4
+ const EXPLICIT_PORT_RE = /^[^/]+:\d+$/;
5
+ const URL_ERROR_MESSAGE = "Browser URL must be a valid host or http:// / https:// URL.";
6
+ function hasUnsupportedBareScheme(rawValue) {
7
+ if (rawValue.includes("://")) {
8
+ return false;
9
+ }
10
+ const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
11
+ const colonIndex = authority.indexOf(":");
12
+ if (colonIndex <= 0 || LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
13
+ return false;
14
+ }
15
+ const prefix = authority.slice(0, colonIndex);
16
+ const suffix = authority.slice(colonIndex + 1);
17
+ return /^[a-z][a-z\d+\-.]*$/i.test(prefix) && !prefix.includes(".") && !/^\d+$/.test(suffix);
18
+ }
19
+ function inferDefaultScheme(rawValue) {
20
+ const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
21
+ if (LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
22
+ return "http://";
23
+ }
24
+ return "https://";
25
+ }
26
+ function parseUrlCandidate(rawValue) {
27
+ if (rawValue == null) {
28
+ return null;
29
+ }
30
+ if (typeof rawValue !== "string") {
31
+ throw new Error("Browser URL must be a string.");
32
+ }
33
+ const trimmed = rawValue.trim();
34
+ if (!trimmed) {
35
+ return null;
36
+ }
37
+ if (hasUnsupportedBareScheme(trimmed)) {
38
+ throw new Error("Browser URL must use http:// or https://.");
39
+ }
40
+ if (SCHEME_RE.test(trimmed) && !HTTP_SCHEME_RE.test(trimmed)) {
41
+ throw new Error("Browser URL must use http:// or https://.");
42
+ }
43
+ const candidate = HTTP_SCHEME_RE.test(trimmed)
44
+ ? trimmed
45
+ : `${inferDefaultScheme(trimmed)}${trimmed}`;
46
+ let parsedUrl;
47
+ try {
48
+ parsedUrl = new URL(candidate);
49
+ }
50
+ catch {
51
+ throw new Error(URL_ERROR_MESSAGE);
52
+ }
53
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
54
+ throw new Error("Browser URL must use http:// or https://.");
55
+ }
56
+ if (!parsedUrl.hostname) {
57
+ throw new Error(URL_ERROR_MESSAGE);
58
+ }
59
+ return parsedUrl;
60
+ }
61
+ export function normalizeOptionalAutomationUrl(rawValue) {
62
+ const parsedUrl = parseUrlCandidate(rawValue);
63
+ return parsedUrl ? parsedUrl.toString() : null;
64
+ }
65
+ export function normalizeAutomationUrl(rawValue) {
66
+ const normalizedUrl = normalizeOptionalAutomationUrl(rawValue);
67
+ if (!normalizedUrl) {
68
+ throw new Error("Browser URL is required.");
69
+ }
70
+ return normalizedUrl;
71
+ }
72
+ export function formatAutomationDisplayName(rawUrl) {
73
+ const normalizedUrl = normalizeOptionalAutomationUrl(rawUrl);
74
+ if (!normalizedUrl) {
75
+ return "Browser";
76
+ }
77
+ const parsedUrl = new URL(normalizedUrl);
78
+ return parsedUrl.host || parsedUrl.origin || normalizedUrl;
79
+ }
@@ -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
+ }
@@ -10,15 +10,19 @@
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
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' });
@@ -52,6 +56,7 @@ export async function handleHelp(args, session, context) {
52
56
  addOutput({ type: 'info', text: 'Platform Support:' });
53
57
  addOutput({ type: 'info', text: ' Android: Uses ADB to communicate with Android devices (physical or emulator)' });
54
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' });
55
60
  addOutput({ type: 'info', text: '' });
56
61
  addOutput({ type: 'info', text: 'Platform Detection:' });
57
62
  addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
@@ -71,6 +76,7 @@ export async function handleHelp(args, session, context) {
71
76
  addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
72
77
  addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
73
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' });
74
80
  addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
75
81
  addOutput({ type: 'info', text: ' /list (see all tests)' });
76
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
+ }
@@ -8,6 +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
+ import { CAPTURE_SOURCES } from "./capture-sources.js";
11
12
  const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
12
13
  function extractActionableOutputItems(items) {
13
14
  const entries = [];
@@ -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,
@@ -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;