@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.
@@ -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: `Tapping at (${targetX}, ${targetY})`, ...meta({ x: targetX, y: targetY }) });
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 = Math.round((action.scroll_x / scale) / dpr);
97
- const scrollY = Math.round((action.scroll_y / scale) / dpr);
98
- const centerX = 200;
99
- const centerY = 400;
100
- const endX = centerX + scrollX;
101
- const endY = centerY - scrollY;
102
- addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY})`, ...meta({ scrollX, scrollY }) });
103
- await session.client.scroll(session.sessionId, centerX, centerY, endX, endY);
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: `Tapping at (${pointX}, ${pointY}) points`, ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
88
+ addOutput({ type: "action", text: "Click", ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
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 = Math.round((action.scroll_x / scale) / dpr);
96
- const scrollY = Math.round((action.scroll_y / scale) / dpr);
97
- addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY}) points`, ...meta({ scrollX, scrollY, unit: "points" }) });
98
- // Start from center of screen (in logical points)
99
- const centerX = 197; // Center of iPhone 16 (393/2)
100
- const centerY = 426; // Center of iPhone 16 (852/2)
101
- const endX = centerX + scrollX;
102
- const endY = centerY - scrollY; // Invert Y for natural scrolling
103
- await appium.scroll(session.sessionId, centerX, centerY, endX, endY);
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 normalizePreviewMessages(messages, screenshotBase64, includeInitialScreenshot) {
12
- const normalized = messages.map((message) => {
13
- if (!message?.role) {
14
- return message;
15
- }
16
- const contentItems = Array.isArray(message.content)
17
- ? message.content.map((item) => {
18
- if (typeof item === "string") {
19
- return { type: "input_text", text: item };
20
- }
21
- return item;
22
- })
23
- : [{ type: "input_text", text: String(message.content ?? "") }];
24
- return {
25
- role: message.role,
26
- content: contentItems,
27
- };
28
- });
29
- if (!includeInitialScreenshot || !screenshotBase64) {
30
- return normalized;
11
+ function normalizeMessageContent(content) {
12
+ if (Array.isArray(content)) {
13
+ return content.map((item) => {
14
+ if (typeof item === "string") {
15
+ return { type: "input_text", text: item };
16
+ }
17
+ return item;
18
+ });
31
19
  }
32
- for (let index = normalized.length - 1; index >= 0; index -= 1) {
33
- const item = normalized[index];
20
+ return [{ type: "input_text", text: String(content ?? "") }];
21
+ }
22
+ function buildScreenshotDataUrl(screenshotBase64) {
23
+ return `data:image/png;base64,${screenshotBase64}`;
24
+ }
25
+ const previewClickTool = {
26
+ type: "function",
27
+ name: "preview_click",
28
+ description: "Preview where a proposed click would land on the current device screen. Returns an annotated screenshot with a marker at the given coordinates; the device state is unchanged and the click is not executed. Use this before issuing a click action to verify your aim.",
29
+ strict: true,
30
+ parameters: {
31
+ type: "object",
32
+ properties: {
33
+ x: { type: "number" },
34
+ y: { type: "number" },
35
+ },
36
+ required: ["x", "y"],
37
+ additionalProperties: false,
38
+ },
39
+ };
40
+ function appendInitialScreenshot(input, screenshotBase64) {
41
+ // Most callers end with the active user message, but some recovery flows can
42
+ // reset the conversation to system-only state before the next screenshot send.
43
+ // Attach to the last user message when present, otherwise synthesize one below.
44
+ for (let index = input.length - 1; index >= 0; index -= 1) {
45
+ const item = input[index];
34
46
  if (item?.role !== "user" || !Array.isArray(item.content))
35
47
  continue;
36
48
  item.content.push({
37
49
  type: "input_image",
38
- image_url: `data:image/png;base64,${screenshotBase64}`,
50
+ image_url: buildScreenshotDataUrl(screenshotBase64),
39
51
  });
40
- return normalized;
52
+ return;
41
53
  }
42
- normalized.push({
54
+ input.push({
43
55
  role: "user",
44
56
  content: [{
45
57
  type: "input_image",
46
- image_url: `data:image/png;base64,${screenshotBase64}`,
58
+ image_url: buildScreenshotDataUrl(screenshotBase64),
47
59
  }],
48
60
  });
49
- return normalized;
50
61
  }
51
- function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
62
+ export function buildCuaInput({ messages, screenshotBase64, previousResponseId, callId }) {
63
+ const input = messages.map((message) => {
64
+ if (!message?.role) {
65
+ return message;
66
+ }
67
+ return {
68
+ role: message.role,
69
+ content: normalizeMessageContent(message.content),
70
+ };
71
+ });
72
+ if (!screenshotBase64) {
73
+ return input;
74
+ }
75
+ if (callId) {
76
+ input.push({
77
+ type: "computer_call_output",
78
+ call_id: callId,
79
+ output: {
80
+ type: "computer_screenshot",
81
+ image_url: buildScreenshotDataUrl(screenshotBase64),
82
+ },
83
+ });
84
+ return input;
85
+ }
86
+ if (!previousResponseId) {
87
+ appendInitialScreenshot(input, screenshotBase64);
88
+ }
89
+ return input;
90
+ }
91
+ export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
52
92
  if (cuaModel === "computer-use-preview") {
53
93
  return {
54
94
  model: cuaModel,
@@ -56,24 +96,31 @@ function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input
56
96
  input,
57
97
  store: true,
58
98
  truncation: "auto",
59
- tools: [{
99
+ tools: [
100
+ {
60
101
  type: "computer_use_preview",
61
102
  display_width: deviceInfo.scaled_width,
62
103
  display_height: deviceInfo.scaled_height,
63
104
  environment: "browser",
64
- }],
105
+ },
106
+ previewClickTool,
107
+ ],
65
108
  reasoning: { summary: "concise" },
66
109
  };
67
110
  }
68
111
  return {
69
112
  model: cuaModel,
113
+ reasoning: { effort: "medium" },
70
114
  previous_response_id: previousResponseId || undefined,
71
115
  input,
72
116
  store: true,
73
117
  truncation: "auto",
74
- tools: [{
118
+ tools: [
119
+ {
75
120
  type: "computer",
76
- }],
121
+ },
122
+ previewClickTool,
123
+ ],
77
124
  };
78
125
  }
79
126
  function shouldMapPreviewAccessError(err) {
@@ -153,20 +200,7 @@ export async function compactAppContext({ contextDocument, taskDescription, toke
153
200
  }
154
201
  export async function sendCUARequest({ messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
155
202
  const cuaModel = getSelectedCuaModel();
156
- const includeInitialScreenshot = cuaModel === "computer-use-preview" && !previousResponseId && !callId;
157
- const input = cuaModel === "computer-use-preview"
158
- ? normalizePreviewMessages(messages, screenshotBase64, includeInitialScreenshot)
159
- : [...messages];
160
- if (callId && screenshotBase64) {
161
- input.push({
162
- type: "computer_call_output",
163
- call_id: callId,
164
- output: {
165
- type: "computer_screenshot",
166
- image_url: `data:image/png;base64,${screenshotBase64}`,
167
- },
168
- });
169
- }
203
+ const input = buildCuaInput({ messages, screenshotBase64, previousResponseId, callId });
170
204
  const requestParams = buildCuaRequestParams({
171
205
  cuaModel,
172
206
  previousResponseId,
@@ -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.split("\n").filter(line => line.trim().length > 0).length;
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?.computerCallIds) ? lastResponseMeta.computerCallIds : [];
219
+ const lastResponseCallIds = Array.isArray(lastResponseMeta?.toolCallIds) ? lastResponseMeta.toolCallIds : [];
183
220
  const missingCallIds = inputCallIds.filter((callId) => !lastResponseCallIds.includes(callId));
184
221
  const previousResponseIdMatchesLastResponseId = !previousResponseId || !lastResponseMeta?.id ? null : previousResponseId === lastResponseMeta.id;
185
222
  const allInputCallIdsFoundInLastResponse = inputCallIds.length === 0 ? true : missingCallIds.length === 0;
@@ -252,10 +289,12 @@ export class CuaDebugTracer {
252
289
  onResponse(trace, response) {
253
290
  const outputTypes = (response.output || []).map((item) => item.type);
254
291
  const toolCalls = (response.output || [])
255
- .filter((item) => item.type === "computer_call")
292
+ .filter((item) => item.type === "computer_call" || item.type === "function_call")
256
293
  .map((item) => ({
257
294
  call_id: item.call_id,
258
- action_type: item.action?.type
295
+ action_type: item.action?.type,
296
+ name: item.name,
297
+ type: item.type
259
298
  }));
260
299
  const safetyChecks = (response.output || [])
261
300
  .filter((item) => item.type === "pending_safety_check")
@@ -268,7 +307,11 @@ export class CuaDebugTracer {
268
307
  this.lastResponseMetaByChain.set(trace.chainKey, {
269
308
  id: response.id,
270
309
  outputTypes,
271
- computerCallIds: toolCalls.map((item) => item.call_id).filter(Boolean),
310
+ toolCallIds: toolCalls.map((item) => item.call_id).filter(Boolean),
311
+ computerCallIds: toolCalls
312
+ .filter((item) => item.type === "computer_call")
313
+ .map((item) => item.call_id)
314
+ .filter(Boolean),
272
315
  pendingSafetyCheckIds: safetyChecks.map((item) => item.id).filter(Boolean)
273
316
  });
274
317
  const accountedItems = toolCalls.length + safetyChecks.length;