testronaut 1.2.2 → 1.2.4
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/bin/cli.js +160 -8
- package/core/agent.js +92 -37
- package/core/config.js +146 -0
- package/core/turnLoop.js +35 -4
- package/package.json +1 -1
- package/runner/testronaut.js +44 -21
- package/tests/coreTests/agent.test.js +127 -0
- package/tests/coreTests/config.test.js +146 -0
- package/tests/runnerTests/testronaut.test.js +164 -0
package/bin/cli.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cli.js
|
|
5
|
+
* -------
|
|
6
|
+
* Purpose:
|
|
7
|
+
* Single entry point for the Testronaut CLI:
|
|
8
|
+
* - Parses flags (e.g., --model, --turns, --init, help).
|
|
9
|
+
* - Runs mission files (single or all), aggregates results, and writes HTML/JSON reports.
|
|
10
|
+
* - Provides subcommands: login, upload (report + screenshots), serve/view (static file server).
|
|
11
|
+
*
|
|
12
|
+
* Key flags:
|
|
13
|
+
* --model <id> / --model=<id> → sets TESTRONAUT_MODEL env (wins over config file)
|
|
14
|
+
* --turns <n> / --turns=<n> → sets TESTRONAUT_TURNS env (wins over config file)
|
|
15
|
+
* --init → scaffolds project + optional Playwright browsers
|
|
16
|
+
* --help → prints help
|
|
17
|
+
*
|
|
18
|
+
* Notable helpers (defined below):
|
|
19
|
+
* parseJsonSafe(res, label) → tolerant JSON parse with good error messages
|
|
20
|
+
* pkgManagerForCwd(cwd) → detects npm/pnpm/yarn/bun
|
|
21
|
+
* hasAny(modNames) → “do we have at least one of these deps?” (ESM-safe)
|
|
22
|
+
* installDev(pm, pkg) → dev-install a package with the detected package manager
|
|
23
|
+
* ensurePlaywrightInstalled() → install @playwright/test and browsers (skippable in CI)
|
|
24
|
+
* guessMimeType(p) → static server content types
|
|
25
|
+
* safeJoin(root, relUrlPath) → path traversal protection for static server
|
|
26
|
+
* findLatestReportPair(reportDir) → find latest run_<ts>.html (+ matching .json if present)
|
|
27
|
+
* serveLatestReport() → read-only file server for most recent HTML report
|
|
28
|
+
*
|
|
29
|
+
* Test strategy:
|
|
30
|
+
* To test pure helpers without running the whole CLI, we export a tiny test bundle:
|
|
31
|
+
* export const __test__ = { guessMimeType, safeJoin, findLatestReportPair, pkgManagerForCwd }
|
|
32
|
+
* See tests in tests/cliTests/cli.helpers.test.js
|
|
33
|
+
*/
|
|
34
|
+
|
|
2
35
|
import fs from 'fs';
|
|
3
36
|
import path from 'path';
|
|
4
37
|
import { fileURLToPath } from 'url';
|
|
@@ -18,7 +51,7 @@ import { ensureBrowsers } from '../tools/playwrightSetup.js';
|
|
|
18
51
|
// Keep PW browsers inside the project to avoid global cache skew
|
|
19
52
|
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
|
|
20
53
|
|
|
21
|
-
|
|
54
|
+
const TMP_DIR = path.resolve('./missions/tmp');
|
|
22
55
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
56
|
|
|
24
57
|
// const apiBase = `http://localhost:3002` //
|
|
@@ -27,6 +60,52 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
|
|
|
27
60
|
|
|
28
61
|
const args = process.argv.slice(2);
|
|
29
62
|
|
|
63
|
+
function readJsonlSteps(p) {
|
|
64
|
+
if (!p || !fs.existsSync(p)) return null;
|
|
65
|
+
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
66
|
+
const parsed = [];
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
try { parsed.push(JSON.parse(line)); } catch { /* ignore bad lines */ }
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Merge duplicate turns without losing information.
|
|
75
|
+
* Strategy:
|
|
76
|
+
* - Keep the latest **non-empty** version for a given turn if both exist
|
|
77
|
+
* - If both are non-empty, keep the later one (last write wins)
|
|
78
|
+
* - Preserve original order by line index as a tiebreaker
|
|
79
|
+
*/
|
|
80
|
+
function mergeDuplicateTurns(steps) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const byTurn = new Map(); // turn -> index in out
|
|
83
|
+
for (const s of steps) {
|
|
84
|
+
const turn = Number.isFinite(s.turn) ? s.turn : out.length;
|
|
85
|
+
const hasEvents = Array.isArray(s.events) && s.events.length > 0;
|
|
86
|
+
if (!byTurn.has(turn)) {
|
|
87
|
+
out.push(s);
|
|
88
|
+
byTurn.set(turn, out.length - 1);
|
|
89
|
+
} else {
|
|
90
|
+
const idx = byTurn.get(turn);
|
|
91
|
+
const existing = out[idx];
|
|
92
|
+
const existingHasEvents = Array.isArray(existing.events) && existing.events.length > 0;
|
|
93
|
+
// Prefer the one with events; if both have events, prefer the newer (s)
|
|
94
|
+
if (!existingHasEvents && hasEvents) {
|
|
95
|
+
out[idx] = s;
|
|
96
|
+
} else if (existingHasEvents && hasEvents) {
|
|
97
|
+
out[idx] = s;
|
|
98
|
+
} else {
|
|
99
|
+
// both empty or both sparse — keep latest
|
|
100
|
+
out[idx] = s;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// stable sort by `turn` ascending, then by original order via array index already preserved
|
|
105
|
+
out.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0));
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
30
109
|
// Look for --model=<id> or --model <id>
|
|
31
110
|
let modelOverride;
|
|
32
111
|
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
@@ -46,6 +125,29 @@ if (modelFlagIndex >= 0) {
|
|
|
46
125
|
args.splice(modelFlagIndex, modelOverride ? 2 : 1);
|
|
47
126
|
}
|
|
48
127
|
|
|
128
|
+
// Look for --turns=<n> or --turns <n>
|
|
129
|
+
let turnsOverride;
|
|
130
|
+
const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
|
|
131
|
+
if (turnsFlagIndex >= 0) {
|
|
132
|
+
if (args[turnsFlagIndex].includes('=')) {
|
|
133
|
+
turnsOverride = args[turnsFlagIndex].split('=')[1];
|
|
134
|
+
} else if (args[turnsFlagIndex + 1]) {
|
|
135
|
+
turnsOverride = args[turnsFlagIndex + 1];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (turnsOverride) {
|
|
139
|
+
const n = Number(turnsOverride.trim());
|
|
140
|
+
if (Number.isFinite(n) && n > 0) {
|
|
141
|
+
process.env.TESTRONAUT_TURNS = String(n);
|
|
142
|
+
console.log(`🎯 Turn override: ${process.env.TESTRONAUT_TURNS}`);
|
|
143
|
+
} else {
|
|
144
|
+
console.warn(`⚠️ Invalid --turns value "${turnsOverride}". Ignoring.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Remove flag & value so they aren’t treated as filenames
|
|
149
|
+
args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
|
|
150
|
+
}
|
|
49
151
|
|
|
50
152
|
const missionsDir = path.resolve(process.cwd(), 'missions');
|
|
51
153
|
|
|
@@ -66,6 +168,7 @@ Usage:
|
|
|
66
168
|
|
|
67
169
|
Options:
|
|
68
170
|
--init Scaffold project folders and a welcome mission
|
|
171
|
+
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
69
172
|
--help Show this help message
|
|
70
173
|
|
|
71
174
|
Examples:
|
|
@@ -179,11 +282,22 @@ const endTime = new Date();
|
|
|
179
282
|
|
|
180
283
|
const flatMissions = allResults.flatMap(entry => {
|
|
181
284
|
const result = entry.result;
|
|
182
|
-
const missions = Array.isArray(result) ? result : [result];
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
285
|
+
const missions = Array.isArray(result) ? result : [result];
|
|
286
|
+
|
|
287
|
+
return missions.map(m => {
|
|
288
|
+
let steps = m.steps;
|
|
289
|
+
if (m.stepFile && fs.existsSync(m.stepFile)) {
|
|
290
|
+
const fromJsonl = readJsonlSteps(m.stepFile);
|
|
291
|
+
if (fromJsonl && fromJsonl.length) {
|
|
292
|
+
steps = mergeDuplicateTurns(fromJsonl);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
...m,
|
|
297
|
+
steps,
|
|
298
|
+
file: entry.file,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
187
301
|
});
|
|
188
302
|
|
|
189
303
|
// Read provider/model from config (allow env override for model)
|
|
@@ -228,7 +342,13 @@ fs.mkdirSync(outputDir, { recursive: true });
|
|
|
228
342
|
fs.writeFileSync(`${outputDir}/${runId}.json`, JSON.stringify(report, null, 2));
|
|
229
343
|
generateHtmlReport(report, `${outputDir}/${runId}.html`);
|
|
230
344
|
|
|
231
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Parse a fetch Response safely and return JSON or throw a rich error.
|
|
347
|
+
* @param {Response} res
|
|
348
|
+
* @param {string} urlLabel - human-friendly label for error messages
|
|
349
|
+
* @returns {Promise<any>}
|
|
350
|
+
* @throws when HTTP status is not ok or body is empty/invalid
|
|
351
|
+
*/
|
|
232
352
|
async function parseJsonSafe(res, urlLabel) {
|
|
233
353
|
const text = await res.text(); // read once
|
|
234
354
|
let data = null;
|
|
@@ -248,6 +368,11 @@ async function parseJsonSafe(res, urlLabel) {
|
|
|
248
368
|
return data;
|
|
249
369
|
}
|
|
250
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Detect the local package manager based on lockfiles.
|
|
373
|
+
* @param {string} [cwd=process.cwd()]
|
|
374
|
+
* @returns {'pnpm'|'yarn'|'bun'|'npm'}
|
|
375
|
+
*/
|
|
251
376
|
function pkgManagerForCwd(cwd = process.cwd()) {
|
|
252
377
|
const has = (f) => fs.existsSync(path.join(cwd, f));
|
|
253
378
|
if (has('pnpm-lock.yaml')) return 'pnpm';
|
|
@@ -559,7 +684,11 @@ async function uploadReport() {
|
|
|
559
684
|
}
|
|
560
685
|
}
|
|
561
686
|
|
|
562
|
-
|
|
687
|
+
/**
|
|
688
|
+
* Guess a reasonable MIME type for static serving.
|
|
689
|
+
* @param {string} p - file path
|
|
690
|
+
* @returns {string}
|
|
691
|
+
*/
|
|
563
692
|
function guessMimeType(p) {
|
|
564
693
|
const ext = path.extname(p).toLowerCase();
|
|
565
694
|
if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8';
|
|
@@ -574,6 +703,12 @@ function guessMimeType(p) {
|
|
|
574
703
|
return 'application/octet-stream';
|
|
575
704
|
}
|
|
576
705
|
|
|
706
|
+
/**
|
|
707
|
+
* Path-join a request path to a root directory with traversal protection.
|
|
708
|
+
* @param {string} root - filesystem directory
|
|
709
|
+
* @param {string} relUrlPath - URL path from incoming request
|
|
710
|
+
* @returns {string|null} resolved path or null on attempted escape
|
|
711
|
+
*/
|
|
577
712
|
function safeJoin(root, relUrlPath) {
|
|
578
713
|
// Prevent path traversal and keep within root
|
|
579
714
|
const decoded = decodeURIComponent(relUrlPath);
|
|
@@ -593,6 +728,12 @@ async function openInBrowser(targetUrl) {
|
|
|
593
728
|
return exec(`xdg-open ${quoted}`).catch(() => {}); // best-effort on Linux
|
|
594
729
|
}
|
|
595
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Find the latest run HTML (and its matching JSON, if present).
|
|
733
|
+
* Expects files like "run_<timestamp>.html" in the report directory.
|
|
734
|
+
* @param {string} reportDir
|
|
735
|
+
* @returns {{htmlFile:string, jsonFile:string|null}|null}
|
|
736
|
+
*/
|
|
596
737
|
function findLatestReportPair(reportDir) {
|
|
597
738
|
if (!fs.existsSync(reportDir)) return null;
|
|
598
739
|
const files = fs.readdirSync(reportDir).filter(f => f.endsWith('.html'));
|
|
@@ -691,3 +832,14 @@ async function serveLatestReport() {
|
|
|
691
832
|
// Best-effort auto-open
|
|
692
833
|
await openInBrowser(reportUrl).catch(() => {});
|
|
693
834
|
}
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
|
|
838
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
839
|
+
console.log('🧹 Cleaned up temporary files.');
|
|
840
|
+
} else {
|
|
841
|
+
console.log('⚠️ Skipped tmp cleanup (TN_KEEP_TMP set).');
|
|
842
|
+
}
|
|
843
|
+
} catch (err) {
|
|
844
|
+
console.warn(`⚠️ Could not remove tmp folder: ${err.message}`);
|
|
845
|
+
}
|
package/core/agent.js
CHANGED
|
@@ -1,18 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent.js
|
|
3
|
+
* --------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Orchestrate a browser-backed autonomous agent over a sequence of goals.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Start/stop the browser lifecycle.
|
|
9
|
+
* - For each goal, construct initial messages and invoke turnLoop().
|
|
10
|
+
* - Collect per-goal traces into a normalized missionResults array.
|
|
11
|
+
*
|
|
12
|
+
* Message contract (initial):
|
|
13
|
+
* messages = [
|
|
14
|
+
* { role: 'system', content: 'instructions...' },
|
|
15
|
+
* { role: 'user', content: <goal text> }
|
|
16
|
+
* ]
|
|
17
|
+
*
|
|
18
|
+
* Related tests:
|
|
19
|
+
* tests/agentTests/agent.test.js
|
|
20
|
+
*
|
|
21
|
+
* Used by:
|
|
22
|
+
* - cli/testronaut.js (runMissions)
|
|
23
|
+
*/
|
|
24
|
+
|
|
1
25
|
import 'dotenv/config';
|
|
2
26
|
import { ChromeBrowser } from '../tools/chromeBrowser.js';
|
|
3
27
|
import { turnLoop } from './turnLoop.js';
|
|
28
|
+
import fs from 'fs';
|
|
29
|
+
import path from 'path';
|
|
4
30
|
|
|
5
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Execute goals with a browser agent.
|
|
33
|
+
*
|
|
34
|
+
* @param {Array<{goal:any, submissionType?:string, submissionName?:string, label?:string}>} goals
|
|
35
|
+
* @param {string} missionName
|
|
36
|
+
* @param {number} [maxTurns=20] - upper bound for turnLoop per goal
|
|
37
|
+
* @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
|
|
38
|
+
*/
|
|
39
|
+
export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
6
40
|
const browser = new ChromeBrowser();
|
|
7
41
|
await browser.start();
|
|
8
|
-
const startTime = Date.now();
|
|
9
42
|
let result;
|
|
10
43
|
|
|
11
44
|
try {
|
|
12
45
|
const missionResults = [];
|
|
46
|
+
const tmpDir = path.resolve(process.cwd(), 'missions/tmp');
|
|
47
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
48
|
+
|
|
13
49
|
for (const goal of goals) {
|
|
14
|
-
// console.log(`\n🚀 Running Agent with goal:\n${goal}\n`);
|
|
15
50
|
const steps = [];
|
|
51
|
+
// Unique-ish JSONL file for this mission’s steps
|
|
52
|
+
const stepFile = path.join(
|
|
53
|
+
tmpDir,
|
|
54
|
+
`${missionName.replace(/[^\w.-]+/g, '_')}_${Date.now()}_steps.jsonl`
|
|
55
|
+
);
|
|
56
|
+
// Start clean
|
|
57
|
+
fs.writeFileSync(stepFile, '');
|
|
58
|
+
|
|
59
|
+
// Ensure user message is a string (functions/objects → toString fallback)
|
|
60
|
+
const userContent =
|
|
61
|
+
typeof goal.goal === 'string'
|
|
62
|
+
? goal.goal
|
|
63
|
+
: goal?.goal?.toString?.() ?? String(goal.goal);
|
|
64
|
+
|
|
16
65
|
const messages = [
|
|
17
66
|
{
|
|
18
67
|
role: 'system',
|
|
@@ -29,61 +78,67 @@ export async function runAgent(goals, missionName, maxTurns = 20, ) {
|
|
|
29
78
|
After completing the goal, respond with a final plain-text message starting with SUCCESS or FAILURE.
|
|
30
79
|
`.trim(),
|
|
31
80
|
},
|
|
32
|
-
{ role: 'user', content:
|
|
81
|
+
{ role: 'user', content: userContent },
|
|
33
82
|
];
|
|
34
83
|
|
|
84
|
+
// Run the turn loop for this goal
|
|
35
85
|
result = await turnLoop(
|
|
36
|
-
browser,
|
|
37
|
-
messages,
|
|
38
|
-
maxTurns,
|
|
39
|
-
0,
|
|
40
|
-
0,
|
|
41
|
-
null,
|
|
86
|
+
browser,
|
|
87
|
+
messages,
|
|
88
|
+
maxTurns,
|
|
89
|
+
0, // currentTurn
|
|
90
|
+
0, // retryCount
|
|
91
|
+
null, // currentStep
|
|
42
92
|
{
|
|
43
|
-
steps,
|
|
44
|
-
missionName,
|
|
93
|
+
steps,
|
|
94
|
+
missionName,
|
|
95
|
+
onStep: (s) => {
|
|
96
|
+
// Append each step as a JSON line
|
|
97
|
+
try {
|
|
98
|
+
fs.appendFileSync(stepFile, JSON.stringify(s) + '\n');
|
|
99
|
+
// Keep memory in check: retain only the last ~20 steps in RAM
|
|
100
|
+
if (steps.length > 20) steps.splice(0, steps.length - 20);
|
|
101
|
+
} catch {} // best-effort; don’t crash the agent
|
|
102
|
+
}
|
|
45
103
|
}
|
|
46
104
|
);
|
|
47
|
-
|
|
105
|
+
|
|
106
|
+
function dedupeSteps(steps) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const s of steps) {
|
|
110
|
+
const first = s?.events?.[0] || '';
|
|
111
|
+
const key = `${s.turn}::${s.summary}::${first}`;
|
|
112
|
+
if (!seen.has(key)) {
|
|
113
|
+
seen.add(key);
|
|
114
|
+
out.push(s);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const compact = dedupeSteps(steps);
|
|
121
|
+
|
|
122
|
+
// Snapshot steps (avoid retaining references to mutable arrays)
|
|
48
123
|
missionResults.push({
|
|
49
124
|
missionName,
|
|
50
125
|
submissionType: goal.submissionType || goal.label,
|
|
51
126
|
submissionName: goal.submissionName || null,
|
|
52
127
|
status: result?.success ? 'passed' : 'failed',
|
|
53
|
-
steps: JSON.parse(JSON.stringify(
|
|
54
|
-
|
|
128
|
+
steps: JSON.parse(JSON.stringify(compact)), // last 20 (by memory design)
|
|
129
|
+
stepFile, // full history is in this JSONL (one step per line)
|
|
130
|
+
startTime: Date.now() - 1, // TODO: wire actual timings if needed
|
|
55
131
|
endTime: Date.now(),
|
|
56
132
|
});
|
|
57
133
|
|
|
58
|
-
if (!result
|
|
134
|
+
if (!result?.success) {
|
|
59
135
|
console.log('🛑 Agent stopped due to failed goal.\n');
|
|
60
136
|
return missionResults;
|
|
61
137
|
}
|
|
62
|
-
|
|
63
|
-
// if (!result.success) {
|
|
64
|
-
// console.log('🛑 Agent stopped due to failed goal.\n');
|
|
65
|
-
// const endTime = Date.now();
|
|
66
|
-
// return {
|
|
67
|
-
// missionName: missionName,
|
|
68
|
-
// status: 'failed',
|
|
69
|
-
// steps: result.steps,
|
|
70
|
-
// startTime: startTime,
|
|
71
|
-
// endTime: endTime,
|
|
72
|
-
// };
|
|
73
|
-
// }
|
|
74
138
|
}
|
|
75
139
|
|
|
76
140
|
console.log('✅ All goals completed successfully.\n');
|
|
77
|
-
// const endTime = Date.now();
|
|
78
141
|
return missionResults;
|
|
79
|
-
// return {
|
|
80
|
-
// missionName: missionName,
|
|
81
|
-
// status: 'passed',
|
|
82
|
-
// steps: result.steps,
|
|
83
|
-
// startTime: startTime,
|
|
84
|
-
// endTime: endTime,
|
|
85
|
-
// };
|
|
86
|
-
|
|
87
142
|
} finally {
|
|
88
143
|
await browser.close();
|
|
89
144
|
}
|
package/core/config.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js
|
|
3
|
+
* ----------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Centralized config loading and turn-budget guardrails for Testronaut.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Load `testronaut-config.json` (best effort).
|
|
9
|
+
* - Read specific knobs with safe fallbacks (e.g., maxTurns).
|
|
10
|
+
* - Define baseline limits (soft/hard/min/idle/errors/time).
|
|
11
|
+
* - Enforce user requests via clamp + warn (default) or fail-fast (strict).
|
|
12
|
+
*
|
|
13
|
+
* Related tests:
|
|
14
|
+
* tests/configTests/config.test.js
|
|
15
|
+
*
|
|
16
|
+
* Used by:
|
|
17
|
+
* - cli/testronaut.js (to derive effectiveMax turns per run)
|
|
18
|
+
* - core/agent.js / core/turnLoop.js (runtime stop conditions)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// core/config.js
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load testronaut-config.json from the given cwd.
|
|
27
|
+
* Returns {} when missing or invalid JSON.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} [cwd=process.cwd()]
|
|
30
|
+
* @returns {Promise<object>}
|
|
31
|
+
*/
|
|
32
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(path.join(cwd, 'testronaut-config.json'), 'utf8');
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
// No config or invalid JSON — fall back to defaults at call sites
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the requested maxTurns from config with a safe fallback.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} cfg
|
|
46
|
+
* @param {number} [fallback=20]
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
export function getMaxTurns(cfg, fallback = 20) {
|
|
50
|
+
const n = Number(cfg?.maxTurns);
|
|
51
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Baseline limits for mission runs. Adjust here for global defaults.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{
|
|
59
|
+
* softMaxTurns:number,
|
|
60
|
+
* hardMaxTurns:number,
|
|
61
|
+
* hardMinTurns:number,
|
|
62
|
+
* maxIdleTurns:number,
|
|
63
|
+
* maxErrors:number,
|
|
64
|
+
* maxSeconds:number
|
|
65
|
+
* }}
|
|
66
|
+
*/
|
|
67
|
+
export function resolveTurnLimits() {
|
|
68
|
+
const softMaxTurns = 50;
|
|
69
|
+
const hardMaxTurns = 200;
|
|
70
|
+
const hardMinTurns = 5;
|
|
71
|
+
const maxIdleTurns = 6;
|
|
72
|
+
const maxErrors = 5;
|
|
73
|
+
const maxSeconds = 600; //10 minutes
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
softMaxTurns,
|
|
77
|
+
hardMaxTurns,
|
|
78
|
+
hardMinTurns,
|
|
79
|
+
maxIdleTurns,
|
|
80
|
+
maxErrors,
|
|
81
|
+
maxSeconds,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compute an effective turn budget and normalized limits from config.
|
|
87
|
+
* - Default: clamp + warn (lenient).
|
|
88
|
+
* - Strict mode (cfg.strictLimits or env STRICT_LIMITS): throw on violations.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} cfg
|
|
91
|
+
* @param {ReturnType<typeof resolveTurnLimits>} [baseLimits=resolveTurnLimits()]
|
|
92
|
+
* @returns {{
|
|
93
|
+
* effectiveMax:number,
|
|
94
|
+
* limits: ReturnType<typeof resolveTurnLimits>,
|
|
95
|
+
* notes:string[],
|
|
96
|
+
* strict:boolean
|
|
97
|
+
* }}
|
|
98
|
+
*/
|
|
99
|
+
export function enforceTurnBudget(cfg, baseLimits = resolveTurnLimits()) {
|
|
100
|
+
const strict = !!(cfg?.strictLimits || process.env.STRICT_LIMITS);
|
|
101
|
+
|
|
102
|
+
const limits = { ...baseLimits };
|
|
103
|
+
const notes = [];
|
|
104
|
+
|
|
105
|
+
// Normalize soft/hard ordering
|
|
106
|
+
if (limits.softMaxTurns > limits.hardMaxTurns) {
|
|
107
|
+
if (strict) throw new Error(`[limits] softMaxTurns (${limits.softMaxTurns}) > hardMaxTurns (${limits.hardMaxTurns}).`);
|
|
108
|
+
notes.push(`Adjusted softMaxTurns down to hardMaxTurns (${limits.hardMaxTurns}).`);
|
|
109
|
+
limits.softMaxTurns = limits.hardMaxTurns;
|
|
110
|
+
}
|
|
111
|
+
if (limits.hardMinTurns > limits.softMaxTurns) {
|
|
112
|
+
if (strict) throw new Error(`[limits] hardMinTurns (${limits.hardMinTurns}) > softMaxTurns (${limits.softMaxTurns}).`);
|
|
113
|
+
notes.push(`Adjusted hardMinTurns down to softMaxTurns (${limits.softMaxTurns}).`);
|
|
114
|
+
limits.hardMinTurns = limits.softMaxTurns;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Resolve requested maxTurns (env override wins over config)
|
|
118
|
+
const envTurns = Number(process.env.TESTRONAUT_TURNS);
|
|
119
|
+
const hasEnv = Number.isFinite(envTurns) && envTurns > 0;
|
|
120
|
+
const cfgTurns = Number(cfg?.maxTurns);
|
|
121
|
+
const hasCfg = Number.isFinite(cfgTurns) && cfgTurns > 0;
|
|
122
|
+
let requested = hasEnv ? envTurns : (hasCfg ? cfgTurns : 20);
|
|
123
|
+
let effectiveMax = requested;
|
|
124
|
+
|
|
125
|
+
if (hasEnv) {
|
|
126
|
+
notes.push(`ℹ️ Using CLI turn override (TESTRONAUT_TURNS=${envTurns}).`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (effectiveMax > limits.hardMaxTurns) {
|
|
130
|
+
const msg = `maxTurns (${effectiveMax}) exceeds hardMaxTurns (${limits.hardMaxTurns}).`;
|
|
131
|
+
if (strict) throw new Error(`[limits] ${msg}`);
|
|
132
|
+
notes.push(`⚠️ ${msg} Clamping to ${limits.hardMaxTurns}.`);
|
|
133
|
+
effectiveMax = limits.hardMaxTurns;
|
|
134
|
+
} else if (effectiveMax > limits.softMaxTurns) {
|
|
135
|
+
const msg = `maxTurns (${effectiveMax}) exceeds softMaxTurns (${limits.softMaxTurns}).`;
|
|
136
|
+
if (strict) throw new Error(`[limits] ${msg}`);
|
|
137
|
+
notes.push(`⚠️ ${msg} Proceeding, but consider lowering to control cost.`);
|
|
138
|
+
} else if (effectiveMax < limits.hardMinTurns) {
|
|
139
|
+
const msg = `maxTurns (${effectiveMax}) is below hardMinTurns (${limits.hardMinTurns}).`;
|
|
140
|
+
if (strict) throw new Error(`[limits] ${msg}`);
|
|
141
|
+
notes.push(`⚠️ ${msg} Raising to ${limits.hardMinTurns}.`);
|
|
142
|
+
effectiveMax = limits.hardMinTurns;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { effectiveMax, limits, notes, strict };
|
|
146
|
+
}
|
package/core/turnLoop.js
CHANGED
|
@@ -60,7 +60,7 @@ const llm = getLLM(PROVIDER_ID);
|
|
|
60
60
|
let totalTokensUsed = 0;
|
|
61
61
|
let turnTimestamps = [];
|
|
62
62
|
let shouldBackoff;
|
|
63
|
-
let steps = [];
|
|
63
|
+
// let steps = [];
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* Utility: format byte counts into human-readable strings.
|
|
@@ -148,6 +148,15 @@ export const turnLoop = async (
|
|
|
148
148
|
const { steps = [], missionName } = ctx;
|
|
149
149
|
let agentMemory = { lastMenuExpanded: false };
|
|
150
150
|
|
|
151
|
+
// Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
|
|
152
|
+
// Idempotent recorder – prevents accidental double-push
|
|
153
|
+
const recordStep = (step) => {
|
|
154
|
+
if (!step || step.__recorded) return;
|
|
155
|
+
step.__recorded = true;
|
|
156
|
+
try { steps.push(step); } catch {}
|
|
157
|
+
try { ctx?.onStep?.(step); } catch {}
|
|
158
|
+
};
|
|
159
|
+
|
|
151
160
|
// ─────────────────────────────────────────────
|
|
152
161
|
// STEP 1: Begin reasoning cycle
|
|
153
162
|
// ─────────────────────────────────────────────
|
|
@@ -163,7 +172,13 @@ export const turnLoop = async (
|
|
|
163
172
|
// Adaptive cooldown if nearing provider rate limit
|
|
164
173
|
({ totalTokensUsed, turnTimestamps, shouldBackoff } =
|
|
165
174
|
await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
|
|
166
|
-
if (shouldBackoff)
|
|
175
|
+
if (shouldBackoff) {
|
|
176
|
+
// step.result = '🟡 In Progress';
|
|
177
|
+
recordStep(step); // ✅ write the partial step before sleeping
|
|
178
|
+
turn -= 1; // retry same turn index
|
|
179
|
+
continue;
|
|
180
|
+
// return await turnLoop(browser, messages, maxTurns, turn, currentStep);
|
|
181
|
+
}
|
|
167
182
|
|
|
168
183
|
// Verify message structure integrity before requesting next model step
|
|
169
184
|
if (!validateAndInsertMissingToolResponses(messages, { insertPlaceholders: true })) {
|
|
@@ -171,6 +186,7 @@ export const turnLoop = async (
|
|
|
171
186
|
step.events.push('❌ Tool call structure invalid and no placeholders inserted.');
|
|
172
187
|
step.result = '❌ Failure';
|
|
173
188
|
steps.push(step);
|
|
189
|
+
recordStep(step);
|
|
174
190
|
return steps;
|
|
175
191
|
}
|
|
176
192
|
|
|
@@ -191,6 +207,7 @@ export const turnLoop = async (
|
|
|
191
207
|
step.events.push('🛑 Detected assistant tool calls without matching tool responses');
|
|
192
208
|
step.result = '❌ Failure';
|
|
193
209
|
steps.push(step);
|
|
210
|
+
recordStep(step);
|
|
194
211
|
return steps;
|
|
195
212
|
}
|
|
196
213
|
|
|
@@ -212,6 +229,9 @@ export const turnLoop = async (
|
|
|
212
229
|
try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
|
|
213
230
|
const delay = Math.min(60000, 2 ** retryCount * 2000);
|
|
214
231
|
console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
|
|
232
|
+
step.events.push(`⏳ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
|
|
233
|
+
// step.result = '🟡 In Progress';
|
|
234
|
+
recordStep(step); // ✅ persist this step before sleeping
|
|
215
235
|
await wait(delay);
|
|
216
236
|
|
|
217
237
|
if (retryCount >= 5) {
|
|
@@ -219,14 +239,20 @@ export const turnLoop = async (
|
|
|
219
239
|
step.events.push('❌ Too many retries. Exiting.');
|
|
220
240
|
step.result = '❌ Failure';
|
|
221
241
|
steps.push(step);
|
|
242
|
+
recordStep(step);
|
|
222
243
|
return steps;
|
|
223
244
|
}
|
|
224
|
-
return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
245
|
+
// return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
|
|
246
|
+
// reattempt same turn after delay; keep same loop, bump retryCount
|
|
247
|
+
retryCount += 1;
|
|
248
|
+
turn -= 1;
|
|
249
|
+
continue;
|
|
225
250
|
} else if (err.status === 400) {
|
|
226
251
|
console.error('❌ Bad request:', err.message);
|
|
227
252
|
step.events.push(`❌ Bad request: ${err.message}`);
|
|
228
253
|
step.result = '❌ Failure';
|
|
229
254
|
steps.push(step);
|
|
255
|
+
recordStep(step);
|
|
230
256
|
return steps;
|
|
231
257
|
} else {
|
|
232
258
|
throw err;
|
|
@@ -296,7 +322,7 @@ export const turnLoop = async (
|
|
|
296
322
|
} catch { /* non-JSON results ignored */ }
|
|
297
323
|
|
|
298
324
|
console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : '✅ Success');
|
|
299
|
-
const truncated = result.length >
|
|
325
|
+
const truncated = result.length > 1000 ? result.slice(0, 1000) + '…' : result;
|
|
300
326
|
step.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : '✅ Success'}`);
|
|
301
327
|
step.events.push(`[tool ] ← ${truncated}`);
|
|
302
328
|
|
|
@@ -340,6 +366,7 @@ export const turnLoop = async (
|
|
|
340
366
|
messages.push(msg, ...toolResponses);
|
|
341
367
|
step.result = '✅ Passed';
|
|
342
368
|
steps.push(step);
|
|
369
|
+
recordStep(step);
|
|
343
370
|
continue;
|
|
344
371
|
}
|
|
345
372
|
|
|
@@ -351,6 +378,7 @@ export const turnLoop = async (
|
|
|
351
378
|
step.events.push(finalResponse.finalMessage);
|
|
352
379
|
step.result = finalResponse.success ? '✅ Mission Success' : '❌ Mission Failure';
|
|
353
380
|
steps.push(step);
|
|
381
|
+
recordStep(step);
|
|
354
382
|
return { success: finalResponse.finalMessage, steps };
|
|
355
383
|
}
|
|
356
384
|
|
|
@@ -362,5 +390,8 @@ export const turnLoop = async (
|
|
|
362
390
|
});
|
|
363
391
|
console.log(`[auto] → Injected DOM for next reasoning step`);
|
|
364
392
|
step.events.push(`[auto] → Injected DOM for next reasoning step`);
|
|
393
|
+
// This is a meaningful turn even without tool calls — record it.
|
|
394
|
+
step.result = step.result || '🟡 In Progress';
|
|
395
|
+
recordStep(step);
|
|
365
396
|
}
|
|
366
397
|
};
|
package/package.json
CHANGED
package/runner/testronaut.js
CHANGED
|
@@ -1,27 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
/**
|
|
2
|
+
* testronaut.js
|
|
3
|
+
* -------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* High-level mission runner: normalize pre/main/post submissions,
|
|
6
|
+
* compute a safe turn budget from config, and invoke the agent.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Read config once and derive turn limits via enforceTurnBudget().
|
|
10
|
+
* - Build a normalized list of submissions with human-friendly names.
|
|
11
|
+
* - Redact sensitive text when logging mission strings.
|
|
12
|
+
* - Call runAgent(goals, missionName, maxTurns) and post-process status.
|
|
13
|
+
*
|
|
14
|
+
* Message contract (goal → initial messages inside agent):
|
|
15
|
+
* - system: operational guidance + success/failure contract
|
|
16
|
+
* - user: the mission text (or coerced string)
|
|
17
|
+
*
|
|
18
|
+
* Related tests:
|
|
19
|
+
* tests/missionTests/testronaut.test.js
|
|
20
|
+
*
|
|
21
|
+
* Used by:
|
|
22
|
+
* - CLI entrypoint(s)
|
|
23
|
+
*/
|
|
20
24
|
|
|
21
25
|
|
|
26
|
+
import { runAgent } from '../core/agent.js';
|
|
27
|
+
import { redactPasswordInText } from '../core/redaction.js';
|
|
28
|
+
import { loadConfig, enforceTurnBudget } from '../core/config.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run a mission flow.
|
|
32
|
+
*
|
|
33
|
+
* @param {{ preMission?: any|any[], mission?: any|any[], postMission?: any|any[] }} params
|
|
34
|
+
* @param {string} missionName
|
|
35
|
+
*/
|
|
22
36
|
export async function runMissions({ preMission, mission, postMission }, missionName) {
|
|
23
|
-
|
|
37
|
+
// 1) Config and turn budget (with guardrails)
|
|
38
|
+
const cfg = await loadConfig();
|
|
39
|
+
const { effectiveMax, limits, notes } = enforceTurnBudget(cfg);
|
|
40
|
+
const maxTurns = effectiveMax;
|
|
41
|
+
if (notes.length) {
|
|
42
|
+
console.warn(notes.join('\n'));
|
|
43
|
+
}
|
|
24
44
|
|
|
45
|
+
// 2) Normalize submissions
|
|
46
|
+
const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
|
|
25
47
|
const pre = normalizeToArray(preMission);
|
|
26
48
|
const main = normalizeToArray(mission);
|
|
27
49
|
const post = normalizeToArray(postMission);
|
|
@@ -100,7 +122,8 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
100
122
|
})
|
|
101
123
|
);
|
|
102
124
|
|
|
103
|
-
|
|
125
|
+
// 3) Execute
|
|
126
|
+
const success = await runAgent(goals, missionName, maxTurns);
|
|
104
127
|
if (!success) {
|
|
105
128
|
console.log(`❌ Aborting after failed goal.`);
|
|
106
129
|
return;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// tests/coreTests/agent.test.js
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// 1) Provide a constructable mock without using `vi` inside the factory.
|
|
5
|
+
vi.mock('../../tools/chromeBrowser.js', () => {
|
|
6
|
+
function ChromeBrowser() {}
|
|
7
|
+
ChromeBrowser.prototype.start = async function () {};
|
|
8
|
+
ChromeBrowser.prototype.close = async function () {};
|
|
9
|
+
return { ChromeBrowser };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Keep turnLoop simple; using vi is fine here (no factory-hoist issue)
|
|
13
|
+
vi.mock('../../core/turnLoop.js', () => ({
|
|
14
|
+
turnLoop: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { ChromeBrowser } from '../../tools/chromeBrowser.js';
|
|
18
|
+
import { turnLoop } from '../../core/turnLoop.js';
|
|
19
|
+
import { runAgent } from '../../core/agent.js';
|
|
20
|
+
|
|
21
|
+
let startSpy, closeSpy;
|
|
22
|
+
|
|
23
|
+
describe('core/agent.runAgent', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
// 2) Spy on prototype methods AFTER import time
|
|
26
|
+
startSpy = vi.spyOn(ChromeBrowser.prototype, 'start').mockResolvedValue();
|
|
27
|
+
closeSpy = vi.spyOn(ChromeBrowser.prototype, 'close').mockResolvedValue();
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
startSpy.mockRestore();
|
|
33
|
+
closeSpy.mockRestore();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('runs all goals and returns missionResults (all passed)', async () => {
|
|
37
|
+
turnLoop
|
|
38
|
+
.mockResolvedValueOnce({ success: true })
|
|
39
|
+
.mockResolvedValueOnce({ success: true });
|
|
40
|
+
|
|
41
|
+
const goals = [
|
|
42
|
+
{ goal: 'Do A', submissionType: 'mission', submissionName: 'A' },
|
|
43
|
+
{ goal: 'Do B', submissionType: 'mission', submissionName: 'B' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const res = await runAgent(goals, 'My Mission', 10);
|
|
47
|
+
|
|
48
|
+
// Browser lifecycle
|
|
49
|
+
expect(startSpy).toHaveBeenCalledTimes(1);
|
|
50
|
+
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
51
|
+
|
|
52
|
+
// turnLoop called once per goal and receives maxTurns
|
|
53
|
+
expect(turnLoop).toHaveBeenCalledTimes(2);
|
|
54
|
+
expect(turnLoop.mock.calls[0][2]).toBe(10);
|
|
55
|
+
expect(turnLoop.mock.calls[1][2]).toBe(10);
|
|
56
|
+
|
|
57
|
+
expect(res).toHaveLength(2);
|
|
58
|
+
expect(res[0].status).toBe('passed');
|
|
59
|
+
expect(res[1].status).toBe('passed');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('stops early on first failure and returns partial results', async () => {
|
|
63
|
+
turnLoop.mockResolvedValueOnce({ success: false });
|
|
64
|
+
|
|
65
|
+
const res = await runAgent(
|
|
66
|
+
[
|
|
67
|
+
{ goal: 'A', submissionType: 'mission', submissionName: 'A' },
|
|
68
|
+
{ goal: 'B', submissionType: 'mission', submissionName: 'B' },
|
|
69
|
+
],
|
|
70
|
+
'Fail Mission',
|
|
71
|
+
7
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(turnLoop).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(res).toHaveLength(1);
|
|
76
|
+
expect(res[0].status).toBe('failed');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('coerces non-string goal to string for the user message', async () => {
|
|
80
|
+
turnLoop.mockResolvedValueOnce({ success: true });
|
|
81
|
+
|
|
82
|
+
await runAgent([{ goal: { toString: () => 'coerced text' } }], 'Coerce', 5);
|
|
83
|
+
|
|
84
|
+
const messages = turnLoop.mock.calls[0][1];
|
|
85
|
+
expect(messages[1]).toEqual({ role: 'user', content: 'coerced text' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('deep-clones steps so external mutations do not leak', async () => {
|
|
89
|
+
turnLoop.mockImplementationOnce(async (browser, messages, maxTurns, ct, rc, cs, ctx) => {
|
|
90
|
+
ctx.steps.push({ result: 'SUCCESS: step1' });
|
|
91
|
+
return { success: true };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const res1 = await runAgent([{ goal: 'Check clone' }], 'Clone Test', 3);
|
|
95
|
+
res1[0].steps[0].result = 'TAMPERED';
|
|
96
|
+
|
|
97
|
+
turnLoop.mockImplementationOnce(async (browser, messages, maxTurns, ct, rc, cs, ctx) => {
|
|
98
|
+
ctx.steps.push({ result: 'SUCCESS: step2' });
|
|
99
|
+
return { success: true };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const res2 = await runAgent([{ goal: 'Check clone' }], 'Clone Test 2', 3);
|
|
103
|
+
expect(res1[0].steps[0].result).toBe('TAMPERED');
|
|
104
|
+
expect(res2[0].steps[0].result).toBe('SUCCESS: step2');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('includes missionName and submission metadata in results', async () => {
|
|
108
|
+
turnLoop.mockResolvedValueOnce({ success: true });
|
|
109
|
+
|
|
110
|
+
const res = await runAgent(
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
goal: 'Meta',
|
|
114
|
+
submissionType: 'mission',
|
|
115
|
+
submissionName: 'MetaName',
|
|
116
|
+
label: 'legacy-label',
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
'MetaMission',
|
|
120
|
+
4
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(res[0].missionName).toBe('MetaMission');
|
|
124
|
+
expect(res[0].submissionType).toBe('mission');
|
|
125
|
+
expect(res[0].submissionName).toBe('MetaName');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node:fs/promises', () => ({
|
|
4
|
+
readFile: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import {
|
|
9
|
+
loadConfig,
|
|
10
|
+
getMaxTurns,
|
|
11
|
+
resolveTurnLimits,
|
|
12
|
+
enforceTurnBudget,
|
|
13
|
+
} from '../../core/config.js';
|
|
14
|
+
|
|
15
|
+
describe('core/config', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.resetAllMocks();
|
|
18
|
+
delete process.env.STRICT_LIMITS;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('loadConfig', () => {
|
|
22
|
+
it('parses JSON when present', async () => {
|
|
23
|
+
readFile.mockResolvedValueOnce(JSON.stringify({ maxTurns: 33 }));
|
|
24
|
+
const cfg = await loadConfig('/any');
|
|
25
|
+
expect(cfg).toEqual({ maxTurns: 33 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns {} on ENOENT', async () => {
|
|
29
|
+
readFile.mockRejectedValueOnce(new Error('ENOENT'));
|
|
30
|
+
const cfg = await loadConfig('/missing');
|
|
31
|
+
expect(cfg).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns {} on invalid JSON', async () => {
|
|
35
|
+
readFile.mockResolvedValueOnce('{ nope ');
|
|
36
|
+
const cfg = await loadConfig('/bad');
|
|
37
|
+
expect(cfg).toEqual({});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('getMaxTurns', () => {
|
|
42
|
+
it('uses fallback when invalid or missing', () => {
|
|
43
|
+
expect(getMaxTurns({}, 20)).toBe(20);
|
|
44
|
+
expect(getMaxTurns({ maxTurns: 'x' }, 20)).toBe(20);
|
|
45
|
+
expect(getMaxTurns({ maxTurns: 0 }, 20)).toBe(20);
|
|
46
|
+
expect(getMaxTurns({ maxTurns: -5 }, 20)).toBe(20);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns numeric when valid', () => {
|
|
50
|
+
expect(getMaxTurns({ maxTurns: 7 }, 20)).toBe(7);
|
|
51
|
+
expect(getMaxTurns({ maxTurns: '15' }, 20)).toBe(15);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('resolveTurnLimits', () => {
|
|
56
|
+
it('returns expected defaults', () => {
|
|
57
|
+
const l = resolveTurnLimits();
|
|
58
|
+
expect(l.softMaxTurns).toBe(50);
|
|
59
|
+
expect(l.hardMaxTurns).toBe(200);
|
|
60
|
+
expect(l.hardMinTurns).toBe(5);
|
|
61
|
+
expect(l.maxIdleTurns).toBe(6);
|
|
62
|
+
expect(l.maxErrors).toBe(5);
|
|
63
|
+
expect(l.maxSeconds).toBe(600);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('enforceTurnBudget (lenient)', () => {
|
|
68
|
+
it('clamps > hardMaxTurns', () => {
|
|
69
|
+
const cfg = { maxTurns: 1000 };
|
|
70
|
+
const { effectiveMax, limits, notes, strict } = enforceTurnBudget(cfg);
|
|
71
|
+
expect(strict).toBe(false);
|
|
72
|
+
expect(effectiveMax).toBe(limits.hardMaxTurns);
|
|
73
|
+
expect(notes.join('\n')).toMatch(/Clamping to/i);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('warns when > softMaxTurns but <= hardMaxTurns', () => {
|
|
77
|
+
const cfg = { maxTurns: 120 };
|
|
78
|
+
const { effectiveMax, limits, notes } = enforceTurnBudget(cfg);
|
|
79
|
+
expect(effectiveMax).toBe(120);
|
|
80
|
+
expect(notes.join('\n')).toMatch(/exceeds softMaxTurns/i);
|
|
81
|
+
expect(effectiveMax).toBeLessThanOrEqual(limits.hardMaxTurns);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('raises to hardMinTurns when below minimum', () => {
|
|
85
|
+
const cfg = { maxTurns: 1 };
|
|
86
|
+
const { effectiveMax, limits, notes } = enforceTurnBudget(cfg);
|
|
87
|
+
expect(effectiveMax).toBe(limits.hardMinTurns);
|
|
88
|
+
expect(notes.join('\n')).toMatch(/Raising to/i);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('normalizes soft>hard ordering with a note', () => {
|
|
92
|
+
const weird = {
|
|
93
|
+
softMaxTurns: 300,
|
|
94
|
+
hardMaxTurns: 200,
|
|
95
|
+
hardMinTurns: 5,
|
|
96
|
+
maxIdleTurns: 6,
|
|
97
|
+
maxErrors: 5,
|
|
98
|
+
maxSeconds: 600,
|
|
99
|
+
};
|
|
100
|
+
const { limits, notes } = enforceTurnBudget({ maxTurns: 20 }, weird);
|
|
101
|
+
expect(limits.softMaxTurns).toBe(limits.hardMaxTurns);
|
|
102
|
+
expect(notes.join('\n')).toMatch(/Adjusted softMaxTurns down to hardMaxTurns/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('enforceTurnBudget (strict)', () => {
|
|
107
|
+
it('throws when > hardMaxTurns', () => {
|
|
108
|
+
const cfg = { maxTurns: 999, strictLimits: true };
|
|
109
|
+
expect(() => enforceTurnBudget(cfg)).toThrow(/exceeds hardMaxTurns/i);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('throws when below hardMinTurns', () => {
|
|
113
|
+
process.env.STRICT_LIMITS = '1';
|
|
114
|
+
const cfg = { maxTurns: 2 };
|
|
115
|
+
expect(() => enforceTurnBudget(cfg)).toThrow(/below hardMinTurns/i);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('throws when softMaxTurns > hardMaxTurns', () => {
|
|
119
|
+
const bad = {
|
|
120
|
+
softMaxTurns: 300,
|
|
121
|
+
hardMaxTurns: 200,
|
|
122
|
+
hardMinTurns: 5,
|
|
123
|
+
maxIdleTurns: 6,
|
|
124
|
+
maxErrors: 5,
|
|
125
|
+
maxSeconds: 600,
|
|
126
|
+
};
|
|
127
|
+
expect(() => enforceTurnBudget({ strictLimits: true, maxTurns: 20 }, bad))
|
|
128
|
+
.toThrow(/softMaxTurns .* > hardMaxTurns/);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
describe('enforceTurnBudget env override', () => {
|
|
134
|
+
const old = process.env.TESTRONAUT_TURNS;
|
|
135
|
+
|
|
136
|
+
beforeEach(() => { delete process.env.TESTRONAUT_TURNS; });
|
|
137
|
+
afterEach(() => { if (old === undefined) delete process.env.TESTRONAUT_TURNS; else process.env.TESTRONAUT_TURNS = old; });
|
|
138
|
+
|
|
139
|
+
it('uses TESTRONAUT_TURNS over cfg.maxTurns', () => {
|
|
140
|
+
process.env.TESTRONAUT_TURNS = '30';
|
|
141
|
+
const { effectiveMax, notes } = enforceTurnBudget({ maxTurns: 5 });
|
|
142
|
+
expect(effectiveMax).toBe(30);
|
|
143
|
+
expect(notes.join('\n')).toMatch(/CLI turn override/i);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mocks
|
|
4
|
+
vi.mock('../../core/agent.js', () => ({ runAgent: vi.fn() }));
|
|
5
|
+
vi.mock('../../core/redaction.js', () => ({
|
|
6
|
+
// simple deterministic redaction stub
|
|
7
|
+
redactPasswordInText: (s) => String(s).replace(/password:\s*\S+/gi, 'password: ********'),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('../../core/config.js', () => ({
|
|
10
|
+
loadConfig: vi.fn(),
|
|
11
|
+
enforceTurnBudget: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { runAgent } from '../../core/agent.js';
|
|
15
|
+
import { loadConfig, enforceTurnBudget } from '../../core/config.js';
|
|
16
|
+
|
|
17
|
+
// Adjust the import path if your file lives elsewhere
|
|
18
|
+
import { runMissions } from '../../runner/testronaut.js';
|
|
19
|
+
|
|
20
|
+
describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('passes effectiveMax to runAgent and logs any notes', async () => {
|
|
26
|
+
loadConfig.mockResolvedValue({ maxTurns: 999 });
|
|
27
|
+
enforceTurnBudget.mockReturnValue({
|
|
28
|
+
effectiveMax: 200,
|
|
29
|
+
limits: { softMaxTurns: 50, hardMaxTurns: 200, hardMinTurns: 5 },
|
|
30
|
+
notes: ['⚠️ maxTurns (999) exceeds hardMaxTurns (200). Clamping to 200.'],
|
|
31
|
+
strict: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
35
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
36
|
+
|
|
37
|
+
runAgent.mockResolvedValue([
|
|
38
|
+
{ steps: [{ result: 'SUCCESS: done' }], status: 'passed' },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
await runMissions({ mission: 'Do xyz' }, 'Budgeted Run');
|
|
42
|
+
|
|
43
|
+
expect(loadConfig).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(enforceTurnBudget).toHaveBeenCalledWith({ maxTurns: 999 });
|
|
45
|
+
expect(warn).toHaveBeenCalledWith(
|
|
46
|
+
expect.stringContaining('Clamping to 200')
|
|
47
|
+
);
|
|
48
|
+
expect(runAgent).toHaveBeenCalledWith(
|
|
49
|
+
expect.any(Array), 'Budgeted Run', 200
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
warn.mockRestore();
|
|
53
|
+
log.mockRestore();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not warn when no notes are returned', async () => {
|
|
57
|
+
loadConfig.mockResolvedValue({ maxTurns: 20 });
|
|
58
|
+
enforceTurnBudget.mockReturnValue({
|
|
59
|
+
effectiveMax: 20,
|
|
60
|
+
limits: { softMaxTurns: 50, hardMaxTurns: 200, hardMinTurns: 5 },
|
|
61
|
+
notes: [],
|
|
62
|
+
strict: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
66
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
67
|
+
|
|
68
|
+
runAgent.mockResolvedValue([
|
|
69
|
+
{ steps: [{ result: 'SUCCESS: ok' }], status: 'passed' },
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
await runMissions({ mission: 'No warnings' }, 'Clean');
|
|
73
|
+
|
|
74
|
+
expect(warn).not.toHaveBeenCalled();
|
|
75
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20);
|
|
76
|
+
|
|
77
|
+
warn.mockRestore();
|
|
78
|
+
log.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('marks first mission failed when last step result includes "failure"', async () => {
|
|
82
|
+
loadConfig.mockResolvedValue({});
|
|
83
|
+
enforceTurnBudget.mockReturnValue({
|
|
84
|
+
effectiveMax: 20,
|
|
85
|
+
limits: {},
|
|
86
|
+
notes: [],
|
|
87
|
+
strict: false,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
91
|
+
runAgent.mockResolvedValue([
|
|
92
|
+
{ steps: [{ result: 'FAILURE: could not do it' }], status: 'passed' },
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const res = await runMissions({ mission: 'Will fail' }, 'Status');
|
|
96
|
+
expect(res[0].status).toBe('failed');
|
|
97
|
+
|
|
98
|
+
log.mockRestore();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('normalizes single strings and functions into arrays and names missions', async () => {
|
|
102
|
+
loadConfig.mockResolvedValue({});
|
|
103
|
+
enforceTurnBudget.mockReturnValue({
|
|
104
|
+
effectiveMax: 15,
|
|
105
|
+
limits: {},
|
|
106
|
+
notes: [],
|
|
107
|
+
strict: false,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
111
|
+
runAgent.mockResolvedValue([
|
|
112
|
+
{ steps: [{ result: 'SUCCESS: done' }], status: 'passed' },
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
function preTask() {}
|
|
116
|
+
function postTask() {}
|
|
117
|
+
|
|
118
|
+
await runMissions(
|
|
119
|
+
{
|
|
120
|
+
preMission: preTask,
|
|
121
|
+
mission: 'Main mission text\nwith details',
|
|
122
|
+
postMission: postTask,
|
|
123
|
+
},
|
|
124
|
+
'My Mission'
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Check that runAgent got 3 goals (pre/main/post)
|
|
128
|
+
const call = (runAgent.mock.calls[0] || []);
|
|
129
|
+
const goals = call[0];
|
|
130
|
+
expect(Array.isArray(goals)).toBe(true);
|
|
131
|
+
expect(goals).toHaveLength(3);
|
|
132
|
+
|
|
133
|
+
// Submission types and names are set
|
|
134
|
+
expect(goals[0].submissionType).toBe('premission');
|
|
135
|
+
expect(goals[1].submissionType).toBe('mission');
|
|
136
|
+
expect(goals[2].submissionType).toBe('postmission');
|
|
137
|
+
|
|
138
|
+
// Mission name is the passed-in missionName for main
|
|
139
|
+
expect(goals[1].submissionName).toMatch(/^My Mission/);
|
|
140
|
+
|
|
141
|
+
// Effective max turns passed through
|
|
142
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15);
|
|
143
|
+
|
|
144
|
+
log.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('aborts early when runAgent returns falsy (defensive)', async () => {
|
|
148
|
+
loadConfig.mockResolvedValue({});
|
|
149
|
+
enforceTurnBudget.mockReturnValue({
|
|
150
|
+
effectiveMax: 10,
|
|
151
|
+
limits: {},
|
|
152
|
+
notes: [],
|
|
153
|
+
strict: false,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
157
|
+
runAgent.mockResolvedValue(null); // simulate unexpected falsy
|
|
158
|
+
|
|
159
|
+
const res = await runMissions({ mission: 'Edge case' }, 'Abort');
|
|
160
|
+
expect(res).toBeUndefined();
|
|
161
|
+
|
|
162
|
+
log.mockRestore();
|
|
163
|
+
});
|
|
164
|
+
});
|