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
package/README.md
CHANGED
|
@@ -374,6 +374,204 @@ Notes:
|
|
|
374
374
|
|
|
375
375
|
---
|
|
376
376
|
|
|
377
|
+
## π¬ Experimental Jev shadow mode
|
|
378
|
+
|
|
379
|
+
Jev shadow mode evaluates each agent turn without changing browser actions,
|
|
380
|
+
mission status, or exit codes. Its typed recommendations, latency, model, and
|
|
381
|
+
token usage are written to each step in the JSON report. A run-level
|
|
382
|
+
`jevShadow` summary aggregates completed calls, errors, latency, and tokens.
|
|
383
|
+
|
|
384
|
+
Enable it with a TypeSafe API key:
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
export TYPESAFE_API_KEY=YOUR_KEY
|
|
388
|
+
TESTRONAUT_JEV_SHADOW=1 testronaut login.mission.js
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Optional overrides:
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
export TESTRONAUT_JEV_MODEL=jev-latest
|
|
395
|
+
export TESTRONAUT_JEV_TIMEOUT_MS=5000
|
|
396
|
+
export TESTRONAUT_JEV_API_BASE=https://api.typesafe.ai
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The shadow request contains a bounded representation of the mission, assistant
|
|
400
|
+
response, redacted tool arguments, recent events, Ground Control state, and
|
|
401
|
+
structured browser evidence derived from DOM snapshots already collected by
|
|
402
|
+
Testronaut. Browser evidence includes the query-free URL, title, headings,
|
|
403
|
+
control labels, element counts, and value-free live control flags (`hasValue`,
|
|
404
|
+
`checked`, `disabled`, `readOnly`, and `visible`). It excludes raw DOM, body
|
|
405
|
+
text, input values and value lengths, URL queries, and URL fragments. Because this data is sent to a
|
|
406
|
+
third-party service, enable the experiment only for test data appropriate for that service.
|
|
407
|
+
API errors and timeouts are recorded as shadow telemetry and do not stop the
|
|
408
|
+
mission.
|
|
409
|
+
|
|
410
|
+
### Experimental completion gate
|
|
411
|
+
|
|
412
|
+
After validating shadow results, opt into early completion with:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
TESTRONAUT_JEV_GATE=1 testronaut login.mission.js
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The gate defaults to an 80% threshold and requires all three independent Jev
|
|
419
|
+
signalsβmission completion, complete progress, and finish-next probabilityβto
|
|
420
|
+
meet it. It also requires structured browser evidence and a clean tool turn.
|
|
421
|
+
Before returning success, Testronaut captures a final screenshot when
|
|
422
|
+
screenshots are enabled and records completion telemetry. Jev errors, missing
|
|
423
|
+
evidence, incomplete resource guards, and screenshot failures fail open to the
|
|
424
|
+
ordinary agent loop.
|
|
425
|
+
|
|
426
|
+
Use a different experimental threshold with, for example:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
TESTRONAUT_JEV_GATE=1 TESTRONAUT_JEV_GATE_THRESHOLD=0.85 testronaut login.mission.js
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
JSON reports retain the full Jev evaluation telemetry. HTML reports show the
|
|
433
|
+
models used and input/output token totals at run, mission, and turn levels
|
|
434
|
+
without displaying internal strategy counts or estimated cost. The JSON cost
|
|
435
|
+
estimate defaults to Jev's current `$0.042` per million input tokens. Override
|
|
436
|
+
the reporting rate without changing API behavior:
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
TESTRONAUT_JEV_INPUT_USD_PER_MILLION=0.042 testronaut
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
If the gate is enabled without an API key, the report highlights every skipped
|
|
443
|
+
evaluation. Ensure `TYPESAFE_API_KEY` or `TESTRONAUT_JEV_API_KEY` is exported in
|
|
444
|
+
the same process that launches Testronaut.
|
|
445
|
+
|
|
446
|
+
### Additional Jev savings experiments
|
|
447
|
+
|
|
448
|
+
Enable the batched shadow strategy bundle without changing mission behavior:
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
TESTRONAUT_JEV_STRATEGIES=1 testronaut
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
The same Jev request then records:
|
|
455
|
+
|
|
456
|
+
- relevant structured DOM regions for future context filtering;
|
|
457
|
+
- a choice among controls already enumerated by Testronaut;
|
|
458
|
+
- deterministic, fast-model, full-model, observe, or finish routing;
|
|
459
|
+
- postcondition verification probability;
|
|
460
|
+
- continue, retry, reobserve, escalate, or stop recovery policy;
|
|
461
|
+
- mission stage and prerequisite readiness.
|
|
462
|
+
|
|
463
|
+
Strategy results and aggregates appear in JSON reports. They remain advisory
|
|
464
|
+
unless a separate experiment such as dynamic routing is explicitly enabled.
|
|
465
|
+
|
|
466
|
+
### Stable Jev guardrails
|
|
467
|
+
|
|
468
|
+
Run Jev completion and evidence guardrails without allowing any model switch:
|
|
469
|
+
|
|
470
|
+
```bash
|
|
471
|
+
export TESTRONAUT_JEV_API_KEY="your-key"
|
|
472
|
+
testronaut --guardrails
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
This profile is fail-open: if Jev is unavailable, times out, or lacks sufficient
|
|
476
|
+
evidence, the primary model continues normally. It does not request the larger
|
|
477
|
+
experimental strategy question set and ignores fast-model routing configuration.
|
|
478
|
+
The default completion probability is 0.8.
|
|
479
|
+
|
|
480
|
+
```json
|
|
481
|
+
{
|
|
482
|
+
"guardrails": {
|
|
483
|
+
"enabled": true,
|
|
484
|
+
"mode": "live",
|
|
485
|
+
"completionProbability": 0.8
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Guardrails are opt-in and use a user-supplied Jev key. The earlier
|
|
491
|
+
`--optimize=tokens --optimization-mode=live` and nested optimization config
|
|
492
|
+
remain supported as compatibility forms. Run `testronaut config --json` to
|
|
493
|
+
check credential readiness without printing the key.
|
|
494
|
+
|
|
495
|
+
Reports expose stable `jevGuardrails` telemetry separately from beta
|
|
496
|
+
`modelRouting` telemetry. The legacy `jevShadow` field remains for compatibility.
|
|
497
|
+
|
|
498
|
+
### Beta dynamic model routing
|
|
499
|
+
|
|
500
|
+
Choose an optimization objective and whether it runs in shadow or live mode:
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
testronaut --optimize=cost --optimization-mode=live --beta-model-routing
|
|
504
|
+
testronaut --optimize=tokens --optimization-mode=shadow
|
|
505
|
+
testronaut --optimize=balanced --optimization-mode=live --beta-model-routing
|
|
506
|
+
testronaut --optimize=off
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
The supported objectives are `off`, `cost`, `tokens`, `balanced`, and `speed`.
|
|
510
|
+
`cost` requests cheaper family-local models; `tokens` uses stable guardrails
|
|
511
|
+
without model switching; `balanced` combines guardrails and beta routing; and
|
|
512
|
+
`speed` requests beta fast-model routing. The `cost`, `balanced`, and `speed`
|
|
513
|
+
objectives cannot switch models unless beta routing is explicitly enabled.
|
|
514
|
+
When an objective is supplied without a mode, it defaults to `shadow`.
|
|
515
|
+
|
|
516
|
+
The equivalent `testronaut-config.json` configuration is:
|
|
517
|
+
|
|
518
|
+
```json
|
|
519
|
+
{
|
|
520
|
+
"optimization": {
|
|
521
|
+
"objective": "balanced",
|
|
522
|
+
"mode": "live",
|
|
523
|
+
"guardrails": {
|
|
524
|
+
"completionProbability": 0.8,
|
|
525
|
+
"minimumRoutingProbability": 0.4
|
|
526
|
+
},
|
|
527
|
+
"beta": {
|
|
528
|
+
"modelRouting": true
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
CLI flags override configuration. Existing `TESTRONAUT_JEV_*` variables remain
|
|
535
|
+
available as lower-level experimental overrides.
|
|
536
|
+
|
|
537
|
+
Beta dynamic routing uses a high-confidence Jev `fast_model` recommendation from the
|
|
538
|
+
completed turn to choose the model for the next turn. Start in shadow mode to
|
|
539
|
+
record eligibility without changing models:
|
|
540
|
+
|
|
541
|
+
```bash
|
|
542
|
+
TESTRONAUT_JEV_ROUTING=shadow TESTRONAUT_JEV_FAST_MODEL=gpt-5.6-luna testronaut
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
After reviewing the recommendations, enable one-turn live routing:
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
TESTRONAUT_JEV_ROUTING=live TESTRONAUT_JEV_FAST_MODEL=gpt-5.6-luna testronaut
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
The family-local defaults are `gpt-5.6-luna` for OpenAI,
|
|
552
|
+
`gemini-3.5-flash-lite` for Gemini, and `claude-haiku-4-5` for Anthropic. An
|
|
553
|
+
explicit `TESTRONAUT_JEV_FAST_MODEL` must still belong to the active provider
|
|
554
|
+
family. Routing uses Jev's selected `fast_model` probability with a default
|
|
555
|
+
threshold of 40%; change it with `TESTRONAUT_JEV_ROUTING_THRESHOLD`.
|
|
556
|
+
Authentication, completion, failed,
|
|
557
|
+
uncertain recovery, and low-confidence turns stay on the primary model. If the
|
|
558
|
+
routed request failsβor a routed action requires a retryβTestronaut retries on
|
|
559
|
+
the primary model. Reports
|
|
560
|
+
record the actual model and tokens for every turn and split aggregate tokens by
|
|
561
|
+
model.
|
|
562
|
+
|
|
563
|
+
Evaluate risk-based suite selection separately as a dry run:
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
TYPESAFE_API_KEY=YOUR_KEY testronaut select --base=origin/main
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
This reads the git diff, asks one bounded relevance question per discovered
|
|
570
|
+
mission, and prints selected mission filenames without running or skipping any
|
|
571
|
+
tests. Use repeated `--changed-file=path` arguments instead of a git diff when
|
|
572
|
+
testing a hypothetical change. The default selection threshold is 70%; override
|
|
573
|
+
it with `TESTRONAUT_JEV_SELECTION_THRESHOLD`.
|
|
574
|
+
|
|
377
575
|
## π Reports
|
|
378
576
|
|
|
379
577
|
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';
|
|
@@ -57,6 +57,11 @@ import { discoverMissionFiles } from '../core/missionDiscovery.js';
|
|
|
57
57
|
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
|
+
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';
|
|
60
65
|
|
|
61
66
|
// Keep PW browsers inside the project to avoid global cache skew
|
|
62
67
|
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
|
|
@@ -78,6 +83,7 @@ const jsonOutput = args.includes('--json');
|
|
|
78
83
|
const quietOutput = args.includes('--quiet') || jsonOutput;
|
|
79
84
|
args = args.filter(arg => arg !== '--json' && arg !== '--quiet');
|
|
80
85
|
const writeOutput = value => process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`);
|
|
86
|
+
const terminalUi = createTerminalUi({ quiet: quietOutput });
|
|
81
87
|
if (quietOutput) {
|
|
82
88
|
console.log = () => {};
|
|
83
89
|
console.warn = () => {};
|
|
@@ -358,6 +364,7 @@ export const __test__ = {
|
|
|
358
364
|
createVercelBypassHeader,
|
|
359
365
|
parseProviderArgs,
|
|
360
366
|
parseRunOptionsArgs,
|
|
367
|
+
parseOptimizationArgs,
|
|
361
368
|
detectCliName,
|
|
362
369
|
isDirectInvocation,
|
|
363
370
|
resolveMissionPath,
|
|
@@ -387,6 +394,7 @@ function resolveReportFile(requested, reportDir, cwd = process.cwd()) {
|
|
|
387
394
|
|
|
388
395
|
function buildEffectiveConfig(config = {}, cwd = process.cwd()) {
|
|
389
396
|
const resolved = resolveProviderModel({ cwd });
|
|
397
|
+
const optimization = resolveOptimizationPolicy({ config });
|
|
390
398
|
const source = key => process.env[key] ? 'environment' : undefined;
|
|
391
399
|
return {
|
|
392
400
|
configFile: path.resolve(cwd, 'testronaut-config.json'),
|
|
@@ -399,6 +407,21 @@ function buildEffectiveConfig(config = {}, cwd = process.cwd()) {
|
|
|
399
407
|
addTags: normalizeTags([...(config.addTags || []), ...cliAddTags]),
|
|
400
408
|
screenshots: screenshotsEnabled,
|
|
401
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
|
+
},
|
|
402
425
|
};
|
|
403
426
|
}
|
|
404
427
|
|
|
@@ -506,6 +529,56 @@ function parseTagArgs(argsList) {
|
|
|
506
529
|
}
|
|
507
530
|
__test__.parseTagArgs = parseTagArgs;
|
|
508
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
|
+
|
|
509
582
|
// Look for --model=<id> or --model <id>
|
|
510
583
|
let modelOverride;
|
|
511
584
|
const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
|
|
@@ -532,6 +605,16 @@ if (providerResult.invalid) {
|
|
|
532
605
|
console.warn('β οΈ Invalid --provider value. Provide a non-empty provider id.');
|
|
533
606
|
}
|
|
534
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';
|
|
535
618
|
const providerOverride = providerResult.provider;
|
|
536
619
|
if (providerOverride) {
|
|
537
620
|
process.env.TESTRONAUT_PROVIDER = providerOverride.trim();
|
|
@@ -754,6 +837,7 @@ Usage:
|
|
|
754
837
|
${cliName} <file> Run a .mission.js or .mission.ts file by name or path
|
|
755
838
|
${cliName} login Log in and store session token
|
|
756
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)
|
|
757
841
|
${cliName} config Show effective configuration and value sources
|
|
758
842
|
${cliName} upload [file] Upload the latest report or a selected report file/run ID
|
|
759
843
|
${cliName} serve Serve & open the most recent HTML report (read-only)
|
|
@@ -765,6 +849,10 @@ Options:
|
|
|
765
849
|
--debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
|
|
766
850
|
--provider=<id> Override LLM provider (openai, gemini, or anthropic)
|
|
767
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
|
|
768
856
|
--session=<mode> Browser auth reuse: isolated (default) or shared-auth
|
|
769
857
|
-o, --options key=value Set run options, such as mfa=github-test-mfa or inbox=github-staging
|
|
770
858
|
--dev Use the staging API base URL
|
|
@@ -778,6 +866,8 @@ Options:
|
|
|
778
866
|
--no-upload-screenshots Upload report JSON without its screenshots
|
|
779
867
|
--json Emit machine-readable command/run output
|
|
780
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
|
|
781
871
|
--retry_limit=<n> Override agent turn retry limits (minimum 1, maximum 10)
|
|
782
872
|
--tag=<tag> Run missions matching a tag; repeat for multiple tags
|
|
783
873
|
--tags=<tag,...> Compact comma-list form (OR/any by default)
|
|
@@ -789,6 +879,7 @@ Examples:
|
|
|
789
879
|
${cliName}
|
|
790
880
|
${cliName} login
|
|
791
881
|
${cliName} list
|
|
882
|
+
${cliName} select --base=origin/main
|
|
792
883
|
${cliName} upload
|
|
793
884
|
${cliName} serve
|
|
794
885
|
${cliName} --init
|
|
@@ -862,10 +953,91 @@ if (args.includes('serve') || args.includes('view')) {
|
|
|
862
953
|
|
|
863
954
|
const { root: missionsRoot, files: discoveredMissions } = await discoverMissionFiles({ cwd: process.cwd() });
|
|
864
955
|
const tagConfig = await loadConfig(process.cwd());
|
|
956
|
+
const optimizationPolicy = resolveOptimizationPolicy({ config: tagConfig });
|
|
957
|
+
if (!optimizationPolicy.valid) {
|
|
958
|
+
console.error('β Invalid optimization objective or mode in testronaut-config.json.');
|
|
959
|
+
process.exit(1);
|
|
960
|
+
}
|
|
961
|
+
applyOptimizationPolicy(optimizationPolicy, process.env, {
|
|
962
|
+
force: Boolean(optimizationArgs.objective || optimizationArgs.mode || optimizationArgs.betaModelRouting),
|
|
963
|
+
});
|
|
964
|
+
const jevPreflight = resolveJevShadowConfig();
|
|
965
|
+
if (optimizationPolicy.objective === 'tokens' && optimizationPolicy.mode !== 'off' && !jevPreflight.apiKey) {
|
|
966
|
+
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.');
|
|
967
|
+
}
|
|
968
|
+
if (optimizationPolicy.routingRequested && !optimizationPolicy.betaModelRouting) {
|
|
969
|
+
console.warn('β οΈ Model routing is beta and remains disabled. Add --beta-model-routing or optimization.beta.modelRouting=true to enable it.');
|
|
970
|
+
}
|
|
865
971
|
const outputDir = resolveReportDir(tagConfig);
|
|
866
972
|
process.env.TESTRONAUT_OUTPUT_DIR = outputDir;
|
|
867
973
|
process.env.TESTRONAUT_RUN_ID = runId;
|
|
868
974
|
|
|
975
|
+
if (args[0] === 'select') {
|
|
976
|
+
const baseArg = args.find(arg => arg.startsWith('--base='));
|
|
977
|
+
const base = baseArg ? baseArg.slice('--base='.length) : 'HEAD~1';
|
|
978
|
+
const explicitChangedFiles = args
|
|
979
|
+
.filter(arg => arg.startsWith('--changed-file='))
|
|
980
|
+
.map(arg => arg.slice('--changed-file='.length))
|
|
981
|
+
.filter(Boolean);
|
|
982
|
+
let changedFiles = explicitChangedFiles;
|
|
983
|
+
if (!changedFiles.length) {
|
|
984
|
+
try {
|
|
985
|
+
const outputs = [
|
|
986
|
+
execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { cwd: process.cwd(), encoding: 'utf8' }),
|
|
987
|
+
execFileSync('git', ['diff', '--name-only'], { cwd: process.cwd(), encoding: 'utf8' }),
|
|
988
|
+
execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: process.cwd(), encoding: 'utf8' }),
|
|
989
|
+
];
|
|
990
|
+
changedFiles = [...new Set(outputs.flatMap(output => output.split(/\r?\n/).filter(Boolean)))].sort();
|
|
991
|
+
} catch (error) {
|
|
992
|
+
writeOutput({ mode: 'jev-suite-selection', status: 'error', error: `Could not resolve git diff from ${base}: ${error.message}` });
|
|
993
|
+
process.exitCode = 1;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const missions = [];
|
|
999
|
+
for (const file of discoveredMissions) {
|
|
1000
|
+
try {
|
|
1001
|
+
const mission = await loadMissionModule(path.resolve(missionsRoot, file));
|
|
1002
|
+
missions.push({ file, tags: normalizeTags(mission.tags) });
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
missions.push({ file, tags: [], loadError: error.message });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (!missions.length) {
|
|
1008
|
+
writeOutput({ mode: 'jev-suite-selection', status: 'error', error: 'No missions were discovered.' });
|
|
1009
|
+
process.exitCode = 1;
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const request = buildJevSuiteSelectionRequest({ changedFiles, missions });
|
|
1013
|
+
const config = { ...resolveJevShadowConfig(), enabled: true };
|
|
1014
|
+
const evaluation = await evaluateJevShadow(request.state, { config, questions: request.questions });
|
|
1015
|
+
const thresholdRaw = Number(process.env.TESTRONAUT_JEV_SELECTION_THRESHOLD);
|
|
1016
|
+
const threshold = Number.isFinite(thresholdRaw) && thresholdRaw >= 0 && thresholdRaw <= 1 ? thresholdRaw : 0.7;
|
|
1017
|
+
const selection = evaluation.status === 'ok'
|
|
1018
|
+
? interpretJevSuiteSelection(evaluation.answers, request.catalog, threshold)
|
|
1019
|
+
: [];
|
|
1020
|
+
writeOutput({
|
|
1021
|
+
mode: 'jev-suite-selection',
|
|
1022
|
+
dryRun: true,
|
|
1023
|
+
base,
|
|
1024
|
+
changedFiles,
|
|
1025
|
+
threshold,
|
|
1026
|
+
evaluation: {
|
|
1027
|
+
status: evaluation.status,
|
|
1028
|
+
model: evaluation.model,
|
|
1029
|
+
usage: evaluation.usage,
|
|
1030
|
+
latencyMs: evaluation.latencyMs,
|
|
1031
|
+
reason: evaluation.reason,
|
|
1032
|
+
error: evaluation.error,
|
|
1033
|
+
},
|
|
1034
|
+
missions: selection,
|
|
1035
|
+
selected: selection.filter(mission => mission.selected).map(mission => mission.file),
|
|
1036
|
+
});
|
|
1037
|
+
if (evaluation.status !== 'ok') process.exitCode = 1;
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
869
1041
|
if (args.length === 1 && args[0] === 'config') {
|
|
870
1042
|
writeOutput(buildEffectiveConfig(tagConfig));
|
|
871
1043
|
return;
|
|
@@ -879,14 +1051,14 @@ if (args.length === 1 && args[0] === 'list') {
|
|
|
879
1051
|
const listedMissions = [];
|
|
880
1052
|
const listTags = cliTags ?? normalizeTags(tagConfig?.tags, { allowUntagged: true });
|
|
881
1053
|
const listTagMatch = normalizeTagMatch(cliTagMatch ?? tagConfig?.tagMatch);
|
|
882
|
-
if (!jsonOutput)
|
|
1054
|
+
if (!jsonOutput) terminalUi.phase('preflight', `Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}`);
|
|
883
1055
|
for (const file of discoveredMissions) {
|
|
884
1056
|
try {
|
|
885
1057
|
const mission = await loadMissionModule(path.resolve(missionsRoot, file));
|
|
886
1058
|
const tags = normalizeTags(mission.tags);
|
|
887
1059
|
const selected = matchesTagFilter(tags, listTags, listTagMatch);
|
|
888
1060
|
listedMissions.push({ file, tags, selected });
|
|
889
|
-
if (!jsonOutput)
|
|
1061
|
+
if (!jsonOutput) terminalUi.mission(selected, file, tags);
|
|
890
1062
|
} catch (error) {
|
|
891
1063
|
console.log(` ${file} [could not load: ${error.message}]`);
|
|
892
1064
|
process.exitCode = 1;
|
|
@@ -943,6 +1115,17 @@ if (dryRun) {
|
|
|
943
1115
|
return;
|
|
944
1116
|
}
|
|
945
1117
|
|
|
1118
|
+
if (!jsonOutput) {
|
|
1119
|
+
const previewModel = resolveProviderModel({ cwd: process.cwd() });
|
|
1120
|
+
terminalUi.banner({
|
|
1121
|
+
version: CLI_VERSION,
|
|
1122
|
+
provider: previewModel.provider,
|
|
1123
|
+
model: previewModel.model,
|
|
1124
|
+
session: sessionMode,
|
|
1125
|
+
});
|
|
1126
|
+
terminalUi.phase('preflight', `${explicitFiles ? args.length : discoveredMissions.length} mission file(s) queued`);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
946
1129
|
const runFile = async (filePath) => {
|
|
947
1130
|
const modulePath = resolveMissionPath(filePath, { cwd: process.cwd(), missionsRoot });
|
|
948
1131
|
try {
|
|
@@ -960,6 +1143,7 @@ const runFile = async (filePath) => {
|
|
|
960
1143
|
}
|
|
961
1144
|
|
|
962
1145
|
if (typeof missionsModule.executeMission === 'function') {
|
|
1146
|
+
if (!jsonOutput) terminalUi.phase('launch', path.relative(process.cwd(), modulePath) || filePath);
|
|
963
1147
|
const result = await missionsModule.executeMission();
|
|
964
1148
|
if (result == null) {
|
|
965
1149
|
console.error(`β Mission did not return a result: ${filePath}`);
|
|
@@ -1018,19 +1202,26 @@ const flatMissions = allResults.flatMap(entry => {
|
|
|
1018
1202
|
steps = mergeDuplicateTurns(fromJsonl);
|
|
1019
1203
|
}
|
|
1020
1204
|
}
|
|
1021
|
-
|
|
1205
|
+
const mission = {
|
|
1022
1206
|
...m,
|
|
1023
1207
|
steps,
|
|
1024
1208
|
file: entry.file,
|
|
1025
1209
|
};
|
|
1210
|
+
const jevShadow = summarizeJevUsage(steps);
|
|
1211
|
+
return jevShadow.evaluations ? { ...mission, jevShadow } : mission;
|
|
1026
1212
|
});
|
|
1027
1213
|
});
|
|
1028
1214
|
|
|
1215
|
+
const allMissionSteps = flatMissions
|
|
1216
|
+
.flatMap(mission => Array.isArray(mission.steps) ? mission.steps : []);
|
|
1217
|
+
const jevShadowUsage = summarizeJevUsage(allMissionSteps);
|
|
1218
|
+
|
|
1029
1219
|
// Read provider/model from config (allow env override)
|
|
1030
1220
|
const { provider: llmProvider, model: llmModel } = resolveProviderModel({ cwd: process.cwd() });
|
|
1031
1221
|
|
|
1032
1222
|
|
|
1033
1223
|
const report = {
|
|
1224
|
+
schemaVersion: 2,
|
|
1034
1225
|
runId,
|
|
1035
1226
|
cli: { version: CLI_VERSION },
|
|
1036
1227
|
startTime: startTime.toISOString(),
|
|
@@ -1040,6 +1231,7 @@ const report = {
|
|
|
1040
1231
|
model: llmModel,
|
|
1041
1232
|
},
|
|
1042
1233
|
session: { mode: sessionMode },
|
|
1234
|
+
optimization: optimizationPolicy,
|
|
1043
1235
|
summary: {
|
|
1044
1236
|
totalMissions: flatMissions.length,
|
|
1045
1237
|
passed: flatMissions.filter(m => m.status === 'passed').length,
|
|
@@ -1047,14 +1239,51 @@ const report = {
|
|
|
1047
1239
|
},
|
|
1048
1240
|
missions: flatMissions
|
|
1049
1241
|
};
|
|
1242
|
+
if (process.env.TESTRONAUT_EXPERIMENT_ID) {
|
|
1243
|
+
report.experiment = {
|
|
1244
|
+
id: process.env.TESTRONAUT_EXPERIMENT_ID,
|
|
1245
|
+
condition: process.env.TESTRONAUT_EXPERIMENT_CONDITION || null,
|
|
1246
|
+
block: Number(process.env.TESTRONAUT_EXPERIMENT_BLOCK) || null,
|
|
1247
|
+
unit: process.env.TESTRONAUT_EXPERIMENT_UNIT || null,
|
|
1248
|
+
selectedBy: process.env.TESTRONAUT_EXPERIMENT_SELECTED_BY || null,
|
|
1249
|
+
selectionScore: process.env.TESTRONAUT_EXPERIMENT_SELECTION_SCORE === ''
|
|
1250
|
+
? null
|
|
1251
|
+
: Number(process.env.TESTRONAUT_EXPERIMENT_SELECTION_SCORE),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
if (jevShadowUsage.evaluations) {
|
|
1255
|
+
const gateMode = /^(1|true|yes|on)$/i.test(String(process.env.TESTRONAUT_JEV_GATE || ''));
|
|
1256
|
+
report.jevShadow = {
|
|
1257
|
+
mode: gateMode ? 'completion-gate' : 'shadow',
|
|
1258
|
+
...jevShadowUsage,
|
|
1259
|
+
};
|
|
1260
|
+
const { strategies, ...guardrailUsage } = jevShadowUsage;
|
|
1261
|
+
report.jevGuardrails = {
|
|
1262
|
+
mode: gateMode ? 'live' : 'shadow',
|
|
1263
|
+
completionProbability: optimizationPolicy.completionProbability,
|
|
1264
|
+
failOpen: true,
|
|
1265
|
+
...guardrailUsage,
|
|
1266
|
+
};
|
|
1267
|
+
if (optimizationPolicy.routingRequested) {
|
|
1268
|
+
report.modelRouting = {
|
|
1269
|
+
stability: 'beta',
|
|
1270
|
+
optedIn: optimizationPolicy.betaModelRouting,
|
|
1271
|
+
active: optimizationPolicy.routing,
|
|
1272
|
+
mode: optimizationPolicy.routing ? optimizationPolicy.mode : 'off',
|
|
1273
|
+
strategies,
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1050
1277
|
report.tags = normalizeTags(flatMissions.flatMap(m =>
|
|
1051
1278
|
m.submissionType === 'mission' ? (m.tags ?? []) : []
|
|
1052
1279
|
));
|
|
1053
1280
|
|
|
1054
1281
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
1055
1282
|
fs.writeFileSync(path.join(outputDir, `${runId}.json`), JSON.stringify(report, null, 2));
|
|
1056
|
-
|
|
1283
|
+
const htmlReportPath = path.join(outputDir, `${runId}.html`);
|
|
1284
|
+
generateHtmlReport(report, htmlReportPath);
|
|
1057
1285
|
if (jsonOutput) writeOutput(report);
|
|
1286
|
+
else terminalUi.summary({ ...report.summary, reportPath: path.relative(process.cwd(), htmlReportPath) });
|
|
1058
1287
|
|
|
1059
1288
|
try {
|
|
1060
1289
|
if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const ANSI = {
|
|
2
|
+
reset: '\u001b[0m',
|
|
3
|
+
bold: '\u001b[1m',
|
|
4
|
+
dim: '\u001b[2m',
|
|
5
|
+
orange: '\u001b[38;5;208m',
|
|
6
|
+
cyan: '\u001b[38;5;81m',
|
|
7
|
+
teal: '\u001b[38;5;43m',
|
|
8
|
+
red: '\u001b[38;5;203m',
|
|
9
|
+
cream: '\u001b[38;5;230m',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function shouldUseColor(stream = process.stdout, env = process.env) {
|
|
13
|
+
return Boolean(stream?.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createTerminalUi({
|
|
17
|
+
stream = process.stdout,
|
|
18
|
+
color = shouldUseColor(stream),
|
|
19
|
+
quiet = false,
|
|
20
|
+
} = {}) {
|
|
21
|
+
const paint = (codes, value) => color ? `${codes}${value}${ANSI.reset}` : String(value);
|
|
22
|
+
const write = value => {
|
|
23
|
+
if (!quiet) stream.write(`${value}\n`);
|
|
24
|
+
};
|
|
25
|
+
const label = value => paint(`${ANSI.bold}${ANSI.orange}`, value.padEnd(10));
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
banner({ version, provider, model, session }) {
|
|
29
|
+
write(paint(`${ANSI.bold}${ANSI.cream}`, 'π§βπ TESTRONAUT / MISSION RUNNER'));
|
|
30
|
+
write(paint(ANSI.dim, ` v${version} β’ ${provider}/${model} β’ ${session}`));
|
|
31
|
+
write(paint(ANSI.dim, ' βββββββββββββββββββββββββββββββββββββββββ'));
|
|
32
|
+
},
|
|
33
|
+
phase(name, message) {
|
|
34
|
+
write(`${label(name.toUpperCase())}${paint(ANSI.cyan, message)}`);
|
|
35
|
+
},
|
|
36
|
+
mission(selected, file, tags = []) {
|
|
37
|
+
const marker = selected ? paint(ANSI.teal, 'β') : paint(ANSI.dim, 'β');
|
|
38
|
+
const tagText = tags.length ? paint(ANSI.dim, ` ${tags.map(tag => `#${tag}`).join(' ')}`) : '';
|
|
39
|
+
write(` ${marker} ${file}${tagText}`);
|
|
40
|
+
},
|
|
41
|
+
summary({ totalMissions = 0, passed = 0, failed = 0, reportPath }) {
|
|
42
|
+
const status = failed === 0
|
|
43
|
+
? paint(`${ANSI.bold}${ANSI.teal}`, 'MISSION COMPLETE')
|
|
44
|
+
: paint(`${ANSI.bold}${ANSI.red}`, 'MISSION NEEDS REVIEW');
|
|
45
|
+
write('');
|
|
46
|
+
write(`${label('COMPLETE')}${status}`);
|
|
47
|
+
write(` ${paint(ANSI.teal, `${passed} passed`)} ${paint(failed ? ANSI.red : ANSI.dim, `${failed} failed`)} ${paint(ANSI.dim, `${totalMissions} total`)}`);
|
|
48
|
+
if (reportPath) write(paint(ANSI.dim, ` Report: ${reportPath}`));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|