@loadmill/droid-cua 2.25.0 → 2.27.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 (37) hide show
  1. package/README.md +13 -5
  2. package/build/index.js +210 -2
  3. package/build/src/cli/cloud-status-line.js +58 -26
  4. package/build/src/cli/headless-execution-config.js +11 -0
  5. package/build/src/cli/headless-report.js +105 -15
  6. package/build/src/cli/headless-runner.js +125 -24
  7. package/build/src/cli/headless-target.js +63 -7
  8. package/build/src/cli/loadmill-cloud-credentials.js +11 -0
  9. package/build/src/cli/loadmill-cloud-usage.js +18 -0
  10. package/build/src/commands/help.js +5 -0
  11. package/build/src/core/cua-models.js +7 -0
  12. package/build/src/core/droid-cua-run-usage.js +8 -2
  13. package/build/src/core/execution-engine.js +6 -2
  14. package/build/src/core/report/render-html-report.js +69 -12
  15. package/build/src/core/runtime-config.js +10 -4
  16. package/build/src/device/assertions.js +3 -0
  17. package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
  18. package/build/src/device/cloud/connection.js +18 -6
  19. package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
  20. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
  21. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
  22. package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
  23. package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
  24. package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
  25. package/build/src/device/cloud/progress.js +29 -0
  26. package/build/src/device/cloud/registry.js +2 -1
  27. package/build/src/device/connection.js +3 -0
  28. package/build/src/device/openai.js +1 -0
  29. package/build/src/integrations/loadmill/client.js +14 -0
  30. package/build/src/integrations/loadmill/report-upload-manager.js +221 -0
  31. package/build/src/integrations/loadmill/run-reporter.js +6 -2
  32. package/build/src/modes/execution-mode.js +2 -1
  33. package/build/src/prompts/design.js +9 -1
  34. package/build/src/prompts/execution.js +10 -7
  35. package/build/src/utils/console-output.js +3 -0
  36. package/package.json +1 -1
  37. package/skill/droid-cua/references/running-and-healing.md +3 -3
@@ -7,6 +7,15 @@
7
7
  * previous phase (if any) just ended
8
8
  * - "note": persistent information worth keeping after the line clears
9
9
  * (e.g. billed device-minutes)
10
+ * - "phase-close-text": overrides the persisted "✔ <name> (Nms)" line that
11
+ * will be printed when the current phase closes, with an
12
+ * arbitrary caller-supplied string (e.g. richer poll/download
13
+ * summaries) instead of the default elapsed-ms text.
14
+ * - "print": a verbatim persistent line (no ℹ/✔ prefix) that first closes
15
+ * any still-open phase, so a final summary can never race
16
+ * ahead of / glue onto the active spinner or an unflushed
17
+ * phase-close line. Use this instead of raw console.log while
18
+ * a status line may be active.
10
19
  * With no listener installed, reports are dropped.
11
20
  */
12
21
  let listener = null;
@@ -22,3 +31,23 @@ export function reportCloudPhase(name) {
22
31
  export function reportCloudNote(message) {
23
32
  listener?.({ kind: "note", message });
24
33
  }
34
+ export function reportCloudPhaseCloseText(text) {
35
+ listener?.({ kind: "phase-close-text", message: text });
36
+ }
37
+ // Forces the currently-open phase (if any) to flush its "✔ ..." close line
38
+ // immediately, instead of waiting for the next reportCloudPhase() call to
39
+ // lazily trigger it. Needed when a note must appear strictly *after* a
40
+ // phase's close line rather than racing ahead of it (the close line only
41
+ // carries its final text via reportCloudPhaseCloseText, which by itself
42
+ // doesn't print anything until something forces the phase closed).
43
+ export function reportCloudPhaseFlush() {
44
+ listener?.({ kind: "phase-flush" });
45
+ }
46
+ export function reportCloudPrint(text) {
47
+ if (listener) {
48
+ listener({ kind: "print", message: text });
49
+ }
50
+ else {
51
+ console.log(text);
52
+ }
53
+ }
@@ -1,7 +1,8 @@
1
1
  import { browserStackAdapter } from "./browserstack/adapter.js";
2
2
  import { lambdaTestAdapter } from "./lambdatest/adapter.js";
3
3
  import { awsDeviceFarmAdapter } from "./aws-device-farm/adapter.js";
4
- const availableAdapters = [browserStackAdapter, lambdaTestAdapter, awsDeviceFarmAdapter];
4
+ import { loadmillCloudAdapter } from "./loadmill-cloud/adapter.js";
5
+ const availableAdapters = [browserStackAdapter, lambdaTestAdapter, awsDeviceFarmAdapter, loadmillCloudAdapter];
5
6
  export function listCloudProviderAdapters() {
6
7
  return availableAdapters;
7
8
  }
@@ -23,6 +23,9 @@ export async function connectToDevice(deviceNameOrOptions, platform = null, opti
23
23
  log(`Platform: ${detectedPlatform}`);
24
24
  return currentBackend.connectToDevice(cloudOptions, options);
25
25
  }
26
+ if (getCurrentSource() !== "local" && currentBackend) {
27
+ throw new Error("A cloud device session is active; disconnect it before connecting to a local device.");
28
+ }
26
29
  const detectedPlatform = platform || detectPlatform(deviceNameOrOptions);
27
30
  setCurrentSource("local");
28
31
  setCurrentPlatform(detectedPlatform);
@@ -9,6 +9,7 @@ const cuaDebugTracer = new CuaDebugTracer(logger);
9
9
  const LOADMILL_DEVICE_METADATA_MODELS = new Set([
10
10
  ...LOADMILL_CUA_MODELS,
11
11
  LOADMILL_MODEL_ALIASES.SMART,
12
+ LOADMILL_MODEL_ALIASES.PULSE,
12
13
  LOADMILL_MODEL_ALIASES.BEACON,
13
14
  ]);
14
15
  function resolveOpenAiBaseUrl(client) {
@@ -138,6 +138,18 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
138
138
  body: JSON.stringify(payload),
139
139
  });
140
140
  }
141
+ async function requestDroidRunReportUploadPolicy(uploadId, payload) {
142
+ return await apiRequest(`/droid-cua/report-uploads/${uploadId}/policy`, {
143
+ method: "POST",
144
+ body: JSON.stringify(payload),
145
+ });
146
+ }
147
+ async function associateDroidRunReport(runId, payload) {
148
+ return await apiRequest(`/droid-cua/runs/${runId}/report`, {
149
+ method: "POST",
150
+ body: JSON.stringify(payload),
151
+ });
152
+ }
141
153
  async function validateApiToken() {
142
154
  const resolvedToken = getApiToken();
143
155
  if (!resolvedToken) {
@@ -187,6 +199,8 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
187
199
  getTestSuiteFlowRun,
188
200
  explainFailure,
189
201
  reportDroidRun,
202
+ requestDroidRunReportUploadPolicy,
203
+ associateDroidRunReport,
190
204
  validateApiToken,
191
205
  getCurrentUser,
192
206
  };
@@ -0,0 +1,221 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ export const MAX_SCREENSHOTS_PER_REPORT = 1000;
3
+ export const MAX_REPORT_OBJECT_BYTES = 10 * 1024 * 1024;
4
+ function warn(session, message) {
5
+ session.failed = true;
6
+ session.onWarning?.(message);
7
+ }
8
+ function getHttpStatus(error) {
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ return /\((\d{3})\)/.exec(message)?.[1] ?? null;
11
+ }
12
+ function warnAboutUploadFailure(session) {
13
+ if (!session.uploadFailed) {
14
+ return;
15
+ }
16
+ const status = session.uploadFailureStatus ? ` (HTTP ${session.uploadFailureStatus})` : "";
17
+ session.onWarning?.(`Cloud report upload failed${status}; further uploads skipped.`);
18
+ }
19
+ function decodeScreenshot(dataUrl) {
20
+ const match = /^data:image\/png;base64,([a-zA-Z0-9+/=\s]+)$/.exec(dataUrl);
21
+ return match ? Buffer.from(match[1], "base64") : null;
22
+ }
23
+ async function uploadObject(uploadPolicy, body, name, contentType, fetchImpl) {
24
+ const form = new FormData();
25
+ for (const [key, value] of Object.entries(uploadPolicy.data ?? {})) {
26
+ form.append(key, String(value));
27
+ }
28
+ form.append("file", new Blob([body], { type: contentType }), name);
29
+ const response = await fetchImpl(uploadPolicy.url, { method: "POST", body: form });
30
+ if (!response.ok) {
31
+ throw new Error(`S3 upload failed (${response.status}): ${await response.text()}`);
32
+ }
33
+ }
34
+ export class DroidReportUploadManager {
35
+ constructor({ workers = 4, fetchImpl = fetch } = {}) {
36
+ this.workers = workers;
37
+ this.fetchImpl = fetchImpl;
38
+ this.queue = [];
39
+ this.activeWorkers = 0;
40
+ this.sessions = new Set();
41
+ }
42
+ /**
43
+ * @param {{
44
+ * loadmillClient?: {
45
+ * requestDroidRunReportUploadPolicy: (uploadId: string, payload: { name: string, size: number }) => Promise<unknown>,
46
+ * associateDroidRunReport: (runId: string, payload: object) => Promise<unknown>,
47
+ * },
48
+ * onWarning?: ((message: string) => void) | null,
49
+ * }} options
50
+ */
51
+ startSession({ loadmillClient, onWarning = null } = {}) {
52
+ const session = {
53
+ uploadId: randomUUID(),
54
+ loadmillClient,
55
+ onWarning,
56
+ sequence: 0,
57
+ screenshotCount: 0,
58
+ deduplication: new Map(),
59
+ pending: new Set(),
60
+ failed: false,
61
+ uploadFailed: false,
62
+ uploadFailureStatus: null,
63
+ finishing: false,
64
+ finishPromise: null,
65
+ };
66
+ this.sessions.add(session);
67
+ return session;
68
+ }
69
+ enqueueScreenshot(session, dataUrl) {
70
+ if (!session || session.finishing || session.uploadFailed || typeof dataUrl !== "string") {
71
+ return null;
72
+ }
73
+ const hash = createHash("sha256").update(dataUrl).digest("hex");
74
+ const existing = session.deduplication.get(hash);
75
+ if (existing) {
76
+ return existing;
77
+ }
78
+ const body = decodeScreenshot(dataUrl);
79
+ if (!body) {
80
+ warn(session, "Failed to upload Droid report screenshot: unsupported image data.");
81
+ return null;
82
+ }
83
+ if (body.length > MAX_REPORT_OBJECT_BYTES) {
84
+ warn(session, `Droid report screenshot exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`);
85
+ return null;
86
+ }
87
+ if (session.screenshotCount >= MAX_SCREENSHOTS_PER_REPORT) {
88
+ warn(session, `Droid report exceeds ${MAX_SCREENSHOTS_PER_REPORT} screenshots.`);
89
+ return null;
90
+ }
91
+ const name = `screenshots/${String(++session.sequence).padStart(4, "0")}.png`;
92
+ session.screenshotCount += 1;
93
+ session.deduplication.set(hash, name);
94
+ this.#enqueue(session, { name, body, contentType: "image/png" });
95
+ return name;
96
+ }
97
+ finish(session, { savedRunId, html } = {}) {
98
+ if (!session) {
99
+ return Promise.resolve({ published: false, reason: "missing_session" });
100
+ }
101
+ if (session.finishPromise) {
102
+ return session.finishPromise;
103
+ }
104
+ session.finishing = true;
105
+ session.finishPromise = this.#finish(session, { savedRunId, html }).catch((error) => {
106
+ const status = getHttpStatus(error);
107
+ warn(session, `Failed to publish Droid run report${status ? ` (HTTP ${status})` : ""}.`);
108
+ return { published: false, reason: "publish_failed" };
109
+ }).finally(() => {
110
+ this.sessions.delete(session);
111
+ });
112
+ return session.finishPromise;
113
+ }
114
+ async #finish(session, { savedRunId, html }) {
115
+ await this.waitForSession(session);
116
+ if (session.uploadFailed) {
117
+ warnAboutUploadFailure(session);
118
+ return { published: false, reason: "upload_failed" };
119
+ }
120
+ if (!savedRunId || typeof html !== "string") {
121
+ return { published: false, reason: "missing_report" };
122
+ }
123
+ const body = Buffer.from(html, "utf8");
124
+ if (body.length > MAX_REPORT_OBJECT_BYTES) {
125
+ warn(session, `Droid HTML report exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`);
126
+ return { published: false, reason: "too_large" };
127
+ }
128
+ const htmlUpload = await this.#enqueue(session, {
129
+ name: "report.html",
130
+ body,
131
+ contentType: "text/html; charset=utf-8",
132
+ });
133
+ if (session.failed) {
134
+ warnAboutUploadFailure(session);
135
+ return { published: false, reason: "upload_failed" };
136
+ }
137
+ const key = htmlUpload.key;
138
+ await session.loadmillClient.associateDroidRunReport(savedRunId, {
139
+ key,
140
+ screenshotCount: session.screenshotCount,
141
+ });
142
+ return { published: true, key };
143
+ }
144
+ async waitForSession(session) {
145
+ while (session?.pending?.size) {
146
+ await Promise.all([...session.pending]);
147
+ }
148
+ return { failed: Boolean(session?.failed) };
149
+ }
150
+ getPendingCount(session) {
151
+ return session?.pending?.size ?? 0;
152
+ }
153
+ /**
154
+ * @param {{ timeoutMs?: number | null }} options
155
+ */
156
+ async drain({ timeoutMs = null } = {}) {
157
+ const pending = Promise.all([...this.sessions].map((session) => session.finishPromise ?? this.waitForSession(session)));
158
+ if (!Number.isFinite(timeoutMs)) {
159
+ await pending;
160
+ return { timedOut: false };
161
+ }
162
+ let timer;
163
+ const result = await Promise.race([
164
+ pending.then(() => false),
165
+ new Promise((resolve) => {
166
+ timer = setTimeout(() => resolve(true), timeoutMs);
167
+ }),
168
+ ]);
169
+ clearTimeout(timer);
170
+ return { timedOut: result };
171
+ }
172
+ #enqueue(session, object) {
173
+ let resolveTask;
174
+ const promise = new Promise((resolve) => {
175
+ resolveTask = resolve;
176
+ });
177
+ session.pending.add(promise);
178
+ this.queue.push({ session, object, resolveTask, promise });
179
+ this.#pump();
180
+ return promise;
181
+ }
182
+ #pump() {
183
+ while (this.activeWorkers < this.workers && this.queue.length > 0) {
184
+ const task = this.queue.shift();
185
+ this.activeWorkers += 1;
186
+ this.#runTask(task).finally(() => {
187
+ this.activeWorkers -= 1;
188
+ this.#pump();
189
+ });
190
+ }
191
+ }
192
+ async #runTask(task) {
193
+ let result = { uploaded: false, key: null };
194
+ try {
195
+ if (task.session.uploadFailed) {
196
+ return;
197
+ }
198
+ const policyResult = await task.session.loadmillClient.requestDroidRunReportUploadPolicy(task.session.uploadId, { name: task.object.name, size: task.object.body.length });
199
+ const uploadPolicy = policyResult?.uploadPolicy;
200
+ if (!uploadPolicy?.key) {
201
+ throw new Error("Loadmill did not return an upload policy.");
202
+ }
203
+ await uploadObject(uploadPolicy, task.object.body, task.object.name.split("/").at(-1), task.object.contentType, this.fetchImpl);
204
+ result = { uploaded: true, key: uploadPolicy.key };
205
+ }
206
+ catch (error) {
207
+ task.session.failed = true;
208
+ task.session.uploadFailed = true;
209
+ task.session.uploadFailureStatus ??= getHttpStatus(error);
210
+ }
211
+ finally {
212
+ task.session.pending.delete(task.promise);
213
+ task.resolveTask(result);
214
+ }
215
+ }
216
+ }
217
+ export const droidReportUploadManager = new DroidReportUploadManager();
218
+ export function droidReportScreenshotUrl(savedRunId, objectName) {
219
+ const fileName = objectName.split("/").at(-1);
220
+ return `/api/droid-cua/runs/${encodeURIComponent(savedRunId)}/report/screenshots/${encodeURIComponent(fileName)}`;
221
+ }
@@ -100,8 +100,12 @@ export async function reportDroidRunCompletion({ loadmillClient, onWarning = nul
100
100
  return { reported: false, reason: "missing_run_id" };
101
101
  }
102
102
  try {
103
- await loadmillClient.reportDroidRun(payload);
104
- return { reported: true };
103
+ const response = await loadmillClient.reportDroidRun(payload);
104
+ return {
105
+ reported: true,
106
+ savedRunId: response?.run?.id ?? null,
107
+ run: response?.run ?? null,
108
+ };
105
109
  }
106
110
  catch (error) {
107
111
  const message = error instanceof Error ? error.message : String(error);
@@ -592,7 +592,8 @@ export class ExecutionMode {
592
592
  // clearMessages() restores the base system prompt, but we need to add context
593
593
  const recoverySystemPrompt = buildExecutionRecoveryPrompt({
594
594
  basePrompt: this.initialSystemText,
595
- transcript: transcriptContext
595
+ transcript: transcriptContext,
596
+ explainMode: context?.runtimeConfig?.explainMode === true,
596
597
  });
597
598
  // Replace the system message with the enhanced one
598
599
  this.session.installRecoverySystemPrompt(recoverySystemPrompt);
@@ -2,6 +2,7 @@ import { appendCustomSections, buildBaseSystemPrompt, isBrowserTarget } from "./
2
2
  import { buildAppContextSection, buildSecretKeysContextSection } from "./execution.js";
3
3
  export function buildDesignModePrompt(deviceInfo, customInstructions = {}, appContextText = "", options = {}) {
4
4
  const designCustomText = typeof customInstructions.designModeInstructions === "string" ? customInstructions.designModeInstructions.trim() : "";
5
+ const explainMode = options.explainMode === true;
5
6
  const basePrompt = buildBaseSystemPrompt(deviceInfo, customInstructions);
6
7
  const appContextSection = buildAppContextSection(appContextText);
7
8
  const secretKeysContextSection = buildSecretKeysContextSection(options.availableSecretKeys);
@@ -94,7 +95,14 @@ WRONG Example (DON'T DO THIS):
94
95
  3. etc...
95
96
  \`\`\`
96
97
 
97
- Remember: You are autonomous. Explore confidently. Generate simple, executable test scripts.${appContextSection ? `\n\n${appContextSection}` : ""}${secretKeysContextSection ? `\n\n${secretKeysContextSection}` : ""}
98
+ ${explainMode ? `EXPLAIN MODE:
99
+ - Briefly explain non-obvious decisions, including why you choose one path, skip an expected action, or consider the goal complete
100
+ - Name only the visible evidence that matters to the decision
101
+ - Do not narrate routine clicks, typing, scrolling, or other obvious actions
102
+ - Before a non-obvious action, briefly explain the decision in the same response as that action
103
+ - Include the evidence that proves the requested flow is complete in the same response as the \`test_design_complete\` call
104
+
105
+ ` : ""}Remember: You are autonomous. Explore confidently. Generate simple, executable test scripts.${appContextSection ? `\n\n${appContextSection}` : ""}${secretKeysContextSection ? `\n\n${secretKeysContextSection}` : ""}
98
106
  `;
99
107
  return appendCustomSections(prompt, [
100
108
  { title: "Base Prompt Instructions", text: customInstructions.basePromptInstructions },
@@ -38,6 +38,7 @@ ${normalized.map((key) => `- ${key}`).join("\n")}`;
38
38
  }
39
39
  export function buildExecutionModePrompt(deviceInfo, customInstructions = {}, appContextText = "", options = {}) {
40
40
  const executionCustomText = typeof customInstructions.executionModeInstructions === "string" ? customInstructions.executionModeInstructions.trim() : "";
41
+ const explainMode = options.explainMode === true;
41
42
  const aimAssistMode = typeof options.aimAssistMode === "string" ? options.aimAssistMode : DEFAULT_AIM_ASSIST_MODE;
42
43
  const basePrompt = buildBaseSystemPrompt(deviceInfo, { aimAssistMode });
43
44
  const appContextSection = buildAppContextSection(appContextText);
@@ -54,12 +55,12 @@ EXECUTION MODE - Critical Behavior:
54
55
  You are executing test script commands one at a time. This is an automated test run, not a chat with the user.
55
56
 
56
57
  CRITICAL RULES:
57
- - DO NOT generate conversational text or narration
58
+ - ${explainMode ? "Explain non-obvious decisions briefly, including why you skip an expected action or consider the current instruction complete" : "DO NOT generate conversational text or narration"}
58
59
  - DO NOT ask questions like "What should I do next?", "Would you like...", "Can I assist...?"
59
- - DO NOT describe what you see on screen
60
+ - ${explainMode ? "When a decision depends on visible state, name only the evidence that matters to that decision" : "DO NOT describe what you see on screen"}
60
61
  - DO NOT say "Let me know if you need help" or similar phrases
61
- - Just execute the action silently and stop immediately
62
- - Only generate text if the action FAILED or cannot be completed
62
+ - ${explainMode ? "Do not narrate routine clicks, typing, scrolling, or other obvious actions" : "Just execute the action silently and stop immediately"}
63
+ - ${explainMode ? "Before a non-obvious action, briefly explain the decision in the same response as that action\n- Explain completion evidence using assistant text; do not call `assert` merely to confirm that an ordinary action succeeded\n- Call `assert` only when the current instruction explicitly contains assertion or verification intent\n- If an action FAILED or the instruction cannot be completed, state that clearly and explain why" : "Only generate text if the action FAILED or cannot be completed"}
63
64
  - ${browserTarget ? "Use browser-appropriate keyboard input only when it is the most reliable path" : "Never emit desktop keyboard shortcuts or modifier combos; mobile execution only supports mobile-safe single-key presses"}
64
65
  - Never repeat the same mutating action with the same apparent intent unless the UI clearly shows failure or no state change
65
66
  - If a submit/create/approve/reject/login action appears to succeed, stop instead of trying to reconfirm by doing it again
@@ -86,7 +87,7 @@ ${browserTarget ? "" : ` - Every \`type\` action must end its computer-action
86
87
  4. ${browserTarget ? "Before tapping a mutating action, dismiss the keyboard if it is open and not needed" : "Use `close_keyboard` before any later interaction when the post-type dismissal rules require it"}
87
88
  5. After a mutating action, inspect the resulting screen for success cues such as cleared fields, reset buttons, changed status, refreshed content, or navigation
88
89
  6. Stop as soon as success is visible
89
- 7. Stop immediately - no commentary, no questions
90
+ 7. ${explainMode ? "If the completion decision is not obvious, briefly state the visible evidence, then stop - no questions" : "Stop immediately - no commentary, no questions"}
90
91
 
91
92
  ${instructionContextRule}
92
93
  ${appContextSection ? `\n\n${appContextSection}` : ""}${testDataContextSection ? `\n\n${testDataContextSection}` : ""}${secretKeysContextSection ? `\n\n${secretKeysContextSection}` : ""}`;
@@ -95,7 +96,7 @@ ${appContextSection ? `\n\n${appContextSection}` : ""}${testDataContextSection ?
95
96
  { title: "Execution Mode Instructions", text: executionCustomText }
96
97
  ]);
97
98
  }
98
- export function buildExecutionRecoveryPrompt({ basePrompt, transcript }) {
99
+ export function buildExecutionRecoveryPrompt({ basePrompt, transcript, explainMode = false }) {
99
100
  if (!transcript) {
100
101
  return basePrompt;
101
102
  }
@@ -104,7 +105,9 @@ export function buildExecutionRecoveryPrompt({ basePrompt, transcript }) {
104
105
  [SESSION RECOVERY - Connection was lost. Previous actions completed before the error:]
105
106
  ${transcript}
106
107
 
107
- [IMPORTANT: Resume execution silently. Do NOT narrate or explain. Just execute the next instruction.]`;
108
+ ${explainMode
109
+ ? "[IMPORTANT: Resume execution. Continue using concise decision explanations without narrating routine actions.]"
110
+ : "[IMPORTANT: Resume execution silently. Do NOT narrate or explain. Just execute the next instruction.]"}`;
108
111
  }
109
112
  export function buildExecutionNavigationRecoveryPrompt({ basePrompt, transcript, navigatedUrl }) {
110
113
  const normalizedTranscript = typeof transcript === "string" ? transcript.trim() : "";
@@ -27,6 +27,9 @@ export function formatCliOutput(item) {
27
27
  }
28
28
  const stepCompletion = stepCompletionData(text);
29
29
  if (stepCompletion) {
30
+ if (item.payload?.explainMode === true) {
31
+ return `assistant: Step complete — ${stepCompletion.instruction}\nassistant: Reason: ${stepCompletion.reason}`;
32
+ }
30
33
  return `assistant: Step complete — ${stepCompletion.instruction}`;
31
34
  }
32
35
  return text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.25.0",
3
+ "version": "2.27.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -2,17 +2,17 @@
2
2
 
3
3
  ## Construct the run
4
4
 
5
- Prefer a single test before a directory or scenario run. Preserve an existing provider/model choice in `ci/droid-cua.json`; otherwise use the Loadmill provider. Use Loadmill Smart as the recommended default, or Loadmill Beacon when the user prioritizes faster, lower-cost execution:
5
+ Prefer a single test before a directory or scenario run. Preserve an existing provider/model choice in `ci/droid-cua.json`; otherwise use the Loadmill provider. Use Loadmill Smart as the recommended default, or Loadmill Pulse when the user prioritizes faster, lower-cost execution:
6
6
 
7
7
  ```sh
8
8
  # Recommended default
9
9
  --llm-provider loadmill --cua-model loadmill-smart
10
10
 
11
11
  # Faster, lower-cost option
12
- --llm-provider loadmill --cua-model loadmill-beacon
12
+ --llm-provider loadmill --cua-model loadmill-pulse
13
13
  ```
14
14
 
15
- Both Loadmill models require `LOADMILL_API_TOKEN`. Do not switch between Smart and Beacon silently; preserve the project's model or explain the speed/cost tradeoff and follow the user's choice. If the user chooses direct OpenAI access instead, require `OPENAI_API_KEY` and use `--llm-provider openai` with one of `gpt-5.6-terra`, `gpt-5.6-luna`, or `gpt-5.4`. Do not mix a provider with another provider's model, silently switch providers, or override an existing project choice without explaining it.
15
+ All Loadmill models require `LOADMILL_API_TOKEN`. Do not switch between Smart, Pulse, and Beacon silently; preserve the project's model or explain the capability, speed, and experimental tradeoffs and follow the user's choice. If the user chooses direct OpenAI access instead, require `OPENAI_API_KEY` and use `--llm-provider openai` with one of `gpt-5.6-terra`, `gpt-5.6-luna`, or `gpt-5.4`. Do not mix a provider with another provider's model, silently switch providers, or override an existing project choice without explaining it.
16
16
 
17
17
  Include `--report reports/<test-name>.html` for normal test runs unless the user opts out. Prefer an explicit filename over a directory so the result is easy to locate. Add `--debug` for every agent-observed run: it writes the screenshots needed for live progress updates as well as diagnosis.
18
18