@loadmill/droid-cua 2.10.0 → 2.10.1-rc.9

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.
@@ -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
+ }
@@ -34,6 +34,99 @@ const previewClickTool = {
34
34
  additionalProperties: false,
35
35
  },
36
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
+ }
37
130
  function appendInitialScreenshot(input, screenshotBase64) {
38
131
  // Most callers end with the active user message, but some recovery flows can
39
132
  // reset the conversation to system-only state before the next screenshot send.
@@ -91,7 +184,7 @@ function buildContextManagement(contextOptimizationEnabled, contextOptimizationT
91
184
  }
92
185
  return [{ type: "compaction", compact_threshold: contextOptimizationThreshold }];
93
186
  }
94
- export function buildCuaRequestParams({ cuaModel, aimAssistMode = DEFAULT_AIM_ASSIST_MODE, 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 = {}, }) {
95
188
  const contextManagement = buildContextManagement(contextOptimizationEnabled, contextOptimizationThreshold);
96
189
  // Keep Responses API storage enabled on every turn so execution can chain
97
190
  // reliably through previous_response_id across the whole run.
@@ -103,12 +196,10 @@ export function buildCuaRequestParams({ cuaModel, aimAssistMode = DEFAULT_AIM_AS
103
196
  input,
104
197
  store: true,
105
198
  truncation: "auto",
106
- tools: aimAssistMode === "off"
107
- ? [{ type: "computer" }]
108
- : [
109
- { type: "computer" },
110
- previewClickTool,
111
- ],
199
+ tools: [
200
+ ...(includeComputerTool ? [{ type: "computer" }] : []),
201
+ ...buildFunctionTools({ aimAssistMode, toolCapabilities }),
202
+ ],
112
203
  };
113
204
  }
114
205
  /**
@@ -144,15 +235,17 @@ export async function reviseTestScript({ client, originalScript, revisionRequest
144
235
  * debugContext?: unknown,
145
236
  * }} options
146
237
  */
147
- export async function sendCUARequest({ client, cuaModel, aimAssistMode = DEFAULT_AIM_ASSIST_MODE, 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, }) {
148
239
  const input = buildCuaInput({ messages, screenshotBase64, previousResponseId, callId });
149
240
  const requestParams = buildCuaRequestParams({
150
241
  cuaModel,
151
242
  aimAssistMode,
152
243
  previousResponseId,
153
244
  input,
245
+ includeComputerTool,
154
246
  contextOptimizationEnabled,
155
247
  contextOptimizationThreshold,
248
+ toolCapabilities,
156
249
  });
157
250
  const trace = cuaDebugTracer.startTurn({
158
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";
@@ -35,6 +36,14 @@ export class DesignModeInk {
35
36
  const designPrompt = buildDesignModePrompt(this.session.deviceInfo, {});
36
37
  this.baseDesignPrompt = designPrompt;
37
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;
38
47
  // Update UI
39
48
  if (this.context.setMode) {
40
49
  this.context.setMode('design');
@@ -182,14 +191,19 @@ export class DesignModeInk {
182
191
  }
183
192
  // Get screenshot and send to model
184
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
+ });
185
198
  const response = await this.sendCUARequest({
186
199
  messages: this.session.messages,
187
200
  screenshotBase64,
188
201
  previousResponseId: this.session.previousResponseId,
189
202
  deviceInfo: this.session.deviceInfo,
203
+ toolCapabilities: this.engine.toolCapabilities,
190
204
  });
191
205
  // Run turn with action tracking and interruption checking
192
- const newResponseId = await this.engine.runFullTurn(response, (action) => {
206
+ const { responseId: newResponseId } = await this.engine.runFullTurn(response, (action) => {
193
207
  // Check if user interrupted - stop immediately
194
208
  if (this.inputQueue.length > 0) {
195
209
  return true; // Signal to stop execution
@@ -205,7 +219,7 @@ export class DesignModeInk {
205
219
  return true; // Stop for guidance
206
220
  }
207
221
  return false; // Continue execution
208
- }, this.context);
222
+ }, this.context, null, { currentScreenshot });
209
223
  this.session.updateResponseId(newResponseId);
210
224
  this.consecutiveErrorCount = 0;
211
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";
@@ -32,6 +33,14 @@ export class DesignMode {
32
33
  const designPrompt = buildDesignModePrompt(this.session.deviceInfo, {});
33
34
  this.baseDesignPrompt = designPrompt;
34
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;
35
44
  console.log(`\n=== Design Mode: Creating test "${this.testName}" ===`);
36
45
  console.log("Describe what you want to test. The agent will explore autonomously.");
37
46
  console.log('When ready, type "generate the script" to create the test.');
@@ -146,14 +155,19 @@ export class DesignMode {
146
155
  const keypressHandler = this.setupEscDetection();
147
156
  // Get screenshot and send to model
148
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
+ });
149
162
  const response = await this.sendCUARequest({
150
163
  messages: this.session.messages,
151
164
  screenshotBase64,
152
165
  previousResponseId: this.session.previousResponseId,
153
166
  deviceInfo: this.session.deviceInfo,
167
+ toolCapabilities: this.engine.toolCapabilities,
154
168
  });
155
169
  // Run turn with action tracking and interruption checking
156
- const newResponseId = await this.engine.runFullTurn(response, (action) => {
170
+ const { responseId: newResponseId } = await this.engine.runFullTurn(response, (action) => {
157
171
  // Check if user pressed ESC - stop immediately
158
172
  if (this.escPressed) {
159
173
  return true; // Signal to stop execution
@@ -170,7 +184,7 @@ export class DesignMode {
170
184
  this.session._shouldStopForGuidance = true;
171
185
  }
172
186
  return false; // Continue execution
173
- });
187
+ }, null, null, { currentScreenshot });
174
188
  this.session.updateResponseId(newResponseId);
175
189
  this.consecutiveErrorCount = 0;
176
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) {