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/core/agent.js CHANGED
@@ -59,6 +59,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
59
59
  const groundControl = createEmptyGroundControl();
60
60
 
61
61
  for (const goal of goals) {
62
+ const goalStartTime = Date.now();
62
63
  const telemetryStart = groundControl.telemetry.length;
63
64
  const steps = [];
64
65
  const stepsArchive = [];
@@ -114,6 +115,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
114
115
  stepFile,
115
116
  startTime: now,
116
117
  endTime: now,
118
+ durationMs: 0,
117
119
  telemetry: [],
118
120
  launchProtocol: { id: protocol.id, status: 'reused', probe: 'passed' },
119
121
  });
@@ -259,6 +261,7 @@ Email invitation and magic links:
259
261
  steps,
260
262
  stepsArchive,
261
263
  missionName,
264
+ goal: userContent,
262
265
  retryLimit,
263
266
  groundControl,
264
267
  resourceGuard: opts.resourceGuard,
@@ -288,6 +291,7 @@ Email invitation and magic links:
288
291
  }
289
292
 
290
293
  const compact = dedupeSteps(stepsArchive.length ? stepsArchive : steps);
294
+ const goalEndTime = Date.now();
291
295
 
292
296
  // Snapshot steps (avoid retaining references to mutable arrays)
293
297
  missionResults.push({
@@ -297,8 +301,9 @@ Email invitation and magic links:
297
301
  status: result?.success ? 'passed' : 'failed',
298
302
  steps: JSON.parse(JSON.stringify(compact)), // last 20 (by memory design)
299
303
  stepFile, // full history is in this JSONL (one step per line)
300
- startTime: Date.now() - 1, // TODO: wire actual timings if needed
301
- endTime: Date.now(),
304
+ startTime: goalStartTime,
305
+ endTime: goalEndTime,
306
+ durationMs: Math.max(0, goalEndTime - goalStartTime),
302
307
  telemetry: JSON.parse(JSON.stringify(groundControl.telemetry.slice(telemetryStart))),
303
308
  ...(protocolResult ? { launchProtocol: protocolResult } : {}),
304
309
  });
@@ -0,0 +1,184 @@
1
+ import * as cheerio from 'cheerio';
2
+
3
+ function compact(value, maxLength = 160) {
4
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
5
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
6
+ }
7
+
8
+ function safeUrl(value) {
9
+ try {
10
+ const url = new URL(String(value));
11
+ return `${url.origin}${url.pathname}`;
12
+ } catch {
13
+ return compact(String(value ?? '').split(/[?#]/, 1)[0], 500) || null;
14
+ }
15
+ }
16
+
17
+ function unique(values, limit) {
18
+ return [...new Set(values.filter(Boolean))].slice(0, limit);
19
+ }
20
+
21
+ function controlLabel($, element) {
22
+ const node = $(element);
23
+ const id = node.attr('id');
24
+ const explicitLabel = id
25
+ ? $('label').filter((_, label) => $(label).attr('for') === id).first().text()
26
+ : '';
27
+ return compact(
28
+ node.attr('aria-label')
29
+ || explicitLabel
30
+ || node.closest('label').text()
31
+ || node.attr('placeholder')
32
+ || node.text(),
33
+ 120,
34
+ );
35
+ }
36
+
37
+ function safeSelectorAttribute(value) {
38
+ return compact(value, 100).replace(/[^A-Za-z0-9_.:-]/g, '');
39
+ }
40
+
41
+ function selectorFor($, element, fallbackIndex) {
42
+ const node = $(element);
43
+ const id = safeSelectorAttribute(node.attr('id'));
44
+ if (id) return `#${id}`;
45
+ const tag = String(element.tagName || element.name || 'section').toLowerCase();
46
+ return `${tag}:nth-of-type(${fallbackIndex + 1})`;
47
+ }
48
+
49
+ /**
50
+ * Extract bounded, value-free evidence from a DOM snapshot already collected by
51
+ * Testronaut. Input values, query strings, hashes, and body text are omitted.
52
+ */
53
+ export function buildBrowserEvidence(html, {
54
+ url,
55
+ previous = null,
56
+ redactText = value => String(value ?? ''),
57
+ } = {}) {
58
+ const currentUrl = safeUrl(url) || previous?.url || null;
59
+ const routeChanged = Boolean(previous?.url && currentUrl && previous.url !== currentUrl);
60
+ const base = !routeChanged && previous ? { ...previous } : {};
61
+ const evidence = {
62
+ ...base,
63
+ url: currentUrl,
64
+ previousUrl: routeChanged ? previous.url : (previous?.previousUrl || null),
65
+ routeChanged,
66
+ };
67
+
68
+ if (typeof html !== 'string' || !html.includes('<')) return evidence;
69
+
70
+ const $ = cheerio.load(html);
71
+ const redact = value => compact(redactText(compact(value)), 160);
72
+ const headings = unique(
73
+ $('h1, h2, h3').map((_, element) => redact($(element).text())).get(),
74
+ 12,
75
+ );
76
+ const controls = [];
77
+ $('button, a[href], input, select, textarea, [role="button"], [role="link"]').each((_, element) => {
78
+ if (controls.length >= 20) return;
79
+ const node = $(element);
80
+ const tag = String(element.tagName || element.name || '').toLowerCase();
81
+ const type = compact(node.attr('type'), 40);
82
+ controls.push({
83
+ tag,
84
+ ...(node.attr('role') ? { role: compact(node.attr('role'), 40) } : {}),
85
+ ...(type ? { type } : {}),
86
+ ...(controlLabel($, element) ? { label: redact(controlLabel($, element)) } : {}),
87
+ ...(node.attr('id') ? { id: safeSelectorAttribute(node.attr('id')) } : {}),
88
+ ...(node.attr('name') ? { name: safeSelectorAttribute(node.attr('name')) } : {}),
89
+ ...(node.attr('href') ? { href: safeUrl(node.attr('href')) } : {}),
90
+ });
91
+ });
92
+ const regions = [];
93
+ $('header, nav, main, aside, footer, form, table, [role="dialog"], [role="main"], [role="navigation"]').each((index, element) => {
94
+ if (regions.length >= 12) return;
95
+ const node = $(element);
96
+ const kind = node.attr('role') || String(element.tagName || element.name || 'region').toLowerCase();
97
+ const regionHeadings = unique(
98
+ node.find('h1, h2, h3').map((_, heading) => redact($(heading).text())).get(),
99
+ 4,
100
+ );
101
+ const regionControls = unique(
102
+ node.find('button, a[href], input, select, textarea, [role="button"], [role="link"]')
103
+ .map((_, control) => redact(controlLabel($, control))).get(),
104
+ 8,
105
+ );
106
+ regions.push({
107
+ id: `region_${regions.length + 1}`,
108
+ kind: compact(kind, 40),
109
+ selector: selectorFor($, element, index),
110
+ headings: regionHeadings,
111
+ controls: regionControls,
112
+ counts: {
113
+ headings: node.find('h1, h2, h3').length,
114
+ controls: node.find('button, a[href], input, select, textarea, [role="button"], [role="link"]').length,
115
+ },
116
+ });
117
+ });
118
+
119
+ return {
120
+ ...evidence,
121
+ title: redact($('title').first().text()),
122
+ headings,
123
+ controls,
124
+ regions,
125
+ counts: {
126
+ headings: $('h1, h2, h3').length,
127
+ forms: $('form').length,
128
+ buttons: $('button, [role="button"]').length,
129
+ links: $('a[href], [role="link"]').length,
130
+ inputs: $('input, select, textarea').length,
131
+ },
132
+ };
133
+ }
134
+
135
+ export function mergeBrowserControlState(evidence, states = []) {
136
+ if (!evidence || !Array.isArray(evidence.controls)) return evidence;
137
+ return {
138
+ ...evidence,
139
+ controls: evidence.controls.map((control, index) => {
140
+ const state = states[index];
141
+ if (!state) return control;
142
+ return {
143
+ ...control,
144
+ hasValue: Boolean(state.hasValue),
145
+ checked: Boolean(state.checked),
146
+ disabled: Boolean(state.disabled),
147
+ readOnly: Boolean(state.readOnly),
148
+ visible: Boolean(state.visible),
149
+ };
150
+ }),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Read only boolean control state from the live page. Values and value lengths
156
+ * never leave the browser context.
157
+ */
158
+ export async function collectBrowserControlState(browser) {
159
+ try {
160
+ const locator = browser?.page?.locator?.(
161
+ 'button, a[href], input, select, textarea, [role="button"], [role="link"]',
162
+ );
163
+ if (!locator?.evaluateAll) return [];
164
+ return await locator.evaluateAll(elements => elements.slice(0, 20).map(element => {
165
+ const tag = element.tagName.toLowerCase();
166
+ const style = getComputedStyle(element);
167
+ const rect = element.getBoundingClientRect();
168
+ const supportsValue = tag === 'input' || tag === 'select' || tag === 'textarea';
169
+ const inputType = tag === 'input' ? String(element.type || '').toLowerCase() : '';
170
+ const supportsChecked = tag === 'input' && ['checkbox', 'radio'].includes(inputType);
171
+ return {
172
+ hasValue: supportsValue ? String(element.value ?? '').length > 0 : false,
173
+ checked: supportsChecked ? Boolean(element.checked) : false,
174
+ disabled: 'disabled' in element ? Boolean(element.disabled) : element.getAttribute('aria-disabled') === 'true',
175
+ readOnly: 'readOnly' in element ? Boolean(element.readOnly) : false,
176
+ visible: rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden',
177
+ };
178
+ }));
179
+ } catch {
180
+ return [];
181
+ }
182
+ }
183
+
184
+ export const __browserEvidenceInternals = { compact, safeUrl };
@@ -0,0 +1,77 @@
1
+ const DEFAULT_CONFIDENCE_THRESHOLD = 0.4;
2
+
3
+ function probability(value, fallback) {
4
+ const parsed = Number(value);
5
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : fallback;
6
+ }
7
+
8
+ export function resolveJevRoutingConfig(env = process.env, { provider, primaryModel } = {}) {
9
+ const rawMode = String(env.TESTRONAUT_JEV_ROUTING || 'off').trim().toLowerCase();
10
+ const mode = ['shadow', 'live'].includes(rawMode) ? rawMode : 'off';
11
+ const providerKey = String(provider).toLowerCase();
12
+ const familyDefault = {
13
+ openai: 'gpt-5.6-luna',
14
+ gemini: 'gemini-3.5-flash-lite',
15
+ anthropic: 'claude-haiku-4-5',
16
+ claude: 'claude-haiku-4-5',
17
+ }[providerKey] || '';
18
+ const defaultFastModel = primaryModel === familyDefault ? '' : familyDefault;
19
+ return {
20
+ mode,
21
+ fastModel: String(env.TESTRONAUT_JEV_FAST_MODEL || defaultFastModel).trim(),
22
+ confidenceThreshold: probability(
23
+ env.TESTRONAUT_JEV_ROUTING_THRESHOLD,
24
+ DEFAULT_CONFIDENCE_THRESHOLD,
25
+ ),
26
+ };
27
+ }
28
+
29
+ function belongsToProvider(model, provider) {
30
+ const value = String(model || '').toLowerCase();
31
+ switch (String(provider || '').toLowerCase()) {
32
+ case 'openai': return /^(gpt-|o\d)/.test(value);
33
+ case 'gemini': return value.startsWith('gemini-');
34
+ case 'anthropic':
35
+ case 'claude': return value.startsWith('claude-');
36
+ default: return true;
37
+ }
38
+ }
39
+
40
+ export function chooseJevTurnModel(strategy, config, { provider, primaryModel } = {}) {
41
+ const route = strategy?.modelRoute;
42
+ const score = Number(route?.probabilities?.fast_model);
43
+ const confidence = Number(route?.confidence);
44
+ const recommendation = route?.choice || null;
45
+ const stage = strategy?.missionStage?.choice || null;
46
+ const recovery = strategy?.recovery?.choice || null;
47
+ const blockedReason =
48
+ config.mode === 'off' ? 'routing_off' :
49
+ recommendation !== 'fast_model' ? 'not_fast_model' :
50
+ !config.fastModel ? 'fast_model_unconfigured' :
51
+ !belongsToProvider(config.fastModel, provider) ? 'fast_model_provider_mismatch' :
52
+ config.fastModel === primaryModel ? 'fast_model_matches_primary' :
53
+ !Number.isFinite(score) || score < config.confidenceThreshold ? 'low_probability' :
54
+ ['authenticate', 'failed', 'complete'].includes(stage) ? `stage_${stage}` :
55
+ (recovery && recovery !== 'continue') ? `recovery_${recovery}` :
56
+ null;
57
+ const eligible = !blockedReason;
58
+ const applied = eligible && config.mode === 'live';
59
+
60
+ return {
61
+ mode: config.mode,
62
+ provider,
63
+ primaryModel,
64
+ fastModel: config.fastModel || null,
65
+ selectedModel: applied ? config.fastModel : primaryModel,
66
+ recommendation,
67
+ confidence: Number.isFinite(confidence) ? confidence : null,
68
+ score: Number.isFinite(score) ? score : null,
69
+ scoreType: 'fast_model_probability',
70
+ threshold: config.confidenceThreshold,
71
+ eligible,
72
+ applied,
73
+ reason: blockedReason || (applied ? 'jev_fast_model' : 'shadow_only'),
74
+ };
75
+ }
76
+
77
+ export const __jevRoutingInternals = { DEFAULT_CONFIDENCE_THRESHOLD, probability, belongsToProvider };
@@ -0,0 +1,325 @@
1
+ const DEFAULT_API_BASE = 'https://api.typesafe.ai';
2
+ const DEFAULT_MODEL = 'jev-latest';
3
+ const DEFAULT_TIMEOUT_MS = 5000;
4
+ const DEFAULT_GATE_THRESHOLD = 0.8;
5
+
6
+ const SHADOW_QUESTIONS = {
7
+ missionComplete: {
8
+ type: 'noul',
9
+ instructions: 'Is there enough evidence that the stated browser-testing mission is complete?',
10
+ criteria: {
11
+ true: 'The goal is demonstrably satisfied by the current state or observed results.',
12
+ false: 'The goal is incomplete, failed, uncertain, or still requires verification.',
13
+ },
14
+ },
15
+ progressState: {
16
+ type: 'choice',
17
+ instructions: 'Classify the current progress of this browser-testing mission.',
18
+ criteria: {
19
+ complete: 'The mission is demonstrably complete.',
20
+ advancing: 'Recent actions made useful progress, but more work is required.',
21
+ stalled: 'The agent is repeating, blocked, or not making useful progress.',
22
+ failed: 'The available evidence shows the mission cannot or did not complete.',
23
+ uncertain: 'There is not enough evidence to classify progress reliably.',
24
+ },
25
+ },
26
+ nextCompute: {
27
+ type: 'choice',
28
+ instructions: 'What kind of computation should Testronaut use next?',
29
+ criteria: {
30
+ finish: 'No more browser-agent reasoning is needed.',
31
+ deterministic_action: 'A known browser action can run without generative reasoning.',
32
+ observe: 'More browser state should be collected before deciding.',
33
+ full_reasoning: 'A generative reasoning model is needed to decide or construct the next action.',
34
+ stop_failed: 'The mission should stop as failed rather than spend more tokens.',
35
+ },
36
+ },
37
+ };
38
+
39
+ const STRATEGY_QUESTIONS = {
40
+ modelRoute: {
41
+ type: 'choice',
42
+ instructions: 'Choose the least expensive reliable computation for the next turn.',
43
+ criteria: {
44
+ deterministic: 'The exact action and every required argument are already constructed in state; code can execute it directly.',
45
+ fast_model: 'The relevant control or local intent is apparent, but a short tool choice, known-value mapping, or localized inference is still needed.',
46
+ full_model: 'Multi-step planning, broad ambiguity, novel content, or difficult recovery requires frontier reasoning.',
47
+ observe: 'Collect more browser evidence before choosing.',
48
+ finish: 'The mission is complete and should finish.',
49
+ },
50
+ },
51
+ postconditionSatisfied: {
52
+ type: 'noul',
53
+ instructions: 'Did the most recent browser action satisfy its intended postcondition?',
54
+ criteria: {
55
+ true: 'Structured browser evidence shows the intended state change or result.',
56
+ false: 'The result is absent, contradicted, failed, or cannot yet be verified.',
57
+ },
58
+ },
59
+ recoveryRoute: {
60
+ type: 'choice',
61
+ instructions: 'Choose the best recovery policy for the current mission state.',
62
+ criteria: {
63
+ continue: 'Progress is healthy; continue normally.',
64
+ retry_action: 'Retry the most recent action once.',
65
+ reobserve: 'Refresh browser evidence before deciding.',
66
+ escalate: 'Use the full reasoning model to recover.',
67
+ stop_failed: 'Further work is unlikely to succeed; stop as failed.',
68
+ },
69
+ },
70
+ missionStage: {
71
+ type: 'choice',
72
+ instructions: 'Classify the current stage of this browser mission.',
73
+ criteria: {
74
+ setup: 'Preparing browser or initial state.',
75
+ navigate: 'Navigating to the target area.',
76
+ authenticate: 'Establishing or verifying authentication.',
77
+ interact: 'Performing the mission-specific user actions.',
78
+ verify: 'Checking that the requested outcome occurred.',
79
+ complete: 'The mission outcome is complete.',
80
+ failed: 'The mission cannot complete.',
81
+ },
82
+ },
83
+ prerequisiteSatisfied: {
84
+ type: 'noul',
85
+ instructions: 'Does the current browser state already satisfy the mission prerequisite or required starting state?',
86
+ criteria: {
87
+ true: 'The required session, route, and starting conditions are already present.',
88
+ false: 'Setup, navigation, authentication, or other prerequisite work remains.',
89
+ },
90
+ },
91
+ };
92
+
93
+ function truthy(value) {
94
+ return /^(1|true|yes|on)$/i.test(String(value ?? '').trim());
95
+ }
96
+
97
+ function positiveInteger(value, fallback) {
98
+ const parsed = Number(value);
99
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
100
+ }
101
+
102
+ function probability(value, fallback) {
103
+ const parsed = Number(value);
104
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : fallback;
105
+ }
106
+
107
+ function trimTrailingSlash(value) {
108
+ return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
109
+ }
110
+
111
+ function compactText(value, maxLength = 2000) {
112
+ const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
113
+ if (text.length <= maxLength) return text;
114
+ return `${text.slice(0, maxLength)}…[truncated ${text.length - maxLength} chars]`;
115
+ }
116
+
117
+ export function resolveJevShadowConfig(env = process.env) {
118
+ const gateEnabled = truthy(env.TESTRONAUT_JEV_GATE);
119
+ const routingEnabled = /^(shadow|live)$/i.test(String(env.TESTRONAUT_JEV_ROUTING || '').trim());
120
+ const strategiesEnabled = truthy(env.TESTRONAUT_JEV_STRATEGIES) || routingEnabled;
121
+ return {
122
+ enabled: truthy(env.TESTRONAUT_JEV_SHADOW) || gateEnabled || strategiesEnabled,
123
+ gateEnabled,
124
+ gateThreshold: probability(env.TESTRONAUT_JEV_GATE_THRESHOLD, DEFAULT_GATE_THRESHOLD),
125
+ strategiesEnabled,
126
+ apiKey: env.TESTRONAUT_JEV_API_KEY || env.TYPESAFE_API_KEY || env.JEV_API_KEY || '',
127
+ apiBase: trimTrailingSlash(env.TESTRONAUT_JEV_API_BASE || env.TYPESAFE_BASE_URL),
128
+ model: env.TESTRONAUT_JEV_MODEL || env.TYPESAFE_DEFAULT_MODEL || DEFAULT_MODEL,
129
+ timeoutMs: positiveInteger(env.TESTRONAUT_JEV_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
130
+ };
131
+ }
132
+
133
+ function actionDescription(control = {}) {
134
+ const selector = control.id ? `#${control.id}` : control.name ? `[name="${control.name}"]` : '';
135
+ const label = control.label ? ` “${control.label}”` : '';
136
+ const state = [
137
+ control.hasValue ? 'currently populated' : 'currently empty',
138
+ control.checked ? 'checked' : '',
139
+ control.disabled ? 'disabled' : 'enabled',
140
+ control.visible === false ? 'not visible' : 'visible',
141
+ ].filter(Boolean).join(', ');
142
+ if (['input', 'select', 'textarea'].includes(control.tag)) {
143
+ return `Fill or select ${control.tag}${label}${selector ? ` at ${selector}` : ''} using an already-known mission value. Control state: ${state}. Do not refill an already populated control unless replacement is required.`;
144
+ }
145
+ return `Click ${control.role || control.tag || 'control'}${label}${selector ? ` at ${selector}` : ''}. Control state: ${state}.`;
146
+ }
147
+
148
+ export function buildJevStrategyQuestions(browserEvidence = {}) {
149
+ const questions = { ...SHADOW_QUESTIONS, ...STRATEGY_QUESTIONS };
150
+ const regions = (browserEvidence.regions || []).slice(0, 12);
151
+ for (const region of regions) {
152
+ questions[`regionRelevant_${region.id}`] = {
153
+ type: 'noul',
154
+ instructions: `Is browser region ${region.id} relevant to deciding or executing the next mission action?`,
155
+ criteria: {
156
+ true: 'The region contains evidence or controls needed for the next action.',
157
+ false: 'The region can be omitted from the next generative-model context.',
158
+ },
159
+ };
160
+ }
161
+
162
+ const controls = (browserEvidence.controls || []).slice(0, 20);
163
+ const actionCriteria = {
164
+ observe: 'Collect more browser evidence before acting.',
165
+ full_reasoning: 'No listed action is safely executable without generative reasoning.',
166
+ };
167
+ controls.forEach((control, index) => {
168
+ actionCriteria[`action_${index + 1}`] = actionDescription(control);
169
+ });
170
+ if (controls.length) {
171
+ questions.actionChoice = {
172
+ type: 'choice',
173
+ instructions: 'Choose the safest next action for progressing the mission.',
174
+ criteria: actionCriteria,
175
+ };
176
+ }
177
+ return { questions, regions, controls };
178
+ }
179
+
180
+ export function extractJevStrategyResults(answers = {}, catalog = {}) {
181
+ const regionRelevance = (catalog.regions || []).map(region => ({
182
+ ...region,
183
+ probability: Number(answers[`regionRelevant_${region.id}`]?.noul),
184
+ }));
185
+ const actionAnswer = answers.actionChoice;
186
+ const actionIndex = String(actionAnswer?.choice || '').match(/^action_(\d+)$/)?.[1];
187
+ return {
188
+ domRelevance: regionRelevance,
189
+ actionChoice: actionAnswer ? {
190
+ ...actionAnswer,
191
+ selectedControl: actionIndex ? catalog.controls?.[Number(actionIndex) - 1] ?? null : null,
192
+ } : null,
193
+ modelRoute: answers.modelRoute ?? null,
194
+ verification: answers.postconditionSatisfied ?? null,
195
+ recovery: answers.recoveryRoute ?? null,
196
+ missionStage: answers.missionStage ?? null,
197
+ prerequisite: answers.prerequisiteSatisfied ?? null,
198
+ };
199
+ }
200
+
201
+ export function evaluateJevCompletionCandidate(result, {
202
+ threshold = DEFAULT_GATE_THRESHOLD,
203
+ browserEvidence,
204
+ } = {}) {
205
+ const answers = result?.status === 'ok' ? result.answers : null;
206
+ const signals = {
207
+ missionComplete: Number(answers?.missionComplete?.noul),
208
+ progressComplete: Number(answers?.progressState?.probabilities?.complete),
209
+ finish: Number(answers?.nextCompute?.probabilities?.finish),
210
+ };
211
+ const hasBrowserEvidence = Boolean(
212
+ browserEvidence?.title
213
+ || browserEvidence?.headings?.length
214
+ || browserEvidence?.controls?.length,
215
+ );
216
+ const values = Object.values(signals);
217
+ return {
218
+ candidate: Boolean(
219
+ hasBrowserEvidence
220
+ && values.every(value => Number.isFinite(value) && value >= threshold),
221
+ ),
222
+ threshold,
223
+ hasBrowserEvidence,
224
+ signals,
225
+ };
226
+ }
227
+
228
+ export function buildJevShadowState({
229
+ mission,
230
+ turn,
231
+ maxTurns,
232
+ phase,
233
+ assistantSummary,
234
+ assistantContent,
235
+ proposedTools = [],
236
+ events = [],
237
+ groundControl,
238
+ browserEvidence,
239
+ } = {}) {
240
+ return {
241
+ mission: compactText(mission, 3000),
242
+ turn: { current: Number(turn) + 1, maximum: maxTurns },
243
+ phase,
244
+ assistant: {
245
+ summary: compactText(assistantSummary, 1000),
246
+ content: compactText(assistantContent, 2000),
247
+ proposedTools: proposedTools.slice(0, 12).map(tool => ({
248
+ name: compactText(tool?.name, 120),
249
+ arguments: tool?.arguments,
250
+ })),
251
+ },
252
+ recentEvents: events.slice(-12).map(event => compactText(event, 500)),
253
+ groundControl: groundControl ?? null,
254
+ browserEvidence: browserEvidence ?? null,
255
+ };
256
+ }
257
+
258
+ export function sanitizeJevShadowEvents(events = [], redactText = value => String(value ?? '')) {
259
+ return events
260
+ .filter(event => !String(event).startsWith('[tool ] ←') || String(event).includes(' result:'))
261
+ .map(event => redactText(event));
262
+ }
263
+
264
+ export async function evaluateJevShadow(state, {
265
+ config = resolveJevShadowConfig(),
266
+ questions = SHADOW_QUESTIONS,
267
+ fetchImpl = globalThis.fetch,
268
+ now = () => Date.now(),
269
+ } = {}) {
270
+ if (!config.enabled) return null;
271
+ if (!config.apiKey) {
272
+ return { status: 'skipped', reason: 'missing_api_key' };
273
+ }
274
+ if (typeof fetchImpl !== 'function') {
275
+ return { status: 'error', error: 'fetch_unavailable' };
276
+ }
277
+
278
+ const controller = new AbortController();
279
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
280
+ const startedAt = now();
281
+
282
+ try {
283
+ const response = await fetchImpl(`${config.apiBase}/v1/systemone`, {
284
+ method: 'POST',
285
+ headers: {
286
+ authorization: `Bearer ${config.apiKey}`,
287
+ 'content-type': 'application/json',
288
+ },
289
+ body: JSON.stringify({
290
+ state,
291
+ model: config.model,
292
+ questions,
293
+ }),
294
+ signal: controller.signal,
295
+ });
296
+
297
+ const latencyMs = Math.max(0, now() - startedAt);
298
+ if (!response.ok) {
299
+ return {
300
+ status: 'error',
301
+ error: `http_${response.status}`,
302
+ latencyMs,
303
+ };
304
+ }
305
+
306
+ const payload = await response.json();
307
+ return {
308
+ status: 'ok',
309
+ model: payload.model,
310
+ answers: payload.answers,
311
+ usage: payload.usage,
312
+ latencyMs,
313
+ };
314
+ } catch (error) {
315
+ return {
316
+ status: 'error',
317
+ error: error?.name === 'AbortError' ? 'timeout' : compactText(error?.message || error, 300),
318
+ latencyMs: Math.max(0, now() - startedAt),
319
+ };
320
+ } finally {
321
+ clearTimeout(timer);
322
+ }
323
+ }
324
+
325
+ export const __jevShadowInternals = { SHADOW_QUESTIONS, compactText, truthy };
@@ -0,0 +1,39 @@
1
+ export function buildJevSuiteSelectionRequest({ changedFiles = [], missions = [] } = {}) {
2
+ const catalog = missions.map((mission, index) => ({
3
+ id: `mission_${index + 1}`,
4
+ file: mission.file,
5
+ tags: mission.tags || [],
6
+ }));
7
+ const questions = {};
8
+ for (const mission of catalog) {
9
+ questions[`select_${mission.id}`] = {
10
+ type: 'noul',
11
+ instructions: `Should the test mission ${mission.file} be selected for this code change?`,
12
+ criteria: {
13
+ true: 'The changed files could affect behavior covered by this mission, directly or through shared dependencies.',
14
+ false: 'The mission is unrelated to the changed files and can be omitted from this targeted run.',
15
+ },
16
+ };
17
+ }
18
+ return {
19
+ state: {
20
+ task: 'Select relevant end-to-end test missions for a targeted CI run. Prefer coverage when uncertain.',
21
+ changedFiles,
22
+ missions: catalog,
23
+ },
24
+ questions,
25
+ catalog,
26
+ };
27
+ }
28
+
29
+ export function interpretJevSuiteSelection(answers = {}, catalog = [], threshold = 0.7) {
30
+ return catalog.map(mission => {
31
+ const probability = Number(answers[`select_${mission.id}`]?.noul);
32
+ return {
33
+ file: mission.file,
34
+ tags: mission.tags,
35
+ probability,
36
+ selected: Number.isFinite(probability) && probability >= threshold,
37
+ };
38
+ });
39
+ }