@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.
- package/build/src/core/execution-engine.js +380 -130
- package/build/src/device/android/actions.js +16 -8
- package/build/src/device/assertions.js +6 -4
- package/build/src/device/cloud/actions.js +18 -9
- package/build/src/device/ios/actions.js +18 -10
- package/build/src/device/openai.js +80 -46
- package/build/src/device/screenshot-resolution.js +33 -0
- package/build/src/device/scroll-gesture.js +20 -0
- package/build/src/modes/execution-mode.js +36 -3
- package/build/src/prompts/base.js +4 -0
- package/build/src/test-store/test-manager.js +3 -5
- package/build/src/test-store/test-script.js +50 -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
|
@@ -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
|
}
|
|
@@ -2,6 +2,7 @@ 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
4
|
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
5
|
+
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
5
6
|
function normalizeMobileKeypress(platform, keys = []) {
|
|
6
7
|
if (!Array.isArray(keys) || keys.length === 0) {
|
|
7
8
|
throw new Error("Keypress action is missing keys");
|
|
@@ -53,6 +54,9 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
53
54
|
stepId: context?.stepId,
|
|
54
55
|
instructionIndex: context?.instructionIndex,
|
|
55
56
|
payload: {
|
|
57
|
+
eventId: context?.eventId,
|
|
58
|
+
screenshotPath: context?.screenshotPath,
|
|
59
|
+
screenshotDataUrl: context?.screenshotDataUrl,
|
|
56
60
|
platform,
|
|
57
61
|
source: session?.providerId,
|
|
58
62
|
...payload
|
|
@@ -83,7 +87,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
83
87
|
case "double_click": {
|
|
84
88
|
const targetX = Math.round((action.x / scale) / dpr);
|
|
85
89
|
const targetY = Math.round((action.y / scale) / dpr);
|
|
86
|
-
addOutput({ type: "action", text:
|
|
90
|
+
addOutput({ type: "action", text: "Click", ...meta({ x: targetX, y: targetY }) });
|
|
87
91
|
await session.client.tap(session.sessionId, targetX, targetY);
|
|
88
92
|
break;
|
|
89
93
|
}
|
|
@@ -93,14 +97,18 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
93
97
|
break;
|
|
94
98
|
}
|
|
95
99
|
case "scroll": {
|
|
96
|
-
const scrollX =
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
addOutput({
|
|
103
|
-
|
|
100
|
+
const { scrollX, scrollY, startX, startY, endX, endY, hasAnchor } = resolveScrollGesture(action, {
|
|
101
|
+
scale,
|
|
102
|
+
dpr,
|
|
103
|
+
fallbackStartX: 200,
|
|
104
|
+
fallbackStartY: 400
|
|
105
|
+
});
|
|
106
|
+
addOutput({
|
|
107
|
+
type: "action",
|
|
108
|
+
text: `Scrolling from (${startX}, ${startY}) to (${endX}, ${endY}) by (${scrollX}, ${scrollY})`,
|
|
109
|
+
...meta({ scrollX, scrollY, startX, startY, endX, endY, anchorSource: hasAnchor ? "action" : "fallback" })
|
|
110
|
+
});
|
|
111
|
+
await session.client.scroll(session.sessionId, startX, startY, endX, endY);
|
|
104
112
|
break;
|
|
105
113
|
}
|
|
106
114
|
case "drag": {
|
|
@@ -176,6 +184,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
|
|
|
176
184
|
stepId: context?.stepId,
|
|
177
185
|
instructionIndex: context?.instructionIndex,
|
|
178
186
|
payload: {
|
|
187
|
+
eventId: context?.eventId,
|
|
179
188
|
message: error.message,
|
|
180
189
|
platform,
|
|
181
190
|
source: session.providerId
|
|
@@ -8,6 +8,7 @@ import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
9
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
10
10
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
11
|
+
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
11
12
|
function normalizeMobileKeypress(keys = []) {
|
|
12
13
|
if (!Array.isArray(keys) || keys.length === 0) {
|
|
13
14
|
throw new Error("Keypress action is missing keys");
|
|
@@ -50,6 +51,9 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
50
51
|
stepId: context?.stepId,
|
|
51
52
|
instructionIndex: context?.instructionIndex,
|
|
52
53
|
payload: {
|
|
54
|
+
eventId: context?.eventId,
|
|
55
|
+
screenshotPath: context?.screenshotPath,
|
|
56
|
+
screenshotDataUrl: context?.screenshotDataUrl,
|
|
53
57
|
platform: "ios",
|
|
54
58
|
...payload
|
|
55
59
|
}
|
|
@@ -81,7 +85,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
81
85
|
const pixelY = Math.round(action.y / scale);
|
|
82
86
|
const pointX = Math.round(pixelX / dpr);
|
|
83
87
|
const pointY = Math.round(pixelY / dpr);
|
|
84
|
-
addOutput({ type: "action", text:
|
|
88
|
+
addOutput({ type: "action", text: "Click", ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
|
|
85
89
|
await appium.tap(session.sessionId, pointX, pointY);
|
|
86
90
|
break;
|
|
87
91
|
}
|
|
@@ -92,15 +96,18 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
92
96
|
}
|
|
93
97
|
case "scroll": {
|
|
94
98
|
const dpr = getDevicePixelRatio();
|
|
95
|
-
const scrollX =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
const { scrollX, scrollY, startX, startY, endX, endY, hasAnchor } = resolveScrollGesture(action, {
|
|
100
|
+
scale,
|
|
101
|
+
dpr,
|
|
102
|
+
fallbackStartX: 197,
|
|
103
|
+
fallbackStartY: 426
|
|
104
|
+
});
|
|
105
|
+
addOutput({
|
|
106
|
+
type: "action",
|
|
107
|
+
text: `Scrolling from (${startX}, ${startY}) to (${endX}, ${endY}) by (${scrollX}, ${scrollY}) points`,
|
|
108
|
+
...meta({ scrollX, scrollY, startX, startY, endX, endY, anchorSource: hasAnchor ? "action" : "fallback", unit: "points" })
|
|
109
|
+
});
|
|
110
|
+
await appium.scroll(session.sessionId, startX, startY, endX, endY);
|
|
104
111
|
break;
|
|
105
112
|
}
|
|
106
113
|
case "drag": {
|
|
@@ -176,6 +183,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
|
|
|
176
183
|
stepId: context?.stepId,
|
|
177
184
|
instructionIndex: context?.instructionIndex,
|
|
178
185
|
payload: {
|
|
186
|
+
eventId: context?.eventId,
|
|
179
187
|
message: error.message,
|
|
180
188
|
platform: "ios"
|
|
181
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,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const TARGET_SCALED_WIDTH = 400;
|
|
2
|
+
export const SCREENSHOT_RESOLUTION_MODE_DOWNSCALED = "downscaled";
|
|
3
|
+
export const SCREENSHOT_RESOLUTION_MODE_NATIVE = "native";
|
|
4
|
+
export function normalizeScreenshotResolutionMode(value) {
|
|
5
|
+
return value === SCREENSHOT_RESOLUTION_MODE_NATIVE
|
|
6
|
+
? SCREENSHOT_RESOLUTION_MODE_NATIVE
|
|
7
|
+
: SCREENSHOT_RESOLUTION_MODE_DOWNSCALED;
|
|
8
|
+
}
|
|
9
|
+
export function validateScreenshotResolutionMode(value, label) {
|
|
10
|
+
if (typeof value !== "string") {
|
|
11
|
+
throw new Error(`${label} must be one of: downscaled, native.`);
|
|
12
|
+
}
|
|
13
|
+
const normalized = normalizeScreenshotResolutionMode(value);
|
|
14
|
+
if (normalized !== value) {
|
|
15
|
+
throw new Error(`${label} must be one of: downscaled, native.`);
|
|
16
|
+
}
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
export function buildResolutionAwareDeviceInfo({ width, height, screenshotResolutionMode, }) {
|
|
20
|
+
const normalizedMode = normalizeScreenshotResolutionMode(screenshotResolutionMode);
|
|
21
|
+
const scale = normalizedMode === SCREENSHOT_RESOLUTION_MODE_NATIVE || width <= TARGET_SCALED_WIDTH
|
|
22
|
+
? 1.0
|
|
23
|
+
: TARGET_SCALED_WIDTH / width;
|
|
24
|
+
return {
|
|
25
|
+
scaled_width: Math.round(width * scale),
|
|
26
|
+
scaled_height: Math.round(height * scale),
|
|
27
|
+
scale,
|
|
28
|
+
screenshot_resolution_mode: normalizedMode,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function readScreenshotResolutionModeFromEnv() {
|
|
32
|
+
return normalizeScreenshotResolutionMode(process.env.DROID_CUA_SCREENSHOT_RESOLUTION_MODE);
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The model returns scroll actions with `scroll_x` / `scroll_y` plus optional
|
|
2
|
+
// anchor coordinates `x` / `y`. This helper converts those model coordinates
|
|
3
|
+
// into backend gesture coordinates: startX/startY and endX/endY.
|
|
4
|
+
export function resolveScrollGesture(action, { scale = 1.0, dpr = 1.0, fallbackStartX = 0, fallbackStartY = 0 } = {}) {
|
|
5
|
+
const divisor = scale * dpr;
|
|
6
|
+
const scrollX = Math.round((action?.scroll_x ?? 0) / divisor);
|
|
7
|
+
const scrollY = Math.round((action?.scroll_y ?? 0) / divisor);
|
|
8
|
+
const hasAnchor = Number.isFinite(action?.x) && Number.isFinite(action?.y);
|
|
9
|
+
const startX = hasAnchor ? Math.round(action.x / divisor) : Math.round(fallbackStartX);
|
|
10
|
+
const startY = hasAnchor ? Math.round(action.y / divisor) : Math.round(fallbackStartY);
|
|
11
|
+
return {
|
|
12
|
+
scrollX,
|
|
13
|
+
scrollY,
|
|
14
|
+
startX,
|
|
15
|
+
startY,
|
|
16
|
+
endX: startX + scrollX,
|
|
17
|
+
endY: startY - scrollY,
|
|
18
|
+
hasAnchor
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -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.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, writeFile, unlink, stat, mkdir } from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { countExecutableInstructions, parseTestScript } from "./test-script.js";
|
|
3
4
|
// Tests directory is relative to current working directory
|
|
4
5
|
const TESTS_DIR = path.join(process.cwd(), "tests");
|
|
5
6
|
/**
|
|
@@ -27,10 +28,7 @@ export async function loadTest(name) {
|
|
|
27
28
|
const filename = name.endsWith(".dcua") ? name : `${name}.dcua`;
|
|
28
29
|
const filepath = path.join(TESTS_DIR, filename);
|
|
29
30
|
const content = await readFile(filepath, "utf-8");
|
|
30
|
-
return content
|
|
31
|
-
.split("\n")
|
|
32
|
-
.map(line => line.trim())
|
|
33
|
-
.filter(line => line.length > 0);
|
|
31
|
+
return parseTestScript(content);
|
|
34
32
|
}
|
|
35
33
|
/**
|
|
36
34
|
* Get the raw content of a test file
|
|
@@ -60,7 +58,7 @@ export async function listTests() {
|
|
|
60
58
|
const filepath = path.join(TESTS_DIR, filename);
|
|
61
59
|
const stats = await stat(filepath);
|
|
62
60
|
const content = await readFile(filepath, "utf-8");
|
|
63
|
-
const lines = content
|
|
61
|
+
const lines = countExecutableInstructions(content);
|
|
64
62
|
return {
|
|
65
63
|
name: filename.replace(".dcua", ""),
|
|
66
64
|
filename: filename,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the first comment marker in a test script line.
|
|
3
|
+
* `//` starts a comment only at line start or when preceded by whitespace.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} line
|
|
6
|
+
* @returns {number}
|
|
7
|
+
*/
|
|
8
|
+
export function findCommentStart(line) {
|
|
9
|
+
for (let i = 0; i < line.length - 1; i++) {
|
|
10
|
+
if (line[i] !== "/" || line[i + 1] !== "/") {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (i === 0 || /\s/.test(line[i - 1])) {
|
|
14
|
+
return i;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return -1;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Remove inline comments and surrounding instruction whitespace from a line.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} line
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function stripInstructionComment(line) {
|
|
26
|
+
const commentStart = findCommentStart(line);
|
|
27
|
+
const instruction = commentStart >= 0 ? line.slice(0, commentStart) : line;
|
|
28
|
+
return instruction.trim();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse executable instructions from raw .dcua content.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} content
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
export function parseTestScript(content) {
|
|
37
|
+
return content
|
|
38
|
+
.split("\n")
|
|
39
|
+
.map(stripInstructionComment)
|
|
40
|
+
.filter((line) => line.length > 0);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Count executable instructions in raw .dcua content.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} content
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
export function countExecutableInstructions(content) {
|
|
49
|
+
return parseTestScript(content).length;
|
|
50
|
+
}
|
|
@@ -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;
|