testronaut 1.10.2 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +198 -0
- package/TODO.md +5 -0
- package/bin/cli.js +234 -5
- package/bin/terminalUi.js +51 -0
- package/core/agent.js +1 -0
- package/core/browserEvidence.js +184 -0
- package/core/jevRouting.js +77 -0
- package/core/jevShadow.js +325 -0
- package/core/jevSuiteSelection.js +39 -0
- package/core/jevUsage.js +62 -0
- package/core/optimization.js +98 -0
- package/core/turnLoop.js +174 -10
- package/experiments/CROSS_PROVIDER.md +19 -0
- package/experiments/README.md +62 -0
- package/experiments/buildExperimentPublicationPackage.js +219 -0
- package/experiments/buildOptimizationDataPackage.js +390 -0
- package/experiments/crossProviderBenchmark.js +227 -0
- package/experiments/optimizationBenchmark.js +339 -0
- package/package.json +5 -1
- package/tests/binTests/cli.helpers.test.js +16 -0
- package/tests/binTests/cli.integration.test.js +14 -0
- package/tests/binTests/terminalUi.test.js +34 -0
- package/tests/coreTests/browserEvidence.test.js +70 -0
- package/tests/coreTests/jevRouting.test.js +57 -0
- package/tests/coreTests/jevShadow.test.js +158 -0
- package/tests/coreTests/jevSuiteSelection.test.js +24 -0
- package/tests/coreTests/jevUsage.test.js +58 -0
- package/tests/coreTests/optimization.test.js +80 -0
- package/tests/coreTests/turnLoop.test.js +304 -0
- package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
- package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
- package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
- package/tests/toolsTests/generateHtmlReport.test.js +83 -0
- package/tests/toolsTests/turnLoopUtils.test.js +17 -0
- package/tools/generateHtmlReport.js +96 -26
- package/tools/turnLoopUtils.js +7 -11
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import dotenv from 'dotenv';
|
|
8
|
+
import { evaluateJevShadow, resolveJevShadowConfig } from '../core/jevShadow.js';
|
|
9
|
+
import { __benchmarkInternals } from './optimizationBenchmark.js';
|
|
10
|
+
|
|
11
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const DEFAULT_CLI = path.resolve(HERE, '../bin/cli.js');
|
|
13
|
+
const DEFAULT_REPO = '/home/shane/workspace/testronaut-examples';
|
|
14
|
+
const MISSIONS = [
|
|
15
|
+
'missions/login.mission.js',
|
|
16
|
+
'missions/removeTask.mission.js',
|
|
17
|
+
'missions/fileTransfer.mission.js',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const MODELS = {
|
|
21
|
+
gpt55: { provider: 'openai', model: 'gpt-5.5', profile: 'Strongest OpenAI reliability and token result in the prior pilot; slower than Terra.' },
|
|
22
|
+
terra: { provider: 'openai', model: 'gpt-5.6-terra', profile: 'Current-generation balanced OpenAI reference.' },
|
|
23
|
+
gemini38: { provider: 'gemini', model: 'gemini-3.8-flash', profile: 'Reliable current-generation Gemini reference.' },
|
|
24
|
+
gemini35lite: { provider: 'gemini', model: 'gemini-3.5-flash-lite', profile: 'Low-cost Gemini candidate; prior guarded runs were reliable but slower.' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const CROSS_PROVIDER_CONDITIONS = [
|
|
28
|
+
{ id: 'c1_gpt55', candidates: [MODELS.gpt55] },
|
|
29
|
+
{ id: 'c2_terra', candidates: [MODELS.terra] },
|
|
30
|
+
{ id: 'c3_gemini38', candidates: [MODELS.gemini38] },
|
|
31
|
+
{ id: 'c4_gemini35lite', candidates: [MODELS.gemini35lite] },
|
|
32
|
+
{ id: 'x1_gpt55_gemini38', candidates: [MODELS.gpt55, MODELS.gemini38] },
|
|
33
|
+
{ id: 'x2_gpt55_gemini35lite', candidates: [MODELS.gpt55, MODELS.gemini35lite] },
|
|
34
|
+
{ id: 'x3_terra_gemini38', candidates: [MODELS.terra, MODELS.gemini38] },
|
|
35
|
+
{ id: 'x4_terra_gemini35lite', candidates: [MODELS.terra, MODELS.gemini35lite] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function buildCrossProviderSchedule({ repetitions = 5, seed = 'cross-provider-pilot-v1', repo = DEFAULT_REPO } = {}) {
|
|
39
|
+
const schedule = [];
|
|
40
|
+
for (let block = 1; block <= repetitions; block += 1) {
|
|
41
|
+
const conditions = __benchmarkInternals.shuffled(CROSS_PROVIDER_CONDITIONS, `${seed}:conditions:${block}`);
|
|
42
|
+
for (const condition of conditions) {
|
|
43
|
+
const missions = __benchmarkInternals.shuffled(MISSIONS, `${seed}:${condition.id}:${block}`);
|
|
44
|
+
for (const mission of missions) {
|
|
45
|
+
const unit = path.basename(mission, '.mission.js');
|
|
46
|
+
schedule.push({
|
|
47
|
+
key: `b${block}:${condition.id}:${unit}`,
|
|
48
|
+
block,
|
|
49
|
+
unit,
|
|
50
|
+
mission,
|
|
51
|
+
repo,
|
|
52
|
+
...condition,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return schedule;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function interpretSelection(evaluation, candidates, threshold = 0.55) {
|
|
61
|
+
const fallback = candidates[0];
|
|
62
|
+
if (candidates.length === 1) return { selected: fallback, selectedBy: 'fixed', score: 1, evaluation: null };
|
|
63
|
+
const answer = evaluation?.answers?.executionModel;
|
|
64
|
+
const index = String(answer?.choice || '').match(/^model_(\d+)$/)?.[1];
|
|
65
|
+
const candidate = index ? candidates[Number(index) - 1] : null;
|
|
66
|
+
const score = candidate ? Number(answer?.probabilities?.[`model_${index}`]) : Number.NaN;
|
|
67
|
+
if (evaluation?.status === 'ok' && candidate && Number.isFinite(score) && score >= threshold) {
|
|
68
|
+
return { selected: candidate, selectedBy: 'jev', score, evaluation };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
selected: fallback,
|
|
72
|
+
selectedBy: 'fallback',
|
|
73
|
+
score: Number.isFinite(score) ? score : null,
|
|
74
|
+
reason: evaluation?.status === 'ok' ? 'low_or_invalid_probability' : evaluation?.reason || evaluation?.error || 'no_evaluation',
|
|
75
|
+
evaluation,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function selectExecutionModel(run, { env = process.env, threshold = 0.55, fetchImpl } = {}) {
|
|
80
|
+
if (run.candidates.length === 1) return interpretSelection(null, run.candidates, threshold);
|
|
81
|
+
const missionText = fs.readFileSync(path.join(run.repo, run.mission), 'utf8').slice(0, 12000);
|
|
82
|
+
const criteria = Object.fromEntries(run.candidates.map((candidate, index) => [
|
|
83
|
+
`model_${index + 1}`,
|
|
84
|
+
`${candidate.provider}/${candidate.model}: ${candidate.profile}`,
|
|
85
|
+
]));
|
|
86
|
+
const evaluation = await evaluateJevShadow({
|
|
87
|
+
task: 'Choose one execution model before a browser-testing mission starts.',
|
|
88
|
+
mission: missionText,
|
|
89
|
+
constraints: ['One model owns the entire mission transcript.', 'Do not assume cross-provider failover.', 'Prefer lower expected spend when reliability is comparable.'],
|
|
90
|
+
}, {
|
|
91
|
+
config: { ...resolveJevShadowConfig(env), enabled: true, strategiesEnabled: false },
|
|
92
|
+
questions: {
|
|
93
|
+
executionModel: {
|
|
94
|
+
type: 'choice',
|
|
95
|
+
instructions: 'Choose the least expensive candidate likely to complete this mission reliably.',
|
|
96
|
+
criteria,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
100
|
+
});
|
|
101
|
+
return interpretSelection(evaluation, run.candidates, threshold);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function commandForCrossProviderRun(run, selection, cliPath = DEFAULT_CLI) {
|
|
105
|
+
return {
|
|
106
|
+
command: process.execPath,
|
|
107
|
+
args: [cliPath, `--provider=${selection.selected.provider}`, `--model=${selection.selected.model}`, '--optimize=tokens', '--optimization-mode=live', run.mission],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseArgs(argv) {
|
|
112
|
+
const read = (name, fallback) => argv.find(arg => arg.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
|
|
113
|
+
return {
|
|
114
|
+
dryRun: argv.includes('--dry-run'),
|
|
115
|
+
repetitions: Math.max(1, Number.parseInt(read('repetitions', '5'), 10) || 5),
|
|
116
|
+
seed: read('seed', 'cross-provider-pilot-v1'),
|
|
117
|
+
experimentId: read('experiment-id', 'cross-provider-pilot-v1'),
|
|
118
|
+
repo: path.resolve(read('repo', DEFAULT_REPO)),
|
|
119
|
+
cliPath: path.resolve(read('cli', DEFAULT_CLI)),
|
|
120
|
+
threshold: Math.min(1, Math.max(0, Number(read('selection-threshold', '0.55')) || 0.55)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeJson(file, value) {
|
|
125
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
126
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reportFiles(repo) {
|
|
130
|
+
const directory = path.join(repo, 'missions/mission_reports');
|
|
131
|
+
if (!fs.existsSync(directory)) return new Set();
|
|
132
|
+
return new Set(fs.readdirSync(directory).filter(file => /^run_\d+\.json$/.test(file)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findNewReport(repo, before) {
|
|
136
|
+
const directory = path.join(repo, 'missions/mission_reports');
|
|
137
|
+
if (!fs.existsSync(directory)) return null;
|
|
138
|
+
const file = fs.readdirSync(directory)
|
|
139
|
+
.filter(name => /^run_\d+\.json$/.test(name) && !before.has(name))
|
|
140
|
+
.map(name => ({ name, time: fs.statSync(path.join(directory, name)).mtimeMs }))
|
|
141
|
+
.sort((a, b) => b.time - a.time)[0]?.name;
|
|
142
|
+
return file ? path.join(directory, file) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function summarize(reportPath) {
|
|
146
|
+
if (!reportPath) return null;
|
|
147
|
+
try {
|
|
148
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
149
|
+
const steps = report.missions?.flatMap(mission => mission.steps || []) || [];
|
|
150
|
+
return {
|
|
151
|
+
runId: report.runId,
|
|
152
|
+
passed: report.summary?.passed || 0,
|
|
153
|
+
failed: report.summary?.failed || 0,
|
|
154
|
+
turns: steps.length,
|
|
155
|
+
tokens: steps.reduce((sum, step) => sum + Number(step.tokensUsed || 0), 0),
|
|
156
|
+
jevInputTokens: Number(report.jevShadow?.inputTokens || 0),
|
|
157
|
+
jevOutputTokens: Number(report.jevShadow?.outputTokens || 0),
|
|
158
|
+
};
|
|
159
|
+
} catch { return null; }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function writeCsv(file, state) {
|
|
163
|
+
const columns = ['key', 'block', 'condition', 'mission', 'provider', 'model', 'selectedBy', 'score', 'status', 'passed', 'failed', 'turns', 'tokens', 'jevInputTokens', 'jevOutputTokens'];
|
|
164
|
+
const quote = value => /[",\n]/.test(String(value ?? '')) ? `"${String(value ?? '').replaceAll('"', '""')}"` : String(value ?? '');
|
|
165
|
+
const rows = Object.values(state.runs).map(run => [run.key, run.block, run.id, run.mission, run.selection?.selected?.provider, run.selection?.selected?.model, run.selection?.selectedBy, run.selection?.score, run.status, run.metrics?.passed, run.metrics?.failed, run.metrics?.turns, run.metrics?.tokens, run.metrics?.jevInputTokens, run.metrics?.jevOutputTokens].map(quote).join(','));
|
|
166
|
+
fs.writeFileSync(file, `${columns.join(',')}\n${rows.join('\n')}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function runCrossProviderBenchmark(options) {
|
|
170
|
+
dotenv.config({ path: path.join(options.repo, '.env'), override: false, quiet: true });
|
|
171
|
+
const schedule = buildCrossProviderSchedule(options);
|
|
172
|
+
if (options.dryRun) return { schedule, completed: 0, skipped: 0 };
|
|
173
|
+
const root = path.resolve(HERE, 'results', options.experimentId);
|
|
174
|
+
const stateFile = path.join(root, 'state.json');
|
|
175
|
+
let state;
|
|
176
|
+
try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch { state = { experimentId: options.experimentId, seed: options.seed, repetitions: options.repetitions, createdAt: new Date().toISOString(), runs: {} }; }
|
|
177
|
+
let completed = 0;
|
|
178
|
+
let skipped = 0;
|
|
179
|
+
for (const run of schedule) {
|
|
180
|
+
if (state.runs[run.key]?.status === 'completed') { skipped += 1; continue; }
|
|
181
|
+
let selection = state.runs[run.key]?.selection;
|
|
182
|
+
if (!selection) {
|
|
183
|
+
selection = await selectExecutionModel(run, { threshold: options.threshold });
|
|
184
|
+
state.runs[run.key] = { ...run, status: 'selected', selection };
|
|
185
|
+
writeJson(stateFile, state);
|
|
186
|
+
}
|
|
187
|
+
const invocation = commandForCrossProviderRun(run, selection, options.cliPath);
|
|
188
|
+
console.log(`\n[${run.key}] ${selection.selected.provider}/${selection.selected.model} (${selection.selectedBy})`);
|
|
189
|
+
const before = reportFiles(run.repo);
|
|
190
|
+
const logFile = path.join(root, 'logs', `${run.key.replaceAll(':', '_')}.log`);
|
|
191
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
192
|
+
const env = { ...process.env,
|
|
193
|
+
TESTRONAUT_EXPERIMENT_ID: options.experimentId,
|
|
194
|
+
TESTRONAUT_EXPERIMENT_CONDITION: run.id,
|
|
195
|
+
TESTRONAUT_EXPERIMENT_BLOCK: String(run.block),
|
|
196
|
+
TESTRONAUT_EXPERIMENT_UNIT: run.unit,
|
|
197
|
+
TESTRONAUT_EXPERIMENT_SELECTED_BY: selection.selectedBy,
|
|
198
|
+
TESTRONAUT_EXPERIMENT_SELECTION_SCORE: selection.score == null ? '' : String(selection.score),
|
|
199
|
+
};
|
|
200
|
+
delete env.TESTRONAUT_JEV_FAST_MODEL;
|
|
201
|
+
const startedAt = new Date().toISOString();
|
|
202
|
+
const result = spawnSync(invocation.command, invocation.args, { cwd: run.repo, env, encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'], maxBuffer: 50 * 1024 * 1024 });
|
|
203
|
+
fs.writeFileSync(logFile, `${result.stdout || ''}${result.stderr || ''}`);
|
|
204
|
+
const reportPath = findNewReport(run.repo, before);
|
|
205
|
+
const status = reportPath ? 'completed' : 'no_report';
|
|
206
|
+
state.runs[run.key] = { ...run, status, selection, exitCode: result.status, signal: result.signal, startedAt, endedAt: new Date().toISOString(), reportPath, logFile, metrics: summarize(reportPath) };
|
|
207
|
+
writeJson(stateFile, state);
|
|
208
|
+
writeCsv(path.join(root, 'runs.csv'), state);
|
|
209
|
+
console.log(`[${run.key}] ${status}; exit=${result.status}; report=${reportPath || 'none'}`);
|
|
210
|
+
if (status === 'completed') completed += 1;
|
|
211
|
+
}
|
|
212
|
+
return { schedule, completed, skipped, stateFile };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function printSchedule(schedule) {
|
|
216
|
+
console.log(`Cross-provider benchmark schedule: ${schedule.length} isolated mission executions`);
|
|
217
|
+
for (const run of schedule) console.log(`${run.key} | ${run.candidates.map(item => `${item.provider}/${item.model}`).join(' vs ')} | ${run.mission}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
221
|
+
const options = parseArgs(process.argv.slice(2));
|
|
222
|
+
const result = await runCrossProviderBenchmark(options);
|
|
223
|
+
if (options.dryRun) printSchedule(result.schedule);
|
|
224
|
+
else console.log(`Benchmark progress saved to ${result.stateFile}. Completed ${result.completed}; resumed/skipped ${result.skipped}.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const __crossProviderInternals = { parseArgs };
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const DEFAULT_CLI = path.resolve(HERE, '../bin/cli.js');
|
|
10
|
+
const DEFAULT_REPOS = {
|
|
11
|
+
openai: '/home/shane/workspace/testronaut-examples',
|
|
12
|
+
gemini: '/home/shane/workspace/e2e-testing-collegium',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const BENCHMARK_TARGETS = {
|
|
16
|
+
openai: {
|
|
17
|
+
provider: 'openai',
|
|
18
|
+
repo: DEFAULT_REPOS.openai,
|
|
19
|
+
missions: [
|
|
20
|
+
'missions/login.mission.js',
|
|
21
|
+
'missions/removeTask.mission.js',
|
|
22
|
+
'missions/fileTransfer.mission.js',
|
|
23
|
+
],
|
|
24
|
+
conditions: [
|
|
25
|
+
{ id: 'l1_terra_control', level: 1, primary: 'gpt-5.6-terra', objective: 'off', mode: 'off' },
|
|
26
|
+
{ id: 'l2_terra_guarded', level: 2, primary: 'gpt-5.6-terra', objective: 'tokens', mode: 'live' },
|
|
27
|
+
{ id: 'l2_luna_guarded', level: 2, primary: 'gpt-5.6-luna', objective: 'tokens', mode: 'live' },
|
|
28
|
+
{ id: 'l2_gpt55_guarded', level: 2, primary: 'gpt-5.5', objective: 'tokens', mode: 'live' },
|
|
29
|
+
{ id: 'l3_terra_luna', level: 3, primary: 'gpt-5.6-terra', secondary: 'gpt-5.6-luna', objective: 'cost', mode: 'live' },
|
|
30
|
+
{ id: 'l4_terra_gpt55', level: 4, primary: 'gpt-5.6-terra', secondary: 'gpt-5.5', objective: 'cost', mode: 'live' },
|
|
31
|
+
{ id: 'l4_terra_gpt54', level: 4, primary: 'gpt-5.6-terra', secondary: 'gpt-5.4', objective: 'cost', mode: 'live' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
gemini: {
|
|
35
|
+
provider: 'gemini',
|
|
36
|
+
repo: DEFAULT_REPOS.gemini,
|
|
37
|
+
missions: [
|
|
38
|
+
'missions/profileNavigation.mission.js',
|
|
39
|
+
'missions/projectsDashboard.mission.js',
|
|
40
|
+
'missions/projectWorkspaceNavigation.mission.js',
|
|
41
|
+
],
|
|
42
|
+
conditions: [
|
|
43
|
+
{ id: 'l1_gemini38_control', level: 1, primary: 'gemini-3.8-flash', objective: 'off', mode: 'off' },
|
|
44
|
+
{ id: 'l2_gemini38_guarded', level: 2, primary: 'gemini-3.8-flash', objective: 'tokens', mode: 'live' },
|
|
45
|
+
{ id: 'l2_gemini36_guarded', level: 2, primary: 'gemini-3.6-flash', objective: 'tokens', mode: 'live' },
|
|
46
|
+
{ id: 'l2_gemini35lite_guarded', level: 2, primary: 'gemini-3.5-flash-lite', objective: 'tokens', mode: 'live' },
|
|
47
|
+
{ id: 'l3_gemini38_gemini37', level: 3, primary: 'gemini-3.8-flash', secondary: 'gemini-3.7-flash', objective: 'cost', mode: 'live' },
|
|
48
|
+
{ id: 'l4_gemini38_gemini36', level: 4, primary: 'gemini-3.8-flash', secondary: 'gemini-3.6-flash', objective: 'cost', mode: 'live' },
|
|
49
|
+
{ id: 'l4_gemini38_gemini35lite', level: 4, primary: 'gemini-3.8-flash', secondary: 'gemini-3.5-flash-lite', objective: 'cost', mode: 'live' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function hashSeed(value) {
|
|
55
|
+
let hash = 2166136261;
|
|
56
|
+
for (const char of String(value)) {
|
|
57
|
+
hash ^= char.charCodeAt(0);
|
|
58
|
+
hash = Math.imul(hash, 16777619);
|
|
59
|
+
}
|
|
60
|
+
return hash >>> 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function seededRandom(seed) {
|
|
64
|
+
let value = seed || 1;
|
|
65
|
+
return () => {
|
|
66
|
+
value += 0x6D2B79F5;
|
|
67
|
+
let next = value;
|
|
68
|
+
next = Math.imul(next ^ (next >>> 15), next | 1);
|
|
69
|
+
next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
|
|
70
|
+
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shuffled(values, seed) {
|
|
75
|
+
const result = [...values];
|
|
76
|
+
const random = seededRandom(hashSeed(seed));
|
|
77
|
+
for (let index = result.length - 1; index > 0; index -= 1) {
|
|
78
|
+
const swap = Math.floor(random() * (index + 1));
|
|
79
|
+
[result[index], result[swap]] = [result[swap], result[index]];
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildBenchmarkSchedule({ repetitions = 5, targets = ['openai', 'gemini'], seed = 'optimization-pilot-v1' } = {}) {
|
|
85
|
+
const schedule = [];
|
|
86
|
+
for (let block = 1; block <= repetitions; block += 1) {
|
|
87
|
+
for (const targetName of targets) {
|
|
88
|
+
const target = BENCHMARK_TARGETS[targetName];
|
|
89
|
+
if (!target) throw new Error(`Unknown benchmark target: ${targetName}`);
|
|
90
|
+
for (const condition of shuffled(target.conditions, `${seed}:${targetName}:${block}`)) {
|
|
91
|
+
schedule.push({
|
|
92
|
+
key: `${targetName}:b${block}:${condition.id}`,
|
|
93
|
+
target: targetName,
|
|
94
|
+
block,
|
|
95
|
+
provider: target.provider,
|
|
96
|
+
repo: target.repo,
|
|
97
|
+
missions: target.missions,
|
|
98
|
+
...condition,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return schedule;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function commandForRun(run, cliPath = DEFAULT_CLI) {
|
|
107
|
+
return {
|
|
108
|
+
command: process.execPath,
|
|
109
|
+
args: [
|
|
110
|
+
cliPath,
|
|
111
|
+
`--provider=${run.provider}`,
|
|
112
|
+
`--model=${run.primary}`,
|
|
113
|
+
`--optimize=${run.objective}`,
|
|
114
|
+
`--optimization-mode=${run.mode}`,
|
|
115
|
+
...run.missions,
|
|
116
|
+
],
|
|
117
|
+
env: {
|
|
118
|
+
TESTRONAUT_JEV_ROUTING_THRESHOLD: '0.4',
|
|
119
|
+
...(run.secondary ? { TESTRONAUT_BETA_MODEL_ROUTING: '1' } : {}),
|
|
120
|
+
...(run.secondary ? { TESTRONAUT_JEV_FAST_MODEL: run.secondary } : {}),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseArgs(argv) {
|
|
126
|
+
const read = (name, fallback) => argv.find(arg => arg.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
|
|
127
|
+
const target = read('target', 'both');
|
|
128
|
+
return {
|
|
129
|
+
dryRun: argv.includes('--dry-run'),
|
|
130
|
+
repetitions: Math.max(1, Number.parseInt(read('repetitions', '5'), 10) || 5),
|
|
131
|
+
targets: target === 'both' ? ['openai', 'gemini'] : target.split(',').filter(Boolean),
|
|
132
|
+
seed: read('seed', 'optimization-pilot-v1'),
|
|
133
|
+
experimentId: read('experiment-id', 'optimization-pilot-v1'),
|
|
134
|
+
cliPath: path.resolve(read('cli', DEFAULT_CLI)),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reportFiles(repo) {
|
|
139
|
+
const directory = path.join(repo, 'missions/mission_reports');
|
|
140
|
+
if (!fs.existsSync(directory)) return new Set();
|
|
141
|
+
return new Set(fs.readdirSync(directory).filter(file => /^run_\d+\.json$/.test(file)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findNewReport(repo, before) {
|
|
145
|
+
const directory = path.join(repo, 'missions/mission_reports');
|
|
146
|
+
if (!fs.existsSync(directory)) return null;
|
|
147
|
+
const candidates = fs.readdirSync(directory)
|
|
148
|
+
.filter(file => /^run_\d+\.json$/.test(file) && !before.has(file))
|
|
149
|
+
.map(file => ({ file, time: fs.statSync(path.join(directory, file)).mtimeMs }))
|
|
150
|
+
.sort((a, b) => b.time - a.time);
|
|
151
|
+
return candidates[0] ? path.join(directory, candidates[0].file) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function existingExperimentReports(repo, experimentId) {
|
|
155
|
+
const directory = path.join(repo, 'missions/mission_reports');
|
|
156
|
+
const reports = new Map();
|
|
157
|
+
if (!fs.existsSync(directory)) return reports;
|
|
158
|
+
for (const file of fs.readdirSync(directory).filter(name => /^run_\d+\.json$/.test(name))) {
|
|
159
|
+
const reportPath = path.join(directory, file);
|
|
160
|
+
try {
|
|
161
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
162
|
+
if (report.experiment?.id !== experimentId) continue;
|
|
163
|
+
reports.set(`${report.experiment.condition}:${report.experiment.block}`, reportPath);
|
|
164
|
+
} catch { /* ignore incomplete or unrelated reports */ }
|
|
165
|
+
}
|
|
166
|
+
return reports;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function loadState(file, metadata) {
|
|
170
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { ...metadata, runs: {} }; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function writeJson(file, value) {
|
|
174
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
175
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writeSummaryCsv(file, state) {
|
|
179
|
+
const columns = [
|
|
180
|
+
'key', 'target', 'block', 'level', 'condition', 'primary', 'secondary', 'objective',
|
|
181
|
+
'status', 'runId', 'passed', 'failed', 'turns', 'tokens', 'routedTurns',
|
|
182
|
+
'forcedPrimaryRetries', 'completionGates', 'jevInputTokens', 'jevOutputTokens',
|
|
183
|
+
];
|
|
184
|
+
const escape = value => {
|
|
185
|
+
const text = String(value ?? '');
|
|
186
|
+
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
187
|
+
};
|
|
188
|
+
const rows = Object.entries(state.runs).map(([key, run]) => {
|
|
189
|
+
const metrics = run.metrics || {};
|
|
190
|
+
return [
|
|
191
|
+
key, run.target, run.block, run.level, run.id, run.primary, run.secondary, run.objective,
|
|
192
|
+
run.status, metrics.runId, metrics.passed, metrics.failed, metrics.turns, metrics.tokens,
|
|
193
|
+
metrics.routedTurns, metrics.forcedPrimaryRetries, metrics.completionGates,
|
|
194
|
+
metrics.jevInputTokens, metrics.jevOutputTokens,
|
|
195
|
+
].map(escape).join(',');
|
|
196
|
+
});
|
|
197
|
+
fs.writeFileSync(file, `${columns.join(',')}\n${rows.join('\n')}\n`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function summarizeReport(reportPath) {
|
|
201
|
+
if (!reportPath) return null;
|
|
202
|
+
try {
|
|
203
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
204
|
+
const steps = report.missions?.flatMap(mission => mission.steps || []) || [];
|
|
205
|
+
const models = {};
|
|
206
|
+
for (const step of steps) {
|
|
207
|
+
const model = step.model || report.llm?.model || 'unknown';
|
|
208
|
+
models[model] ||= { turns: 0, tokens: 0 };
|
|
209
|
+
models[model].turns += 1;
|
|
210
|
+
models[model].tokens += Number(step.tokensUsed || 0);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
runId: report.runId,
|
|
214
|
+
passed: report.summary?.passed || 0,
|
|
215
|
+
failed: report.summary?.failed || 0,
|
|
216
|
+
turns: steps.length,
|
|
217
|
+
tokens: steps.reduce((sum, step) => sum + Number(step.tokensUsed || 0), 0),
|
|
218
|
+
routedTurns: steps.filter(step => step.modelRouting?.applied).length,
|
|
219
|
+
forcedPrimaryRetries: steps.filter(step => step.modelRouting?.forcedPrimaryRetry).length,
|
|
220
|
+
jevInputTokens: Number(report.jevShadow?.inputTokens || 0),
|
|
221
|
+
jevOutputTokens: Number(report.jevShadow?.outputTokens || 0),
|
|
222
|
+
completionGates: Number(report.jevShadow?.triggeredCompletions || 0),
|
|
223
|
+
models,
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function runBenchmark(options) {
|
|
231
|
+
const schedule = buildBenchmarkSchedule(options);
|
|
232
|
+
if (options.dryRun) return { schedule, completed: 0, skipped: 0 };
|
|
233
|
+
|
|
234
|
+
const stateRoot = path.resolve(HERE, 'results', options.experimentId);
|
|
235
|
+
const legacyStateFile = path.join(stateRoot, 'state.json');
|
|
236
|
+
const legacyState = loadState(legacyStateFile, { runs: {} });
|
|
237
|
+
const states = {};
|
|
238
|
+
const stateFiles = {};
|
|
239
|
+
for (const target of options.targets) {
|
|
240
|
+
const stateFile = path.join(stateRoot, `state-${target}.json`);
|
|
241
|
+
const legacyRuns = Object.fromEntries(
|
|
242
|
+
Object.entries(legacyState.runs || {}).filter(([, run]) => run.target === target),
|
|
243
|
+
);
|
|
244
|
+
const state = loadState(stateFile, {
|
|
245
|
+
experimentId: options.experimentId,
|
|
246
|
+
seed: options.seed,
|
|
247
|
+
repetitions: options.repetitions,
|
|
248
|
+
targets: [target],
|
|
249
|
+
createdAt: new Date().toISOString(),
|
|
250
|
+
runs: legacyRuns,
|
|
251
|
+
});
|
|
252
|
+
const targetSchedule = schedule.filter(run => run.target === target);
|
|
253
|
+
const existing = existingExperimentReports(targetSchedule[0]?.repo, options.experimentId);
|
|
254
|
+
for (const run of targetSchedule) {
|
|
255
|
+
if (state.runs[run.key]?.status === 'completed') continue;
|
|
256
|
+
const reportPath = existing.get(`${run.id}:${run.block}`);
|
|
257
|
+
if (!reportPath) continue;
|
|
258
|
+
state.runs[run.key] = {
|
|
259
|
+
...run,
|
|
260
|
+
status: 'completed',
|
|
261
|
+
exitCode: null,
|
|
262
|
+
recoveredFromReport: true,
|
|
263
|
+
reportPath,
|
|
264
|
+
metrics: summarizeReport(reportPath),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
states[target] = state;
|
|
268
|
+
stateFiles[target] = stateFile;
|
|
269
|
+
writeJson(stateFile, state);
|
|
270
|
+
writeSummaryCsv(path.join(stateRoot, `runs-${target}.csv`), state);
|
|
271
|
+
}
|
|
272
|
+
let completed = 0;
|
|
273
|
+
let skipped = 0;
|
|
274
|
+
|
|
275
|
+
for (const run of schedule) {
|
|
276
|
+
const state = states[run.target];
|
|
277
|
+
const stateFile = stateFiles[run.target];
|
|
278
|
+
if (state.runs[run.key]?.status === 'completed') {
|
|
279
|
+
skipped += 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const invocation = commandForRun(run, options.cliPath);
|
|
283
|
+
console.log(`\n[${run.key}] ${run.primary}${run.secondary ? ` -> ${run.secondary}` : ''} (${run.objective}/${run.mode})`);
|
|
284
|
+
const before = reportFiles(run.repo);
|
|
285
|
+
const logFile = path.join(stateRoot, 'logs', `${run.key.replaceAll(':', '_')}.log`);
|
|
286
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
287
|
+
const startedAt = new Date().toISOString();
|
|
288
|
+
const env = { ...process.env, ...invocation.env };
|
|
289
|
+
if (!run.secondary) delete env.TESTRONAUT_JEV_FAST_MODEL;
|
|
290
|
+
Object.assign(env, {
|
|
291
|
+
TESTRONAUT_EXPERIMENT_ID: options.experimentId,
|
|
292
|
+
TESTRONAUT_EXPERIMENT_CONDITION: run.id,
|
|
293
|
+
TESTRONAUT_EXPERIMENT_BLOCK: String(run.block),
|
|
294
|
+
});
|
|
295
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
296
|
+
cwd: run.repo,
|
|
297
|
+
env,
|
|
298
|
+
encoding: 'utf8',
|
|
299
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
300
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
301
|
+
});
|
|
302
|
+
fs.writeFileSync(logFile, `${result.stdout || ''}${result.stderr || ''}`);
|
|
303
|
+
const reportPath = findNewReport(run.repo, before);
|
|
304
|
+
const status = reportPath ? 'completed' : 'no_report';
|
|
305
|
+
state.runs[run.key] = {
|
|
306
|
+
...run,
|
|
307
|
+
status,
|
|
308
|
+
exitCode: result.status,
|
|
309
|
+
signal: result.signal,
|
|
310
|
+
startedAt,
|
|
311
|
+
endedAt: new Date().toISOString(),
|
|
312
|
+
reportPath,
|
|
313
|
+
logFile,
|
|
314
|
+
metrics: summarizeReport(reportPath),
|
|
315
|
+
};
|
|
316
|
+
writeJson(stateFile, state);
|
|
317
|
+
writeSummaryCsv(path.join(stateRoot, `runs-${run.target}.csv`), state);
|
|
318
|
+
console.log(`[${run.key}] ${status}; exit=${result.status}; report=${reportPath || 'none'}`);
|
|
319
|
+
if (status === 'completed') completed += 1;
|
|
320
|
+
}
|
|
321
|
+
return { schedule, completed, skipped, stateFiles };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function printSchedule(schedule) {
|
|
325
|
+
console.log(`Optimization benchmark schedule: ${schedule.length} runs`);
|
|
326
|
+
for (const run of schedule) {
|
|
327
|
+
const secondary = run.secondary ? ` -> ${run.secondary}` : '';
|
|
328
|
+
console.log(`${run.key} | L${run.level} | ${run.primary}${secondary} | ${run.objective}/${run.mode} | ${run.missions.length} missions`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
333
|
+
const options = parseArgs(process.argv.slice(2));
|
|
334
|
+
const result = runBenchmark(options);
|
|
335
|
+
if (options.dryRun) printSchedule(result.schedule);
|
|
336
|
+
else console.log(`Benchmark progress saved to ${Object.values(result.stateFiles).join(', ')}. Completed ${result.completed}; resumed/skipped ${result.skipped}.`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export const __benchmarkInternals = { hashSeed, seededRandom, shuffled, parseArgs, summarizeReport };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testronaut",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"main": "./runner/testronaut.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"testronaut": "bin/cli.js"
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
14
|
"scripts": {
|
|
15
|
+
"benchmark:optimization": "node ./experiments/optimizationBenchmark.js",
|
|
16
|
+
"benchmark:cross-provider": "node ./experiments/crossProviderBenchmark.js",
|
|
17
|
+
"benchmark:package": "node ./experiments/buildOptimizationDataPackage.js",
|
|
18
|
+
"benchmark:publish-package": "node ./experiments/buildExperimentPublicationPackage.js",
|
|
15
19
|
"test": "vitest run",
|
|
16
20
|
"test:watch": "vitest",
|
|
17
21
|
"start": "node ./bin/cli.js"
|
|
@@ -108,6 +108,7 @@ describe('cli helpers', () => {
|
|
|
108
108
|
}, '/tmp/project');
|
|
109
109
|
expect(effective.outputDir).toEqual({ value: '/tmp/project/artifacts', source: 'config' });
|
|
110
110
|
expect(effective.authenticated).toBe(true);
|
|
111
|
+
expect(effective.guardrails).toMatchObject({ enabled: false, failOpen: true, credentialConfigured: false });
|
|
111
112
|
expect(JSON.stringify(effective)).not.toContain('secret');
|
|
112
113
|
});
|
|
113
114
|
|
|
@@ -128,6 +129,21 @@ describe('cli helpers', () => {
|
|
|
128
129
|
expect(res.invalid).toBe(false);
|
|
129
130
|
});
|
|
130
131
|
|
|
132
|
+
it('parses optimization objective and execution mode flags', () => {
|
|
133
|
+
expect(__test__.parseOptimizationArgs([
|
|
134
|
+
'--optimize=balanced', '--optimization-mode', 'live', '--beta-model-routing', 'login.mission.js',
|
|
135
|
+
])).toMatchObject({
|
|
136
|
+
objective: 'balanced', mode: 'live', betaModelRouting: true, args: ['login.mission.js'], invalid: false,
|
|
137
|
+
});
|
|
138
|
+
expect(__test__.parseOptimizationArgs(['--optimize=unknown'])).toMatchObject({ invalid: true });
|
|
139
|
+
expect(__test__.parseOptimizationArgs(['--guardrails', 'login.mission.js'])).toMatchObject({
|
|
140
|
+
objective: 'tokens', mode: 'live', guardrailsMode: 'live', args: ['login.mission.js'], invalid: false,
|
|
141
|
+
});
|
|
142
|
+
expect(__test__.parseOptimizationArgs(['--guardrails=shadow', 'login.mission.js'])).toMatchObject({
|
|
143
|
+
objective: 'tokens', mode: 'shadow', args: ['login.mission.js'], invalid: false,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
131
147
|
it('parses vercel bypass value from next arg', () => {
|
|
132
148
|
const { parseVercelBypassArgs } = __test__;
|
|
133
149
|
const res = parseVercelBypassArgs(['--vercel_bypass', 'abc', 'login']);
|
|
@@ -62,6 +62,20 @@ describe('CLI inspection commands', () => {
|
|
|
62
62
|
expect(result.stdout).not.toContain('private');
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it('reports opt-in guardrail readiness without exposing the Jev key', () => {
|
|
66
|
+
const cwd = makeProject();
|
|
67
|
+
fs.writeFileSync(path.join(cwd, 'testronaut-config.json'), JSON.stringify({
|
|
68
|
+
provider: 'openai', model: 'gpt-5.6', guardrails: { enabled: true, completionProbability: 0.85 },
|
|
69
|
+
}));
|
|
70
|
+
const result = spawnSync(process.execPath, [cliPath, 'config', '--json'], {
|
|
71
|
+
cwd, encoding: 'utf8', env: { ...process.env, TESTRONAUT_JEV_API_KEY: 'private-jev-key' },
|
|
72
|
+
});
|
|
73
|
+
expect(result.status).toBe(0);
|
|
74
|
+
const output = JSON.parse(result.stdout);
|
|
75
|
+
expect(output.guardrails).toMatchObject({ enabled: true, mode: 'live', completionProbability: 0.85, credentialConfigured: true, credentialSource: 'TESTRONAUT_JEV_API_KEY' });
|
|
76
|
+
expect(result.stdout).not.toContain('private-jev-key');
|
|
77
|
+
});
|
|
78
|
+
|
|
65
79
|
it('suggests a close mission name and exits nonzero during dry-run', () => {
|
|
66
80
|
const result = run(makeProject(), ['logn.mission.js', '--dry-run', '--json']);
|
|
67
81
|
expect(result.status).toBe(1);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createTerminalUi, shouldUseColor } from '../../bin/terminalUi.js';
|
|
3
|
+
|
|
4
|
+
function capture() {
|
|
5
|
+
let output = '';
|
|
6
|
+
return {
|
|
7
|
+
stream: { isTTY: false, write(value) { output += value; } },
|
|
8
|
+
read: () => output,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('terminal UI', () => {
|
|
13
|
+
it('renders a readable color-free mission lifecycle', () => {
|
|
14
|
+
const target = capture();
|
|
15
|
+
const ui = createTerminalUi({ stream: target.stream, color: false });
|
|
16
|
+
|
|
17
|
+
ui.banner({ version: '1.2.3', provider: 'openai', model: 'gpt-test', session: 'isolated' });
|
|
18
|
+
ui.phase('launch', 'login.mission.js');
|
|
19
|
+
ui.mission(true, 'login.mission.js', ['smoke']);
|
|
20
|
+
ui.summary({ totalMissions: 1, passed: 1, failed: 0, reportPath: 'reports/run.html' });
|
|
21
|
+
|
|
22
|
+
expect(target.read()).toContain('🧑🚀 TESTRONAUT / MISSION RUNNER');
|
|
23
|
+
expect(target.read()).toContain('LAUNCH login.mission.js');
|
|
24
|
+
expect(target.read()).toContain('#smoke');
|
|
25
|
+
expect(target.read()).toContain('MISSION COMPLETE');
|
|
26
|
+
expect(target.read()).not.toContain('\u001b[');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('honors terminal capability and NO_COLOR', () => {
|
|
30
|
+
expect(shouldUseColor({ isTTY: true }, {})).toBe(true);
|
|
31
|
+
expect(shouldUseColor({ isTTY: true }, { NO_COLOR: '' })).toBe(false);
|
|
32
|
+
expect(shouldUseColor({ isTTY: false }, {})).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|