@loadmill/droid-cua 2.44.0 → 2.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,10 +58,16 @@ models use your own OpenAI API key.
58
58
  - Android: connect a device or select an emulator
59
59
  - iOS: choose a simulator on macOS
60
60
 
61
+ No app of your own yet? The **Apps** page includes a built-in **Demo Shop**
62
+ Android app. Install it on a connected Android device or emulator, or pick it
63
+ for a cloud Android session, to try the agent right away.
64
+
61
65
  **5. Create or run a test**
62
66
 
63
67
  Use the desktop app to create a new test, edit an existing one, or run a saved script with live execution logs.
64
68
 
69
+ Use the Back and Forward buttons next to the sidebar toggle to revisit pages and selections. Pending editor changes are saved before Back or Forward leaves the page. Automatic batch-test transitions do not add history entries. Report summaries remain visible while switching between saved runs. Navigation history lasts for the current app session; opening a new destination after going back clears forward history.
70
+
65
71
  You can also keep project run history in a results folder and review past runs from desktop app reports.
66
72
 
67
73
  ---
@@ -99,7 +105,7 @@ Desktop app support:
99
105
  - **Desktop-first workflow** - Create, run, and manage tests from one app
100
106
  - **Setup guidance** - Configure API access and platform prerequisites in the app
101
107
  - **Device and simulator connection** - Connect Android targets and iOS simulators
102
- - **Network certificate preparation** - Manage one active root CA and install it on a connected local simulator or supported emulator, or get manual setup steps for a physical iPhone or Android phone, from the desktop app's Tools menu
108
+ - **Network certificate preparation** - Manage one active root CA and install it on a connected local simulator or supported emulator, or get manual setup steps for a physical iPhone or Android phone, from the desktop app's Settings → Network capture page
103
109
  - **Natural-language test creation** - Describe flows in plain English
104
110
  - **Test management** - Create, edit, save, and rerun reusable scripts, including tests organized in nested project folders
105
111
  - **Live execution logs** - Watch actions and progress as tests run
@@ -108,7 +114,7 @@ Desktop app support:
108
114
  - **Run video export** - Generate and download a polished MP4 replay from a desktop run's screenshots and instruction timeline
109
115
  - **Headless support** - Reuse scripts in CLI and automation workflows
110
116
 
111
- **Network certificate preparation:** Use **Tools → Manage Certificates** to see the active CA, upload a single `.pem` containing a CA certificate and its matching unencrypted private key, generate a new Droid CA, or remove the active CA. Droid stores one active pair in `~/.droid-cua/network/`; upload copies the PEM material without changing the source file, and removal does not change device trust. Connect a local iOS Simulator or a rootable Android Emulator, then choose **Tools → Install Network Certificate**. This installs the active CA, generating a Droid CA if none exists. Devices prepared with a previous CA may need installation again. Droid may reapply Android system trust when a prepared emulator reconnects after a cold boot. Restart an already running Android app after installation so it loads the updated trust store. With a local iPhone connected by USB, **Tools → Install Network Certificate** does not change the phone; it shows the manual steps instead. **Send to iPhone** opens the macOS share menu with a DER `.cer` copy of the active CA named after its common name (for example `Droid CUA Root fbd877ab.cer`, written to `~/.droid-cua/network/iphone/`); choose AirDrop and your iPhone, install it from **Settings → Profile Downloaded**, then turn on full trust for that common name under **Settings → General → About → Certificate Trust Settings**. Android Emulator certificate setup supports capable rootable images through Android 16; Google Play images without root access and Android 17 are not supported. With a physical Android phone connected over ADB, **Tools → Install Network Certificate** writes a PEM `.crt` copy of the active CA named after its common name (for example `Droid CUA Root fbd877ab.crt`, in `~/.droid-cua/network/android/`), copies it to the phone's `/sdcard/Download/` folder with `adb push`, and shows the manual steps; it changes no phone settings. Install it from **Settings → Security → Encryption & credentials → Install a certificate → CA certificate** (names vary by vendor), then restart the tested app. If the copy fails, Droid shows the local file path so you can copy it to the phone yourself.
117
+ **Network certificate preparation:** Use **Settings → Network capture → Manage certificates** to see the active CA, upload a single `.pem` containing a CA certificate and its matching unencrypted private key, generate a new Droid CA, or remove the active CA. Droid stores one active pair in `~/.droid-cua/network/`; upload copies the PEM material without changing the source file, and removal does not change device trust. Connect a local iOS Simulator or a rootable Android Emulator, then choose **Settings → Network capture → Install on connected device**. This installs the active CA, generating a Droid CA if none exists. Devices prepared with a previous CA may need installation again. Droid may reapply Android system trust when a prepared emulator reconnects after a cold boot. Restart an already running Android app after installation so it loads the updated trust store. With a local iPhone connected by USB, **Settings → Network capture → Install on connected device** does not change the phone; it shows the manual steps instead. **Send to iPhone** opens the macOS share menu with a DER `.cer` copy of the active CA named after its common name (for example `Droid CUA Root fbd877ab.cer`, written to `~/.droid-cua/network/iphone/`); choose AirDrop and your iPhone, install it from **Settings → Profile Downloaded**, then turn on full trust for that common name under **Settings → General → About → Certificate Trust Settings**. Android Emulator certificate setup supports capable rootable images through Android 16; Google Play images without root access and Android 17 are not supported. With a physical Android phone connected over ADB, **Settings → Network capture → Install on connected device** writes a PEM `.crt` copy of the active CA named after its common name (for example `Droid CUA Root fbd877ab.crt`, in `~/.droid-cua/network/android/`), copies it to the phone's `/sdcard/Download/` folder with `adb push`, and shows the manual steps; it changes no phone settings. Install it from **Settings → Security → Encryption & credentials → Install a certificate → CA certificate** (names vary by vendor), then restart the tested app. If the copy fails, Droid shows the local file path so you can copy it to the phone yourself.
112
118
 
113
119
  **Network capture:** Enable **Network Capture** in desktop Settings or pass `--capture-network` for CLI tests. Local Chrome and Edge capture through Playwright with no certificate or proxy setup, using the same network queries and report cards as mobile. Capture covers the test browser context, including frames and popups, from run start. Previously loaded traffic is not recovered and existing pages are not automatically reloaded. CLI `--url` navigation happens after capture starts. Cloud browser capture, WebSocket messages and HAR export are not supported.
114
120
 
@@ -319,6 +325,7 @@ Supported CLI options include:
319
325
  - `--context-optimization-threshold`
320
326
  - `--no-context-optimization`
321
327
  - `--explain`
328
+ - `--step-watchdog <off|on>`
322
329
  - `--base-prompt`
323
330
  - `--execution-prompt`
324
331
  - `--base-prompt-file`
@@ -359,6 +366,7 @@ Config and precedence rules:
359
366
  - `--no-context-optimization` disables Context Optimization.
360
367
  - Explain Mode is enabled by default and asks the agent for concise explanations of non-obvious decisions, skipped actions, and completion evidence; explanations appear in terminal output and reports.
361
368
  - Set `"explainMode": false` in the JSON config to disable it. The `--explain` flag remains available to explicitly enable it for a run.
369
+ - Step Watchdog is experimental and defaults to `off`. Use `--step-watchdog on` to check for failed operations or blocked execution, or `--step-watchdog off` to disable checks. The JSON field is `"stepWatchdogMode": "on"`; the CLI flag overrides it. Desktop users can toggle **Settings → Agent → Verify Step Results** on or off. Successful checks run silently. Failures use an assertion-style **Step Failed** card with the expected instruction, actor explanation, and screenshot. Interactive runs offer **Skip** to continue or **Stop** to fail; headless runs fail automatically. Evaluator errors end execution. Enable `--debug` or desktop **Debug Mode** to inspect `step_watchdog.result` entries; probabilities and evaluator diagnostics remain out of normal output and reports. See [Step Watchdog](docs/step-watchdog.md) for provider requirements, logging, and calibration.
362
370
  - `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
363
371
  - `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
364
372
  - If both inline and file-based prompt overrides are provided, the inline prompt flags win.
@@ -560,7 +568,7 @@ droid-cua \
560
568
 
561
569
  Headless debug artifacts:
562
570
  - `--debug` writes desktop-style structured JSONL artifacts under `logs/`.
563
- - Each run creates `logs/execution-<runId>-<timestamp>.jsonl`.
571
+ - Each run creates `logs/execution-<runId>-<timestamp>.jsonl`. New run IDs are UUIDs; a batch shares one ID across its tests and aggregate report, and a rerun gets a new ID.
564
572
  - Each run also creates a sibling screenshot folder next to that JSONL file.
565
573
  - Shared device events are written to `logs/device-events.jsonl`.
566
574
  - `--debug` no longer creates the legacy `logs/debug-*.log` file for headless runs.
package/build/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { withExecutionRunId } from "./src/core/execution-context.js";
1
3
  const processStartTime = Date.now();
2
4
  import minimist from "minimist";
3
5
  import dotenv from "dotenv";
@@ -850,6 +852,7 @@ async function main() {
850
852
  session.setSystemPrompt(initialSystemText);
851
853
  // If tests were selected, run in headless mode.
852
854
  if (isHeadlessInstructionsRun) {
855
+ const runId = randomUUID();
853
856
  let exitCode = null;
854
857
  let succeeded = false;
855
858
  try {
@@ -860,6 +863,7 @@ async function main() {
860
863
  llmProviderMode: headlessConfig.llmProviderMode,
861
864
  cuaModel: headlessConfig.cuaModel,
862
865
  explainMode: headlessConfig.explainMode,
866
+ stepWatchdogMode: headlessConfig.stepWatchdogMode,
863
867
  promptCustomizations: headlessConfig.promptCustomizations,
864
868
  contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
865
869
  contextOptimizationThreshold: headlessConfig.contextOptimizationThreshold,
@@ -875,7 +879,7 @@ async function main() {
875
879
  console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
876
880
  if (headlessConfig.scenarioPrompt) {
877
881
  const candidateRuns = await loadScenarioCandidateRuns(headlessInstructionRuns);
878
- const scenarioCases = await planScenarioRuns({
882
+ const scenarioCases = await withExecutionRunId(runId, () => planScenarioRuns({
879
883
  client: runtimeContext.openAiClient,
880
884
  runtimeConfig: runtimeContext.config,
881
885
  scenarioPrompt: headlessConfig.scenarioPrompt,
@@ -885,7 +889,7 @@ async function main() {
885
889
  })),
886
890
  appContextText,
887
891
  testDataText: preloadedTestData?.text || "",
888
- });
892
+ }));
889
893
  printScenarioCasePreview(scenarioCases);
890
894
  headlessInstructionRuns = buildScenarioRuns({
891
895
  candidateRuns,
@@ -894,6 +898,7 @@ async function main() {
894
898
  preloadedHeadlessTarget = buildScenarioInstructionTarget(preloadedHeadlessTarget, headlessConfig.scenarioPrompt);
895
899
  }
896
900
  const result = await runHeadlessInstructionRuns({
901
+ runId,
897
902
  runs: headlessInstructionRuns,
898
903
  instructionsTarget: preloadedHeadlessTarget,
899
904
  session,
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { parseStepWatchdogMode } from "../core/step-watchdog.js";
2
3
  import { readFile } from "fs/promises";
3
4
  import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
4
5
  import { AIM_ASSIST_MODES, DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
@@ -131,6 +132,7 @@ async function loadConfigFromFile(configPath) {
131
132
  cuaModel: undefined,
132
133
  aimAssistMode: undefined,
133
134
  explainMode: undefined,
135
+ stepWatchdogMode: undefined,
134
136
  promptCustomizations: createEmptyPromptCustomizations(),
135
137
  appContextEnabled: undefined,
136
138
  appContextPath: undefined,
@@ -152,6 +154,9 @@ async function loadConfigFromFile(configPath) {
152
154
  }
153
155
  normalized.explainMode = rawConfig.explainMode;
154
156
  }
157
+ if ("stepWatchdogMode" in rawConfig) {
158
+ normalized.stepWatchdogMode = parseStepWatchdogMode(rawConfig.stepWatchdogMode);
159
+ }
155
160
  if ("promptCustomizations" in rawConfig) {
156
161
  normalized.promptCustomizations = normalizePromptCustomizations(rawConfig.promptCustomizations, "config.promptCustomizations");
157
162
  }
@@ -226,6 +231,7 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
226
231
  cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
227
232
  aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
228
233
  explainMode: fileConfig?.explainMode ?? true,
234
+ stepWatchdogMode: fileConfig?.stepWatchdogMode ?? "off",
229
235
  promptCustomizations,
230
236
  appContextEnabled: fileConfig?.appContextEnabled ?? true,
231
237
  appContextPath: fileConfig?.appContextPath || null,
@@ -247,6 +253,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
247
253
  if (args.explain === true) {
248
254
  resolved.explainMode = true;
249
255
  }
256
+ if (args["step-watchdog"] !== undefined) {
257
+ resolved.stepWatchdogMode = parseStepWatchdogMode(args["step-watchdog"]);
258
+ }
250
259
  if (hasContextOptimizationThresholdFlag) {
251
260
  resolved.contextOptimizationEnabled = true;
252
261
  resolved.contextOptimizationThreshold = parseContextOptimizationThreshold(args["context-optimization-threshold"], "--context-optimization-threshold");
@@ -16,7 +16,7 @@ export function createCapturingAddOutput(underlying, sink, { cloudSink = null, u
16
16
  return (item) => {
17
17
  if (item && typeof item === "object") {
18
18
  const event = {
19
- ts: typeof item.ts === "string" ? item.ts : null,
19
+ ts: typeof item.ts === "string" && item.ts.trim() ? item.ts : new Date().toISOString(),
20
20
  kind: typeof item.type === "string" ? item.type : "info",
21
21
  text: typeof item.text === "string" ? item.text : "",
22
22
  eventType: item.eventType,
@@ -45,26 +45,52 @@ export function createCapturingAddOutput(underlying, sink, { cloudSink = null, u
45
45
  // boolean/error/event signals. Exactly one of {stopped, failures, errors} is ever set, so
46
46
  // the shared precedence (error > fail > stopped > skipped > pass) yields the same verdict
47
47
  // the CLI produced before — see R1.
48
- function deriveCounts(success, error, events) {
48
+ function deriveCounts(success, error, events, failureKind) {
49
49
  if (success)
50
50
  return { failures: 0, errors: 0, stopped: false };
51
51
  if (String(error || "").toLowerCase().includes("stopped by user")) {
52
52
  return { failures: 0, errors: 0, stopped: true };
53
53
  }
54
+ if (failureKind === "step_watchdog_error")
55
+ return { failures: 0, errors: 1, stopped: false };
56
+ if (failureKind === "step_watchdog")
57
+ return { failures: 1, errors: 0, stopped: false };
54
58
  const failedAssertion = events.some((event) => event.eventType === "assertion_result" && event.payload?.passed === false) ||
55
59
  String(error || "").startsWith("Assertion failed:");
56
60
  return failedAssertion ? { failures: 1, errors: 0, stopped: false } : { failures: 0, errors: 1, stopped: false };
57
61
  }
62
+ function deviceReportEvents(deviceInfo, startedAtIso, testName) {
63
+ if (!deviceInfo)
64
+ return [];
65
+ return [{
66
+ ts: startedAtIso ?? null,
67
+ kind: "system",
68
+ text: `Loading test: ${testName ?? "Test run"}`,
69
+ eventType: "run_started",
70
+ payload: {
71
+ deviceName: deviceInfo.device_name,
72
+ platform: deviceInfo.platform,
73
+ deviceSource: deviceInfo.source,
74
+ osVersion: deviceInfo.os_version,
75
+ },
76
+ }];
77
+ }
58
78
  // Builds a RunReportModel from captured CLI events plus the run's final stats. This is the
59
79
  // CLI-acquisition adapter: it knows how to capture live events + run stats, then delegates
60
80
  // ALL model assembly to the shared run-report-model.js (same code path as the XML/Desktop
61
81
  // parser). A `run_finished` event is synthesized when the captured output stream omits one.
62
- export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, instructionsTotal, stats = {}, events, }) {
82
+ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, durationMs, success, error, failureKind = undefined, stepWatchdogMode = undefined, stepWatchdogUsage = undefined, instructionsTotal, stats = {}, events, deviceInfo = undefined, }) {
63
83
  const warnings = [];
64
84
  const normalizedEvents = normalizeEvents(events, { fallbackTs: startedAtIso ?? null, warnings });
85
+ const terminalCheck = normalizedEvents.findLast((event) => event.eventType === "step_watchdog_result" && event.payload?.mode === "on");
86
+ failureKind ??= terminalCheck?.payload?.outcome === "failure" ? "step_watchdog"
87
+ : terminalCheck?.payload?.outcome === "error" ? "step_watchdog_error" : undefined;
65
88
  const summaryPayload = buildRunFinishedPayload({
66
89
  success,
67
90
  error,
91
+ failureKind,
92
+ stepWatchdogMode,
93
+ stepWatchdogUsage,
68
94
  durationMs,
69
95
  instructionsTotal,
70
96
  instructionsCompleted: stats.instructionsCompleted,
@@ -74,15 +100,18 @@ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, dura
74
100
  retries: stats.retryCount,
75
101
  });
76
102
  const hasRunFinished = normalizedEvents.some((event) => event.eventType === "run_finished");
77
- // The synthetic run_finished is already in canonical shape; append it as-is (ts stays
78
- // null, matching the historical CLI output) only when the producer did not emit one.
103
+ // Use execution time, not report generation/upload time, for the final step boundary.
104
+ const startedAtMs = Date.parse(startedAtIso);
105
+ const finishedAt = Number.isFinite(startedAtMs) && Number.isFinite(durationMs) && durationMs >= 0
106
+ ? new Date(startedAtMs + durationMs).toISOString()
107
+ : null;
79
108
  const allEvents = hasRunFinished
80
109
  ? normalizedEvents
81
110
  : [
82
111
  ...normalizedEvents,
83
- { ts: null, kind: "system", text: "Execution finished", eventType: "run_finished", payload: summaryPayload },
112
+ { ts: finishedAt, kind: "system", text: "Execution finished", eventType: "run_finished", payload: summaryPayload },
84
113
  ];
85
- const { failures, errors, stopped } = deriveCounts(success, error, allEvents);
114
+ const { failures, errors, stopped } = deriveCounts(success, error, allEvents, failureKind);
86
115
  const status = deriveRunStatus({ tests: 1, failures, errors, stopped });
87
116
  const successRate = computeSuccessRate({ tests: 1, failures, errors, status });
88
117
  return assembleRunReportModel({
@@ -96,7 +125,7 @@ export function buildCliRunReportModel({ suiteName, testName, startedAtIso, dura
96
125
  failureOrError: success
97
126
  ? undefined
98
127
  : makeFailureOrError({ kind: status === "error" ? "error" : "failure", message: error ?? undefined }),
99
- events: allEvents,
128
+ events: [...deviceReportEvents(deviceInfo, startedAtIso, testName), ...allEvents],
100
129
  warnings,
101
130
  });
102
131
  }
@@ -127,7 +156,7 @@ export function createCliRunReport(modelInput) {
127
156
  }),
128
157
  };
129
158
  }
130
- export function createCliAggregateRunReport({ suiteName, testName, startedAtIso, durationMs, childModels, }) {
159
+ export function createCliAggregateRunReport({ suiteName, testName, startedAtIso, durationMs, childModels, deviceInfo = undefined, }) {
131
160
  const childTests = childModels.map((model) => ({
132
161
  testName: model.testName ?? "Test",
133
162
  durationSeconds: model.durationSeconds,
@@ -162,7 +191,7 @@ export function createCliAggregateRunReport({ suiteName, testName, startedAtIso,
162
191
  skipped,
163
192
  status,
164
193
  }),
165
- events: [],
194
+ events: deviceReportEvents(deviceInfo, startedAtIso, testName),
166
195
  childTests,
167
196
  warnings: [],
168
197
  });
@@ -183,7 +212,7 @@ export function renderCliCloudReport(model, savedRunId) {
183
212
  for (const child of cloudModel.childTests ?? []) {
184
213
  replaceScreenshots(child.events);
185
214
  }
186
- return renderHtmlReport(cloudModel, { includeScripts: false });
215
+ return renderHtmlReport(cloudModel, { includeScripts: false, showRunDetails: false });
187
216
  }
188
217
  export async function writeCliRunReportArtifact(artifact, { reportPath, cwd = process.cwd(), isBatch = false } = {}) {
189
218
  const outputPath = resolveReportOutputPath({
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import path from "path";
2
3
  import { mkdir, writeFile } from "fs/promises";
3
4
  import { getNetworkCaptureContext, navigateToUrl as navigateBrowserToUrl, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
@@ -9,6 +10,7 @@ import { buildExecutionModePrompt } from "../core/prompts.js";
9
10
  import { addOpenAiResponseToDroidCuaRunUsage, createDroidCuaRunUsageTotals, estimateDroidCuaRunCostUsd, setDroidCuaRunUsageModel, } from "../core/droid-cua-run-usage.js";
10
11
  import { getDroidVersion } from "../core/droid-version.js";
11
12
  import { ExecutionMode } from "../modes/execution-mode.js";
13
+ import { STEP_WATCHDOG_MODEL } from "../core/step-watchdog.js";
12
14
  import { buildDroidRunConfiguration, reportDroidRunCompletion, } from "../integrations/loadmill/run-reporter.js";
13
15
  import { droidReportUploadManager } from "../integrations/loadmill/report-upload-manager.js";
14
16
  import { reportDroidCuaCiTestRunCompleted } from "../integrations/segment/droid-cua-segment.js";
@@ -28,10 +30,6 @@ function sanitizePathSegment(value) {
28
30
  .replace(/-+/g, "-")
29
31
  .replace(/^[-_.]+|[-_.]+$/g, "") || "test";
30
32
  }
31
- function createHeadlessRunId(index = 0) {
32
- const suffix = index > 0 ? `-${index + 1}` : "";
33
- return `run-${Date.now()}${suffix}`;
34
- }
35
33
  function networkLogPath(runId, report) {
36
34
  if (!report?.enabled)
37
35
  return path.resolve("logs", `network-${runId}.jsonl`);
@@ -73,14 +71,14 @@ function getRecordScreenshotDirForTest(baseScreenshotDir, testName, isBatchRun)
73
71
  }
74
72
  return path.join(baseScreenshotDir, sanitizePathSegment(testName));
75
73
  }
76
- function toDroidCuaSegmentRunStatus(success, error = null) {
74
+ function toDroidCuaSegmentRunStatus(success, error = null, failureKind = null) {
77
75
  if (success) {
78
76
  return "pass";
79
77
  }
80
78
  if (error === "Stopped by user") {
81
79
  return "skipped";
82
80
  }
83
- if (typeof error === "string" && error.startsWith("Assertion failed:")) {
81
+ if (failureKind === "step_watchdog" || (typeof error === "string" && error.startsWith("Assertion failed:"))) {
84
82
  return "failure";
85
83
  }
86
84
  return "error";
@@ -179,7 +177,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
179
177
  }
180
178
  return engineOptions;
181
179
  }
182
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
180
+ async function runHeadlessTest({ runId, captureIndex = 0, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
183
181
  let executionMode = null;
184
182
  let captureStarted = false;
185
183
  const finishCapture = async () => {
@@ -201,12 +199,13 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
201
199
  }
202
200
  };
203
201
  let loadmillSessionUser = null;
202
+ let stepWatchdogUsage = createDroidCuaRunUsageTotals({ model: STEP_WATCHDOG_MODEL });
204
203
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
205
204
  const buildSegmentUsageProperties = () => ({
206
205
  model: runUsageTotals.model,
207
206
  llmProviderMode: runtimeContext.config.llmProviderMode,
208
- estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals),
209
- tokensUsed: runUsageTotals.totalTokens,
207
+ estimatedCostUsd: estimateDroidCuaRunCostUsd(runUsageTotals, stepWatchdogUsage),
208
+ tokensUsed: runUsageTotals.totalTokens + stepWatchdogUsage.totalTokens,
210
209
  });
211
210
  const reportSegmentRunCompleted = async (input) => {
212
211
  await reportDroidCuaCiTestRunCompleted({
@@ -288,7 +287,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
288
287
  if (networkCapture) {
289
288
  try {
290
289
  const browserContext = isBrowserTarget ? getNetworkCaptureContext(session.deviceId) : null;
291
- const capturePath = await networkCapture.startCapture(networkLogPath(runId, report), { browserContext });
290
+ const capturePath = await networkCapture.startCapture(networkLogPath(isBatchRun ? `${runId}-${captureIndex + 1}` : runId, report), { browserContext });
292
291
  console.log(`Network log: ${capturePath}`);
293
292
  captureStarted = true;
294
293
  engine.networkQuery = networkCapture.query;
@@ -309,6 +308,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
309
308
  loadmillClient: runtimeContext.loadmillClient,
310
309
  runtimeConfig: runtimeContext.config,
311
310
  runtimeCredentials: runtimeContext.credentials,
311
+ testDataContext,
312
+ onStepWatchdogResponse: (response) => {
313
+ stepWatchdogUsage = addOpenAiResponseToDroidCuaRunUsage(stepWatchdogUsage, response);
314
+ },
312
315
  // Inline screenshots as data URLs so the HTML report is self-contained.
313
316
  ...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
314
317
  });
@@ -318,6 +321,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
318
321
  await headlessDebug.endExecutionSession(runId, {
319
322
  success: Boolean(result.success),
320
323
  error: result.error ?? null,
324
+ failureKind: result.failureKind,
321
325
  durationMs,
322
326
  instructionsTotal: instructions.length,
323
327
  instructionsCompleted: stats.instructionsCompleted ?? 0,
@@ -327,7 +331,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
327
331
  retries: stats.retryCount ?? 0
328
332
  });
329
333
  await reportSegmentRunCompleted({
330
- status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null),
334
+ status: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null, result.failureKind),
331
335
  durationMs,
332
336
  numOfSteps: instructions.length,
333
337
  });
@@ -338,6 +342,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
338
342
  loadmillClient: runtimeContext.loadmillClient,
339
343
  source: "ci",
340
344
  runId,
345
+ outcome: toDroidCuaSegmentRunStatus(Boolean(result.success), result.error ?? null, result.failureKind),
341
346
  projectName: path.basename(path.dirname(testPath)) || null,
342
347
  testName,
343
348
  runType: "test",
@@ -363,12 +368,17 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
363
368
  console.error(`\nTest failed: ${result.error}`);
364
369
  }
365
370
  const reportInput = {
371
+ deviceInfo,
366
372
  suiteName: path.basename(path.dirname(testPath)) || null,
367
373
  testName,
368
374
  startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
369
375
  durationMs,
370
376
  success: Boolean(result.success),
371
377
  error: result.error ?? null,
378
+ failureKind: result.failureKind,
379
+ ...(runtimeContext.config.stepWatchdogMode !== "off" && runtimeContext.config.stepWatchdogMode
380
+ ? { stepWatchdogMode: runtimeContext.config.stepWatchdogMode, stepWatchdogUsage }
381
+ : {}),
372
382
  instructionsTotal: instructions.length,
373
383
  stats,
374
384
  };
@@ -465,6 +475,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
465
475
  });
466
476
  }
467
477
  const failedReportInput = {
478
+ deviceInfo,
468
479
  suiteName: path.basename(path.dirname(testPath)) || null,
469
480
  testName,
470
481
  startedAtIso: stats.startTime ? new Date(stats.startTime).toISOString() : null,
@@ -518,7 +529,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
518
529
  throw error;
519
530
  }
520
531
  }
521
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
532
+ export async function runHeadlessInstructionRuns({ runId = randomUUID(), runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, networkCapture = null, initialBrowserUrl = null, }) {
522
533
  await clearLoadmillReportMetadata(report?.metadataPath);
523
534
  const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
524
535
  const runConfiguration = buildDroidRunConfiguration({
@@ -560,7 +571,6 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
560
571
  const aggregateLocalReportModels = [];
561
572
  const aggregateReportModels = [];
562
573
  for (const [index, run] of runs.entries()) {
563
- const runId = createHeadlessRunId(index);
564
574
  const testScreenshotDir = getRecordScreenshotDirForTest(screenshotDir, run.testName, isBatchRun);
565
575
  if (isBatchRun) {
566
576
  console.log(`\n[${index + 1}/${runs.length}] ${run.testName}`);
@@ -572,6 +582,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
572
582
  }
573
583
  const result = await runHeadlessTestImpl({
574
584
  runId,
585
+ captureIndex: index,
575
586
  testPath: run.testPath,
576
587
  testName: run.testName,
577
588
  instructions,
@@ -625,6 +636,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
625
636
  const message = error instanceof Error ? error.message : String(error);
626
637
  if (instructionsTarget?.type === "selection" && (report?.enabled || cloudReportEnabled)) {
627
638
  const failedReportModel = buildCliRunReportModel({
639
+ deviceInfo,
628
640
  suiteName: path.basename(path.dirname(run.testPath)) || null,
629
641
  testName: run.testName,
630
642
  startedAtIso: new Date().toISOString(),
@@ -670,6 +682,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
670
682
  : path.basename(instructionsTarget.path);
671
683
  if (isSelection && report?.enabled && aggregateLocalReportModels.length > 0) {
672
684
  const aggregateReport = createCliAggregateRunReport({
685
+ deviceInfo,
673
686
  suiteName: projectName,
674
687
  testName,
675
688
  startedAtIso: new Date(batchStartedAt).toISOString(),
@@ -691,7 +704,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
691
704
  ...runConfiguration,
692
705
  loadmillClient: runtimeContext.loadmillClient,
693
706
  source: "ci",
694
- runId: createHeadlessRunId(),
707
+ runId,
695
708
  projectName,
696
709
  testName,
697
710
  runType: "project",
@@ -715,6 +728,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
715
728
  });
716
729
  if (cloudUploadSession && reportedRun.savedRunId) {
717
730
  const aggregateReport = createCliAggregateRunReport({
731
+ deviceInfo,
718
732
  suiteName: projectName,
719
733
  testName,
720
734
  startedAtIso: new Date(batchStartedAt).toISOString(),
@@ -56,6 +56,7 @@ export async function handleHelp(args, session, context) {
56
56
  addOutput({ type: 'info', text: ' --execution-prompt-file Headless execution prompt customization file' });
57
57
  addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
58
58
  addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
59
+ addOutput({ type: 'info', text: ' --step-watchdog <off|on> Check for failures after each instruction: off (default), on' });
59
60
  addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
60
61
  addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through port 8765 without capture (all Mac addresses for iPhones and Android phones)' });
61
62
  addOutput({ type: 'info', text: ' --capture-network Capture local mobile or browser test traffic to one JSONL log per test' });
@@ -23,6 +23,7 @@ export async function handleStop(args, session, context) {
23
23
  // Set flag to stop execution
24
24
  if (context.activeExecutionMode) {
25
25
  context.activeExecutionMode.shouldStop = true;
26
+ context.activeExecutionMode.requestStop?.();
26
27
  }
27
28
  return true; // Continue loop
28
29
  }
@@ -20,15 +20,19 @@ export const CAPTURE_SOURCES = Object.freeze({
20
20
  CALL_OUTPUT_EXPLICIT_ACTION: "call-output-explicit-action",
21
21
  INSTRUCTION_INPUT: "instruction-input",
22
22
  INSTRUCTION_INPUT_ASSERTION: "instruction-input-assertion",
23
+ STEP_WATCHDOG: "step-watchdog",
24
+ STEP_FAILURE: "step-failure",
23
25
  DESIGN_INPUT: "design-input",
24
26
  });
25
27
  const HIDDEN_DEBUG_MEDIA_KINDS = new Set([
26
28
  CAPTURE_SOURCES.PREVIEW_CLICK,
29
+ CAPTURE_SOURCES.STEP_WATCHDOG,
27
30
  ]);
28
31
  const CLICK_ANNOTATION_MEDIA_KINDS = new Set([
29
32
  CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
30
33
  ]);
31
34
  const SCREENSHOT_MEDIA_KINDS = new Set([
35
+ CAPTURE_SOURCES.STEP_FAILURE,
32
36
  CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
33
37
  CAPTURE_SOURCES.CALL_OUTPUT_EXPLICIT_ACTION,
34
38
  CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
@@ -61,6 +61,7 @@ export function normalizeDroidCuaCostModel(model) {
61
61
  }
62
62
  return null;
63
63
  }
64
+ /** @param {{ model?: string | null }} [options] */
64
65
  export function createDroidCuaRunUsageTotals({ model = null } = {}) {
65
66
  const normalizedModel = trimToNull(model);
66
67
  return {
@@ -110,7 +111,11 @@ export function addOpenAiResponseToDroidCuaRunUsage(totals, response) {
110
111
  totalTokens: totals.totalTokens + totalTokens,
111
112
  };
112
113
  }
113
- export function estimateDroidCuaRunCostUsd(totals) {
114
+ /**
115
+ * @param {ReturnType<typeof createDroidCuaRunUsageTotals>} totals
116
+ * @param {{ inputTokens: number } | null} [stepWatchdogUsage]
117
+ */
118
+ export function estimateDroidCuaRunCostUsd(totals, stepWatchdogUsage = null) {
114
119
  const costModel = totals.costModel ?? normalizeDroidCuaCostModel(totals.model);
115
120
  if (!costModel) {
116
121
  return null;
@@ -119,6 +124,8 @@ export function estimateDroidCuaRunCostUsd(totals) {
119
124
  if (!pricing) {
120
125
  return null;
121
126
  }
122
- const cost = (totals.inputTokens / TOKENS_PER_MILLION) * pricing.inputPerMillionUsd;
127
+ // Decisions charges input tokens only; keep its usage separate from the actor model.
128
+ const cost = (totals.inputTokens / TOKENS_PER_MILLION) * pricing.inputPerMillionUsd
129
+ + (finiteNumber(stepWatchdogUsage?.inputTokens) / TOKENS_PER_MILLION) * 0.1;
123
130
  return Number(cost.toFixed(8));
124
131
  }
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ const executionRunId = new AsyncLocalStorage();
3
+ export function withExecutionRunId(runId, callback) {
4
+ return executionRunId.run(runId, callback);
5
+ }
6
+ export function getExecutionRunId() {
7
+ return executionRunId.getStore();
8
+ }