@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.
- package/README.md +0 -2
- package/build/index.js +24 -18
- package/build/src/brand/loadmill-mark.js +20 -0
- package/build/src/browser/playwright/connection.js +148 -11
- package/build/src/browser/playwright/welcome-page.js +161 -0
- package/build/src/cli/headless-execution-config.js +16 -24
- package/build/src/cli/headless-target.js +0 -9
- package/build/src/commands/help.js +3 -5
- package/build/src/commands/run.js +1 -1
- package/build/src/core/aim-assist.js +5 -0
- package/build/src/core/execution-engine.js +266 -505
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/runtime-config.js +1 -2
- package/build/src/core/screenshot-store.js +153 -0
- package/build/src/core/tool-handlers/assert.js +155 -0
- package/build/src/core/tool-handlers/loadmill.js +29 -0
- package/build/src/core/tool-handlers/navigate.js +110 -0
- package/build/src/core/tool-handlers/preview-click.js +112 -0
- package/build/src/core/tool-handlers/test-design-complete.js +93 -0
- package/build/src/core/tool-handlers/utils.js +80 -0
- package/build/src/device/openai.js +103 -4
- package/build/src/modes/design-mode-ink.js +17 -5
- package/build/src/modes/design-mode.js +17 -5
- package/build/src/modes/execution-mode.js +42 -234
- package/build/src/prompts/base.js +44 -31
- package/build/src/prompts/design.js +55 -15
- package/build/src/prompts/editor.js +1 -1
- package/build/src/prompts/execution.js +15 -10
- package/build/src/utils/cua-debug-tracer.js +3 -1
- package/package.json +3 -4
- 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
|
-
|
|
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,
|
|
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 {
|
|
9
|
-
import { isAssertion
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
201
|
+
const currentScreenshot = await this.engine.recordScreenshot?.(screenshotBase64, {
|
|
337
202
|
runId: context?.runId,
|
|
338
203
|
stepId: stepContext?.stepId,
|
|
339
204
|
instructionIndex: stepContext?.instructionIndex,
|
|
340
|
-
|
|
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:
|
|
208
|
+
messages: this.session.messages,
|
|
366
209
|
screenshotBase64,
|
|
367
|
-
previousResponseId:
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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) {
|