@loadmill/droid-cua 2.15.1 → 2.17.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 CHANGED
@@ -46,9 +46,9 @@ Under the hood, `droid-cua` uses an AI agent to explore your app, execute action
46
46
 
47
47
  Open the desktop app and choose the platform you want to test.
48
48
 
49
- **3. Add your credentials**
49
+ **3. Choose your AI provider**
50
50
 
51
- Set your OpenAI API key in the app Settings screen, or log in with your Loadmill account.
51
+ In Settings, choose the CUA model you want to run. `Loadmill - Smart` uses your Loadmill sign-in and usage tracking. The OpenAI models use your own OpenAI API key.
52
52
 
53
53
  **4. Connect a target device**
54
54
 
@@ -75,7 +75,7 @@ Desktop app support:
75
75
  ## Requirements
76
76
 
77
77
  **All platforms:**
78
- - OpenAI API key
78
+ - Loadmill sign-in for `Loadmill - Smart`, or an OpenAI API key for the OpenAI models
79
79
 
80
80
  **Android:**
81
81
  - Android Debug Bridge (ADB)
package/build/index.js CHANGED
@@ -13,6 +13,7 @@ import { createOpenAiExecutor } from "./src/device/openai.js";
13
13
  import { logger } from "./src/utils/logger.js";
14
14
  import { selectDevice } from "./src/cli/device-selector.js";
15
15
  import { resolveExecutionAppContext } from "./src/core/app-context.js";
16
+ import { resolveExecutionTestData } from "./src/core/test-data.js";
16
17
  import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
17
18
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
18
19
  import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
@@ -20,6 +21,7 @@ import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
20
21
  import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
21
22
  import { listHeadlessInstructionFiles, resolveHeadlessInstructionsTarget } from "./src/cli/headless-instruction-target.js";
22
23
  import { runHeadlessInstructionRuns } from "./src/cli/headless-runner.js";
24
+ import { planScenarioRuns } from "./src/cli/scenario-planner.js";
23
25
  import { resolveLambdaTestCredentials, uploadLambdaTestCliApp } from "./src/cli/lambdatest-app-upload.js";
24
26
  import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
25
27
  import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
@@ -29,6 +31,7 @@ const targetConfig = resolveHeadlessTarget(args);
29
31
  let avdName = targetConfig.avdName;
30
32
  let platform = targetConfig.platform || null; // 'ios' or 'android'
31
33
  const recordScreenshots = args["record"] || false;
34
+ const startupUrl = typeof args.url === "string" && args.url.trim().length > 0 ? args.url.trim() : null;
32
35
  const instructionsFile = args.instructions || args.i || null;
33
36
  const appContextPath = typeof args.context === "string" ? args.context : null;
34
37
  const debugMode = args["debug"] || false;
@@ -66,6 +69,41 @@ function wait(ms) {
66
69
  function isLambdaTestAppAutomationUrl(value) {
67
70
  return typeof value === "string" && value.startsWith("https://appautomation.lambdatest.com/test?");
68
71
  }
72
+ async function loadScenarioCandidateRuns(runs) {
73
+ return await Promise.all(runs.map(async (run) => ({
74
+ ...run,
75
+ instructions: run.instructions ?? await loadHeadlessInstructions(run.testPath),
76
+ })));
77
+ }
78
+ function printScenarioCasePreview(cases) {
79
+ console.log(`\nScenario preview: ${cases.length} case${cases.length === 1 ? "" : "s"}\n`);
80
+ cases.forEach((entry, index) => {
81
+ console.log(`${index + 1}. ${entry.caseName}`);
82
+ });
83
+ console.log("");
84
+ }
85
+ function buildScenarioInstructionTarget(originalTarget, scenarioPrompt) {
86
+ return {
87
+ type: "scenario",
88
+ path: originalTarget?.path || null,
89
+ sourceType: originalTarget?.type || null,
90
+ scenarioPrompt,
91
+ };
92
+ }
93
+ function buildScenarioRuns({ candidateRuns, cases }) {
94
+ const runByTestName = new Map(candidateRuns.map((run) => [run.testName, run]));
95
+ return cases.map((entry) => {
96
+ const sourceRun = runByTestName.get(entry.testName);
97
+ return {
98
+ ...sourceRun,
99
+ testName: entry.caseName,
100
+ sourceTestName: entry.testName,
101
+ scenarioCaseName: entry.caseName,
102
+ testDataContext: entry.testDataContext,
103
+ instructions: sourceRun.instructions,
104
+ };
105
+ });
106
+ }
69
107
  async function resolveLambdaTestDashboardUrl(credentials, sessionId, { attempts = 1, delayMs = 1000 } = {}) {
70
108
  const adapter = getCloudProviderAdapter("lambdatest");
71
109
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
@@ -106,10 +144,17 @@ async function main() {
106
144
  if (isLambdaTestTarget && !isHeadlessInstructionsRun) {
107
145
  throw new Error("--device-source lambdatest is only supported with --instructions headless execution.");
108
146
  }
147
+ if (!isHeadlessInstructionsRun && args.scenario != null) {
148
+ throw new Error("--scenario can only be used with --instructions headless execution.");
149
+ }
150
+ if (!isHeadlessInstructionsRun && args["test-data"] != null) {
151
+ throw new Error("--test-data can only be used with --instructions headless execution.");
152
+ }
109
153
  let preloadedHeadlessConfig = null;
110
154
  let preloadedHeadlessTarget = null;
111
155
  let headlessInstructionRuns = [];
112
156
  let preloadedAppContext = null;
157
+ let preloadedTestData = null;
113
158
  if (isHeadlessInstructionsRun) {
114
159
  preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
115
160
  preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
@@ -138,6 +183,14 @@ async function main() {
138
183
  if (preloadedAppContext.status === "failed") {
139
184
  throw new Error(`Could not read app context file ${preloadedAppContext.contextPath}: ${preloadedAppContext.message}`);
140
185
  }
186
+ if (preloadedHeadlessConfig.scenarioPrompt) {
187
+ preloadedTestData = await resolveExecutionTestData({
188
+ testDataPath: preloadedHeadlessConfig.testDataPath,
189
+ });
190
+ if (preloadedTestData.status === "failed") {
191
+ throw new Error(`Could not read test data file ${preloadedTestData.testDataPath}: ${preloadedTestData.message}`);
192
+ }
193
+ }
141
194
  }
142
195
  // If no device specified, show interactive selection menu
143
196
  if (!isBrowserTarget && !isLambdaTestTarget && !avdName && !platform) {
@@ -149,6 +202,7 @@ async function main() {
149
202
  let deviceInfo;
150
203
  if (isBrowserTarget) {
151
204
  targetId = await connectToBrowser({
205
+ url: startupUrl,
152
206
  headless: true,
153
207
  browserFamily: targetConfig.browserFamily,
154
208
  sessionMode: targetConfig.sessionMode
@@ -219,6 +273,7 @@ async function main() {
219
273
  const appContext = preloadedAppContext;
220
274
  const appContextText = appContext.text;
221
275
  const runtimeContext = createCliRuntimeContext({
276
+ llmProviderMode: headlessConfig.llmProviderMode,
222
277
  cuaModel: headlessConfig.cuaModel,
223
278
  promptCustomizations: headlessConfig.promptCustomizations,
224
279
  contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
@@ -231,6 +286,28 @@ async function main() {
231
286
  message: appContext.message,
232
287
  },
233
288
  });
289
+ console.log(`Using AI provider: ${runtimeContext.config.llmProviderMode}`);
290
+ console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
291
+ if (headlessConfig.scenarioPrompt) {
292
+ const candidateRuns = await loadScenarioCandidateRuns(headlessInstructionRuns);
293
+ const scenarioCases = await planScenarioRuns({
294
+ client: runtimeContext.openAiClient,
295
+ runtimeConfig: runtimeContext.config,
296
+ scenarioPrompt: headlessConfig.scenarioPrompt,
297
+ tests: candidateRuns.map((run) => ({
298
+ testName: run.testName,
299
+ instructions: run.instructions,
300
+ })),
301
+ appContextText,
302
+ testDataText: preloadedTestData?.text || "",
303
+ });
304
+ printScenarioCasePreview(scenarioCases);
305
+ headlessInstructionRuns = buildScenarioRuns({
306
+ candidateRuns,
307
+ cases: scenarioCases,
308
+ });
309
+ preloadedHeadlessTarget = buildScenarioInstructionTarget(preloadedHeadlessTarget, headlessConfig.scenarioPrompt);
310
+ }
234
311
  const result = await runHeadlessInstructionRuns({
235
312
  runs: headlessInstructionRuns,
236
313
  instructionsTarget: preloadedHeadlessTarget,
@@ -2,7 +2,8 @@ import path from "path";
2
2
  import { readFile } from "fs/promises";
3
3
  import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
4
4
  import { AIM_ASSIST_MODES, DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
5
- import { CUA_MODELS, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
5
+ import { CUA_MODELS, DEFAULT_OPENAI_CUA_MODEL, isOpenAiCuaModel, isValidCuaModel, resolveCuaModel } from "../core/cua-models.js";
6
+ import { LLM_PROVIDER_MODES, isValidLlmProviderMode, resolveLlmProviderMode } from "../core/runtime-config.js";
6
7
  function createEmptyPromptCustomizations() {
7
8
  return {
8
9
  basePromptInstructions: "",
@@ -30,8 +31,25 @@ function validateAimAssistMode(value, label) {
30
31
  }
31
32
  return value;
32
33
  }
34
+ function validateLlmProviderMode(value, label) {
35
+ if (typeof value !== "string" || !isValidLlmProviderMode(value)) {
36
+ throw new Error(`${label} must be one of: ${LLM_PROVIDER_MODES.join(", ")}.`);
37
+ }
38
+ return value;
39
+ }
33
40
  function readEnvDefaultCuaModel() {
34
- return resolveCuaModel(process.env.OPENAI_CUA_MODEL);
41
+ const envValue = process.env.OPENAI_CUA_MODEL;
42
+ // OPENAI_CUA_MODEL only selects OpenAI models. If it is set but not a valid
43
+ // OpenAI model, stay on the OpenAI default rather than crossing over to the
44
+ // global (Loadmill) default.
45
+ if (typeof envValue === "string" && envValue.trim()) {
46
+ const trimmed = envValue.trim();
47
+ return isOpenAiCuaModel(trimmed) ? trimmed : DEFAULT_OPENAI_CUA_MODEL;
48
+ }
49
+ return resolveCuaModel(envValue);
50
+ }
51
+ function readEnvDefaultLlmProviderMode() {
52
+ return resolveLlmProviderMode(process.env.DROID_CUA_LLM_PROVIDER);
35
53
  }
36
54
  function parseContextOptimizationThreshold(rawValue, label) {
37
55
  const numericValue = (() => {
@@ -107,6 +125,7 @@ async function loadConfigFromFile(configPath) {
107
125
  const configDir = path.dirname(absoluteConfigPath);
108
126
  const normalized = {
109
127
  configPath: absoluteConfigPath,
128
+ llmProviderMode: undefined,
110
129
  cuaModel: undefined,
111
130
  aimAssistMode: undefined,
112
131
  promptCustomizations: createEmptyPromptCustomizations(),
@@ -115,6 +134,9 @@ async function loadConfigFromFile(configPath) {
115
134
  contextOptimizationEnabled: undefined,
116
135
  contextOptimizationThreshold: undefined,
117
136
  };
137
+ if ("llmProviderMode" in rawConfig) {
138
+ normalized.llmProviderMode = validateLlmProviderMode(rawConfig.llmProviderMode, "config.llmProviderMode");
139
+ }
118
140
  if ("cuaModel" in rawConfig) {
119
141
  normalized.cuaModel = validateCuaModel(rawConfig.cuaModel, "config.cuaModel");
120
142
  }
@@ -150,6 +172,10 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
150
172
  const cwd = typeof options.cwd === "string" ? options.cwd : process.cwd();
151
173
  const configPath = typeof args.config === "string" ? args.config : null;
152
174
  const explicitContextPath = typeof args.context === "string" ? path.resolve(cwd, args.context) : null;
175
+ const scenarioPrompt = typeof args.scenario === "string" ? args.scenario.trim() : null;
176
+ const hasScenarioFlag = args.scenario != null;
177
+ const testDataPath = typeof args["test-data"] === "string" ? path.resolve(cwd, args["test-data"]) : null;
178
+ const hasTestDataFlag = args["test-data"] != null;
153
179
  const noContext = args["no-context"] === true || args.context === false;
154
180
  const contextOptimizationEnabledByFlag = args["context-optimization"] === true;
155
181
  const noContextOptimization = args["no-context-optimization"] === true || args["context-optimization"] === false;
@@ -161,6 +187,15 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
161
187
  if (explicitContextPath && noContext) {
162
188
  throw new Error("--context and --no-context cannot be used together.");
163
189
  }
190
+ if (hasScenarioFlag && !scenarioPrompt) {
191
+ throw new Error("--scenario must be a non-empty string.");
192
+ }
193
+ if (hasTestDataFlag && !testDataPath) {
194
+ throw new Error("--test-data must be a non-empty path.");
195
+ }
196
+ if (testDataPath && !scenarioPrompt) {
197
+ throw new Error("--test-data can only be used with --scenario.");
198
+ }
164
199
  if (contextOptimizationEnabledByFlag) {
165
200
  throw new Error("--context-optimization is not supported; Context Optimization is enabled by default.");
166
201
  }
@@ -174,14 +209,20 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
174
209
  };
175
210
  const resolved = {
176
211
  configPath: fileConfig?.configPath || null,
212
+ llmProviderMode: fileConfig?.llmProviderMode || readEnvDefaultLlmProviderMode(),
177
213
  cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
178
214
  aimAssistMode: fileConfig?.aimAssistMode || DEFAULT_AIM_ASSIST_MODE,
179
215
  promptCustomizations,
180
216
  appContextEnabled: fileConfig?.appContextEnabled ?? true,
181
217
  appContextPath: fileConfig?.appContextPath || null,
218
+ scenarioPrompt,
219
+ testDataPath,
182
220
  contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
183
221
  contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
184
222
  };
223
+ if (typeof args["llm-provider"] === "string") {
224
+ resolved.llmProviderMode = validateLlmProviderMode(args["llm-provider"], "--llm-provider");
225
+ }
185
226
  if (typeof args["cua-model"] === "string") {
186
227
  resolved.cuaModel = validateCuaModel(args["cua-model"], "--cua-model");
187
228
  }
@@ -100,11 +100,12 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
100
100
  }
101
101
  return engineOptions;
102
102
  }
103
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
103
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
104
104
  let executionMode = null;
105
105
  try {
106
106
  await headlessDebug.startExecutionSession(runId, {
107
107
  testName,
108
+ ...(testDataContext ? { testDataUsed: true } : {}),
108
109
  platform: deviceInfo.platform,
109
110
  deviceName: deviceInfo.device_name,
110
111
  aimAssistMode: headlessConfig.aimAssistMode
@@ -119,7 +120,10 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
119
120
  emitCliAppContextDebug(runId, appContext, appContextText);
120
121
  session.updateResponseId(undefined);
121
122
  session.clearMessages();
122
- const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, { aimAssistMode: headlessConfig.aimAssistMode });
123
+ const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, {
124
+ aimAssistMode: headlessConfig.aimAssistMode,
125
+ testDataContext,
126
+ });
123
127
  session.setSystemPrompt(executionPrompt);
124
128
  const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
125
129
  runId,
@@ -188,9 +192,12 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
188
192
  throw error;
189
193
  }
190
194
  }
191
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget }) {
192
- const isBatchRun = instructionsTarget?.type === "directory";
193
- if (isBatchRun) {
195
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isLambdaTestTarget, runHeadlessTestImpl = runHeadlessTest }) {
196
+ const isBatchRun = instructionsTarget?.type === "directory" || instructionsTarget?.type === "scenario";
197
+ if (instructionsTarget?.type === "scenario") {
198
+ console.log(`\nRunning ${runs.length} scenario case${runs.length === 1 ? "" : "s"}\n`);
199
+ }
200
+ else if (isBatchRun) {
194
201
  console.log(`\nRunning ${runs.length} tests from: ${instructionsTarget.path}\n`);
195
202
  }
196
203
  let failures = 0;
@@ -205,7 +212,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
205
212
  if (recordScreenshots && testScreenshotDir) {
206
213
  await mkdir(testScreenshotDir, { recursive: true });
207
214
  }
208
- const result = await runHeadlessTest({
215
+ const result = await runHeadlessTestImpl({
209
216
  runId,
210
217
  testPath: run.testPath,
211
218
  testName: run.testName,
@@ -214,6 +221,7 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
214
221
  deviceInfo,
215
222
  headlessConfig,
216
223
  appContext,
224
+ testDataContext: run.testDataContext || "",
217
225
  runtimeContext,
218
226
  headlessDebug,
219
227
  recordScreenshots,
@@ -234,7 +242,11 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
234
242
  console.error(`\nTest failed: ${run.testName}: ${message}`);
235
243
  }
236
244
  }
237
- if (isBatchRun) {
245
+ if (instructionsTarget?.type === "scenario") {
246
+ const passed = runs.length - failures;
247
+ console.log(`\nScenario run complete: ${passed}/${runs.length} passed`);
248
+ }
249
+ else if (isBatchRun) {
238
250
  const passed = runs.length - failures;
239
251
  console.log(`\nFolder run complete: ${passed}/${runs.length} passed`);
240
252
  }
@@ -35,6 +35,7 @@ export async function startInkShell(session, executionEngine, options = {}) {
35
35
  engine: executionEngine,
36
36
  appContextPath: options.appContextPath ?? null,
37
37
  runtimeContext: options.runtimeContext ?? null,
38
+ runtimeConfig: options.runtimeContext?.config ?? null,
38
39
  openAiClient: options.runtimeContext?.openAiClient ?? null,
39
40
  loadmillClient: options.runtimeContext?.loadmillClient ?? null,
40
41
  runtimeCredentials: options.runtimeContext?.credentials ?? null
@@ -0,0 +1 @@
1
+ export { planScenarioRuns, validateScenarioCases, } from "../core/scenario-planner.js";
@@ -26,9 +26,12 @@ export async function handleHelp(args, session, context) {
26
26
  addOutput({ type: 'info', text: ' --os-version <version> LambdaTest OS version' });
27
27
  addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
28
28
  addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
29
+ addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
30
+ addOutput({ type: 'info', text: ' --test-data <file> Optional test-data.md file for --scenario' });
29
31
  addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
30
32
  addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
31
- addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4 or gpt-5.4-mini' });
33
+ addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
34
+ addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, or gpt-5.4-mini' });
32
35
  addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
33
36
  addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
34
37
  addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
@@ -79,6 +82,7 @@ export async function handleHelp(args, session, context) {
79
82
  addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
80
83
  addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
81
84
  addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
85
+ addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
82
86
  addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
83
87
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --platform android --device-name "Galaxy S24" --os-version 14 --app app.apk --instructions tests/login.dcua' });
84
88
  addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
@@ -2,7 +2,7 @@
2
2
  * Loadmill command handler
3
3
  */
4
4
  import { createLoadmillApiClient, executeLoadmillCommand, getApiToken } from "../integrations/loadmill/index.js";
5
- import { createOpenAiClient, resolveRuntimeCredentialsFromEnv } from "../core/runtime-config.js";
5
+ import { createResolvedLlmClient, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "../core/runtime-config.js";
6
6
  /**
7
7
  * Handle /loadmill command
8
8
  * @param {string} args - Command arguments (natural language command)
@@ -48,33 +48,60 @@ export async function handleLoadmill(args, session, context) {
48
48
  if (context.setAgentWorking) {
49
49
  context.setAgentWorking(true, 'Running Loadmill flow...');
50
50
  }
51
- const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
52
- const loadmillClient = context.loadmillClient || createLoadmillApiClient({
53
- token: runtimeCredentials.loadmillApiToken,
54
- baseUrl: runtimeCredentials.loadmillBaseUrl,
55
- });
56
- const openAiClient = context.openAiClient || createOpenAiClient(runtimeCredentials.openAiApiKey);
57
- // Execute the command
58
- const result = await executeLoadmillCommand(command, {
59
- loadmillClient,
60
- openAiClient,
61
- onProgress: ({ message }) => {
62
- addOutput({ type: 'info', text: `[Loadmill] ${message}` });
51
+ try {
52
+ const runtimeCredentials = context.runtimeCredentials || resolveRuntimeCredentialsFromEnv();
53
+ const runtimeConfig = context.runtimeConfig || resolveRuntimeConfigFromEnv();
54
+ const loadmillClient = context.loadmillClient || createLoadmillApiClient({
55
+ token: runtimeCredentials.loadmillApiToken,
56
+ baseUrl: runtimeCredentials.loadmillBaseUrl,
57
+ });
58
+ const openAiClient = context.openAiClient || createResolvedLlmClient({
59
+ llmProviderMode: runtimeConfig.llmProviderMode,
60
+ credentials: runtimeCredentials,
61
+ clientName: "cli",
62
+ });
63
+ // Execute the command
64
+ const result = await executeLoadmillCommand(command, {
65
+ loadmillClient,
66
+ openAiClient,
67
+ onProgress: ({ message }) => {
68
+ addOutput({ type: 'info', text: `[Loadmill] ${message}` });
69
+ }
70
+ });
71
+ // Display results
72
+ if (result.success) {
73
+ if (result.action === "search") {
74
+ addOutput({ type: 'success', text: `Found ${result.result.flows.length} flow(s):` });
75
+ result.result.flows.forEach((flow, i) => {
76
+ const name = flow.description || flow.name || "Unknown";
77
+ const suite = flow.testSuiteDescription ? ` (Suite: ${flow.testSuiteDescription})` : '';
78
+ addOutput({ type: 'info', text: ` ${i + 1}. ${name}${suite}` });
79
+ addOutput({ type: 'info', text: ` ID: ${flow.id}` });
80
+ });
81
+ if (result.result.selectedFlow) {
82
+ const selectedName = result.result.selectedFlow.description || result.result.selectedFlow.name || "Unknown";
83
+ addOutput({ type: 'info', text: '' });
84
+ addOutput({ type: 'info', text: `Best match: "${selectedName}" (confidence: ${(result.result.confidence * 100).toFixed(0)}%)` });
85
+ }
86
+ }
87
+ else {
88
+ addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
89
+ if (result.runId) {
90
+ addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
91
+ }
92
+ }
63
93
  }
64
- });
65
- // Clear agent working status
66
- if (context.setAgentWorking) {
67
- context.setAgentWorking(false);
68
- }
69
- // Display results
70
- if (result.success) {
71
- addOutput({ type: 'success', text: `Flow "${result.flowName}" passed` });
72
- if (result.runId) {
73
- addOutput({ type: 'info', text: `Run ID: ${result.runId}` });
94
+ else {
95
+ addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
74
96
  }
75
97
  }
76
- else {
77
- addOutput({ type: 'error', text: `Loadmill failed: ${result.error}` });
98
+ catch (error) {
99
+ addOutput({ type: 'error', text: error instanceof Error ? error.message : 'Loadmill command failed.' });
100
+ }
101
+ finally {
102
+ if (context.setAgentWorking) {
103
+ context.setAgentWorking(false);
104
+ }
78
105
  }
79
106
  return true; // Continue loop
80
107
  }
@@ -0,0 +1,11 @@
1
+ export const CUA_BACKENDS = ["openai", "fara"];
2
+ export const DEFAULT_CUA_BACKEND = "openai";
3
+ export const DEFAULT_FARA_BASE_URL = "http://localhost:5000/v1";
4
+ export const DEFAULT_FARA_MODEL = "Fara-7B";
5
+ const VALID_CUA_BACKENDS = new Set(CUA_BACKENDS);
6
+ export function isValidCuaBackend(value) {
7
+ return VALID_CUA_BACKENDS.has(value);
8
+ }
9
+ export function resolveCuaBackend(value) {
10
+ return isValidCuaBackend(value) ? value : DEFAULT_CUA_BACKEND;
11
+ }
@@ -1,9 +1,54 @@
1
- export const CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
2
- export const DEFAULT_CUA_MODEL = "gpt-5.4";
1
+ export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
2
+ export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
3
+ export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
4
+ export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
5
+ export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
6
+ export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
7
+ export const CUA_MODEL_OPTIONS = Object.freeze([
8
+ Object.freeze({
9
+ id: "loadmill-smart",
10
+ providerMode: "loadmill",
11
+ providerLabel: "Loadmill",
12
+ name: "Loadmill Smart",
13
+ label: "Loadmill Smart (recommended)",
14
+ }),
15
+ Object.freeze({
16
+ id: "gpt-5.4",
17
+ providerMode: "openai",
18
+ providerLabel: "OpenAI",
19
+ name: "GPT-5.4",
20
+ label: "OpenAI GPT-5.4",
21
+ }),
22
+ Object.freeze({
23
+ id: "gpt-5.4-mini",
24
+ providerMode: "openai",
25
+ providerLabel: "OpenAI",
26
+ name: "GPT-5.4 mini",
27
+ label: "OpenAI GPT-5.4 mini (faster)",
28
+ }),
29
+ ]);
3
30
  const VALID_CUA_MODELS = new Set(CUA_MODELS);
31
+ const OPENAI_CUA_MODEL_SET = new Set(OPENAI_CUA_MODELS);
32
+ const LOADMILL_CUA_MODEL_SET = new Set(LOADMILL_CUA_MODELS);
33
+ const CUA_MODEL_PROVIDER_MODE_MAP = new Map(CUA_MODEL_OPTIONS.map((option) => [option.id, option.providerMode]));
4
34
  export function isValidCuaModel(value) {
5
35
  return VALID_CUA_MODELS.has(value);
6
36
  }
37
+ export function isOpenAiCuaModel(value) {
38
+ return OPENAI_CUA_MODEL_SET.has(value);
39
+ }
40
+ export function isLoadmillCuaModel(value) {
41
+ return LOADMILL_CUA_MODEL_SET.has(value);
42
+ }
7
43
  export function resolveCuaModel(value) {
8
44
  return isValidCuaModel(value) ? value : DEFAULT_CUA_MODEL;
9
45
  }
46
+ export function resolveCuaModelProviderMode(value) {
47
+ const normalized = resolveCuaModel(value);
48
+ return CUA_MODEL_PROVIDER_MODE_MAP.get(normalized) || "openai";
49
+ }
50
+ export function resolveDefaultCuaModelForProvider(providerMode) {
51
+ return providerMode === "loadmill"
52
+ ? DEFAULT_LOADMILL_CUA_MODEL
53
+ : DEFAULT_OPENAI_CUA_MODEL;
54
+ }
@@ -1,3 +1,3 @@
1
1
  export { buildBaseSystemPrompt } from "../prompts/base.js";
2
2
  export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
3
- export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
3
+ export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, buildTestDataContextSection, } from "../prompts/execution.js";