@loadmill/droid-cua 2.4.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 || [];
@@ -1,6 +1,7 @@
1
1
  import { logger } from "../../utils/logger.js";
2
2
  import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
3
3
  import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
4
+ import { resolveScrollGesture } from "../scroll-gesture.js";
4
5
  import { execAdb } from "./tools.js";
5
6
  function adbShell(deviceId, command) {
6
7
  return execAdb(["-s", deviceId, "shell", command]);
@@ -39,6 +40,9 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
39
40
  stepId: context?.stepId,
40
41
  instructionIndex: context?.instructionIndex,
41
42
  payload: {
43
+ eventId: context?.eventId,
44
+ screenshotPath: context?.screenshotPath,
45
+ screenshotDataUrl: context?.screenshotDataUrl,
42
46
  platform: 'android',
43
47
  ...payload
44
48
  }
@@ -70,17 +74,20 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
70
74
  case "double_click":
71
75
  const realX = Math.round(x / scale);
72
76
  const realY = Math.round(y / scale);
73
- 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' }) });
74
78
  await adbShell(deviceId, `input tap ${realX} ${realY}`);
75
79
  break;
76
80
  case "scroll":
77
- const scrollX = Math.round(action.scroll_x / scale);
78
- const scrollY = Math.round(action.scroll_y / scale);
79
- addOutput({ type: 'action', text: `Scrolling by (${scrollX}, ${scrollY})`, ...meta({ scrollX, scrollY, unit: 'px' }) });
80
- const startX = 500;
81
- const startY = 500;
82
- const endX = startX + scrollX;
83
- const endY = startY - scrollY; // <--- INVERT Y
81
+ const { scrollX, scrollY, startX, startY, endX, endY, hasAnchor } = resolveScrollGesture(action, {
82
+ scale,
83
+ fallbackStartX: 500,
84
+ fallbackStartY: 500
85
+ });
86
+ addOutput({
87
+ type: 'action',
88
+ text: `Scrolling from (${startX}, ${startY}) to (${endX}, ${endY}) by (${scrollX}, ${scrollY})`,
89
+ ...meta({ scrollX, scrollY, startX, startY, endX, endY, anchorSource: hasAnchor ? 'action' : 'fallback', unit: 'px' })
90
+ });
84
91
  await adbShell(deviceId, `input swipe ${startX} ${startY} ${endX} ${endY} 500`);
85
92
  break;
86
93
  case "drag":
@@ -150,6 +157,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
150
157
  stepId: context?.stepId,
151
158
  instructionIndex: context?.instructionIndex,
152
159
  payload: {
160
+ eventId: context?.eventId,
153
161
  message: error.message,
154
162
  platform: 'android'
155
163
  }