@loadmill/droid-cua 2.21.0 → 2.23.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.
Files changed (34) hide show
  1. package/build/index.js +31 -1
  2. package/build/src/browser/playwright/actions.js +65 -1
  3. package/build/src/cli/headless-runner.js +174 -12
  4. package/build/src/commands/help.js +1 -1
  5. package/build/src/core/cua-models.js +11 -4
  6. package/build/src/core/droid-cua-run-usage.js +106 -0
  7. package/build/src/core/droid-cua-segment-events.js +32 -0
  8. package/build/src/core/execution-engine.js +334 -5
  9. package/build/src/core/prompts.js +1 -1
  10. package/build/src/core/report/render-html-report.js +8 -3
  11. package/build/src/core/report/run-log-model.js +42 -0
  12. package/build/src/core/runtime-config.js +24 -39
  13. package/build/src/core/session.js +6 -0
  14. package/build/src/core/tool-handlers/loadmill.js +3 -0
  15. package/build/src/core/tool-handlers/test-design-complete.js +1 -0
  16. package/build/src/core/tool-handlers/type-sensitive-text.js +1 -0
  17. package/build/src/device/android/actions.js +6 -2
  18. package/build/src/device/android/connection.js +58 -23
  19. package/build/src/device/assertions.js +15 -8
  20. package/build/src/device/ios/connection.js +36 -30
  21. package/build/src/device/openai.js +68 -2
  22. package/build/src/device/scroll-gesture.js +22 -9
  23. package/build/src/integrations/loadmill/client.js +62 -0
  24. package/build/src/integrations/loadmill/run-reporter.js +113 -0
  25. package/build/src/integrations/loadmill/session-user.js +39 -0
  26. package/build/src/integrations/segment/droid-cua-segment.js +64 -0
  27. package/build/src/modes/design-mode-ink.js +10 -1
  28. package/build/src/modes/design-mode.js +7 -1
  29. package/build/src/modes/execution-mode.js +84 -8
  30. package/build/src/prompts/design.js +21 -1
  31. package/build/src/utils/console-output.js +5 -0
  32. package/build/src/utils/cua-debug-tracer.js +50 -7
  33. package/package.json +2 -1
  34. package/build/src/core/cua-backends.js +0 -11
@@ -19,6 +19,51 @@ import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
19
19
  import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
20
20
  import { handleTypeSensitiveTextToolCall } from "./tool-handlers/type-sensitive-text.js";
21
21
  import { buildDebugContext, buildTextToolOutputItem, } from "./tool-handlers/utils.js";
22
+ const HOLO_RESPONSE_ID_PREFIX = "resp_holo_";
23
+ function isHoloResponseId(responseId) {
24
+ return typeof responseId === "string" && responseId.startsWith(HOLO_RESPONSE_ID_PREFIX);
25
+ }
26
+ function isImageOutputChunk(chunk) {
27
+ if (!chunk || typeof chunk !== "object") {
28
+ return false;
29
+ }
30
+ if (chunk.type !== "input_image" && chunk.type !== "image_url") {
31
+ return false;
32
+ }
33
+ if (typeof chunk.image_url === "string") {
34
+ return chunk.image_url.trim().length > 0;
35
+ }
36
+ return typeof chunk.image_url?.url === "string" && chunk.image_url.url.trim().length > 0;
37
+ }
38
+ function functionCallOutputHasImage(outputItem) {
39
+ if (outputItem?.type !== "function_call_output") {
40
+ return false;
41
+ }
42
+ const chunks = [];
43
+ if (Array.isArray(outputItem.output)) {
44
+ chunks.push(...outputItem.output);
45
+ }
46
+ if (Array.isArray(outputItem.content)) {
47
+ chunks.push(...outputItem.content);
48
+ }
49
+ return chunks.some((chunk) => isImageOutputChunk(chunk));
50
+ }
51
+ function appendImageToFunctionCallOutput(outputItem, screenshotBase64) {
52
+ if (outputItem?.type !== "function_call_output" || typeof screenshotBase64 !== "string" || !screenshotBase64) {
53
+ return outputItem;
54
+ }
55
+ return {
56
+ ...outputItem,
57
+ output: [
58
+ ...(Array.isArray(outputItem.output) ? outputItem.output : []),
59
+ {
60
+ type: "input_image",
61
+ image_url: `data:image/png;base64,${screenshotBase64}`,
62
+ detail: "high",
63
+ },
64
+ ],
65
+ };
66
+ }
22
67
  function extractActionableOutputItems(items) {
23
68
  const entries = [];
24
69
  for (const item of items) {
@@ -58,6 +103,129 @@ function collectPendingSafetyChecks(items = []) {
58
103
  .filter((item) => item.type === "pending_safety_check")
59
104
  .map((item) => ({ id: item.id }));
60
105
  }
106
+ const MAX_OBSERVATION_ONLY_TURNS_WITHOUT_ACTION = 10;
107
+ const OBSERVATION_ONLY_RECOVERY_TURN = 5;
108
+ const MAX_RECOVERY_ACTION_SUMMARIES = 8;
109
+ const OBSERVATION_ONLY_RECOVERY_PROMPT = [
110
+ "You have repeatedly requested screenshots/previews without taking a device action.",
111
+ "Perform the next real action, choose a meaningfully different action, or stop if this instruction is complete.",
112
+ "Do not request another screenshot or preview unless you need it after changing the UI.",
113
+ ].join(" ");
114
+ function isObservationOnlyActionableItem(actionableItem) {
115
+ if (actionableItem?.type === "function_call") {
116
+ return actionableItem.name === "preview_click";
117
+ }
118
+ if (actionableItem?.type !== "computer_call") {
119
+ return false;
120
+ }
121
+ return actionableItem.actions.length > 0 &&
122
+ actionableItem.actions.every((action) => action?.type === "screenshot");
123
+ }
124
+ function parseObservationToolArguments(argumentsText) {
125
+ if (typeof argumentsText !== "string" || !argumentsText.trim()) {
126
+ return null;
127
+ }
128
+ try {
129
+ const parsed = JSON.parse(argumentsText);
130
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ function buildObservationOnlyPayload(actionableItem) {
137
+ if (actionableItem?.type === "function_call") {
138
+ const parsedArguments = parseObservationToolArguments(actionableItem.arguments);
139
+ return {
140
+ observationKind: "preview_click",
141
+ callId: actionableItem.call_id,
142
+ toolName: actionableItem.name,
143
+ ...(parsedArguments ? { toolArguments: parsedArguments } : {}),
144
+ };
145
+ }
146
+ return {
147
+ observationKind: "screenshot_only",
148
+ callId: actionableItem?.call_id,
149
+ actionTypes: actionableItem?.actions?.map((action) => action?.type).filter(Boolean) ?? [],
150
+ };
151
+ }
152
+ function truncateRecoveryText(text, maxLength = 80) {
153
+ if (typeof text !== "string")
154
+ return "";
155
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}...` : text;
156
+ }
157
+ function formatActionForRecovery(action) {
158
+ if (!action || action.type === "screenshot" || action.type === "wait") {
159
+ return null;
160
+ }
161
+ switch (action.type) {
162
+ case "click":
163
+ return "Clicked";
164
+ case "double_click":
165
+ return "Double-clicked";
166
+ case "type": {
167
+ if (action.sensitive) {
168
+ return `Typed secret${action.sensitiveKey ? `: ${action.sensitiveKey}` : ""}`;
169
+ }
170
+ const text = truncateRecoveryText(action.text);
171
+ return text ? `Typed ${JSON.stringify(text)}` : "Typed text";
172
+ }
173
+ case "keypress": {
174
+ const keys = Array.isArray(action.keys) ? action.keys.join("+") : action.key || action.keys;
175
+ return keys ? `Pressed ${keys}` : "Pressed a key";
176
+ }
177
+ case "scroll":
178
+ return "Scrolled";
179
+ default:
180
+ return null;
181
+ }
182
+ }
183
+ function buildObservationOnlyRecoveryMessage({ instruction = null, recentRealActions = [] } = {}) {
184
+ const content = [OBSERVATION_ONLY_RECOVERY_PROMPT];
185
+ if (typeof instruction === "string" && instruction.trim()) {
186
+ content.push(`Current instruction: ${instruction.trim()}`);
187
+ }
188
+ if (recentRealActions.length > 0) {
189
+ content.push([
190
+ "Recent real interactions:",
191
+ ...recentRealActions.map((actionSummary) => `- ${actionSummary}`),
192
+ ].join("\n"));
193
+ }
194
+ return {
195
+ role: "user",
196
+ content: content.join("\n\n"),
197
+ };
198
+ }
199
+ function getInstructionScrollDirection(instruction) {
200
+ if (typeof instruction !== "string") {
201
+ return null;
202
+ }
203
+ const normalizedInstruction = instruction.toLowerCase();
204
+ if (normalizedInstruction.includes("scroll down")) {
205
+ return 1;
206
+ }
207
+ if (normalizedInstruction.includes("scroll up")) {
208
+ return -1;
209
+ }
210
+ return null;
211
+ }
212
+ function normalizeScrollActionForInstruction(action, instruction) {
213
+ if (action?.type !== "scroll") {
214
+ return action;
215
+ }
216
+ const expectedDirection = getInstructionScrollDirection(instruction);
217
+ const scrollY = Number(action.scroll_y);
218
+ if (!expectedDirection || !Number.isFinite(scrollY) || scrollY === 0) {
219
+ return action;
220
+ }
221
+ if (Math.sign(scrollY) === expectedDirection) {
222
+ return action;
223
+ }
224
+ return {
225
+ ...action,
226
+ scroll_y: Math.abs(scrollY) * expectedDirection,
227
+ };
228
+ }
61
229
  export class ExecutionEngine {
62
230
  constructor(session, options = {}) {
63
231
  this.session = session;
@@ -169,9 +337,10 @@ export class ExecutionEngine {
169
337
  }
170
338
  }
171
339
  async requestNextTurn(messages, previousResponseId, context = null, stepContext = null, options = {}) {
340
+ const continuationMode = options.continuationModeOverride === "fresh" ? "fresh" : "auto";
172
341
  return await this.sendCUARequest({
173
342
  messages,
174
- previousResponseId,
343
+ previousResponseId: continuationMode === "fresh" ? null : previousResponseId,
175
344
  deviceInfo: this.session.deviceInfo,
176
345
  debugContext: options.debugContextOverride ?? buildDebugContext(context, stepContext),
177
346
  toolCapabilities: options.toolCapabilitiesOverride ?? this.toolCapabilities,
@@ -180,8 +349,21 @@ export class ExecutionEngine {
180
349
  }
181
350
  async executeActionBatch(actions, currentScreenshot, shouldStop, context = null, stepContext = null, trackAction = null) {
182
351
  let sawExplicitScreenshotAction = false;
183
- for (const action of actions) {
352
+ for (const rawAction of actions) {
353
+ const action = normalizeScrollActionForInstruction(rawAction, context?.currentInstruction);
184
354
  const actionEventId = randomUUID();
355
+ if (action !== rawAction) {
356
+ emitDesktopDebug("runner.action.normalized", "execution", {
357
+ runId: context?.runId,
358
+ stepId: stepContext?.stepId,
359
+ instructionIndex: stepContext?.instructionIndex
360
+ }, {
361
+ reason: "instruction_scroll_direction",
362
+ instruction: context?.currentInstruction,
363
+ originalAction: rawAction,
364
+ normalizedAction: action
365
+ });
366
+ }
185
367
  if (shouldStop()) {
186
368
  return { stopped: true, sawExplicitScreenshotAction };
187
369
  }
@@ -219,6 +401,18 @@ export class ExecutionEngine {
219
401
  shouldStop,
220
402
  stepId: stepContext?.stepId,
221
403
  instructionIndex: stepContext?.instructionIndex,
404
+ deviceWidth: Number.isFinite(this.session.deviceInfo?.device_width)
405
+ ? Math.round(this.session.deviceInfo.device_width)
406
+ : undefined,
407
+ deviceHeight: Number.isFinite(this.session.deviceInfo?.device_height)
408
+ ? Math.round(this.session.deviceInfo.device_height)
409
+ : undefined,
410
+ fallbackScrollStartX: Number.isFinite(this.session.deviceInfo?.device_width)
411
+ ? Math.round(this.session.deviceInfo.device_width / 2)
412
+ : undefined,
413
+ fallbackScrollStartY: Number.isFinite(this.session.deviceInfo?.device_height)
414
+ ? Math.round(this.session.deviceInfo.device_height / 2)
415
+ : undefined,
222
416
  eventId: actionEventId,
223
417
  captureSource: CAPTURE_SOURCES.ACTION_CLICK_ANNOTATION,
224
418
  ...(annotatedScreenshot?.screenshotPath ? { screenshotPath: annotatedScreenshot.screenshotPath } : {}),
@@ -366,7 +560,8 @@ export class ExecutionEngine {
366
560
  const addOutput = context?.addOutput || printCliOutput;
367
561
  let newResponseId = response.id;
368
562
  let currentScreenshot = options.currentScreenshot ?? null;
369
- const shouldStop = () => Boolean(trackAction?.());
563
+ const originalTrackAction = trackAction;
564
+ const shouldStop = () => Boolean(originalTrackAction?.());
370
565
  const summary = {
371
566
  responseId: newResponseId,
372
567
  invokedFunctionTools: [],
@@ -383,6 +578,93 @@ export class ExecutionEngine {
383
578
  ...extra
384
579
  });
385
580
  let refreshBeforeNextComputerAction = false;
581
+ let observationOnlyTurnsSinceLastAction = 0;
582
+ let observationOnlyRecoverySent = false;
583
+ let pendingObservationOnlyRecoveryMessage = null;
584
+ const recentRealActions = [];
585
+ const recordRecentRealAction = (action) => {
586
+ const summaryText = formatActionForRecovery(action);
587
+ if (!summaryText)
588
+ return;
589
+ recentRealActions.push(summaryText);
590
+ if (recentRealActions.length > MAX_RECOVERY_ACTION_SUMMARIES) {
591
+ recentRealActions.shift();
592
+ }
593
+ };
594
+ const recordRecentRealActions = (actions = []) => {
595
+ for (const action of actions)
596
+ recordRecentRealAction(action);
597
+ };
598
+ const wrappedTrackAction = (action = null) => {
599
+ if (action)
600
+ recordRecentRealAction(action);
601
+ return originalTrackAction?.(action);
602
+ };
603
+ const resetObservationOnlyStreak = () => {
604
+ observationOnlyTurnsSinceLastAction = 0;
605
+ observationOnlyRecoverySent = false;
606
+ pendingObservationOnlyRecoveryMessage = null;
607
+ };
608
+ const buildObservationLoopPayload = (actionableItem) => ({
609
+ reason: "observation_only_loop",
610
+ responseId: newResponseId,
611
+ observationOnlyTurnsSinceLastAction,
612
+ maxObservationOnlyTurnsWithoutAction: MAX_OBSERVATION_ONLY_TURNS_WITHOUT_ACTION,
613
+ recoveryTurn: OBSERVATION_ONLY_RECOVERY_TURN,
614
+ recoverySent: observationOnlyRecoverySent,
615
+ ...buildObservationOnlyPayload(actionableItem),
616
+ });
617
+ const queueObservationOnlyRecovery = (actionableItem) => {
618
+ if (observationOnlyRecoverySent ||
619
+ observationOnlyTurnsSinceLastAction < OBSERVATION_ONLY_RECOVERY_TURN) {
620
+ return false;
621
+ }
622
+ observationOnlyRecoverySent = true;
623
+ pendingObservationOnlyRecoveryMessage = buildObservationOnlyRecoveryMessage({
624
+ instruction: stepContext?.instruction,
625
+ recentRealActions,
626
+ });
627
+ addOutput({
628
+ type: "warning",
629
+ text: "The agent seems confused. Trying to get it back on track.",
630
+ eventType: "system_message",
631
+ ...eventMeta({
632
+ payload: {
633
+ ...buildObservationLoopPayload(actionableItem),
634
+ recoveryPrompt: pendingObservationOnlyRecoveryMessage.content,
635
+ ...(typeof stepContext?.instruction === "string" && stepContext.instruction.trim()
636
+ ? { instruction: stepContext.instruction.trim() }
637
+ : {}),
638
+ ...(recentRealActions.length > 0 ? { recentRealActions: [...recentRealActions] } : {}),
639
+ },
640
+ }),
641
+ });
642
+ return true;
643
+ };
644
+ const failObservationOnlyLoop = (actionableItem) => {
645
+ const error = "The agent got too confused. Stopping the test.";
646
+ summary.failed = true;
647
+ summary.error = error;
648
+ summary.observationLoopDetected = true;
649
+ addOutput({
650
+ type: "error",
651
+ text: error,
652
+ eventType: "error",
653
+ ...eventMeta({
654
+ payload: buildObservationLoopPayload(actionableItem),
655
+ }),
656
+ });
657
+ newResponseId = null;
658
+ };
659
+ const updateObservationOnlyStreak = (actionableItem) => {
660
+ observationOnlyTurnsSinceLastAction += 1;
661
+ if (observationOnlyTurnsSinceLastAction >= MAX_OBSERVATION_ONLY_TURNS_WITHOUT_ACTION) {
662
+ failObservationOnlyLoop(actionableItem);
663
+ return true;
664
+ }
665
+ queueObservationOnlyRecovery(actionableItem);
666
+ return false;
667
+ };
386
668
  while (true) {
387
669
  // Check for interruption before processing next batch of actions
388
670
  if (shouldStop()) {
@@ -409,6 +691,10 @@ export class ExecutionEngine {
409
691
  if (!actionableItem?.call_id) {
410
692
  break;
411
693
  }
694
+ const observationOnlyTurn = isObservationOnlyActionableItem(actionableItem);
695
+ if (!observationOnlyTurn) {
696
+ resetObservationOnlyStreak();
697
+ }
412
698
  if (actionableItem.type === "function_call") {
413
699
  const toolResult = await this.handleFunctionToolCall(actionableItem.name, actionableItem.call_id, actionableItem.arguments, context, stepContext);
414
700
  summary.invokedFunctionTools.push(actionableItem.name);
@@ -445,15 +731,51 @@ export class ExecutionEngine {
445
731
  newResponseId = null;
446
732
  break;
447
733
  }
448
- const nextTurnMessages = [toolResult.outputItem];
734
+ if (Array.isArray(toolResult.executedActions)) {
735
+ recordRecentRealActions(toolResult.executedActions);
736
+ }
737
+ if (observationOnlyTurn && updateObservationOnlyStreak(actionableItem)) {
738
+ break;
739
+ }
449
740
  const nextTurnConfig = toolResult.nextTurnConfig ?? {};
741
+ let nextTurnOutputItem = toolResult.outputItem;
742
+ if (nextTurnConfig.attachFreshScreenshotOnContinuation === true
743
+ && isHoloResponseId(newResponseId)
744
+ && nextTurnOutputItem?.type === "function_call_output"
745
+ && !functionCallOutputHasImage(nextTurnOutputItem)) {
746
+ let continuationScreenshot = toolResult.currentScreenshot ?? null;
747
+ let screenshotBase64 = typeof continuationScreenshot?.base64 === "string" && continuationScreenshot.base64
748
+ ? continuationScreenshot.base64
749
+ : null;
750
+ if (!screenshotBase64) {
751
+ screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
752
+ continuationScreenshot = await this.recordScreenshot(screenshotBase64, {
753
+ runId: context?.runId,
754
+ sessionId: context?.sessionId,
755
+ stepId: stepContext?.stepId,
756
+ instructionIndex: stepContext?.instructionIndex,
757
+ captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
758
+ });
759
+ }
760
+ nextTurnOutputItem = appendImageToFunctionCallOutput(nextTurnOutputItem, screenshotBase64);
761
+ currentScreenshot = continuationScreenshot ?? currentScreenshot;
762
+ }
763
+ const startsFreshResponseChain = nextTurnConfig.continuationMode === "fresh";
764
+ // Fresh follow-ups start a new provider response chain. Tool outputs from
765
+ // the previous chain cannot be acknowledged without the matching tool call.
766
+ const nextTurnMessages = startsFreshResponseChain ? [] : [nextTurnOutputItem];
450
767
  if (Array.isArray(nextTurnConfig.followUpMessages) && nextTurnConfig.followUpMessages.length > 0) {
451
768
  nextTurnMessages.push(...nextTurnConfig.followUpMessages);
452
769
  }
770
+ if (pendingObservationOnlyRecoveryMessage) {
771
+ nextTurnMessages.push(pendingObservationOnlyRecoveryMessage);
772
+ pendingObservationOnlyRecoveryMessage = null;
773
+ }
453
774
  response = await this.requestNextTurn(nextTurnMessages, newResponseId, context, stepContext, {
454
775
  debugContextOverride: options.debugContextOverride,
455
776
  toolCapabilitiesOverride: nextTurnConfig.toolCapabilitiesOverride,
456
777
  includeComputerToolOverride: nextTurnConfig.includeComputerToolOverride,
778
+ continuationModeOverride: nextTurnConfig.continuationMode,
457
779
  });
458
780
  newResponseId = response.id;
459
781
  if (toolResult.kind === "close_turn") {
@@ -470,7 +792,7 @@ export class ExecutionEngine {
470
792
  refreshBeforeNextComputerAction = false;
471
793
  continue;
472
794
  }
473
- const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, trackAction);
795
+ const actionBatchResult = await this.executeActionBatch(actions, currentScreenshot, shouldStop, context, stepContext, wrappedTrackAction);
474
796
  if (actionBatchResult.stopped) {
475
797
  summary.stopped = true;
476
798
  return finalize();
@@ -496,6 +818,13 @@ export class ExecutionEngine {
496
818
  refreshBeforeNextComputerAction = true;
497
819
  }
498
820
  currentScreenshot = followUp.currentScreenshot ?? currentScreenshot;
821
+ if (observationOnlyTurn && updateObservationOnlyStreak(actionableItem)) {
822
+ break;
823
+ }
824
+ if (pendingObservationOnlyRecoveryMessage) {
825
+ followUp.input.push(pendingObservationOnlyRecoveryMessage);
826
+ pendingObservationOnlyRecoveryMessage = null;
827
+ }
499
828
  response = await this.requestNextTurn(followUp.input, newResponseId, context, stepContext);
500
829
  newResponseId = response.id;
501
830
  }
@@ -1,3 +1,3 @@
1
1
  export { buildBaseSystemPrompt } from "../prompts/base.js";
2
- export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
2
+ export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptFormatRetryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
3
3
  export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, buildSecretKeysContextSection, buildTestDataContextSection, } from "../prompts/execution.js";
@@ -2,7 +2,7 @@
2
2
  // HTML document that reproduces the Desktop App's run-details view. Pure: no I/O and
3
3
  // no external references — screenshots must already be inlined as data URLs in
4
4
  // `event.payload.screenshotDataUrl`. Use generate-html-report.js to inline them.
5
- import { assertionData, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, } from "./run-log-model.js";
5
+ import { assertionData, formatDuration, hasNearbyAssertionCard, isAssistantAssertionSummary, isDuplicateAssertionLine, isHiddenExecutionLogEvent, runSummaryData, stepCompletionData, stepCompletionReasonTooltip, } from "./run-log-model.js";
6
6
  // ---------- text / value formatting (mirrors the Desktop App) ----------
7
7
  function escapeHtml(value) {
8
8
  return String(value ?? "")
@@ -18,7 +18,7 @@ function formatRunTimestamp(iso) {
18
18
  const parsed = Date.parse(iso);
19
19
  if (!Number.isFinite(parsed))
20
20
  return "—";
21
- return new Date(parsed).toLocaleString();
21
+ return new Date(parsed).toLocaleString("en-US");
22
22
  }
23
23
  function formatRunDuration(durationSeconds) {
24
24
  if (durationSeconds === null || durationSeconds === undefined || !Number.isFinite(durationSeconds)) {
@@ -121,6 +121,11 @@ function renderEvent(event, shots) {
121
121
  }
122
122
  const isAssistant = event.eventType === "assistant_message" || event.kind === "assistant";
123
123
  if (isAssistant) {
124
+ const stepCompletion = stepCompletionData(event);
125
+ if (stepCompletion) {
126
+ const tooltip = stepCompletionReasonTooltip(stepCompletion);
127
+ return `<div class="dc-line"${tooltip ? ` title="${escapeHtml(tooltip)}"` : ""}><span aria-hidden="true">&#9873;</span><span>Step complete</span></div>`;
128
+ }
124
129
  return `<div class="dc-assistant">${escapeHtml(event.text)}</div>`;
125
130
  }
126
131
  const assertion = assertionData(event);
@@ -239,7 +244,7 @@ body { margin: 0; background: #f1f5f9; color: #0f172a; font-family: -apple-syste
239
244
  .dc-row-right { justify-content: flex-end; }
240
245
  .dc-bubble { display: inline-block; max-width: 70%; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 16px; border-bottom-right-radius: 6px; padding: 8px 14px; color: #0f172a; }
241
246
  .dc-assistant { max-width: 82%; color: #334155; }
242
- .dc-line { display: flex; align-items: center; gap: 8px; color: #475569; font-size: 13px; }
247
+ .dc-line { display: flex; align-items: center; gap: 8px; color: #475569; }
243
248
  .dc-retry { color: #b45309; }
244
249
  .dc-error { color: #b91c1c; }
245
250
  .dc-warning { color: #b45309; }
@@ -98,6 +98,48 @@ export function runSummaryData(event) {
98
98
  error,
99
99
  };
100
100
  }
101
+ export function stepCompletionData(eventOrText) {
102
+ const text = typeof eventOrText === "string"
103
+ ? eventOrText
104
+ : typeof eventOrText?.text === "string"
105
+ ? eventOrText.text
106
+ : null;
107
+ if (!text)
108
+ return null;
109
+ const normalized = text.replace(/\r/g, "").trim();
110
+ const prefix = "Step complete:";
111
+ if (!normalized.startsWith(prefix))
112
+ return null;
113
+ const rest = normalized.slice(prefix.length);
114
+ const reasonLabel = /\sReason:\s*/.exec(rest);
115
+ if (!reasonLabel)
116
+ return null;
117
+ const instruction = rest.slice(0, reasonLabel.index).trim();
118
+ const reasonStart = reasonLabel.index + reasonLabel[0].length;
119
+ const afterReason = rest.slice(reasonStart);
120
+ const visibleStateLabel = /\sVisible state:\s*/.exec(afterReason);
121
+ if (!visibleStateLabel)
122
+ return null;
123
+ const reason = afterReason.slice(0, visibleStateLabel.index).trim();
124
+ const visibleState = afterReason.slice(visibleStateLabel.index + visibleStateLabel[0].length).trim();
125
+ if (!instruction || !reason || !visibleState)
126
+ return null;
127
+ return { instruction, reason, visibleState };
128
+ }
129
+ export function stepCompletionReasonTooltip(stepCompletion, maxLength = 140) {
130
+ if (!stepCompletion?.reason)
131
+ return null;
132
+ const firstSentence = stepCompletion.reason.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
133
+ const text = (firstSentence || stepCompletion.reason).replace(/\s+/g, " ").trim();
134
+ if (!text)
135
+ return null;
136
+ if (text.length <= maxLength)
137
+ return `Reason: ${text}`;
138
+ const truncated = text.slice(0, Math.max(0, maxLength - 1)).trimEnd();
139
+ const wordBoundary = truncated.lastIndexOf(" ");
140
+ const cleanTruncated = wordBoundary > maxLength * 0.6 ? truncated.slice(0, wordBoundary).trimEnd() : truncated;
141
+ return `Reason: ${cleanTruncated}…`;
142
+ }
101
143
  export function formatDuration(ms) {
102
144
  if (ms === null || ms === undefined)
103
145
  return "n/a";
@@ -2,13 +2,12 @@ import OpenAI from "openai";
2
2
  import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
3
3
  import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
4
4
  import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
5
- import { DEFAULT_FARA_BASE_URL, DEFAULT_FARA_MODEL, resolveCuaBackend, } from "./cua-backends.js";
6
5
  import { normalizeStepDelayMs } from "../utils/step-delay.js";
7
6
  export const LOADMILL_MODEL_ALIASES = Object.freeze({
8
7
  DEFAULT_AGENT_MODEL: "default-agent-model",
9
8
  FAST: "fast",
10
9
  SMART: "smart",
11
- COMPUTER_USE: "computer-use",
10
+ BEACON: "beacon",
12
11
  });
13
12
  const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
14
13
  export const LLM_PROVIDER_MODES = Object.freeze(["loadmill", "openai"]);
@@ -89,19 +88,19 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
89
88
  }
90
89
  return DEFAULT_LLM_PROVIDER_MODE;
91
90
  }
92
- export function resolveGatewayCuaModelAlias({ cuaBackend, cuaModel } = {}) {
93
- if (resolveCuaBackend(cuaBackend) === "fara") {
94
- return LOADMILL_MODEL_ALIASES.COMPUTER_USE;
95
- }
91
+ export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
96
92
  const normalizedCuaModel = resolveCuaModel(cuaModel);
93
+ if (normalizedCuaModel === "loadmill-beacon") {
94
+ return LOADMILL_MODEL_ALIASES.BEACON;
95
+ }
97
96
  return normalizedCuaModel === "gpt-5.4-mini"
98
97
  ? LOADMILL_MODEL_ALIASES.FAST
99
98
  : LOADMILL_MODEL_ALIASES.SMART;
100
99
  }
101
- export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaBackend = null, cuaModel = null, } = {}) {
100
+ export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
102
101
  const providerMode = resolveLlmProviderMode(llmProviderMode);
103
102
  if (providerMode === "loadmill") {
104
- return resolveGatewayCuaModelAlias({ cuaBackend, cuaModel });
103
+ return resolveGatewayCuaModelAlias({ cuaModel });
105
104
  }
106
105
  return resolveCuaModelProviderMode(cuaModel) === "openai"
107
106
  ? resolveCuaModel(cuaModel)
@@ -115,17 +114,14 @@ export function resolveEffectiveHelperModel({ llmProviderMode = null, cuaModel =
115
114
  ? resolveCuaModel(cuaModel)
116
115
  : DEFAULT_OPENAI_CUA_MODEL;
117
116
  }
118
- export function resolveRuntimeCredentials({ openAiApiKey = null, faraApiKey = null, faraBaseUrl = null, faraModel = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
117
+ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
119
118
  return {
120
119
  openAiApiKey: trimNullableString(openAiApiKey),
121
- faraApiKey: trimNullableString(faraApiKey),
122
- faraBaseUrl: trimNullableString(faraBaseUrl) || DEFAULT_FARA_BASE_URL,
123
- faraModel: trimNullableString(faraModel) || DEFAULT_FARA_MODEL,
124
120
  loadmillApiToken: trimNullableString(loadmillApiToken),
125
121
  loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
126
122
  };
127
123
  }
128
- export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
124
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
129
125
  const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
130
126
  ? cuaModel
131
127
  : resolveCuaModel(cuaModel);
@@ -134,7 +130,6 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null
134
130
  llmProviderMode: llmProviderMode == null && isLoadmillCuaModel(normalizedCuaModel)
135
131
  ? "loadmill"
136
132
  : normalizedProviderMode,
137
- cuaBackend: resolveCuaBackend(cuaBackend),
138
133
  cuaModel: normalizedCuaModel,
139
134
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
140
135
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
@@ -161,11 +156,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
161
156
  const contextOptimization = readContextOptimizationEnv(env);
162
157
  return resolveRuntimeConfig({
163
158
  llmProviderMode: env.DROID_CUA_LLM_PROVIDER,
164
- // Only the explicit backend flag should influence CUA model routing.
165
- // Legacy screenshot-provider env vars must not silently remap Loadmill
166
- // model aliases like "smart" to backend-specific aliases like
167
- // "computer-use".
168
- cuaBackend: env.DROID_CUA_BACKEND,
169
159
  cuaModel: env.OPENAI_CUA_MODEL,
170
160
  stepDelayMs: env.DROID_CUA_STEP_DELAY_MS,
171
161
  contextOptimizationEnabled: contextOptimization.enabled,
@@ -176,9 +166,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
176
166
  export function resolveRuntimeCredentialsFromEnv(env = process.env, overrides = {}) {
177
167
  return resolveRuntimeCredentials({
178
168
  openAiApiKey: env.OPENAI_API_KEY,
179
- faraApiKey: env.DROID_CUA_FARA_API_KEY,
180
- faraBaseUrl: env.DROID_CUA_FARA_BASE_URL,
181
- faraModel: env.DROID_CUA_FARA_MODEL,
182
169
  loadmillApiToken: env.LOADMILL_API_TOKEN,
183
170
  loadmillBaseUrl: env.LOADMILL_BASE_URL,
184
171
  ...overrides,
@@ -278,19 +265,27 @@ export function createLlmClient({ loadmillApiToken, loadmillBaseUrl, clientName
278
265
  },
279
266
  });
280
267
  }
281
- export function createFaraClient({ apiKey, baseUrl }) {
282
- return new OpenAI({
283
- apiKey: trimNullableString(apiKey) || "",
284
- baseURL: trimNullableString(baseUrl) || DEFAULT_FARA_BASE_URL,
285
- });
286
- }
287
- export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
268
+ export function assertRuntimeCredentialsForProvider({ llmProviderMode = null, credentials = null, } = {}) {
288
269
  const resolvedCredentials = resolveRuntimeCredentials(credentials);
289
270
  const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
290
271
  if (resolvedProviderMode === "loadmill") {
291
272
  if (!hasLoadmillLlmAccess(resolvedCredentials)) {
292
273
  throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
293
274
  }
275
+ return;
276
+ }
277
+ if (!hasOpenAiLlmAccess(resolvedCredentials)) {
278
+ throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
279
+ }
280
+ }
281
+ export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
282
+ const resolvedCredentials = resolveRuntimeCredentials(credentials);
283
+ const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
284
+ assertRuntimeCredentialsForProvider({
285
+ llmProviderMode: resolvedProviderMode,
286
+ credentials: resolvedCredentials,
287
+ });
288
+ if (resolvedProviderMode === "loadmill") {
294
289
  return createLlmClient({
295
290
  loadmillApiToken: resolvedCredentials.loadmillApiToken,
296
291
  loadmillBaseUrl: resolvedCredentials.loadmillBaseUrl,
@@ -298,9 +293,6 @@ export function createResolvedLlmClient({ llmProviderMode = null, credentials =
298
293
  maxRetries,
299
294
  });
300
295
  }
301
- if (!hasOpenAiLlmAccess(resolvedCredentials)) {
302
- throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
303
- }
304
296
  return createOpenAiClient(resolvedCredentials.openAiApiKey);
305
297
  }
306
298
  export function createRunContext({ config, credentials }) {
@@ -312,7 +304,6 @@ export function createRunContext({ config, credentials }) {
312
304
  llmProviderMode: resolvedProviderMode,
313
305
  cuaModel: resolveEffectiveCuaModel({
314
306
  llmProviderMode: resolvedProviderMode,
315
- cuaBackend: resolvedConfig.cuaBackend,
316
307
  cuaModel: resolvedConfig.cuaModel,
317
308
  }),
318
309
  };
@@ -328,12 +319,6 @@ export function createRunContext({ config, credentials }) {
328
319
  credentials: resolvedCredentials,
329
320
  clientName: "desktop",
330
321
  }),
331
- fara: resolvedConfig.cuaBackend === "fara"
332
- ? createFaraClient({
333
- apiKey: resolvedCredentials.faraApiKey,
334
- baseUrl: resolvedCredentials.faraBaseUrl,
335
- })
336
- : null,
337
322
  },
338
323
  };
339
324
  }