@loadmill/droid-cua 2.5.0 → 2.7.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.
@@ -54,6 +54,9 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
54
54
  stepId: context?.stepId,
55
55
  instructionIndex: context?.instructionIndex,
56
56
  payload: {
57
+ eventId: context?.eventId,
58
+ screenshotPath: context?.screenshotPath,
59
+ screenshotDataUrl: context?.screenshotDataUrl,
57
60
  platform,
58
61
  source: session?.providerId,
59
62
  ...payload
@@ -84,7 +87,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
84
87
  case "double_click": {
85
88
  const targetX = Math.round((action.x / scale) / dpr);
86
89
  const targetY = Math.round((action.y / scale) / dpr);
87
- addOutput({ type: "action", text: `Tapping at (${targetX}, ${targetY})`, ...meta({ x: targetX, y: targetY }) });
90
+ addOutput({ type: "action", text: "Click", ...meta({ x: targetX, y: targetY }) });
88
91
  await session.client.tap(session.sessionId, targetX, targetY);
89
92
  break;
90
93
  }
@@ -181,6 +184,7 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
181
184
  stepId: context?.stepId,
182
185
  instructionIndex: context?.instructionIndex,
183
186
  payload: {
187
+ eventId: context?.eventId,
184
188
  message: error.message,
185
189
  platform,
186
190
  source: session.providerId
@@ -51,6 +51,9 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
51
51
  stepId: context?.stepId,
52
52
  instructionIndex: context?.instructionIndex,
53
53
  payload: {
54
+ eventId: context?.eventId,
55
+ screenshotPath: context?.screenshotPath,
56
+ screenshotDataUrl: context?.screenshotDataUrl,
54
57
  platform: "ios",
55
58
  ...payload
56
59
  }
@@ -82,7 +85,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
82
85
  const pixelY = Math.round(action.y / scale);
83
86
  const pointX = Math.round(pixelX / dpr);
84
87
  const pointY = Math.round(pixelY / dpr);
85
- addOutput({ type: "action", text: `Tapping at (${pointX}, ${pointY}) points`, ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
88
+ addOutput({ type: "action", text: "Click", ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
86
89
  await appium.tap(session.sessionId, pointX, pointY);
87
90
  break;
88
91
  }
@@ -180,6 +183,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
180
183
  stepId: context?.stepId,
181
184
  instructionIndex: context?.instructionIndex,
182
185
  payload: {
186
+ eventId: context?.eventId,
183
187
  message: error.message,
184
188
  platform: "ios"
185
189
  }
@@ -7,6 +7,7 @@
7
7
  import { spawn } from "child_process";
8
8
  import { existsSync, readdirSync } from "fs";
9
9
  import path from "path";
10
+ import { COMMON_NODE_BIN_DIRS, makeNodeCliCommandEnv } from "./node-cli-env.js";
10
11
  let appiumProcess = null;
11
12
  let cleanupRegistered = false;
12
13
  function getAppiumBaseUrl() {
@@ -35,8 +36,7 @@ export function resolveAppiumCommand() {
35
36
  return explicit;
36
37
  }
37
38
  const commonCandidates = [
38
- "/opt/homebrew/bin/appium",
39
- "/usr/local/bin/appium",
39
+ ...COMMON_NODE_BIN_DIRS.map((dir) => path.join(dir, "appium")),
40
40
  ...collectNvmAppiumCandidates()
41
41
  ];
42
42
  for (const candidate of commonCandidates) {
@@ -50,7 +50,8 @@ async function spawnAppiumProcess(command) {
50
50
  return await new Promise((resolve, reject) => {
51
51
  const child = spawn(command, [], {
52
52
  detached: true,
53
- stdio: "ignore"
53
+ stdio: "ignore",
54
+ env: makeNodeCliCommandEnv(command)
54
55
  });
55
56
  child.once("error", reject);
56
57
  child.once("spawn", () => resolve(child));
@@ -0,0 +1,28 @@
1
+ import path from "path";
2
+ export const COMMON_NODE_BIN_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin"];
3
+ function prependPathEntries(entries, originalPath) {
4
+ const seen = new Set();
5
+ const next = [];
6
+ const allEntries = [
7
+ ...entries,
8
+ ...(originalPath
9
+ ?.split(path.delimiter)
10
+ .map((entry) => entry.trim())
11
+ .filter(Boolean) ?? [])
12
+ ];
13
+ for (const entry of allEntries) {
14
+ if (seen.has(entry))
15
+ continue;
16
+ seen.add(entry);
17
+ next.push(entry);
18
+ }
19
+ return next.join(path.delimiter);
20
+ }
21
+ export function makeNodeCliCommandEnv(command) {
22
+ const binDirs = path.isAbsolute(command) ? [path.dirname(command), ...COMMON_NODE_BIN_DIRS] : COMMON_NODE_BIN_DIRS;
23
+ return {
24
+ ...process.env,
25
+ // Finder-launched apps may miss Homebrew paths; npm/Appium shims use "#!/usr/bin/env node".
26
+ PATH: prependPathEntries(binDirs, process.env.PATH)
27
+ };
28
+ }
@@ -1,54 +1,107 @@
1
1
  import OpenAI from "openai";
2
+ import { readContextOptimizationEnv } from "../core/context-optimization.js";
2
3
  import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
3
4
  import { buildAppContextCompactionInput } from "../prompts/execution.js";
4
5
  import { logger } from "../utils/logger.js";
5
6
  import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
6
7
  let openai = null;
7
8
  const cuaDebugTracer = new CuaDebugTracer(logger);
9
+ const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
8
10
  function getSelectedCuaModel() {
9
- return process.env.OPENAI_CUA_MODEL === "computer-use-preview" ? "computer-use-preview" : "gpt-5.4";
11
+ return VALID_CUA_MODELS.has(process.env.OPENAI_CUA_MODEL) ? process.env.OPENAI_CUA_MODEL : "gpt-5.4";
10
12
  }
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;
13
+ function normalizeMessageContent(content) {
14
+ if (Array.isArray(content)) {
15
+ return content.map((item) => {
16
+ if (typeof item === "string") {
17
+ return { type: "input_text", text: item };
18
+ }
19
+ return item;
20
+ });
31
21
  }
32
- for (let index = normalized.length - 1; index >= 0; index -= 1) {
33
- const item = normalized[index];
22
+ return [{ type: "input_text", text: String(content ?? "") }];
23
+ }
24
+ function buildScreenshotDataUrl(screenshotBase64) {
25
+ return `data:image/png;base64,${screenshotBase64}`;
26
+ }
27
+ const previewClickTool = {
28
+ type: "function",
29
+ name: "preview_click",
30
+ 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.",
31
+ strict: true,
32
+ parameters: {
33
+ type: "object",
34
+ properties: {
35
+ x: { type: "number" },
36
+ y: { type: "number" },
37
+ },
38
+ required: ["x", "y"],
39
+ additionalProperties: false,
40
+ },
41
+ };
42
+ function appendInitialScreenshot(input, screenshotBase64) {
43
+ // Most callers end with the active user message, but some recovery flows can
44
+ // reset the conversation to system-only state before the next screenshot send.
45
+ // Attach to the last user message when present, otherwise synthesize one below.
46
+ for (let index = input.length - 1; index >= 0; index -= 1) {
47
+ const item = input[index];
34
48
  if (item?.role !== "user" || !Array.isArray(item.content))
35
49
  continue;
36
50
  item.content.push({
37
51
  type: "input_image",
38
- image_url: `data:image/png;base64,${screenshotBase64}`,
52
+ image_url: buildScreenshotDataUrl(screenshotBase64),
39
53
  });
40
- return normalized;
54
+ return;
41
55
  }
42
- normalized.push({
56
+ input.push({
43
57
  role: "user",
44
58
  content: [{
45
59
  type: "input_image",
46
- image_url: `data:image/png;base64,${screenshotBase64}`,
60
+ image_url: buildScreenshotDataUrl(screenshotBase64),
47
61
  }],
48
62
  });
49
- return normalized;
50
63
  }
51
- function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
64
+ export function buildCuaInput({ messages, screenshotBase64, previousResponseId, callId }) {
65
+ const input = messages.map((message) => {
66
+ if (!message?.role) {
67
+ return message;
68
+ }
69
+ return {
70
+ role: message.role,
71
+ content: normalizeMessageContent(message.content),
72
+ };
73
+ });
74
+ if (!screenshotBase64) {
75
+ return input;
76
+ }
77
+ if (callId) {
78
+ input.push({
79
+ type: "computer_call_output",
80
+ call_id: callId,
81
+ output: {
82
+ type: "computer_screenshot",
83
+ image_url: buildScreenshotDataUrl(screenshotBase64),
84
+ },
85
+ });
86
+ return input;
87
+ }
88
+ if (!previousResponseId) {
89
+ appendInitialScreenshot(input, screenshotBase64);
90
+ }
91
+ return input;
92
+ }
93
+ function buildContextManagement(cuaModel) {
94
+ if (cuaModel === "computer-use-preview") {
95
+ return undefined;
96
+ }
97
+ const { enabled, threshold } = readContextOptimizationEnv();
98
+ if (!enabled) {
99
+ return undefined;
100
+ }
101
+ return [{ type: "compaction", compact_threshold: threshold }];
102
+ }
103
+ export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
104
+ const contextManagement = buildContextManagement(cuaModel);
52
105
  if (cuaModel === "computer-use-preview") {
53
106
  return {
54
107
  model: cuaModel,
@@ -56,24 +109,32 @@ function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input
56
109
  input,
57
110
  store: true,
58
111
  truncation: "auto",
59
- tools: [{
112
+ tools: [
113
+ {
60
114
  type: "computer_use_preview",
61
115
  display_width: deviceInfo.scaled_width,
62
116
  display_height: deviceInfo.scaled_height,
63
117
  environment: "browser",
64
- }],
118
+ },
119
+ previewClickTool,
120
+ ],
65
121
  reasoning: { summary: "concise" },
66
122
  };
67
123
  }
68
124
  return {
69
125
  model: cuaModel,
126
+ reasoning: { effort: "medium" },
127
+ ...(contextManagement ? { context_management: contextManagement } : {}),
70
128
  previous_response_id: previousResponseId || undefined,
71
129
  input,
72
130
  store: true,
73
131
  truncation: "auto",
74
- tools: [{
132
+ tools: [
133
+ {
75
134
  type: "computer",
76
- }],
135
+ },
136
+ previewClickTool,
137
+ ],
77
138
  };
78
139
  }
79
140
  function shouldMapPreviewAccessError(err) {
@@ -90,12 +151,41 @@ function shouldMapPreviewAccessError(err) {
90
151
  return (message.includes("computer-use-preview") &&
91
152
  (message.includes("access") || message.includes("permission") || message.includes("not found") || message.includes("unsupported")));
92
153
  }
154
+ export function shouldMapMiniCompatibilityError(err) {
155
+ const status = Number(err?.status);
156
+ const code = typeof err?.code === "string" ? err.code.toLowerCase() : "";
157
+ const type = typeof err?.type === "string" ? err.type.toLowerCase() : "";
158
+ const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
159
+ const mentionsMiniModel = message.includes("gpt-5.4-mini");
160
+ const mentionsComputerTool = message.includes("computer") || message.includes("tool");
161
+ const mentionsUnsupported = (message.includes("unsupported") ||
162
+ message.includes("not supported") ||
163
+ message.includes("does not support"));
164
+ if (code.includes("model_not_found")) {
165
+ return true;
166
+ }
167
+ if (status === 404) {
168
+ return mentionsMiniModel && (message.includes("not found") || mentionsUnsupported);
169
+ }
170
+ if (code.includes("unsupported")) {
171
+ return mentionsMiniModel || mentionsComputerTool;
172
+ }
173
+ if (!type.includes("invalid")) {
174
+ return false;
175
+ }
176
+ return mentionsUnsupported && (mentionsMiniModel || mentionsComputerTool);
177
+ }
93
178
  function mapCuaError(err, cuaModel) {
94
179
  if (cuaModel === "computer-use-preview" && shouldMapPreviewAccessError(err)) {
95
180
  const mapped = new Error("OpenAI API key does not have access to computer-use-preview. Switch to gpt-5.4 in Settings > CUA Model.");
96
181
  mapped.cause = err;
97
182
  return mapped;
98
183
  }
184
+ if (cuaModel === "gpt-5.4-mini" && shouldMapMiniCompatibilityError(err)) {
185
+ const mapped = new Error("OpenAI rejected gpt-5.4-mini for computer-use execution. Switch to gpt-5.4 or computer-use-preview in Settings > CUA Model.");
186
+ mapped.cause = err;
187
+ return mapped;
188
+ }
99
189
  return err;
100
190
  }
101
191
  export function isNonRetryableCuaError(err) {
@@ -110,7 +200,9 @@ export function isNonRetryableCuaError(err) {
110
200
  code.includes("permission") ||
111
201
  type.includes("permission") ||
112
202
  message.includes("does not have access to computer-use-preview") ||
113
- message.includes("switch to gpt-5.4 in settings > cua model"));
203
+ message.includes("switch to gpt-5.4 in settings > cua model") ||
204
+ message.includes("openai rejected gpt-5.4-mini for computer-use execution") ||
205
+ message.includes("switch to gpt-5.4 or computer-use-preview in settings > cua model"));
114
206
  }
115
207
  function getOpenAI() {
116
208
  if (!openai) {
@@ -153,20 +245,7 @@ export async function compactAppContext({ contextDocument, taskDescription, toke
153
245
  }
154
246
  export async function sendCUARequest({ messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
155
247
  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
- }
248
+ const input = buildCuaInput({ messages, screenshotBase64, previousResponseId, callId });
170
249
  const requestParams = buildCuaRequestParams({
171
250
  cuaModel,
172
251
  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,12 @@ 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
+ - Click if the marker is clearly inside the intended tappable control. Text inside a button, field, row, or bottom bar is okay.
68
+ - Adjust and preview again only if the marker is outside the target, on the target border/edge, or closer to a neighboring target than the intended one.
69
+ - If you preview more than once, the next click should use the most recent accepted preview coordinates unless the screen changed.
70
+ - If the click does not cause the expected UI change, treat it as a likely misclick and try a meaningfully different coordinate.
65
71
  - Click precisely based on your visual estimate.
66
72
 
67
73
  Available actions: click, scroll, type, keypress, wait, screenshot.
@@ -63,6 +63,19 @@ function summarizeRequestInput(input) {
63
63
  hasSafetyChecks: Array.isArray(item.acknowledged_safety_checks) && item.acknowledged_safety_checks.length > 0
64
64
  };
65
65
  }
66
+ if (item?.type === "function_call_output") {
67
+ const content = Array.isArray(item.output) ? item.output : [];
68
+ const imageBytes = content
69
+ .filter((entry) => entry?.type === "input_image" && typeof entry.image_url === "string")
70
+ .map((entry) => entry.image_url.replace("data:image/png;base64,", "").length);
71
+ return {
72
+ type: "function_call_output",
73
+ callId: item.call_id,
74
+ contentTypes: content.map((entry) => entry?.type).filter(Boolean),
75
+ imageBytes,
76
+ text: summarizeContent(content),
77
+ };
78
+ }
66
79
  return {
67
80
  type: item?.type || "unknown"
68
81
  };
@@ -85,6 +98,21 @@ function redactRequestForFileLog(requestParams) {
85
98
  acknowledged_safety_checks: item.acknowledged_safety_checks
86
99
  };
87
100
  }
101
+ if (item.type === "function_call_output" && Array.isArray(item.output)) {
102
+ return {
103
+ ...item,
104
+ output: item.output.map((entry) => {
105
+ if (entry?.type === "input_image" && typeof entry.image_url === "string") {
106
+ const base64Data = entry.image_url.replace("data:image/png;base64,", "");
107
+ return {
108
+ ...entry,
109
+ image_url: `data:image/png;base64,[${base64Data.length} chars]`
110
+ };
111
+ }
112
+ return entry;
113
+ })
114
+ };
115
+ }
88
116
  return item;
89
117
  })
90
118
  };
@@ -117,6 +145,15 @@ function summarizeResponseOutput(response) {
117
145
  actionType: item.action?.type
118
146
  };
119
147
  }
148
+ if (item.type === "function_call") {
149
+ return {
150
+ type: "function_call",
151
+ id: item.id,
152
+ callId: item.call_id,
153
+ name: item.name,
154
+ arguments: truncateForDebug(item.arguments, 300)
155
+ };
156
+ }
120
157
  if (item.type === "pending_safety_check") {
121
158
  return { type: "pending_safety_check", id: item.id, code: item.code };
122
159
  }
@@ -175,11 +212,11 @@ export class CuaDebugTracer {
175
212
  }));
176
213
  });
177
214
  const inputCallIds = input
178
- .filter((item) => item?.type === "computer_call_output")
215
+ .filter((item) => item?.type === "computer_call_output" || item?.type === "function_call_output")
179
216
  .map((item) => item.call_id)
180
217
  .filter(Boolean);
181
218
  const inputItemTypes = input.map((item) => item?.type || (item?.role ? `message:${item.role}` : "unknown"));
182
- const lastResponseCallIds = Array.isArray(lastResponseMeta?.computerCallIds) ? lastResponseMeta.computerCallIds : [];
219
+ const lastResponseCallIds = Array.isArray(lastResponseMeta?.toolCallIds) ? lastResponseMeta.toolCallIds : [];
183
220
  const missingCallIds = inputCallIds.filter((callId) => !lastResponseCallIds.includes(callId));
184
221
  const previousResponseIdMatchesLastResponseId = !previousResponseId || !lastResponseMeta?.id ? null : previousResponseId === lastResponseMeta.id;
185
222
  const allInputCallIdsFoundInLastResponse = inputCallIds.length === 0 ? true : missingCallIds.length === 0;
@@ -252,10 +289,12 @@ export class CuaDebugTracer {
252
289
  onResponse(trace, response) {
253
290
  const outputTypes = (response.output || []).map((item) => item.type);
254
291
  const toolCalls = (response.output || [])
255
- .filter((item) => item.type === "computer_call")
292
+ .filter((item) => item.type === "computer_call" || item.type === "function_call")
256
293
  .map((item) => ({
257
294
  call_id: item.call_id,
258
- action_type: item.action?.type
295
+ action_type: item.action?.type,
296
+ name: item.name,
297
+ type: item.type
259
298
  }));
260
299
  const safetyChecks = (response.output || [])
261
300
  .filter((item) => item.type === "pending_safety_check")
@@ -268,7 +307,11 @@ export class CuaDebugTracer {
268
307
  this.lastResponseMetaByChain.set(trace.chainKey, {
269
308
  id: response.id,
270
309
  outputTypes,
271
- computerCallIds: toolCalls.map((item) => item.call_id).filter(Boolean),
310
+ toolCallIds: toolCalls.map((item) => item.call_id).filter(Boolean),
311
+ computerCallIds: toolCalls
312
+ .filter((item) => item.type === "computer_call")
313
+ .map((item) => item.call_id)
314
+ .filter(Boolean),
272
315
  pendingSafetyCheckIds: safetyChecks.map((item) => item.id).filter(Boolean)
273
316
  });
274
317
  const accountedItems = toolCalls.length + safetyChecks.length;