testronaut 1.11.0 → 1.12.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/README.md +226 -0
- package/TODO.md +5 -0
- package/bin/cli.js +232 -5
- package/bin/terminalUi.js +9 -1
- package/core/agent.js +7 -2
- 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/missionDiscovery.js +56 -6
- package/core/optimization.js +98 -0
- package/core/turnLoop.js +232 -11
- 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 +18 -1
- package/tests/binTests/terminalUi.test.js +2 -1
- package/tests/coreTests/agent.test.js +2 -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/missionDiscovery.test.js +44 -0
- package/tests/coreTests/optimization.test.js +80 -0
- package/tests/coreTests/turnLoop.test.js +351 -1
- 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 +100 -0
- package/tools/generateHtmlReport.js +67 -10
|
@@ -96,7 +96,7 @@ vi.mock('../../tools/turnLoopUtils.js', async () => {
|
|
|
96
96
|
finalResponseHandler: (msg) => {
|
|
97
97
|
const content = (msg?.content || '').toString();
|
|
98
98
|
if (content.includes('FINAL')) {
|
|
99
|
-
return { finalMessage:
|
|
99
|
+
return { finalMessage: content, success: !content.includes('FAILURE') };
|
|
100
100
|
}
|
|
101
101
|
return null;
|
|
102
102
|
},
|
|
@@ -112,6 +112,7 @@ vi.mock('../../core/turnIntent.js', () => ({
|
|
|
112
112
|
vi.mock('../../core/redaction.js', () => ({
|
|
113
113
|
maskPreview: (value) => `masked:${String(value ?? '').length}`,
|
|
114
114
|
redactArgs: (name, args) => args,
|
|
115
|
+
redactPasswordInText: (value) => String(value ?? ''),
|
|
115
116
|
}));
|
|
116
117
|
|
|
117
118
|
// Import SUT after mocks
|
|
@@ -130,6 +131,13 @@ describe('turnLoop', () => {
|
|
|
130
131
|
|
|
131
132
|
beforeEach(() => {
|
|
132
133
|
delete process.env.TESTRONAUT_SCREENSHOTS;
|
|
134
|
+
delete process.env.TESTRONAUT_JEV_SHADOW;
|
|
135
|
+
delete process.env.TESTRONAUT_JEV_GATE;
|
|
136
|
+
delete process.env.TESTRONAUT_JEV_STRATEGIES;
|
|
137
|
+
delete process.env.TESTRONAUT_JEV_ROUTING;
|
|
138
|
+
delete process.env.TESTRONAUT_JEV_FAST_MODEL;
|
|
139
|
+
delete process.env.TESTRONAUT_JEV_ROUTING_THRESHOLD;
|
|
140
|
+
delete process.env.TESTRONAUT_JEV_API_KEY;
|
|
133
141
|
browser = {};
|
|
134
142
|
// reset spies and chat mock
|
|
135
143
|
shared.chromeToolSpies.fill.mockClear();
|
|
@@ -174,6 +182,348 @@ describe('turnLoop', () => {
|
|
|
174
182
|
expect(shared.chromeToolSpies.get_dom).not.toHaveBeenCalled();
|
|
175
183
|
});
|
|
176
184
|
|
|
185
|
+
it('uses one tool-free closure turn after the working-turn budget is exhausted', async () => {
|
|
186
|
+
shared.chatMock
|
|
187
|
+
.mockResolvedValueOnce({
|
|
188
|
+
message: { role: 'assistant', content: 'Still investigating' },
|
|
189
|
+
usage: { total_tokens: 10 },
|
|
190
|
+
headers: {},
|
|
191
|
+
})
|
|
192
|
+
.mockResolvedValueOnce({
|
|
193
|
+
message: { role: 'assistant', content: 'FINAL: FAILURE: budget exhausted before completion' },
|
|
194
|
+
usage: { total_tokens: 4 },
|
|
195
|
+
headers: {},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await turnLoop(browser, baseMessages(), 1, 0, 0, {}, {
|
|
199
|
+
steps: [], missionName: 'demo',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(result).toMatchObject({ success: false, completedBy: 'budget_closure' });
|
|
203
|
+
expect(shared.chatMock).toHaveBeenCalledTimes(2);
|
|
204
|
+
expect(shared.chatMock.mock.calls[1][0].tools).toEqual([]);
|
|
205
|
+
expect(shared.chatMock.mock.calls[1][0].messages.at(-1).content).toContain('working-turn budget is exhausted');
|
|
206
|
+
expect(result.steps.at(-1)).toMatchObject({
|
|
207
|
+
isClosureTurn: true,
|
|
208
|
+
result: '❌ Mission Failure',
|
|
209
|
+
tokensUsed: 4,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('records elapsed milliseconds for normal and closure turns', async () => {
|
|
214
|
+
shared.chatMock
|
|
215
|
+
.mockResolvedValueOnce({ message: { role: 'assistant', content: 'Continue' }, usage: {} })
|
|
216
|
+
.mockResolvedValueOnce({ message: { role: 'assistant', content: 'FINAL: FAILURE: incomplete' }, usage: {} });
|
|
217
|
+
|
|
218
|
+
const result = await turnLoop(browser, baseMessages(), 1, 0, 0, {}, {
|
|
219
|
+
steps: [], missionName: 'timed',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.steps).toHaveLength(2);
|
|
223
|
+
for (const step of result.steps) {
|
|
224
|
+
expect(step.startTime).toEqual(expect.any(Number));
|
|
225
|
+
expect(step.endTime).toEqual(expect.any(Number));
|
|
226
|
+
expect(step.durationMs).toEqual(expect.any(Number));
|
|
227
|
+
expect(step.durationMs).toBeGreaterThanOrEqual(0);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('records Jev shadow telemetry without changing the final result', async () => {
|
|
232
|
+
process.env.TESTRONAUT_JEV_SHADOW = '1';
|
|
233
|
+
process.env.TESTRONAUT_JEV_STRATEGIES = '1';
|
|
234
|
+
process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
|
|
235
|
+
const originalFetch = globalThis.fetch;
|
|
236
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
237
|
+
ok: true,
|
|
238
|
+
json: async () => ({
|
|
239
|
+
model: 'jev-test',
|
|
240
|
+
answers: {
|
|
241
|
+
missionComplete: { type: 'noul', noul: 0.95 },
|
|
242
|
+
modelRoute: { type: 'choice', choice: 'finish', confidence: 0.9, probabilities: { finish: 0.9 } },
|
|
243
|
+
postconditionSatisfied: { type: 'noul', noul: 0.9 },
|
|
244
|
+
recoveryRoute: { type: 'choice', choice: 'continue', confidence: 0.9, probabilities: { continue: 0.9 } },
|
|
245
|
+
missionStage: { type: 'choice', choice: 'complete', confidence: 0.9, probabilities: { complete: 0.9 } },
|
|
246
|
+
prerequisiteSatisfied: { type: 'noul', noul: 0.9 },
|
|
247
|
+
},
|
|
248
|
+
usage: { input_tokens: 75, output_tokens: 2 },
|
|
249
|
+
}),
|
|
250
|
+
}));
|
|
251
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
252
|
+
message: { role: 'assistant', content: 'FINAL: success' },
|
|
253
|
+
usage: { total_tokens: 10 },
|
|
254
|
+
headers: {},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const result = await turnLoop(browser, baseMessages(), 1, 0, 0, {}, {
|
|
259
|
+
steps: [],
|
|
260
|
+
missionName: 'demo',
|
|
261
|
+
goal: 'Complete the demo mission',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result.success).toBe(true);
|
|
265
|
+
expect(result.steps[0].result).toMatch(/Success/);
|
|
266
|
+
expect(result.steps[0].jevShadow).toMatchObject({
|
|
267
|
+
status: 'ok',
|
|
268
|
+
model: 'jev-test',
|
|
269
|
+
usage: { input_tokens: 75, output_tokens: 2 },
|
|
270
|
+
strategies: { modelRoute: { choice: 'finish' } },
|
|
271
|
+
});
|
|
272
|
+
} finally {
|
|
273
|
+
globalThis.fetch = originalFetch;
|
|
274
|
+
delete process.env.TESTRONAUT_JEV_SHADOW;
|
|
275
|
+
delete process.env.TESTRONAUT_JEV_STRATEGIES;
|
|
276
|
+
delete process.env.TESTRONAUT_JEV_API_KEY;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('routes the turn after a safe Jev fast-model recommendation', async () => {
|
|
281
|
+
process.env.TESTRONAUT_JEV_ROUTING = 'live';
|
|
282
|
+
process.env.TESTRONAUT_JEV_FAST_MODEL = 'stub-fast';
|
|
283
|
+
process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
|
|
284
|
+
const originalFetch = globalThis.fetch;
|
|
285
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
286
|
+
ok: true,
|
|
287
|
+
json: async () => ({
|
|
288
|
+
model: 'jev-test',
|
|
289
|
+
answers: {
|
|
290
|
+
modelRoute: { type: 'choice', choice: 'fast_model', confidence: 0.25, probabilities: { fast_model: 0.55 } },
|
|
291
|
+
missionStage: { type: 'choice', choice: 'interact', confidence: 0.9 },
|
|
292
|
+
recoveryRoute: { type: 'choice', choice: 'continue', confidence: 0.9 },
|
|
293
|
+
},
|
|
294
|
+
usage: { input_tokens: 50, output_tokens: 2 },
|
|
295
|
+
}),
|
|
296
|
+
}));
|
|
297
|
+
shared.chatMock
|
|
298
|
+
.mockResolvedValueOnce({
|
|
299
|
+
message: {
|
|
300
|
+
role: 'assistant', content: '',
|
|
301
|
+
tool_calls: [{ id: 'route_1', type: 'function', function: { name: 'click_text', arguments: '{"text":"Continue"}' } }],
|
|
302
|
+
},
|
|
303
|
+
usage: { total_tokens: 10 }, headers: {},
|
|
304
|
+
})
|
|
305
|
+
.mockResolvedValueOnce({
|
|
306
|
+
message: { role: 'assistant', content: 'FINAL: success' },
|
|
307
|
+
usage: { total_tokens: 5 }, headers: {},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const result = await turnLoop(browser, baseMessages(), 2, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
312
|
+
expect(result.success).toBe(true);
|
|
313
|
+
expect(shared.chatMock.mock.calls.map(([request]) => request.model)).toEqual(['stub-model', 'stub-fast']);
|
|
314
|
+
expect(result.steps[1]).toMatchObject({
|
|
315
|
+
model: 'stub-fast',
|
|
316
|
+
modelRouting: { recommendation: 'fast_model', applied: true, reason: 'jev_fast_model' },
|
|
317
|
+
});
|
|
318
|
+
} finally {
|
|
319
|
+
globalThis.fetch = originalFetch;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('falls back to the primary model when a routed request fails', async () => {
|
|
324
|
+
process.env.TESTRONAUT_JEV_ROUTING = 'live';
|
|
325
|
+
process.env.TESTRONAUT_JEV_FAST_MODEL = 'stub-fast';
|
|
326
|
+
process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
|
|
327
|
+
const originalFetch = globalThis.fetch;
|
|
328
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
329
|
+
ok: true,
|
|
330
|
+
json: async () => ({
|
|
331
|
+
model: 'jev-test',
|
|
332
|
+
answers: {
|
|
333
|
+
modelRoute: { type: 'choice', choice: 'fast_model', confidence: 0.25, probabilities: { fast_model: 0.55 } },
|
|
334
|
+
missionStage: { type: 'choice', choice: 'interact', confidence: 0.9 },
|
|
335
|
+
recoveryRoute: { type: 'choice', choice: 'continue', confidence: 0.9 },
|
|
336
|
+
},
|
|
337
|
+
usage: { input_tokens: 50, output_tokens: 2 },
|
|
338
|
+
}),
|
|
339
|
+
}));
|
|
340
|
+
shared.chatMock
|
|
341
|
+
.mockResolvedValueOnce({
|
|
342
|
+
message: {
|
|
343
|
+
role: 'assistant', content: '',
|
|
344
|
+
tool_calls: [{ id: 'route_1', type: 'function', function: { name: 'click_text', arguments: '{"text":"Continue"}' } }],
|
|
345
|
+
},
|
|
346
|
+
usage: { total_tokens: 10 }, headers: {},
|
|
347
|
+
})
|
|
348
|
+
.mockRejectedValueOnce(new Error('fast model unavailable'))
|
|
349
|
+
.mockResolvedValueOnce({
|
|
350
|
+
message: { role: 'assistant', content: 'FINAL: success' },
|
|
351
|
+
usage: { total_tokens: 5 }, headers: {},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const result = await turnLoop(browser, baseMessages(), 2, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
356
|
+
expect(shared.chatMock.mock.calls.map(([request]) => request.model)).toEqual(['stub-model', 'stub-fast', 'stub-model']);
|
|
357
|
+
expect(result.steps[1].modelRouting).toMatchObject({ fallback: true, selectedModel: 'stub-model' });
|
|
358
|
+
expect(result.steps[1].events).toContain('↩️ Jev route fallback to stub-model');
|
|
359
|
+
} finally {
|
|
360
|
+
globalThis.fetch = originalFetch;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('forces a routed action retry back onto the primary model', async () => {
|
|
365
|
+
process.env.TESTRONAUT_JEV_ROUTING = 'live';
|
|
366
|
+
process.env.TESTRONAUT_JEV_FAST_MODEL = 'stub-fast';
|
|
367
|
+
process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
|
|
368
|
+
const originalFetch = globalThis.fetch;
|
|
369
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
370
|
+
ok: true,
|
|
371
|
+
json: async () => ({
|
|
372
|
+
model: 'jev-test',
|
|
373
|
+
answers: {
|
|
374
|
+
modelRoute: { type: 'choice', choice: 'fast_model', confidence: 0.2, probabilities: { fast_model: 0.55 } },
|
|
375
|
+
missionStage: { type: 'choice', choice: 'interact' },
|
|
376
|
+
recoveryRoute: { type: 'choice', choice: 'continue' },
|
|
377
|
+
},
|
|
378
|
+
usage: { input_tokens: 50, output_tokens: 2 },
|
|
379
|
+
}),
|
|
380
|
+
}));
|
|
381
|
+
shared.chromeToolSpies.click_text
|
|
382
|
+
.mockResolvedValueOnce(JSON.stringify({ ok: true }))
|
|
383
|
+
.mockRejectedValueOnce(new Error('temporary click failure'));
|
|
384
|
+
const toolResponse = id => ({
|
|
385
|
+
message: {
|
|
386
|
+
role: 'assistant', content: '',
|
|
387
|
+
tool_calls: [{ id, type: 'function', function: { name: 'click_text', arguments: '{"text":"Continue"}' } }],
|
|
388
|
+
},
|
|
389
|
+
usage: { total_tokens: 10 }, headers: {},
|
|
390
|
+
});
|
|
391
|
+
shared.chatMock
|
|
392
|
+
.mockResolvedValueOnce(toolResponse('primary_action'))
|
|
393
|
+
.mockResolvedValueOnce(toolResponse('fast_action'))
|
|
394
|
+
.mockResolvedValueOnce({
|
|
395
|
+
message: { role: 'assistant', content: 'FINAL: success' },
|
|
396
|
+
usage: { total_tokens: 5 }, headers: {},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const result = await turnLoop(browser, baseMessages(), 3, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
401
|
+
expect(result.success).toBe(true);
|
|
402
|
+
expect(shared.chatMock.mock.calls.map(([request]) => request.model)).toEqual(['stub-model', 'stub-fast', 'stub-model']);
|
|
403
|
+
expect(result.steps[2].modelRouting).toMatchObject({
|
|
404
|
+
applied: false,
|
|
405
|
+
forcedPrimaryRetry: true,
|
|
406
|
+
reason: 'routed_retry_primary',
|
|
407
|
+
selectedModel: 'stub-model',
|
|
408
|
+
});
|
|
409
|
+
} finally {
|
|
410
|
+
globalThis.fetch = originalFetch;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('allows the opt-in Jev gate to finish after strong browser evidence', async () => {
|
|
415
|
+
process.env.TESTRONAUT_JEV_GATE = '1';
|
|
416
|
+
process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
|
|
417
|
+
const originalFetch = globalThis.fetch;
|
|
418
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
419
|
+
ok: true,
|
|
420
|
+
json: async () => ({
|
|
421
|
+
model: 'jev-test',
|
|
422
|
+
answers: {
|
|
423
|
+
missionComplete: { type: 'noul', noul: 0.91 },
|
|
424
|
+
progressState: { type: 'choice', choice: 'complete', confidence: 0.9, probabilities: { complete: 0.92 } },
|
|
425
|
+
nextCompute: { type: 'choice', choice: 'finish', confidence: 0.9, probabilities: { finish: 0.93 } },
|
|
426
|
+
},
|
|
427
|
+
usage: { input_tokens: 75, output_tokens: 2 },
|
|
428
|
+
}),
|
|
429
|
+
}));
|
|
430
|
+
shared.chromeToolSpies.get_dom.mockResolvedValueOnce('<html><title>Dashboard</title><h1>Mission complete</h1></html>');
|
|
431
|
+
shared.chromeToolSpies.screenshot.mockResolvedValueOnce('Screenshot saved at: ./screenshots/jev-gate.png');
|
|
432
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
433
|
+
message: {
|
|
434
|
+
role: 'assistant',
|
|
435
|
+
content: '',
|
|
436
|
+
tool_calls: [{
|
|
437
|
+
id: 'tool_gate',
|
|
438
|
+
type: 'function',
|
|
439
|
+
function: { name: 'click_text', arguments: '{"text":"Sign in"}' },
|
|
440
|
+
}],
|
|
441
|
+
},
|
|
442
|
+
usage: { total_tokens: 10 },
|
|
443
|
+
headers: {},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const result = await turnLoop(browser, baseMessages(), 5, 0, 0, {}, {
|
|
448
|
+
steps: [],
|
|
449
|
+
missionName: 'demo',
|
|
450
|
+
goal: 'Reach the dashboard',
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
expect(result).toMatchObject({ success: true, completedBy: 'jev_gate' });
|
|
454
|
+
expect(shared.chatMock).toHaveBeenCalledTimes(1);
|
|
455
|
+
expect(shared.chromeToolSpies.screenshot).toHaveBeenCalledTimes(1);
|
|
456
|
+
expect(result.steps[0]).toMatchObject({
|
|
457
|
+
result: '✅ Mission Success (Jev gate)',
|
|
458
|
+
screenshotPath: './screenshots/jev-gate.png',
|
|
459
|
+
jevGate: { triggered: true, candidate: true, threshold: 0.8 },
|
|
460
|
+
});
|
|
461
|
+
} finally {
|
|
462
|
+
globalThis.fetch = originalFetch;
|
|
463
|
+
delete process.env.TESTRONAUT_JEV_GATE;
|
|
464
|
+
delete process.env.TESTRONAUT_JEV_API_KEY;
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('fails open to the normal loop when gate finalization cannot capture a screenshot', async () => {
|
|
469
|
+
process.env.TESTRONAUT_JEV_GATE = '1';
|
|
470
|
+
process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
|
|
471
|
+
const originalFetch = globalThis.fetch;
|
|
472
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
473
|
+
ok: true,
|
|
474
|
+
json: async () => ({
|
|
475
|
+
model: 'jev-test',
|
|
476
|
+
answers: {
|
|
477
|
+
missionComplete: { type: 'noul', noul: 0.91 },
|
|
478
|
+
progressState: { type: 'choice', choice: 'complete', confidence: 0.9, probabilities: { complete: 0.92 } },
|
|
479
|
+
nextCompute: { type: 'choice', choice: 'finish', confidence: 0.9, probabilities: { finish: 0.93 } },
|
|
480
|
+
},
|
|
481
|
+
usage: { input_tokens: 75, output_tokens: 2 },
|
|
482
|
+
}),
|
|
483
|
+
}));
|
|
484
|
+
shared.chromeToolSpies.get_dom.mockResolvedValueOnce('<html><title>Dashboard</title><h1>Mission complete</h1></html>');
|
|
485
|
+
shared.chromeToolSpies.screenshot.mockRejectedValueOnce(new Error('disk unavailable'));
|
|
486
|
+
shared.chatMock
|
|
487
|
+
.mockResolvedValueOnce({
|
|
488
|
+
message: {
|
|
489
|
+
role: 'assistant',
|
|
490
|
+
content: '',
|
|
491
|
+
tool_calls: [{
|
|
492
|
+
id: 'tool_gate_fail_open',
|
|
493
|
+
type: 'function',
|
|
494
|
+
function: { name: 'click_text', arguments: '{"text":"Sign in"}' },
|
|
495
|
+
}],
|
|
496
|
+
},
|
|
497
|
+
usage: { total_tokens: 10 },
|
|
498
|
+
headers: {},
|
|
499
|
+
})
|
|
500
|
+
.mockResolvedValueOnce({
|
|
501
|
+
message: { role: 'assistant', content: 'FINAL: success' },
|
|
502
|
+
usage: { total_tokens: 10 },
|
|
503
|
+
headers: {},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const result = await turnLoop(browser, baseMessages(), 5, 0, 0, {}, {
|
|
508
|
+
steps: [],
|
|
509
|
+
missionName: 'demo',
|
|
510
|
+
goal: 'Reach the dashboard',
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(result.success).toBe(true);
|
|
514
|
+
expect(result.completedBy).toBeUndefined();
|
|
515
|
+
expect(shared.chatMock).toHaveBeenCalledTimes(2);
|
|
516
|
+
expect(result.steps[0].jevGate).toMatchObject({
|
|
517
|
+
triggered: false,
|
|
518
|
+
reason: 'screenshot_failed',
|
|
519
|
+
});
|
|
520
|
+
} finally {
|
|
521
|
+
globalThis.fetch = originalFetch;
|
|
522
|
+
delete process.env.TESTRONAUT_JEV_GATE;
|
|
523
|
+
delete process.env.TESTRONAUT_JEV_API_KEY;
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
177
527
|
it('executes a tool call, injects DOM, then finishes on next turn', async () => {
|
|
178
528
|
shared.chatMock.mockResolvedValueOnce({
|
|
179
529
|
message: {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __dataPackageInternals } from '../../experiments/buildOptimizationDataPackage.js';
|
|
3
|
+
|
|
4
|
+
describe('optimization data package statistics', () => {
|
|
5
|
+
it('calculates sample statistics', () => {
|
|
6
|
+
expect(__dataPackageInternals.mean([1, 2, 3])).toBe(2);
|
|
7
|
+
expect(__dataPackageInternals.sampleSd([1, 2, 3])).toBe(1);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('calculates paired percentage deltas by block', () => {
|
|
11
|
+
const condition = [
|
|
12
|
+
{ block: 1, tokens: 80 }, { block: 2, tokens: 160 },
|
|
13
|
+
{ block: 3, tokens: 240 }, { block: 4, tokens: 320 }, { block: 5, tokens: 400 },
|
|
14
|
+
];
|
|
15
|
+
const control = [
|
|
16
|
+
{ block: 1, tokens: 100 }, { block: 2, tokens: 200 },
|
|
17
|
+
{ block: 3, tokens: 300 }, { block: 4, tokens: 400 }, { block: 5, tokens: 500 },
|
|
18
|
+
];
|
|
19
|
+
expect(__dataPackageInternals.pairedDelta(condition, control, 'tokens')).toEqual({
|
|
20
|
+
average: -20,
|
|
21
|
+
low: -20,
|
|
22
|
+
high: -20,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CROSS_PROVIDER_CONDITIONS, buildCrossProviderSchedule, commandForCrossProviderRun, interpretSelection } from '../../experiments/crossProviderBenchmark.js';
|
|
3
|
+
|
|
4
|
+
describe('cross-provider benchmark', () => {
|
|
5
|
+
it('builds 120 deterministic isolated mission executions', () => {
|
|
6
|
+
const first = buildCrossProviderSchedule();
|
|
7
|
+
const repeat = buildCrossProviderSchedule();
|
|
8
|
+
expect(first).toHaveLength(120);
|
|
9
|
+
expect(first.map(run => run.key)).toEqual(repeat.map(run => run.key));
|
|
10
|
+
for (let block = 1; block <= 5; block += 1) {
|
|
11
|
+
expect(first.filter(run => run.block === block)).toHaveLength(24);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('limits portfolios to two models and crosses providers', () => {
|
|
16
|
+
for (const condition of CROSS_PROVIDER_CONDITIONS) expect(condition.candidates.length).toBeLessThanOrEqual(2);
|
|
17
|
+
for (const condition of CROSS_PROVIDER_CONDITIONS.filter(item => item.id.startsWith('x'))) {
|
|
18
|
+
expect(new Set(condition.candidates.map(item => item.provider)).size).toBe(2);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('uses a sufficiently confident Jev selection and otherwise falls back', () => {
|
|
23
|
+
const candidates = CROSS_PROVIDER_CONDITIONS.find(item => item.id.startsWith('x')).candidates;
|
|
24
|
+
const chosen = interpretSelection({ status: 'ok', answers: { executionModel: { choice: 'model_2', probabilities: { model_2: 0.8 } } } }, candidates);
|
|
25
|
+
expect(chosen).toMatchObject({ selected: candidates[1], selectedBy: 'jev', score: 0.8 });
|
|
26
|
+
const fallback = interpretSelection({ status: 'ok', answers: { executionModel: { choice: 'model_2', probabilities: { model_2: 0.4 } } } }, candidates);
|
|
27
|
+
expect(fallback).toMatchObject({ selected: candidates[0], selectedBy: 'fallback' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('runs one mission with guardrails and no intra-mission fast model', () => {
|
|
31
|
+
const run = buildCrossProviderSchedule({ repetitions: 1 })[0];
|
|
32
|
+
const selection = { selected: run.candidates[0] };
|
|
33
|
+
const invocation = commandForCrossProviderRun(run, selection, '/cli.js');
|
|
34
|
+
expect(invocation.args).toEqual(['/cli.js', `--provider=${selection.selected.provider}`, `--model=${selection.selected.model}`, '--optimize=tokens', '--optimization-mode=live', run.mission]);
|
|
35
|
+
expect(invocation.args.filter(arg => arg.endsWith('.mission.js'))).toHaveLength(1);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { BENCHMARK_TARGETS, buildBenchmarkSchedule, commandForRun } from '../../experiments/optimizationBenchmark.js';
|
|
3
|
+
|
|
4
|
+
describe('optimization benchmark matrix', () => {
|
|
5
|
+
it('builds seven conditions and five blocked repetitions per repository', () => {
|
|
6
|
+
const schedule = buildBenchmarkSchedule();
|
|
7
|
+
expect(schedule).toHaveLength(70);
|
|
8
|
+
expect(schedule.filter(run => run.target === 'openai')).toHaveLength(35);
|
|
9
|
+
expect(schedule.filter(run => run.target === 'gemini')).toHaveLength(35);
|
|
10
|
+
for (const target of ['openai', 'gemini']) {
|
|
11
|
+
for (let block = 1; block <= 5; block += 1) {
|
|
12
|
+
const runs = schedule.filter(run => run.target === target && run.block === block);
|
|
13
|
+
expect(runs).toHaveLength(7);
|
|
14
|
+
expect(new Set(runs.map(run => run.id)).size).toBe(7);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('is deterministically randomized by seed', () => {
|
|
20
|
+
const first = buildBenchmarkSchedule({ repetitions: 2, seed: 'one' }).map(run => run.key);
|
|
21
|
+
const repeat = buildBenchmarkSchedule({ repetitions: 2, seed: 'one' }).map(run => run.key);
|
|
22
|
+
const other = buildBenchmarkSchedule({ repetitions: 2, seed: 'two' }).map(run => run.key);
|
|
23
|
+
expect(first).toEqual(repeat);
|
|
24
|
+
expect(first).not.toEqual(other);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('keeps routed conditions to exactly two execution models', () => {
|
|
28
|
+
for (const target of Object.values(BENCHMARK_TARGETS)) {
|
|
29
|
+
for (const condition of target.conditions) {
|
|
30
|
+
expect([condition.primary, condition.secondary].filter(Boolean).length).toBeLessThanOrEqual(2);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('constructs explicit, reproducible CLI arguments', () => {
|
|
36
|
+
const run = buildBenchmarkSchedule({ repetitions: 1, targets: ['openai'] })
|
|
37
|
+
.find(item => item.id === 'l4_terra_gpt55');
|
|
38
|
+
const invocation = commandForRun(run, '/cli/bin.js');
|
|
39
|
+
expect(invocation.args).toEqual(expect.arrayContaining([
|
|
40
|
+
'/cli/bin.js', '--provider=openai', '--model=gpt-5.6-terra',
|
|
41
|
+
'--optimize=cost', '--optimization-mode=live', 'missions/login.mission.js',
|
|
42
|
+
]));
|
|
43
|
+
expect(invocation.env).toMatchObject({
|
|
44
|
+
TESTRONAUT_JEV_FAST_MODEL: 'gpt-5.5',
|
|
45
|
+
TESTRONAUT_JEV_ROUTING_THRESHOLD: '0.4',
|
|
46
|
+
TESTRONAUT_BETA_MODEL_ROUTING: '1',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -18,6 +18,26 @@ afterEach(() => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
describe('generateHtmlReport', () => {
|
|
21
|
+
it('renders explicit suite, mission, and turn durations', () => {
|
|
22
|
+
const report = {
|
|
23
|
+
runId: 'timed_run',
|
|
24
|
+
durationMs: 6500,
|
|
25
|
+
missions: [{
|
|
26
|
+
missionName: 'Timed Mission',
|
|
27
|
+
submissionType: 'mission',
|
|
28
|
+
status: 'passed',
|
|
29
|
+
durationMs: 2500,
|
|
30
|
+
steps: [{ turn: 0, result: '✅ Mission Success', durationMs: 1250 }],
|
|
31
|
+
}],
|
|
32
|
+
};
|
|
33
|
+
const outPath = path.join(tmpDir, 'timed.html');
|
|
34
|
+
generateHtmlReport(report, outPath);
|
|
35
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
36
|
+
expect(html).toContain('<strong>Duration:</strong> 6.50s');
|
|
37
|
+
expect(html).toContain('duration: 2.50s');
|
|
38
|
+
expect(html).toContain('Turn 1 • 1.25s');
|
|
39
|
+
});
|
|
40
|
+
|
|
21
41
|
it('writes an HTML report containing mission and retry metadata', () => {
|
|
22
42
|
const report = {
|
|
23
43
|
runId: 'run_test',
|
|
@@ -116,4 +136,84 @@ describe('generateHtmlReport', () => {
|
|
|
116
136
|
expect(html).toContain('<strong>gpt-5.6</strong>');
|
|
117
137
|
expect(html).not.toContain('<strong>LLM:</strong>');
|
|
118
138
|
});
|
|
139
|
+
|
|
140
|
+
it('renders run, mission, and step Jev token spend', () => {
|
|
141
|
+
const steps = [{
|
|
142
|
+
turn: 0,
|
|
143
|
+
result: '✅ Mission Success (Jev gate)',
|
|
144
|
+
tokensUsed: 500,
|
|
145
|
+
inputTokens: 450,
|
|
146
|
+
outputTokens: 50,
|
|
147
|
+
events: [],
|
|
148
|
+
jevShadow: {
|
|
149
|
+
status: 'ok',
|
|
150
|
+
usage: { input_tokens: 1000, output_tokens: 25 },
|
|
151
|
+
latencyMs: 100,
|
|
152
|
+
completionGate: { candidate: true },
|
|
153
|
+
},
|
|
154
|
+
jevGate: { triggered: true },
|
|
155
|
+
}];
|
|
156
|
+
const outPath = path.join(tmpDir, 'jev.html');
|
|
157
|
+
generateHtmlReport({
|
|
158
|
+
runId: 'jev_run',
|
|
159
|
+
llm: { provider: 'openai', model: 'gpt-5.6-terra' },
|
|
160
|
+
summary: { totalMissions: 1, passed: 1, failed: 0 },
|
|
161
|
+
jevShadow: {
|
|
162
|
+
evaluations: 1,
|
|
163
|
+
completed: 1,
|
|
164
|
+
errors: 0,
|
|
165
|
+
skipped: 0,
|
|
166
|
+
inputTokens: 1000,
|
|
167
|
+
outputTokens: 25,
|
|
168
|
+
estimatedInputCostUsd: 0.000042,
|
|
169
|
+
},
|
|
170
|
+
missions: [{ missionName: 'Jev Mission', status: 'passed', steps }],
|
|
171
|
+
}, outPath);
|
|
172
|
+
|
|
173
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
174
|
+
expect(html).toContain('<strong>gpt-5.6-terra</strong>');
|
|
175
|
+
expect(html).toContain('title="Jev"');
|
|
176
|
+
expect(html).toContain('<strong>Jev</strong>');
|
|
177
|
+
expect(html).toContain('Model tokens: 500 (450 in / 50 out)');
|
|
178
|
+
expect(html).toContain('Jev tokens: 1,000 in / 25 out');
|
|
179
|
+
expect(html).toContain('Jev: 1,000 in / 25 out');
|
|
180
|
+
expect(html).not.toContain('estimated spend');
|
|
181
|
+
expect(html).not.toContain('est. $');
|
|
182
|
+
expect(html).not.toContain('strategy eval');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('breaks dynamically routed token usage down by model', () => {
|
|
186
|
+
const outPath = path.join(tmpDir, 'routed.html');
|
|
187
|
+
generateHtmlReport({
|
|
188
|
+
runId: 'routed_run',
|
|
189
|
+
llm: { provider: 'openai', model: 'gpt-5.6-terra' },
|
|
190
|
+
optimization: { configured: true, objective: 'cost', mode: 'live' },
|
|
191
|
+
missions: [{
|
|
192
|
+
missionName: 'Routed Mission', status: 'passed', steps: [
|
|
193
|
+
{ turn: 0, model: 'gpt-5.6-terra', tokensUsed: 100, inputTokens: 90, outputTokens: 10, result: '✅ Passed' },
|
|
194
|
+
{ turn: 1, model: 'gpt-5.6-luna', tokensUsed: 40, inputTokens: 35, outputTokens: 5, result: '✅ Passed', modelRouting: { applied: true } },
|
|
195
|
+
],
|
|
196
|
+
}],
|
|
197
|
+
}, outPath);
|
|
198
|
+
|
|
199
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
200
|
+
expect(html).toContain('<strong>gpt-5.6-terra + gpt-5.6-luna</strong>');
|
|
201
|
+
expect(html).toContain('gpt-5.6-terra tokens: 100 (90 in / 10 out)');
|
|
202
|
+
expect(html).toContain('gpt-5.6-luna tokens: 40 (35 in / 5 out)');
|
|
203
|
+
expect(html).toContain('By model: gpt-5.6-terra 100 / gpt-5.6-luna 40');
|
|
204
|
+
expect(html).toContain('routed by Jev');
|
|
205
|
+
expect(html).toContain('Optimization: cost (live)');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('makes skipped Jev evaluations visible', () => {
|
|
209
|
+
const outPath = path.join(tmpDir, 'jev-skipped.html');
|
|
210
|
+
generateHtmlReport({
|
|
211
|
+
runId: 'jev_skipped',
|
|
212
|
+
jevShadow: { evaluations: 3, completed: 0, errors: 0, skipped: 3, inputTokens: 0, outputTokens: 0 },
|
|
213
|
+
}, outPath);
|
|
214
|
+
|
|
215
|
+
const html = fs.readFileSync(outPath, 'utf8');
|
|
216
|
+
expect(html).toContain('Jev skipped: 3');
|
|
217
|
+
expect(html).not.toContain('Jev estimated spend:');
|
|
218
|
+
});
|
|
119
219
|
});
|