@loadmill/droid-cua 2.5.0 → 2.6.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,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import path from "path";
2
3
  import { writeFile } from "fs/promises";
3
4
  import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
@@ -6,31 +7,99 @@ import { sendCUARequest } from "../device/openai.js";
6
7
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
7
8
  import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
8
9
  import { printCliOutput } from "../utils/console-output.js";
9
- function extractComputerCalls(items) {
10
+ import { annotateScreenshotArtifact, createAnnotatedScreenshotFromBase64 } from "../utils/run-screenshot-recorder.js";
11
+ function extractActionableOutputItems(items) {
10
12
  const entries = [];
11
13
  for (const item of items) {
12
- if (item?.type !== "computer_call")
13
- continue;
14
- const baseCallId = item.call_id || item.id;
15
- if (!baseCallId)
14
+ if (item?.type === "function_call" && item.name === "preview_click") {
15
+ entries.push({
16
+ type: "preview_click",
17
+ call_id: item.call_id || item.id,
18
+ arguments: item.arguments,
19
+ });
16
20
  continue;
17
- const actions = [];
18
- if (Array.isArray(item.actions)) {
19
- for (const actionEntry of item.actions) {
20
- if (!actionEntry)
21
- continue;
22
- const action = actionEntry.action || actionEntry;
23
- if (action?.type)
24
- actions.push(action);
25
- }
26
21
  }
27
- else if (item.action?.type) {
28
- actions.push(item.action);
22
+ if (item?.type === "computer_call") {
23
+ const baseCallId = item.call_id || item.id;
24
+ if (!baseCallId)
25
+ continue;
26
+ const actions = [];
27
+ if (Array.isArray(item.actions)) {
28
+ for (const actionEntry of item.actions) {
29
+ if (!actionEntry)
30
+ continue;
31
+ const action = actionEntry.action || actionEntry;
32
+ if (action?.type)
33
+ actions.push(action);
34
+ }
35
+ }
36
+ else if (item.action?.type) {
37
+ actions.push(item.action);
38
+ }
39
+ entries.push({ type: "computer_call", call_id: baseCallId, actions });
29
40
  }
30
- entries.push({ call_id: baseCallId, actions });
31
41
  }
32
42
  return entries;
33
43
  }
44
+ function buildPreviewClickValidationError(argumentsText) {
45
+ if (!argumentsText || typeof argumentsText !== "string") {
46
+ return "Invalid preview_click arguments: expected a JSON object with numeric x and y.";
47
+ }
48
+ return `Invalid preview_click arguments: ${argumentsText}`;
49
+ }
50
+ function parsePreviewClickArguments(argumentsText) {
51
+ let parsedArguments;
52
+ try {
53
+ parsedArguments = JSON.parse(argumentsText);
54
+ }
55
+ catch {
56
+ return {
57
+ ok: false,
58
+ message: buildPreviewClickValidationError("arguments could not be parsed as JSON; expected numeric x and y."),
59
+ };
60
+ }
61
+ const x = parsedArguments?.x;
62
+ const y = parsedArguments?.y;
63
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
64
+ return {
65
+ ok: false,
66
+ message: buildPreviewClickValidationError("x and y must both be numeric."),
67
+ };
68
+ }
69
+ return {
70
+ ok: true,
71
+ x,
72
+ y,
73
+ };
74
+ }
75
+ function buildFunctionCallOutputItem(callId, output) {
76
+ return {
77
+ type: "function_call_output",
78
+ call_id: callId,
79
+ output,
80
+ };
81
+ }
82
+ function buildPreviewOutputText({ requestedX, requestedY, renderedX, renderedY, wasClamped }) {
83
+ const baseText = `Marker placed at (${renderedX}, ${renderedY}) on the current screen. The click has not been executed.`;
84
+ if (!wasClamped) {
85
+ return baseText;
86
+ }
87
+ return `${baseText} Requested point (${requestedX}, ${requestedY}) was off-screen; marker rendered at (${renderedX}, ${renderedY}).`;
88
+ }
89
+ function buildPreviewOutputContent({ text, annotatedScreenshotBase64 = null }) {
90
+ const content = [{
91
+ type: "input_text",
92
+ text,
93
+ }];
94
+ if (annotatedScreenshotBase64) {
95
+ content.push({
96
+ type: "input_image",
97
+ image_url: `data:image/png;base64,${annotatedScreenshotBase64}`,
98
+ detail: "high",
99
+ });
100
+ }
101
+ return content;
102
+ }
34
103
  function getScopeAndIds(context = null, stepContext = null) {
35
104
  const scope = context?.sessionId ? "design" : "execution";
36
105
  const ids = scope === "design"
@@ -80,6 +149,37 @@ function buildStrictModePlan(actions = [], strictMode = false) {
80
149
  runtimeNote
81
150
  };
82
151
  }
152
+ function normalizeArtifactPaths(result) {
153
+ if (result && typeof result === "object" && !Array.isArray(result)) {
154
+ if (Array.isArray(result.annotationSourcePaths)) {
155
+ return normalizeArtifactPaths(result.annotationSourcePaths);
156
+ }
157
+ return normalizeArtifactPaths(result.artifactPaths);
158
+ }
159
+ if (Array.isArray(result)) {
160
+ return result.filter((value) => typeof value === "string" && value.trim());
161
+ }
162
+ if (typeof result === "string" && result.trim()) {
163
+ return [result];
164
+ }
165
+ return [];
166
+ }
167
+ function screenshotPathFromRecorderResult(result) {
168
+ if (result && typeof result === "object" && !Array.isArray(result)) {
169
+ return typeof result.screenshotPath === "string" && result.screenshotPath.trim()
170
+ ? result.screenshotPath
171
+ : null;
172
+ }
173
+ return typeof result === "string" && result.trim() ? result : null;
174
+ }
175
+ function screenshotDataUrlFromRecorderResult(result) {
176
+ if (result && typeof result === "object" && !Array.isArray(result)) {
177
+ return typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
178
+ ? result.screenshotDataUrl
179
+ : null;
180
+ }
181
+ return null;
182
+ }
83
183
  export class ExecutionEngine {
84
184
  constructor(session, options = {}) {
85
185
  this.session = session;
@@ -89,26 +189,45 @@ export class ExecutionEngine {
89
189
  this.strictMode = options.strictMode === true;
90
190
  this.stepDelayMs = getConfiguredStepDelayMs();
91
191
  this.reportedScreenshotWriteError = false;
192
+ this.reportedScreenshotAnnotateError = false;
193
+ this.lastRecordedScreenshotPaths = [];
92
194
  this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
93
195
  this.handleModelAction = options.handleModelAction || handleModelAction;
94
196
  this.sendCUARequest = options.sendCUARequest || sendCUARequest;
95
197
  this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
96
198
  }
97
- async recordScreenshot(screenshotBase64, metadata = {}) {
199
+ async saveScreenshotArtifact(screenshotBase64, metadata = {}, options = {}) {
98
200
  if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
99
201
  return null;
100
202
  }
203
+ const updateLastRecordedScreenshotPaths = options.updateLastRecordedScreenshotPaths !== false;
204
+ const previousRecordedScreenshotPaths = updateLastRecordedScreenshotPaths
205
+ ? null
206
+ : [...this.lastRecordedScreenshotPaths];
101
207
  try {
102
208
  if (this.screenshotRecorder?.saveScreenshot) {
103
- return await this.screenshotRecorder.saveScreenshot(screenshotBase64, metadata);
209
+ const result = await this.screenshotRecorder.saveScreenshot(screenshotBase64, metadata);
210
+ if (updateLastRecordedScreenshotPaths) {
211
+ this.lastRecordedScreenshotPaths = normalizeArtifactPaths(result);
212
+ }
213
+ return result;
104
214
  }
105
215
  if (this.recordScreenshots && this.screenshotDir) {
106
216
  const framePath = path.join(this.screenshotDir, `frame_${String(Date.now())}.png`);
107
217
  await writeFile(framePath, Buffer.from(screenshotBase64, "base64"));
218
+ if (updateLastRecordedScreenshotPaths) {
219
+ this.lastRecordedScreenshotPaths = [framePath];
220
+ }
108
221
  return framePath;
109
222
  }
110
223
  }
111
224
  catch (error) {
225
+ if (updateLastRecordedScreenshotPaths) {
226
+ this.lastRecordedScreenshotPaths = [];
227
+ }
228
+ else {
229
+ this.lastRecordedScreenshotPaths = previousRecordedScreenshotPaths;
230
+ }
112
231
  if (!this.reportedScreenshotWriteError) {
113
232
  this.reportedScreenshotWriteError = true;
114
233
  const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
@@ -131,6 +250,100 @@ export class ExecutionEngine {
131
250
  }
132
251
  return null;
133
252
  }
253
+ async recordScreenshot(screenshotBase64, metadata = {}) {
254
+ return await this.saveScreenshotArtifact(screenshotBase64, metadata);
255
+ }
256
+ async recordPreviewScreenshot(screenshotBase64, metadata = {}) {
257
+ return await this.saveScreenshotArtifact(screenshotBase64, metadata, {
258
+ updateLastRecordedScreenshotPaths: false,
259
+ });
260
+ }
261
+ async annotateLatestScreenshot(action, metadata = {}) {
262
+ if (!(action?.type === "click" || action?.type === "double_click") || this.lastRecordedScreenshotPaths.length === 0) {
263
+ return null;
264
+ }
265
+ try {
266
+ const marker = {
267
+ x: action.x,
268
+ y: action.y,
269
+ style: "click",
270
+ };
271
+ const sourcePath = this.lastRecordedScreenshotPaths.find((filePath) => typeof filePath === "string" && filePath.trim());
272
+ if (sourcePath && this.screenshotRecorder?.saveAnnotatedScreenshot) {
273
+ return await this.screenshotRecorder.saveAnnotatedScreenshot(sourcePath, marker, metadata);
274
+ }
275
+ await Promise.all(this.lastRecordedScreenshotPaths.map((filePath) => annotateScreenshotArtifact(filePath, marker)));
276
+ return null;
277
+ }
278
+ catch (error) {
279
+ if (!this.reportedScreenshotAnnotateError) {
280
+ this.reportedScreenshotAnnotateError = true;
281
+ const scope = typeof metadata.sessionId === "string" ? "design" : "execution";
282
+ const ids = scope === "design"
283
+ ? {
284
+ sessionId: metadata.sessionId,
285
+ stepId: metadata.stepId,
286
+ instructionIndex: metadata.instructionIndex
287
+ }
288
+ : {
289
+ runId: metadata.runId,
290
+ stepId: metadata.stepId,
291
+ instructionIndex: metadata.instructionIndex
292
+ };
293
+ emitDesktopDebug("run.screenshot_annotate.error", scope, ids, {
294
+ actionType: action?.type ?? null,
295
+ x: action?.x ?? null,
296
+ y: action?.y ?? null,
297
+ message: error instanceof Error ? error.message : "Failed to annotate screenshot"
298
+ });
299
+ }
300
+ }
301
+ return null;
302
+ }
303
+ async handlePreviewClick(callId, argumentsText, context = null, stepContext = null) {
304
+ const parsedArguments = parsePreviewClickArguments(argumentsText);
305
+ if (!parsedArguments.ok) {
306
+ return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({ text: parsedArguments.message }));
307
+ }
308
+ try {
309
+ const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
310
+ const previewResult = await createAnnotatedScreenshotFromBase64(screenshotBase64, {
311
+ x: parsedArguments.x,
312
+ y: parsedArguments.y,
313
+ style: "preview",
314
+ });
315
+ if (!previewResult?.screenshotBase64 || !previewResult?.marker) {
316
+ return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
317
+ text: "preview_click failed: could not annotate the current screenshot.",
318
+ }));
319
+ }
320
+ const previewEventId = randomUUID();
321
+ await this.recordPreviewScreenshot(previewResult.screenshotBase64, {
322
+ runId: context?.runId,
323
+ sessionId: context?.sessionId,
324
+ stepId: stepContext?.stepId,
325
+ instructionIndex: stepContext?.instructionIndex,
326
+ callId,
327
+ eventId: previewEventId,
328
+ captureSource: "preview-click",
329
+ });
330
+ return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
331
+ text: buildPreviewOutputText({
332
+ requestedX: previewResult.marker.requestedX,
333
+ requestedY: previewResult.marker.requestedY,
334
+ renderedX: previewResult.marker.x,
335
+ renderedY: previewResult.marker.y,
336
+ wasClamped: previewResult.marker.wasClamped,
337
+ }),
338
+ annotatedScreenshotBase64: previewResult.screenshotBase64,
339
+ }));
340
+ }
341
+ catch (error) {
342
+ return buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
343
+ text: `preview_click failed: ${error instanceof Error ? error.message : "unable to capture or annotate the current screen."}`,
344
+ }));
345
+ }
346
+ }
134
347
  /**
135
348
  * Run a full turn with the CUA model
136
349
  * Executes actions until the model stops requesting actions
@@ -156,7 +369,7 @@ export class ExecutionEngine {
156
369
  return newResponseId;
157
370
  }
158
371
  const items = response.output || [];
159
- const computerCalls = extractComputerCalls(items);
372
+ const actionableItems = extractActionableOutputItems(items);
160
373
  // ── Collect pending safety checks ──
161
374
  const pendingSafetyChecks = items
162
375
  .filter(item => item.type === "pending_safety_check")
@@ -203,117 +416,18 @@ export class ExecutionEngine {
203
416
  });
204
417
  }
205
418
  }
206
- if (computerCalls.length === 0) {
419
+ if (actionableItems.length === 0) {
207
420
  // No actions = turn complete
208
421
  break;
209
422
  }
210
- // ── Process model actions ──
211
- for (const { call_id, actions } of computerCalls) {
212
- if (!call_id)
213
- continue;
214
- const { actionsToExecute, droppedActions, truncationReason, runtimeNote } = buildStrictModePlan(actions, this.strictMode);
215
- let sawExplicitScreenshotAction = false;
216
- for (const action of actionsToExecute) {
217
- if (shouldStop()) {
218
- return newResponseId;
219
- }
220
- if (action.type === "screenshot") {
221
- sawExplicitScreenshotAction = true;
222
- addOutput({
223
- type: 'info',
224
- text: 'Capturing screen',
225
- eventType: 'screenshot_captured',
226
- actionType: 'screenshot',
227
- ...eventMeta({
228
- payload: {
229
- callId: call_id,
230
- source: 'explicit_action'
231
- }
232
- })
233
- });
234
- }
235
- else {
236
- await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
237
- ...context,
238
- shouldStop,
239
- stepId: stepContext?.stepId,
240
- instructionIndex: stepContext?.instructionIndex
241
- });
242
- // Track action and check for interruption
243
- if (trackAction) {
244
- trackAction(action);
245
- }
246
- if (shouldStop()) {
247
- // User interrupted - stop execution immediately
248
- return newResponseId;
249
- }
250
- // Add delay after UI-changing actions to let the interface update
251
- // before taking the screenshot (except for explicit wait actions which have their own delay)
252
- if (action.type !== "wait") {
253
- await new Promise(resolve => setTimeout(resolve, this.stepDelayMs));
254
- }
255
- }
256
- }
257
- if (shouldStop()) {
258
- return newResponseId;
259
- }
260
- const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
261
- emitDesktopDebug("device.screenshot", "device", {
262
- runId: context?.runId,
263
- stepId: stepContext?.stepId,
264
- instructionIndex: stepContext?.instructionIndex
265
- }, {
266
- source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
267
- callId: call_id,
268
- width: this.session.deviceInfo?.scaled_width,
269
- height: this.session.deviceInfo?.scaled_height,
270
- base64Length: screenshotBase64.length
271
- });
272
- await this.recordScreenshot(screenshotBase64, {
273
- runId: context?.runId,
274
- sessionId: context?.sessionId,
275
- stepId: stepContext?.stepId,
276
- instructionIndex: stepContext?.instructionIndex,
277
- callId: call_id,
278
- captureSource: sawExplicitScreenshotAction ? "call-output-explicit-action" : "call-output-post-action"
279
- });
280
- if (runtimeNote) {
281
- const executedActionTypes = actionsToExecute
282
- .map((action) => action?.type)
283
- .filter(Boolean);
284
- const droppedActionTypes = droppedActions
285
- .map((action) => action?.type)
286
- .filter(Boolean);
287
- emitDesktopDebug("cua.strict_mode.truncation", scope, ids, {
288
- callId: call_id,
289
- executedCount: actionsToExecute.length,
290
- droppedCount: droppedActions.length,
291
- executedActionTypes,
292
- droppedActionTypes,
293
- reason: truncationReason,
294
- note: runtimeNote
295
- });
296
- }
297
- // Build next input: screenshot + any carryover reasoning
298
- const selectedCuaModel = process.env.OPENAI_CUA_MODEL === "computer-use-preview" ? "computer-use-preview" : "gpt-5.4";
299
- const input = [{
300
- type: "computer_call_output",
301
- call_id,
302
- output: {
303
- type: "computer_screenshot",
304
- image_url: `data:image/png;base64,${screenshotBase64}`,
305
- },
306
- ...(selectedCuaModel === "computer-use-preview"
307
- ? { current_url: this.getCurrentPlatform() === "ios" ? "ios://simulator" : "android://device" }
308
- : {}),
309
- ...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
310
- }];
311
- if (runtimeNote) {
312
- input.push({
313
- role: "user",
314
- content: runtimeNote
315
- });
316
- }
423
+ const actionableItem = actionableItems[0];
424
+ if (!actionableItem?.call_id) {
425
+ break;
426
+ }
427
+ if (actionableItem.type === "preview_click") {
428
+ const input = [
429
+ await this.handlePreviewClick(actionableItem.call_id, actionableItem.arguments, context, stepContext),
430
+ ];
317
431
  if (shouldStop()) {
318
432
  return newResponseId;
319
433
  }
@@ -330,9 +444,145 @@ export class ExecutionEngine {
330
444
  }
331
445
  });
332
446
  newResponseId = response.id;
333
- // Each tool output advances the response chain; process newly returned calls next.
334
- break;
447
+ continue;
448
+ }
449
+ const { call_id, actions } = actionableItem;
450
+ const { actionsToExecute, droppedActions, truncationReason, runtimeNote } = buildStrictModePlan(actions, this.strictMode);
451
+ let sawExplicitScreenshotAction = false;
452
+ for (const action of actionsToExecute) {
453
+ const actionEventId = randomUUID();
454
+ if (shouldStop()) {
455
+ return newResponseId;
456
+ }
457
+ if (action.type === "screenshot") {
458
+ sawExplicitScreenshotAction = true;
459
+ }
460
+ else {
461
+ const annotatedScreenshotResult = await this.annotateLatestScreenshot(action, {
462
+ runId: context?.runId,
463
+ sessionId: context?.sessionId,
464
+ stepId: stepContext?.stepId,
465
+ instructionIndex: stepContext?.instructionIndex,
466
+ eventId: actionEventId,
467
+ captureSource: "action-click-annotation"
468
+ });
469
+ const annotatedScreenshotPath = screenshotPathFromRecorderResult(annotatedScreenshotResult);
470
+ const annotatedScreenshotDataUrl = screenshotDataUrlFromRecorderResult(annotatedScreenshotResult);
471
+ await this.handleModelAction(this.session.deviceId, action, this.session.deviceInfo.scale, {
472
+ ...context,
473
+ shouldStop,
474
+ stepId: stepContext?.stepId,
475
+ instructionIndex: stepContext?.instructionIndex,
476
+ eventId: actionEventId,
477
+ ...(annotatedScreenshotPath ? { screenshotPath: annotatedScreenshotPath } : {}),
478
+ ...(context?.includeScreenshotDataUrl && annotatedScreenshotDataUrl ? { screenshotDataUrl: annotatedScreenshotDataUrl } : {})
479
+ });
480
+ if (trackAction) {
481
+ trackAction(action);
482
+ }
483
+ if (shouldStop()) {
484
+ return newResponseId;
485
+ }
486
+ if (action.type !== "wait") {
487
+ await new Promise(resolve => setTimeout(resolve, this.stepDelayMs));
488
+ }
489
+ }
335
490
  }
491
+ if (shouldStop()) {
492
+ return newResponseId;
493
+ }
494
+ const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
495
+ emitDesktopDebug("device.screenshot", "device", {
496
+ runId: context?.runId,
497
+ stepId: stepContext?.stepId,
498
+ instructionIndex: stepContext?.instructionIndex
499
+ }, {
500
+ source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
501
+ callId: call_id,
502
+ width: this.session.deviceInfo?.scaled_width,
503
+ height: this.session.deviceInfo?.scaled_height,
504
+ base64Length: screenshotBase64.length
505
+ });
506
+ const screenshotEventId = randomUUID();
507
+ const captureSource = sawExplicitScreenshotAction ? "call-output-explicit-action" : "call-output-post-action";
508
+ const screenshotResult = await this.recordScreenshot(screenshotBase64, {
509
+ runId: context?.runId,
510
+ sessionId: context?.sessionId,
511
+ stepId: stepContext?.stepId,
512
+ instructionIndex: stepContext?.instructionIndex,
513
+ eventId: screenshotEventId,
514
+ captureSource
515
+ });
516
+ const screenshotPath = screenshotPathFromRecorderResult(screenshotResult);
517
+ addOutput({
518
+ type: 'info',
519
+ text: 'Capturing screen',
520
+ eventType: 'screenshot_captured',
521
+ actionType: 'screenshot',
522
+ ...eventMeta({
523
+ payload: {
524
+ eventId: screenshotEventId,
525
+ captureSource,
526
+ source: sawExplicitScreenshotAction ? "explicit_action" : "post_action",
527
+ ...(typeof screenshotPath === "string" && screenshotPath ? { screenshotPath } : {}),
528
+ ...(context?.includeScreenshotDataUrl
529
+ ? { screenshotDataUrl: `data:image/png;base64,${screenshotBase64}` }
530
+ : {})
531
+ }
532
+ })
533
+ });
534
+ if (runtimeNote) {
535
+ const executedActionTypes = actionsToExecute
536
+ .map((action) => action?.type)
537
+ .filter(Boolean);
538
+ const droppedActionTypes = droppedActions
539
+ .map((action) => action?.type)
540
+ .filter(Boolean);
541
+ emitDesktopDebug("cua.strict_mode.truncation", scope, ids, {
542
+ callId: call_id,
543
+ executedCount: actionsToExecute.length,
544
+ droppedCount: droppedActions.length,
545
+ executedActionTypes,
546
+ droppedActionTypes,
547
+ reason: truncationReason,
548
+ note: runtimeNote
549
+ });
550
+ }
551
+ const selectedCuaModel = process.env.OPENAI_CUA_MODEL === "computer-use-preview" ? "computer-use-preview" : "gpt-5.4";
552
+ const input = [{
553
+ type: "computer_call_output",
554
+ call_id,
555
+ output: {
556
+ type: "computer_screenshot",
557
+ image_url: `data:image/png;base64,${screenshotBase64}`,
558
+ },
559
+ ...(selectedCuaModel === "computer-use-preview"
560
+ ? { current_url: this.getCurrentPlatform() === "ios" ? "ios://simulator" : "android://device" }
561
+ : {}),
562
+ ...(pendingSafetyChecks.length > 0 ? { acknowledged_safety_checks: pendingSafetyChecks } : {})
563
+ }];
564
+ if (runtimeNote) {
565
+ input.push({
566
+ role: "user",
567
+ content: runtimeNote
568
+ });
569
+ }
570
+ if (shouldStop()) {
571
+ return newResponseId;
572
+ }
573
+ response = await this.sendCUARequest({
574
+ messages: input,
575
+ previousResponseId: newResponseId,
576
+ deviceInfo: this.session.deviceInfo,
577
+ debugContext: {
578
+ scope,
579
+ runId: context?.runId,
580
+ sessionId: context?.sessionId,
581
+ stepId: stepContext?.stepId,
582
+ instructionIndex: stepContext?.instructionIndex
583
+ }
584
+ });
585
+ newResponseId = response.id;
336
586
  }
337
587
  // ── At end, if last output was only reasoning ──
338
588
  const finalItems = response.output || [];
@@ -40,6 +40,9 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
40
40
  stepId: context?.stepId,
41
41
  instructionIndex: context?.instructionIndex,
42
42
  payload: {
43
+ eventId: context?.eventId,
44
+ screenshotPath: context?.screenshotPath,
45
+ screenshotDataUrl: context?.screenshotDataUrl,
43
46
  platform: 'android',
44
47
  ...payload
45
48
  }
@@ -71,7 +74,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
71
74
  case "double_click":
72
75
  const realX = Math.round(x / scale);
73
76
  const realY = Math.round(y / scale);
74
- addOutput({ type: 'action', text: `Clicking at (${realX}, ${realY})`, ...meta({ x: realX, y: realY, unit: 'px' }) });
77
+ addOutput({ type: 'action', text: 'Click', ...meta({ x: realX, y: realY, unit: 'px' }) });
75
78
  await adbShell(deviceId, `input tap ${realX} ${realY}`);
76
79
  break;
77
80
  case "scroll":
@@ -154,6 +157,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
154
157
  stepId: context?.stepId,
155
158
  instructionIndex: context?.instructionIndex,
156
159
  payload: {
160
+ eventId: context?.eventId,
157
161
  message: error.message,
158
162
  platform: 'android'
159
163
  }
@@ -35,7 +35,7 @@ export function extractFailureDetails(transcript) {
35
35
  const parts = recentTranscript.split("ASSERTION RESULT: FAIL");
36
36
  return parts[1]?.trim() || "Could not confidently validate the assertion.";
37
37
  }
38
- export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context, stepContext = null) {
38
+ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context, stepContext = null, eventPayload = {}) {
39
39
  const details = extractFailureDetails(transcript);
40
40
  const addOutput = context?.addOutput || printCliOutput;
41
41
  const meta = {
@@ -46,7 +46,8 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
46
46
  payload: {
47
47
  assertion: assertionPrompt,
48
48
  passed: false,
49
- details
49
+ details,
50
+ ...eventPayload
50
51
  }
51
52
  };
52
53
  addOutput({ type: 'error', text: '❌ ASSERTION FAILED', ...meta });
@@ -61,7 +62,7 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
61
62
  }
62
63
  // Interactive mode: caller should clear remaining instructions
63
64
  }
64
- export function handleAssertionSuccess(assertionPrompt, context = null, stepContext = null) {
65
+ export function handleAssertionSuccess(assertionPrompt, context = null, stepContext = null, eventPayload = {}) {
65
66
  const addOutput = context?.addOutput || printCliOutput;
66
67
  addOutput({
67
68
  type: 'success',
@@ -72,7 +73,8 @@ export function handleAssertionSuccess(assertionPrompt, context = null, stepCont
72
73
  instructionIndex: stepContext?.instructionIndex,
73
74
  payload: {
74
75
  assertion: assertionPrompt,
75
- passed: true
76
+ passed: true,
77
+ ...eventPayload
76
78
  }
77
79
  });
78
80
  }
@@ -54,6 +54,9 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
54
54
  stepId: context?.stepId,
55
55
  instructionIndex: context?.instructionIndex,
56
56
  payload: {
57
+ eventId: context?.eventId,
58
+ screenshotPath: context?.screenshotPath,
59
+ screenshotDataUrl: context?.screenshotDataUrl,
57
60
  platform,
58
61
  source: session?.providerId,
59
62
  ...payload
@@ -84,7 +87,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
84
87
  case "double_click": {
85
88
  const targetX = Math.round((action.x / scale) / dpr);
86
89
  const targetY = Math.round((action.y / scale) / dpr);
87
- addOutput({ type: "action", text: `Tapping at (${targetX}, ${targetY})`, ...meta({ x: targetX, y: targetY }) });
90
+ addOutput({ type: "action", text: "Click", ...meta({ x: targetX, y: targetY }) });
88
91
  await session.client.tap(session.sessionId, targetX, targetY);
89
92
  break;
90
93
  }
@@ -181,6 +184,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
181
184
  stepId: context?.stepId,
182
185
  instructionIndex: context?.instructionIndex,
183
186
  payload: {
187
+ eventId: context?.eventId,
184
188
  message: error.message,
185
189
  platform,
186
190
  source: session.providerId
@@ -51,6 +51,9 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
51
51
  stepId: context?.stepId,
52
52
  instructionIndex: context?.instructionIndex,
53
53
  payload: {
54
+ eventId: context?.eventId,
55
+ screenshotPath: context?.screenshotPath,
56
+ screenshotDataUrl: context?.screenshotDataUrl,
54
57
  platform: "ios",
55
58
  ...payload
56
59
  }
@@ -82,7 +85,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
82
85
  const pixelY = Math.round(action.y / scale);
83
86
  const pointX = Math.round(pixelX / dpr);
84
87
  const pointY = Math.round(pixelY / dpr);
85
- addOutput({ type: "action", text: `Tapping at (${pointX}, ${pointY}) points`, ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
88
+ addOutput({ type: "action", text: "Click", ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
86
89
  await appium.tap(session.sessionId, pointX, pointY);
87
90
  break;
88
91
  }
@@ -180,6 +183,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
180
183
  stepId: context?.stepId,
181
184
  instructionIndex: context?.instructionIndex,
182
185
  payload: {
186
+ eventId: context?.eventId,
183
187
  message: error.message,
184
188
  platform: "ios"
185
189
  }
@@ -8,47 +8,87 @@ const cuaDebugTracer = new CuaDebugTracer(logger);
8
8
  function getSelectedCuaModel() {
9
9
  return process.env.OPENAI_CUA_MODEL === "computer-use-preview" ? "computer-use-preview" : "gpt-5.4";
10
10
  }
11
- function normalizePreviewMessages(messages, screenshotBase64, includeInitialScreenshot) {
12
- const normalized = messages.map((message) => {
13
- if (!message?.role) {
14
- return message;
15
- }
16
- const contentItems = Array.isArray(message.content)
17
- ? message.content.map((item) => {
18
- if (typeof item === "string") {
19
- return { type: "input_text", text: item };
20
- }
21
- return item;
22
- })
23
- : [{ type: "input_text", text: String(message.content ?? "") }];
24
- return {
25
- role: message.role,
26
- content: contentItems,
27
- };
28
- });
29
- if (!includeInitialScreenshot || !screenshotBase64) {
30
- return normalized;
11
+ function normalizeMessageContent(content) {
12
+ if (Array.isArray(content)) {
13
+ return content.map((item) => {
14
+ if (typeof item === "string") {
15
+ return { type: "input_text", text: item };
16
+ }
17
+ return item;
18
+ });
31
19
  }
32
- for (let index = normalized.length - 1; index >= 0; index -= 1) {
33
- const item = normalized[index];
20
+ return [{ type: "input_text", text: String(content ?? "") }];
21
+ }
22
+ function buildScreenshotDataUrl(screenshotBase64) {
23
+ return `data:image/png;base64,${screenshotBase64}`;
24
+ }
25
+ const previewClickTool = {
26
+ type: "function",
27
+ name: "preview_click",
28
+ description: "Preview where a proposed click would land on the current device screen. Returns an annotated screenshot with a marker at the given coordinates; the device state is unchanged and the click is not executed. Use this before issuing a click action to verify your aim.",
29
+ strict: true,
30
+ parameters: {
31
+ type: "object",
32
+ properties: {
33
+ x: { type: "number" },
34
+ y: { type: "number" },
35
+ },
36
+ required: ["x", "y"],
37
+ additionalProperties: false,
38
+ },
39
+ };
40
+ function appendInitialScreenshot(input, screenshotBase64) {
41
+ // Most callers end with the active user message, but some recovery flows can
42
+ // reset the conversation to system-only state before the next screenshot send.
43
+ // Attach to the last user message when present, otherwise synthesize one below.
44
+ for (let index = input.length - 1; index >= 0; index -= 1) {
45
+ const item = input[index];
34
46
  if (item?.role !== "user" || !Array.isArray(item.content))
35
47
  continue;
36
48
  item.content.push({
37
49
  type: "input_image",
38
- image_url: `data:image/png;base64,${screenshotBase64}`,
50
+ image_url: buildScreenshotDataUrl(screenshotBase64),
39
51
  });
40
- return normalized;
52
+ return;
41
53
  }
42
- normalized.push({
54
+ input.push({
43
55
  role: "user",
44
56
  content: [{
45
57
  type: "input_image",
46
- image_url: `data:image/png;base64,${screenshotBase64}`,
58
+ image_url: buildScreenshotDataUrl(screenshotBase64),
47
59
  }],
48
60
  });
49
- return normalized;
50
61
  }
51
- function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
62
+ export function buildCuaInput({ messages, screenshotBase64, previousResponseId, callId }) {
63
+ const input = messages.map((message) => {
64
+ if (!message?.role) {
65
+ return message;
66
+ }
67
+ return {
68
+ role: message.role,
69
+ content: normalizeMessageContent(message.content),
70
+ };
71
+ });
72
+ if (!screenshotBase64) {
73
+ return input;
74
+ }
75
+ if (callId) {
76
+ input.push({
77
+ type: "computer_call_output",
78
+ call_id: callId,
79
+ output: {
80
+ type: "computer_screenshot",
81
+ image_url: buildScreenshotDataUrl(screenshotBase64),
82
+ },
83
+ });
84
+ return input;
85
+ }
86
+ if (!previousResponseId) {
87
+ appendInitialScreenshot(input, screenshotBase64);
88
+ }
89
+ return input;
90
+ }
91
+ export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
52
92
  if (cuaModel === "computer-use-preview") {
53
93
  return {
54
94
  model: cuaModel,
@@ -56,24 +96,31 @@ function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input
56
96
  input,
57
97
  store: true,
58
98
  truncation: "auto",
59
- tools: [{
99
+ tools: [
100
+ {
60
101
  type: "computer_use_preview",
61
102
  display_width: deviceInfo.scaled_width,
62
103
  display_height: deviceInfo.scaled_height,
63
104
  environment: "browser",
64
- }],
105
+ },
106
+ previewClickTool,
107
+ ],
65
108
  reasoning: { summary: "concise" },
66
109
  };
67
110
  }
68
111
  return {
69
112
  model: cuaModel,
113
+ reasoning: { effort: "medium" },
70
114
  previous_response_id: previousResponseId || undefined,
71
115
  input,
72
116
  store: true,
73
117
  truncation: "auto",
74
- tools: [{
118
+ tools: [
119
+ {
75
120
  type: "computer",
76
- }],
121
+ },
122
+ previewClickTool,
123
+ ],
77
124
  };
78
125
  }
79
126
  function shouldMapPreviewAccessError(err) {
@@ -153,20 +200,7 @@ export async function compactAppContext({ contextDocument, taskDescription, toke
153
200
  }
154
201
  export async function sendCUARequest({ messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
155
202
  const cuaModel = getSelectedCuaModel();
156
- const includeInitialScreenshot = cuaModel === "computer-use-preview" && !previousResponseId && !callId;
157
- const input = cuaModel === "computer-use-preview"
158
- ? normalizePreviewMessages(messages, screenshotBase64, includeInitialScreenshot)
159
- : [...messages];
160
- if (callId && screenshotBase64) {
161
- input.push({
162
- type: "computer_call_output",
163
- call_id: callId,
164
- output: {
165
- type: "computer_screenshot",
166
- image_url: `data:image/png;base64,${screenshotBase64}`,
167
- },
168
- });
169
- }
203
+ const input = buildCuaInput({ messages, screenshotBase64, previousResponseId, callId });
170
204
  const requestParams = buildCuaRequestParams({
171
205
  cuaModel,
172
206
  previousResponseId,
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { getScreenshotAsBase64, connectToDevice, getDeviceInfo, getCurrentPlatform } from "../device/connection.js";
2
3
  import { sendCUARequest } from "../device/openai.js";
3
4
  import { buildExecutionRecoveryPrompt } from "../core/prompts.js";
@@ -14,6 +15,22 @@ export function buildExecutionRequestPayload({ instruction, isAssertionStep, mes
14
15
  previousResponseIdToSend: previousResponseId,
15
16
  };
16
17
  }
18
+ function screenshotPathFromRecorderResult(result) {
19
+ if (result && typeof result === "object" && !Array.isArray(result)) {
20
+ return typeof result.screenshotPath === "string" && result.screenshotPath.trim()
21
+ ? result.screenshotPath
22
+ : null;
23
+ }
24
+ return null;
25
+ }
26
+ function screenshotDataUrlFromRecorderResult(result) {
27
+ if (result && typeof result === "object" && !Array.isArray(result)) {
28
+ return typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
29
+ ? result.screenshotDataUrl
30
+ : null;
31
+ }
32
+ return null;
33
+ }
17
34
  /**
18
35
  * Execution Mode - Run test scripts line-by-line
19
36
  * Each instruction is executed in isolation (messages cleared after each turn)
@@ -197,6 +214,7 @@ export class ExecutionMode {
197
214
  }
198
215
  // ── Check for assertion ──
199
216
  const isAssertionStep = isAssertion(instruction);
217
+ const assertionEventId = isAssertionStep ? randomUUID() : null;
200
218
  let assertionPrompt = null;
201
219
  const transcriptStartIndex = this.session.transcript.length;
202
220
  if (isAssertionStep) {
@@ -213,12 +231,27 @@ export class ExecutionMode {
213
231
  }
214
232
  try {
215
233
  const screenshotBase64 = await getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
216
- await this.engine.recordScreenshot?.(screenshotBase64, {
234
+ const screenshotResult = await this.engine.recordScreenshot?.(screenshotBase64, {
217
235
  runId: context?.runId,
218
236
  stepId: stepContext?.stepId,
219
237
  instructionIndex: stepContext?.instructionIndex,
238
+ ...(assertionEventId ? { eventId: assertionEventId } : {}),
220
239
  captureSource: isAssertionStep ? "instruction-input-assertion" : "instruction-input"
221
240
  });
241
+ const assertionScreenshotPath = isAssertionStep ? screenshotPathFromRecorderResult(screenshotResult) : null;
242
+ const assertionScreenshotDataUrl = isAssertionStep
243
+ ? screenshotDataUrlFromRecorderResult(screenshotResult) ||
244
+ (context?.includeScreenshotDataUrl && assertionScreenshotPath
245
+ ? `data:image/png;base64,${screenshotBase64}`
246
+ : null)
247
+ : null;
248
+ const assertionEventPayload = isAssertionStep
249
+ ? {
250
+ eventId: assertionEventId,
251
+ ...(assertionScreenshotPath ? { screenshotPath: assertionScreenshotPath } : {}),
252
+ ...(assertionScreenshotDataUrl ? { screenshotDataUrl: assertionScreenshotDataUrl } : {})
253
+ }
254
+ : {};
222
255
  // Assertions rely on the prior response chain for earlier execution context.
223
256
  const { messagesToSend, previousResponseIdToSend } = buildExecutionRequestPayload({
224
257
  instruction,
@@ -253,7 +286,7 @@ export class ExecutionMode {
253
286
  const result = checkAssertionResult(assertionTranscript);
254
287
  if (result.failed) {
255
288
  handleAssertionFailure(assertionPrompt, assertionTranscript, false, // Never exit process - we'll always prompt the user in interactive mode
256
- context, stepContext);
289
+ context, stepContext, assertionEventPayload);
257
290
  // In headless mode, exit immediately on assertion failure
258
291
  if (this.isHeadlessMode) {
259
292
  this.stats.assertionsFailed++;
@@ -297,7 +330,7 @@ export class ExecutionMode {
297
330
  }
298
331
  else if (result.passed) {
299
332
  this.stats.assertionsPassed++;
300
- handleAssertionSuccess(assertionPrompt, context, stepContext);
333
+ handleAssertionSuccess(assertionPrompt, context, stepContext, assertionEventPayload);
301
334
  }
302
335
  }
303
336
  // Clear messages after each turn but KEEP the response chain for context
@@ -62,6 +62,10 @@ function buildBaseSystemPrompt(deviceInfo, customInstructions = {}) {
62
62
 
63
63
  When aiming for visual targets:
64
64
  - Reason carefully about the approximate pixel position.
65
+ - Before every click action, call \`preview_click\` with the exact coordinates you intend to click.
66
+ - Review the returned annotated screenshot before clicking.
67
+ - Only click if the marker is clearly inside the intended target and not on its edge, border, text, or nearby empty space.
68
+ - If the click does not cause the expected UI change, treat it as a likely misclick and try a meaningfully different coordinate.
65
69
  - Click precisely based on your visual estimate.
66
70
 
67
71
  Available actions: click, scroll, type, keypress, wait, screenshot.
@@ -63,6 +63,19 @@ function summarizeRequestInput(input) {
63
63
  hasSafetyChecks: Array.isArray(item.acknowledged_safety_checks) && item.acknowledged_safety_checks.length > 0
64
64
  };
65
65
  }
66
+ if (item?.type === "function_call_output") {
67
+ const content = Array.isArray(item.output) ? item.output : [];
68
+ const imageBytes = content
69
+ .filter((entry) => entry?.type === "input_image" && typeof entry.image_url === "string")
70
+ .map((entry) => entry.image_url.replace("data:image/png;base64,", "").length);
71
+ return {
72
+ type: "function_call_output",
73
+ callId: item.call_id,
74
+ contentTypes: content.map((entry) => entry?.type).filter(Boolean),
75
+ imageBytes,
76
+ text: summarizeContent(content),
77
+ };
78
+ }
66
79
  return {
67
80
  type: item?.type || "unknown"
68
81
  };
@@ -85,6 +98,21 @@ function redactRequestForFileLog(requestParams) {
85
98
  acknowledged_safety_checks: item.acknowledged_safety_checks
86
99
  };
87
100
  }
101
+ if (item.type === "function_call_output" && Array.isArray(item.output)) {
102
+ return {
103
+ ...item,
104
+ output: item.output.map((entry) => {
105
+ if (entry?.type === "input_image" && typeof entry.image_url === "string") {
106
+ const base64Data = entry.image_url.replace("data:image/png;base64,", "");
107
+ return {
108
+ ...entry,
109
+ image_url: `data:image/png;base64,[${base64Data.length} chars]`
110
+ };
111
+ }
112
+ return entry;
113
+ })
114
+ };
115
+ }
88
116
  return item;
89
117
  })
90
118
  };
@@ -117,6 +145,15 @@ function summarizeResponseOutput(response) {
117
145
  actionType: item.action?.type
118
146
  };
119
147
  }
148
+ if (item.type === "function_call") {
149
+ return {
150
+ type: "function_call",
151
+ id: item.id,
152
+ callId: item.call_id,
153
+ name: item.name,
154
+ arguments: truncateForDebug(item.arguments, 300)
155
+ };
156
+ }
120
157
  if (item.type === "pending_safety_check") {
121
158
  return { type: "pending_safety_check", id: item.id, code: item.code };
122
159
  }
@@ -175,11 +212,11 @@ export class CuaDebugTracer {
175
212
  }));
176
213
  });
177
214
  const inputCallIds = input
178
- .filter((item) => item?.type === "computer_call_output")
215
+ .filter((item) => item?.type === "computer_call_output" || item?.type === "function_call_output")
179
216
  .map((item) => item.call_id)
180
217
  .filter(Boolean);
181
218
  const inputItemTypes = input.map((item) => item?.type || (item?.role ? `message:${item.role}` : "unknown"));
182
- const lastResponseCallIds = Array.isArray(lastResponseMeta?.computerCallIds) ? lastResponseMeta.computerCallIds : [];
219
+ const lastResponseCallIds = Array.isArray(lastResponseMeta?.toolCallIds) ? lastResponseMeta.toolCallIds : [];
183
220
  const missingCallIds = inputCallIds.filter((callId) => !lastResponseCallIds.includes(callId));
184
221
  const previousResponseIdMatchesLastResponseId = !previousResponseId || !lastResponseMeta?.id ? null : previousResponseId === lastResponseMeta.id;
185
222
  const allInputCallIdsFoundInLastResponse = inputCallIds.length === 0 ? true : missingCallIds.length === 0;
@@ -252,10 +289,12 @@ export class CuaDebugTracer {
252
289
  onResponse(trace, response) {
253
290
  const outputTypes = (response.output || []).map((item) => item.type);
254
291
  const toolCalls = (response.output || [])
255
- .filter((item) => item.type === "computer_call")
292
+ .filter((item) => item.type === "computer_call" || item.type === "function_call")
256
293
  .map((item) => ({
257
294
  call_id: item.call_id,
258
- action_type: item.action?.type
295
+ action_type: item.action?.type,
296
+ name: item.name,
297
+ type: item.type
259
298
  }));
260
299
  const safetyChecks = (response.output || [])
261
300
  .filter((item) => item.type === "pending_safety_check")
@@ -268,7 +307,11 @@ export class CuaDebugTracer {
268
307
  this.lastResponseMetaByChain.set(trace.chainKey, {
269
308
  id: response.id,
270
309
  outputTypes,
271
- computerCallIds: toolCalls.map((item) => item.call_id).filter(Boolean),
310
+ toolCallIds: toolCalls.map((item) => item.call_id).filter(Boolean),
311
+ computerCallIds: toolCalls
312
+ .filter((item) => item.type === "computer_call")
313
+ .map((item) => item.call_id)
314
+ .filter(Boolean),
272
315
  pendingSafetyCheckIds: safetyChecks.map((item) => item.id).filter(Boolean)
273
316
  });
274
317
  const accountedItems = toolCalls.length + safetyChecks.length;
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
+ import sharp from "sharp";
3
4
  function pad2(value) {
4
5
  return String(value).padStart(2, "0");
5
6
  }
@@ -32,6 +33,12 @@ function buildCallSegment(callId) {
32
33
  }
33
34
  return `call-${sanitizeArtifactSegment(callId, "call").slice(0, 24)}`;
34
35
  }
36
+ function buildEventSegment(eventId) {
37
+ if (typeof eventId !== "string" || !eventId.trim()) {
38
+ return null;
39
+ }
40
+ return `event-${sanitizeArtifactSegment(eventId, "event").slice(0, 36)}`;
41
+ }
35
42
  function buildScreenshotFileName(sequence, metadata = {}) {
36
43
  const segments = [
37
44
  String(sequence).padStart(4, "0"),
@@ -50,8 +57,119 @@ function buildScreenshotFileName(sequence, metadata = {}) {
50
57
  if (callSegment) {
51
58
  segments.push(callSegment);
52
59
  }
60
+ const eventSegment = buildEventSegment(metadata.eventId);
61
+ if (eventSegment) {
62
+ segments.push(eventSegment);
63
+ }
53
64
  return `${segments.join("_")}.png`;
54
65
  }
66
+ function clampCoordinate(value, max) {
67
+ if (!Number.isFinite(value) || max <= 0) {
68
+ return 0;
69
+ }
70
+ return Math.max(0, Math.min(max - 1, Math.round(value)));
71
+ }
72
+ function buildMarkerPlacement(marker = {}, width, height) {
73
+ const requestedX = Number(marker.x);
74
+ const requestedY = Number(marker.y);
75
+ const x = clampCoordinate(requestedX, width);
76
+ const y = clampCoordinate(requestedY, height);
77
+ return {
78
+ requestedX,
79
+ requestedY,
80
+ x,
81
+ y,
82
+ wasClamped: !Number.isFinite(requestedX) || !Number.isFinite(requestedY) || x !== Math.round(requestedX) || y !== Math.round(requestedY),
83
+ };
84
+ }
85
+ const MARKER_APPEARANCES = {
86
+ click: {
87
+ haloFill: "#39ff14",
88
+ haloOpacity: 0.28,
89
+ ringStroke: "#39ff14",
90
+ centerDotFill: "#07131f",
91
+ },
92
+ preview: {
93
+ haloFill: "#00d8ff",
94
+ haloOpacity: 0.28,
95
+ ringStroke: "#00d8ff",
96
+ centerDotFill: "#07131f",
97
+ },
98
+ };
99
+ function getMarkerAppearance(marker = {}) {
100
+ if (marker?.style === "preview") {
101
+ return MARKER_APPEARANCES.preview;
102
+ }
103
+ return MARKER_APPEARANCES.click;
104
+ }
105
+ function buildClickMarkerSvg({ width, height, x, y, radius, marker }) {
106
+ const haloRadius = radius + 8;
107
+ const whiteStrokeWidth = Math.max(5, Math.round(radius * 0.34));
108
+ const ringStrokeWidth = Math.max(3, Math.round(radius * 0.18));
109
+ const centerDotRadius = Math.max(2, Math.round(radius * 0.22));
110
+ const centerDotStrokeWidth = Math.max(2, Math.round(radius * 0.1));
111
+ const appearance = getMarkerAppearance(marker);
112
+ return `
113
+ <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
114
+ <circle cx="${x}" cy="${y}" r="${haloRadius}" fill="${appearance.haloFill}" opacity="${appearance.haloOpacity}" />
115
+ <circle cx="${x}" cy="${y}" r="${radius}" fill="none" stroke="#ffffff" stroke-width="${whiteStrokeWidth}" />
116
+ <circle cx="${x}" cy="${y}" r="${radius}" fill="none" stroke="${appearance.ringStroke}" stroke-width="${ringStrokeWidth}" />
117
+ <circle cx="${x}" cy="${y}" r="${centerDotRadius}" fill="${appearance.centerDotFill}" stroke="#ffffff" stroke-width="${centerDotStrokeWidth}" />
118
+ </svg>
119
+ `.trim();
120
+ }
121
+ async function renderAnnotatedScreenshot(image, marker = {}) {
122
+ const metadata = await image.metadata();
123
+ const width = Number(metadata.width) || 0;
124
+ const height = Number(metadata.height) || 0;
125
+ if (width <= 0 || height <= 0) {
126
+ return null;
127
+ }
128
+ const placement = buildMarkerPlacement(marker, width, height);
129
+ const radius = Math.max(14, Math.round(Math.min(width, height) * 0.055));
130
+ const overlay = Buffer.from(buildClickMarkerSvg({ width, height, x: placement.x, y: placement.y, radius, marker }));
131
+ const outputBuffer = await image
132
+ .composite([{ input: overlay }])
133
+ .png()
134
+ .toBuffer();
135
+ return {
136
+ outputBuffer,
137
+ width,
138
+ height,
139
+ marker: placement,
140
+ };
141
+ }
142
+ export async function createAnnotatedScreenshotBuffer(filePath, marker = {}) {
143
+ if (typeof filePath !== "string" || !filePath.trim()) {
144
+ return null;
145
+ }
146
+ const result = await renderAnnotatedScreenshot(sharp(filePath), marker);
147
+ if (!result) {
148
+ return null;
149
+ }
150
+ return result.outputBuffer;
151
+ }
152
+ export async function createAnnotatedScreenshotFromBase64(screenshotBase64, marker = {}) {
153
+ if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
154
+ return null;
155
+ }
156
+ const result = await renderAnnotatedScreenshot(sharp(Buffer.from(screenshotBase64, "base64")), marker);
157
+ if (!result) {
158
+ return null;
159
+ }
160
+ return {
161
+ ...result,
162
+ screenshotBase64: result.outputBuffer.toString("base64"),
163
+ };
164
+ }
165
+ export async function annotateScreenshotArtifact(filePath, marker = {}) {
166
+ const outputBuffer = await createAnnotatedScreenshotBuffer(filePath, marker);
167
+ if (!outputBuffer) {
168
+ return null;
169
+ }
170
+ await writeFile(filePath, outputBuffer);
171
+ return filePath;
172
+ }
55
173
  export function createDebugScreenshotRecorder({ directoryPath }) {
56
174
  let nextSequence = 1;
57
175
  let ensuredDirectoryPromise = null;
@@ -76,6 +194,23 @@ export function createDebugScreenshotRecorder({ directoryPath }) {
76
194
  const filePath = path.join(directoryPath, fileName);
77
195
  await writeFile(filePath, Buffer.from(screenshotBase64, "base64"));
78
196
  return filePath;
197
+ },
198
+ async saveAnnotatedScreenshot(sourceFilePath, marker = {}, metadata = {}) {
199
+ if (typeof sourceFilePath !== "string" || !sourceFilePath.trim()) {
200
+ return null;
201
+ }
202
+ await ensureDirectory();
203
+ const outputBuffer = await createAnnotatedScreenshotBuffer(sourceFilePath, marker);
204
+ if (!outputBuffer) {
205
+ return null;
206
+ }
207
+ const fileName = buildScreenshotFileName(nextSequence++, metadata);
208
+ const filePath = path.join(directoryPath, fileName);
209
+ await writeFile(filePath, outputBuffer);
210
+ return {
211
+ artifactPaths: [filePath],
212
+ annotationSourcePaths: [sourceFilePath],
213
+ };
79
214
  }
80
215
  };
81
216
  }
@@ -93,6 +228,32 @@ export function createCompositeScreenshotRecorder({ recorders }) {
93
228
  }
94
229
  const results = await Promise.all(activeRecorders.map((recorder) => recorder.saveScreenshot(screenshotBase64, metadata)));
95
230
  return results.filter(Boolean);
231
+ },
232
+ async saveAnnotatedScreenshot(sourceFilePath, marker = {}, metadata = {}) {
233
+ if (typeof sourceFilePath !== "string" || !sourceFilePath.trim()) {
234
+ return null;
235
+ }
236
+ const results = await Promise.all(activeRecorders.map((recorder) => recorder.saveAnnotatedScreenshot?.(sourceFilePath, marker, metadata)));
237
+ const filteredResults = results.filter(Boolean);
238
+ if (filteredResults.length === 0) {
239
+ return null;
240
+ }
241
+ return {
242
+ artifactPaths: filteredResults.flatMap((result) => {
243
+ if (result && typeof result === "object" && !Array.isArray(result)) {
244
+ return Array.isArray(result.artifactPaths) ? result.artifactPaths.filter(Boolean) : [];
245
+ }
246
+ return typeof result === "string" ? [result] : [];
247
+ }),
248
+ annotationSourcePaths: filteredResults.flatMap((result) => {
249
+ if (result && typeof result === "object" && !Array.isArray(result) && Array.isArray(result.annotationSourcePaths)) {
250
+ return result.annotationSourcePaths.filter(Boolean);
251
+ }
252
+ return [];
253
+ }),
254
+ screenshotPath: filteredResults.find((result) => result?.screenshotPath)?.screenshotPath ?? null,
255
+ screenshotDataUrl: filteredResults.find((result) => result?.screenshotDataUrl)?.screenshotDataUrl ?? null,
256
+ };
96
257
  }
97
258
  };
98
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.5.0",
3
+ "version": "2.6.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",