testronaut 1.0.27 → 1.1.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 +46 -0
- package/bin/init.js +54 -11
- package/core/redaction.js +79 -0
- package/core/turnIntent.js +64 -0
- package/core/turnLoop.js +24 -7
- package/openAI/modelResolver.js +33 -0
- package/package.json +1 -1
- package/runner/testronaut.js +59 -26
- package/tools/generateHtmlReport.js +16 -1
- package/tools/tokenControl.js +156 -29
package/bin/cli.js
CHANGED
|
@@ -26,6 +26,27 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
const args = process.argv.slice(2);
|
|
29
|
+
|
|
30
|
+
// Look for --model=<id> or --model <id>
|
|
31
|
+
let modelOverride;
|
|
32
|
+
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
33
|
+
if (modelFlagIndex >= 0) {
|
|
34
|
+
if (args[modelFlagIndex].includes('=')) {
|
|
35
|
+
modelOverride = args[modelFlagIndex].split('=')[1];
|
|
36
|
+
} else if (args[modelFlagIndex + 1]) {
|
|
37
|
+
modelOverride = args[modelFlagIndex + 1];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (modelOverride) {
|
|
41
|
+
process.env.TESTRONAUT_MODEL = modelOverride.trim();
|
|
42
|
+
console.log(`🧠 Model override: ${process.env.TESTRONAUT_MODEL}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Remove flag & value from args so they don't look like mission filenames
|
|
46
|
+
args.splice(modelFlagIndex, modelOverride ? 2 : 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
29
50
|
const missionsDir = path.resolve(process.cwd(), 'missions');
|
|
30
51
|
|
|
31
52
|
const allResults = [];
|
|
@@ -165,10 +186,35 @@ const flatMissions = allResults.flatMap(entry => {
|
|
|
165
186
|
}));
|
|
166
187
|
});
|
|
167
188
|
|
|
189
|
+
// Read provider/model from config (allow env override for model)
|
|
190
|
+
const cfgPath = path.resolve(process.cwd(), 'testronaut-config.json');
|
|
191
|
+
let llmProvider = 'openai';
|
|
192
|
+
let llmModel = 'gpt-4o';
|
|
193
|
+
|
|
194
|
+
if (fs.existsSync(cfgPath)) {
|
|
195
|
+
try {
|
|
196
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
197
|
+
if (cfg?.provider) llmProvider = String(cfg.provider);
|
|
198
|
+
if (cfg?.model) llmModel = String(cfg.model);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.warn('⚠️ Could not read testronaut-config.json:', e.message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Env override takes precedence for the model (matches runtime behavior)
|
|
205
|
+
if (process.env.TESTRONAUT_MODEL?.trim()) {
|
|
206
|
+
llmModel = process.env.TESTRONAUT_MODEL.trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
168
210
|
const report = {
|
|
169
211
|
runId,
|
|
170
212
|
startTime: startTime.toISOString(),
|
|
171
213
|
endTime: endTime.toISOString(),
|
|
214
|
+
llm: {
|
|
215
|
+
provider: llmProvider,
|
|
216
|
+
model: llmModel,
|
|
217
|
+
},
|
|
172
218
|
summary: {
|
|
173
219
|
totalMissions: flatMissions.length,
|
|
174
220
|
passed: flatMissions.filter(m => m.status === 'passed').length,
|
package/bin/init.js
CHANGED
|
@@ -13,33 +13,76 @@ export async function initializeTestronautProject() {
|
|
|
13
13
|
let config = {};
|
|
14
14
|
if (fs.existsSync(configPath)) {
|
|
15
15
|
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
16
|
+
|
|
17
|
+
// --- Back-compat migration: old configs stored provider in `model`
|
|
18
|
+
if (!config.provider && config.model === 'openai') {
|
|
19
|
+
config.provider = 'openai';
|
|
20
|
+
// don't overwrite model yet; we'll re-ask below only if not initialized
|
|
21
|
+
}
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
// If not initialized before, walk through setup
|
|
19
25
|
if (!config.initialized) {
|
|
20
|
-
//
|
|
21
|
-
const
|
|
26
|
+
// 1) Provider
|
|
27
|
+
const { llmProvider } = await prompts({
|
|
22
28
|
type: 'select',
|
|
23
29
|
name: 'llmProvider',
|
|
24
30
|
message: 'Which LLM provider would you like to use?',
|
|
25
31
|
choices: [
|
|
26
|
-
{ title: 'OpenAI (GPT
|
|
32
|
+
{ title: 'OpenAI (GPT family)', value: 'openai' },
|
|
27
33
|
// Add others like Anthropic, Mistral, etc. later
|
|
28
34
|
]
|
|
29
35
|
});
|
|
30
36
|
|
|
31
|
-
config.
|
|
37
|
+
config.provider = llmProvider; // store provider explicitly
|
|
38
|
+
|
|
39
|
+
// 2) If OpenAI, ask for model
|
|
40
|
+
const { openaiModel } = await prompts({
|
|
41
|
+
type: 'select',
|
|
42
|
+
name: 'openaiModel',
|
|
43
|
+
message: 'Select an OpenAI model for agentic workflows (function/tool calling):',
|
|
44
|
+
choices: [
|
|
45
|
+
{ title: 'GPT-4.1 (general purpose)', value: 'gpt-4.1' },
|
|
46
|
+
{ title: 'GPT-4.1 mini (faster, cheaper)', value: 'gpt-4.1-mini' },
|
|
47
|
+
{ title: 'GPT-4o (multimodal, tool use)', value: 'gpt-4o' },
|
|
48
|
+
{ title: 'GPT-4o mini (speed/cost optimized)', value: 'gpt-4o-mini' },
|
|
49
|
+
{ title: 'o3 (reasoning w/ native tool use)', value: 'o3' },
|
|
50
|
+
{ title: 'o4-mini (reasoning, cost-effective)', value: 'o4-mini' },
|
|
51
|
+
|
|
52
|
+
// 👇 GPT-5 family
|
|
53
|
+
{ title: 'GPT-5 (latest reasoning model)', value: 'gpt-5' },
|
|
54
|
+
{ title: 'GPT-5 mini (faster)', value: 'gpt-5-mini' },
|
|
55
|
+
{ title: 'GPT-5 nano (lightweight)', value: 'gpt-5-nano' },
|
|
56
|
+
],
|
|
57
|
+
initial: 1
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
config.model = openaiModel;
|
|
61
|
+
|
|
62
|
+
// Warn about GPT-5 availability
|
|
63
|
+
if (openaiModel.startsWith('gpt-5')) {
|
|
64
|
+
console.log(`
|
|
65
|
+
⚠️ Warning: GPT-5 support may not be available for all users.
|
|
66
|
+
- Access depends on your OpenAI account/region
|
|
67
|
+
- Some capabilities may be gated or rate-limited
|
|
68
|
+
`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Default config fields
|
|
32
72
|
config.initialized = true;
|
|
33
|
-
config.outputDir = 'missions/mission_reports';
|
|
34
|
-
config.projectName = path.basename(root);
|
|
35
|
-
config.maxTurns = 20;
|
|
73
|
+
config.outputDir = config.outputDir || 'missions/mission_reports';
|
|
74
|
+
config.projectName = config.projectName || path.basename(root);
|
|
75
|
+
config.maxTurns = config.maxTurns ?? 20;
|
|
76
|
+
|
|
36
77
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
37
78
|
console.log('📝 Created testronaut-config.json');
|
|
38
79
|
|
|
39
|
-
if (
|
|
40
|
-
// Display .env instructions
|
|
80
|
+
if (llmProvider === 'openai') {
|
|
81
|
+
// Display .env instructions for OpenAI
|
|
41
82
|
if (!fs.existsSync(envPath)) {
|
|
42
|
-
const exampleEnv = `# Add your OpenAI API key below
|
|
83
|
+
const exampleEnv = `# Add your OpenAI API key below
|
|
84
|
+
OPENAI_API_KEY=sk-...
|
|
85
|
+
`;
|
|
43
86
|
fs.writeFileSync(envPath, exampleEnv);
|
|
44
87
|
console.log('🔐 Created .env file');
|
|
45
88
|
}
|
|
@@ -51,7 +94,7 @@ export async function initializeTestronautProject() {
|
|
|
51
94
|
|
|
52
95
|
OPENAI_API_KEY=sk-...
|
|
53
96
|
|
|
54
|
-
📌 The key must have permissions to call the OpenAI
|
|
97
|
+
📌 The key must have permissions to call the OpenAI API.
|
|
55
98
|
|
|
56
99
|
✅ You’re now ready to write missions in \`missions/\` and run your first mission using:
|
|
57
100
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// core/redaction.js
|
|
2
|
+
|
|
3
|
+
function clone(obj) {
|
|
4
|
+
try { return structuredClone(obj); } catch { return JSON.parse(JSON.stringify(obj ?? {})); }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function maskPreview(val, showLength = true) {
|
|
8
|
+
const s = String(val ?? '');
|
|
9
|
+
if (!s) return '••••••';
|
|
10
|
+
// Show length for debugging without leaking the value
|
|
11
|
+
return showLength ? `•••••• (${s.length})` : '••••••';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasPasswordHint(str = '') {
|
|
15
|
+
return /\b(pass(word)?|pwd|passwd)\b/i.test(String(str || ''));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPasswordCall(fnName, args = {}) {
|
|
19
|
+
if (!(fnName === 'type' || fnName === 'fill')) return false;
|
|
20
|
+
const { selector, label, placeholder, name, inputType, role, testId } = args;
|
|
21
|
+
return (
|
|
22
|
+
hasPasswordHint(selector) ||
|
|
23
|
+
hasPasswordHint(label) ||
|
|
24
|
+
hasPasswordHint(placeholder) ||
|
|
25
|
+
hasPasswordHint(name) ||
|
|
26
|
+
String(inputType || '').toLowerCase() === 'password' ||
|
|
27
|
+
hasPasswordHint(role) ||
|
|
28
|
+
hasPasswordHint(testId)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Redact ONLY password values in tool args:
|
|
34
|
+
* - Applies to type/fill on password-looking fields
|
|
35
|
+
* - Also masks any arg property explicitly named password/pwd/passwd (shallow)
|
|
36
|
+
*/
|
|
37
|
+
export function redactArgs(fnName, args = {}, { showLength = true } = {}) {
|
|
38
|
+
const out = clone(args);
|
|
39
|
+
|
|
40
|
+
// Mask direct props named password/pwd/passwd if present
|
|
41
|
+
for (const k of Object.keys(out || {})) {
|
|
42
|
+
if (hasPasswordHint(k)) {
|
|
43
|
+
out[k] = maskPreview(out[k], showLength);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For typing into a password field, mask the value
|
|
48
|
+
if (isPasswordCall(fnName, args)) {
|
|
49
|
+
if ('text' in out) out.text = maskPreview(out.text, showLength);
|
|
50
|
+
if ('value' in out) out.value = maskPreview(out.value, showLength);
|
|
51
|
+
if ('input' in out) out.input = maskPreview(out.input, showLength);
|
|
52
|
+
if ('keys' in out) out.keys = maskPreview(out.keys, showLength);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Redact password literals in freeform mission text.
|
|
60
|
+
* Heuristic: look for the word "password" then the next quoted string OR
|
|
61
|
+
* a token after "with|as|to|=" within ~80 chars.
|
|
62
|
+
*/
|
|
63
|
+
export function redactPasswordInText(text = '', { showLength = true } = {}) {
|
|
64
|
+
let s = String(text ?? '');
|
|
65
|
+
|
|
66
|
+
// Case 1: password ... "VALUE"
|
|
67
|
+
s = s.replace(
|
|
68
|
+
/(\bpassword\b[^"'\\\n]{0,80}["'])([^"']+)(["'])/gi,
|
|
69
|
+
(_m, pre, pwd, post) => pre + maskPreview(pwd, showLength) + post
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Case 2: password ... (with|as|to|=) VALUE
|
|
73
|
+
s = s.replace(
|
|
74
|
+
/(\bpassword\b[^.\n]{0,80}?\b(with|as|to|=)\s*)([^\s"'`]+)/gi,
|
|
75
|
+
(_m, pre, _kw, pwd) => pre + maskPreview(pwd, showLength)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { redactArgs } from './redaction.js';
|
|
2
|
+
|
|
3
|
+
export function truncateMiddle(str = '', max = 80) {
|
|
4
|
+
if (!str || str.length <= max) return str;
|
|
5
|
+
const half = Math.floor((max - 1) / 2);
|
|
6
|
+
return `${str.slice(0, half)}…${str.slice(-half)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function pickArg(args, keys) {
|
|
10
|
+
for (const k of keys) {
|
|
11
|
+
if (args?.[k] != null && String(args[k]).trim() !== '') return String(args[k]);
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function summarizeToolCall(fnName, args = {}) {
|
|
17
|
+
const safeArgs = redactArgs(fnName, args);
|
|
18
|
+
|
|
19
|
+
const selector = pickArg(safeArgs, ['selector','xpath','css','role','testId']);
|
|
20
|
+
const text = pickArg(safeArgs, ['text','value','keys','input','query','label']);
|
|
21
|
+
const url = pickArg(safeArgs, ['url','href','to']);
|
|
22
|
+
const file = pickArg(safeArgs, ['filePath','fileName','downloadPath']);
|
|
23
|
+
const x = pickArg(safeArgs, ['x']);
|
|
24
|
+
const y = pickArg(safeArgs, ['y']);
|
|
25
|
+
const timeout = pickArg(safeArgs, ['timeoutMs','timeout']);
|
|
26
|
+
|
|
27
|
+
const sel = selector ? ` (${truncateMiddle(selector, 60)})` : '';
|
|
28
|
+
const val = text ? ` "${truncateMiddle(text, 60)}"` : '';
|
|
29
|
+
const tmo = timeout ? ` [≤${timeout}ms]` : '';
|
|
30
|
+
|
|
31
|
+
switch (fnName) {
|
|
32
|
+
case 'navigate': return `Navigate to ${truncateMiddle(url || '(no url)', 80)}`;
|
|
33
|
+
case 'click_text': return `Click text${val}`;
|
|
34
|
+
case 'click': return `Click element${sel}`;
|
|
35
|
+
case 'type':
|
|
36
|
+
case 'fill': return `Type${val}${selector ? ` into ${truncateMiddle(selector, 60)}` : ''}`;
|
|
37
|
+
case 'press': return `Press key${val || ''}${selector ? ` in ${truncateMiddle(selector, 60)}` : ''}`;
|
|
38
|
+
case 'hover': return `Hover over${sel || val || ''}`;
|
|
39
|
+
case 'wait_for_selector': return `Wait for${sel}${tmo}`;
|
|
40
|
+
case 'wait': return `Wait${tmo || ''}`;
|
|
41
|
+
case 'screenshot': return `Capture screenshot${selector ? ` of ${truncateMiddle(selector, 60)}` : ''}`;
|
|
42
|
+
case 'get_dom': return `Inspect DOM (focused extract)`;
|
|
43
|
+
case 'check_text': return `Check page for${val || ' specific text'}`;
|
|
44
|
+
case 'scroll': return `Scroll to coords (${x ?? '?'}, ${y ?? '?'})`;
|
|
45
|
+
case 'upload_file': return `Upload file ${truncateMiddle(file || '(unknown)', 60)}${selector ? ` via ${truncateMiddle(selector, 60)}` : ''}`;
|
|
46
|
+
case 'download_file': return `Download file to ${truncateMiddle(file || '(unknown)', 60)}`;
|
|
47
|
+
case 'expand_menu': return `Expand menu${sel || ''}`;
|
|
48
|
+
case 'set_viewport': return `Set viewport ${safeArgs?.width}×${safeArgs?.height}`;
|
|
49
|
+
default:
|
|
50
|
+
return `Run ${fnName} with ${truncateMiddle(JSON.stringify(safeArgs ?? {}), 80)}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function summarizeTurnIntentFromMessage(msg) {
|
|
55
|
+
try {
|
|
56
|
+
if (msg?.tool_calls?.length) {
|
|
57
|
+
return msg.tool_calls
|
|
58
|
+
.map(c => summarizeToolCall(c.function?.name || 'unknown', JSON.parse(c.function?.arguments || '{}')))
|
|
59
|
+
.join(' → ');
|
|
60
|
+
}
|
|
61
|
+
if (msg?.content) return `Reasoning: ${truncateMiddle(msg.content, 120)}`;
|
|
62
|
+
} catch {}
|
|
63
|
+
return 'No intent detected';
|
|
64
|
+
}
|
package/core/turnLoop.js
CHANGED
|
@@ -11,8 +11,17 @@ import {
|
|
|
11
11
|
tokenEstimate,
|
|
12
12
|
tokenUseCoolOff,
|
|
13
13
|
recordTokenUsage,
|
|
14
|
-
pruneOldTokenUsage
|
|
14
|
+
pruneOldTokenUsage,
|
|
15
|
+
getCurrentTokenLimit,
|
|
16
|
+
updateLimitsFromHeaders
|
|
15
17
|
} from '../tools/tokenControl.js';
|
|
18
|
+
import { resolveModel } from '../openAI/modelResolver.js';
|
|
19
|
+
import { summarizeTurnIntentFromMessage } from './turnIntent.js';
|
|
20
|
+
import { redactArgs } from './redaction.js';
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const MODEL_ID = resolveModel();
|
|
24
|
+
console.log(`🧠 Using OpenAI model: ${MODEL_ID}`);
|
|
16
25
|
|
|
17
26
|
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
18
27
|
|
|
@@ -52,7 +61,7 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
|
|
|
52
61
|
});
|
|
53
62
|
|
|
54
63
|
const domHtml = await CHROME_TOOL_MAP.get_dom(browser, { limit: 100000, exclude: true }, agentMemory);
|
|
55
|
-
await tokenEstimate(
|
|
64
|
+
await tokenEstimate( MODEL_ID, domHtml);
|
|
56
65
|
// fs.writeFileSync(`missions/mission_reports/debug-expanded-${Date.now()}.html`, domHtml);
|
|
57
66
|
|
|
58
67
|
messages.push({
|
|
@@ -89,7 +98,7 @@ export const turnLoop = async (
|
|
|
89
98
|
({ turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(turnTimestamps));
|
|
90
99
|
|
|
91
100
|
// Cool off check
|
|
92
|
-
({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps));
|
|
101
|
+
({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
|
|
93
102
|
if(shouldBackoff) return await turnLoop(browser, messages, maxTurns, turn, currentStep);
|
|
94
103
|
|
|
95
104
|
//Tool Validator
|
|
@@ -123,12 +132,14 @@ export const turnLoop = async (
|
|
|
123
132
|
|
|
124
133
|
|
|
125
134
|
response = await openai.chat.completions.create({
|
|
126
|
-
model:
|
|
135
|
+
model: MODEL_ID,
|
|
127
136
|
messages,
|
|
128
137
|
tools: toolsSchema,
|
|
129
138
|
});
|
|
130
139
|
} catch (err) {
|
|
131
140
|
if (err.status === 429) {
|
|
141
|
+
// If headers include limits, learn them for this model
|
|
142
|
+
try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
|
|
132
143
|
const delay = Math.min(60000, 2 ** retryCount * 2000);
|
|
133
144
|
console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
|
|
134
145
|
await wait(delay);
|
|
@@ -169,6 +180,11 @@ export const turnLoop = async (
|
|
|
169
180
|
|
|
170
181
|
const msg = response.choices[0].message;
|
|
171
182
|
|
|
183
|
+
const planLine = summarizeTurnIntentFromMessage(msg);
|
|
184
|
+
currentStep.summary = planLine;
|
|
185
|
+
currentStep.events.unshift(`📝 Plan: ${planLine}`);
|
|
186
|
+
console.log(`📝 Plan: ${planLine}`);
|
|
187
|
+
|
|
172
188
|
if (msg.tool_calls?.length) {
|
|
173
189
|
console.log('Processing tool calls...');
|
|
174
190
|
const toolResponses = [];
|
|
@@ -178,8 +194,9 @@ export const turnLoop = async (
|
|
|
178
194
|
const fnName = call.function.name;
|
|
179
195
|
lastToolName = fnName;
|
|
180
196
|
const args = JSON.parse(call.function.arguments || '{}');
|
|
181
|
-
|
|
182
|
-
|
|
197
|
+
const safeArgs = redactArgs(fnName, args);
|
|
198
|
+
console.log(`[model] → ${fnName}`, safeArgs);
|
|
199
|
+
currentStep.events.push(`[model] → ${fnName} ${safeArgs}`);
|
|
183
200
|
// console.log('Calling tool:', fnName);
|
|
184
201
|
let result;
|
|
185
202
|
let errorMessage = null;
|
|
@@ -248,7 +265,7 @@ export const turnLoop = async (
|
|
|
248
265
|
exclude: true,
|
|
249
266
|
focus: [],
|
|
250
267
|
}, agentMemory);
|
|
251
|
-
await tokenEstimate(
|
|
268
|
+
await tokenEstimate(MODEL_ID, domHtml);
|
|
252
269
|
await pushDOMAssistant(browser, messages, agentMemory, {
|
|
253
270
|
skipIfLastTool: ['get_dom', 'check_text']
|
|
254
271
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
// Resolve model from config (or ENV) with safe fallback
|
|
5
|
+
const root = process.cwd();
|
|
6
|
+
const configPath = path.join(root, 'testronaut-config.json');
|
|
7
|
+
|
|
8
|
+
export function resolveModel() {
|
|
9
|
+
// Highest priority: env override
|
|
10
|
+
if (process.env.TESTRONAUT_MODEL?.trim()) {
|
|
11
|
+
return process.env.TESTRONAUT_MODEL.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(configPath)) {
|
|
16
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
17
|
+
// Only use the model if provider is openai (future-proofing for other providers)
|
|
18
|
+
if (cfg?.provider === 'openai' && typeof cfg?.model === 'string' && cfg.model.trim()) {
|
|
19
|
+
return cfg.model.trim();
|
|
20
|
+
}
|
|
21
|
+
// Back-compat: older configs may have stored 'openai' in model
|
|
22
|
+
if (!cfg?.provider && cfg?.model === 'openai') {
|
|
23
|
+
return 'gpt-4o';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.warn('⚠️ Could not read testronaut-config.json; using default model. Error:', e.message);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return 'gpt-4o';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
package/package.json
CHANGED
package/runner/testronaut.js
CHANGED
|
@@ -1,37 +1,68 @@
|
|
|
1
1
|
import { runAgent } from '../core/agent.js';
|
|
2
|
-
import {
|
|
2
|
+
import { redactPasswordInText } from '../core/redaction.js';
|
|
3
|
+
// import { runSuite } from './suiteRunner.js';
|
|
3
4
|
|
|
4
|
-
const objectives = [];
|
|
5
|
+
// const objectives = [];
|
|
5
6
|
|
|
6
|
-
export function mission(title, fn) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
7
|
+
// export function mission(title, fn) {
|
|
8
|
+
// console.log(`\n🚀 Mission: ${title}`);
|
|
9
|
+
// fn();
|
|
10
|
+
// }
|
|
11
|
+
|
|
12
|
+
// export function objective(desc, workflow) {
|
|
13
|
+
// objectives.push({ name: desc, workflow });
|
|
14
|
+
// }
|
|
15
|
+
|
|
16
|
+
// export async function launch(preMissionSetup) {
|
|
17
|
+
// await runSuite(objectives, preMissionSetup);
|
|
18
|
+
// }
|
|
10
19
|
|
|
11
|
-
export function objective(desc, workflow) {
|
|
12
|
-
objectives.push({ name: desc, workflow });
|
|
13
|
-
}
|
|
14
20
|
|
|
15
|
-
export async function launch(preMissionSetup) {
|
|
16
|
-
await runSuite(objectives, preMissionSetup);
|
|
17
|
-
}
|
|
18
21
|
|
|
19
22
|
export async function runMissions({ preMission, mission, postMission }, missionName) {
|
|
20
23
|
const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
|
|
21
|
-
|
|
22
|
-
const pre
|
|
24
|
+
|
|
25
|
+
const pre = normalizeToArray(preMission);
|
|
23
26
|
const main = normalizeToArray(mission);
|
|
24
27
|
const post = normalizeToArray(postMission);
|
|
25
28
|
|
|
26
29
|
const goals = [];
|
|
27
30
|
|
|
31
|
+
const pickName = (kind, fn, i) => {
|
|
32
|
+
const baseFallback =
|
|
33
|
+
kind === 'premission' ? `pre-${i + 1}` :
|
|
34
|
+
kind === 'postmission' ? `post-${i + 1}` :
|
|
35
|
+
`mission-${i + 1}`;
|
|
36
|
+
|
|
37
|
+
// MAIN mission: prefer the missionName argument
|
|
38
|
+
if (kind === 'mission') {
|
|
39
|
+
if (missionName) {
|
|
40
|
+
// if multiple main items, disambiguate nicely
|
|
41
|
+
const suffix = main.length > 1 ? ` (${i + 1}/${main.length})` : '';
|
|
42
|
+
return `${missionName}${suffix}`;
|
|
43
|
+
}
|
|
44
|
+
// no missionName: if mission is a string, derive from its first non-empty line
|
|
45
|
+
if (typeof fn === 'string') {
|
|
46
|
+
const firstLine = (fn.trim().split('\n').find(Boolean) || fn.trim()).slice(0, 60);
|
|
47
|
+
return firstLine.length === 60 ? `${firstLine}…` : firstLine || baseFallback;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// PRE/POST (and fallback for mission): prefer author-provided labels on the function
|
|
52
|
+
const explicit =
|
|
53
|
+
fn?.submissionName || fn?.displayName || fn?.title || fn?.name;
|
|
54
|
+
if (explicit && String(explicit).trim()) return String(explicit).trim();
|
|
55
|
+
|
|
56
|
+
return baseFallback;
|
|
57
|
+
};
|
|
58
|
+
|
|
28
59
|
if (pre.length) {
|
|
29
60
|
goals.push(
|
|
30
61
|
...pre.map((fn, i) => ({
|
|
31
62
|
goal: fn,
|
|
32
|
-
label: 'pre-mission',
|
|
33
|
-
submissionType: 'premission',
|
|
34
|
-
submissionName: fn
|
|
63
|
+
label: 'pre-mission', // legacy label (kept for logs)
|
|
64
|
+
submissionType: 'premission', // normalized
|
|
65
|
+
submissionName: pickName('premission', fn, i),
|
|
35
66
|
}))
|
|
36
67
|
);
|
|
37
68
|
}
|
|
@@ -42,7 +73,7 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
42
73
|
goal: fn,
|
|
43
74
|
label: 'mission',
|
|
44
75
|
submissionType: 'mission',
|
|
45
|
-
submissionName: fn
|
|
76
|
+
submissionName: pickName('mission', fn, i),
|
|
46
77
|
}))
|
|
47
78
|
);
|
|
48
79
|
}
|
|
@@ -53,28 +84,30 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
53
84
|
goal: fn,
|
|
54
85
|
label: 'post-mission',
|
|
55
86
|
submissionType: 'postmission',
|
|
56
|
-
submissionName: fn
|
|
87
|
+
submissionName: pickName('postmission', fn, i),
|
|
57
88
|
}))
|
|
58
89
|
);
|
|
59
90
|
}
|
|
60
91
|
|
|
61
92
|
console.log(
|
|
62
93
|
'\n🧭 Running mission flow:\n',
|
|
63
|
-
goals.map(g =>
|
|
94
|
+
goals.map(g => {
|
|
95
|
+
if (typeof g.goal === 'string') {
|
|
96
|
+
// redact only the password literal in the human-readable mission text
|
|
97
|
+
return `${g.label}: ${redactPasswordInText(g.goal)}`;
|
|
98
|
+
}
|
|
99
|
+
return `${g.label}: ${g.goal?.name || g.submissionName || g.goal}`;
|
|
100
|
+
})
|
|
64
101
|
);
|
|
65
|
-
|
|
102
|
+
|
|
66
103
|
const success = await runAgent(goals, missionName);
|
|
67
104
|
if (!success) {
|
|
68
105
|
console.log(`❌ Aborting after failed goal.`);
|
|
69
106
|
return;
|
|
70
107
|
}
|
|
71
|
-
|
|
72
|
-
// console.log("success[0].steps.length: ", success[0].steps.length);
|
|
73
|
-
// console.log("success[0].steps[success[0].steps.length - 1].result: ", success[0].steps[success[0].steps.length - 1].result)
|
|
108
|
+
|
|
74
109
|
const missionStatus = success[0].steps[success[0].steps.length - 1].result;
|
|
75
|
-
// console.log("missionStatus: ", missionStatus)
|
|
76
110
|
if (missionStatus.toLowerCase().includes('failure')) {
|
|
77
|
-
// console.log("we detected the failure!")
|
|
78
111
|
success[0].status = 'failed';
|
|
79
112
|
}
|
|
80
113
|
console.log('\n✅ Mission flow complete.');
|
|
@@ -3,7 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
|
|
5
5
|
export function generateHtmlReport(report, outputPath) {
|
|
6
|
-
const { runId, startTime, endTime, missions = [], summary = {} } = report;
|
|
6
|
+
const { runId, startTime, endTime, missions = [], summary = {}, llm = {} } = report;
|
|
7
7
|
const durationSec =
|
|
8
8
|
(startTime && endTime)
|
|
9
9
|
? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
|
|
@@ -13,6 +13,7 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
13
13
|
const esc = (s) => String(s ?? '')
|
|
14
14
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
15
15
|
.replace(/>/g, '>');
|
|
16
|
+
const escAttr = (s) => esc(s).replace(/"/g, '"'); // for HTML attributes
|
|
16
17
|
const badge = (status) =>
|
|
17
18
|
status === 'passed' ? '✅ Passed' :
|
|
18
19
|
status === 'failed' ? '❌ Failed' : (status || '—');
|
|
@@ -31,10 +32,18 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
31
32
|
? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn ?? idx)}">`
|
|
32
33
|
: '';
|
|
33
34
|
|
|
35
|
+
const plan = (typeof step.summary === 'string' && step.summary.trim())
|
|
36
|
+
? step.summary.trim()
|
|
37
|
+
: '';
|
|
38
|
+
const planSpan = plan
|
|
39
|
+
? `<span class="plan" title="${escAttr(plan)}">${esc(plan)}</span>`
|
|
40
|
+
: '';
|
|
41
|
+
|
|
34
42
|
return `
|
|
35
43
|
<details class="step" ${ok ? '' : 'open'}>
|
|
36
44
|
<summary>
|
|
37
45
|
<span class="turn">Turn ${esc((step.turn ?? idx) + 1)}</span>
|
|
46
|
+
${planSpan}
|
|
38
47
|
<span class="step-result ${ok ? 'ok' : 'bad'}">${esc(step.result || '—')}</span>
|
|
39
48
|
<span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} / total: ${esc(step.totalTokensUsed ?? '—')}</span>
|
|
40
49
|
</summary>
|
|
@@ -210,6 +219,10 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
210
219
|
.step summary:hover{ background: rgba(255,255,255,.08); }
|
|
211
220
|
|
|
212
221
|
.turn{ font-weight:700; }
|
|
222
|
+
.plan{
|
|
223
|
+
flex:1; color: var(--text-muted); font-size:12px;
|
|
224
|
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
225
|
+
}
|
|
213
226
|
.step-result.ok{ color: var(--ok); font-weight:700; }
|
|
214
227
|
.step-result.bad{ color: var(--bad); font-weight:700; }
|
|
215
228
|
.tokens{ color: var(--text-muted); font-size:12px; margin-left:auto; font-variant-numeric: tabular-nums; }
|
|
@@ -241,6 +254,7 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
241
254
|
<div><strong>Run ID:</strong> ${esc(runId ?? '—')}</div>
|
|
242
255
|
<div><strong>Start:</strong> ${esc(startTime ?? '—')}</div>
|
|
243
256
|
<div><strong>End:</strong> ${esc(endTime ?? '—')} • <strong>Duration:</strong> ${durationSec}s</div>
|
|
257
|
+
<div><strong>LLM:</strong> ${esc(llm.provider ?? '—')} • <strong>Model:</strong> ${esc(llm.model ?? '—')}</div>
|
|
244
258
|
</div>
|
|
245
259
|
</div>
|
|
246
260
|
|
|
@@ -248,6 +262,7 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
248
262
|
<div class="pill">Missions: ${esc(summary.totalMissions ?? totals.total)}</div>
|
|
249
263
|
<div class="pill ok">Passed: ${esc(summary.passed ?? totals.passed)}</div>
|
|
250
264
|
<div class="pill bad">Failed: ${esc(summary.failed ?? totals.failed)}</div>
|
|
265
|
+
<div class="pill">LLM: ${esc(llm.provider ?? '—')} • ${esc(llm.model ?? '')}</div>
|
|
251
266
|
</div>
|
|
252
267
|
|
|
253
268
|
<div class="container">
|
package/tools/tokenControl.js
CHANGED
|
@@ -1,33 +1,164 @@
|
|
|
1
|
-
import { encoding_for_model } from '@dqbd/tiktoken';
|
|
1
|
+
import { encoding_for_model, get_encoding } from '@dqbd/tiktoken';
|
|
2
2
|
import { wait } from './turnLoopUtils.js';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Dynamic token-per-minute limits by model family.
|
|
6
|
+
* - You can tweak these defaults anytime.
|
|
7
|
+
* - ENV override: TESTRONAUT_TOKENS_PER_MIN forces a single limit for everything.
|
|
8
|
+
* - At runtime, you can call `updateLimitsFromHeaders(model, headers)` after a 429
|
|
9
|
+
* to adopt server-advertised limits (if present).
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_LIMITS = [
|
|
12
|
+
// Newer families first (checked in order)
|
|
13
|
+
{ test: /^gpt-5(-|$)/i, tpm: 90000 },
|
|
14
|
+
{ test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
|
|
15
|
+
{ test: /^gpt-5-nano(-|$)/i, tpm: 600000 },
|
|
16
|
+
|
|
17
|
+
{ test: /^gpt-4o(-|$)/i, tpm: 450000 },
|
|
18
|
+
{ test: /^gpt-4\.1(-|$)/i, tpm: 1000000 },
|
|
19
|
+
|
|
20
|
+
{ test: /^o3(-|$)/i, tpm: 300000 },
|
|
21
|
+
{ test: /^o4-mini(-|$)/i, tpm: 600000 },
|
|
22
|
+
|
|
23
|
+
// Older / fallback
|
|
24
|
+
{ test: /^gpt-4(-|$)/i, tpm: 150000 },
|
|
25
|
+
{ test: /^gpt-3\.5(-|$)/i, tpm: 600000 },
|
|
26
|
+
|
|
27
|
+
// Ultimate fallback for anything else
|
|
28
|
+
{ test: /.*/, tpm: 150000 },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Live, mutable limits (can be updated by headers at runtime)
|
|
32
|
+
const liveLimits = new Map(); // modelId -> { tpm, source: 'default'|'env'|'header' }
|
|
33
|
+
|
|
34
|
+
// One-time warning tracking for tokenizer fallback
|
|
35
|
+
const warnedModels = new Set();
|
|
36
|
+
|
|
37
|
+
/* ---------------- Tokenizer helpers (same idea as before) ---------------- */
|
|
38
|
+
|
|
39
|
+
function getTokenizer(model) {
|
|
40
|
+
try {
|
|
41
|
+
return encoding_for_model(model);
|
|
42
|
+
} catch (_) {
|
|
43
|
+
const m = (model || '').toLowerCase();
|
|
44
|
+
const useO200k =
|
|
45
|
+
m.startsWith('gpt-5') ||
|
|
46
|
+
m.startsWith('gpt-4o') ||
|
|
47
|
+
m.startsWith('gpt-4.1') ||
|
|
48
|
+
m.startsWith('o') ||
|
|
49
|
+
m.includes('omni');
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
return get_encoding(useO200k ? 'o200k_base' : 'cl100k_base');
|
|
53
|
+
} catch (e2) {
|
|
54
|
+
if (!warnedModels.has(model)) {
|
|
55
|
+
console.warn(`⚠️ Could not load tokenizer for "${model}". Will approximate by bytes/4.`, e2?.message || e2);
|
|
56
|
+
warnedModels.add(model);
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
6
62
|
|
|
7
63
|
export const tokenEstimate = async (model, text) => {
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
64
|
+
const str = typeof text === 'string' ? text : JSON.stringify(text ?? '');
|
|
65
|
+
const enc = getTokenizer(model);
|
|
66
|
+
|
|
67
|
+
if (enc) {
|
|
68
|
+
try {
|
|
69
|
+
const tokenCount = enc.encode(str).length;
|
|
70
|
+
enc.free?.();
|
|
71
|
+
console.log(`🧠 Estimated token count (${model}): ${tokenCount}`);
|
|
72
|
+
return tokenCount;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
enc.free?.();
|
|
75
|
+
if (!warnedModels.has(model)) {
|
|
76
|
+
console.warn(`⚠️ Tokenizer encode failed for "${model}". Falling back to bytes/4.`, e?.message || e);
|
|
77
|
+
warnedModels.add(model);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Final fallback: rough heuristic (UTF-8 bytes / 4)
|
|
83
|
+
const bytes = Buffer.from(str, 'utf8').length;
|
|
84
|
+
const approx = Math.ceil(bytes / 4);
|
|
85
|
+
console.log(`🧠 Estimated token count (approx, ${model}): ${approx}`);
|
|
86
|
+
return approx;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/* ---------------- Dynamic limit resolution ---------------- */
|
|
90
|
+
|
|
91
|
+
function resolveDefaultLimitForModel(model) {
|
|
92
|
+
// Highest priority: ENV override (forces a single limit for all models)
|
|
93
|
+
const envTPM = process.env.TESTRONAUT_TOKENS_PER_MIN
|
|
94
|
+
? Number(process.env.TESTRONAUT_TOKENS_PER_MIN)
|
|
95
|
+
: undefined;
|
|
96
|
+
if (envTPM && Number.isFinite(envTPM) && envTPM > 0) {
|
|
97
|
+
return { tpm: envTPM, source: 'env' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Otherwise find best-matching default
|
|
101
|
+
const hit = DEFAULT_LIMITS.find(entry => entry.test.test(model || ''));
|
|
102
|
+
return { tpm: hit?.tpm ?? 150000, source: 'default' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Public: get the current token-per-minute limit for a model.
|
|
107
|
+
* Checks liveOverrides first (header-learned), then ENV, then defaults.
|
|
108
|
+
*/
|
|
109
|
+
export function getCurrentTokenLimit(model) {
|
|
110
|
+
const m = (model || '').trim() || 'unknown';
|
|
111
|
+
const live = liveLimits.get(m);
|
|
112
|
+
if (live?.tpm) return live;
|
|
113
|
+
|
|
114
|
+
const resolved = resolveDefaultLimitForModel(m);
|
|
115
|
+
liveLimits.set(m, resolved);
|
|
116
|
+
return resolved;
|
|
12
117
|
}
|
|
13
118
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Public: update limits from HTTP response headers (e.g., after 429).
|
|
121
|
+
* Looks for typical headers like:
|
|
122
|
+
* - x-ratelimit-limit-tokens: "90000"
|
|
123
|
+
* - x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens, etc.
|
|
124
|
+
* If found, we store the new TPM for this model (source='header').
|
|
125
|
+
*/
|
|
126
|
+
export function updateLimitsFromHeaders(model, headers = {}) {
|
|
127
|
+
if (!model) return;
|
|
128
|
+
|
|
129
|
+
// Normalize header keys to lowercase
|
|
130
|
+
const lower = {};
|
|
131
|
+
for (const k of Object.keys(headers || {})) lower[k.toLowerCase()] = headers[k];
|
|
132
|
+
|
|
133
|
+
// Common header names OpenAI/Azure may set (varies!)
|
|
134
|
+
const tokenCap =
|
|
135
|
+
Number(lower['x-ratelimit-limit-tokens']) ||
|
|
136
|
+
Number(lower['x-ratelimit-limit-tpm']) ||
|
|
137
|
+
Number(lower['x-ratelimit-limit-token']) ||
|
|
138
|
+
undefined;
|
|
139
|
+
|
|
140
|
+
if (tokenCap && Number.isFinite(tokenCap) && tokenCap > 0) {
|
|
141
|
+
const cur = getCurrentTokenLimit(model);
|
|
142
|
+
if (cur.tpm !== tokenCap || cur.source !== 'header') {
|
|
143
|
+
liveLimits.set(model, { tpm: tokenCap, source: 'header' });
|
|
144
|
+
console.log(`📏 Updated TPM for ${model}: ${tokenCap} (from headers)`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ---------------- Cooloff / backoff logic ---------------- */
|
|
150
|
+
|
|
151
|
+
export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps, model) => {
|
|
152
|
+
const { tpm } = getCurrentTokenLimit(model);
|
|
153
|
+
if (totalTokensUsed > tpm) {
|
|
154
|
+
const msToWait = await getDynamicBackoffMs(turnTimestamps, tpm);
|
|
155
|
+
console.warn(`⚠️ Token throttle risk (${totalTokensUsed}/${tpm}) → Waiting ${Math.ceil((msToWait || 1000)/1000)}s...`);
|
|
156
|
+
await wait(msToWait || 1000);
|
|
24
157
|
console.log('✅ Backoff complete, resuming...');
|
|
25
|
-
// return true;
|
|
26
158
|
return { shouldBackoff: true, totalTokensUsed: 0, turnTimestamps: [] };
|
|
27
159
|
}
|
|
28
|
-
// return false;
|
|
29
160
|
return { shouldBackoff: false, totalTokensUsed, turnTimestamps };
|
|
30
|
-
}
|
|
161
|
+
};
|
|
31
162
|
|
|
32
163
|
export const recordTokenUsage = (turnTimestamps, tokensUsed) => {
|
|
33
164
|
const now = Date.now();
|
|
@@ -41,22 +172,18 @@ export const pruneOldTokenUsage = (turnTimestamps, windowMs = 60000) => {
|
|
|
41
172
|
return { turnTimestamps: recentEntries, totalTokensUsed };
|
|
42
173
|
};
|
|
43
174
|
|
|
44
|
-
const getDynamicBackoffMs = async (turnTimestamps, tokenLimit
|
|
175
|
+
const getDynamicBackoffMs = async (turnTimestamps, tokenLimit) => {
|
|
45
176
|
const now = Date.now();
|
|
46
177
|
let runningTotal = 0;
|
|
47
178
|
|
|
48
|
-
// Sort oldest → newest to find the earliest point to fall below the limit
|
|
49
179
|
const sorted = [...turnTimestamps].sort((a, b) => a[0] - b[0]);
|
|
50
|
-
|
|
51
180
|
for (let i = 0; i < sorted.length; i++) {
|
|
52
|
-
runningTotal += sorted[i][1];
|
|
53
|
-
|
|
181
|
+
runningTotal += sorted[i][1];
|
|
54
182
|
if (runningTotal > tokenLimit) {
|
|
55
183
|
const [timestampOfExcess] = sorted[i];
|
|
56
184
|
const msUntilSafe = 60000 - (now - timestampOfExcess);
|
|
57
|
-
return Math.max(msUntilSafe, 1000); //
|
|
185
|
+
return Math.max(msUntilSafe, 1000); // at least 1s
|
|
58
186
|
}
|
|
59
187
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
188
|
+
return 0;
|
|
189
|
+
};
|