@loadmill/droid-cua 2.22.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.
package/build/index.js CHANGED
@@ -15,7 +15,7 @@ import { selectDevice } from "./src/cli/device-selector.js";
15
15
  import { resolveExecutionAppContext } from "./src/core/app-context.js";
16
16
  import { resolveExecutionTestData } from "./src/core/test-data.js";
17
17
  import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
18
- import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
18
+ import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
19
19
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
20
20
  import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
21
21
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
@@ -71,6 +71,33 @@ function createCliRuntimeContext(configOverrides = {}) {
71
71
  loadmillClient,
72
72
  };
73
73
  }
74
+ async function assertCliRuntimeCredentials({ llmProviderMode }) {
75
+ const credentials = resolveRuntimeCredentialsFromEnv(process.env);
76
+ assertRuntimeCredentialsForProvider({
77
+ llmProviderMode,
78
+ credentials,
79
+ });
80
+ if (llmProviderMode !== "loadmill") {
81
+ return;
82
+ }
83
+ const loadmillClient = createLoadmillApiClient({
84
+ token: credentials.loadmillApiToken,
85
+ baseUrl: credentials.loadmillBaseUrl,
86
+ });
87
+ let tokenValidation = null;
88
+ try {
89
+ tokenValidation = await loadmillClient.validateApiToken();
90
+ }
91
+ catch {
92
+ throw new Error("Could not validate LOADMILL_API_TOKEN.");
93
+ }
94
+ if (tokenValidation?.reason === "invalid") {
95
+ throw new Error("LOADMILL_API_TOKEN is invalid.");
96
+ }
97
+ if (!tokenValidation?.valid) {
98
+ throw new Error("Could not validate LOADMILL_API_TOKEN.");
99
+ }
100
+ }
74
101
  function createLambdaTestCliBuildName() {
75
102
  return `loadmill-droid-cua-cli-${new Date().toISOString().replace(/[:.]/g, "-")}`;
76
103
  }
@@ -184,6 +211,9 @@ async function main() {
184
211
  let preloadedSecrets = null;
185
212
  if (isHeadlessInstructionsRun) {
186
213
  preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
214
+ await assertCliRuntimeCredentials({
215
+ llmProviderMode: preloadedHeadlessConfig.llmProviderMode,
216
+ });
187
217
  preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
188
218
  preloadedSecrets = await resolveExecutionSecrets({
189
219
  secretsPath: getDefaultSecretsPath(process.cwd()),
@@ -3,7 +3,8 @@ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js
3
3
  import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
4
4
  import { buildActionEventMeta } from "../../device/action-event-meta.js";
5
5
  import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../../device/sensitive-action.js";
6
- import { getActiveBrowserPage } from "./session-state.js";
6
+ import { normalizeAutomationUrl } from "./url.js";
7
+ import { getActiveBrowserSession, getActiveBrowserPage } from "./session-state.js";
7
8
  const KEY_ALIASES = {
8
9
  CTRL: "Control",
9
10
  CONTROL: "Control",
@@ -58,8 +59,47 @@ function buildMeta(context, action, payload = {}) {
58
59
  payload,
59
60
  });
60
61
  }
62
+ function handleNavigationTimeout(error, description) {
63
+ if (!(error instanceof Error) || error.name !== "TimeoutError") {
64
+ throw error;
65
+ }
66
+ console.warn(`Page load did not finish within the timeout for ${description}. Continuing with the current page state.`);
67
+ }
68
+ async function gotoWithTimeoutTolerance(page, url) {
69
+ try {
70
+ await page.goto(url, { waitUntil: "load" });
71
+ }
72
+ catch (error) {
73
+ handleNavigationTimeout(error, url);
74
+ }
75
+ }
76
+ async function goBackWithTimeoutTolerance(page) {
77
+ try {
78
+ await page.goBack({ waitUntil: "load" });
79
+ }
80
+ catch (error) {
81
+ handleNavigationTimeout(error, "browser history back");
82
+ }
83
+ }
84
+ async function goForwardWithTimeoutTolerance(page) {
85
+ try {
86
+ await page.goForward({ waitUntil: "load" });
87
+ }
88
+ catch (error) {
89
+ handleNavigationTimeout(error, "browser history forward");
90
+ }
91
+ }
92
+ function updateSessionUrlFromPage(session, page) {
93
+ const currentUrl = typeof page.url === "function" ? page.url() : null;
94
+ if (!currentUrl) {
95
+ return;
96
+ }
97
+ session.pageUrls?.set?.(page, currentUrl);
98
+ session.url = currentUrl;
99
+ }
61
100
  export async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
62
101
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
102
+ const session = getActiveBrowserSession(browserSessionId);
63
103
  const page = getActiveBrowserPage(browserSessionId);
64
104
  try {
65
105
  emitDesktopDebug("browser.action.execute", "device", {
@@ -166,6 +206,30 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
166
206
  await page.waitForTimeout(timeoutMs);
167
207
  break;
168
208
  }
209
+ case "goto": {
210
+ const normalizedUrl = normalizeAutomationUrl(action.url);
211
+ addOutput({
212
+ type: "action",
213
+ text: `Navigating to ${normalizedUrl}`,
214
+ ...buildMeta(context, action, { url: normalizedUrl }),
215
+ });
216
+ await gotoWithTimeoutTolerance(page, normalizedUrl);
217
+ session.pageUrls?.set?.(page, normalizedUrl);
218
+ session.url = normalizedUrl;
219
+ break;
220
+ }
221
+ case "back": {
222
+ addOutput({ type: "action", text: "Going back", ...buildMeta(context, action) });
223
+ await goBackWithTimeoutTolerance(page);
224
+ updateSessionUrlFromPage(session, page);
225
+ break;
226
+ }
227
+ case "forward": {
228
+ addOutput({ type: "action", text: "Going forward", ...buildMeta(context, action) });
229
+ await goForwardWithTimeoutTolerance(page);
230
+ updateSessionUrlFromPage(session, page);
231
+ break;
232
+ }
169
233
  default:
170
234
  addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
171
235
  }
@@ -32,7 +32,7 @@ export async function handleHelp(args, session, context) {
32
32
  addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
33
33
  addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
34
34
  addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
35
- addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, or gpt-5.4-mini' });
35
+ addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, loadmill-beacon, or gpt-5.4-mini' });
36
36
  addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
37
37
  addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
38
38
  addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
@@ -2,15 +2,22 @@ export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
2
2
  export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
3
3
  export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
4
4
  export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
5
- export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
6
- export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
5
+ export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL, "loadmill-beacon"];
6
+ export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "loadmill-beacon", "gpt-5.4-mini"];
7
7
  export const CUA_MODEL_OPTIONS = Object.freeze([
8
8
  Object.freeze({
9
9
  id: "loadmill-smart",
10
10
  providerMode: "loadmill",
11
11
  providerLabel: "Loadmill",
12
- name: "Loadmill Smart",
13
- label: "Loadmill Smart (recommended)",
12
+ name: "Loadmill - Smart",
13
+ label: "Loadmill - Smart (recommended)",
14
+ }),
15
+ Object.freeze({
16
+ id: "loadmill-beacon",
17
+ providerMode: "loadmill",
18
+ providerLabel: "Loadmill",
19
+ name: "Loadmill - Beacon",
20
+ label: "Loadmill - Beacon",
14
21
  }),
15
22
  Object.freeze({
16
23
  id: "gpt-5.4",
@@ -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; }