@loadmill/droid-cua 2.40.0 → 2.42.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 +33 -3
- package/build/index.js +69 -10
- package/build/src/browser/connection.js +5 -0
- package/build/src/browser/factory.js +1 -0
- package/build/src/browser/playwright/connection.js +3 -0
- package/build/src/cli/headless-runner.js +55 -3
- package/build/src/cli/headless-target.js +15 -7
- package/build/src/commands/help.js +10 -4
- package/build/src/core/execution-engine.js +26 -1
- package/build/src/core/report/render-html-report.js +36 -6
- package/build/src/core/report/run-log-model.js +65 -0
- package/build/src/core/report/run-report-model.js +1 -0
- package/build/src/core/report/run-report-sections.js +15 -0
- package/build/src/core/tool-handlers/assert.js +45 -2
- package/build/src/core/tool-handlers/query-network.js +92 -0
- package/build/src/desktop/computer-use.js +137 -51
- package/build/src/desktop/primary-display.js +74 -0
- package/build/src/device/openai.js +25 -0
- package/build/src/modes/execution-mode.js +10 -1
- package/build/src/network/browser-capture.js +110 -0
- package/build/src/network/certificate-authority.js +205 -0
- package/build/src/network/certificate-install.js +242 -0
- package/build/src/network/local-proxy.js +287 -0
- package/build/src/network/network-capture.js +243 -0
- package/build/src/network/query-network.js +215 -0
- package/build/src/network/run-network-capture.js +107 -0
- package/build/src/prompts/base.js +9 -6
- package/build/src/prompts/execution.js +10 -6
- package/package.json +2 -1
- package/skill/droid-cua/SKILL.md +1 -1
- package/skill/droid-cua/references/authoring-tests.md +16 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
- package/skill/droid-cua/references/running-and-healing.md +9 -1
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ Desktop app support:
|
|
|
97
97
|
- **Desktop-first workflow** - Create, run, and manage tests from one app
|
|
98
98
|
- **Setup guidance** - Configure API access and platform prerequisites in the app
|
|
99
99
|
- **Device and simulator connection** - Connect Android targets and iOS simulators
|
|
100
|
+
- **Network certificate preparation** - Manage one active root CA and install it on a connected local simulator or supported emulator from the desktop app's Tools menu
|
|
100
101
|
- **Natural-language test creation** - Describe flows in plain English
|
|
101
102
|
- **Test management** - Create, edit, save, and rerun reusable scripts, including tests organized in nested project folders
|
|
102
103
|
- **Live execution logs** - Watch actions and progress as tests run
|
|
@@ -105,6 +106,22 @@ Desktop app support:
|
|
|
105
106
|
- **Run video export** - Generate and download a polished MP4 replay from a desktop run's screenshots and instruction timeline
|
|
106
107
|
- **Headless support** - Reuse scripts in CLI and automation workflows
|
|
107
108
|
|
|
109
|
+
**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. Android certificate setup supports capable rootable images through Android 16; Google Play images without root access and Android 17 are not supported.
|
|
110
|
+
|
|
111
|
+
**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.
|
|
112
|
+
|
|
113
|
+
**Mobile setup:** Manually route a prepared local simulator or emulator to `127.0.0.1:8765` (`10.0.2.2:8765` from Android Emulator). Enable **Network Capture** in desktop Settings to keep the proxy forwarding while Droid is open and automatically capture every local mobile test, including each project or scenario case. Existing **Network Proxy** settings do not turn capture on; opt in to the new setting. For CLI tests, pass `--capture-network`; this starts the proxy and writes one JSONL log per test. `--network-proxy` remains a forwarding-only CLI flag. Droid prints or logs each capture path and a best-effort health summary. Desktop artifacts are in the project's results folder; CLI artifacts are beside `--report` output when enabled, or under `logs/`. A missing CA stops mobile capture before test actions. No observed traffic or failed HTTPS decryption is reported separately from test assertions, but capture health does not prove that all traffic was observed. WebSocket messages are unsupported. Manual proxy setup is the V1 workflow. Droid does not change device or Mac proxy settings; turn off manually configured routing when finished. If Droid exits while routing remains enabled, restart Droid or clear the proxy to restore connectivity.
|
|
114
|
+
|
|
115
|
+
Captures stop recording at 100 MiB per run, retaining a partial JSONL with a `CAPTURE_LIMIT` error. Traffic continues forwarding; live network queries report incomplete evidence. Captured bodies are limited to 1 MiB, both on the wire and after decompression. Compressed bodies that expand beyond that limit are omitted with `decoded-body-too-large`; this is incomplete evidence, not a missing response. The separate query evidence limit is 1,000,000 serialized characters. A capture limit or missing traffic is not proof that an application request never occurred.
|
|
116
|
+
|
|
117
|
+
For troubleshooting, “No traffic observed” points to routing or an app that did not send traffic; it does not prove an expected request was absent. HTTPS connections with no decrypted requests point to certificate trust or interception compatibility. A port-in-use error means another Droid instance or proxy already owns the listener. Stop that listener before starting the CLI. A capture-size or write failure retains only partial evidence. iOS network validation is deferred; ordinary iOS runs without capture do not require proxy or certificate preparation.
|
|
118
|
+
|
|
119
|
+
**Network questions and assertions:** With capture enabled, tests can ask Droid to inspect HTTP traffic and compare values across requests in natural language. For example: “Assert that the POST to `/pageview` after opening Home contains the `item_id` returned by `/home`.” The `query_network` tool searches by URL substring, method, status, flow IDs, request timestamps, or a starting step. It considers all matching exchanges in time order and sends only selected headers/bodies to one side call using the selected run model. Large results require a narrower search; they are not silently truncated. Optional observation windows wait the full duration, up to 30 seconds per query. Absence means only “no matching traffic observed during this window”; unavailable capture or insufficient evidence cannot establish absence. Desktop and HTML reports show one collapsed Network query card per call; expand it to see the question and answer. The agent receives structured flow references and capture diagnostics, while model details stay out of the timeline.
|
|
120
|
+
|
|
121
|
+
To inspect a saved capture without a model, run `node scripts/query-network.mjs path/to/network.jsonl '{"url_contains":"/pageview","method":"POST","sections":["request_body"]}'` from the repository. Select `request_headers`, `response_headers`, `request_body`, or `response_body`; use `sections: []` for metadata. Saved-log queries accept `after` and `before` timestamps, but not live step IDs or observation waits.
|
|
122
|
+
|
|
123
|
+
**Developer network capture:** `node scripts/network-capture-smoke.mjs` still offers a standalone proxy checkpoint. Leave desktop **Network Capture** off and omit CLI network flags while it owns `127.0.0.1:8765`. Press Ctrl+C when finished; use `--output path.jsonl` to choose the JSONL path. Request and response headers are written immediately, body records when streams finish or abort, binary bodies are omitted, and captured bodies are limited to 1 MiB on the wire and after decompression. Filtering is partial: JSONL captures may contain credentials, tokens, cookies, or other sensitive data. Treat them as private run artifacts.
|
|
124
|
+
|
|
108
125
|
---
|
|
109
126
|
|
|
110
127
|
<h2 id="how-it-works">🔧 How It Works</h2>
|
|
@@ -193,6 +210,9 @@ droid-cua run tests/login.dcua --platform ios --avd "iPhone 16"
|
|
|
193
210
|
# List running desktop applications and their window IDs (macOS or Windows)
|
|
194
211
|
droid-cua --target desktop --list-desktop-apps
|
|
195
212
|
|
|
213
|
+
# Control the full primary display across applications
|
|
214
|
+
droid-cua --target desktop --instructions tests/launcher.dcua
|
|
215
|
+
|
|
196
216
|
# Control one already-running desktop application window
|
|
197
217
|
droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-window 123 --instructions tests/editor.dcua
|
|
198
218
|
|
|
@@ -276,8 +296,8 @@ Supported CLI options include:
|
|
|
276
296
|
- `--list-devices`
|
|
277
297
|
- `--browser`
|
|
278
298
|
- `--target` (`mobile`, `web`, or `desktop`)
|
|
279
|
-
- `--desktop-app` (desktop application ID or exact name)
|
|
280
|
-
- `--desktop-window` (
|
|
299
|
+
- `--desktop-app` (limit desktop control to an application ID or exact name; omit for full desktop)
|
|
300
|
+
- `--desktop-window` (select a window within `--desktop-app`)
|
|
281
301
|
- `--desktop-input` (`physical` by default; use `accessibility` for cursor-free input)
|
|
282
302
|
- `--list-desktop-apps`
|
|
283
303
|
- `--instructions`
|
|
@@ -294,6 +314,8 @@ Supported CLI options include:
|
|
|
294
314
|
- `--execution-prompt-file`
|
|
295
315
|
- `--secrets`
|
|
296
316
|
- `--record`
|
|
317
|
+
- `--network-proxy` (local mobile sessions only; starts idle forwarding at `127.0.0.1:8765`)
|
|
318
|
+
- `--capture-network` (local mobile and Chrome/Edge test runs; writes a JSONL log for each test, using a proxy only for mobile)
|
|
297
319
|
- `--report`
|
|
298
320
|
- `--report-metadata <path>`
|
|
299
321
|
- `--no-cloud-report`
|
|
@@ -304,7 +326,15 @@ Supported CLI options include:
|
|
|
304
326
|
`OPENAI_API_KEY`. Saved direct OpenAI selections of GPT-5.6 Terra, GPT-5.6 Luna,
|
|
305
327
|
or GPT-5.4 move to GPT-6 Sol, GPT-6 Luna, or GPT-6 Sol, respectively.
|
|
306
328
|
|
|
307
|
-
Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use
|
|
329
|
+
Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use physical pointer and keyboard input by default. Droid shows a system notice for three seconds before taking control and a completion notice when the run finishes, fails, or is stopped. The delay is fixed; use Stop in Droid once the run starts. Avoid using the mouse or keyboard during the run. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. On Windows, Droid must run at the same integrity level as the target application. System notices and automatic minimization apply only to runs started in the desktop app.
|
|
330
|
+
|
|
331
|
+
CLI runs with `--target desktop` control the full primary display by default, with no application flag needed. In the desktop app, choose **Full desktop**, the first option in the Application dropdown. This mode captures and controls the primary display across applications, including apps launched during the test; no target application needs to be running when connecting. Keep all test windows on the primary display. Full desktop requires physical input. Droid minimizes itself after the three-second notice and remains minimized when the run ends; open Droid to review the result or use Stop. Existing saved application selections are preserved.
|
|
332
|
+
|
|
333
|
+
Use `--desktop-app <id-or-exact-name>` or select an application in Droid to limit control to that application's windows. Application mode brings the selected window forward and leaves it there when the run ends. Add `--desktop-window <id>` to select a particular window, or `--desktop-input accessibility` for cursor-free CLI input; both options require `--desktop-app`. Before each run and screenshot, Droid refreshes window selection within the original application so it can follow a recreated login window. If replacement is ambiguous, select a window again. If a captured window disappears or moves before input, Droid requests a fresh screenshot instead of transferring its coordinates to a replacement. Focus errors report the activation reason; they do not establish an elevation problem.
|
|
334
|
+
|
|
335
|
+
If Windows returns an all-black window capture, Droid brings the target forward and crops the visible desktop to the window's visible frame, excluding its invisible resize border. The fallback logs a warning, crops before resizing to preserve detail, and requires the target to remain unobstructed on the primary display; off-screen portions appear black. With accessibility input, bring the target forward yourself when requested. Full desktop reports capture failures directly, without this window capture fallback.
|
|
336
|
+
|
|
337
|
+
Desktop **Test design** uses the same notices once you submit your instructions and exploration starts. Full desktop minimizes Droid at that point. Droid sends the completion notice when the generated test is ready, exploration is stopped, or recovery ends in an error. It does not automatically bring itself forward; open Droid whenever you need to give guidance, use Stop, or review the generated test. Opening Test design alone does not take desktop control. When a controller rejects an action, Droid skips the remaining actions in that batch and asks the model to continue from a fresh screenshot with the failure reason. Rejected actions are not counted as completed.
|
|
308
338
|
|
|
309
339
|
The system notices are shown only for desktop control runs started in the desktop app. On macOS, they may not appear in the unsigned Electron dev app; verify them in a signed packaged build.
|
|
310
340
|
|
package/build/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import minimist from "minimist";
|
|
|
3
3
|
import dotenv from "dotenv";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { mkdir } from "fs/promises";
|
|
6
|
-
import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo } from "./src/device/connection.js";
|
|
6
|
+
import { connectToDevice, disconnect as disconnectDevice, getDeviceInfo, getCurrentPlatform as getConnectedDevicePlatform } from "./src/device/connection.js";
|
|
7
7
|
import { connectToBrowser, disconnect as disconnectBrowser, getBrowserInfo, } from "./src/browser/connection.js";
|
|
8
8
|
import { setBrowserBackendId } from "./src/browser/factory.js";
|
|
9
9
|
import { normalizeBrowserInfoForSession } from "./src/browser/normalize.js";
|
|
@@ -45,9 +45,14 @@ import { ARTIFACT_READY_STATUSES } from "./src/device/cloud/loadmill-cloud/run-s
|
|
|
45
45
|
import { formatDuration } from "./src/core/report/run-log-model.js";
|
|
46
46
|
import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
|
|
47
47
|
import { createDesktopController, listDesktopApplications } from "./src/desktop/computer-use.js";
|
|
48
|
+
import { createRunNetworkCapture } from "./src/network/run-network-capture.js";
|
|
49
|
+
import { loadCertificateAuthority } from "./src/network/certificate-authority.js";
|
|
50
|
+
import { ensureCliAndroidCertificate, inspectNetworkDevice } from "./src/network/certificate-install.js";
|
|
48
51
|
dotenv.config();
|
|
52
|
+
let networkCapture = null;
|
|
49
53
|
process.once("SIGTERM", async () => {
|
|
50
54
|
process.exitCode = 143;
|
|
55
|
+
await networkCapture?.shutdown().catch(() => { });
|
|
51
56
|
// Registering a SIGTERM listener cancels Node's default terminate, so exit
|
|
52
57
|
// explicitly; await the /stop because a bare fire-and-forget fetch may not
|
|
53
58
|
// dispatch before exit. Capped so a slow server can't hang the exit.
|
|
@@ -386,6 +391,36 @@ async function main() {
|
|
|
386
391
|
if (!isHeadlessInstructionsRun && args["test-data"] != null) {
|
|
387
392
|
throw new Error("--test-data requires `droid-cua run <path>` or --instructions headless execution.");
|
|
388
393
|
}
|
|
394
|
+
if (args["network-proxy"] != null && args["network-proxy"] !== true) {
|
|
395
|
+
throw new Error("--network-proxy is a flag and does not take a value.");
|
|
396
|
+
}
|
|
397
|
+
if (args["network-proxy"] === true && (isBrowserTarget || isDesktopTarget || isCloudTarget)) {
|
|
398
|
+
throw new Error("--network-proxy is available only for local mobile sessions.");
|
|
399
|
+
}
|
|
400
|
+
if (args["capture-network"] != null && args["capture-network"] !== true) {
|
|
401
|
+
throw new Error("--capture-network is a flag and does not take a value.");
|
|
402
|
+
}
|
|
403
|
+
if (args["capture-network"] === true && (!isHeadlessInstructionsRun || isDesktopTarget || isCloudTarget)) {
|
|
404
|
+
throw new Error("--capture-network requires a local mobile or browser test run.");
|
|
405
|
+
}
|
|
406
|
+
const captureNetwork = args["capture-network"] === true;
|
|
407
|
+
if (captureNetwork && !isBrowserTarget)
|
|
408
|
+
await loadCertificateAuthority();
|
|
409
|
+
networkCapture = args["network-proxy"] === true || captureNetwork ? createRunNetworkCapture() : null;
|
|
410
|
+
if (captureNetwork) {
|
|
411
|
+
process.once("SIGINT", async () => {
|
|
412
|
+
await networkCapture?.shutdown().catch(() => { });
|
|
413
|
+
process.exit(130);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (networkCapture && !isBrowserTarget) {
|
|
417
|
+
try {
|
|
418
|
+
await networkCapture.startProxy();
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
throw new Error(`Droid network proxy could not start: ${error.message}`, { cause: error });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
389
424
|
let preloadedHeadlessConfig = null;
|
|
390
425
|
let preloadedHeadlessTarget = null;
|
|
391
426
|
let headlessInstructionRuns = [];
|
|
@@ -520,7 +555,7 @@ async function main() {
|
|
|
520
555
|
}
|
|
521
556
|
else if (isBrowserTarget) {
|
|
522
557
|
targetId = await connectToBrowser({
|
|
523
|
-
url: startupUrl,
|
|
558
|
+
url: captureNetwork ? undefined : startupUrl,
|
|
524
559
|
headless: true,
|
|
525
560
|
browserFamily: targetConfig.browserFamily,
|
|
526
561
|
sessionMode: targetConfig.sessionMode
|
|
@@ -534,6 +569,7 @@ async function main() {
|
|
|
534
569
|
}
|
|
535
570
|
else if (isDesktopTarget) {
|
|
536
571
|
desktopController = await createDesktopController({
|
|
572
|
+
scope: targetConfig.desktopScope,
|
|
537
573
|
app: targetConfig.desktopApp,
|
|
538
574
|
windowId: targetConfig.desktopWindow,
|
|
539
575
|
inputMode: targetConfig.desktopInput,
|
|
@@ -542,8 +578,8 @@ async function main() {
|
|
|
542
578
|
});
|
|
543
579
|
targetId = desktopController.deviceId;
|
|
544
580
|
deviceInfo = desktopController.deviceInfo;
|
|
545
|
-
console.log(`Attached to desktop
|
|
546
|
-
console.log(`Model sees
|
|
581
|
+
console.log(`Attached to desktop target: ${deviceInfo.device_name}`);
|
|
582
|
+
console.log(`Model sees desktop target: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
547
583
|
console.log(`Desktop input: ${deviceInfo.desktopInputMode === "physical" ? "physical OS pointer" : "cursor-free accessibility"}`);
|
|
548
584
|
process.once("SIGINT", async () => {
|
|
549
585
|
console.log("\nStopping desktop run...");
|
|
@@ -766,6 +802,20 @@ async function main() {
|
|
|
766
802
|
}
|
|
767
803
|
else {
|
|
768
804
|
targetId = await connectToDevice(avdName, platform);
|
|
805
|
+
if (captureNetwork) {
|
|
806
|
+
const connectedPlatform = getConnectedDevicePlatform();
|
|
807
|
+
if (connectedPlatform === "android") {
|
|
808
|
+
if (!/^emulator-\d+$/.test(targetId))
|
|
809
|
+
throw new Error("--capture-network supports Android Emulators, not physical Android devices.");
|
|
810
|
+
const ca = await loadCertificateAuthority();
|
|
811
|
+
const trust = await ensureCliAndroidCertificate(targetId, ca);
|
|
812
|
+
if (trust.changed)
|
|
813
|
+
console.log(`Reapplied Droid's network certificate on ${targetId}.`);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
await inspectNetworkDevice({ platform: "ios", deviceId: targetId });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
769
819
|
deviceInfo = await getDeviceInfo(targetId);
|
|
770
820
|
console.log(`Using real resolution: ${deviceInfo.device_width}x${deviceInfo.device_height}`);
|
|
771
821
|
console.log(`Model sees resolution: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
|
|
@@ -836,12 +886,15 @@ async function main() {
|
|
|
836
886
|
desktopController,
|
|
837
887
|
isCloudTarget,
|
|
838
888
|
runTarget: { isBrowserTarget, isDesktopTarget, isCloudTarget },
|
|
839
|
-
report: reportConfig
|
|
889
|
+
report: reportConfig,
|
|
890
|
+
networkCapture: captureNetwork ? networkCapture : null,
|
|
891
|
+
initialBrowserUrl: captureNetwork && isBrowserTarget ? startupUrl : null
|
|
840
892
|
});
|
|
841
893
|
exitCode = result.exitCode;
|
|
842
894
|
succeeded = true;
|
|
843
895
|
}
|
|
844
896
|
finally {
|
|
897
|
+
await networkCapture?.shutdown();
|
|
845
898
|
await droidReportUploadManager.drain();
|
|
846
899
|
if (lambdaTestSessionConnected) {
|
|
847
900
|
console.log("Ending LambdaTest session...");
|
|
@@ -910,12 +963,18 @@ async function main() {
|
|
|
910
963
|
reviseTestScript: runtimeContext.openAi.reviseTestScript,
|
|
911
964
|
});
|
|
912
965
|
// Otherwise, start interactive Ink shell
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
966
|
+
try {
|
|
967
|
+
await startInkShell(session, engine, {
|
|
968
|
+
appContextPath: appContextPath ? path.resolve(appContextPath) : null,
|
|
969
|
+
runtimeContext,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
finally {
|
|
973
|
+
await networkCapture?.shutdown();
|
|
974
|
+
}
|
|
917
975
|
}
|
|
918
|
-
main().catch((error) => {
|
|
976
|
+
main().catch(async (error) => {
|
|
977
|
+
await networkCapture?.shutdown().catch(() => { });
|
|
919
978
|
const message = error instanceof Error ? error.message : String(error);
|
|
920
979
|
console.error(`\nTest failed: ${message}`);
|
|
921
980
|
process.exit(1);
|
|
@@ -35,6 +35,11 @@ export async function closeCurrentTab(browserSessionId) {
|
|
|
35
35
|
return currentBackend.closeCurrentTab(browserSessionId);
|
|
36
36
|
}
|
|
37
37
|
export { getCurrentPlatform };
|
|
38
|
+
export function getNetworkCaptureContext(browserSessionId) {
|
|
39
|
+
if (!currentBackend?.getNetworkCaptureContext)
|
|
40
|
+
throw new Error("Network capture supports local Chrome and Edge sessions only.");
|
|
41
|
+
return currentBackend.getNetworkCaptureContext(browserSessionId);
|
|
42
|
+
}
|
|
38
43
|
export async function disconnect() {
|
|
39
44
|
if (currentBackend?.disconnect) {
|
|
40
45
|
await currentBackend.disconnect();
|
|
@@ -16,6 +16,7 @@ function buildPlaywrightBackend() {
|
|
|
16
16
|
closeCurrentTab: playwrightConnection.closeCurrentTab,
|
|
17
17
|
handleModelAction: playwrightActions.handleModelAction,
|
|
18
18
|
disconnect: playwrightConnection.disconnect,
|
|
19
|
+
getNetworkCaptureContext: playwrightConnection.getNetworkCaptureContext,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
function buildWebDriverBackend() {
|
|
@@ -373,6 +373,9 @@ export async function connectToBrowser(targetOrOptions) {
|
|
|
373
373
|
});
|
|
374
374
|
return sessionId;
|
|
375
375
|
}
|
|
376
|
+
export function getNetworkCaptureContext(browserSessionId) {
|
|
377
|
+
return getActiveBrowserSession(browserSessionId).context;
|
|
378
|
+
}
|
|
376
379
|
export async function getBrowserInfo(browserSessionId) {
|
|
377
380
|
const session = getActiveBrowserSession(browserSessionId);
|
|
378
381
|
const activePage = getActivePage(session);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { mkdir, writeFile } from "fs/promises";
|
|
3
|
-
import { getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
|
|
3
|
+
import { getNetworkCaptureContext, navigateToUrl as navigateBrowserToUrl, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, getScreenshotAsBase64 as getBrowserScreenshotAsBase64, } from "../browser/connection.js";
|
|
4
|
+
import { normalizeBrowserInfoForSession } from "../browser/normalize.js";
|
|
5
|
+
import { updateBrowserSessionInfo } from "../core/tool-handlers/browser-session.js";
|
|
4
6
|
import { handleModelAction as handleBrowserModelAction } from "../browser/actions.js";
|
|
5
7
|
import { ExecutionEngine } from "../core/execution-engine.js";
|
|
6
8
|
import { buildExecutionModePrompt } from "../core/prompts.js";
|
|
@@ -17,6 +19,7 @@ import { clearLoadmillReportMetadata } from "./loadmill-report-metadata.js";
|
|
|
17
19
|
import { formatHeadlessTestSourceLabel } from "./headless-test-source-label.js";
|
|
18
20
|
import { buildCliRunReportModel, createCapturingAddOutput, createCliAggregateRunReport, createCliRunReport, renderCliCloudReport, writeCliRunReportArtifact, } from "./headless-report.js";
|
|
19
21
|
import { createRefuseReconnectConnectToBrowser } from "../desktop-shared/connection-predicates.js";
|
|
22
|
+
import { describeNetworkCapture, hasNetworkCaptureIssue } from "../network/run-network-capture.js";
|
|
20
23
|
function sanitizePathSegment(value) {
|
|
21
24
|
return String(value || "test")
|
|
22
25
|
.replace(/\.dcua$/i, "")
|
|
@@ -29,6 +32,15 @@ function createHeadlessRunId(index = 0) {
|
|
|
29
32
|
const suffix = index > 0 ? `-${index + 1}` : "";
|
|
30
33
|
return `run-${Date.now()}${suffix}`;
|
|
31
34
|
}
|
|
35
|
+
function networkLogPath(runId, report) {
|
|
36
|
+
if (!report?.enabled)
|
|
37
|
+
return path.resolve("logs", `network-${runId}.jsonl`);
|
|
38
|
+
if (!report.path)
|
|
39
|
+
return path.resolve(`network-${runId}.jsonl`);
|
|
40
|
+
const resolved = path.resolve(report.path);
|
|
41
|
+
const directory = /\.html?$/i.test(report.path) ? path.dirname(resolved) : resolved;
|
|
42
|
+
return path.join(directory, `network-${runId}.jsonl`);
|
|
43
|
+
}
|
|
32
44
|
function logPendingScreenshotUploads(uploadManager, uploadSession, subject) {
|
|
33
45
|
console.log('Verifying no pending upload exists');
|
|
34
46
|
const pendingCount = uploadManager.getPendingCount?.(uploadSession) ?? 0;
|
|
@@ -167,8 +179,27 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
|
|
|
167
179
|
}
|
|
168
180
|
return engineOptions;
|
|
169
181
|
}
|
|
170
|
-
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, }) {
|
|
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, }) {
|
|
171
183
|
let executionMode = null;
|
|
184
|
+
let captureStarted = false;
|
|
185
|
+
const finishCapture = async () => {
|
|
186
|
+
if (!captureStarted)
|
|
187
|
+
return;
|
|
188
|
+
captureStarted = false;
|
|
189
|
+
try {
|
|
190
|
+
const result = await networkCapture.stopCapture();
|
|
191
|
+
if (result) {
|
|
192
|
+
const line = `${describeNetworkCapture(result)} Network log: ${result.path}`;
|
|
193
|
+
if (hasNetworkCaptureIssue(result))
|
|
194
|
+
console.warn(`Warning: ${line}`);
|
|
195
|
+
else
|
|
196
|
+
console.log(line);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.warn(`Warning: network capture could not finish: ${error.message}`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
172
203
|
let loadmillSessionUser = null;
|
|
173
204
|
let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
|
|
174
205
|
const buildSegmentUsageProperties = () => ({
|
|
@@ -254,6 +285,23 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
254
285
|
});
|
|
255
286
|
const engine = new ExecutionEngine(session, engineOptions);
|
|
256
287
|
executionMode = new ExecutionMode(session, engine, instructions, true);
|
|
288
|
+
if (networkCapture) {
|
|
289
|
+
try {
|
|
290
|
+
const browserContext = isBrowserTarget ? getNetworkCaptureContext(session.deviceId) : null;
|
|
291
|
+
const capturePath = await networkCapture.startCapture(networkLogPath(runId, report), { browserContext });
|
|
292
|
+
console.log(`Network log: ${capturePath}`);
|
|
293
|
+
captureStarted = true;
|
|
294
|
+
engine.networkQuery = networkCapture.query;
|
|
295
|
+
engine.toolCapabilities.queryNetwork = true;
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
throw new Error(`Network capture setup failed: ${error.message}`, { cause: error });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (initialBrowserUrl) {
|
|
302
|
+
await navigateBrowserToUrl(session.deviceId, initialBrowserUrl);
|
|
303
|
+
updateBrowserSessionInfo(session, normalizeBrowserInfoForSession(await getBrowserInfo(session.deviceId)));
|
|
304
|
+
}
|
|
257
305
|
const result = await executionMode.execute({
|
|
258
306
|
runId,
|
|
259
307
|
addOutput,
|
|
@@ -264,6 +312,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
264
312
|
// Inline screenshots as data URLs so the HTML report is self-contained.
|
|
265
313
|
...(reportEnabled ? { includeScreenshotDataUrl: true } : {}),
|
|
266
314
|
});
|
|
315
|
+
await finishCapture();
|
|
267
316
|
const stats = executionMode.stats || {};
|
|
268
317
|
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
269
318
|
await headlessDebug.endExecutionSession(runId, {
|
|
@@ -363,6 +412,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
363
412
|
};
|
|
364
413
|
}
|
|
365
414
|
catch (error) {
|
|
415
|
+
await finishCapture();
|
|
366
416
|
const message = error instanceof Error ? error.message : "Failed to start execution.";
|
|
367
417
|
const stats = executionMode?.stats || {};
|
|
368
418
|
const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
|
|
@@ -468,7 +518,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
|
|
|
468
518
|
throw error;
|
|
469
519
|
}
|
|
470
520
|
}
|
|
471
|
-
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, }) {
|
|
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, }) {
|
|
472
522
|
await clearLoadmillReportMetadata(report?.metadataPath);
|
|
473
523
|
const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
|
|
474
524
|
const runConfiguration = buildDroidRunConfiguration({
|
|
@@ -546,6 +596,8 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
|
|
|
546
596
|
runConfiguration,
|
|
547
597
|
cloudUploadSession,
|
|
548
598
|
uploadManager,
|
|
599
|
+
networkCapture,
|
|
600
|
+
initialBrowserUrl: index === 0 ? initialBrowserUrl : null,
|
|
549
601
|
});
|
|
550
602
|
aggregateStats.durationMs += result.durationMs ?? 0;
|
|
551
603
|
aggregateStats.instructionsTotal += result.instructionsTotal ?? 0;
|
|
@@ -184,6 +184,9 @@ export function shouldWaitForLoadmillCloudCompletion(targetConfig) {
|
|
|
184
184
|
const MOBILE_ONLY_CLOUD_SOURCES = new Set(["lambdatest", "aws-device-farm"]);
|
|
185
185
|
export function resolveHeadlessTarget(args = {}) {
|
|
186
186
|
const target = normalizeTarget(args.target);
|
|
187
|
+
if (target !== "desktop" && (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null)) {
|
|
188
|
+
throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
|
|
189
|
+
}
|
|
187
190
|
const targetGiven = hasNonEmptyString(args.target);
|
|
188
191
|
const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
|
|
189
192
|
const platform = normalizeMobilePlatform(args.platform, "--platform");
|
|
@@ -264,18 +267,25 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
264
267
|
}
|
|
265
268
|
const listDesktopApps = args["list-desktop-apps"] != null;
|
|
266
269
|
const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
|
|
270
|
+
if (args["desktop-app"] != null && !desktopApp) {
|
|
271
|
+
throw new Error("--desktop-app requires an application ID or exact name.");
|
|
272
|
+
}
|
|
267
273
|
const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
|
|
268
274
|
const desktopWindow = rawWindow == null ? null : Number(rawWindow);
|
|
269
275
|
const desktopInput = hasNonEmptyString(args["desktop-input"]) ? args["desktop-input"].trim().toLowerCase() : "physical";
|
|
270
|
-
if (
|
|
271
|
-
throw new Error("--desktop-
|
|
276
|
+
if (args["desktop-window"] != null && !desktopApp) {
|
|
277
|
+
throw new Error("--desktop-window requires --desktop-app.");
|
|
272
278
|
}
|
|
273
|
-
if (
|
|
279
|
+
if (args["desktop-window"] != null && (rawWindow == null || !Number.isInteger(desktopWindow) || desktopWindow < 0)) {
|
|
274
280
|
throw new Error("--desktop-window must be a non-negative integer window ID.");
|
|
275
281
|
}
|
|
276
|
-
if (
|
|
282
|
+
if ((args["desktop-input"] != null && !hasNonEmptyString(args["desktop-input"])) ||
|
|
283
|
+
(desktopInput !== "accessibility" && desktopInput !== "physical")) {
|
|
277
284
|
throw new Error('--desktop-input must be "accessibility" or "physical".');
|
|
278
285
|
}
|
|
286
|
+
if (desktopInput === "accessibility" && !desktopApp) {
|
|
287
|
+
throw new Error("--desktop-input accessibility requires --desktop-app. Full desktop uses physical input only.");
|
|
288
|
+
}
|
|
279
289
|
return {
|
|
280
290
|
target,
|
|
281
291
|
sessionMode: null,
|
|
@@ -290,14 +300,12 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
290
300
|
artifacts: null,
|
|
291
301
|
listDevices: false,
|
|
292
302
|
listDesktopApps,
|
|
303
|
+
desktopScope: desktopApp ? "window" : "desktop",
|
|
293
304
|
desktopApp,
|
|
294
305
|
desktopWindow,
|
|
295
306
|
desktopInput,
|
|
296
307
|
};
|
|
297
308
|
}
|
|
298
|
-
if (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null) {
|
|
299
|
-
throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
|
|
300
|
-
}
|
|
301
309
|
if (args["session-mode"] != null) {
|
|
302
310
|
throw new Error("--session-mode can only be used with --target web.");
|
|
303
311
|
}
|
|
@@ -11,19 +11,21 @@ import { CUA_MODELS } from '../core/cua-models.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export async function handleHelp(args, session, context) {
|
|
13
13
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
14
|
-
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile and
|
|
14
|
+
addOutput({ type: 'system', text: 'droid-cua - AI-powered mobile, web, and desktop testing CLI' });
|
|
15
15
|
addOutput({ type: 'info', text: '' });
|
|
16
16
|
addOutput({ type: 'info', text: 'Usage:' });
|
|
17
17
|
addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
|
|
18
18
|
addOutput({ type: 'info', text: ' droid-cua run <file-or-directory...> [options]' });
|
|
19
19
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
|
|
20
|
+
addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions <file> [options]' });
|
|
20
21
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
|
|
21
22
|
addOutput({ type: 'info', text: '' });
|
|
22
23
|
addOutput({ type: 'info', text: 'Options:' });
|
|
23
24
|
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
|
|
24
|
-
addOutput({ type: 'info', text: '
|
|
25
|
-
addOutput({ type: 'info', text: ' --desktop-
|
|
26
|
-
addOutput({ type: 'info', text: ' --desktop-
|
|
25
|
+
addOutput({ type: 'info', text: ' Desktop controls the primary display by default' });
|
|
26
|
+
addOutput({ type: 'info', text: ' --desktop-app <id> Limit desktop control to a running app ID or exact name' });
|
|
27
|
+
addOutput({ type: 'info', text: ' --desktop-window <id> Select a window within --desktop-app' });
|
|
28
|
+
addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default); accessibility requires --desktop-app' });
|
|
27
29
|
addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
|
|
28
30
|
addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
|
|
29
31
|
addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
|
|
@@ -53,6 +55,8 @@ export async function handleHelp(args, session, context) {
|
|
|
53
55
|
addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
|
|
54
56
|
addOutput({ type: 'info', text: ' --explain Explain non-obvious decisions and completion evidence' });
|
|
55
57
|
addOutput({ type: 'info', text: ' --report [path] Save a self-contained HTML report locally' });
|
|
58
|
+
addOutput({ type: 'info', text: ' --network-proxy Forward local mobile traffic through 127.0.0.1:8765 without capture' });
|
|
59
|
+
addOutput({ type: 'info', text: ' --capture-network Capture local mobile or browser test traffic to one JSONL log per test' });
|
|
56
60
|
addOutput({ type: 'info', text: ' --no-cloud-report Skip uploading the HTML report and screenshots to Loadmill' });
|
|
57
61
|
addOutput({ type: 'info', text: ' --report-metadata <path> Write uploaded Loadmill report metadata as JSON' });
|
|
58
62
|
addOutput({ type: 'info', text: ' --debug Enable structured JSONL debug artifacts' });
|
|
@@ -76,6 +80,7 @@ export async function handleHelp(args, session, context) {
|
|
|
76
80
|
addOutput({ type: 'info', text: ' iOS: Uses Appium + XCUITest for iOS Simulator automation' });
|
|
77
81
|
addOutput({ type: 'info', text: ' LambdaTest: Uses real cloud devices for headless mobile runs' });
|
|
78
82
|
addOutput({ type: 'info', text: ' Web: Uses Playwright with installed Google Chrome or Microsoft Edge for browser execution' });
|
|
83
|
+
addOutput({ type: 'info', text: ' Desktop: Controls the primary display or a selected application on macOS and Windows' });
|
|
79
84
|
addOutput({ type: 'info', text: '' });
|
|
80
85
|
addOutput({ type: 'info', text: 'Platform Detection:' });
|
|
81
86
|
addOutput({ type: 'info', text: ' - Use --platform flag to force a specific platform' });
|
|
@@ -98,6 +103,7 @@ export async function handleHelp(args, session, context) {
|
|
|
98
103
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
99
104
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
|
|
100
105
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
|
|
106
|
+
addOutput({ type: 'info', text: ' droid-cua --target desktop --instructions tests/launcher.dcua' });
|
|
101
107
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
|
|
102
108
|
addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
|
|
103
109
|
addOutput({ type: 'info', text: ' /list (see all tests)' });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { answerNetworkQuestion, handleQueryNetworkToolCall, NETWORK_QUERY_FAILURE_MESSAGE } from "./tool-handlers/query-network.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
|
|
3
4
|
import { closeCurrentTab as closeCurrentBrowserTab, getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
|
|
@@ -246,6 +247,8 @@ export class ExecutionEngine {
|
|
|
246
247
|
this.handleModelAction = options.handleModelAction || handleModelAction;
|
|
247
248
|
this.closeKeyboard = options.closeKeyboard || closeKeyboard;
|
|
248
249
|
this.sendCUARequest = options.sendCUARequest || sendCUARequest;
|
|
250
|
+
this.networkQuery = options.networkQuery || null;
|
|
251
|
+
this.answerNetworkQuestion = options.answerNetworkQuestion || answerNetworkQuestion;
|
|
249
252
|
this.reviseTestScript = options.reviseTestScript || null;
|
|
250
253
|
this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
|
|
251
254
|
this.getBrowserInfo = options.getBrowserInfo || getBrowserInfo;
|
|
@@ -323,6 +326,8 @@ export class ExecutionEngine {
|
|
|
323
326
|
switch (toolName) {
|
|
324
327
|
case "preview_click":
|
|
325
328
|
return await handlePreviewClick(handlerArgs);
|
|
329
|
+
case "query_network":
|
|
330
|
+
return await handleQueryNetworkToolCall(handlerArgs);
|
|
326
331
|
case "assert":
|
|
327
332
|
return await handleAssertionToolCall(handlerArgs);
|
|
328
333
|
case "navigate":
|
|
@@ -408,7 +413,7 @@ export class ExecutionEngine {
|
|
|
408
413
|
// The Desktop recorder returns one from disk; for runs without a recorder (e.g. a
|
|
409
414
|
// plain CLI `--report`) render the same marker in-memory from the raw screenshot.
|
|
410
415
|
const annotatedDataUrl = await this.resolveAnnotatedDataUrl(annotatedScreenshot, currentScreenshot, action, context);
|
|
411
|
-
await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
|
|
416
|
+
const actionResult = await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
|
|
412
417
|
...context,
|
|
413
418
|
shouldStop,
|
|
414
419
|
stepId: stepContext?.stepId,
|
|
@@ -430,6 +435,13 @@ export class ExecutionEngine {
|
|
|
430
435
|
...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
|
|
431
436
|
...(context?.includeScreenshotDataUrl && annotatedDataUrl ? { screenshotDataUrl: annotatedDataUrl } : {})
|
|
432
437
|
});
|
|
438
|
+
if (actionResult?.success === false) {
|
|
439
|
+
if (shouldStop())
|
|
440
|
+
return { stopped: true, sawExplicitScreenshotAction };
|
|
441
|
+
const actionError = `${action.type} failed: ${actionResult.error || "The action could not be completed."}`;
|
|
442
|
+
this.session.addToTranscript?.(`[Action Error] ${actionError}`);
|
|
443
|
+
return { refreshModelState: true, actionError, sawExplicitScreenshotAction };
|
|
444
|
+
}
|
|
433
445
|
if (trackAction) {
|
|
434
446
|
trackAction(action);
|
|
435
447
|
}
|
|
@@ -599,6 +611,7 @@ export class ExecutionEngine {
|
|
|
599
611
|
...extra
|
|
600
612
|
});
|
|
601
613
|
let refreshBeforeNextComputerAction = false;
|
|
614
|
+
let consecutiveNetworkQueryFailures = 0;
|
|
602
615
|
let observationOnlyTurnsSinceLastAction = 0;
|
|
603
616
|
let observationOnlyRecoverySent = false;
|
|
604
617
|
let pendingObservationOnlyRecoveryMessage = null;
|
|
@@ -745,6 +758,15 @@ export class ExecutionEngine {
|
|
|
745
758
|
summary.error = toolResult.error;
|
|
746
759
|
break;
|
|
747
760
|
}
|
|
761
|
+
if (actionableItem.name === "query_network") {
|
|
762
|
+
consecutiveNetworkQueryFailures = toolResult.queryFailed ? consecutiveNetworkQueryFailures + 1 : 0;
|
|
763
|
+
if (consecutiveNetworkQueryFailures >= 3) {
|
|
764
|
+
summary.failed = true;
|
|
765
|
+
summary.error = NETWORK_QUERY_FAILURE_MESSAGE;
|
|
766
|
+
addOutput({ type: "error", text: summary.error, eventType: "error", ...eventMeta() });
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
748
770
|
if (toolResult.kind === "retry_instruction") {
|
|
749
771
|
summary.retryInstruction = true;
|
|
750
772
|
newResponseId = null;
|
|
@@ -823,6 +845,9 @@ export class ExecutionEngine {
|
|
|
823
845
|
}
|
|
824
846
|
if (actionBatchResult.refreshModelState) {
|
|
825
847
|
const followUp = await this.buildActionFollowUp(call_id, pendingSafetyChecks, true, addOutput, eventMeta, context, stepContext);
|
|
848
|
+
if (actionBatchResult.actionError) {
|
|
849
|
+
followUp.input.push({ role: "user", content: `${actionBatchResult.actionError} The remaining actions were skipped. Use the fresh screenshot to decide what to do next.` });
|
|
850
|
+
}
|
|
826
851
|
currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
|
|
827
852
|
response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
|
|
828
853
|
newResponseId = response.id;
|