testronaut 1.11.0 → 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.
Files changed (32) hide show
  1. package/README.md +198 -0
  2. package/TODO.md +5 -0
  3. package/bin/cli.js +215 -2
  4. package/core/agent.js +1 -0
  5. package/core/browserEvidence.js +184 -0
  6. package/core/jevRouting.js +77 -0
  7. package/core/jevShadow.js +325 -0
  8. package/core/jevSuiteSelection.js +39 -0
  9. package/core/jevUsage.js +62 -0
  10. package/core/optimization.js +98 -0
  11. package/core/turnLoop.js +174 -10
  12. package/experiments/CROSS_PROVIDER.md +19 -0
  13. package/experiments/README.md +62 -0
  14. package/experiments/buildExperimentPublicationPackage.js +219 -0
  15. package/experiments/buildOptimizationDataPackage.js +390 -0
  16. package/experiments/crossProviderBenchmark.js +227 -0
  17. package/experiments/optimizationBenchmark.js +339 -0
  18. package/package.json +5 -1
  19. package/tests/binTests/cli.helpers.test.js +16 -0
  20. package/tests/binTests/cli.integration.test.js +14 -0
  21. package/tests/coreTests/browserEvidence.test.js +70 -0
  22. package/tests/coreTests/jevRouting.test.js +57 -0
  23. package/tests/coreTests/jevShadow.test.js +158 -0
  24. package/tests/coreTests/jevSuiteSelection.test.js +24 -0
  25. package/tests/coreTests/jevUsage.test.js +58 -0
  26. package/tests/coreTests/optimization.test.js +80 -0
  27. package/tests/coreTests/turnLoop.test.js +304 -0
  28. package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
  29. package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
  30. package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
  31. package/tests/toolsTests/generateHtmlReport.test.js +80 -0
  32. package/tools/generateHtmlReport.js +57 -5
@@ -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,302 @@ describe('turnLoop', () => {
174
182
  expect(shared.chromeToolSpies.get_dom).not.toHaveBeenCalled();
175
183
  });
176
184
 
185
+ it('records Jev shadow telemetry without changing the final result', async () => {
186
+ process.env.TESTRONAUT_JEV_SHADOW = '1';
187
+ process.env.TESTRONAUT_JEV_STRATEGIES = '1';
188
+ process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
189
+ const originalFetch = globalThis.fetch;
190
+ globalThis.fetch = vi.fn(async () => ({
191
+ ok: true,
192
+ json: async () => ({
193
+ model: 'jev-test',
194
+ answers: {
195
+ missionComplete: { type: 'noul', noul: 0.95 },
196
+ modelRoute: { type: 'choice', choice: 'finish', confidence: 0.9, probabilities: { finish: 0.9 } },
197
+ postconditionSatisfied: { type: 'noul', noul: 0.9 },
198
+ recoveryRoute: { type: 'choice', choice: 'continue', confidence: 0.9, probabilities: { continue: 0.9 } },
199
+ missionStage: { type: 'choice', choice: 'complete', confidence: 0.9, probabilities: { complete: 0.9 } },
200
+ prerequisiteSatisfied: { type: 'noul', noul: 0.9 },
201
+ },
202
+ usage: { input_tokens: 75, output_tokens: 2 },
203
+ }),
204
+ }));
205
+ shared.chatMock.mockResolvedValueOnce({
206
+ message: { role: 'assistant', content: 'FINAL: success' },
207
+ usage: { total_tokens: 10 },
208
+ headers: {},
209
+ });
210
+
211
+ try {
212
+ const result = await turnLoop(browser, baseMessages(), 1, 0, 0, {}, {
213
+ steps: [],
214
+ missionName: 'demo',
215
+ goal: 'Complete the demo mission',
216
+ });
217
+
218
+ expect(result.success).toBe(true);
219
+ expect(result.steps[0].result).toMatch(/Success/);
220
+ expect(result.steps[0].jevShadow).toMatchObject({
221
+ status: 'ok',
222
+ model: 'jev-test',
223
+ usage: { input_tokens: 75, output_tokens: 2 },
224
+ strategies: { modelRoute: { choice: 'finish' } },
225
+ });
226
+ } finally {
227
+ globalThis.fetch = originalFetch;
228
+ delete process.env.TESTRONAUT_JEV_SHADOW;
229
+ delete process.env.TESTRONAUT_JEV_STRATEGIES;
230
+ delete process.env.TESTRONAUT_JEV_API_KEY;
231
+ }
232
+ });
233
+
234
+ it('routes the turn after a safe Jev fast-model recommendation', async () => {
235
+ process.env.TESTRONAUT_JEV_ROUTING = 'live';
236
+ process.env.TESTRONAUT_JEV_FAST_MODEL = 'stub-fast';
237
+ process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
238
+ const originalFetch = globalThis.fetch;
239
+ globalThis.fetch = vi.fn(async () => ({
240
+ ok: true,
241
+ json: async () => ({
242
+ model: 'jev-test',
243
+ answers: {
244
+ modelRoute: { type: 'choice', choice: 'fast_model', confidence: 0.25, probabilities: { fast_model: 0.55 } },
245
+ missionStage: { type: 'choice', choice: 'interact', confidence: 0.9 },
246
+ recoveryRoute: { type: 'choice', choice: 'continue', confidence: 0.9 },
247
+ },
248
+ usage: { input_tokens: 50, output_tokens: 2 },
249
+ }),
250
+ }));
251
+ shared.chatMock
252
+ .mockResolvedValueOnce({
253
+ message: {
254
+ role: 'assistant', content: '',
255
+ tool_calls: [{ id: 'route_1', type: 'function', function: { name: 'click_text', arguments: '{"text":"Continue"}' } }],
256
+ },
257
+ usage: { total_tokens: 10 }, headers: {},
258
+ })
259
+ .mockResolvedValueOnce({
260
+ message: { role: 'assistant', content: 'FINAL: success' },
261
+ usage: { total_tokens: 5 }, headers: {},
262
+ });
263
+
264
+ try {
265
+ const result = await turnLoop(browser, baseMessages(), 2, 0, 0, {}, { steps: [], missionName: 'demo' });
266
+ expect(result.success).toBe(true);
267
+ expect(shared.chatMock.mock.calls.map(([request]) => request.model)).toEqual(['stub-model', 'stub-fast']);
268
+ expect(result.steps[1]).toMatchObject({
269
+ model: 'stub-fast',
270
+ modelRouting: { recommendation: 'fast_model', applied: true, reason: 'jev_fast_model' },
271
+ });
272
+ } finally {
273
+ globalThis.fetch = originalFetch;
274
+ }
275
+ });
276
+
277
+ it('falls back to the primary model when a routed request fails', async () => {
278
+ process.env.TESTRONAUT_JEV_ROUTING = 'live';
279
+ process.env.TESTRONAUT_JEV_FAST_MODEL = 'stub-fast';
280
+ process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
281
+ const originalFetch = globalThis.fetch;
282
+ globalThis.fetch = vi.fn(async () => ({
283
+ ok: true,
284
+ json: async () => ({
285
+ model: 'jev-test',
286
+ answers: {
287
+ modelRoute: { type: 'choice', choice: 'fast_model', confidence: 0.25, probabilities: { fast_model: 0.55 } },
288
+ missionStage: { type: 'choice', choice: 'interact', confidence: 0.9 },
289
+ recoveryRoute: { type: 'choice', choice: 'continue', confidence: 0.9 },
290
+ },
291
+ usage: { input_tokens: 50, output_tokens: 2 },
292
+ }),
293
+ }));
294
+ shared.chatMock
295
+ .mockResolvedValueOnce({
296
+ message: {
297
+ role: 'assistant', content: '',
298
+ tool_calls: [{ id: 'route_1', type: 'function', function: { name: 'click_text', arguments: '{"text":"Continue"}' } }],
299
+ },
300
+ usage: { total_tokens: 10 }, headers: {},
301
+ })
302
+ .mockRejectedValueOnce(new Error('fast model unavailable'))
303
+ .mockResolvedValueOnce({
304
+ message: { role: 'assistant', content: 'FINAL: success' },
305
+ usage: { total_tokens: 5 }, headers: {},
306
+ });
307
+
308
+ try {
309
+ const result = await turnLoop(browser, baseMessages(), 2, 0, 0, {}, { steps: [], missionName: 'demo' });
310
+ expect(shared.chatMock.mock.calls.map(([request]) => request.model)).toEqual(['stub-model', 'stub-fast', 'stub-model']);
311
+ expect(result.steps[1].modelRouting).toMatchObject({ fallback: true, selectedModel: 'stub-model' });
312
+ expect(result.steps[1].events).toContain('↩️ Jev route fallback to stub-model');
313
+ } finally {
314
+ globalThis.fetch = originalFetch;
315
+ }
316
+ });
317
+
318
+ it('forces a routed action retry back onto the primary model', async () => {
319
+ process.env.TESTRONAUT_JEV_ROUTING = 'live';
320
+ process.env.TESTRONAUT_JEV_FAST_MODEL = 'stub-fast';
321
+ process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
322
+ const originalFetch = globalThis.fetch;
323
+ globalThis.fetch = vi.fn(async () => ({
324
+ ok: true,
325
+ json: async () => ({
326
+ model: 'jev-test',
327
+ answers: {
328
+ modelRoute: { type: 'choice', choice: 'fast_model', confidence: 0.2, probabilities: { fast_model: 0.55 } },
329
+ missionStage: { type: 'choice', choice: 'interact' },
330
+ recoveryRoute: { type: 'choice', choice: 'continue' },
331
+ },
332
+ usage: { input_tokens: 50, output_tokens: 2 },
333
+ }),
334
+ }));
335
+ shared.chromeToolSpies.click_text
336
+ .mockResolvedValueOnce(JSON.stringify({ ok: true }))
337
+ .mockRejectedValueOnce(new Error('temporary click failure'));
338
+ const toolResponse = id => ({
339
+ message: {
340
+ role: 'assistant', content: '',
341
+ tool_calls: [{ id, type: 'function', function: { name: 'click_text', arguments: '{"text":"Continue"}' } }],
342
+ },
343
+ usage: { total_tokens: 10 }, headers: {},
344
+ });
345
+ shared.chatMock
346
+ .mockResolvedValueOnce(toolResponse('primary_action'))
347
+ .mockResolvedValueOnce(toolResponse('fast_action'))
348
+ .mockResolvedValueOnce({
349
+ message: { role: 'assistant', content: 'FINAL: success' },
350
+ usage: { total_tokens: 5 }, headers: {},
351
+ });
352
+
353
+ try {
354
+ const result = await turnLoop(browser, baseMessages(), 3, 0, 0, {}, { steps: [], missionName: 'demo' });
355
+ expect(result.success).toBe(true);
356
+ expect(shared.chatMock.mock.calls.map(([request]) => request.model)).toEqual(['stub-model', 'stub-fast', 'stub-model']);
357
+ expect(result.steps[2].modelRouting).toMatchObject({
358
+ applied: false,
359
+ forcedPrimaryRetry: true,
360
+ reason: 'routed_retry_primary',
361
+ selectedModel: 'stub-model',
362
+ });
363
+ } finally {
364
+ globalThis.fetch = originalFetch;
365
+ }
366
+ });
367
+
368
+ it('allows the opt-in Jev gate to finish after strong browser evidence', async () => {
369
+ process.env.TESTRONAUT_JEV_GATE = '1';
370
+ process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
371
+ const originalFetch = globalThis.fetch;
372
+ globalThis.fetch = vi.fn(async () => ({
373
+ ok: true,
374
+ json: async () => ({
375
+ model: 'jev-test',
376
+ answers: {
377
+ missionComplete: { type: 'noul', noul: 0.91 },
378
+ progressState: { type: 'choice', choice: 'complete', confidence: 0.9, probabilities: { complete: 0.92 } },
379
+ nextCompute: { type: 'choice', choice: 'finish', confidence: 0.9, probabilities: { finish: 0.93 } },
380
+ },
381
+ usage: { input_tokens: 75, output_tokens: 2 },
382
+ }),
383
+ }));
384
+ shared.chromeToolSpies.get_dom.mockResolvedValueOnce('<html><title>Dashboard</title><h1>Mission complete</h1></html>');
385
+ shared.chromeToolSpies.screenshot.mockResolvedValueOnce('Screenshot saved at: ./screenshots/jev-gate.png');
386
+ shared.chatMock.mockResolvedValueOnce({
387
+ message: {
388
+ role: 'assistant',
389
+ content: '',
390
+ tool_calls: [{
391
+ id: 'tool_gate',
392
+ type: 'function',
393
+ function: { name: 'click_text', arguments: '{"text":"Sign in"}' },
394
+ }],
395
+ },
396
+ usage: { total_tokens: 10 },
397
+ headers: {},
398
+ });
399
+
400
+ try {
401
+ const result = await turnLoop(browser, baseMessages(), 5, 0, 0, {}, {
402
+ steps: [],
403
+ missionName: 'demo',
404
+ goal: 'Reach the dashboard',
405
+ });
406
+
407
+ expect(result).toMatchObject({ success: true, completedBy: 'jev_gate' });
408
+ expect(shared.chatMock).toHaveBeenCalledTimes(1);
409
+ expect(shared.chromeToolSpies.screenshot).toHaveBeenCalledTimes(1);
410
+ expect(result.steps[0]).toMatchObject({
411
+ result: '✅ Mission Success (Jev gate)',
412
+ screenshotPath: './screenshots/jev-gate.png',
413
+ jevGate: { triggered: true, candidate: true, threshold: 0.8 },
414
+ });
415
+ } finally {
416
+ globalThis.fetch = originalFetch;
417
+ delete process.env.TESTRONAUT_JEV_GATE;
418
+ delete process.env.TESTRONAUT_JEV_API_KEY;
419
+ }
420
+ });
421
+
422
+ it('fails open to the normal loop when gate finalization cannot capture a screenshot', async () => {
423
+ process.env.TESTRONAUT_JEV_GATE = '1';
424
+ process.env.TESTRONAUT_JEV_API_KEY = 'test-key';
425
+ const originalFetch = globalThis.fetch;
426
+ globalThis.fetch = vi.fn(async () => ({
427
+ ok: true,
428
+ json: async () => ({
429
+ model: 'jev-test',
430
+ answers: {
431
+ missionComplete: { type: 'noul', noul: 0.91 },
432
+ progressState: { type: 'choice', choice: 'complete', confidence: 0.9, probabilities: { complete: 0.92 } },
433
+ nextCompute: { type: 'choice', choice: 'finish', confidence: 0.9, probabilities: { finish: 0.93 } },
434
+ },
435
+ usage: { input_tokens: 75, output_tokens: 2 },
436
+ }),
437
+ }));
438
+ shared.chromeToolSpies.get_dom.mockResolvedValueOnce('<html><title>Dashboard</title><h1>Mission complete</h1></html>');
439
+ shared.chromeToolSpies.screenshot.mockRejectedValueOnce(new Error('disk unavailable'));
440
+ shared.chatMock
441
+ .mockResolvedValueOnce({
442
+ message: {
443
+ role: 'assistant',
444
+ content: '',
445
+ tool_calls: [{
446
+ id: 'tool_gate_fail_open',
447
+ type: 'function',
448
+ function: { name: 'click_text', arguments: '{"text":"Sign in"}' },
449
+ }],
450
+ },
451
+ usage: { total_tokens: 10 },
452
+ headers: {},
453
+ })
454
+ .mockResolvedValueOnce({
455
+ message: { role: 'assistant', content: 'FINAL: success' },
456
+ usage: { total_tokens: 10 },
457
+ headers: {},
458
+ });
459
+
460
+ try {
461
+ const result = await turnLoop(browser, baseMessages(), 5, 0, 0, {}, {
462
+ steps: [],
463
+ missionName: 'demo',
464
+ goal: 'Reach the dashboard',
465
+ });
466
+
467
+ expect(result.success).toBe(true);
468
+ expect(result.completedBy).toBeUndefined();
469
+ expect(shared.chatMock).toHaveBeenCalledTimes(2);
470
+ expect(result.steps[0].jevGate).toMatchObject({
471
+ triggered: false,
472
+ reason: 'screenshot_failed',
473
+ });
474
+ } finally {
475
+ globalThis.fetch = originalFetch;
476
+ delete process.env.TESTRONAUT_JEV_GATE;
477
+ delete process.env.TESTRONAUT_JEV_API_KEY;
478
+ }
479
+ });
480
+
177
481
  it('executes a tool call, injects DOM, then finishes on next turn', async () => {
178
482
  shared.chatMock.mockResolvedValueOnce({
179
483
  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
+ });
@@ -116,4 +116,84 @@ describe('generateHtmlReport', () => {
116
116
  expect(html).toContain('<strong>gpt-5.6</strong>');
117
117
  expect(html).not.toContain('<strong>LLM:</strong>');
118
118
  });
119
+
120
+ it('renders run, mission, and step Jev token spend', () => {
121
+ const steps = [{
122
+ turn: 0,
123
+ result: '✅ Mission Success (Jev gate)',
124
+ tokensUsed: 500,
125
+ inputTokens: 450,
126
+ outputTokens: 50,
127
+ events: [],
128
+ jevShadow: {
129
+ status: 'ok',
130
+ usage: { input_tokens: 1000, output_tokens: 25 },
131
+ latencyMs: 100,
132
+ completionGate: { candidate: true },
133
+ },
134
+ jevGate: { triggered: true },
135
+ }];
136
+ const outPath = path.join(tmpDir, 'jev.html');
137
+ generateHtmlReport({
138
+ runId: 'jev_run',
139
+ llm: { provider: 'openai', model: 'gpt-5.6-terra' },
140
+ summary: { totalMissions: 1, passed: 1, failed: 0 },
141
+ jevShadow: {
142
+ evaluations: 1,
143
+ completed: 1,
144
+ errors: 0,
145
+ skipped: 0,
146
+ inputTokens: 1000,
147
+ outputTokens: 25,
148
+ estimatedInputCostUsd: 0.000042,
149
+ },
150
+ missions: [{ missionName: 'Jev Mission', status: 'passed', steps }],
151
+ }, outPath);
152
+
153
+ const html = fs.readFileSync(outPath, 'utf8');
154
+ expect(html).toContain('<strong>gpt-5.6-terra</strong>');
155
+ expect(html).toContain('title="Jev"');
156
+ expect(html).toContain('<strong>Jev</strong>');
157
+ expect(html).toContain('Model tokens: 500 (450 in / 50 out)');
158
+ expect(html).toContain('Jev tokens: 1,000 in / 25 out');
159
+ expect(html).toContain('Jev: 1,000 in / 25 out');
160
+ expect(html).not.toContain('estimated spend');
161
+ expect(html).not.toContain('est. $');
162
+ expect(html).not.toContain('strategy eval');
163
+ });
164
+
165
+ it('breaks dynamically routed token usage down by model', () => {
166
+ const outPath = path.join(tmpDir, 'routed.html');
167
+ generateHtmlReport({
168
+ runId: 'routed_run',
169
+ llm: { provider: 'openai', model: 'gpt-5.6-terra' },
170
+ optimization: { configured: true, objective: 'cost', mode: 'live' },
171
+ missions: [{
172
+ missionName: 'Routed Mission', status: 'passed', steps: [
173
+ { turn: 0, model: 'gpt-5.6-terra', tokensUsed: 100, inputTokens: 90, outputTokens: 10, result: '✅ Passed' },
174
+ { turn: 1, model: 'gpt-5.6-luna', tokensUsed: 40, inputTokens: 35, outputTokens: 5, result: '✅ Passed', modelRouting: { applied: true } },
175
+ ],
176
+ }],
177
+ }, outPath);
178
+
179
+ const html = fs.readFileSync(outPath, 'utf8');
180
+ expect(html).toContain('<strong>gpt-5.6-terra + gpt-5.6-luna</strong>');
181
+ expect(html).toContain('gpt-5.6-terra tokens: 100 (90 in / 10 out)');
182
+ expect(html).toContain('gpt-5.6-luna tokens: 40 (35 in / 5 out)');
183
+ expect(html).toContain('By model: gpt-5.6-terra 100 / gpt-5.6-luna 40');
184
+ expect(html).toContain('routed by Jev');
185
+ expect(html).toContain('Optimization: cost (live)');
186
+ });
187
+
188
+ it('makes skipped Jev evaluations visible', () => {
189
+ const outPath = path.join(tmpDir, 'jev-skipped.html');
190
+ generateHtmlReport({
191
+ runId: 'jev_skipped',
192
+ jevShadow: { evaluations: 3, completed: 0, errors: 0, skipped: 3, inputTokens: 0, outputTokens: 0 },
193
+ }, outPath);
194
+
195
+ const html = fs.readFileSync(outPath, 'utf8');
196
+ expect(html).toContain('Jev skipped: 3');
197
+ expect(html).not.toContain('Jev estimated spend:');
198
+ });
119
199
  });