@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.
Files changed (33) hide show
  1. package/README.md +7 -6
  2. package/build/index.js +89 -71
  3. package/build/src/brand/loadmill-mark.js +20 -0
  4. package/build/src/browser/playwright/connection.js +160 -12
  5. package/build/src/browser/playwright/welcome-page.js +161 -0
  6. package/build/src/cli/headless-execution-config.js +18 -58
  7. package/build/src/cli/headless-target.js +0 -9
  8. package/build/src/cli/ink-shell.js +5 -2
  9. package/build/src/commands/help.js +5 -8
  10. package/build/src/commands/loadmill.js +10 -1
  11. package/build/src/commands/run.js +17 -28
  12. package/build/src/core/aim-assist.js +5 -0
  13. package/build/src/core/app-context.js +57 -33
  14. package/build/src/core/context-optimization.js +1 -1
  15. package/build/src/core/cua-models.js +9 -0
  16. package/build/src/core/execution-engine.js +121 -199
  17. package/build/src/core/runtime-config.js +95 -0
  18. package/build/src/device/loadmill.js +15 -9
  19. package/build/src/device/openai.js +76 -142
  20. package/build/src/integrations/loadmill/client.js +149 -131
  21. package/build/src/integrations/loadmill/executor.js +17 -11
  22. package/build/src/integrations/loadmill/index.js +1 -1
  23. package/build/src/integrations/loadmill/interpreter.js +4 -14
  24. package/build/src/modes/design-mode-ink.js +5 -6
  25. package/build/src/modes/design-mode.js +5 -6
  26. package/build/src/modes/execution-mode.js +26 -0
  27. package/build/src/prompts/base.js +44 -30
  28. package/build/src/prompts/design.js +3 -4
  29. package/build/src/prompts/execution.js +13 -110
  30. package/build/src/utils/console-output.js +8 -0
  31. package/build/src/utils/cua-debug-tracer.js +0 -1
  32. package/build/src/utils/structured-debug-log-manager.js +4 -1
  33. 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 { buildAppContextCompactionInput } from "../prompts/execution.js";
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(cuaModel) {
94
- if (cuaModel === "computer-use-preview") {
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: threshold }];
92
+ return [{ type: "compaction", compact_threshold: contextOptimizationThreshold }];
102
93
  }
103
- export function buildCuaRequestParams({ cuaModel, previousResponseId, deviceInfo, input }) {
104
- const contextManagement = buildContextManagement(cuaModel);
105
- if (cuaModel === "computer-use-preview") {
106
- return {
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
- type: "computer",
135
- },
136
- previewClickTool,
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 {string} originalScript - The original test script
218
- * @param {string} revisionRequest - User's requested changes
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 getOpenAI().chat.completions.create({
223
- model: "gpt-4o",
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
- export async function compactAppContext({ contextDocument, taskDescription, tokenBudget }) {
232
- const response = await getOpenAI().responses.create({
233
- model: "gpt-5.4",
234
- temperature: 0,
235
- input: buildAppContextCompactionInput({
236
- contextDocument,
237
- taskDescription,
238
- tokenBudget,
239
- })
240
- });
241
- return {
242
- briefing: typeof response.output_text === "string" ? response.output_text.trim() : "",
243
- outputTokens: typeof response.usage?.output_tokens === "number" ? response.usage.output_tokens : null,
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 getOpenAI().responses.create(requestParams);
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 mapCuaError(err, cuaModel);
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
- * Get Loadmill API token from environment
24
- * @returns {string|null}
23
+ * @param {{
24
+ * token?: string | null,
25
+ * baseUrl?: string | null,
26
+ * onUnauthorized?: (() => Promise<unknown> | unknown) | null,
27
+ * }} [options]
25
28
  */
26
- export function getApiToken() {
27
- return process.env.LOADMILL_API_TOKEN || null;
28
- }
29
- /**
30
- * Get Loadmill base URL from environment
31
- * @returns {string}
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
- const baseUrl = getBaseUrl();
48
- const url = `${baseUrl}${endpoint}`;
49
- const response = await fetch(url, {
50
- ...options,
51
- headers: {
52
- "Authorization": `Bearer ${token}`,
53
- "Content-Type": "application/json",
54
- ...options.headers,
55
- },
56
- });
57
- if (response.status === 401 || response.status === 403) {
58
- const unauthorizedHandler = globalThis.__DROID_CUA_HANDLE_LOADMILL_UNAUTHORIZED__;
59
- if (typeof unauthorizedHandler === "function") {
60
- await unauthorizedHandler();
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
- if (!response.ok) {
64
- const errorText = await response.text();
65
- throw new Error(`Loadmill API error (${response.status}): ${errorText}`);
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
- return response.json();
68
- }
69
- /**
70
- * Search for test flows by query
71
- * @param {string} searchQuery - Search query string
72
- * @param {Object} options - Additional options
73
- * @param {number} options.limit - Maximum number of results (default: 10)
74
- * @returns {Promise<Array>} - Array of matching flows
75
- */
76
- export async function searchFlows(searchQuery, options = {}) {
77
- const { limit = 10 } = options;
78
- const encodedQuery = encodeURIComponent(searchQuery);
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
- if (result && Array.isArray(result.flows)) {
85
- return result.flows;
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
- if (result && Array.isArray(result.data)) {
88
- return result.data;
109
+ async function getTestRunStatus(testRunId) {
110
+ return await apiRequest(`/test-suites-runs/${testRunId}`);
89
111
  }
90
- if (result && Array.isArray(result.items)) {
91
- return result.items;
112
+ async function getFlowRunStatus(flowRunId) {
113
+ return await apiRequest(`/test-flows-runs/${flowRunId}`);
92
114
  }
93
- if (result && Array.isArray(result.testFlows)) {
94
- return result.testFlows;
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
- // Log unexpected response format for debugging
97
- console.error("[Loadmill] Unexpected API response format:", JSON.stringify(result, null, 2));
98
- return [];
124
+ return {
125
+ getApiToken,
126
+ getBaseUrl,
127
+ searchFlows,
128
+ runTestSuite,
129
+ runTestFlow,
130
+ getTestRunStatus,
131
+ getFlowRunStatus,
132
+ explainFailure,
133
+ };
99
134
  }
100
- /**
101
- * Run a test suite with specified flows
102
- * @param {string} suiteId - Test suite ID
103
- * @param {Object} options - Run options
104
- * @param {string[]} options.flowIds - Array of flow IDs to run
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
- * Run a single test flow
123
- * @param {string} flowId - Flow ID to run
124
- * @param {string} suiteId - Test suite ID containing the flow
125
- * @param {Object} options - Run options
126
- * @param {Object} options.parameters - Parameters to pass to the flow
127
- * @returns {Promise<Object>} - Run result with testSuiteRunId
128
- */
129
- export async function runTestFlow(flowId, suiteId, { parameters = {} } = {}) {
130
- const body = {
131
- flows: [flowId],
132
- inlineParameterOverride: true,
133
- sharedFlowVersionOverrides: [],
134
- };
135
- if (Object.keys(parameters).length > 0) {
136
- body.overrideParameters = parameters;
137
- }
138
- return apiRequest(`/test-suites/${suiteId}/run?ui=true`, {
139
- method: "POST",
140
- body: JSON.stringify(body),
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 apiRequest(`/test-suites-runs/${testRunId}`);
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 apiRequest(`/test-flows-runs/${flowRunId}`);
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 apiRequest(`/explain-failures`, {
166
- method: "POST",
167
- body: JSON.stringify({
168
- testRunId,
169
- testRunType: "flowRun"
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 { searchFlows, runTestFlow, getTestRunStatus, getApiToken, explainFailure } from "./client.js";
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 {