testronaut 1.3.0 β 1.3.1
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 +74 -9
- package/core/agent.js +13 -12
- package/core/config.js +30 -0
- package/core/turnLoop.js +51 -8
- package/package.json +1 -1
- package/runner/testronaut.js +7 -2
- package/tests/binTests/cli.helpers.test.js +36 -0
- package/tests/coreTests/config.test.js +41 -0
- package/tests/coreTests/turnLoop.test.js +54 -2
- package/tests/runnerTests/testronaut.test.js +10 -5
- package/tests/toolsTests/generateHtmlReport.test.js +90 -0
- package/tools/generateHtmlReport.js +38 -4
package/bin/cli.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* Test strategy:
|
|
30
30
|
* To test pure helpers without running the whole CLI, we export a tiny test bundle:
|
|
31
31
|
* export const __test__ = { guessMimeType, safeJoin, findLatestReportPair, pkgManagerForCwd }
|
|
32
|
-
* See tests in tests/
|
|
32
|
+
* See tests in tests/toolsTests/cli.helpers.test.js
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import fs from 'fs';
|
|
@@ -60,6 +60,11 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
|
|
|
60
60
|
|
|
61
61
|
const args = process.argv.slice(2);
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Read a JSONL steps file into an array of objects.
|
|
65
|
+
* @param {string} p
|
|
66
|
+
* @returns {object[]|null}
|
|
67
|
+
*/
|
|
63
68
|
function readJsonlSteps(p) {
|
|
64
69
|
if (!p || !fs.existsSync(p)) return null;
|
|
65
70
|
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
@@ -73,21 +78,27 @@ function readJsonlSteps(p) {
|
|
|
73
78
|
/**
|
|
74
79
|
* Merge duplicate turns without losing information.
|
|
75
80
|
* Strategy:
|
|
76
|
-
* - Keep the latest **non-empty** version for a given turn
|
|
81
|
+
* - Keep the latest **non-empty** version for a given (turn,retry) pair
|
|
77
82
|
* - If both are non-empty, keep the later one (last write wins)
|
|
78
83
|
* - Preserve original order by line index as a tiebreaker
|
|
84
|
+
*
|
|
85
|
+
* @param {Array} steps
|
|
86
|
+
* @returns {Array}
|
|
79
87
|
*/
|
|
80
88
|
function mergeDuplicateTurns(steps) {
|
|
81
89
|
const out = [];
|
|
82
|
-
const
|
|
90
|
+
const byKey = new Map(); // composite key -> index in out
|
|
83
91
|
for (const s of steps) {
|
|
84
92
|
const turn = Number.isFinite(s.turn) ? s.turn : out.length;
|
|
93
|
+
const attempt = Number.isFinite(s.retryAttempt) ? s.retryAttempt : 1;
|
|
85
94
|
const hasEvents = Array.isArray(s.events) && s.events.length > 0;
|
|
86
|
-
|
|
95
|
+
const key = `${turn}::${attempt}`;
|
|
96
|
+
|
|
97
|
+
if (!byKey.has(key)) {
|
|
87
98
|
out.push(s);
|
|
88
|
-
|
|
99
|
+
byKey.set(key, out.length - 1);
|
|
89
100
|
} else {
|
|
90
|
-
const idx =
|
|
101
|
+
const idx = byKey.get(key);
|
|
91
102
|
const existing = out[idx];
|
|
92
103
|
const existingHasEvents = Array.isArray(existing.events) && existing.events.length > 0;
|
|
93
104
|
// Prefer the one with events; if both have events, prefer the newer (s)
|
|
@@ -101,11 +112,28 @@ function mergeDuplicateTurns(steps) {
|
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
|
-
// stable sort by `turn`
|
|
105
|
-
out.sort((a, b) =>
|
|
115
|
+
// stable sort by `turn` then `retryAttempt`, then by original order
|
|
116
|
+
out.sort((a, b) => {
|
|
117
|
+
const ta = a.turn ?? 0;
|
|
118
|
+
const tb = b.turn ?? 0;
|
|
119
|
+
if (ta !== tb) return ta - tb;
|
|
120
|
+
const ra = Number.isFinite(a.retryAttempt) ? a.retryAttempt : 1;
|
|
121
|
+
const rb = Number.isFinite(b.retryAttempt) ? b.retryAttempt : 1;
|
|
122
|
+
if (ra !== rb) return ra - rb;
|
|
123
|
+
return 0;
|
|
124
|
+
});
|
|
106
125
|
return out;
|
|
107
126
|
}
|
|
108
127
|
|
|
128
|
+
// Expose a small bundle for unit tests (helper-only; not the CLI flow)
|
|
129
|
+
export const __test__ = {
|
|
130
|
+
guessMimeType,
|
|
131
|
+
safeJoin,
|
|
132
|
+
findLatestReportPair,
|
|
133
|
+
mergeDuplicateTurns,
|
|
134
|
+
readJsonlSteps,
|
|
135
|
+
};
|
|
136
|
+
|
|
109
137
|
// Look for --model=<id> or --model <id>
|
|
110
138
|
let modelOverride;
|
|
111
139
|
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
@@ -149,6 +177,37 @@ if (turnsFlagIndex >= 0) {
|
|
|
149
177
|
args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
|
|
150
178
|
}
|
|
151
179
|
|
|
180
|
+
// Look for --retry_limit / --retry-limit
|
|
181
|
+
let retryOverride;
|
|
182
|
+
const retryFlagIndex = args.findIndex(a =>
|
|
183
|
+
a === '--retry_limit' ||
|
|
184
|
+
a.startsWith('--retry_limit=') ||
|
|
185
|
+
a === '--retry-limit' ||
|
|
186
|
+
a.startsWith('--retry-limit=')
|
|
187
|
+
);
|
|
188
|
+
if (retryFlagIndex >= 0) {
|
|
189
|
+
const rawArg = args[retryFlagIndex];
|
|
190
|
+
if (rawArg.includes('=')) {
|
|
191
|
+
retryOverride = rawArg.split('=')[1];
|
|
192
|
+
} else if (args[retryFlagIndex + 1]) {
|
|
193
|
+
retryOverride = args[retryFlagIndex + 1];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (retryOverride) {
|
|
197
|
+
const n = Number(retryOverride.trim());
|
|
198
|
+
if (Number.isFinite(n)) {
|
|
199
|
+
const clamped = Math.min(10, Math.max(1, n));
|
|
200
|
+
process.env.TESTRONAUT_RETRY_LIMIT = String(clamped);
|
|
201
|
+
console.log(`π Retry limit override: ${process.env.TESTRONAUT_RETRY_LIMIT} (allowed 1-10)`);
|
|
202
|
+
} else {
|
|
203
|
+
console.warn(`β οΈ Invalid --retry_limit value "${retryOverride}". Ignoring.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Remove flag & value so they arenβt treated as filenames
|
|
208
|
+
args.splice(retryFlagIndex, retryOverride ? 2 : 1);
|
|
209
|
+
}
|
|
210
|
+
|
|
152
211
|
const missionsDir = path.resolve(process.cwd(), 'missions');
|
|
153
212
|
|
|
154
213
|
const allResults = [];
|
|
@@ -170,6 +229,7 @@ Options:
|
|
|
170
229
|
--init Scaffold project folders and a welcome mission
|
|
171
230
|
--turns=<n> Override max turns for this run (e.g., --turns=30)
|
|
172
231
|
--help Show this help message
|
|
232
|
+
--retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
|
|
173
233
|
|
|
174
234
|
Examples:
|
|
175
235
|
npx testronaut
|
|
@@ -720,6 +780,11 @@ function safeJoin(root, relUrlPath) {
|
|
|
720
780
|
return resolved;
|
|
721
781
|
}
|
|
722
782
|
|
|
783
|
+
/**
|
|
784
|
+
* Best-effort cross-platform URL opener.
|
|
785
|
+
* @param {string} targetUrl
|
|
786
|
+
* @returns {Promise<void>}
|
|
787
|
+
*/
|
|
723
788
|
async function openInBrowser(targetUrl) {
|
|
724
789
|
const platform = process.platform;
|
|
725
790
|
const quoted = `"${targetUrl}"`;
|
|
@@ -848,4 +913,4 @@ try {
|
|
|
848
913
|
}
|
|
849
914
|
} catch (err) {
|
|
850
915
|
console.warn(`β οΈ Could not remove tmp folder: ${err.message}`);
|
|
851
|
-
}
|
|
916
|
+
}
|
package/core/agent.js
CHANGED
|
@@ -37,7 +37,7 @@ import path from 'path';
|
|
|
37
37
|
* @param {number} [maxTurns=20] - upper bound for turnLoop per goal
|
|
38
38
|
* @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
|
|
39
39
|
*/
|
|
40
|
-
export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
40
|
+
export async function runAgent(goals, missionName, maxTurns = 20, retryLimit) {
|
|
41
41
|
const browser = new ChromeBrowser();
|
|
42
42
|
await browser.start();
|
|
43
43
|
let result;
|
|
@@ -52,6 +52,7 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
|
|
|
52
52
|
|
|
53
53
|
for (const goal of goals) {
|
|
54
54
|
const steps = [];
|
|
55
|
+
const stepsArchive = [];
|
|
55
56
|
// Unique-ish JSONL file for this missionβs steps
|
|
56
57
|
const stepFile = path.join(
|
|
57
58
|
tmpDir,
|
|
@@ -152,7 +153,9 @@ Use Ground Control as your persistent mission memory about:
|
|
|
152
153
|
null, // currentStep
|
|
153
154
|
{
|
|
154
155
|
steps,
|
|
156
|
+
stepsArchive,
|
|
155
157
|
missionName,
|
|
158
|
+
retryLimit,
|
|
156
159
|
groundControl,
|
|
157
160
|
onStep: (s) => {
|
|
158
161
|
// Append each step as a JSON line
|
|
@@ -160,26 +163,24 @@ Use Ground Control as your persistent mission memory about:
|
|
|
160
163
|
fs.appendFileSync(stepFile, JSON.stringify(s) + '\n');
|
|
161
164
|
// Keep memory in check: retain only the last ~20 steps in RAM
|
|
162
165
|
if (steps.length > 20) steps.splice(0, steps.length - 20);
|
|
166
|
+
stepsArchive.push(s);
|
|
163
167
|
} catch {} // best-effort; donβt crash the agent
|
|
164
168
|
}
|
|
165
169
|
}
|
|
166
170
|
);
|
|
167
171
|
|
|
168
172
|
function dedupeSteps(steps) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const key = `${s.turn}::${s.summary}::${first}`;
|
|
174
|
-
if (!seen.has(key)) {
|
|
175
|
-
seen.add(key);
|
|
176
|
-
out.push(s);
|
|
173
|
+
const copy = [...steps];
|
|
174
|
+
copy.sort((a, b) => {
|
|
175
|
+
if (Number.isFinite(a?._seq) && Number.isFinite(b?._seq)) {
|
|
176
|
+
return a._seq - b._seq;
|
|
177
177
|
}
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
return (a?.turn ?? 0) - (b?.turn ?? 0);
|
|
179
|
+
});
|
|
180
|
+
return copy;
|
|
180
181
|
}
|
|
181
182
|
|
|
182
|
-
const compact = dedupeSteps(steps);
|
|
183
|
+
const compact = dedupeSteps(stepsArchive.length ? stepsArchive : steps);
|
|
183
184
|
|
|
184
185
|
// Snapshot steps (avoid retaining references to mutable arrays)
|
|
185
186
|
missionResults.push({
|
package/core/config.js
CHANGED
|
@@ -52,6 +52,36 @@ export function getMaxTurns(cfg, fallback = 20) {
|
|
|
52
52
|
return n;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Resolve retry limit per turn (attempts - 1) with clamping.
|
|
57
|
+
* Priority: env TESTRONAUT_RETRY_LIMIT β config.retryLimit β fallback
|
|
58
|
+
*
|
|
59
|
+
* @param {object} cfg
|
|
60
|
+
* @param {number} [fallback=2]
|
|
61
|
+
* @returns {{ value:number, source:'env'|'config'|'default', clamped:boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function getRetryLimit(cfg, fallback = 2) {
|
|
64
|
+
const clampRetry = (n) => {
|
|
65
|
+
const clamped = Math.min(10, Math.max(1, n));
|
|
66
|
+
return { value: clamped, clamped: clamped !== n };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const envVal = Number(process.env.TESTRONAUT_RETRY_LIMIT);
|
|
70
|
+
if (Number.isFinite(envVal)) {
|
|
71
|
+
const { value, clamped } = clampRetry(envVal);
|
|
72
|
+
return { value, source: 'env', clamped };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cfgVal = Number(cfg?.retryLimit);
|
|
76
|
+
if (Number.isFinite(cfgVal)) {
|
|
77
|
+
const { value, clamped } = clampRetry(cfgVal);
|
|
78
|
+
return { value, source: 'config', clamped };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { value, clamped } = clampRetry(fallback);
|
|
82
|
+
return { value, source: 'default', clamped };
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
/**
|
|
56
86
|
* Baseline limits for mission runs. Adjust here for global defaults.
|
|
57
87
|
*
|
package/core/turnLoop.js
CHANGED
|
@@ -67,6 +67,8 @@ const llm = getLLM(PROVIDER_ID);
|
|
|
67
67
|
let totalTokensUsed = 0;
|
|
68
68
|
let turnTimestamps = [];
|
|
69
69
|
let shouldBackoff;
|
|
70
|
+
const DEFAULT_TURN_RETRY_LIMIT = 2; // number of retries (not counting initial attempt)
|
|
71
|
+
const TURN_RETRY_BASE_DELAY_MS = 500;
|
|
70
72
|
|
|
71
73
|
// Certain tools mutate the browser or produce side effects that are
|
|
72
74
|
// *useful for humans* (reports/screenshots), but do not carry semantic
|
|
@@ -171,16 +173,25 @@ export const turnLoop = async (
|
|
|
171
173
|
currentStep = {},
|
|
172
174
|
ctx = {} // { steps, missionName, groundControl }
|
|
173
175
|
) => {
|
|
174
|
-
const { steps = [], missionName, groundControl = createEmptyGroundControl() } = ctx;
|
|
176
|
+
const { steps = [], missionName, groundControl = createEmptyGroundControl(), retryLimit: retryLimitRaw } = ctx;
|
|
177
|
+
const stepsArchive = ctx.stepsArchive || steps;
|
|
178
|
+
const retryLimitClamped = Math.min(10, Math.max(1, Number.isFinite(retryLimitRaw) ? retryLimitRaw : DEFAULT_TURN_RETRY_LIMIT)); // retries allowed (excludes initial)
|
|
179
|
+
const maxAttempts = retryLimitClamped + 1; // includes initial attempt
|
|
175
180
|
ctx.groundControl = groundControl;
|
|
176
181
|
let agentMemory = { lastMenuExpanded: false };
|
|
182
|
+
let turnRetries = 0;
|
|
183
|
+
let stepSeq = ctx._stepSeq || 0;
|
|
177
184
|
|
|
178
185
|
// Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
|
|
179
186
|
// Idempotent recorder β prevents accidental double-push
|
|
180
187
|
const recordStep = (step) => {
|
|
181
188
|
if (!step || step.__recorded) return;
|
|
189
|
+
step._seq = stepSeq++;
|
|
182
190
|
step.__recorded = true;
|
|
183
191
|
try { steps.push(step); } catch {}
|
|
192
|
+
if (stepsArchive && stepsArchive !== steps) {
|
|
193
|
+
try { stepsArchive.push(step); } catch {}
|
|
194
|
+
}
|
|
184
195
|
try { ctx?.onStep?.(step); } catch {}
|
|
185
196
|
};
|
|
186
197
|
|
|
@@ -190,7 +201,19 @@ export const turnLoop = async (
|
|
|
190
201
|
for (let turn = currentTurn; turn < maxTurns; turn++) {
|
|
191
202
|
console.log(`\nπ Turn ${turn + 1}/${maxTurns}`);
|
|
192
203
|
let response;
|
|
193
|
-
const
|
|
204
|
+
const attempt = turnRetries + 1;
|
|
205
|
+
const step = {
|
|
206
|
+
turn,
|
|
207
|
+
retryAttempt: attempt,
|
|
208
|
+
retryLimit: retryLimitClamped, // number of retries allowed (excludes initial)
|
|
209
|
+
events: [],
|
|
210
|
+
result: 'π‘ In Progress',
|
|
211
|
+
missionName,
|
|
212
|
+
};
|
|
213
|
+
if (attempt > 1) {
|
|
214
|
+
const retryNumber = attempt - 1;
|
|
215
|
+
step.events.push(`π Re-attempt ${retryNumber}/${retryLimitClamped} for turn`);
|
|
216
|
+
}
|
|
194
217
|
|
|
195
218
|
try {
|
|
196
219
|
// Refresh token usage window (rolling 60 seconds)
|
|
@@ -211,7 +234,7 @@ export const turnLoop = async (
|
|
|
211
234
|
step.events.push('β Tool call structure invalid and no placeholders inserted.');
|
|
212
235
|
step.result = 'β Failure';
|
|
213
236
|
recordStep(step);
|
|
214
|
-
return steps;
|
|
237
|
+
return { success: false, steps: stepsArchive };
|
|
215
238
|
}
|
|
216
239
|
|
|
217
240
|
// Detect orphaned assistant tool calls with no matching tool responses
|
|
@@ -231,7 +254,7 @@ export const turnLoop = async (
|
|
|
231
254
|
step.events.push('π Detected assistant tool calls without matching tool responses');
|
|
232
255
|
step.result = 'β Failure';
|
|
233
256
|
recordStep(step);
|
|
234
|
-
return steps;
|
|
257
|
+
return { success: false, steps: stepsArchive };
|
|
235
258
|
}
|
|
236
259
|
|
|
237
260
|
// Before calling the model, trim/sanitize the conversation context.
|
|
@@ -273,7 +296,7 @@ export const turnLoop = async (
|
|
|
273
296
|
step.events.push('β Too many retries. Exiting.');
|
|
274
297
|
step.result = 'β Failure';
|
|
275
298
|
recordStep(step);
|
|
276
|
-
return steps;
|
|
299
|
+
return { success: false, steps: stepsArchive };
|
|
277
300
|
}
|
|
278
301
|
retryCount += 1;
|
|
279
302
|
turn -= 1;
|
|
@@ -283,7 +306,7 @@ export const turnLoop = async (
|
|
|
283
306
|
step.events.push(`β Bad request: ${err.message}`);
|
|
284
307
|
step.result = 'β Failure';
|
|
285
308
|
recordStep(step);
|
|
286
|
-
return steps;
|
|
309
|
+
return { success: false, steps: stepsArchive };
|
|
287
310
|
} else {
|
|
288
311
|
throw err;
|
|
289
312
|
}
|
|
@@ -319,6 +342,7 @@ export const turnLoop = async (
|
|
|
319
342
|
if (msg.tool_calls?.length) {
|
|
320
343
|
console.log('Processing tool calls...');
|
|
321
344
|
const toolResponses = [];
|
|
345
|
+
let hadToolIssues = false;
|
|
322
346
|
|
|
323
347
|
for (const call of msg.tool_calls) {
|
|
324
348
|
const fnName = call.function.name;
|
|
@@ -335,6 +359,7 @@ export const turnLoop = async (
|
|
|
335
359
|
} catch (e) {
|
|
336
360
|
errorMessage = `ERROR: ${e.message}`;
|
|
337
361
|
result = errorMessage;
|
|
362
|
+
hadToolIssues = true;
|
|
338
363
|
}
|
|
339
364
|
|
|
340
365
|
// Capture and log any file upload/download events (for reports)
|
|
@@ -416,7 +441,21 @@ export const turnLoop = async (
|
|
|
416
441
|
|
|
417
442
|
// Merge new assistant + tool responses back into conversation
|
|
418
443
|
messages.push(msg, ...toolResponses);
|
|
419
|
-
|
|
444
|
+
if (hadToolIssues && turnRetries < retryLimitClamped) {
|
|
445
|
+
step.result = 'β³ Retrying turn';
|
|
446
|
+
const retryNumber = attempt - 1;
|
|
447
|
+
step.events.push(`π Re-attempt ${retryNumber}/${retryLimitClamped} after tool issues`);
|
|
448
|
+
recordStep(step);
|
|
449
|
+
turnRetries += 1;
|
|
450
|
+
const delay = Math.min(TURN_RETRY_BASE_DELAY_MS * 2 ** (turnRetries - 1), 2000);
|
|
451
|
+
await wait(delay);
|
|
452
|
+
turn -= 1; // re-use the same turn index
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// reset retries after a clean turn or after exhausting retries
|
|
457
|
+
turnRetries = 0;
|
|
458
|
+
step.result = hadToolIssues ? 'β οΈ Turn Issues' : 'β
Passed';
|
|
420
459
|
recordStep(step);
|
|
421
460
|
continue;
|
|
422
461
|
}
|
|
@@ -429,7 +468,8 @@ export const turnLoop = async (
|
|
|
429
468
|
step.events.push(finalResponse.finalMessage);
|
|
430
469
|
step.result = finalResponse.success ? 'β
Mission Success' : 'β Mission Failure';
|
|
431
470
|
recordStep(step);
|
|
432
|
-
|
|
471
|
+
turnRetries = 0;
|
|
472
|
+
return { success: finalResponse.success, finalMessage: finalResponse.finalMessage, steps: stepsArchive };
|
|
433
473
|
}
|
|
434
474
|
|
|
435
475
|
// βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -443,5 +483,8 @@ export const turnLoop = async (
|
|
|
443
483
|
// This is a meaningful turn even without tool calls β record it.
|
|
444
484
|
step.result = step.result || 'π‘ In Progress';
|
|
445
485
|
recordStep(step);
|
|
486
|
+
turnRetries = 0;
|
|
446
487
|
}
|
|
488
|
+
|
|
489
|
+
return { success: false, steps: stepsArchive };
|
|
447
490
|
};
|
package/package.json
CHANGED
package/runner/testronaut.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { runAgent } from '../core/agent.js';
|
|
27
27
|
import { redactPasswordInText } from '../core/redaction.js';
|
|
28
|
-
import { loadConfig, enforceTurnBudget } from '../core/config.js';
|
|
28
|
+
import { loadConfig, enforceTurnBudget, getRetryLimit } from '../core/config.js';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Run a mission flow.
|
|
@@ -38,9 +38,14 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
38
38
|
const cfg = await loadConfig();
|
|
39
39
|
const { effectiveMax, limits, notes } = enforceTurnBudget(cfg);
|
|
40
40
|
const maxTurns = effectiveMax;
|
|
41
|
+
const retryInfo = getRetryLimit(cfg);
|
|
42
|
+
const retryLimit = retryInfo.value;
|
|
41
43
|
if (notes.length) {
|
|
42
44
|
console.warn(notes.join('\n'));
|
|
43
45
|
}
|
|
46
|
+
if (retryInfo.clamped) {
|
|
47
|
+
console.warn(`β οΈ Retry limit clamped to ${retryLimit} (allowed range 1-10).`);
|
|
48
|
+
}
|
|
44
49
|
|
|
45
50
|
// 2) Normalize submissions
|
|
46
51
|
const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
|
|
@@ -123,7 +128,7 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
123
128
|
);
|
|
124
129
|
|
|
125
130
|
// 3) Execute
|
|
126
|
-
const success = await runAgent(goals, missionName, maxTurns);
|
|
131
|
+
const success = await runAgent(goals, missionName, maxTurns, retryLimit);
|
|
127
132
|
if (!success) {
|
|
128
133
|
console.log(`β Aborting after failed goal.`);
|
|
129
134
|
return;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
// Import helper exports from cli.js
|
|
6
|
+
import { __test__ } from '../../bin/cli.js';
|
|
7
|
+
|
|
8
|
+
describe('cli helpers', () => {
|
|
9
|
+
it('guessMimeType returns expected types', () => {
|
|
10
|
+
const { guessMimeType } = __test__;
|
|
11
|
+
expect(guessMimeType('file.html')).toContain('text/html');
|
|
12
|
+
expect(guessMimeType('file.json')).toContain('application/json');
|
|
13
|
+
expect(guessMimeType('file.unknown')).toBe('application/octet-stream');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('safeJoin prevents traversal', () => {
|
|
17
|
+
const { safeJoin } = __test__;
|
|
18
|
+
const root = '/tmp/root';
|
|
19
|
+
expect(safeJoin(root, '/sub/ok.txt')).toBe(path.resolve('/tmp/root/sub/ok.txt'));
|
|
20
|
+
expect(safeJoin(root, '/../etc/passwd')).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('findLatestReportPair finds latest html/json', () => {
|
|
24
|
+
const { findLatestReportPair } = __test__;
|
|
25
|
+
const tmp = fs.mkdtempSync(path.join(process.cwd(), 'testronaut-report-'));
|
|
26
|
+
const r1 = path.join(tmp, 'run_100.html');
|
|
27
|
+
const r2 = path.join(tmp, 'run_200.html');
|
|
28
|
+
fs.writeFileSync(r1, 'a');
|
|
29
|
+
fs.writeFileSync(r2, 'b');
|
|
30
|
+
fs.writeFileSync(path.join(tmp, 'run_200.json'), '{}');
|
|
31
|
+
|
|
32
|
+
const latest = findLatestReportPair(tmp);
|
|
33
|
+
expect(latest?.htmlFile).toBe('run_200.html');
|
|
34
|
+
expect(latest?.jsonFile).toBe('run_200.json');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -8,6 +8,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
8
8
|
import {
|
|
9
9
|
loadConfig,
|
|
10
10
|
getMaxTurns,
|
|
11
|
+
getRetryLimit,
|
|
11
12
|
resolveTurnLimits,
|
|
12
13
|
enforceTurnBudget,
|
|
13
14
|
} from '../../core/config.js';
|
|
@@ -64,6 +65,46 @@ describe('core/config', () => {
|
|
|
64
65
|
});
|
|
65
66
|
});
|
|
66
67
|
|
|
68
|
+
describe('getRetryLimit', () => {
|
|
69
|
+
const OLD_ENV = { ...process.env };
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
process.env = { ...OLD_ENV };
|
|
72
|
+
delete process.env.TESTRONAUT_RETRY_LIMIT;
|
|
73
|
+
});
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
process.env = { ...OLD_ENV };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns default when missing', () => {
|
|
79
|
+
const res = getRetryLimit({});
|
|
80
|
+
expect(res.value).toBe(2);
|
|
81
|
+
expect(res.source).toBe('default');
|
|
82
|
+
expect(res.clamped).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses config value and clamps to range', () => {
|
|
86
|
+
expect(getRetryLimit({ retryLimit: 5 }).value).toBe(5);
|
|
87
|
+
const high = getRetryLimit({ retryLimit: 99 });
|
|
88
|
+
expect(high.value).toBe(10);
|
|
89
|
+
expect(high.clamped).toBe(true);
|
|
90
|
+
const low = getRetryLimit({ retryLimit: 0 });
|
|
91
|
+
expect(low.value).toBe(1);
|
|
92
|
+
expect(low.clamped).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('prefers env over config and clamps', () => {
|
|
96
|
+
process.env.TESTRONAUT_RETRY_LIMIT = '8';
|
|
97
|
+
const res = getRetryLimit({ retryLimit: 3 });
|
|
98
|
+
expect(res.value).toBe(8);
|
|
99
|
+
expect(res.source).toBe('env');
|
|
100
|
+
|
|
101
|
+
process.env.TESTRONAUT_RETRY_LIMIT = '0';
|
|
102
|
+
const clampedLow = getRetryLimit({ retryLimit: 3 });
|
|
103
|
+
expect(clampedLow.value).toBe(1);
|
|
104
|
+
expect(clampedLow.clamped).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
67
108
|
describe('enforceTurnBudget (lenient)', () => {
|
|
68
109
|
it('clamps > hardMaxTurns', () => {
|
|
69
110
|
const cfg = { maxTurns: 1000 };
|
|
@@ -128,7 +128,7 @@ describe('turnLoop', () => {
|
|
|
128
128
|
const messages = baseMessages();
|
|
129
129
|
const result = await turnLoop(browser, messages, 1, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
130
130
|
|
|
131
|
-
expect(result).
|
|
131
|
+
expect(result.success).toBe(true);
|
|
132
132
|
expect(result.steps?.length).toBe(1);
|
|
133
133
|
const step = result.steps[0];
|
|
134
134
|
expect(step.tokensUsed).toBe(42);
|
|
@@ -163,7 +163,7 @@ describe('turnLoop', () => {
|
|
|
163
163
|
const messages = baseMessages();
|
|
164
164
|
const res = await turnLoop(browser, messages, 2, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
165
165
|
|
|
166
|
-
expect(res).
|
|
166
|
+
expect(res.success).toBe(true);
|
|
167
167
|
expect(res.steps.length).toBe(2);
|
|
168
168
|
|
|
169
169
|
const step1 = res.steps[0];
|
|
@@ -174,4 +174,56 @@ describe('turnLoop', () => {
|
|
|
174
174
|
const step2 = res.steps[1];
|
|
175
175
|
expect(step2.result).toMatch(/Success/);
|
|
176
176
|
});
|
|
177
|
+
|
|
178
|
+
it('retries a turn on tool error before marking issues', async () => {
|
|
179
|
+
// First turn: tool call β click_text will throw
|
|
180
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
181
|
+
message: {
|
|
182
|
+
role: 'assistant',
|
|
183
|
+
content: '',
|
|
184
|
+
tool_calls: [
|
|
185
|
+
{
|
|
186
|
+
id: 'tool_retry',
|
|
187
|
+
type: 'function',
|
|
188
|
+
function: { name: 'click_text', arguments: JSON.stringify({ text: 'Hello' }) },
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
usage: { total_tokens: 7 },
|
|
193
|
+
});
|
|
194
|
+
// Retry turn: same tool call, click_text succeeds
|
|
195
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
196
|
+
message: {
|
|
197
|
+
role: 'assistant',
|
|
198
|
+
content: '',
|
|
199
|
+
tool_calls: [
|
|
200
|
+
{
|
|
201
|
+
id: 'tool_retry_2',
|
|
202
|
+
type: 'function',
|
|
203
|
+
function: { name: 'click_text', arguments: JSON.stringify({ text: 'Hello' }) },
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
usage: { total_tokens: 6 },
|
|
208
|
+
});
|
|
209
|
+
// Final turn: mission completes
|
|
210
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
211
|
+
message: { role: 'assistant', content: 'FINAL: done' },
|
|
212
|
+
usage: { total_tokens: 5 },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// click_text fails once, then succeeds
|
|
216
|
+
shared.chromeToolSpies.click_text
|
|
217
|
+
.mockImplementationOnce(async () => { throw new Error('no locator'); })
|
|
218
|
+
.mockImplementationOnce(async () => JSON.stringify({ ok: true, clicked: 'Hello' }));
|
|
219
|
+
|
|
220
|
+
const messages = baseMessages();
|
|
221
|
+
const res = await turnLoop(browser, messages, 3, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
222
|
+
|
|
223
|
+
expect(res.success).toBe(true);
|
|
224
|
+
expect(res.steps.length).toBe(3);
|
|
225
|
+
expect(res.steps[0].result).toBe('β³ Retrying turn');
|
|
226
|
+
expect(res.steps[1].result).toBe('β
Passed');
|
|
227
|
+
expect(res.steps[2].result).toMatch(/Success/);
|
|
228
|
+
});
|
|
177
229
|
});
|
|
@@ -9,10 +9,11 @@ vi.mock('../../core/redaction.js', () => ({
|
|
|
9
9
|
vi.mock('../../core/config.js', () => ({
|
|
10
10
|
loadConfig: vi.fn(),
|
|
11
11
|
enforceTurnBudget: vi.fn(),
|
|
12
|
+
getRetryLimit: vi.fn(),
|
|
12
13
|
}));
|
|
13
14
|
|
|
14
15
|
import { runAgent } from '../../core/agent.js';
|
|
15
|
-
import { loadConfig, enforceTurnBudget } from '../../core/config.js';
|
|
16
|
+
import { loadConfig, enforceTurnBudget, getRetryLimit } from '../../core/config.js';
|
|
16
17
|
|
|
17
18
|
// Adjust the import path if your file lives elsewhere
|
|
18
19
|
import { runMissions } from '../../runner/testronaut.js';
|
|
@@ -20,6 +21,8 @@ import { runMissions } from '../../runner/testronaut.js';
|
|
|
20
21
|
describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
21
22
|
beforeEach(() => {
|
|
22
23
|
vi.clearAllMocks();
|
|
24
|
+
// Default retry limit unless overridden in a test
|
|
25
|
+
getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
|
|
23
26
|
});
|
|
24
27
|
|
|
25
28
|
it('passes effectiveMax to runAgent and logs any notes', async () => {
|
|
@@ -30,6 +33,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
30
33
|
notes: ['β οΈ maxTurns (999) exceeds hardMaxTurns (200). Clamping to 200.'],
|
|
31
34
|
strict: false,
|
|
32
35
|
});
|
|
36
|
+
getRetryLimit.mockReturnValue({ value: 2, source: 'default', clamped: false });
|
|
33
37
|
|
|
34
38
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
35
39
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -46,7 +50,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
46
50
|
expect.stringContaining('Clamping to 200')
|
|
47
51
|
);
|
|
48
52
|
expect(runAgent).toHaveBeenCalledWith(
|
|
49
|
-
expect.any(Array), 'Budgeted Run', 200
|
|
53
|
+
expect.any(Array), 'Budgeted Run', 200, 2
|
|
50
54
|
);
|
|
51
55
|
|
|
52
56
|
warn.mockRestore();
|
|
@@ -61,6 +65,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
61
65
|
notes: [],
|
|
62
66
|
strict: false,
|
|
63
67
|
});
|
|
68
|
+
getRetryLimit.mockReturnValue({ value: 3, source: 'default', clamped: false });
|
|
64
69
|
|
|
65
70
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
66
71
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
@@ -72,7 +77,7 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
72
77
|
await runMissions({ mission: 'No warnings' }, 'Clean');
|
|
73
78
|
|
|
74
79
|
expect(warn).not.toHaveBeenCalled();
|
|
75
|
-
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20);
|
|
80
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'Clean', 20, 3);
|
|
76
81
|
|
|
77
82
|
warn.mockRestore();
|
|
78
83
|
log.mockRestore();
|
|
@@ -138,8 +143,8 @@ describe('cli/testronaut.runMissions (with enforceTurnBudget)', () => {
|
|
|
138
143
|
// Mission name is the passed-in missionName for main
|
|
139
144
|
expect(goals[1].submissionName).toMatch(/^My Mission/);
|
|
140
145
|
|
|
141
|
-
// Effective max turns passed through
|
|
142
|
-
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15);
|
|
146
|
+
// Effective max turns and retry limit passed through
|
|
147
|
+
expect(runAgent).toHaveBeenCalledWith(expect.any(Array), 'My Mission', 15, 2);
|
|
143
148
|
|
|
144
149
|
log.mockRestore();
|
|
145
150
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import { generateHtmlReport } from '../../tools/generateHtmlReport.js';
|
|
7
|
+
|
|
8
|
+
let tmpDir;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-report-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
try {
|
|
16
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
17
|
+
} catch {}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('generateHtmlReport', () => {
|
|
21
|
+
it('writes an HTML report containing mission and retry metadata', () => {
|
|
22
|
+
const report = {
|
|
23
|
+
runId: 'run_test',
|
|
24
|
+
missions: [
|
|
25
|
+
{
|
|
26
|
+
missionName: 'Mission A',
|
|
27
|
+
submissionType: 'mission',
|
|
28
|
+
submissionName: 'Main',
|
|
29
|
+
status: 'passed',
|
|
30
|
+
steps: [
|
|
31
|
+
{
|
|
32
|
+
turn: 0,
|
|
33
|
+
retryAttempt: 2,
|
|
34
|
+
retryLimit: 5,
|
|
35
|
+
events: ['did something'],
|
|
36
|
+
result: 'β οΈ Turn Issues',
|
|
37
|
+
summary: 'Do thing',
|
|
38
|
+
tokensUsed: 10,
|
|
39
|
+
totalTokensUsed: 20,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const outPath = path.join(tmpDir, 'report.html');
|
|
47
|
+
const written = generateHtmlReport(report, outPath);
|
|
48
|
+
|
|
49
|
+
expect(written).toBe(outPath);
|
|
50
|
+
expect(fs.existsSync(outPath)).toBe(true);
|
|
51
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
52
|
+
|
|
53
|
+
expect(html).toContain('Testronaut Report');
|
|
54
|
+
expect(html).toContain('Mission A');
|
|
55
|
+
expect(html).toContain('Turn 1 (re-attempt 1/5)');
|
|
56
|
+
expect(html).toContain('β οΈ Turn Issues');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('escapes unsafe text in output', () => {
|
|
60
|
+
const report = {
|
|
61
|
+
runId: 'run<&>',
|
|
62
|
+
missions: [
|
|
63
|
+
{
|
|
64
|
+
missionName: 'Mission <X>',
|
|
65
|
+
submissionType: 'mission',
|
|
66
|
+
submissionName: 'Main',
|
|
67
|
+
status: 'passed',
|
|
68
|
+
steps: [
|
|
69
|
+
{
|
|
70
|
+
turn: 0,
|
|
71
|
+
retryAttempt: 1,
|
|
72
|
+
events: ['<script>alert(1)</script>'],
|
|
73
|
+
result: 'β
Passed',
|
|
74
|
+
summary: '<b>bold</b>',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const outPath = path.join(tmpDir, 'safe.html');
|
|
82
|
+
generateHtmlReport(report, outPath);
|
|
83
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
84
|
+
|
|
85
|
+
expect(html).toContain('run<&>');
|
|
86
|
+
expect(html).toContain('Mission <X>');
|
|
87
|
+
expect(html).toContain('<script>alert(1)</script>');
|
|
88
|
+
expect(html).toContain('<b>bold</b>');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -1,7 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* generateHtmlReport.js
|
|
3
|
+
* ---------------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Render a Testronaut run summary into a single, self-contained HTML file.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Escape and format mission/step data into a readable report.
|
|
9
|
+
* - Group submissions by mission and surface per-step metadata (tokens, retries).
|
|
10
|
+
* - Write the HTML to disk at the provided output path (or a default location).
|
|
11
|
+
*
|
|
12
|
+
* Related tests:
|
|
13
|
+
* tests/toolsTests/generateHtmlReport.test.js
|
|
14
|
+
*
|
|
15
|
+
* Used by:
|
|
16
|
+
* - CLI report generation and upload flows.
|
|
17
|
+
*/
|
|
2
18
|
import fs from 'fs';
|
|
3
19
|
import path from 'path';
|
|
4
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Render and write a Testronaut run report to disk.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} report - normalized run JSON (runId, missions, summary, llm, etc.)
|
|
25
|
+
* @param {string} [outputPath] - optional absolute/relative path for the HTML file
|
|
26
|
+
* @returns {string} absolute path to the written HTML file
|
|
27
|
+
*/
|
|
5
28
|
export function generateHtmlReport(report, outputPath) {
|
|
6
29
|
const { runId, startTime, endTime, missions = [], summary = {}, llm = {} } = report;
|
|
7
30
|
const durationSec =
|
|
@@ -9,7 +32,7 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
9
32
|
? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
|
|
10
33
|
: 'β';
|
|
11
34
|
|
|
12
|
-
// helpers
|
|
35
|
+
// Escaping helpers for text and HTML attributes
|
|
13
36
|
const esc = (s) => String(s ?? '')
|
|
14
37
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
15
38
|
.replace(/>/g, '>');
|
|
@@ -28,6 +51,17 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
28
51
|
const stepItems = steps.map((step, idx) => {
|
|
29
52
|
const events = Array.isArray(step.events) ? step.events : [];
|
|
30
53
|
const ok = /β
|Passed|Mission Success/i.test(step.result || '');
|
|
54
|
+
const resultRaw = step.result || 'β';
|
|
55
|
+
const resultTooltip = resultRaw.includes('β οΈ Turn Issues')
|
|
56
|
+
? 'Turn had tool/action issues (e.g., selector/timeouts). It may not indicate a product bug.'
|
|
57
|
+
: '';
|
|
58
|
+
const retryAttempt = step.retryAttempt || 1; // includes initial
|
|
59
|
+
const retryLimit = step.retryLimit; // number of retries allowed (excludes initial)
|
|
60
|
+
const retryNumber = retryAttempt - 1;
|
|
61
|
+
const retryTotal = Number.isFinite(retryLimit) ? retryLimit : Math.max(retryNumber, 0);
|
|
62
|
+
const attemptLabel = retryNumber > 0
|
|
63
|
+
? ` (re-attempt ${retryNumber}/${retryTotal || retryNumber})`
|
|
64
|
+
: '';
|
|
31
65
|
const imgTag = step.screenshotPath
|
|
32
66
|
? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn ?? idx)}">`
|
|
33
67
|
: '';
|
|
@@ -42,9 +76,9 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
42
76
|
return `
|
|
43
77
|
<details class="step" ${ok ? '' : 'open'}>
|
|
44
78
|
<summary>
|
|
45
|
-
<span class="turn">Turn ${esc((step.turn ?? idx) + 1)}</span>
|
|
79
|
+
<span class="turn">Turn ${esc((step.turn ?? idx) + 1)}${esc(attemptLabel)}</span>
|
|
46
80
|
${planSpan}
|
|
47
|
-
<span class="step-result ${ok ? 'ok' : 'bad'}"
|
|
81
|
+
<span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${escAttr(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
|
|
48
82
|
<span class="tokens">tokens: ${esc(step.tokensUsed ?? 'β')} / total: ${esc(step.totalTokensUsed ?? 'β')}</span>
|
|
49
83
|
</summary>
|
|
50
84
|
<pre class="events">${esc(events.join('\n')) || '(no events)'}</pre>
|