@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.
- package/README.md +13 -5
- package/build/index.js +210 -2
- package/build/src/cli/cloud-status-line.js +58 -26
- package/build/src/cli/headless-execution-config.js +11 -0
- package/build/src/cli/headless-report.js +105 -15
- package/build/src/cli/headless-runner.js +125 -24
- package/build/src/cli/headless-target.js +63 -7
- package/build/src/cli/loadmill-cloud-credentials.js +11 -0
- package/build/src/cli/loadmill-cloud-usage.js +18 -0
- package/build/src/commands/help.js +5 -0
- package/build/src/core/cua-models.js +7 -0
- package/build/src/core/droid-cua-run-usage.js +8 -2
- package/build/src/core/execution-engine.js +6 -2
- package/build/src/core/report/render-html-report.js +69 -12
- package/build/src/core/runtime-config.js +10 -4
- package/build/src/device/assertions.js +3 -0
- package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
- package/build/src/device/cloud/connection.js +18 -6
- package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
- package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
- package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
- package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
- package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
- package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
- package/build/src/device/cloud/progress.js +29 -0
- package/build/src/device/cloud/registry.js +2 -1
- package/build/src/device/connection.js +3 -0
- package/build/src/device/openai.js +1 -0
- package/build/src/integrations/loadmill/client.js +14 -0
- package/build/src/integrations/loadmill/report-upload-manager.js +221 -0
- package/build/src/integrations/loadmill/run-reporter.js +6 -2
- package/build/src/modes/execution-mode.js +2 -1
- package/build/src/prompts/design.js +9 -1
- package/build/src/prompts/execution.js +10 -7
- package/build/src/utils/console-output.js +3 -0
- package/package.json +1 -1
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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-
|
|
12
|
+
--llm-provider loadmill --cua-model loadmill-pulse
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
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
|
|