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.
Files changed (37) hide show
  1. package/README.md +226 -0
  2. package/TODO.md +5 -0
  3. package/bin/cli.js +232 -5
  4. package/bin/terminalUi.js +9 -1
  5. package/core/agent.js +7 -2
  6. package/core/browserEvidence.js +184 -0
  7. package/core/jevRouting.js +77 -0
  8. package/core/jevShadow.js +325 -0
  9. package/core/jevSuiteSelection.js +39 -0
  10. package/core/jevUsage.js +62 -0
  11. package/core/missionDiscovery.js +56 -6
  12. package/core/optimization.js +98 -0
  13. package/core/turnLoop.js +232 -11
  14. package/experiments/CROSS_PROVIDER.md +19 -0
  15. package/experiments/README.md +62 -0
  16. package/experiments/buildExperimentPublicationPackage.js +219 -0
  17. package/experiments/buildOptimizationDataPackage.js +390 -0
  18. package/experiments/crossProviderBenchmark.js +227 -0
  19. package/experiments/optimizationBenchmark.js +339 -0
  20. package/package.json +5 -1
  21. package/tests/binTests/cli.helpers.test.js +16 -0
  22. package/tests/binTests/cli.integration.test.js +18 -1
  23. package/tests/binTests/terminalUi.test.js +2 -1
  24. package/tests/coreTests/agent.test.js +2 -0
  25. package/tests/coreTests/browserEvidence.test.js +70 -0
  26. package/tests/coreTests/jevRouting.test.js +57 -0
  27. package/tests/coreTests/jevShadow.test.js +158 -0
  28. package/tests/coreTests/jevSuiteSelection.test.js +24 -0
  29. package/tests/coreTests/jevUsage.test.js +58 -0
  30. package/tests/coreTests/missionDiscovery.test.js +44 -0
  31. package/tests/coreTests/optimization.test.js +80 -0
  32. package/tests/coreTests/turnLoop.test.js +351 -1
  33. package/tests/experimentTests/buildOptimizationDataPackage.test.js +25 -0
  34. package/tests/experimentTests/crossProviderBenchmark.test.js +37 -0
  35. package/tests/experimentTests/optimizationBenchmark.test.js +49 -0
  36. package/tests/toolsTests/generateHtmlReport.test.js +100 -0
  37. package/tools/generateHtmlReport.js +67 -10
package/README.md CHANGED
@@ -239,6 +239,34 @@ await runMissions({
239
239
  }, "Contact Form Flow");
240
240
  ```
241
241
 
242
+ Control the order of an automatically discovered suite in
243
+ `testronaut-config.json`:
244
+
245
+ ```json
246
+ {
247
+ "missions": {
248
+ "include": ["**/*.mission.js"],
249
+ "order": [
250
+ "login.mission.js",
251
+ "account/**/*.mission.js",
252
+ "*",
253
+ "logout.mission.js"
254
+ ]
255
+ }
256
+ }
257
+ ```
258
+
259
+ Order entries are applied from top to bottom. Each pattern's matches remain
260
+ alphabetical, `*` places missions not claimed by another entry, and unlisted
261
+ missions are appended alphabetically. An exact filename that is not discovered
262
+ produces a warning. Without `missions.order`, discovery remains alphabetical.
263
+ Mission filenames supplied directly on the command line run in argument order.
264
+
265
+ `maxTurns` counts working turns. If a mission spends the full budget without a
266
+ verdict, Testronaut makes one additional tool-free closure request so the report
267
+ ends with an explicit success or failure. JSON and HTML reports include elapsed
268
+ milliseconds for each turn and mission plus the duration of the complete suite.
269
+
242
270
  ---
243
271
 
244
272
  ## 🧰 Developer Mode (Staging API)
@@ -374,6 +402,204 @@ Notes:
374
402
 
375
403
  ---
376
404
 
405
+ ## 🔬 Experimental Jev shadow mode
406
+
407
+ Jev shadow mode evaluates each agent turn without changing browser actions,
408
+ mission status, or exit codes. Its typed recommendations, latency, model, and
409
+ token usage are written to each step in the JSON report. A run-level
410
+ `jevShadow` summary aggregates completed calls, errors, latency, and tokens.
411
+
412
+ Enable it with a TypeSafe API key:
413
+
414
+ ```bash
415
+ export TYPESAFE_API_KEY=YOUR_KEY
416
+ TESTRONAUT_JEV_SHADOW=1 testronaut login.mission.js
417
+ ```
418
+
419
+ Optional overrides:
420
+
421
+ ```bash
422
+ export TESTRONAUT_JEV_MODEL=jev-latest
423
+ export TESTRONAUT_JEV_TIMEOUT_MS=5000
424
+ export TESTRONAUT_JEV_API_BASE=https://api.typesafe.ai
425
+ ```
426
+
427
+ The shadow request contains a bounded representation of the mission, assistant
428
+ response, redacted tool arguments, recent events, Ground Control state, and
429
+ structured browser evidence derived from DOM snapshots already collected by
430
+ Testronaut. Browser evidence includes the query-free URL, title, headings,
431
+ control labels, element counts, and value-free live control flags (`hasValue`,
432
+ `checked`, `disabled`, `readOnly`, and `visible`). It excludes raw DOM, body
433
+ text, input values and value lengths, URL queries, and URL fragments. Because this data is sent to a
434
+ third-party service, enable the experiment only for test data appropriate for that service.
435
+ API errors and timeouts are recorded as shadow telemetry and do not stop the
436
+ mission.
437
+
438
+ ### Experimental completion gate
439
+
440
+ After validating shadow results, opt into early completion with:
441
+
442
+ ```bash
443
+ TESTRONAUT_JEV_GATE=1 testronaut login.mission.js
444
+ ```
445
+
446
+ The gate defaults to an 80% threshold and requires all three independent Jev
447
+ signals—mission completion, complete progress, and finish-next probability—to
448
+ meet it. It also requires structured browser evidence and a clean tool turn.
449
+ Before returning success, Testronaut captures a final screenshot when
450
+ screenshots are enabled and records completion telemetry. Jev errors, missing
451
+ evidence, incomplete resource guards, and screenshot failures fail open to the
452
+ ordinary agent loop.
453
+
454
+ Use a different experimental threshold with, for example:
455
+
456
+ ```bash
457
+ TESTRONAUT_JEV_GATE=1 TESTRONAUT_JEV_GATE_THRESHOLD=0.85 testronaut login.mission.js
458
+ ```
459
+
460
+ JSON reports retain the full Jev evaluation telemetry. HTML reports show the
461
+ models used and input/output token totals at run, mission, and turn levels
462
+ without displaying internal strategy counts or estimated cost. The JSON cost
463
+ estimate defaults to Jev's current `$0.042` per million input tokens. Override
464
+ the reporting rate without changing API behavior:
465
+
466
+ ```bash
467
+ TESTRONAUT_JEV_INPUT_USD_PER_MILLION=0.042 testronaut
468
+ ```
469
+
470
+ If the gate is enabled without an API key, the report highlights every skipped
471
+ evaluation. Ensure `TYPESAFE_API_KEY` or `TESTRONAUT_JEV_API_KEY` is exported in
472
+ the same process that launches Testronaut.
473
+
474
+ ### Additional Jev savings experiments
475
+
476
+ Enable the batched shadow strategy bundle without changing mission behavior:
477
+
478
+ ```bash
479
+ TESTRONAUT_JEV_STRATEGIES=1 testronaut
480
+ ```
481
+
482
+ The same Jev request then records:
483
+
484
+ - relevant structured DOM regions for future context filtering;
485
+ - a choice among controls already enumerated by Testronaut;
486
+ - deterministic, fast-model, full-model, observe, or finish routing;
487
+ - postcondition verification probability;
488
+ - continue, retry, reobserve, escalate, or stop recovery policy;
489
+ - mission stage and prerequisite readiness.
490
+
491
+ Strategy results and aggregates appear in JSON reports. They remain advisory
492
+ unless a separate experiment such as dynamic routing is explicitly enabled.
493
+
494
+ ### Stable Jev guardrails
495
+
496
+ Run Jev completion and evidence guardrails without allowing any model switch:
497
+
498
+ ```bash
499
+ export TESTRONAUT_JEV_API_KEY="your-key"
500
+ testronaut --guardrails
501
+ ```
502
+
503
+ This profile is fail-open: if Jev is unavailable, times out, or lacks sufficient
504
+ evidence, the primary model continues normally. It does not request the larger
505
+ experimental strategy question set and ignores fast-model routing configuration.
506
+ The default completion probability is 0.8.
507
+
508
+ ```json
509
+ {
510
+ "guardrails": {
511
+ "enabled": true,
512
+ "mode": "live",
513
+ "completionProbability": 0.8
514
+ }
515
+ }
516
+ ```
517
+
518
+ Guardrails are opt-in and use a user-supplied Jev key. The earlier
519
+ `--optimize=tokens --optimization-mode=live` and nested optimization config
520
+ remain supported as compatibility forms. Run `testronaut config --json` to
521
+ check credential readiness without printing the key.
522
+
523
+ Reports expose stable `jevGuardrails` telemetry separately from beta
524
+ `modelRouting` telemetry. The legacy `jevShadow` field remains for compatibility.
525
+
526
+ ### Beta dynamic model routing
527
+
528
+ Choose an optimization objective and whether it runs in shadow or live mode:
529
+
530
+ ```bash
531
+ testronaut --optimize=cost --optimization-mode=live --beta-model-routing
532
+ testronaut --optimize=tokens --optimization-mode=shadow
533
+ testronaut --optimize=balanced --optimization-mode=live --beta-model-routing
534
+ testronaut --optimize=off
535
+ ```
536
+
537
+ The supported objectives are `off`, `cost`, `tokens`, `balanced`, and `speed`.
538
+ `cost` requests cheaper family-local models; `tokens` uses stable guardrails
539
+ without model switching; `balanced` combines guardrails and beta routing; and
540
+ `speed` requests beta fast-model routing. The `cost`, `balanced`, and `speed`
541
+ objectives cannot switch models unless beta routing is explicitly enabled.
542
+ When an objective is supplied without a mode, it defaults to `shadow`.
543
+
544
+ The equivalent `testronaut-config.json` configuration is:
545
+
546
+ ```json
547
+ {
548
+ "optimization": {
549
+ "objective": "balanced",
550
+ "mode": "live",
551
+ "guardrails": {
552
+ "completionProbability": 0.8,
553
+ "minimumRoutingProbability": 0.4
554
+ },
555
+ "beta": {
556
+ "modelRouting": true
557
+ }
558
+ }
559
+ }
560
+ ```
561
+
562
+ CLI flags override configuration. Existing `TESTRONAUT_JEV_*` variables remain
563
+ available as lower-level experimental overrides.
564
+
565
+ Beta dynamic routing uses a high-confidence Jev `fast_model` recommendation from the
566
+ completed turn to choose the model for the next turn. Start in shadow mode to
567
+ record eligibility without changing models:
568
+
569
+ ```bash
570
+ TESTRONAUT_JEV_ROUTING=shadow TESTRONAUT_JEV_FAST_MODEL=gpt-5.6-luna testronaut
571
+ ```
572
+
573
+ After reviewing the recommendations, enable one-turn live routing:
574
+
575
+ ```bash
576
+ TESTRONAUT_JEV_ROUTING=live TESTRONAUT_JEV_FAST_MODEL=gpt-5.6-luna testronaut
577
+ ```
578
+
579
+ The family-local defaults are `gpt-5.6-luna` for OpenAI,
580
+ `gemini-3.5-flash-lite` for Gemini, and `claude-haiku-4-5` for Anthropic. An
581
+ explicit `TESTRONAUT_JEV_FAST_MODEL` must still belong to the active provider
582
+ family. Routing uses Jev's selected `fast_model` probability with a default
583
+ threshold of 40%; change it with `TESTRONAUT_JEV_ROUTING_THRESHOLD`.
584
+ Authentication, completion, failed,
585
+ uncertain recovery, and low-confidence turns stay on the primary model. If the
586
+ routed request fails—or a routed action requires a retry—Testronaut retries on
587
+ the primary model. Reports
588
+ record the actual model and tokens for every turn and split aggregate tokens by
589
+ model.
590
+
591
+ Evaluate risk-based suite selection separately as a dry run:
592
+
593
+ ```bash
594
+ TYPESAFE_API_KEY=YOUR_KEY testronaut select --base=origin/main
595
+ ```
596
+
597
+ This reads the git diff, asks one bounded relevance question per discovered
598
+ mission, and prints selected mission filenames without running or skipping any
599
+ tests. Use repeated `--changed-file=path` arguments instead of a git diff when
600
+ testing a hypothetical change. The default selection threshold is 70%; override
601
+ it with `TESTRONAUT_JEV_SELECTION_THRESHOLD`.
602
+
377
603
  ## 📋 Reports
378
604
 
379
605
  Testronaut generates JSON, HTML, and run-specific screenshots under the configured `outputDir` (default):
package/TODO.md CHANGED
@@ -20,6 +20,11 @@
20
20
  - [ ] Design an optional mission scope/side-effect policy that agents can enforce and reports can display.
21
21
  - [ ] Evaluate semantic DOM diffs/chunks after configurable limits, selector scoping, and table trimming have production data.
22
22
 
23
+ ## Optimization and Rate-Limit Follow-Ups
24
+
25
+ - [ ] Run a history-informed model-routing benchmark before expanding cross-provider routing. Give Jev versioned, per-model/per-mission-class reliability, token, latency, and price observations from prior runs; require a configurable reliability floor; compare against the best fixed-model-plus-guardrails control with blocked repetitions; keep selection at the mission boundary; record the evidence and reason for every choice; and define cold-start, stale-data, low-confidence, and provider-failure fallbacks. Do not retry a failed mission on another provider in the benchmark because that would confound routing quality with retry recovery.
26
+ - [ ] Add an opt-in CLI/config policy that reroutes an imminent throttled LLM request to a compatible model as an alternative to the default backoff behavior. Keep backoff as the default; use observed TPM/RPM headroom and projected request tokens; constrain failover to explicitly configured provider/model families; preserve tool/history compatibility; prevent routing loops; fall back safely when the alternate model is unavailable; and record the original model, routed model, trigger, token usage, latency, and outcome in reports.
27
+
23
28
  ## Automated MFA Follow-Ups
24
29
 
25
30
  - [ ] Add an integration smoke test against a local or staging API fixture for `get_mfa_code`.
package/bin/cli.js CHANGED
@@ -48,7 +48,7 @@ import fetch from 'node-fetch';
48
48
  import crypto from 'crypto';
49
49
  import http from 'http';
50
50
  import { resolveProviderModel } from '../llm/modelResolver.js';
51
- import { exec as execCmd } from 'child_process';
51
+ import { exec as execCmd, execFileSync } from 'child_process';
52
52
  import { promisify } from 'util';
53
53
  const exec = promisify(execCmd);
54
54
  import url from 'url';
@@ -58,6 +58,10 @@ import { loadMissionModule } from '../core/missionLoader.js';
58
58
  import { loadConfig } from '../core/config.js';
59
59
  import { matchesTagFilter, normalizeTagMatch, normalizeTags } from '../core/tags.js';
60
60
  import { createTerminalUi } from './terminalUi.js';
61
+ import { summarizeJevUsage } from '../core/jevUsage.js';
62
+ import { evaluateJevShadow, resolveJevShadowConfig } from '../core/jevShadow.js';
63
+ import { buildJevSuiteSelectionRequest, interpretJevSuiteSelection } from '../core/jevSuiteSelection.js';
64
+ import { applyOptimizationPolicy, OPTIMIZATION_MODES, OPTIMIZATION_OBJECTIVES, resolveOptimizationPolicy } from '../core/optimization.js';
61
65
 
62
66
  // Keep PW browsers inside the project to avoid global cache skew
63
67
  process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
@@ -360,6 +364,7 @@ export const __test__ = {
360
364
  createVercelBypassHeader,
361
365
  parseProviderArgs,
362
366
  parseRunOptionsArgs,
367
+ parseOptimizationArgs,
363
368
  detectCliName,
364
369
  isDirectInvocation,
365
370
  resolveMissionPath,
@@ -389,6 +394,7 @@ function resolveReportFile(requested, reportDir, cwd = process.cwd()) {
389
394
 
390
395
  function buildEffectiveConfig(config = {}, cwd = process.cwd()) {
391
396
  const resolved = resolveProviderModel({ cwd });
397
+ const optimization = resolveOptimizationPolicy({ config });
392
398
  const source = key => process.env[key] ? 'environment' : undefined;
393
399
  return {
394
400
  configFile: path.resolve(cwd, 'testronaut-config.json'),
@@ -401,6 +407,21 @@ function buildEffectiveConfig(config = {}, cwd = process.cwd()) {
401
407
  addTags: normalizeTags([...(config.addTags || []), ...cliAddTags]),
402
408
  screenshots: screenshotsEnabled,
403
409
  authenticated: Boolean(config.sessionToken),
410
+ optimization,
411
+ guardrails: {
412
+ enabled: optimization.objective === 'tokens' && optimization.mode !== 'off',
413
+ mode: optimization.objective === 'tokens' ? optimization.mode : 'off',
414
+ completionProbability: optimization.completionProbability,
415
+ failOpen: true,
416
+ credentialConfigured: Boolean(resolveJevShadowConfig().apiKey),
417
+ credentialSource: process.env.TESTRONAUT_JEV_API_KEY
418
+ ? 'TESTRONAUT_JEV_API_KEY'
419
+ : process.env.TYPESAFE_API_KEY
420
+ ? 'TYPESAFE_API_KEY'
421
+ : process.env.JEV_API_KEY
422
+ ? 'JEV_API_KEY'
423
+ : null,
424
+ },
404
425
  };
405
426
  }
406
427
 
@@ -508,6 +529,56 @@ function parseTagArgs(argsList) {
508
529
  }
509
530
  __test__.parseTagArgs = parseTagArgs;
510
531
 
532
+ function parseOptimizationArgs(argsList) {
533
+ const nextArgs = [...argsList];
534
+ const values = {};
535
+ let invalid = false;
536
+ const guardrailsIndex = nextArgs.findIndex(arg => arg === '--guardrails' || arg.startsWith('--guardrails='));
537
+ if (guardrailsIndex >= 0) {
538
+ const raw = nextArgs[guardrailsIndex];
539
+ const inline = raw.includes('=');
540
+ const following = nextArgs[guardrailsIndex + 1];
541
+ const consumesFollowing = !inline && ['live', 'shadow', 'off'].includes(String(following || '').toLowerCase());
542
+ const guardrailsMode = inline
543
+ ? raw.slice(raw.indexOf('=') + 1).trim().toLowerCase()
544
+ : consumesFollowing ? following.toLowerCase() : 'live';
545
+ if (!['live', 'shadow', 'off'].includes(guardrailsMode)) invalid = true;
546
+ else {
547
+ values.guardrailsMode = guardrailsMode;
548
+ values.objective = guardrailsMode === 'off' ? 'off' : 'tokens';
549
+ values.mode = guardrailsMode;
550
+ }
551
+ nextArgs.splice(guardrailsIndex, consumesFollowing ? 2 : 1);
552
+ }
553
+ const betaRoutingIndex = nextArgs.indexOf('--beta-model-routing');
554
+ if (betaRoutingIndex >= 0) {
555
+ values.betaModelRouting = true;
556
+ nextArgs.splice(betaRoutingIndex, 1);
557
+ }
558
+ const definitions = [
559
+ { names: ['--optimize'], key: 'objective' },
560
+ { names: ['--optimization-mode'], key: 'mode' },
561
+ ];
562
+ for (const definition of definitions) {
563
+ const index = nextArgs.findIndex(arg => definition.names.some(name => arg === name || arg.startsWith(`${name}=`)));
564
+ if (index < 0) continue;
565
+ const raw = nextArgs[index];
566
+ const inline = raw.includes('=');
567
+ const value = inline ? raw.slice(raw.indexOf('=') + 1) : nextArgs[index + 1];
568
+ if (!value || (!inline && value.startsWith('-'))) invalid = true;
569
+ else values[definition.key] = value.trim().toLowerCase();
570
+ nextArgs.splice(index, inline ? 1 : (value && !value.startsWith('-') ? 2 : 1));
571
+ }
572
+ const probe = resolveOptimizationPolicy({
573
+ env: {
574
+ ...(values.objective ? { TESTRONAUT_OPTIMIZE: values.objective } : {}),
575
+ ...(values.mode ? { TESTRONAUT_OPTIMIZATION_MODE: values.mode } : {}),
576
+ },
577
+ });
578
+ if (!probe.valid) invalid = true;
579
+ return { args: nextArgs, ...values, invalid };
580
+ }
581
+
511
582
  // Look for --model=<id> or --model <id>
512
583
  let modelOverride;
513
584
  const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
@@ -534,6 +605,16 @@ if (providerResult.invalid) {
534
605
  console.warn('⚠️ Invalid --provider value. Provide a non-empty provider id.');
535
606
  }
536
607
  args = providerResult.args;
608
+
609
+ const optimizationArgs = parseOptimizationArgs(args);
610
+ args = optimizationArgs.args;
611
+ if (optimizationArgs.invalid) {
612
+ console.error(`❌ --optimize must be one of ${OPTIMIZATION_OBJECTIVES.join(', ')} and --optimization-mode must be one of ${OPTIMIZATION_MODES.join(', ')}.`);
613
+ process.exit(1);
614
+ }
615
+ if (optimizationArgs.objective) process.env.TESTRONAUT_OPTIMIZE = optimizationArgs.objective;
616
+ if (optimizationArgs.mode) process.env.TESTRONAUT_OPTIMIZATION_MODE = optimizationArgs.mode;
617
+ if (optimizationArgs.betaModelRouting) process.env.TESTRONAUT_BETA_MODEL_ROUTING = '1';
537
618
  const providerOverride = providerResult.provider;
538
619
  if (providerOverride) {
539
620
  process.env.TESTRONAUT_PROVIDER = providerOverride.trim();
@@ -756,6 +837,7 @@ Usage:
756
837
  ${cliName} <file> Run a .mission.js or .mission.ts file by name or path
757
838
  ${cliName} login Log in and store session token
758
839
  ${cliName} list List discovered missions and tags without running them
840
+ ${cliName} select Ask Jev which missions are relevant to the current git diff (dry-run only)
759
841
  ${cliName} config Show effective configuration and value sources
760
842
  ${cliName} upload [file] Upload the latest report or a selected report file/run ID
761
843
  ${cliName} serve Serve & open the most recent HTML report (read-only)
@@ -767,6 +849,10 @@ Options:
767
849
  --debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
768
850
  --provider=<id> Override LLM provider (openai, gemini, or anthropic)
769
851
  --model=<id> Override the provider model for this run
852
+ --optimize=<objective> Optimization objective: off, cost, tokens, balanced, or speed
853
+ --optimization-mode=<m> Optimization control: off, shadow, or live
854
+ --guardrails[=<mode>] Opt in to Jev guardrails: live (default), shadow, or off
855
+ --beta-model-routing Enable beta model switching for cost, balanced, or speed
770
856
  --session=<mode> Browser auth reuse: isolated (default) or shared-auth
771
857
  -o, --options key=value Set run options, such as mfa=github-test-mfa or inbox=github-staging
772
858
  --dev Use the staging API base URL
@@ -780,6 +866,8 @@ Options:
780
866
  --no-upload-screenshots Upload report JSON without its screenshots
781
867
  --json Emit machine-readable command/run output
782
868
  --quiet Suppress informational logs
869
+ --base=<git-ref> Base ref for Jev suite selection (default: HEAD~1)
870
+ --changed-file=<path> Explicit changed file for Jev suite selection; repeatable
783
871
  --retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
784
872
  --tag=<tag> Run missions matching a tag; repeat for multiple tags
785
873
  --tags=<tag,...> Compact comma-list form (OR/any by default)
@@ -791,6 +879,7 @@ Examples:
791
879
  ${cliName}
792
880
  ${cliName} login
793
881
  ${cliName} list
882
+ ${cliName} select --base=origin/main
794
883
  ${cliName} upload
795
884
  ${cliName} serve
796
885
  ${cliName} --init
@@ -862,12 +951,98 @@ if (args.includes('serve') || args.includes('view')) {
862
951
  await new Promise(() => {}); // ✅ never resolves; Ctrl+C will terminate
863
952
  }
864
953
 
865
- const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
954
+ const {
955
+ root: missionsRoot,
956
+ files: discoveredMissions,
957
+ warnings: missionDiscoveryWarnings = [],
958
+ } = await discoverMissionFiles({ cwd: process.cwd() });
959
+ for (const warning of missionDiscoveryWarnings) console.warn(`⚠️ ${warning}`);
866
960
  const tagConfig = await loadConfig(process.cwd());
961
+ const optimizationPolicy = resolveOptimizationPolicy({ config: tagConfig });
962
+ if (!optimizationPolicy.valid) {
963
+ console.error('❌ Invalid optimization objective or mode in testronaut-config.json.');
964
+ process.exit(1);
965
+ }
966
+ applyOptimizationPolicy(optimizationPolicy, process.env, {
967
+ force: Boolean(optimizationArgs.objective || optimizationArgs.mode || optimizationArgs.betaModelRouting),
968
+ });
969
+ const jevPreflight = resolveJevShadowConfig();
970
+ if (optimizationPolicy.objective === 'tokens' && optimizationPolicy.mode !== 'off' && !jevPreflight.apiKey) {
971
+ console.warn('⚠️ Jev guardrails are enabled but no user-supplied key was found. Set TESTRONAUT_JEV_API_KEY; this run will fail open to the primary model.');
972
+ }
973
+ if (optimizationPolicy.routingRequested && !optimizationPolicy.betaModelRouting) {
974
+ console.warn('⚠️ Model routing is beta and remains disabled. Add --beta-model-routing or optimization.beta.modelRouting=true to enable it.');
975
+ }
867
976
  const outputDir = resolveReportDir(tagConfig);
868
977
  process.env.TESTRONAUT_OUTPUT_DIR = outputDir;
869
978
  process.env.TESTRONAUT_RUN_ID = runId;
870
979
 
980
+ if (args[0] === 'select') {
981
+ const baseArg = args.find(arg => arg.startsWith('--base='));
982
+ const base = baseArg ? baseArg.slice('--base='.length) : 'HEAD~1';
983
+ const explicitChangedFiles = args
984
+ .filter(arg => arg.startsWith('--changed-file='))
985
+ .map(arg => arg.slice('--changed-file='.length))
986
+ .filter(Boolean);
987
+ let changedFiles = explicitChangedFiles;
988
+ if (!changedFiles.length) {
989
+ try {
990
+ const outputs = [
991
+ execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { cwd: process.cwd(), encoding: 'utf8' }),
992
+ execFileSync('git', ['diff', '--name-only'], { cwd: process.cwd(), encoding: 'utf8' }),
993
+ execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: process.cwd(), encoding: 'utf8' }),
994
+ ];
995
+ changedFiles = [...new Set(outputs.flatMap(output => output.split(/\r?\n/).filter(Boolean)))].sort();
996
+ } catch (error) {
997
+ writeOutput({ mode: 'jev-suite-selection', status: 'error', error: `Could not resolve git diff from ${base}: ${error.message}` });
998
+ process.exitCode = 1;
999
+ return;
1000
+ }
1001
+ }
1002
+
1003
+ const missions = [];
1004
+ for (const file of discoveredMissions) {
1005
+ try {
1006
+ const mission = await loadMissionModule(path.resolve(missionsRoot, file));
1007
+ missions.push({ file, tags: normalizeTags(mission.tags) });
1008
+ } catch (error) {
1009
+ missions.push({ file, tags: [], loadError: error.message });
1010
+ }
1011
+ }
1012
+ if (!missions.length) {
1013
+ writeOutput({ mode: 'jev-suite-selection', status: 'error', error: 'No missions were discovered.' });
1014
+ process.exitCode = 1;
1015
+ return;
1016
+ }
1017
+ const request = buildJevSuiteSelectionRequest({ changedFiles, missions });
1018
+ const config = { ...resolveJevShadowConfig(), enabled: true };
1019
+ const evaluation = await evaluateJevShadow(request.state, { config, questions: request.questions });
1020
+ const thresholdRaw = Number(process.env.TESTRONAUT_JEV_SELECTION_THRESHOLD);
1021
+ const threshold = Number.isFinite(thresholdRaw) && thresholdRaw >= 0 && thresholdRaw <= 1 ? thresholdRaw : 0.7;
1022
+ const selection = evaluation.status === 'ok'
1023
+ ? interpretJevSuiteSelection(evaluation.answers, request.catalog, threshold)
1024
+ : [];
1025
+ writeOutput({
1026
+ mode: 'jev-suite-selection',
1027
+ dryRun: true,
1028
+ base,
1029
+ changedFiles,
1030
+ threshold,
1031
+ evaluation: {
1032
+ status: evaluation.status,
1033
+ model: evaluation.model,
1034
+ usage: evaluation.usage,
1035
+ latencyMs: evaluation.latencyMs,
1036
+ reason: evaluation.reason,
1037
+ error: evaluation.error,
1038
+ },
1039
+ missions: selection,
1040
+ selected: selection.filter(mission => mission.selected).map(mission => mission.file),
1041
+ });
1042
+ if (evaluation.status !== 'ok') process.exitCode = 1;
1043
+ return;
1044
+ }
1045
+
871
1046
  if (args.length === 1 && args[0] === 'config') {
872
1047
  writeOutput(buildEffectiveConfig(tagConfig));
873
1048
  return;
@@ -974,14 +1149,19 @@ const runFile = async (filePath) => {
974
1149
 
975
1150
  if (typeof missionsModule.executeMission === 'function') {
976
1151
  if (!jsonOutput) terminalUi.phase('launch', path.relative(process.cwd(), modulePath) || filePath);
1152
+ const missionFileStartTime = Date.now();
977
1153
  const result = await missionsModule.executeMission();
1154
+ const missionFileEndTime = Date.now();
978
1155
  if (result == null) {
979
1156
  console.error(`❌ Mission did not return a result: ${filePath}`);
980
1157
  return false;
981
1158
  }
982
1159
  allResults.push({
983
1160
  file: path.relative(process.cwd(), modulePath) || path.basename(modulePath),
984
- result
1161
+ result,
1162
+ startTime: missionFileStartTime,
1163
+ endTime: missionFileEndTime,
1164
+ durationMs: Math.max(0, missionFileEndTime - missionFileStartTime),
985
1165
  });
986
1166
  const missionResults = Array.isArray(result) ? result : [result];
987
1167
  return !missionResults.some(item => item?.status === 'failed');
@@ -1032,28 +1212,40 @@ const flatMissions = allResults.flatMap(entry => {
1032
1212
  steps = mergeDuplicateTurns(fromJsonl);
1033
1213
  }
1034
1214
  }
1035
- return {
1215
+ const mission = {
1036
1216
  ...m,
1037
1217
  steps,
1038
1218
  file: entry.file,
1219
+ startTime: m.startTime ?? entry.startTime,
1220
+ endTime: m.endTime ?? entry.endTime,
1221
+ durationMs: m.durationMs ?? entry.durationMs,
1039
1222
  };
1223
+ const jevShadow = summarizeJevUsage(steps);
1224
+ return jevShadow.evaluations ? { ...mission, jevShadow } : mission;
1040
1225
  });
1041
1226
  });
1042
1227
 
1228
+ const allMissionSteps = flatMissions
1229
+ .flatMap(mission => Array.isArray(mission.steps) ? mission.steps : []);
1230
+ const jevShadowUsage = summarizeJevUsage(allMissionSteps);
1231
+
1043
1232
  // Read provider/model from config (allow env override)
1044
1233
  const { provider: llmProvider, model: llmModel } = resolveProviderModel({ cwd: process.cwd() });
1045
1234
 
1046
1235
 
1047
1236
  const report = {
1237
+ schemaVersion: 2,
1048
1238
  runId,
1049
1239
  cli: { version: CLI_VERSION },
1050
1240
  startTime: startTime.toISOString(),
1051
1241
  endTime: endTime.toISOString(),
1242
+ durationMs: Math.max(0, endTime.getTime() - startTime.getTime()),
1052
1243
  llm: {
1053
1244
  provider: llmProvider,
1054
1245
  model: llmModel,
1055
1246
  },
1056
1247
  session: { mode: sessionMode },
1248
+ optimization: optimizationPolicy,
1057
1249
  summary: {
1058
1250
  totalMissions: flatMissions.length,
1059
1251
  passed: flatMissions.filter(m => m.status === 'passed').length,
@@ -1061,6 +1253,41 @@ const report = {
1061
1253
  },
1062
1254
  missions: flatMissions
1063
1255
  };
1256
+ if (process.env.TESTRONAUT_EXPERIMENT_ID) {
1257
+ report.experiment = {
1258
+ id: process.env.TESTRONAUT_EXPERIMENT_ID,
1259
+ condition: process.env.TESTRONAUT_EXPERIMENT_CONDITION || null,
1260
+ block: Number(process.env.TESTRONAUT_EXPERIMENT_BLOCK) || null,
1261
+ unit: process.env.TESTRONAUT_EXPERIMENT_UNIT || null,
1262
+ selectedBy: process.env.TESTRONAUT_EXPERIMENT_SELECTED_BY || null,
1263
+ selectionScore: process.env.TESTRONAUT_EXPERIMENT_SELECTION_SCORE === ''
1264
+ ? null
1265
+ : Number(process.env.TESTRONAUT_EXPERIMENT_SELECTION_SCORE),
1266
+ };
1267
+ }
1268
+ if (jevShadowUsage.evaluations) {
1269
+ const gateMode = /^(1|true|yes|on)$/i.test(String(process.env.TESTRONAUT_JEV_GATE || ''));
1270
+ report.jevShadow = {
1271
+ mode: gateMode ? 'completion-gate' : 'shadow',
1272
+ ...jevShadowUsage,
1273
+ };
1274
+ const { strategies, ...guardrailUsage } = jevShadowUsage;
1275
+ report.jevGuardrails = {
1276
+ mode: gateMode ? 'live' : 'shadow',
1277
+ completionProbability: optimizationPolicy.completionProbability,
1278
+ failOpen: true,
1279
+ ...guardrailUsage,
1280
+ };
1281
+ if (optimizationPolicy.routingRequested) {
1282
+ report.modelRouting = {
1283
+ stability: 'beta',
1284
+ optedIn: optimizationPolicy.betaModelRouting,
1285
+ active: optimizationPolicy.routing,
1286
+ mode: optimizationPolicy.routing ? optimizationPolicy.mode : 'off',
1287
+ strategies,
1288
+ };
1289
+ }
1290
+ }
1064
1291
  report.tags = normalizeTags(flatMissions.flatMap(m =>
1065
1292
  m.submissionType === 'mission' ? (m.tags ?? []) : []
1066
1293
  ));
@@ -1070,7 +1297,7 @@ fs.writeFileSync(path.join(outputDir, `${runId}.json`), JSON.stringify(report, n
1070
1297
  const htmlReportPath = path.join(outputDir, `${runId}.html`);
1071
1298
  generateHtmlReport(report, htmlReportPath);
1072
1299
  if (jsonOutput) writeOutput(report);
1073
- else terminalUi.summary({ ...report.summary, reportPath: path.relative(process.cwd(), htmlReportPath) });
1300
+ else terminalUi.summary({ ...report.summary, durationMs: report.durationMs, reportPath: path.relative(process.cwd(), htmlReportPath) });
1074
1301
 
1075
1302
  try {
1076
1303
  if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
package/bin/terminalUi.js CHANGED
@@ -9,6 +9,12 @@ const ANSI = {
9
9
  cream: '\u001b[38;5;230m',
10
10
  };
11
11
 
12
+ export function formatDuration(durationMs) {
13
+ if (!Number.isFinite(durationMs)) return null;
14
+ const seconds = Math.max(0, durationMs) / 1000;
15
+ return seconds < 60 ? `${seconds.toFixed(2)}s` : `${Math.floor(seconds / 60)}m ${(seconds % 60).toFixed(1)}s`;
16
+ }
17
+
12
18
  export function shouldUseColor(stream = process.stdout, env = process.env) {
13
19
  return Boolean(stream?.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
14
20
  }
@@ -38,13 +44,15 @@ export function createTerminalUi({
38
44
  const tagText = tags.length ? paint(ANSI.dim, ` ${tags.map(tag => `#${tag}`).join(' ')}`) : '';
39
45
  write(` ${marker} ${file}${tagText}`);
40
46
  },
41
- summary({ totalMissions = 0, passed = 0, failed = 0, reportPath }) {
47
+ summary({ totalMissions = 0, passed = 0, failed = 0, durationMs, reportPath }) {
42
48
  const status = failed === 0
43
49
  ? paint(`${ANSI.bold}${ANSI.teal}`, 'MISSION COMPLETE')
44
50
  : paint(`${ANSI.bold}${ANSI.red}`, 'MISSION NEEDS REVIEW');
45
51
  write('');
46
52
  write(`${label('COMPLETE')}${status}`);
47
53
  write(` ${paint(ANSI.teal, `${passed} passed`)} ${paint(failed ? ANSI.red : ANSI.dim, `${failed} failed`)} ${paint(ANSI.dim, `${totalMissions} total`)}`);
54
+ const duration = formatDuration(durationMs);
55
+ if (duration) write(paint(ANSI.dim, ` Duration: ${duration}`));
48
56
  if (reportPath) write(paint(ANSI.dim, ` Report: ${reportPath}`));
49
57
  },
50
58
  };