@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.
- package/build/src/core/execution-engine.js +380 -130
- package/build/src/device/android/actions.js +5 -1
- package/build/src/device/assertions.js +6 -4
- package/build/src/device/cloud/actions.js +5 -1
- package/build/src/device/ios/actions.js +5 -1
- package/build/src/device/openai.js +80 -46
- package/build/src/modes/execution-mode.js +36 -3
- package/build/src/prompts/base.js +4 -0
- package/build/src/utils/cua-debug-tracer.js +48 -5
- package/build/src/utils/run-screenshot-recorder.js +161 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
419
|
+
if (actionableItems.length === 0) {
|
|
207
420
|
// No actions = turn complete
|
|
208
421
|
break;
|
|
209
422
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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:
|
|
50
|
+
image_url: buildScreenshotDataUrl(screenshotBase64),
|
|
39
51
|
});
|
|
40
|
-
return
|
|
52
|
+
return;
|
|
41
53
|
}
|
|
42
|
-
|
|
54
|
+
input.push({
|
|
43
55
|
role: "user",
|
|
44
56
|
content: [{
|
|
45
57
|
type: "input_image",
|
|
46
|
-
image_url:
|
|
58
|
+
image_url: buildScreenshotDataUrl(screenshotBase64),
|
|
47
59
|
}],
|
|
48
60
|
});
|
|
49
|
-
return normalized;
|
|
50
61
|
}
|
|
51
|
-
function
|
|
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
|
|
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?.
|
|
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
|
-
|
|
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
|
}
|