@loadmill/droid-cua 2.9.0 → 2.10.1-rc.8

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 (31) hide show
  1. package/README.md +0 -2
  2. package/build/index.js +24 -18
  3. package/build/src/brand/loadmill-mark.js +20 -0
  4. package/build/src/browser/playwright/connection.js +148 -11
  5. package/build/src/browser/playwright/welcome-page.js +161 -0
  6. package/build/src/cli/headless-execution-config.js +16 -24
  7. package/build/src/cli/headless-target.js +0 -9
  8. package/build/src/commands/help.js +3 -5
  9. package/build/src/commands/run.js +1 -1
  10. package/build/src/core/aim-assist.js +5 -0
  11. package/build/src/core/execution-engine.js +266 -505
  12. package/build/src/core/prompts.js +1 -1
  13. package/build/src/core/runtime-config.js +1 -2
  14. package/build/src/core/screenshot-store.js +153 -0
  15. package/build/src/core/tool-handlers/assert.js +155 -0
  16. package/build/src/core/tool-handlers/loadmill.js +29 -0
  17. package/build/src/core/tool-handlers/navigate.js +110 -0
  18. package/build/src/core/tool-handlers/preview-click.js +112 -0
  19. package/build/src/core/tool-handlers/test-design-complete.js +93 -0
  20. package/build/src/core/tool-handlers/utils.js +80 -0
  21. package/build/src/device/openai.js +103 -4
  22. package/build/src/modes/design-mode-ink.js +17 -5
  23. package/build/src/modes/design-mode.js +17 -5
  24. package/build/src/modes/execution-mode.js +42 -234
  25. package/build/src/prompts/base.js +44 -31
  26. package/build/src/prompts/design.js +55 -15
  27. package/build/src/prompts/editor.js +1 -1
  28. package/build/src/prompts/execution.js +15 -10
  29. package/build/src/utils/cua-debug-tracer.js +3 -1
  30. package/package.json +3 -4
  31. package/build/src/device/screenshot-resolution.js +0 -33
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared helpers for tool handlers. These are pure functions — no engine
3
+ * state. Anything that touches the engine (screenshots, network requests,
4
+ * the session) belongs in the handler files instead.
5
+ */
6
+ export function parseJsonArguments(argumentsText) {
7
+ try {
8
+ return { ok: true, value: JSON.parse(argumentsText) };
9
+ }
10
+ catch {
11
+ return { ok: false, value: null };
12
+ }
13
+ }
14
+ export function parseStringToolArgument(argumentsText, key, toolName) {
15
+ const parsed = parseJsonArguments(argumentsText);
16
+ if (!parsed.ok || typeof parsed.value !== "object" || !parsed.value) {
17
+ return {
18
+ ok: false,
19
+ message: `Invalid ${toolName} arguments: expected a JSON object with a string "${key}" field.`,
20
+ };
21
+ }
22
+ const value = parsed.value[key];
23
+ if (typeof value !== "string" || !value.trim()) {
24
+ return {
25
+ ok: false,
26
+ message: `Invalid ${toolName} arguments: "${key}" must be a non-empty string.`,
27
+ };
28
+ }
29
+ return { ok: true, value: value.trim() };
30
+ }
31
+ export function parseStringArray(value) {
32
+ if (!Array.isArray(value))
33
+ return [];
34
+ return value
35
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
36
+ .filter(Boolean);
37
+ }
38
+ export function buildFunctionCallOutputItem(callId, output) {
39
+ return {
40
+ type: "function_call_output",
41
+ call_id: callId,
42
+ output,
43
+ };
44
+ }
45
+ export function buildTextToolOutputItem(callId, text) {
46
+ return buildFunctionCallOutputItem(callId, [{ type: "input_text", text }]);
47
+ }
48
+ export function buildPreviewOutputContent({ text, annotatedScreenshotBase64 = null }) {
49
+ const content = [{ type: "input_text", text }];
50
+ if (annotatedScreenshotBase64) {
51
+ content.push({
52
+ type: "input_image",
53
+ image_url: `data:image/png;base64,${annotatedScreenshotBase64}`,
54
+ detail: "high",
55
+ });
56
+ }
57
+ return content;
58
+ }
59
+ export function isDesignContext(context = null) {
60
+ return typeof context?.sessionId === "string" && context.sessionId.trim().length > 0;
61
+ }
62
+ export function buildDebugContext(context = null, stepContext = null) {
63
+ return {
64
+ scope: context?.sessionId ? "design" : "execution",
65
+ runId: context?.runId,
66
+ sessionId: context?.sessionId,
67
+ stepId: stepContext?.stepId,
68
+ instructionIndex: stepContext?.instructionIndex,
69
+ };
70
+ }
71
+ export function buildNestedToolDebugContext(context, stepContext, toolName, callId) {
72
+ const debugContext = buildDebugContext(context, stepContext);
73
+ const baseChainId = debugContext.scope === "design"
74
+ ? debugContext.sessionId || "unknown"
75
+ : debugContext.runId || "unknown";
76
+ return {
77
+ ...debugContext,
78
+ chainId: `${debugContext.scope}:${baseChainId}:${toolName}:${callId}`,
79
+ };
80
+ }
@@ -1,5 +1,6 @@
1
1
  import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
2
2
  import { createOpenAiClient, resolveRuntimeConfig } from "../core/runtime-config.js";
3
+ import { DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
3
4
  import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD } from "../core/context-optimization.js";
4
5
  import { logger } from "../utils/logger.js";
5
6
  import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
@@ -33,6 +34,99 @@ const previewClickTool = {
33
34
  additionalProperties: false,
34
35
  },
35
36
  };
37
+ const assertTool = {
38
+ type: "function",
39
+ name: "assert",
40
+ description: "Validate whether a condition is true on the current screen. Use this for instructions like assert, verify, validate, ensure, or confirm. Returns a pass/fail result for the requested condition.",
41
+ strict: true,
42
+ parameters: {
43
+ type: "object",
44
+ properties: {
45
+ condition: { type: "string" },
46
+ },
47
+ required: ["condition"],
48
+ additionalProperties: false,
49
+ },
50
+ };
51
+ const navigateTool = {
52
+ type: "function",
53
+ name: "navigate",
54
+ description: "Navigate the active browser session directly to a target URL or page. Use this instead of trying to click the browser address bar or simulating URL entry.",
55
+ strict: true,
56
+ parameters: {
57
+ type: "object",
58
+ properties: {
59
+ target: { type: "string" },
60
+ },
61
+ required: ["target"],
62
+ additionalProperties: false,
63
+ },
64
+ };
65
+ const loadmillTool = {
66
+ type: "function",
67
+ name: "loadmill",
68
+ description: "Run an explicit Loadmill command. Use this only when the instruction explicitly mentions Loadmill and asks for a Loadmill-specific action.",
69
+ strict: true,
70
+ parameters: {
71
+ type: "object",
72
+ properties: {
73
+ command: { type: "string" },
74
+ },
75
+ required: ["command"],
76
+ additionalProperties: false,
77
+ },
78
+ };
79
+ const testDesignCompleteTool = {
80
+ type: "function",
81
+ name: "test_design_complete",
82
+ description: "Signal that design exploration is complete and hand off a structured summary for final test generation. Use this only after the requested flow has been successfully demonstrated or validated. After calling this, stop exploring.",
83
+ strict: true,
84
+ parameters: {
85
+ type: "object",
86
+ properties: {
87
+ suggestedTestName: { type: "string" },
88
+ goalSummary: { type: "string" },
89
+ finalState: { type: "string" },
90
+ importantSteps: {
91
+ type: "array",
92
+ items: { type: "string" }
93
+ },
94
+ verificationPoints: {
95
+ type: "array",
96
+ items: { type: "string" }
97
+ },
98
+ notesForScript: { type: "string" },
99
+ },
100
+ required: [
101
+ "suggestedTestName",
102
+ "goalSummary",
103
+ "finalState",
104
+ "importantSteps",
105
+ "verificationPoints",
106
+ "notesForScript"
107
+ ],
108
+ additionalProperties: false,
109
+ },
110
+ };
111
+ export function buildFunctionTools({ aimAssistMode = DEFAULT_AIM_ASSIST_MODE, toolCapabilities = {}, } = {}) {
112
+ const tools = [];
113
+ if (aimAssistMode !== "off" && toolCapabilities.previewClick !== false) {
114
+ tools.push(previewClickTool);
115
+ }
116
+ if (toolCapabilities.assert !== false) {
117
+ tools.push(assertTool);
118
+ }
119
+ if (toolCapabilities.navigate) {
120
+ tools.push(navigateTool);
121
+ }
122
+ if (toolCapabilities.loadmill) {
123
+ tools.push(loadmillTool);
124
+ }
125
+ if (toolCapabilities.testDesignComplete) {
126
+ tools.push(testDesignCompleteTool);
127
+ }
128
+ return tools;
129
+ }
36
130
  function appendInitialScreenshot(input, screenshotBase64) {
37
131
  // Most callers end with the active user message, but some recovery flows can
38
132
  // reset the conversation to system-only state before the next screenshot send.
@@ -90,8 +184,10 @@ function buildContextManagement(contextOptimizationEnabled, contextOptimizationT
90
184
  }
91
185
  return [{ type: "compaction", compact_threshold: contextOptimizationThreshold }];
92
186
  }
93
- export function buildCuaRequestParams({ cuaModel, previousResponseId, input, contextOptimizationEnabled = true, contextOptimizationThreshold = DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, }) {
187
+ export function buildCuaRequestParams({ cuaModel, aimAssistMode = DEFAULT_AIM_ASSIST_MODE, previousResponseId, input, includeComputerTool = true, contextOptimizationEnabled = true, contextOptimizationThreshold = DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, toolCapabilities = {}, }) {
94
188
  const contextManagement = buildContextManagement(contextOptimizationEnabled, contextOptimizationThreshold);
189
+ // Keep Responses API storage enabled on every turn so execution can chain
190
+ // reliably through previous_response_id across the whole run.
95
191
  return {
96
192
  model: cuaModel,
97
193
  reasoning: { effort: "medium" },
@@ -101,8 +197,8 @@ export function buildCuaRequestParams({ cuaModel, previousResponseId, input, con
101
197
  store: true,
102
198
  truncation: "auto",
103
199
  tools: [
104
- { type: "computer" },
105
- previewClickTool,
200
+ ...(includeComputerTool ? [{ type: "computer" }] : []),
201
+ ...buildFunctionTools({ aimAssistMode, toolCapabilities }),
106
202
  ],
107
203
  };
108
204
  }
@@ -139,14 +235,17 @@ export async function reviseTestScript({ client, originalScript, revisionRequest
139
235
  * debugContext?: unknown,
140
236
  * }} options
141
237
  */
142
- export async function sendCUARequest({ client, cuaModel, contextOptimizationEnabled = true, contextOptimizationThreshold = DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
238
+ export async function sendCUARequest({ client, cuaModel, aimAssistMode = DEFAULT_AIM_ASSIST_MODE, includeComputerTool = true, contextOptimizationEnabled = true, contextOptimizationThreshold = DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, toolCapabilities = {}, messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
143
239
  const input = buildCuaInput({ messages, screenshotBase64, previousResponseId, callId });
144
240
  const requestParams = buildCuaRequestParams({
145
241
  cuaModel,
242
+ aimAssistMode,
146
243
  previousResponseId,
147
244
  input,
245
+ includeComputerTool,
148
246
  contextOptimizationEnabled,
149
247
  contextOptimizationThreshold,
248
+ toolCapabilities,
150
249
  });
151
250
  const trace = cuaDebugTracer.startTurn({
152
251
  requestParams,
@@ -1,4 +1,5 @@
1
1
  import { getScreenshotAsBase64 } from "../device/connection.js";
2
+ import { CAPTURE_SOURCES } from "../core/capture-sources.js";
2
3
  import { buildDesignModePrompt, buildDesignRecoveryPrompt } from "../core/prompts.js";
3
4
  import { saveTest } from "../test-store/test-manager.js";
4
5
  import { logger } from "../utils/logger.js";
@@ -32,11 +33,17 @@ export class DesignModeInk {
32
33
  async start() {
33
34
  const addOutput = this.context.addOutput;
34
35
  // Set design mode system prompt
35
- const designPrompt = buildDesignModePrompt(this.session.deviceInfo, {}, {
36
- strictMode: Boolean(this.engine?.strictMode)
37
- });
36
+ const designPrompt = buildDesignModePrompt(this.session.deviceInfo, {});
38
37
  this.baseDesignPrompt = designPrompt;
39
38
  this.session.setSystemPrompt(designPrompt);
39
+ this.engine.toolCapabilities = {
40
+ assert: true,
41
+ navigate: this.session?.deviceInfo?.platform === "browser",
42
+ loadmill: false,
43
+ testDesignComplete: true,
44
+ };
45
+ this.engine.baseSystemPrompt = designPrompt;
46
+ this.engine.isHeadlessMode = false;
40
47
  // Update UI
41
48
  if (this.context.setMode) {
42
49
  this.context.setMode('design');
@@ -184,14 +191,19 @@ export class DesignModeInk {
184
191
  }
185
192
  // Get screenshot and send to model
186
193
  const screenshotBase64 = await getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
194
+ const currentScreenshot = await this.engine.recordScreenshot?.(screenshotBase64, {
195
+ sessionId: this.context?.sessionId,
196
+ captureSource: CAPTURE_SOURCES.DESIGN_INPUT,
197
+ });
187
198
  const response = await this.sendCUARequest({
188
199
  messages: this.session.messages,
189
200
  screenshotBase64,
190
201
  previousResponseId: this.session.previousResponseId,
191
202
  deviceInfo: this.session.deviceInfo,
203
+ toolCapabilities: this.engine.toolCapabilities,
192
204
  });
193
205
  // Run turn with action tracking and interruption checking
194
- const newResponseId = await this.engine.runFullTurn(response, (action) => {
206
+ const { responseId: newResponseId } = await this.engine.runFullTurn(response, (action) => {
195
207
  // Check if user interrupted - stop immediately
196
208
  if (this.inputQueue.length > 0) {
197
209
  return true; // Signal to stop execution
@@ -207,7 +219,7 @@ export class DesignModeInk {
207
219
  return true; // Stop for guidance
208
220
  }
209
221
  return false; // Continue execution
210
- }, this.context);
222
+ }, this.context, null, { currentScreenshot });
211
223
  this.session.updateResponseId(newResponseId);
212
224
  this.consecutiveErrorCount = 0;
213
225
  // Clear agent working status
@@ -1,5 +1,6 @@
1
1
  import readline from "readline";
2
2
  import { getScreenshotAsBase64 } from "../device/connection.js";
3
+ import { CAPTURE_SOURCES } from "../core/capture-sources.js";
3
4
  import { buildDesignModePrompt, buildDesignRecoveryPrompt } from "../core/prompts.js";
4
5
  import { saveTest } from "../test-store/test-manager.js";
5
6
  import { logger } from "../utils/logger.js";
@@ -29,11 +30,17 @@ export class DesignMode {
29
30
  */
30
31
  async start(context) {
31
32
  // Set design mode system prompt
32
- const designPrompt = buildDesignModePrompt(this.session.deviceInfo, {}, {
33
- strictMode: Boolean(this.engine?.strictMode)
34
- });
33
+ const designPrompt = buildDesignModePrompt(this.session.deviceInfo, {});
35
34
  this.baseDesignPrompt = designPrompt;
36
35
  this.session.setSystemPrompt(designPrompt);
36
+ this.engine.toolCapabilities = {
37
+ assert: true,
38
+ navigate: this.session?.deviceInfo?.platform === "browser",
39
+ loadmill: false,
40
+ testDesignComplete: true,
41
+ };
42
+ this.engine.baseSystemPrompt = designPrompt;
43
+ this.engine.isHeadlessMode = false;
37
44
  console.log(`\n=== Design Mode: Creating test "${this.testName}" ===`);
38
45
  console.log("Describe what you want to test. The agent will explore autonomously.");
39
46
  console.log('When ready, type "generate the script" to create the test.');
@@ -148,14 +155,19 @@ export class DesignMode {
148
155
  const keypressHandler = this.setupEscDetection();
149
156
  // Get screenshot and send to model
150
157
  const screenshotBase64 = await getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
158
+ const currentScreenshot = await this.engine.recordScreenshot?.(screenshotBase64, {
159
+ sessionId: context?.sessionId,
160
+ captureSource: CAPTURE_SOURCES.DESIGN_INPUT,
161
+ });
151
162
  const response = await this.sendCUARequest({
152
163
  messages: this.session.messages,
153
164
  screenshotBase64,
154
165
  previousResponseId: this.session.previousResponseId,
155
166
  deviceInfo: this.session.deviceInfo,
167
+ toolCapabilities: this.engine.toolCapabilities,
156
168
  });
157
169
  // Run turn with action tracking and interruption checking
158
- const newResponseId = await this.engine.runFullTurn(response, (action) => {
170
+ const { responseId: newResponseId } = await this.engine.runFullTurn(response, (action) => {
159
171
  // Check if user pressed ESC - stop immediately
160
172
  if (this.escPressed) {
161
173
  return true; // Signal to stop execution
@@ -172,7 +184,7 @@ export class DesignMode {
172
184
  this.session._shouldStopForGuidance = true;
173
185
  }
174
186
  return false; // Continue execution
175
- });
187
+ }, null, null, { currentScreenshot });
176
188
  this.session.updateResponseId(newResponseId);
177
189
  this.consecutiveErrorCount = 0;
178
190
  // Cleanup ESC detection
@@ -1,40 +1,13 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { CAPTURE_SOURCES } from "../core/capture-sources.js";
3
2
  import { getScreenshotAsBase64, connectToDevice, getDeviceInfo, getCurrentPlatform } from "../device/connection.js";
4
- import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, navigateToUrl as navigateBrowserToUrl, } from "../browser/connection.js";
3
+ import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, } from "../browser/connection.js";
5
4
  import { normalizeBrowserInfoForSession } from "../browser/normalize.js";
6
- import { extractNavigateTarget, isNavigateInstruction } from "../browser/navigation.js";
7
5
  import { sendCUARequest } from "../device/openai.js";
8
- import { buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt } from "../core/prompts.js";
9
- import { isAssertion, extractAssertionPrompt, buildAssertionSystemPrompt, checkAssertionResult, handleAssertionFailure, handleAssertionSuccess, } from "../device/assertions.js";
10
- import { isLoadmillInstruction, extractLoadmillCommand, executeLoadmillInstruction, } from "../device/loadmill.js";
6
+ import { buildExecutionRecoveryPrompt } from "../core/prompts.js";
7
+ import { isAssertion } from "../device/assertions.js";
11
8
  import { logger } from "../utils/logger.js";
12
9
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
13
10
  import { printCliOutput } from "../utils/console-output.js";
14
- export function buildExecutionRequestPayload({ instruction, isAssertionStep, messages, previousResponseId }) {
15
- return {
16
- messagesToSend: previousResponseId && !isAssertionStep
17
- ? [{ role: "user", content: instruction }]
18
- : messages,
19
- previousResponseIdToSend: previousResponseId,
20
- };
21
- }
22
- function screenshotPathFromRecorderResult(result) {
23
- if (result && typeof result === "object" && !Array.isArray(result)) {
24
- return typeof result.screenshotPath === "string" && result.screenshotPath.trim()
25
- ? result.screenshotPath
26
- : null;
27
- }
28
- return null;
29
- }
30
- function screenshotDataUrlFromRecorderResult(result) {
31
- if (result && typeof result === "object" && !Array.isArray(result)) {
32
- return typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
33
- ? result.screenshotDataUrl
34
- : null;
35
- }
36
- return null;
37
- }
38
11
  /**
39
12
  * Execution Mode - Run test scripts line-by-line
40
13
  * Each instruction is executed in isolation (messages cleared after each turn)
@@ -65,7 +38,15 @@ export class ExecutionMode {
65
38
  executionEngine.getCurrentPlatform || (this.isBrowserSession ? getBrowserCurrentPlatform : getCurrentPlatform);
66
39
  this.connectToBrowser = executionEngine.connectToBrowser || connectToBrowser;
67
40
  this.getBrowserInfo = executionEngine.getBrowserInfo || getBrowserInfo;
68
- this.navigateToUrl = executionEngine.navigateToUrl || navigateBrowserToUrl;
41
+ // The Ink shell shares one engine across modes, so design mode may have
42
+ // changed these three fields. Reset them to the values execution needs.
43
+ this.engine.toolCapabilities = {
44
+ assert: true,
45
+ navigate: this.isBrowserSession,
46
+ loadmill: true,
47
+ };
48
+ this.engine.baseSystemPrompt = this.initialSystemText;
49
+ this.engine.isHeadlessMode = this.isHeadlessMode;
69
50
  }
70
51
  /**
71
52
  * Format duration in human-readable format (Xm Ys)
@@ -142,7 +123,7 @@ export class ExecutionMode {
142
123
  eventType: 'instruction_started',
143
124
  payload: {
144
125
  instruction,
145
- isAssertion: isAssertion(instruction)
126
+ isAssertion: isAssertion(instruction),
146
127
  }
147
128
  });
148
129
  // Check for exit command
@@ -213,165 +194,28 @@ export class ExecutionMode {
213
194
  */
214
195
  async executeInstruction(instruction, context, retryCount = 0, stepContext = null) {
215
196
  const MAX_RETRIES = 10;
216
- const addOutput = context.addOutput || printCliOutput;
217
- if (isNavigateInstruction(instruction)) {
218
- if (!this.isBrowserSession) {
219
- const message = "navigate: instructions are only supported for web runs.";
220
- this.emit(addOutput, "error", message, context, stepContext, {
221
- eventType: "error",
222
- payload: {
223
- instructionType: "navigate",
224
- message
225
- }
226
- });
227
- return { success: false, error: message };
228
- }
229
- const navigationTarget = extractNavigateTarget(instruction);
230
- this.session.addToTranscript(`[Navigate] ${navigationTarget}`);
231
- emitDesktopDebug("runner.navigate.started", "execution", {
232
- runId: context?.runId,
233
- stepId: stepContext?.stepId,
234
- instructionIndex: stepContext?.instructionIndex
235
- }, {
236
- instruction,
237
- target: navigationTarget
238
- });
239
- this.emit(addOutput, "system", `Navigating to ${navigationTarget}...`, context, stepContext, {
240
- eventType: "system_message",
241
- payload: {
242
- instructionType: "navigate",
243
- target: navigationTarget,
244
- }
245
- });
246
- try {
247
- const normalizedUrl = await this.navigateToUrl(this.session.browserSessionId, navigationTarget);
248
- const browserInfo = normalizeBrowserInfoForSession(await this.getBrowserInfo(this.session.browserSessionId));
249
- this.session.browserInfo = browserInfo;
250
- this.session.deviceInfo = browserInfo;
251
- this.session.currentUrl = browserInfo.currentUrl || normalizedUrl;
252
- this.session.browserName = browserInfo.browserName || this.session.currentUrl;
253
- this.session.deviceName = this.session.browserName;
254
- const transcriptContext = this.session.getTranscriptText();
255
- const navigationRecoveryPrompt = buildExecutionNavigationRecoveryPrompt({
256
- basePrompt: this.initialSystemText,
257
- transcript: transcriptContext,
258
- navigatedUrl: this.session.currentUrl,
259
- });
260
- this.session.installRecoverySystemPrompt(navigationRecoveryPrompt);
261
- emitDesktopDebug("runner.navigate.completed", "execution", {
262
- runId: context?.runId,
263
- stepId: stepContext?.stepId,
264
- instructionIndex: stepContext?.instructionIndex
265
- }, {
266
- instruction,
267
- target: navigationTarget,
268
- url: normalizedUrl
269
- });
270
- this.emit(addOutput, "success", `Navigation complete: ${normalizedUrl}`, context, stepContext, {
271
- eventType: "system_message",
272
- payload: {
273
- instructionType: "navigate",
274
- url: normalizedUrl,
275
- }
276
- });
277
- return { success: true };
278
- }
279
- catch (err) {
280
- const message = err instanceof Error ? err.message : "Navigation failed.";
281
- logger.error("Execution navigation error", {
282
- instruction,
283
- retryCount,
284
- message,
285
- });
286
- emitDesktopDebug("runner.navigate.failed", "execution", {
287
- runId: context?.runId,
288
- stepId: stepContext?.stepId,
289
- instructionIndex: stepContext?.instructionIndex
290
- }, {
291
- instruction,
292
- target: navigationTarget,
293
- message
294
- });
295
- this.emit(addOutput, "error", `Navigation failed: ${message}`, context, stepContext, {
296
- eventType: "error",
297
- payload: {
298
- instructionType: "navigate",
299
- message,
300
- }
301
- });
302
- return { success: false, error: message };
303
- }
304
- }
305
- // ── Check for Loadmill instruction ──
306
- if (isLoadmillInstruction(instruction)) {
307
- const loadmillCommand = extractLoadmillCommand(instruction);
308
- this.session.addToTranscript(`[Loadmill] ${loadmillCommand}`);
309
- const result = await executeLoadmillInstruction(loadmillCommand, this.isHeadlessMode, context, stepContext);
310
- // Handle retry request from interactive mode
311
- if (result.retry) {
312
- this.stats.retryCount++;
313
- return await this.executeInstruction(instruction, context, 0, stepContext);
314
- }
315
- return result;
316
- }
317
- // ── Check for assertion ──
318
- const isAssertionStep = isAssertion(instruction);
319
- const assertionEventId = isAssertionStep ? randomUUID() : null;
320
- let assertionPrompt = null;
321
- const transcriptStartIndex = this.session.transcript.length;
322
- if (isAssertionStep) {
323
- assertionPrompt = extractAssertionPrompt(instruction);
324
- const assertionSystemPrompt = buildAssertionSystemPrompt(this.initialSystemText, assertionPrompt);
325
- this.session.clearMessages();
326
- this.session.addMessage("system", assertionSystemPrompt);
327
- this.session.addToTranscript(`[Assertion] ${assertionPrompt}`);
328
- this.session.addMessage("user", `Validate this assertion: ${assertionPrompt}`);
329
- }
330
- else {
331
- this.session.addToTranscript(`[User] ${instruction}`);
332
- this.session.addMessage("user", instruction);
333
- }
197
+ this.session.addToTranscript(`[User] ${instruction}`);
198
+ this.session.addMessage("user", instruction);
334
199
  try {
335
200
  const screenshotBase64 = await this.getScreenshotAsBase64(this.session.deviceId, this.session.deviceInfo);
336
- const screenshotResult = await this.engine.recordScreenshot?.(screenshotBase64, {
201
+ const currentScreenshot = await this.engine.recordScreenshot?.(screenshotBase64, {
337
202
  runId: context?.runId,
338
203
  stepId: stepContext?.stepId,
339
204
  instructionIndex: stepContext?.instructionIndex,
340
- ...(assertionEventId ? { eventId: assertionEventId } : {}),
341
- captureSource: isAssertionStep ? CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION : CAPTURE_SOURCES.INSTRUCTION_INPUT
342
- });
343
- const assertionScreenshotPath = isAssertionStep ? screenshotPathFromRecorderResult(screenshotResult) : null;
344
- const assertionScreenshotDataUrl = isAssertionStep
345
- ? screenshotDataUrlFromRecorderResult(screenshotResult) ||
346
- (context?.includeScreenshotDataUrl && assertionScreenshotPath
347
- ? `data:image/png;base64,${screenshotBase64}`
348
- : null)
349
- : null;
350
- const assertionEventPayload = isAssertionStep
351
- ? {
352
- eventId: assertionEventId,
353
- ...(assertionScreenshotPath ? { screenshotPath: assertionScreenshotPath } : {}),
354
- ...(assertionScreenshotDataUrl ? { screenshotDataUrl: assertionScreenshotDataUrl } : {})
355
- }
356
- : {};
357
- // Assertions rely on the prior response chain for earlier execution context.
358
- const { messagesToSend, previousResponseIdToSend } = buildExecutionRequestPayload({
359
- instruction,
360
- isAssertionStep,
361
- messages: this.session.messages,
362
- previousResponseId: this.session.previousResponseId
205
+ captureSource: CAPTURE_SOURCES.INSTRUCTION_INPUT
363
206
  });
364
207
  const response = await this.sendCUARequest({
365
- messages: messagesToSend,
208
+ messages: this.session.messages,
366
209
  screenshotBase64,
367
- previousResponseId: previousResponseIdToSend,
210
+ previousResponseId: this.session.previousResponseId,
368
211
  deviceInfo: this.session.deviceInfo,
369
212
  debugContext: {
370
213
  scope: "execution",
371
214
  runId: context?.runId,
372
215
  stepId: stepContext?.stepId,
373
216
  instructionIndex: stepContext?.instructionIndex
374
- }
217
+ },
218
+ toolCapabilities: this.engine.toolCapabilities,
375
219
  });
376
220
  // Track actions for stats
377
221
  const trackAction = (action = null) => {
@@ -380,65 +224,29 @@ export class ExecutionMode {
380
224
  }
381
225
  return this.shouldStop;
382
226
  };
383
- const newResponseId = await this.engine.runFullTurn(response, trackAction, context, stepContext);
384
- this.session.updateResponseId(newResponseId);
385
- // ── Check assertion result ──
386
- if (isAssertionStep) {
387
- const assertionTranscript = this.session.transcript.slice(transcriptStartIndex);
388
- const result = checkAssertionResult(assertionTranscript);
389
- if (result.failed) {
390
- handleAssertionFailure(assertionPrompt, assertionTranscript, false, // Never exit process - we'll always prompt the user in interactive mode
391
- context, stepContext, assertionEventPayload);
392
- // In headless mode, exit immediately on assertion failure
393
- if (this.isHeadlessMode) {
394
- this.stats.assertionsFailed++;
395
- return { success: false, error: `Assertion failed: ${assertionPrompt}` };
396
- }
397
- // Interactive mode - ask user what to do
398
- this.emit(addOutput, 'system', 'What would you like to do? (retry/skip/stop)', context, stepContext, {
399
- eventType: 'input_request',
400
- payload: {
401
- options: ['retry', 'skip', 'stop']
402
- }
403
- });
404
- // Wait for user input
405
- const userChoice = await new Promise((resolve) => {
406
- if (context?.waitForUserInput) {
407
- context.waitForUserInput().then(resolve);
408
- }
409
- else {
410
- // Fallback if waitForUserInput not available
411
- resolve('stop');
412
- }
413
- });
414
- const choice = userChoice.toLowerCase().trim();
415
- if (choice === 'retry' || choice === 'r') {
416
- // Retry the same instruction by recursing
417
- this.stats.retryCount++;
418
- return await this.executeInstruction(instruction, context, 0, stepContext);
419
- }
420
- else if (choice === 'skip' || choice === 's') {
421
- // Continue to next instruction
422
- this.stats.assertionsFailed++;
423
- this.emit(addOutput, 'info', 'Skipping failed assertion and continuing...', context, stepContext, {
424
- eventType: 'system_message'
425
- });
426
- }
427
- else {
428
- // Stop execution
429
- this.stats.assertionsFailed++;
430
- return { success: false, error: `Assertion failed: ${assertionPrompt}` };
431
- }
432
- }
433
- else if (result.passed) {
434
- this.stats.assertionsPassed++;
435
- handleAssertionSuccess(assertionPrompt, context, stepContext, assertionEventPayload);
227
+ const { responseId: newResponseId, summary: turnSummary } = await this.engine.runFullTurn(response, trackAction, context, stepContext, { currentScreenshot });
228
+ if (turnSummary.assertionStatus === "passed") {
229
+ this.stats.assertionsPassed++;
230
+ }
231
+ else if (turnSummary.assertionStatus === "failed" ||
232
+ turnSummary.assertionStatus === "failed_skipped") {
233
+ this.stats.assertionsFailed++;
234
+ }
235
+ if (turnSummary.retryInstruction) {
236
+ this.stats.retryCount++;
237
+ this.session.updateResponseId(turnSummary.responseId);
238
+ if (!turnSummary.preserveSessionMessages) {
239
+ this.session.clearMessages();
436
240
  }
241
+ return await this.executeInstruction(instruction, context, 0, stepContext);
242
+ }
243
+ if (turnSummary.failed) {
244
+ return { success: false, error: turnSummary.error || "Instruction failed." };
245
+ }
246
+ this.session.updateResponseId(turnSummary.responseId ?? newResponseId);
247
+ if (!turnSummary.preserveSessionMessages) {
248
+ this.session.clearMessages();
437
249
  }
438
- // Clear messages after each turn but KEEP the response chain for context
439
- // When continuing with previousResponseId, only send new user message (not system)
440
- this.session.clearMessages();
441
- // Note: we keep previousResponseId to maintain context across the test
442
250
  return { success: true };
443
251
  }
444
252
  catch (err) {