@loadmill/droid-cua 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/build/index.js +89 -71
- package/build/src/brand/loadmill-mark.js +20 -0
- package/build/src/browser/playwright/connection.js +160 -12
- package/build/src/browser/playwright/welcome-page.js +161 -0
- package/build/src/cli/headless-execution-config.js +18 -58
- package/build/src/cli/headless-target.js +0 -9
- package/build/src/cli/ink-shell.js +5 -2
- package/build/src/commands/help.js +5 -8
- package/build/src/commands/loadmill.js +10 -1
- package/build/src/commands/run.js +17 -28
- package/build/src/core/aim-assist.js +5 -0
- package/build/src/core/app-context.js +57 -33
- package/build/src/core/context-optimization.js +1 -1
- package/build/src/core/cua-models.js +9 -0
- package/build/src/core/execution-engine.js +121 -199
- package/build/src/core/runtime-config.js +95 -0
- package/build/src/device/loadmill.js +15 -9
- package/build/src/device/openai.js +76 -142
- package/build/src/integrations/loadmill/client.js +149 -131
- package/build/src/integrations/loadmill/executor.js +17 -11
- package/build/src/integrations/loadmill/index.js +1 -1
- package/build/src/integrations/loadmill/interpreter.js +4 -14
- package/build/src/modes/design-mode-ink.js +5 -6
- package/build/src/modes/design-mode.js +5 -6
- package/build/src/modes/execution-mode.js +26 -0
- package/build/src/prompts/base.js +44 -30
- package/build/src/prompts/design.js +3 -4
- package/build/src/prompts/execution.js +13 -110
- package/build/src/utils/console-output.js +8 -0
- package/build/src/utils/cua-debug-tracer.js +0 -1
- package/build/src/utils/structured-debug-log-manager.js +4 -1
- package/package.json +1 -1
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import OpenAI from "openai";
|
|
2
|
-
import { readContextOptimizationEnv } from "../core/context-optimization.js";
|
|
3
1
|
import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
|
|
4
|
-
import {
|
|
2
|
+
import { createOpenAiClient, resolveRuntimeConfig } from "../core/runtime-config.js";
|
|
3
|
+
import { DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
|
|
4
|
+
import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD } from "../core/context-optimization.js";
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
6
|
import { CuaDebugTracer } from "../utils/cua-debug-tracer.js";
|
|
7
|
-
let openai = null;
|
|
8
7
|
const cuaDebugTracer = new CuaDebugTracer(logger);
|
|
9
|
-
const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
|
|
10
|
-
function getSelectedCuaModel() {
|
|
11
|
-
return VALID_CUA_MODELS.has(process.env.OPENAI_CUA_MODEL) ? process.env.OPENAI_CUA_MODEL : "gpt-5.4";
|
|
12
|
-
}
|
|
13
8
|
function normalizeMessageContent(content) {
|
|
14
9
|
if (Array.isArray(content)) {
|
|
15
10
|
return content.map((item) => {
|
|
@@ -90,37 +85,16 @@ export function buildCuaInput({ messages, screenshotBase64, previousResponseId,
|
|
|
90
85
|
}
|
|
91
86
|
return input;
|
|
92
87
|
}
|
|
93
|
-
function buildContextManagement(
|
|
94
|
-
if (
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
const { enabled, threshold } = readContextOptimizationEnv();
|
|
98
|
-
if (!enabled) {
|
|
88
|
+
function buildContextManagement(contextOptimizationEnabled, contextOptimizationThreshold) {
|
|
89
|
+
if (!contextOptimizationEnabled) {
|
|
99
90
|
return undefined;
|
|
100
91
|
}
|
|
101
|
-
return [{ type: "compaction", compact_threshold:
|
|
92
|
+
return [{ type: "compaction", compact_threshold: contextOptimizationThreshold }];
|
|
102
93
|
}
|
|
103
|
-
export function buildCuaRequestParams({ cuaModel,
|
|
104
|
-
const contextManagement = buildContextManagement(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
model: cuaModel,
|
|
108
|
-
previous_response_id: previousResponseId || undefined,
|
|
109
|
-
input,
|
|
110
|
-
store: true,
|
|
111
|
-
truncation: "auto",
|
|
112
|
-
tools: [
|
|
113
|
-
{
|
|
114
|
-
type: "computer_use_preview",
|
|
115
|
-
display_width: deviceInfo.scaled_width,
|
|
116
|
-
display_height: deviceInfo.scaled_height,
|
|
117
|
-
environment: "browser",
|
|
118
|
-
},
|
|
119
|
-
previewClickTool,
|
|
120
|
-
],
|
|
121
|
-
reasoning: { summary: "concise" },
|
|
122
|
-
};
|
|
123
|
-
}
|
|
94
|
+
export function buildCuaRequestParams({ cuaModel, aimAssistMode = DEFAULT_AIM_ASSIST_MODE, previousResponseId, input, contextOptimizationEnabled = true, contextOptimizationThreshold = DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, }) {
|
|
95
|
+
const contextManagement = buildContextManagement(contextOptimizationEnabled, contextOptimizationThreshold);
|
|
96
|
+
// Keep Responses API storage enabled on every turn so execution can chain
|
|
97
|
+
// reliably through previous_response_id across the whole run.
|
|
124
98
|
return {
|
|
125
99
|
model: cuaModel,
|
|
126
100
|
reasoning: { effort: "medium" },
|
|
@@ -129,98 +103,26 @@ export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo
|
|
|
129
103
|
input,
|
|
130
104
|
store: true,
|
|
131
105
|
truncation: "auto",
|
|
132
|
-
tools:
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
106
|
+
tools: aimAssistMode === "off"
|
|
107
|
+
? [{ type: "computer" }]
|
|
108
|
+
: [
|
|
109
|
+
{ type: "computer" },
|
|
110
|
+
previewClickTool,
|
|
111
|
+
],
|
|
138
112
|
};
|
|
139
113
|
}
|
|
140
|
-
function shouldMapPreviewAccessError(err) {
|
|
141
|
-
const status = err?.status;
|
|
142
|
-
const code = typeof err?.code === "string" ? err.code.toLowerCase() : "";
|
|
143
|
-
const type = typeof err?.type === "string" ? err.type.toLowerCase() : "";
|
|
144
|
-
const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
|
|
145
|
-
if (status === 403 || status === 404)
|
|
146
|
-
return true;
|
|
147
|
-
if (code.includes("model_not_found") || code.includes("permission"))
|
|
148
|
-
return true;
|
|
149
|
-
if (type.includes("permission"))
|
|
150
|
-
return true;
|
|
151
|
-
return (message.includes("computer-use-preview") &&
|
|
152
|
-
(message.includes("access") || message.includes("permission") || message.includes("not found") || message.includes("unsupported")));
|
|
153
|
-
}
|
|
154
|
-
export function shouldMapMiniCompatibilityError(err) {
|
|
155
|
-
const status = Number(err?.status);
|
|
156
|
-
const code = typeof err?.code === "string" ? err.code.toLowerCase() : "";
|
|
157
|
-
const type = typeof err?.type === "string" ? err.type.toLowerCase() : "";
|
|
158
|
-
const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
|
|
159
|
-
const mentionsMiniModel = message.includes("gpt-5.4-mini");
|
|
160
|
-
const mentionsComputerTool = message.includes("computer") || message.includes("tool");
|
|
161
|
-
const mentionsUnsupported = (message.includes("unsupported") ||
|
|
162
|
-
message.includes("not supported") ||
|
|
163
|
-
message.includes("does not support"));
|
|
164
|
-
if (code.includes("model_not_found")) {
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
if (status === 404) {
|
|
168
|
-
return mentionsMiniModel && (message.includes("not found") || mentionsUnsupported);
|
|
169
|
-
}
|
|
170
|
-
if (code.includes("unsupported")) {
|
|
171
|
-
return mentionsMiniModel || mentionsComputerTool;
|
|
172
|
-
}
|
|
173
|
-
if (!type.includes("invalid")) {
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
|
-
return mentionsUnsupported && (mentionsMiniModel || mentionsComputerTool);
|
|
177
|
-
}
|
|
178
|
-
function mapCuaError(err, cuaModel) {
|
|
179
|
-
if (cuaModel === "computer-use-preview" && shouldMapPreviewAccessError(err)) {
|
|
180
|
-
const mapped = new Error("OpenAI API key does not have access to computer-use-preview. Switch to gpt-5.4 in Settings > CUA Model.");
|
|
181
|
-
mapped.cause = err;
|
|
182
|
-
return mapped;
|
|
183
|
-
}
|
|
184
|
-
if (cuaModel === "gpt-5.4-mini" && shouldMapMiniCompatibilityError(err)) {
|
|
185
|
-
const mapped = new Error("OpenAI rejected gpt-5.4-mini for computer-use execution. Switch to gpt-5.4 or computer-use-preview in Settings > CUA Model.");
|
|
186
|
-
mapped.cause = err;
|
|
187
|
-
return mapped;
|
|
188
|
-
}
|
|
189
|
-
return err;
|
|
190
|
-
}
|
|
191
|
-
export function isNonRetryableCuaError(err) {
|
|
192
|
-
const status = Number(err?.status);
|
|
193
|
-
const code = typeof err?.code === "string" ? err.code.toLowerCase() : "";
|
|
194
|
-
const type = typeof err?.type === "string" ? err.type.toLowerCase() : "";
|
|
195
|
-
const message = typeof err?.message === "string" ? err.message.toLowerCase() : "";
|
|
196
|
-
if ([401, 403, 404].includes(status)) {
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
return (code.includes("model_not_found") ||
|
|
200
|
-
code.includes("permission") ||
|
|
201
|
-
type.includes("permission") ||
|
|
202
|
-
message.includes("does not have access to computer-use-preview") ||
|
|
203
|
-
message.includes("switch to gpt-5.4 in settings > cua model") ||
|
|
204
|
-
message.includes("openai rejected gpt-5.4-mini for computer-use execution") ||
|
|
205
|
-
message.includes("switch to gpt-5.4 or computer-use-preview in settings > cua model"));
|
|
206
|
-
}
|
|
207
|
-
function getOpenAI() {
|
|
208
|
-
if (!openai) {
|
|
209
|
-
openai = new OpenAI({
|
|
210
|
-
apiKey: process.env.OPENAI_API_KEY,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
return openai;
|
|
214
|
-
}
|
|
215
114
|
/**
|
|
216
115
|
* Revise a test script based on user feedback using simple chat completion
|
|
217
|
-
* @param {
|
|
218
|
-
*
|
|
116
|
+
* @param {{
|
|
117
|
+
* client: import("openai").default,
|
|
118
|
+
* originalScript: string,
|
|
119
|
+
* revisionRequest: string,
|
|
120
|
+
* }} options
|
|
219
121
|
* @returns {Promise<string>} - The revised test script
|
|
220
122
|
*/
|
|
221
|
-
export async function reviseTestScript(originalScript, revisionRequest) {
|
|
222
|
-
const response = await
|
|
223
|
-
model: "gpt-
|
|
123
|
+
export async function reviseTestScript({ client, originalScript, revisionRequest }) {
|
|
124
|
+
const response = await client.chat.completions.create({
|
|
125
|
+
model: "gpt-5.4",
|
|
224
126
|
messages: [{
|
|
225
127
|
role: "system",
|
|
226
128
|
content: buildTestRevisionSystemPrompt(originalScript, revisionRequest)
|
|
@@ -228,29 +130,29 @@ export async function reviseTestScript(originalScript, revisionRequest) {
|
|
|
228
130
|
});
|
|
229
131
|
return response.choices[0].message.content.trim();
|
|
230
132
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
export async function sendCUARequest({ messages, screenshotBase64, previousResponseId, callId, deviceInfo, debugContext, }) {
|
|
247
|
-
const cuaModel = getSelectedCuaModel();
|
|
133
|
+
/**
|
|
134
|
+
* @param {{
|
|
135
|
+
* client: import("openai").default,
|
|
136
|
+
* cuaModel: string,
|
|
137
|
+
* contextOptimizationEnabled?: boolean,
|
|
138
|
+
* contextOptimizationThreshold?: number,
|
|
139
|
+
* messages: Array<unknown>,
|
|
140
|
+
* screenshotBase64?: string,
|
|
141
|
+
* previousResponseId?: string | null,
|
|
142
|
+
* callId?: string | null,
|
|
143
|
+
* deviceInfo?: unknown,
|
|
144
|
+
* debugContext?: unknown,
|
|
145
|
+
* }} options
|
|
146
|
+
*/
|
|
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, }) {
|
|
248
148
|
const input = buildCuaInput({ messages, screenshotBase64, previousResponseId, callId });
|
|
249
149
|
const requestParams = buildCuaRequestParams({
|
|
250
150
|
cuaModel,
|
|
151
|
+
aimAssistMode,
|
|
251
152
|
previousResponseId,
|
|
252
|
-
deviceInfo,
|
|
253
153
|
input,
|
|
154
|
+
contextOptimizationEnabled,
|
|
155
|
+
contextOptimizationThreshold,
|
|
254
156
|
});
|
|
255
157
|
const trace = cuaDebugTracer.startTurn({
|
|
256
158
|
requestParams,
|
|
@@ -262,12 +164,44 @@ export async function sendCUARequest({ messages, screenshotBase64, previousRespo
|
|
|
262
164
|
});
|
|
263
165
|
logger.debug("CUA Request:", trace.requestLog);
|
|
264
166
|
try {
|
|
265
|
-
const response = await
|
|
167
|
+
const response = await client.responses.create(requestParams);
|
|
266
168
|
cuaDebugTracer.onResponse(trace, response);
|
|
267
169
|
return response;
|
|
268
170
|
}
|
|
269
171
|
catch (err) {
|
|
270
172
|
cuaDebugTracer.onError(trace, err);
|
|
271
|
-
throw
|
|
173
|
+
throw err;
|
|
272
174
|
}
|
|
273
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* @param {{
|
|
178
|
+
* client?: import("openai").default | null,
|
|
179
|
+
* config?: Record<string, unknown>,
|
|
180
|
+
* }} options
|
|
181
|
+
*/
|
|
182
|
+
export function createOpenAiExecutor({ client, config }) {
|
|
183
|
+
const runtimeConfig = resolveRuntimeConfig(config);
|
|
184
|
+
const openAiClient = client || createOpenAiClient(null);
|
|
185
|
+
return {
|
|
186
|
+
client: openAiClient,
|
|
187
|
+
config: runtimeConfig,
|
|
188
|
+
async sendCUARequest(args) {
|
|
189
|
+
// Bind model and request-shaping config to the active run so execution
|
|
190
|
+
// stays deterministic even if settings change after the run starts.
|
|
191
|
+
return await sendCUARequest({
|
|
192
|
+
client: openAiClient,
|
|
193
|
+
cuaModel: runtimeConfig.cuaModel,
|
|
194
|
+
contextOptimizationEnabled: runtimeConfig.contextOptimizationEnabled,
|
|
195
|
+
contextOptimizationThreshold: runtimeConfig.contextOptimizationThreshold,
|
|
196
|
+
...args,
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
async reviseTestScript(originalScript, revisionRequest) {
|
|
200
|
+
return await reviseTestScript({
|
|
201
|
+
client: openAiClient,
|
|
202
|
+
originalScript,
|
|
203
|
+
revisionRequest,
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -20,153 +20,171 @@ function normalizeApiBaseUrl(rawBaseUrl) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* @param {{
|
|
24
|
+
* token?: string | null,
|
|
25
|
+
* baseUrl?: string | null,
|
|
26
|
+
* onUnauthorized?: (() => Promise<unknown> | unknown) | null,
|
|
27
|
+
* }} [options]
|
|
25
28
|
*/
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*/
|
|
33
|
-
export function getBaseUrl() {
|
|
34
|
-
return normalizeApiBaseUrl(process.env.LOADMILL_BASE_URL);
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Make an authenticated request to Loadmill API
|
|
38
|
-
* @param {string} endpoint - API endpoint (without base URL)
|
|
39
|
-
* @param {Object} options - Fetch options
|
|
40
|
-
* @returns {Promise<Object>}
|
|
41
|
-
*/
|
|
42
|
-
async function apiRequest(endpoint, options = {}) {
|
|
43
|
-
const token = getApiToken();
|
|
44
|
-
if (!token) {
|
|
45
|
-
throw new Error("LOADMILL_API_TOKEN environment variable is not set");
|
|
29
|
+
export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauthorized = null, } = {}) {
|
|
30
|
+
function getApiToken() {
|
|
31
|
+
return typeof token === "string" && token.trim() ? token.trim() : null;
|
|
32
|
+
}
|
|
33
|
+
function getBaseUrl() {
|
|
34
|
+
return normalizeApiBaseUrl(baseUrl);
|
|
46
35
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
36
|
+
async function apiRequest(endpoint, options = {}) {
|
|
37
|
+
const resolvedToken = getApiToken();
|
|
38
|
+
if (!resolvedToken) {
|
|
39
|
+
throw new Error("LOADMILL_API_TOKEN environment variable is not set");
|
|
40
|
+
}
|
|
41
|
+
const resolvedBaseUrl = getBaseUrl();
|
|
42
|
+
const url = `${resolvedBaseUrl}${endpoint}`;
|
|
43
|
+
const response = await fetch(url, {
|
|
44
|
+
...options,
|
|
45
|
+
headers: {
|
|
46
|
+
"Authorization": `Bearer ${resolvedToken}`,
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
...options.headers,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if ((response.status === 401 || response.status === 403) && typeof onUnauthorized === "function") {
|
|
52
|
+
await onUnauthorized();
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const errorText = await response.text();
|
|
56
|
+
throw new Error(`Loadmill API error (${response.status}): ${errorText}`);
|
|
61
57
|
}
|
|
58
|
+
return response.json();
|
|
62
59
|
}
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
60
|
+
async function searchFlows(searchQuery, options = {}) {
|
|
61
|
+
const { limit = 10 } = options;
|
|
62
|
+
const encodedQuery = encodeURIComponent(searchQuery);
|
|
63
|
+
const result = await apiRequest(`/test-flows?search=${encodedQuery}&limit=${limit}`);
|
|
64
|
+
if (Array.isArray(result)) {
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
if (result && Array.isArray(result.flows)) {
|
|
68
|
+
return result.flows;
|
|
69
|
+
}
|
|
70
|
+
if (result && Array.isArray(result.data)) {
|
|
71
|
+
return result.data;
|
|
72
|
+
}
|
|
73
|
+
if (result && Array.isArray(result.items)) {
|
|
74
|
+
return result.items;
|
|
75
|
+
}
|
|
76
|
+
if (result && Array.isArray(result.testFlows)) {
|
|
77
|
+
return result.testFlows;
|
|
78
|
+
}
|
|
79
|
+
console.error("[Loadmill] Unexpected API response format:", JSON.stringify(result, null, 2));
|
|
80
|
+
return [];
|
|
66
81
|
}
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const result = await apiRequest(`/test-flows?search=${encodedQuery}&limit=${limit}`);
|
|
80
|
-
// Handle different response formats
|
|
81
|
-
if (Array.isArray(result)) {
|
|
82
|
-
return result;
|
|
82
|
+
async function runTestSuite(suiteId, { flowIds = [], parameters = {} } = {}) {
|
|
83
|
+
const body = {};
|
|
84
|
+
if (flowIds.length > 0) {
|
|
85
|
+
body.flowIds = flowIds;
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(parameters).length > 0) {
|
|
88
|
+
body.parameters = parameters;
|
|
89
|
+
}
|
|
90
|
+
return await apiRequest(`/test-suites/${suiteId}/run`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
});
|
|
83
94
|
}
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
async function runTestFlow(flowId, suiteId, { parameters = {} } = {}) {
|
|
96
|
+
const body = {
|
|
97
|
+
flows: [flowId],
|
|
98
|
+
inlineParameterOverride: true,
|
|
99
|
+
sharedFlowVersionOverrides: [],
|
|
100
|
+
};
|
|
101
|
+
if (Object.keys(parameters).length > 0) {
|
|
102
|
+
body.overrideParameters = parameters;
|
|
103
|
+
}
|
|
104
|
+
return await apiRequest(`/test-suites/${suiteId}/run?ui=true`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
body: JSON.stringify(body),
|
|
107
|
+
});
|
|
86
108
|
}
|
|
87
|
-
|
|
88
|
-
return
|
|
109
|
+
async function getTestRunStatus(testRunId) {
|
|
110
|
+
return await apiRequest(`/test-suites-runs/${testRunId}`);
|
|
89
111
|
}
|
|
90
|
-
|
|
91
|
-
return
|
|
112
|
+
async function getFlowRunStatus(flowRunId) {
|
|
113
|
+
return await apiRequest(`/test-flows-runs/${flowRunId}`);
|
|
92
114
|
}
|
|
93
|
-
|
|
94
|
-
return
|
|
115
|
+
async function explainFailure(testRunId) {
|
|
116
|
+
return await apiRequest(`/explain-failures`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
testRunId,
|
|
120
|
+
testRunType: "flowRun"
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
95
123
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
return {
|
|
125
|
+
getApiToken,
|
|
126
|
+
getBaseUrl,
|
|
127
|
+
searchFlows,
|
|
128
|
+
runTestSuite,
|
|
129
|
+
runTestFlow,
|
|
130
|
+
getTestRunStatus,
|
|
131
|
+
getFlowRunStatus,
|
|
132
|
+
explainFailure,
|
|
133
|
+
};
|
|
99
134
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
* @param {Object} options.parameters - Parameters to pass to the flows
|
|
106
|
-
* @returns {Promise<Object>} - Run result with runId
|
|
107
|
-
*/
|
|
108
|
-
export async function runTestSuite(suiteId, { flowIds = [], parameters = {} } = {}) {
|
|
109
|
-
const body = {};
|
|
110
|
-
if (flowIds.length > 0) {
|
|
111
|
-
body.flowIds = flowIds;
|
|
112
|
-
}
|
|
113
|
-
if (Object.keys(parameters).length > 0) {
|
|
114
|
-
body.parameters = parameters;
|
|
115
|
-
}
|
|
116
|
-
return apiRequest(`/test-suites/${suiteId}/run`, {
|
|
117
|
-
method: "POST",
|
|
118
|
-
body: JSON.stringify(body),
|
|
119
|
-
});
|
|
135
|
+
export function getApiToken() {
|
|
136
|
+
return createLoadmillApiClient({
|
|
137
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
138
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
139
|
+
}).getApiToken();
|
|
120
140
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
141
|
+
export function getBaseUrl() {
|
|
142
|
+
return createLoadmillApiClient({
|
|
143
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
144
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
145
|
+
}).getBaseUrl();
|
|
146
|
+
}
|
|
147
|
+
export async function searchFlows(searchQuery, options = {}) {
|
|
148
|
+
// Legacy env-based wrapper kept for older call sites. New execution flows
|
|
149
|
+
// should pass an explicit client created from resolved run credentials.
|
|
150
|
+
return await createLoadmillApiClient({
|
|
151
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
152
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
153
|
+
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
154
|
+
}).searchFlows(searchQuery, options);
|
|
155
|
+
}
|
|
156
|
+
export async function runTestSuite(suiteId, options = {}) {
|
|
157
|
+
return await createLoadmillApiClient({
|
|
158
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
159
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
160
|
+
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
161
|
+
}).runTestSuite(suiteId, options);
|
|
162
|
+
}
|
|
163
|
+
export async function runTestFlow(flowId, suiteId, options = {}) {
|
|
164
|
+
return await createLoadmillApiClient({
|
|
165
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
166
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
167
|
+
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
168
|
+
}).runTestFlow(flowId, suiteId, options);
|
|
142
169
|
}
|
|
143
|
-
/**
|
|
144
|
-
* Get the status of a test run
|
|
145
|
-
* @param {string} testRunId - Test run ID
|
|
146
|
-
* @returns {Promise<Object>} - Test run status
|
|
147
|
-
*/
|
|
148
170
|
export async function getTestRunStatus(testRunId) {
|
|
149
|
-
return
|
|
171
|
+
return await createLoadmillApiClient({
|
|
172
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
173
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
174
|
+
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
175
|
+
}).getTestRunStatus(testRunId);
|
|
150
176
|
}
|
|
151
|
-
/**
|
|
152
|
-
* Get the status of a flow run
|
|
153
|
-
* @param {string} flowRunId - Flow run ID
|
|
154
|
-
* @returns {Promise<Object>} - Flow run status
|
|
155
|
-
*/
|
|
156
177
|
export async function getFlowRunStatus(flowRunId) {
|
|
157
|
-
return
|
|
178
|
+
return await createLoadmillApiClient({
|
|
179
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
180
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
181
|
+
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
182
|
+
}).getFlowRunStatus(flowRunId);
|
|
158
183
|
}
|
|
159
|
-
/**
|
|
160
|
-
* Get AI-generated explanation for a failed test run
|
|
161
|
-
* @param {string} testRunId - The flow run ID (from testSuiteFlowRuns[].id)
|
|
162
|
-
* @returns {Promise<Object>} - Explanation with reason, suggestion, etc.
|
|
163
|
-
*/
|
|
164
184
|
export async function explainFailure(testRunId) {
|
|
165
|
-
return
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}),
|
|
171
|
-
});
|
|
185
|
+
return await createLoadmillApiClient({
|
|
186
|
+
token: process.env.LOADMILL_API_TOKEN,
|
|
187
|
+
baseUrl: process.env.LOADMILL_BASE_URL,
|
|
188
|
+
onUnauthorized: globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__,
|
|
189
|
+
}).explainFailure(testRunId);
|
|
172
190
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Orchestrates Loadmill flow execution
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
4
|
+
import { createLoadmillApiClient } from "./client.js";
|
|
5
5
|
import { interpretLoadmillCommand, selectBestFlow } from "./interpreter.js";
|
|
6
6
|
import { logger } from "../../utils/logger.js";
|
|
7
7
|
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
|
@@ -20,11 +20,11 @@ function sleep(ms) {
|
|
|
20
20
|
* @param {Function} onStatusUpdate - Callback for status updates
|
|
21
21
|
* @returns {Promise<Object>} - Final run status
|
|
22
22
|
*/
|
|
23
|
-
async function pollForCompletion(runId, onStatusUpdate = () => { }) {
|
|
23
|
+
async function pollForCompletion(runId, loadmillClient, onStatusUpdate = () => { }) {
|
|
24
24
|
const startTime = Date.now();
|
|
25
25
|
while (Date.now() - startTime < TIMEOUT_MS) {
|
|
26
26
|
try {
|
|
27
|
-
const status = await getTestRunStatus(runId);
|
|
27
|
+
const status = await loadmillClient.getTestRunStatus(runId);
|
|
28
28
|
onStatusUpdate(status);
|
|
29
29
|
// Check if run is complete
|
|
30
30
|
if (status.status === "PASSED" || status.status === "passed") {
|
|
@@ -36,7 +36,7 @@ async function pollForCompletion(runId, onStatusUpdate = () => { }) {
|
|
|
36
36
|
try {
|
|
37
37
|
const failedFlowRun = status.testSuiteFlowRuns?.find(f => f.status === "FAILED");
|
|
38
38
|
if (failedFlowRun) {
|
|
39
|
-
const explanation = await explainFailure(failedFlowRun.id);
|
|
39
|
+
const explanation = await loadmillClient.explainFailure(failedFlowRun.id);
|
|
40
40
|
if (explanation?.reason) {
|
|
41
41
|
failureReason = explanation.reason;
|
|
42
42
|
}
|
|
@@ -69,22 +69,28 @@ async function pollForCompletion(runId, onStatusUpdate = () => { }) {
|
|
|
69
69
|
* @returns {Promise<{success: boolean, error?: string, result?: Object}>}
|
|
70
70
|
*/
|
|
71
71
|
export async function executeLoadmillCommand(userInput, options = {}) {
|
|
72
|
-
const { onProgress = () => { } } = options;
|
|
72
|
+
const { onProgress = () => { }, loadmillClient = createLoadmillApiClient(), openAiClient = null, } = options;
|
|
73
73
|
// Check for API token
|
|
74
|
-
if (!getApiToken()) {
|
|
74
|
+
if (!loadmillClient.getApiToken()) {
|
|
75
75
|
return {
|
|
76
76
|
success: false,
|
|
77
77
|
error: "LOADMILL_API_TOKEN environment variable is not set. Please set it in your .env file."
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
if (!openAiClient) {
|
|
81
|
+
return {
|
|
82
|
+
success: false,
|
|
83
|
+
error: "OpenAI API key is not configured."
|
|
84
|
+
};
|
|
85
|
+
}
|
|
80
86
|
try {
|
|
81
87
|
// Step 1: Interpret the command
|
|
82
88
|
onProgress({ step: "interpreting", message: "Interpreting command..." });
|
|
83
|
-
const interpreted = await interpretLoadmillCommand(userInput);
|
|
89
|
+
const interpreted = await interpretLoadmillCommand(userInput, { client: openAiClient });
|
|
84
90
|
logger.debug("Loadmill command interpreted", interpreted);
|
|
85
91
|
// Step 2: Search for flows
|
|
86
92
|
onProgress({ step: "searching", message: `Searching for flows matching "${interpreted.searchQuery}"...` });
|
|
87
|
-
const flows = await searchFlows(interpreted.searchQuery);
|
|
93
|
+
const flows = await loadmillClient.searchFlows(interpreted.searchQuery);
|
|
88
94
|
// Ensure flows is an array
|
|
89
95
|
if (!Array.isArray(flows) || flows.length === 0) {
|
|
90
96
|
return {
|
|
@@ -94,7 +100,7 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
94
100
|
}
|
|
95
101
|
// Step 3: Select best match
|
|
96
102
|
onProgress({ step: "selecting", message: `Found ${flows.length} flow(s). Selecting best match...` });
|
|
97
|
-
const { selectedFlow, confidence } = await selectBestFlow(flows, interpreted.searchQuery);
|
|
103
|
+
const { selectedFlow, confidence } = await selectBestFlow(flows, interpreted.searchQuery, { client: openAiClient });
|
|
98
104
|
if (!selectedFlow) {
|
|
99
105
|
return {
|
|
100
106
|
success: false,
|
|
@@ -119,7 +125,7 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
119
125
|
step: "running",
|
|
120
126
|
message: `Running flow "${flowName}" (confidence: ${(confidence * 100).toFixed(0)}%)...`
|
|
121
127
|
});
|
|
122
|
-
const runResult = await runTestFlow(selectedFlow.id, selectedFlow.testSuiteId, {
|
|
128
|
+
const runResult = await loadmillClient.runTestFlow(selectedFlow.id, selectedFlow.testSuiteId, {
|
|
123
129
|
parameters: interpreted.parameters
|
|
124
130
|
});
|
|
125
131
|
const runId = runResult.testSuiteRunId || runResult.id || runResult.runId;
|
|
@@ -131,7 +137,7 @@ export async function executeLoadmillCommand(userInput, options = {}) {
|
|
|
131
137
|
}
|
|
132
138
|
// Step 5: Poll for completion
|
|
133
139
|
onProgress({ step: "polling", message: `Test started (ID: ${runId}). Waiting for completion...`, runId });
|
|
134
|
-
const finalResult = await pollForCompletion(runId, (status) => {
|
|
140
|
+
const finalResult = await pollForCompletion(runId, loadmillClient, (status) => {
|
|
135
141
|
onProgress({ step: "polling", message: `Status: ${status.status}...`, runId });
|
|
136
142
|
});
|
|
137
143
|
return {
|