@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 +3 -3
- package/build/index.js +77 -0
- package/build/src/cli/headless-execution-config.js +43 -2
- package/build/src/cli/headless-runner.js +19 -7
- package/build/src/cli/ink-shell.js +1 -0
- package/build/src/cli/scenario-planner.js +1 -0
- package/build/src/commands/help.js +5 -1
- package/build/src/commands/loadmill.js +52 -25
- package/build/src/core/cua-backends.js +11 -0
- package/build/src/core/cua-models.js +47 -2
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/runtime-config.js +252 -8
- package/build/src/core/scenario-planner.js +85 -0
- package/build/src/core/test-data-import.js +62 -0
- package/build/src/core/test-data.js +103 -0
- package/build/src/device/openai.js +30 -3
- package/build/src/integrations/loadmill/executor.js +2 -1
- package/build/src/integrations/loadmill/interpreter.js +3 -2
- package/build/src/modes/execution-mode.js +45 -0
- package/build/src/prompts/execution.js +13 -1
- package/build/src/prompts/scenario-planner.js +48 -0
- package/build/src/prompts/test-data-import.js +49 -0
- package/build/src/utils/cua-debug-tracer.js +15 -0
- package/package.json +3 -2
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
|
|
2
3
|
import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
|
|
3
|
-
import { resolveCuaModel } from "./cua-models.js";
|
|
4
|
+
import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
|
|
5
|
+
import { DEFAULT_FARA_BASE_URL, DEFAULT_FARA_MODEL, resolveCuaBackend, } from "./cua-backends.js";
|
|
4
6
|
import { normalizeStepDelayMs } from "../utils/step-delay.js";
|
|
7
|
+
export const LOADMILL_MODEL_ALIASES = Object.freeze({
|
|
8
|
+
DEFAULT_AGENT_MODEL: "default-agent-model",
|
|
9
|
+
FAST: "fast",
|
|
10
|
+
SMART: "smart",
|
|
11
|
+
COMPUTER_USE: "computer-use",
|
|
12
|
+
});
|
|
13
|
+
const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
|
|
14
|
+
export const LLM_PROVIDER_MODES = Object.freeze(["loadmill", "openai"]);
|
|
15
|
+
export const DEFAULT_LLM_PROVIDER_MODE = "loadmill";
|
|
16
|
+
export const DEFAULT_LOADMILL_BASE_URL = "https://app.loadmill.com";
|
|
17
|
+
const VALID_LLM_PROVIDER_MODES = new Set(LLM_PROVIDER_MODES);
|
|
18
|
+
export function isValidLlmProviderMode(value) {
|
|
19
|
+
return VALID_LLM_PROVIDER_MODES.has(value);
|
|
20
|
+
}
|
|
21
|
+
export function resolveLlmProviderMode(value) {
|
|
22
|
+
return isValidLlmProviderMode(value) ? value : DEFAULT_LLM_PROVIDER_MODE;
|
|
23
|
+
}
|
|
5
24
|
export function createEmptyPromptCustomizations() {
|
|
6
25
|
return {
|
|
7
26
|
basePromptInstructions: "",
|
|
@@ -28,16 +47,95 @@ function trimNullableString(value) {
|
|
|
28
47
|
const trimmed = value.trim();
|
|
29
48
|
return trimmed || null;
|
|
30
49
|
}
|
|
31
|
-
|
|
50
|
+
function normalizeLoadmillSiteBaseUrl(value) {
|
|
51
|
+
const trimmed = trimNullableString(value) || DEFAULT_LOADMILL_BASE_URL;
|
|
52
|
+
const normalizedCandidate = normalizeOptionalAutomationUrl(trimmed) || trimmed;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = new URL(normalizedCandidate);
|
|
55
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
56
|
+
parsed.pathname = pathname
|
|
57
|
+
.replace(/\/api$/, "")
|
|
58
|
+
.replace(/\/llm\/v1$/, "")
|
|
59
|
+
.replace(/\/llm$/, "");
|
|
60
|
+
parsed.search = "";
|
|
61
|
+
parsed.hash = "";
|
|
62
|
+
return parsed.toString().replace(/\/$/, "");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return normalizedCandidate
|
|
66
|
+
.replace(/\/api\/?$/, "")
|
|
67
|
+
.replace(/\/llm\/v1\/?$/, "")
|
|
68
|
+
.replace(/\/llm\/?$/, "")
|
|
69
|
+
.replace(/\/$/, "") || null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function buildLoadmillLlmBaseUrl(value) {
|
|
73
|
+
const siteBaseUrl = normalizeLoadmillSiteBaseUrl(value);
|
|
74
|
+
return siteBaseUrl ? `${siteBaseUrl}/llm/v1` : null;
|
|
75
|
+
}
|
|
76
|
+
export function hasLoadmillLlmAccess(credentials = {}) {
|
|
77
|
+
return Boolean(trimNullableString(credentials.loadmillApiToken) &&
|
|
78
|
+
buildLoadmillLlmBaseUrl(credentials.loadmillBaseUrl));
|
|
79
|
+
}
|
|
80
|
+
export function hasOpenAiLlmAccess(credentials = {}) {
|
|
81
|
+
return Boolean(trimNullableString(credentials.openAiApiKey));
|
|
82
|
+
}
|
|
83
|
+
export function inferLlmProviderModeFromCredentials(credentials = {}) {
|
|
84
|
+
if (hasLoadmillLlmAccess(credentials)) {
|
|
85
|
+
return "loadmill";
|
|
86
|
+
}
|
|
87
|
+
if (hasOpenAiLlmAccess(credentials)) {
|
|
88
|
+
return "openai";
|
|
89
|
+
}
|
|
90
|
+
return DEFAULT_LLM_PROVIDER_MODE;
|
|
91
|
+
}
|
|
92
|
+
export function resolveGatewayCuaModelAlias({ cuaBackend, cuaModel } = {}) {
|
|
93
|
+
if (resolveCuaBackend(cuaBackend) === "fara") {
|
|
94
|
+
return LOADMILL_MODEL_ALIASES.COMPUTER_USE;
|
|
95
|
+
}
|
|
96
|
+
const normalizedCuaModel = resolveCuaModel(cuaModel);
|
|
97
|
+
return normalizedCuaModel === "gpt-5.4-mini"
|
|
98
|
+
? LOADMILL_MODEL_ALIASES.FAST
|
|
99
|
+
: LOADMILL_MODEL_ALIASES.SMART;
|
|
100
|
+
}
|
|
101
|
+
export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaBackend = null, cuaModel = null, } = {}) {
|
|
102
|
+
const providerMode = resolveLlmProviderMode(llmProviderMode);
|
|
103
|
+
if (providerMode === "loadmill") {
|
|
104
|
+
return resolveGatewayCuaModelAlias({ cuaBackend, cuaModel });
|
|
105
|
+
}
|
|
106
|
+
return resolveCuaModelProviderMode(cuaModel) === "openai"
|
|
107
|
+
? resolveCuaModel(cuaModel)
|
|
108
|
+
: DEFAULT_OPENAI_CUA_MODEL;
|
|
109
|
+
}
|
|
110
|
+
export function resolveEffectiveHelperModel({ llmProviderMode = null, cuaModel = null, } = {}) {
|
|
111
|
+
if (resolveLlmProviderMode(llmProviderMode) === "loadmill") {
|
|
112
|
+
return LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL;
|
|
113
|
+
}
|
|
114
|
+
return resolveCuaModelProviderMode(cuaModel) === "openai"
|
|
115
|
+
? resolveCuaModel(cuaModel)
|
|
116
|
+
: DEFAULT_OPENAI_CUA_MODEL;
|
|
117
|
+
}
|
|
118
|
+
export function resolveRuntimeCredentials({ openAiApiKey = null, faraApiKey = null, faraBaseUrl = null, faraModel = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
|
|
32
119
|
return {
|
|
33
120
|
openAiApiKey: trimNullableString(openAiApiKey),
|
|
121
|
+
faraApiKey: trimNullableString(faraApiKey),
|
|
122
|
+
faraBaseUrl: trimNullableString(faraBaseUrl) || DEFAULT_FARA_BASE_URL,
|
|
123
|
+
faraModel: trimNullableString(faraModel) || DEFAULT_FARA_MODEL,
|
|
34
124
|
loadmillApiToken: trimNullableString(loadmillApiToken),
|
|
35
|
-
loadmillBaseUrl:
|
|
125
|
+
loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
|
|
36
126
|
};
|
|
37
127
|
}
|
|
38
|
-
export function resolveRuntimeConfig({ cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
128
|
+
export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
|
|
129
|
+
const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
|
|
130
|
+
? cuaModel
|
|
131
|
+
: resolveCuaModel(cuaModel);
|
|
132
|
+
const normalizedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
39
133
|
return {
|
|
40
|
-
|
|
134
|
+
llmProviderMode: llmProviderMode == null && isLoadmillCuaModel(normalizedCuaModel)
|
|
135
|
+
? "loadmill"
|
|
136
|
+
: normalizedProviderMode,
|
|
137
|
+
cuaBackend: resolveCuaBackend(cuaBackend),
|
|
138
|
+
cuaModel: normalizedCuaModel,
|
|
41
139
|
stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
|
|
42
140
|
contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
|
|
43
141
|
contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
|
|
@@ -62,6 +160,12 @@ export function resolveRuntimeConfig({ cuaModel = null, stepDelayMs = undefined,
|
|
|
62
160
|
export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
63
161
|
const contextOptimization = readContextOptimizationEnv(env);
|
|
64
162
|
return resolveRuntimeConfig({
|
|
163
|
+
llmProviderMode: env.DROID_CUA_LLM_PROVIDER,
|
|
164
|
+
// Only the explicit backend flag should influence CUA model routing.
|
|
165
|
+
// Legacy screenshot-provider env vars must not silently remap Loadmill
|
|
166
|
+
// model aliases like "smart" to backend-specific aliases like
|
|
167
|
+
// "computer-use".
|
|
168
|
+
cuaBackend: env.DROID_CUA_BACKEND,
|
|
65
169
|
cuaModel: env.OPENAI_CUA_MODEL,
|
|
66
170
|
stepDelayMs: env.DROID_CUA_STEP_DELAY_MS,
|
|
67
171
|
contextOptimizationEnabled: contextOptimization.enabled,
|
|
@@ -72,6 +176,9 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
|
|
|
72
176
|
export function resolveRuntimeCredentialsFromEnv(env = process.env, overrides = {}) {
|
|
73
177
|
return resolveRuntimeCredentials({
|
|
74
178
|
openAiApiKey: env.OPENAI_API_KEY,
|
|
179
|
+
faraApiKey: env.DROID_CUA_FARA_API_KEY,
|
|
180
|
+
faraBaseUrl: env.DROID_CUA_FARA_BASE_URL,
|
|
181
|
+
faraModel: env.DROID_CUA_FARA_MODEL,
|
|
75
182
|
loadmillApiToken: env.LOADMILL_API_TOKEN,
|
|
76
183
|
loadmillBaseUrl: env.LOADMILL_BASE_URL,
|
|
77
184
|
...overrides,
|
|
@@ -82,14 +189,151 @@ export function createOpenAiClient(apiKey) {
|
|
|
82
189
|
apiKey: trimNullableString(apiKey) || undefined,
|
|
83
190
|
});
|
|
84
191
|
}
|
|
192
|
+
function missingCredentialErrorMessage(llmProviderMode) {
|
|
193
|
+
return resolveLlmProviderMode(llmProviderMode) === "loadmill"
|
|
194
|
+
? "Loadmill authentication is required to use the Loadmill model."
|
|
195
|
+
: "An OpenAI API key is required to use OpenAI models.";
|
|
196
|
+
}
|
|
197
|
+
function isLoadmillGatewayErrorPayload(payload) {
|
|
198
|
+
return Boolean(payload &&
|
|
199
|
+
typeof payload === "object" &&
|
|
200
|
+
typeof payload.message === "string" &&
|
|
201
|
+
typeof payload.type === "string" &&
|
|
202
|
+
typeof payload.code === "string");
|
|
203
|
+
}
|
|
204
|
+
function normalizeLoadmillGatewayErrorBody(body) {
|
|
205
|
+
const payload = body?.payload;
|
|
206
|
+
if (!isLoadmillGatewayErrorPayload(payload)) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
error: {
|
|
211
|
+
message: payload.message,
|
|
212
|
+
type: payload.type,
|
|
213
|
+
code: payload.code,
|
|
214
|
+
param: payload.param ?? null,
|
|
215
|
+
provider: typeof payload.provider === "string" ? payload.provider : null,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function normalizeLoadmillGatewayErrorResponse(response) {
|
|
220
|
+
const contentType = response.headers.get("content-type") || "";
|
|
221
|
+
if (!contentType.includes("application/json")) {
|
|
222
|
+
return response;
|
|
223
|
+
}
|
|
224
|
+
const responseClone = response.clone();
|
|
225
|
+
const bodyText = await responseClone.text();
|
|
226
|
+
if (!bodyText) {
|
|
227
|
+
return response;
|
|
228
|
+
}
|
|
229
|
+
let parsedBody;
|
|
230
|
+
try {
|
|
231
|
+
parsedBody = JSON.parse(bodyText);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return response;
|
|
235
|
+
}
|
|
236
|
+
const normalizedBody = normalizeLoadmillGatewayErrorBody(parsedBody);
|
|
237
|
+
if (!normalizedBody) {
|
|
238
|
+
return response;
|
|
239
|
+
}
|
|
240
|
+
const headers = new Headers(response.headers);
|
|
241
|
+
headers.set("content-type", "application/json");
|
|
242
|
+
return new Response(JSON.stringify(normalizedBody), {
|
|
243
|
+
status: response.status,
|
|
244
|
+
statusText: response.statusText,
|
|
245
|
+
headers,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function createLoadmillGatewayFetch(baseFetch = fetch) {
|
|
249
|
+
return async (input, init) => {
|
|
250
|
+
const response = await baseFetch(input, init);
|
|
251
|
+
if (response.ok) {
|
|
252
|
+
return response;
|
|
253
|
+
}
|
|
254
|
+
return await normalizeLoadmillGatewayErrorResponse(response);
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* @param {{
|
|
259
|
+
* loadmillApiToken?: string | null,
|
|
260
|
+
* loadmillBaseUrl?: string | null,
|
|
261
|
+
* clientName?: string,
|
|
262
|
+
* maxRetries?: number,
|
|
263
|
+
* }} [options]
|
|
264
|
+
*/
|
|
265
|
+
export function createLlmClient({ loadmillApiToken, loadmillBaseUrl, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
266
|
+
const apiKey = trimNullableString(loadmillApiToken);
|
|
267
|
+
const baseURL = buildLoadmillLlmBaseUrl(loadmillBaseUrl);
|
|
268
|
+
if (!apiKey || !baseURL) {
|
|
269
|
+
throw new Error("Loadmill authentication is required for LLM access.");
|
|
270
|
+
}
|
|
271
|
+
return new OpenAI({
|
|
272
|
+
apiKey,
|
|
273
|
+
baseURL,
|
|
274
|
+
maxRetries,
|
|
275
|
+
fetch: createLoadmillGatewayFetch(),
|
|
276
|
+
defaultHeaders: {
|
|
277
|
+
"X-Loadmill-Client": clientName,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
export function createFaraClient({ apiKey, baseUrl }) {
|
|
282
|
+
return new OpenAI({
|
|
283
|
+
apiKey: trimNullableString(apiKey) || "",
|
|
284
|
+
baseURL: trimNullableString(baseUrl) || DEFAULT_FARA_BASE_URL,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
|
|
288
|
+
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
289
|
+
const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
|
|
290
|
+
if (resolvedProviderMode === "loadmill") {
|
|
291
|
+
if (!hasLoadmillLlmAccess(resolvedCredentials)) {
|
|
292
|
+
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
293
|
+
}
|
|
294
|
+
return createLlmClient({
|
|
295
|
+
loadmillApiToken: resolvedCredentials.loadmillApiToken,
|
|
296
|
+
loadmillBaseUrl: resolvedCredentials.loadmillBaseUrl,
|
|
297
|
+
clientName,
|
|
298
|
+
maxRetries,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (!hasOpenAiLlmAccess(resolvedCredentials)) {
|
|
302
|
+
throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
|
|
303
|
+
}
|
|
304
|
+
return createOpenAiClient(resolvedCredentials.openAiApiKey);
|
|
305
|
+
}
|
|
85
306
|
export function createRunContext({ config, credentials }) {
|
|
86
307
|
const resolvedConfig = resolveRuntimeConfig(config);
|
|
87
308
|
const resolvedCredentials = resolveRuntimeCredentials(credentials);
|
|
309
|
+
const resolvedProviderMode = resolveLlmProviderMode(resolvedConfig.llmProviderMode);
|
|
310
|
+
const effectiveConfig = {
|
|
311
|
+
...resolvedConfig,
|
|
312
|
+
llmProviderMode: resolvedProviderMode,
|
|
313
|
+
cuaModel: resolveEffectiveCuaModel({
|
|
314
|
+
llmProviderMode: resolvedProviderMode,
|
|
315
|
+
cuaBackend: resolvedConfig.cuaBackend,
|
|
316
|
+
cuaModel: resolvedConfig.cuaModel,
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
const effectiveCredentials = {
|
|
320
|
+
...resolvedCredentials,
|
|
321
|
+
};
|
|
88
322
|
return {
|
|
89
|
-
config:
|
|
90
|
-
credentials:
|
|
323
|
+
config: effectiveConfig,
|
|
324
|
+
credentials: effectiveCredentials,
|
|
91
325
|
clients: {
|
|
92
|
-
openai:
|
|
326
|
+
openai: createResolvedLlmClient({
|
|
327
|
+
llmProviderMode: resolvedProviderMode,
|
|
328
|
+
credentials: resolvedCredentials,
|
|
329
|
+
clientName: "desktop",
|
|
330
|
+
}),
|
|
331
|
+
fara: resolvedConfig.cuaBackend === "fara"
|
|
332
|
+
? createFaraClient({
|
|
333
|
+
apiKey: resolvedCredentials.faraApiKey,
|
|
334
|
+
baseUrl: resolvedCredentials.faraBaseUrl,
|
|
335
|
+
})
|
|
336
|
+
: null,
|
|
93
337
|
},
|
|
94
338
|
};
|
|
95
339
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { resolveEffectiveHelperModel } from "./runtime-config.js";
|
|
2
|
+
import { buildScenarioPlannerMessages } from "../prompts/scenario-planner.js";
|
|
3
|
+
function parseScenarioPlannerContent(content) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(content);
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
const message = error instanceof Error ? error.message : "Invalid JSON.";
|
|
9
|
+
throw new Error(`Could not parse scenario planner response: ${message}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function normalizeCaseName(value, fallback) {
|
|
13
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
14
|
+
return trimmed || fallback;
|
|
15
|
+
}
|
|
16
|
+
function dedupeCaseNames(cases) {
|
|
17
|
+
const usedNames = new Set();
|
|
18
|
+
const baseCounts = new Map();
|
|
19
|
+
return cases.map((entry) => {
|
|
20
|
+
const baseName = entry.caseName;
|
|
21
|
+
let nextIndex = baseCounts.get(baseName) || 1;
|
|
22
|
+
let caseName = baseName;
|
|
23
|
+
while (usedNames.has(caseName)) {
|
|
24
|
+
nextIndex += 1;
|
|
25
|
+
caseName = `${baseName} (${nextIndex})`;
|
|
26
|
+
}
|
|
27
|
+
baseCounts.set(baseName, nextIndex);
|
|
28
|
+
usedNames.add(caseName);
|
|
29
|
+
return {
|
|
30
|
+
...entry,
|
|
31
|
+
caseName,
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function validateScenarioCases(rawCases, candidateTests) {
|
|
36
|
+
if (!Array.isArray(rawCases) || rawCases.length === 0) {
|
|
37
|
+
throw new Error("Scenario planner did not return any cases.");
|
|
38
|
+
}
|
|
39
|
+
const candidateNames = new Set(candidateTests.map((test) => test.testName));
|
|
40
|
+
const normalizedCases = rawCases.map((rawCase, index) => {
|
|
41
|
+
if (!rawCase || typeof rawCase !== "object" || Array.isArray(rawCase)) {
|
|
42
|
+
throw new Error(`Scenario case ${index + 1} must be an object.`);
|
|
43
|
+
}
|
|
44
|
+
const testName = typeof rawCase.testName === "string" ? rawCase.testName.trim() : "";
|
|
45
|
+
if (!candidateNames.has(testName)) {
|
|
46
|
+
throw new Error(`Scenario case ${index + 1} references unknown test "${testName || "(missing)"}".`);
|
|
47
|
+
}
|
|
48
|
+
const caseName = normalizeCaseName(rawCase.caseName, `${testName} / Case ${index + 1}`);
|
|
49
|
+
const testDataContext = typeof rawCase.testDataContext === "string" ? rawCase.testDataContext.trim() : "";
|
|
50
|
+
return {
|
|
51
|
+
testName,
|
|
52
|
+
caseName,
|
|
53
|
+
testDataContext,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
return dedupeCaseNames(normalizedCases);
|
|
57
|
+
}
|
|
58
|
+
export async function planScenarioRuns({ client, runtimeConfig, scenarioPrompt, tests, appContextText = "", testDataText = "", }) {
|
|
59
|
+
if (!client?.chat?.completions?.create) {
|
|
60
|
+
throw new Error("Scenario planning requires an LLM client.");
|
|
61
|
+
}
|
|
62
|
+
const prompt = typeof scenarioPrompt === "string" ? scenarioPrompt.trim() : "";
|
|
63
|
+
if (!prompt) {
|
|
64
|
+
throw new Error("--scenario must be a non-empty string.");
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(tests) || tests.length === 0) {
|
|
67
|
+
throw new Error("Scenario planning requires at least one candidate test.");
|
|
68
|
+
}
|
|
69
|
+
const response = await client.chat.completions.create({
|
|
70
|
+
model: resolveEffectiveHelperModel({
|
|
71
|
+
llmProviderMode: runtimeConfig?.llmProviderMode,
|
|
72
|
+
cuaModel: runtimeConfig?.cuaModel,
|
|
73
|
+
}),
|
|
74
|
+
messages: buildScenarioPlannerMessages({
|
|
75
|
+
scenarioPrompt: prompt,
|
|
76
|
+
tests,
|
|
77
|
+
appContextText,
|
|
78
|
+
testDataText,
|
|
79
|
+
}),
|
|
80
|
+
response_format: { type: "json_object" },
|
|
81
|
+
});
|
|
82
|
+
const content = response.choices?.[0]?.message?.content || "{}";
|
|
83
|
+
const parsed = parseScenarioPlannerContent(content);
|
|
84
|
+
return validateScenarioCases(parsed.cases, tests);
|
|
85
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { resolveEffectiveHelperModel } from "./runtime-config.js";
|
|
3
|
+
import { buildTestDataImportMessages } from "../prompts/test-data-import.js";
|
|
4
|
+
export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt"];
|
|
5
|
+
export function isSupportedTestDataImportPath(filePath) {
|
|
6
|
+
return TEST_DATA_IMPORT_EXTENSIONS.includes(path.extname(String(filePath ?? "")).toLowerCase());
|
|
7
|
+
}
|
|
8
|
+
export function assertSupportedTestDataImportPath(filePath) {
|
|
9
|
+
if (!isSupportedTestDataImportPath(filePath)) {
|
|
10
|
+
throw new Error("Test data import supports CSV and TXT files only.");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function parseTestDataImportContent(content) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(content);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const message = error instanceof Error ? error.message : "Invalid JSON.";
|
|
19
|
+
throw new Error(`Could not parse test data import response: ${message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function normalizeGeneratedContent(value) {
|
|
23
|
+
const content = typeof value === "string" ? value.trim() : "";
|
|
24
|
+
if (!content) {
|
|
25
|
+
throw new Error("Test data import returned empty content.");
|
|
26
|
+
}
|
|
27
|
+
if (/^```/.test(content)) {
|
|
28
|
+
throw new Error("Test data import returned code-fenced content instead of markdown.");
|
|
29
|
+
}
|
|
30
|
+
return content.endsWith("\n") ? content : `${content}\n`;
|
|
31
|
+
}
|
|
32
|
+
export async function importTestDataMarkdown({ client, runtimeConfig, currentContent = "", importedFileName, importedFileText, instructions = "", }) {
|
|
33
|
+
if (!client?.chat?.completions?.create) {
|
|
34
|
+
throw new Error("Test data import requires an LLM client.");
|
|
35
|
+
}
|
|
36
|
+
const fileName = typeof importedFileName === "string" ? importedFileName.trim() : "";
|
|
37
|
+
if (!fileName) {
|
|
38
|
+
throw new Error("Imported file name is required.");
|
|
39
|
+
}
|
|
40
|
+
const fileText = typeof importedFileText === "string" ? importedFileText : "";
|
|
41
|
+
if (!fileText.trim()) {
|
|
42
|
+
throw new Error("Selected import file is empty.");
|
|
43
|
+
}
|
|
44
|
+
const response = await client.chat.completions.create({
|
|
45
|
+
model: resolveEffectiveHelperModel({
|
|
46
|
+
llmProviderMode: runtimeConfig?.llmProviderMode,
|
|
47
|
+
cuaModel: runtimeConfig?.cuaModel,
|
|
48
|
+
}),
|
|
49
|
+
messages: buildTestDataImportMessages({
|
|
50
|
+
currentContent,
|
|
51
|
+
importedFileName: fileName,
|
|
52
|
+
importedFileText: fileText,
|
|
53
|
+
instructions,
|
|
54
|
+
}),
|
|
55
|
+
response_format: { type: "json_object" },
|
|
56
|
+
});
|
|
57
|
+
const content = response.choices?.[0]?.message?.content || "{}";
|
|
58
|
+
const parsed = parseTestDataImportContent(content);
|
|
59
|
+
return {
|
|
60
|
+
content: normalizeGeneratedContent(parsed.content),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { access, readFile } from "fs/promises";
|
|
3
|
+
import { constants as fsConstants } from "fs";
|
|
4
|
+
export const TEST_DATA_FILENAME = "test-data.md";
|
|
5
|
+
export const DEFAULT_TEST_DATA_TEMPLATE = `# Test Data
|
|
6
|
+
<!-- Concrete data the agent can use during Scenario Runs. Keep entries named and specific. -->
|
|
7
|
+
<!-- Examples below are commented out so they are guidance, not usable test data. -->
|
|
8
|
+
|
|
9
|
+
## Accounts
|
|
10
|
+
<!--
|
|
11
|
+
### Standard buyer
|
|
12
|
+
Email:
|
|
13
|
+
Password:
|
|
14
|
+
Notes: When should the agent use this account?
|
|
15
|
+
-->
|
|
16
|
+
|
|
17
|
+
## Products
|
|
18
|
+
<!--
|
|
19
|
+
### In-stock product
|
|
20
|
+
Name:
|
|
21
|
+
SKU:
|
|
22
|
+
Notes:
|
|
23
|
+
-->
|
|
24
|
+
|
|
25
|
+
## Payment Methods
|
|
26
|
+
<!--
|
|
27
|
+
### Valid card
|
|
28
|
+
Number:
|
|
29
|
+
Expiration:
|
|
30
|
+
CVV:
|
|
31
|
+
Notes:
|
|
32
|
+
-->
|
|
33
|
+
|
|
34
|
+
## Roles and Permissions
|
|
35
|
+
|
|
36
|
+
## Regions and Locales
|
|
37
|
+
|
|
38
|
+
## Other Data
|
|
39
|
+
`;
|
|
40
|
+
export function getDefaultProjectTestDataPath(projectPath) {
|
|
41
|
+
return path.join(projectPath, TEST_DATA_FILENAME);
|
|
42
|
+
}
|
|
43
|
+
export function normalizeTestDataTemplateText(text) {
|
|
44
|
+
return String(text ?? "").replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "").trimEnd();
|
|
45
|
+
}
|
|
46
|
+
export function isDefaultTestDataTemplate(text) {
|
|
47
|
+
if (!String(text ?? "").trim()) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return normalizeTestDataTemplateText(text) === normalizeTestDataTemplateText(DEFAULT_TEST_DATA_TEMPLATE);
|
|
51
|
+
}
|
|
52
|
+
export async function readTestDataFile(filePath) {
|
|
53
|
+
await access(filePath, fsConstants.R_OK);
|
|
54
|
+
return await readFile(filePath, "utf-8");
|
|
55
|
+
}
|
|
56
|
+
export async function loadTestData({ testDataPath }) {
|
|
57
|
+
if (!testDataPath) {
|
|
58
|
+
return { text: "", testDataPath: null };
|
|
59
|
+
}
|
|
60
|
+
const text = await readTestDataFile(testDataPath);
|
|
61
|
+
return { text, testDataPath };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* @param {{
|
|
65
|
+
* testDataPath?: string | null,
|
|
66
|
+
* missingFileStatus?: string | null,
|
|
67
|
+
* }} [options]
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveExecutionTestData({ testDataPath = null, missingFileStatus = null, } = {}) {
|
|
70
|
+
if (!testDataPath) {
|
|
71
|
+
return {
|
|
72
|
+
status: "omitted",
|
|
73
|
+
text: "",
|
|
74
|
+
testDataPath: null,
|
|
75
|
+
message: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const result = await loadTestData({ testDataPath });
|
|
80
|
+
return {
|
|
81
|
+
status: result.text.trim().length > 0 ? "loaded" : "empty",
|
|
82
|
+
text: result.text,
|
|
83
|
+
testDataPath: result.testDataPath,
|
|
84
|
+
message: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (missingFileStatus && error && typeof error === "object" && error.code === "ENOENT") {
|
|
89
|
+
return {
|
|
90
|
+
status: missingFileStatus,
|
|
91
|
+
text: "",
|
|
92
|
+
testDataPath,
|
|
93
|
+
message: null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
status: "failed",
|
|
98
|
+
text: "",
|
|
99
|
+
testDataPath,
|
|
100
|
+
message: error instanceof Error ? error.message : "Unknown test data error",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { buildTestRevisionSystemPrompt } from "../prompts/editor.js";
|
|
2
|
-
import { createOpenAiClient, resolveRuntimeConfig } from "../core/runtime-config.js";
|
|
2
|
+
import { createOpenAiClient, LOADMILL_MODEL_ALIASES, resolveEffectiveHelperModel, resolveRuntimeConfig, } from "../core/runtime-config.js";
|
|
3
3
|
import { DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
|
|
4
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
7
|
const cuaDebugTracer = new CuaDebugTracer(logger);
|
|
8
|
+
function resolveOpenAiBaseUrl(client) {
|
|
9
|
+
return client?.baseURL || client?.baseUrl || client?._options?.baseURL || null;
|
|
10
|
+
}
|
|
11
|
+
function resolveOpenAiEndpointUrl(client, endpointPath) {
|
|
12
|
+
const baseURL = resolveOpenAiBaseUrl(client);
|
|
13
|
+
if (!baseURL) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return new URL(endpointPath.replace(/^\/+/, ''), `${String(baseURL).replace(/\/+$/, '')}/`).toString();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return `${String(baseURL).replace(/\/+$/, '')}/${endpointPath.replace(/^\/+/, '')}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
8
23
|
function normalizeMessageContent(content) {
|
|
9
24
|
if (Array.isArray(content)) {
|
|
10
25
|
return content.map((item) => {
|
|
@@ -225,14 +240,15 @@ export function buildCuaRequestParams({ cuaModel, aimAssistMode = DEFAULT_AIM_AS
|
|
|
225
240
|
* Revise a test script based on user feedback using simple chat completion
|
|
226
241
|
* @param {{
|
|
227
242
|
* client: import("openai").default,
|
|
243
|
+
* model?: string,
|
|
228
244
|
* originalScript: string,
|
|
229
245
|
* revisionRequest: string,
|
|
230
246
|
* }} options
|
|
231
247
|
* @returns {Promise<string>} - The revised test script
|
|
232
248
|
*/
|
|
233
|
-
export async function reviseTestScript({ client, originalScript, revisionRequest }) {
|
|
249
|
+
export async function reviseTestScript({ client, model = LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL, originalScript, revisionRequest, }) {
|
|
234
250
|
const response = await client.chat.completions.create({
|
|
235
|
-
model
|
|
251
|
+
model,
|
|
236
252
|
messages: [{
|
|
237
253
|
role: "system",
|
|
238
254
|
content: buildTestRevisionSystemPrompt(originalScript, revisionRequest)
|
|
@@ -274,7 +290,14 @@ export async function sendCUARequest({ client, cuaModel, aimAssistMode = DEFAULT
|
|
|
274
290
|
debugContext,
|
|
275
291
|
previousResponseId
|
|
276
292
|
});
|
|
293
|
+
trace.endpoint = {
|
|
294
|
+
baseURL: resolveOpenAiBaseUrl(client),
|
|
295
|
+
responsesURL: resolveOpenAiEndpointUrl(client, "responses"),
|
|
296
|
+
model: requestParams.model,
|
|
297
|
+
previousResponseId: requestParams.previous_response_id ?? null,
|
|
298
|
+
};
|
|
277
299
|
logger.debug("CUA Request:", trace.requestLog);
|
|
300
|
+
logger.debug("CUA Request endpoint:", trace.endpoint);
|
|
278
301
|
try {
|
|
279
302
|
const response = await client.responses.create(requestParams);
|
|
280
303
|
cuaDebugTracer.onResponse(trace, response);
|
|
@@ -311,6 +334,10 @@ export function createOpenAiExecutor({ client, config }) {
|
|
|
311
334
|
async reviseTestScript(originalScript, revisionRequest) {
|
|
312
335
|
return await reviseTestScript({
|
|
313
336
|
client: openAiClient,
|
|
337
|
+
model: resolveEffectiveHelperModel({
|
|
338
|
+
llmProviderMode: runtimeConfig.llmProviderMode,
|
|
339
|
+
cuaModel: runtimeConfig.cuaModel,
|
|
340
|
+
}),
|
|
314
341
|
originalScript,
|
|
315
342
|
revisionRequest,
|
|
316
343
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { createLoadmillApiClient } from "./client.js";
|
|
5
5
|
import { interpretLoadmillCommand, selectBestFlow } from "./interpreter.js";
|
|
6
6
|
import { extractLoadmillRuntimeParameters, renderLoadmillParameterContextNote, selectLoadmillParameterCandidates, } from "./parameters.js";
|
|
7
|
+
import { LOADMILL_MODEL_ALIASES } from "../../core/runtime-config.js";
|
|
7
8
|
import { logger } from "../../utils/logger.js";
|
|
8
9
|
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
|
9
10
|
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
|
@@ -62,7 +63,7 @@ async function selectRelevantParameterNames({ openAiClient, command, flowName, f
|
|
|
62
63
|
}
|
|
63
64
|
try {
|
|
64
65
|
const response = await openAiClient.chat.completions.create({
|
|
65
|
-
model:
|
|
66
|
+
model: LOADMILL_MODEL_ALIASES.DEFAULT_AGENT_MODEL,
|
|
66
67
|
messages: buildParameterSelectionMessages({
|
|
67
68
|
command,
|
|
68
69
|
flowName,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI-powered text interpretation for Loadmill commands
|
|
3
3
|
*/
|
|
4
|
+
import { LOADMILL_MODEL_ALIASES } from "../../core/runtime-config.js";
|
|
4
5
|
import { buildLoadmillCommandInterpretationMessages, buildLoadmillFlowSelectionMessages, } from "../../prompts/loadmill.js";
|
|
5
6
|
/**
|
|
6
7
|
* Interpret a natural language Loadmill command into structured data
|
|
@@ -9,7 +10,7 @@ import { buildLoadmillCommandInterpretationMessages, buildLoadmillFlowSelectionM
|
|
|
9
10
|
*/
|
|
10
11
|
export async function interpretLoadmillCommand(userInput, { client }) {
|
|
11
12
|
const response = await client.chat.completions.create({
|
|
12
|
-
model:
|
|
13
|
+
model: LOADMILL_MODEL_ALIASES.FAST,
|
|
13
14
|
messages: buildLoadmillCommandInterpretationMessages(userInput),
|
|
14
15
|
response_format: { type: "json_object" }
|
|
15
16
|
});
|
|
@@ -41,7 +42,7 @@ export async function selectBestFlow(flows, originalQuery, { client }) {
|
|
|
41
42
|
return `${i + 1}. ID: ${f.id}, Name: "${name}"${suite ? `, Suite: "${suite}"` : ""}`;
|
|
42
43
|
}).join("\n");
|
|
43
44
|
const response = await client.chat.completions.create({
|
|
44
|
-
model:
|
|
45
|
+
model: LOADMILL_MODEL_ALIASES.FAST,
|
|
45
46
|
messages: buildLoadmillFlowSelectionMessages(originalQuery, flowList),
|
|
46
47
|
response_format: { type: "json_object" }
|
|
47
48
|
});
|