@loadmill/droid-cua 2.38.0 → 2.40.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.
@@ -1,6 +1,8 @@
1
1
  import { buildActionEventMeta } from "../device/action-event-meta.js";
2
2
  import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitive-action.js";
3
3
  import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
4
+ import { getWindowsVisibleBounds } from "./windows-visible-bounds.js";
5
+ import sharp from "sharp";
4
6
  const SCREENSHOT_WIDTH = 1440;
5
7
  const ACCESSIBILITY_HIT_TOLERANCE = 12;
6
8
  const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
@@ -26,6 +28,57 @@ function assertResult(result, label) {
26
28
  throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
27
29
  }
28
30
  }
31
+ function readScreenshot(result) {
32
+ const image = result.content?.find((item) => item.type === "image");
33
+ const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
34
+ const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
35
+ if (!image?.data || !size)
36
+ throw new Error("Desktop screenshot returned no image or dimensions.");
37
+ return { data: image.data, width: Number(size[1]), height: Number(size[2]) };
38
+ }
39
+ async function isBlackScreenshot(data) {
40
+ const { channels } = await sharp(Buffer.from(data, "base64")).stats();
41
+ return channels.slice(0, 3).every((channel) => channel.max === 0);
42
+ }
43
+ async function captureWindowFromDesktop(native, bounds) {
44
+ const display = native.getDisplaySize();
45
+ // Read full-resolution pixels; resizing the desktop first loses window detail.
46
+ const screen = native.takeScreenshot(undefined, undefined, 0, undefined, undefined);
47
+ if (!screen.base64)
48
+ throw new Error("Desktop screen capture returned no image.");
49
+ const left = Math.round(bounds.x * screen.width / display.width);
50
+ const top = Math.round(bounds.y * screen.height / display.height);
51
+ const width = Math.round(bounds.width * screen.width / display.width);
52
+ const height = Math.round(bounds.height * screen.height / display.height);
53
+ const visibleLeft = Math.max(0, left);
54
+ const visibleTop = Math.max(0, top);
55
+ const visibleRight = Math.min(screen.width, left + width);
56
+ const visibleBottom = Math.min(screen.height, top + height);
57
+ if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
58
+ throw new Error("The window must overlap the primary display for Windows screen capture.");
59
+ }
60
+ let image = sharp(Buffer.from(screen.base64, "base64")).extract({
61
+ left: visibleLeft,
62
+ top: visibleTop,
63
+ width: visibleRight - visibleLeft,
64
+ height: visibleBottom - visibleTop,
65
+ });
66
+ if (visibleLeft !== left || visibleTop !== top || visibleRight !== left + width || visibleBottom !== top + height) {
67
+ image = image.extend({
68
+ left: visibleLeft - left,
69
+ top: visibleTop - top,
70
+ right: left + width - visibleRight,
71
+ bottom: top + height - visibleBottom,
72
+ background: "black",
73
+ });
74
+ }
75
+ // Materialize the crop and padding before resize (Sharp can reorder operations).
76
+ const cropped = await image.png().toBuffer();
77
+ const { data, info } = await sharp(cropped)
78
+ .resize({ width: SCREENSHOT_WIDTH, withoutEnlargement: true })
79
+ .png().toBuffer({ resolveWithObject: true });
80
+ return { data: data.toString("base64"), width: info.width, height: info.height };
81
+ }
29
82
  function sameBounds(left, right) {
30
83
  return left && right && left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
31
84
  }
@@ -63,7 +116,7 @@ async function createLibraryClient() {
63
116
  ]);
64
117
  return connectInProcess(createComputerUseServer({ profile: "ax", vision: true }));
65
118
  }
66
- async function loadAccessibilityDriver() {
119
+ async function loadNativeDriver() {
67
120
  const { loadNative } = await import("@zavora-ai/computer-use-mcp/host-native");
68
121
  return loadNative();
69
122
  }
@@ -183,9 +236,9 @@ function normalizeShortcut(keys, platform) {
183
236
  return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
184
237
  }
185
238
  /**
186
- * @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", client?: any, accessibility?: any }} options
239
+ * @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds }} options
187
240
  */
188
- export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", client: injectedClient = null, accessibility: injectedAccessibility = null, } = {}) {
241
+ export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, } = {}) {
189
242
  if (!SUPPORTED_PLATFORMS.has(platform)) {
190
243
  throw new Error("Desktop control is currently supported only on macOS and Windows.");
191
244
  }
@@ -201,59 +254,91 @@ export async function createDesktopController({ app: requestedApp, windowId: req
201
254
  let targetWindow = chooseWindow(windows, requestedWindowId == null ? null : Number(requestedWindowId));
202
255
  let capture = null;
203
256
  let deviceInfo = null;
204
- let accessibility = injectedAccessibility;
257
+ let native = injectedAccessibility;
258
+ let lastVisibleBounds = null;
259
+ let fallbackLogged = false;
205
260
  async function getWindow() {
206
261
  return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
207
262
  }
208
- async function screenshot() {
209
- let window;
210
- let result;
263
+ async function focus() {
264
+ const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
265
+ const focused = await getWindow();
266
+ if (!activation.activated || !focused.isFocused) {
267
+ throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
268
+ }
269
+ }
270
+ async function captureFromDesktop(window) {
271
+ if (!window.isFocused) {
272
+ if (inputMode !== "physical") {
273
+ throw new Error("Windows desktop capture requires the target window to be visible and focused. Bring it to the foreground and retry.");
274
+ }
275
+ await focus();
276
+ window = await getWindow();
277
+ }
278
+ native ||= await loadNativeDriver();
279
+ if (lastVisibleBounds?.windowId !== targetWindow.windowId || !sameBounds(lastVisibleBounds.outer, window.bounds)) {
280
+ lastVisibleBounds = {
281
+ windowId: targetWindow.windowId,
282
+ outer: { ...window.bounds },
283
+ visible: await visibleBoundsReader(targetWindow.windowId, window.bounds),
284
+ };
285
+ }
286
+ const actionBounds = lastVisibleBounds.visible;
287
+ const image = await captureWindowFromDesktop(native, actionBounds);
288
+ if (!fallbackLogged) {
289
+ console.warn("Windows window capture returned all-black pixels; using a visible-desktop crop. Keep the target unobstructed on the primary display.");
290
+ fallbackLogged = true;
291
+ }
292
+ return { ...image, bounds: { ...window.bounds }, actionBounds: { ...actionBounds } };
293
+ }
294
+ async function screenshot({ allowDesktopFallback = true } = {}) {
211
295
  try {
212
296
  const currentWindows = readWindows(await client.listWindows(application.bundleId));
213
297
  const focusedWindow = currentWindows.find((candidate) => candidate.isFocused && candidate.isOnScreen);
214
298
  if (focusedWindow)
215
299
  targetWindow = focusedWindow;
216
- window = await getWindow();
300
+ const window = await getWindow();
217
301
  if (!window.isOnScreen)
218
302
  throw new Error("The selected desktop window is no longer visible.");
219
- result = await client.screenshot({
303
+ const result = await client.screenshot({
220
304
  width: SCREENSHOT_WIDTH,
221
305
  quality: 0,
222
306
  target_window_id: targetWindow.windowId,
223
307
  });
224
308
  assertResult(result, "Desktop screenshot");
309
+ let image = { ...readScreenshot(result), bounds: { ...window.bounds }, actionBounds: { ...window.bounds } };
310
+ if (allowDesktopFallback && platform === "win32" && await isBlackScreenshot(image.data)) {
311
+ image = await captureFromDesktop(window);
312
+ }
313
+ capture = {
314
+ bounds: image.bounds,
315
+ actionBounds: image.actionBounds,
316
+ width: image.width,
317
+ height: image.height,
318
+ contentBounds: getScreenshotContentBounds(image.width, image.height, image.bounds, platform),
319
+ };
320
+ if (deviceInfo) {
321
+ deviceInfo.device_width = image.actionBounds.width;
322
+ deviceInfo.device_height = image.actionBounds.height;
323
+ deviceInfo.scaled_width = image.width;
324
+ deviceInfo.scaled_height = image.height;
325
+ deviceInfo.scale = image.width / image.actionBounds.width;
326
+ deviceInfo.desktopWindowId = targetWindow.windowId;
327
+ }
328
+ return image.data;
225
329
  }
226
330
  catch (error) {
227
331
  throw new Error(formatPermissionError(error, platform));
228
332
  }
229
- const image = result.content?.find((item) => item.type === "image");
230
- const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
231
- const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
232
- if (!image?.data || !size)
233
- throw new Error("Desktop screenshot returned no image or dimensions.");
234
- capture = {
235
- bounds: { ...window.bounds },
236
- width: Number(size[1]),
237
- height: Number(size[2]),
238
- };
239
- capture.contentBounds = getScreenshotContentBounds(capture.width, capture.height, window.bounds, platform);
240
- if (deviceInfo) {
241
- deviceInfo.device_width = window.bounds.width;
242
- deviceInfo.device_height = window.bounds.height;
243
- deviceInfo.scaled_width = capture.width;
244
- deviceInfo.scaled_height = capture.height;
245
- deviceInfo.scale = capture.width / window.bounds.width;
246
- deviceInfo.desktopWindowId = targetWindow.windowId;
247
- }
248
- return image.data;
249
333
  }
250
334
  function scaledPoint(x, y) {
251
335
  if (!capture)
252
336
  throw new Error("Capture the desktop window before executing an action.");
253
337
  const content = capture.contentBounds;
338
+ const bounds = capture.actionBounds;
254
339
  return [
255
- Math.round(capture.bounds.x + ((x - content.x) * capture.bounds.width) / content.width),
256
- Math.round(capture.bounds.y + ((y - content.y) * capture.bounds.height) / content.height),
340
+ Math.round(bounds.x + ((x - content.x) * bounds.width) / content.width),
341
+ Math.round(bounds.y + ((y - content.y) * bounds.height) / content.height),
257
342
  ];
258
343
  }
259
344
  async function prepareAction(requireFocus) {
@@ -265,11 +350,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
265
350
  }
266
351
  if (!requireFocus)
267
352
  return;
268
- const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
269
- const focused = await getWindow();
270
- if (!activation.activated || !focused.isFocused) {
271
- throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
272
- }
353
+ await focus();
273
354
  }
274
355
  const targetOptions = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
275
356
  async function performCursorFreeAction(actionType, x, y, direction = null) {
@@ -277,8 +358,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
277
358
  const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
278
359
  if (!selected)
279
360
  throw new Error(cursorFreeError(actionType));
280
- accessibility ||= await loadAccessibilityDriver();
281
- const result = accessibility.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
361
+ native ||= await loadNativeDriver();
362
+ const result = native.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
282
363
  if (!result?.performed)
283
364
  throw new Error(`${cursorFreeError(actionType)}${result?.reason ? ` Reason: ${result.reason}.` : ""}`);
284
365
  }
@@ -375,16 +456,19 @@ export async function createDesktopController({ app: requestedApp, windowId: req
375
456
  return { success: false, error: message };
376
457
  }
377
458
  }
378
- await screenshot();
459
+ if (focusOnConnect)
460
+ await focus();
461
+ // Without focus, connection only probes the window; the desktop may be covered.
462
+ await screenshot({ allowDesktopFallback: focusOnConnect });
379
463
  deviceInfo = {
380
464
  platform: "desktop",
381
465
  source: "local",
382
466
  device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}`,
383
- device_width: targetWindow.bounds.width,
384
- device_height: targetWindow.bounds.height,
467
+ device_width: capture.actionBounds.width,
468
+ device_height: capture.actionBounds.height,
385
469
  scaled_width: capture.width,
386
470
  scaled_height: capture.height,
387
- scale: capture.width / targetWindow.bounds.width,
471
+ scale: capture.width / capture.actionBounds.width,
388
472
  desktopAppId: application.bundleId,
389
473
  desktopWindowId: targetWindow.windowId,
390
474
  desktopInputMode: inputMode,
@@ -394,7 +478,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
394
478
  window: targetWindow,
395
479
  deviceId: `desktop:${targetWindow.windowId}`,
396
480
  deviceInfo,
397
- getScreenshotAsBase64: screenshot,
481
+ focus,
482
+ getScreenshotAsBase64: () => screenshot(),
398
483
  handleModelAction: handleAction,
399
484
  close: () => client.close(),
400
485
  };
@@ -0,0 +1,55 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const runFile = promisify(execFile);
4
+ const RECT_SCRIPT = `
5
+ Add-Type -TypeDefinition @'
6
+ using System;
7
+ using System.Runtime.InteropServices;
8
+ public static class WindowFrame {
9
+ public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9;
10
+ [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left; public int Top; public int Right; public int Bottom; }
11
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr handle, out Rect rect);
12
+ [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr handle, int attribute, out Rect rect, int size);
13
+ }
14
+ '@
15
+ $handle = [IntPtr]::new([long]WINDOW_ID)
16
+ $outer = [WindowFrame+Rect]::new()
17
+ $visible = [WindowFrame+Rect]::new()
18
+ if (-not [WindowFrame]::GetWindowRect($handle, [ref]$outer)) { exit 1 }
19
+ $result = [WindowFrame]::DwmGetWindowAttribute($handle, [WindowFrame]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$visible, 16)
20
+ if ($result -ne 0) { exit 1 }
21
+ @($outer.Left, $outer.Top, $outer.Right, $outer.Bottom, $visible.Left, $visible.Top, $visible.Right, $visible.Bottom) -join ','
22
+ `;
23
+ export async function getWindowsVisibleBounds(windowId, bounds) {
24
+ if (process.platform !== "win32")
25
+ return bounds;
26
+ if (!Number.isSafeInteger(windowId))
27
+ return bounds;
28
+ try {
29
+ // GetWindowRect includes invisible resize borders; DWM reports the pixels actually drawn.
30
+ const script = RECT_SCRIPT.replace("WINDOW_ID", String(windowId));
31
+ const { stdout } = await runFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
32
+ windowsHide: true,
33
+ timeout: 5000,
34
+ });
35
+ const [outerLeft, outerTop, outerRight, outerBottom, left, top, right, bottom] = stdout.trim().split(",").map(Number);
36
+ const scaleX = bounds.width / (outerRight - outerLeft);
37
+ const scaleY = bounds.height / (outerBottom - outerTop);
38
+ const insetLeft = Math.round((left - outerLeft) * scaleX);
39
+ const insetTop = Math.round((top - outerTop) * scaleY);
40
+ const insetRight = Math.round((outerRight - right) * scaleX);
41
+ const insetBottom = Math.round((outerBottom - bottom) * scaleY);
42
+ if (![insetLeft, insetTop, insetRight, insetBottom].every((value) => Number.isFinite(value) && value >= 0)) {
43
+ throw new Error("Invalid window frame insets.");
44
+ }
45
+ const width = bounds.width - insetLeft - insetRight;
46
+ const height = bounds.height - insetTop - insetBottom;
47
+ if (width <= 0 || height <= 0)
48
+ throw new Error("Invalid visible window dimensions.");
49
+ return { x: bounds.x + insetLeft, y: bounds.y + insetTop, width, height };
50
+ }
51
+ catch {
52
+ console.warn("Windows visible-frame lookup failed; using outer window bounds, which may include invisible resize borders.");
53
+ return bounds;
54
+ }
55
+ }
@@ -5,6 +5,18 @@ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
5
5
  import { execAdb, execEmulator, spawnAdb, spawnEmulator } from "./tools.js";
6
6
  const AVD_READY_TIMEOUT_MS = 180000;
7
7
  const AVD_READY_TIMEOUT_SECONDS = AVD_READY_TIMEOUT_MS / 1000;
8
+ const TARGET_SCALED_SHORT_EDGE = 400;
9
+ export function calculateScaledScreenDimensions(width, height) {
10
+ const shortestEdge = Math.min(width, height);
11
+ const scale = shortestEdge > TARGET_SCALED_SHORT_EDGE
12
+ ? TARGET_SCALED_SHORT_EDGE / shortestEdge
13
+ : 1.0;
14
+ return {
15
+ scaledWidth: Math.round(width * scale),
16
+ scaledHeight: Math.round(height * scale),
17
+ scale,
18
+ };
19
+ }
8
20
  async function listConnectedDevices() {
9
21
  const { stdout } = await execAdb(["devices"]);
10
22
  return stdout
@@ -238,10 +250,7 @@ export async function getDeviceInfo(deviceId) {
238
250
  process.exit(1);
239
251
  }
240
252
  const [_, width, height] = match.map(Number);
241
- const targetWidth = 400;
242
- const scale = width > targetWidth ? targetWidth / width : 1.0;
243
- const scaledWidth = Math.round(width * scale);
244
- const scaledHeight = Math.round(height * scale);
253
+ const { scaledWidth, scaledHeight, scale } = calculateScaledScreenDimensions(width, height);
245
254
  const [{ stdout: manufacturerRaw }, { stdout: modelRaw }, { stdout: deviceNameRaw }] = await Promise.all([
246
255
  execAdb(["-s", deviceId, "shell", "getprop", "ro.product.manufacturer"]),
247
256
  execAdb(["-s", deviceId, "shell", "getprop", "ro.product.model"]),
@@ -31,7 +31,7 @@
31
31
  *
32
32
  * getDeviceInfo(deviceId: string): Promise<DeviceInfo>
33
33
  * - Gets screen dimensions and calculates scale factor
34
- * - Target width for scaling is 400px
34
+ * - Local Android screenshots target a 400px shortest edge
35
35
  *
36
36
  * getScreenshotAsBase64(deviceId: string, deviceInfo: DeviceInfo): Promise<string>
37
37
  * - Captures current screen state
@@ -1,8 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { findDeepestErrorCode } from "../../utils/error-details.js";
2
3
  export const MAX_SCREENSHOTS_PER_REPORT = 1000;
3
4
  export const MAX_REPORT_OBJECT_BYTES = 10 * 1024 * 1024;
4
- function warn(session, message) {
5
+ function warn(session, message, phase) {
5
6
  session.failed = true;
7
+ session.onFailure?.({ uploadId: session.uploadId, phase, objectName: null, httpStatus: null, errorName: "ValidationError", errorCode: null });
6
8
  session.onWarning?.(message);
7
9
  }
8
10
  function getHttpStatus(error) {
@@ -16,6 +18,20 @@ function warnAboutUploadFailure(session) {
16
18
  const status = session.uploadFailureStatus ? ` (HTTP ${session.uploadFailureStatus})` : "";
17
19
  session.onWarning?.(`Cloud report upload failed${status}; further uploads skipped.`);
18
20
  }
21
+ function recordFailure(session, phase, objectName, error) {
22
+ if (session.uploadFailed)
23
+ return;
24
+ session.uploadFailed = true;
25
+ session.uploadFailureStatus = getHttpStatus(error);
26
+ session.onFailure?.({
27
+ uploadId: session.uploadId,
28
+ phase,
29
+ objectName,
30
+ httpStatus: session.uploadFailureStatus,
31
+ errorName: error instanceof Error ? error.name : typeof error,
32
+ errorCode: findDeepestErrorCode(error),
33
+ });
34
+ }
19
35
  function decodeScreenshot(dataUrl) {
20
36
  const match = /^data:image\/png;base64,([a-zA-Z0-9+/=\s]+)$/.exec(dataUrl);
21
37
  return match ? Buffer.from(match[1], "base64") : null;
@@ -46,15 +62,18 @@ export class DroidReportUploadManager {
46
62
  * associateDroidRunReport: (runId: string, payload: object) => Promise<unknown>,
47
63
  * },
48
64
  * onWarning?: ((message: string) => void) | null,
65
+ * onFailure?: ((details: { uploadId: string, phase: string, objectName: string | null, httpStatus: string | null, errorName: string, errorCode: string | null }) => void) | null,
49
66
  * }} options
50
67
  */
51
- startSession({ loadmillClient, onWarning = null } = {}) {
68
+ startSession({ loadmillClient, onWarning = null, onFailure = null } = {}) {
52
69
  const session = {
53
70
  uploadId: randomUUID(),
54
71
  loadmillClient,
55
72
  onWarning,
73
+ onFailure,
56
74
  sequence: 0,
57
75
  screenshotCount: 0,
76
+ lastScreenshotObjectName: null,
58
77
  deduplication: new Map(),
59
78
  pending: new Set(),
60
79
  failed: false,
@@ -73,23 +92,25 @@ export class DroidReportUploadManager {
73
92
  const hash = createHash("sha256").update(dataUrl).digest("hex");
74
93
  const existing = session.deduplication.get(hash);
75
94
  if (existing) {
95
+ session.lastScreenshotObjectName = existing;
76
96
  return existing;
77
97
  }
78
98
  const body = decodeScreenshot(dataUrl);
79
99
  if (!body) {
80
- warn(session, "Failed to upload Droid report screenshot: unsupported image data.");
100
+ warn(session, "Failed to upload Droid report screenshot: unsupported image data.", "validate_screenshot");
81
101
  return null;
82
102
  }
83
103
  if (body.length > MAX_REPORT_OBJECT_BYTES) {
84
- warn(session, `Droid report screenshot exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`);
104
+ warn(session, `Droid report screenshot exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`, "validate_screenshot");
85
105
  return null;
86
106
  }
87
107
  if (session.screenshotCount >= MAX_SCREENSHOTS_PER_REPORT) {
88
- warn(session, `Droid report exceeds ${MAX_SCREENSHOTS_PER_REPORT} screenshots.`);
108
+ warn(session, `Droid report exceeds ${MAX_SCREENSHOTS_PER_REPORT} screenshots.`, "validate_screenshot");
89
109
  return null;
90
110
  }
91
111
  const name = `screenshots/${String(++session.sequence).padStart(4, "0")}.png`;
92
112
  session.screenshotCount += 1;
113
+ session.lastScreenshotObjectName = name;
93
114
  session.deduplication.set(hash, name);
94
115
  this.#enqueue(session, { name, body, contentType: "image/png" });
95
116
  return name;
@@ -104,7 +125,16 @@ export class DroidReportUploadManager {
104
125
  session.finishing = true;
105
126
  session.finishPromise = this.#finish(session, { savedRunId, html }).catch((error) => {
106
127
  const status = getHttpStatus(error);
107
- warn(session, `Failed to publish Droid run report${status ? ` (HTTP ${status})` : ""}.`);
128
+ session.onFailure?.({
129
+ uploadId: session.uploadId,
130
+ phase: "associate_report",
131
+ objectName: null,
132
+ httpStatus: status,
133
+ errorName: error instanceof Error ? error.name : typeof error,
134
+ errorCode: findDeepestErrorCode(error),
135
+ });
136
+ session.failed = true;
137
+ session.onWarning?.(`Failed to publish Droid run report${status ? ` (HTTP ${status})` : ""}.`);
108
138
  return { published: false, reason: "publish_failed" };
109
139
  }).finally(() => {
110
140
  this.sessions.delete(session);
@@ -122,7 +152,7 @@ export class DroidReportUploadManager {
122
152
  }
123
153
  const body = Buffer.from(html, "utf8");
124
154
  if (body.length > MAX_REPORT_OBJECT_BYTES) {
125
- warn(session, `Droid HTML report exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`);
155
+ warn(session, `Droid HTML report exceeds ${MAX_REPORT_OBJECT_BYTES} bytes.`, "validate_report");
126
156
  return { published: false, reason: "too_large" };
127
157
  }
128
158
  const htmlUpload = await this.#enqueue(session, {
@@ -139,7 +169,11 @@ export class DroidReportUploadManager {
139
169
  key,
140
170
  screenshotCount: session.screenshotCount,
141
171
  });
142
- return { published: true, key };
172
+ return {
173
+ published: true,
174
+ key,
175
+ lastScreenshotObjectName: session.lastScreenshotObjectName,
176
+ };
143
177
  }
144
178
  async waitForSession(session) {
145
179
  while (session?.pending?.size) {
@@ -191,6 +225,7 @@ export class DroidReportUploadManager {
191
225
  }
192
226
  async #runTask(task) {
193
227
  let result = { uploaded: false, key: null };
228
+ let phase = "request_policy";
194
229
  try {
195
230
  if (task.session.uploadFailed) {
196
231
  return;
@@ -200,13 +235,13 @@ export class DroidReportUploadManager {
200
235
  if (!uploadPolicy?.key) {
201
236
  throw new Error("Loadmill did not return an upload policy.");
202
237
  }
238
+ phase = "upload_object";
203
239
  await uploadObject(uploadPolicy, task.object.body, task.object.name.split("/").at(-1), task.object.contentType, this.fetchImpl);
204
240
  result = { uploaded: true, key: uploadPolicy.key };
205
241
  }
206
242
  catch (error) {
207
243
  task.session.failed = true;
208
- task.session.uploadFailed = true;
209
- task.session.uploadFailureStatus ??= getHttpStatus(error);
244
+ recordFailure(task.session, phase, task.object.name, error);
210
245
  }
211
246
  finally {
212
247
  task.session.pending.delete(task.promise);
@@ -61,6 +61,7 @@ export function normalizeDroidRunStatus({ status = null, outcome = null, success
61
61
  * contextOptimizationThreshold?: number | null,
62
62
  * } | null,
63
63
  * aimAssistMode?: string | null,
64
+ * lloydJobId?: string | null,
64
65
  * }} input
65
66
  * @returns {{
66
67
  * droidVersion: string | null,
@@ -68,17 +69,19 @@ export function normalizeDroidRunStatus({ status = null, outcome = null, success
68
69
  * llmProviderMode: string | null | undefined,
69
70
  * stepDelayMs: number | null | undefined,
70
71
  * aimAssistMode: string | null,
72
+ * lloydJobId?: string,
71
73
  * contextOptimizationEnabled: boolean | null | undefined,
72
74
  * contextOptimizationThreshold: number | null | undefined,
73
75
  * }}
74
76
  */
75
- export function buildDroidRunConfiguration({ droidVersion = null, runtimeConfig = null, aimAssistMode = null, } = {}) {
77
+ export function buildDroidRunConfiguration({ droidVersion = null, runtimeConfig = null, aimAssistMode = null, lloydJobId = null, } = {}) {
76
78
  return {
77
79
  droidVersion,
78
80
  model: runtimeConfig?.cuaModel,
79
81
  llmProviderMode: runtimeConfig?.llmProviderMode,
80
82
  stepDelayMs: runtimeConfig?.stepDelayMs,
81
83
  aimAssistMode,
84
+ ...(lloydJobId ? { lloydJobId } : {}),
82
85
  contextOptimizationEnabled: runtimeConfig?.contextOptimizationEnabled,
83
86
  contextOptimizationThreshold: runtimeConfig?.contextOptimizationThreshold,
84
87
  };
@@ -108,6 +111,7 @@ export function buildDroidRunReportPayload(input = {}) {
108
111
  assertionsFailed: nonNegativeIntegerOrNull(input.assertionsFailed),
109
112
  retries: nonNegativeIntegerOrNull(input.retries),
110
113
  error: trimToNull(input.error),
114
+ lloydJobId: trimToNull(input.lloydJobId),
111
115
  };
112
116
  for (const key of Object.keys(payload)) {
113
117
  if (payload[key] == null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.38.0",
3
+ "version": "2.40.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",
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  name: droid-cua
3
3
  description: Operate and improve Droid CUA `.dcua` test projects. Use when the user explicitly names Droid CUA or @loadmill/droid-cua, or asks to author, configure, run, diagnose, heal, stabilize, or speed up `.dcua` tests with the Droid CUA CLI or desktop app. Do not use for general web browsing, website development, browser automation, generic mobile work, or non-Droid test tasks.
4
+ metadata:
5
+ version: "1.0.8"
4
6
  ---
5
7
 
6
8
  # Droid CUA
@@ -12,7 +14,7 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
12
14
  1. Inspect the active test project before creating files. Find existing `.dcua` tests, `context.md`, `test-data.md`, `.secrets`, `.env`, and `ci/droid-cua.json` without reading secret values. Do not search other projects or the user's home directory for credentials.
13
15
  2. Determine the requested platform and target from the task or project. Ask the user only when the choice is unclear.
14
16
  3. Check authentication and environment readiness before the first run. Read [project-and-auth.md](references/project-and-auth.md) for project boundaries and token handling. Read [onboarding-and-troubleshooting.md](references/onboarding-and-troubleshooting.md) when setup, dependency, device, simulator, browser, or connection work is needed.
15
- 4. Author or update the smallest useful test. Read [authoring-tests.md](references/authoring-tests.md) before changing `.dcua`, context, test data, secrets, or config.
17
+ 4. Identify the intended coverage, starting state, scenario inputs, and cleanup; author or update the smallest useful test that preserves them. Read [authoring-tests.md](references/authoring-tests.md) before changing `.dcua`, context, test data, secrets, or config.
16
18
  5. Before execution, present the proposed tests and target to the user, resolve any questions, and get their confirmation. Read [running-and-healing.md](references/running-and-healing.md) for the required pre-run check.
17
19
  6. Run the confirmed, narrowest relevant test and monitor it to completion. Keep the user oriented during the run by surfacing meaningful live screenshots, not just terminal output.
18
20
  7. For a new, changed, flaky, slow, or explicitly optimized test, continue with the evidence-driven author-run-improve loop in [iterative-improvement.md](references/iterative-improvement.md). A passing run is a baseline, not proof of readiness.
@@ -27,7 +29,7 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
27
29
  - Get approval before installing dependencies, running `sudo`, accepting licenses, or changing system configuration.
28
30
  - During setup, do not create or edit user tests. Rerun only the failed readiness check, then resume the original task.
29
31
  - During healing, edit only `.dcua`, `context.md`, `test-data.md`, or Droid CUA config. Never modify application code under the guise of healing a test.
30
- - Never weaken or remove an assertion merely to make a failing run pass.
32
+ - Never weaken required coverage or remove an assertion merely to make a failing run pass. Agent-added, out-of-scope assertions may be removed only with a coverage-based explanation; see [authoring-tests.md](references/authoring-tests.md).
31
33
  - Keep one canonical context file and one canonical Droid CUA config file per test project. Reuse and update those shared files; never create test-specific context or config copies for a new or improved test.
32
34
  - When improving an existing test, keep its complete flow in one independently runnable `.dcua` file. Do not split it into ordered or state-dependent tests as an optimization; Droid CUA does not currently guarantee that separate tests will run consecutively or share state.
33
35
  - Before rerunning a long test, apply the smallest evidence-supported batch that addresses its known bottlenecks. The batch may include multiple `.dcua`, context, test-data, or config improvements when each has a clear rationale and preserves coverage. Record the complete change set; isolate or revert individual changes only if the combined result regresses or remains ambiguous.
@@ -39,7 +41,7 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
39
41
 
40
42
  - Prefer an existing project convention over creating a parallel structure.
41
43
  - Prefer headless runs for agent work because they are observable and return deterministic exit codes.
42
- - Write tests as concise, human-readable user journeys. Prefer one business-level instruction per user goal over one instruction per tap, menu, dialog, or other UI gesture; add lower-level guidance only when run evidence shows it is necessary.
44
+ - Preserve the user's intended coverage. Write one readable micro-goal per step: a small user task with a recognizable completion point. During improvement, simplify before adding detail, and retain only detail that is necessary and supported by evidence. Do not optimize for fewer lines or compress several goals into one instruction.
43
45
  - Use explicit, user-observable outcomes in tests rather than implementation details or coordinates.
44
46
  - Diagnose the first divergence between the instruction and observed behavior before changing anything.
45
47
  - Compare runs on the same target and app build, and clearly identify every test, context, data, model, or config change included in the optimization batch.