testronaut 1.9.3 β†’ 1.10.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 CHANGED
@@ -145,7 +145,7 @@ Choose your preferred LLM at init or via environment variables.
145
145
  testronaut --init
146
146
 
147
147
  # Or override anytime
148
- TESTRONAUT_PROVIDER=gemini TESTRONAUT_MODEL=gemini-2.5-pro testronaut
148
+ TESTRONAUT_PROVIDER=gemini TESTRONAUT_MODEL=gemini-3.8-flash testronaut
149
149
  ```
150
150
 
151
151
  ### Token-rate override
@@ -172,7 +172,7 @@ Current supported providers:
172
172
  | Provider | Example Models |
173
173
  |-----------|----------------|
174
174
  | **OpenAI** | gpt-5.6, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, and legacy GPT/o-series models |
175
- | **Google Gemini** | gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-8b |
175
+ | **Google Gemini** | gemini-3.8-flash, gemini-3.7-flash, gemini-3.6-flash, gemini-3.5-flash-lite, gemini-3.1-pro-preview, Gemini 2.5 legacy models |
176
176
  | **Anthropic Claude** | claude-sonnet-5, claude-opus-5, claude-haiku-4-5 |
177
177
 
178
178
  The canonical Claude provider ID is `anthropic`; `claude` is also accepted as a CLI alias.
@@ -191,6 +191,31 @@ Run a specific mission:
191
191
  testronaut login.mission.js
192
192
  ```
193
193
 
194
+ Reuse cookies, local storage, and IndexedDB authentication state across an ordered set of mission files:
195
+
196
+ ```bash
197
+ testronaut --session=shared-auth checkout.mission.js account.mission.js
198
+ ```
199
+
200
+ Each mission still receives a fresh browser context. The default
201
+ `--session=isolated` mode does not carry authentication forward. Shared-auth
202
+ suites can wrap a reusable login prerequisite in `launchProtocol()` so later
203
+ mission files probe the restored authenticated page without an LLM call:
204
+
205
+ ```js
206
+ import { launchProtocol, runMissions } from 'testronaut';
207
+
208
+ const login = launchProtocol('Visit the app and log in.', {
209
+ id: 'authenticated:test-user',
210
+ probe: { url: process.env.URL, selector: '#dashboard' },
211
+ });
212
+
213
+ await runMissions({ preMission: login, mission: 'Test the dashboard.' }, 'dashboard');
214
+ ```
215
+
216
+ Unwrapped `preMission` values remain fully compatible and always run normally.
217
+ Place logout last in a shared-auth suite.
218
+
194
219
  Preview or inspect without launching a browser:
195
220
  ```bash
196
221
  testronaut list
@@ -371,7 +396,7 @@ Each includes:
371
396
  - **LLMs** for reasoning, DOM parsing, and tool use
372
397
  - **Token throttling** + adaptive cooldowns
373
398
  - **Extensible architecture** for custom tools and workflows
374
- - **DOM trimming controls** to cap list sizes (env `TESTRONAUT_DOM_LIST_LIMIT` or config `dom.listItemLimit`; use `all` cautiouslyβ€”it can spike token use)
399
+ - **DOM trimming controls** to cap list/table sizes (`TESTRONAUT_DOM_LIST_LIMIT`) and automatic post-action snapshots (`TESTRONAUT_AUTO_DOM_LIMIT` or `dom.automaticLimit`)
375
400
  - **Resource guard** to ensure full list/table downloads (config `resourceGuard` or env `TESTRONAUT_RESOURCE_*`)
376
401
 
377
402
  ---
package/TODO.md CHANGED
@@ -9,6 +9,17 @@
9
9
  - [ ] Treat `--json` output as a stable automation contract with documented schemas and tests for every supported command.
10
10
  - [ ] If authentication ever moves outside project config, continue reading existing `sessionToken` values and provide an opt-in migration path before deprecating legacy storage.
11
11
 
12
+ ## Session and Reporting Follow-Ups
13
+
14
+ - [ ] Evaluate a true shared browser-context mode for suites that require session storage or in-memory application state; define page reset, crash recovery, and concurrency behavior first.
15
+ - [ ] Add optional shared-auth checkpoints/reset controls for suites that intentionally change users or tenants.
16
+ - [ ] Refresh short-lived Testronaut API session tokens during long runs once the API and credential-storage contract is defined.
17
+ - [ ] Store new CLI authentication outside project config, prefer environment credentials in CI, and migrate legacy project `sessionToken` values without breaking existing projects.
18
+ - [ ] Add structured post-run self-critique fields after the versioned report schema is available.
19
+ - [ ] Separate mission totals from phase totals and add an explicit `phase` field without losing compatibility with `submissionType`.
20
+ - [ ] Design an optional mission scope/side-effect policy that agents can enforce and reports can display.
21
+ - [ ] Evaluate semantic DOM diffs/chunks after configurable limits, selector scoping, and table trimming have production data.
22
+
12
23
  ## Automated MFA Follow-Ups
13
24
 
14
25
  - [ ] Add an integration smoke test against a local or staging API fixture for `get_mfa_code`.
package/bin/cli.js CHANGED
@@ -62,6 +62,8 @@ import { matchesTagFilter, normalizeTagMatch, normalizeTags } from '../core/tags
62
62
  process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
63
63
 
64
64
  const TMP_DIR = path.resolve('./missions/tmp');
65
+ const runId = `run_${Date.now()}`;
66
+ const startTime = new Date();
65
67
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
66
68
  const CLI_VERSION = JSON.parse(
67
69
  fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')
@@ -89,6 +91,28 @@ const uploadScreenshots = !args.includes('--no-upload-screenshots');
89
91
  args = args.filter(arg => arg !== '--no-upload-screenshots');
90
92
  process.env.TESTRONAUT_SCREENSHOTS = screenshotsEnabled ? '1' : '0';
91
93
 
94
+ let sessionMode = 'isolated';
95
+ const sessionFlagIndex = args.findIndex(a => a === '--session' || a.startsWith('--session='));
96
+ if (sessionFlagIndex >= 0) {
97
+ const raw = args[sessionFlagIndex];
98
+ const inline = raw.includes('=');
99
+ const value = inline ? raw.slice(raw.indexOf('=') + 1) : args[sessionFlagIndex + 1];
100
+ if (!['isolated', 'shared-auth'].includes(value)) {
101
+ console.error('❌ --session must be "isolated" or "shared-auth".');
102
+ process.exit(1);
103
+ }
104
+ sessionMode = value;
105
+ args.splice(sessionFlagIndex, inline ? 1 : 2);
106
+ }
107
+ if (sessionMode === 'shared-auth') {
108
+ const storageStatePath = path.join(TMP_DIR, `${runId}-storage-state.json`);
109
+ process.env.TESTRONAUT_STORAGE_STATE_PATH = storageStatePath;
110
+ process.once('exit', () => {
111
+ try { fs.unlinkSync(storageStatePath); } catch {}
112
+ });
113
+ }
114
+ process.env.TESTRONAUT_SESSION_MODE = sessionMode;
115
+
92
116
  // Detect how the CLI was invoked so help text matches the actual command
93
117
  function detectCliName(npmCommand = process.env.npm_command, argv1 = process.argv[1]) {
94
118
  // npx sets npm_command to 'exec'
@@ -486,7 +510,8 @@ __test__.parseTagArgs = parseTagArgs;
486
510
  let modelOverride;
487
511
  const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
488
512
  if (modelFlagIndex >= 0) {
489
- if (args[modelFlagIndex].includes('=')) {
513
+ const inline = args[modelFlagIndex].includes('=');
514
+ if (inline) {
490
515
  modelOverride = args[modelFlagIndex].split('=')[1];
491
516
  } else if (args[modelFlagIndex + 1]) {
492
517
  modelOverride = args[modelFlagIndex + 1];
@@ -498,7 +523,7 @@ if (modelFlagIndex >= 0) {
498
523
  }
499
524
 
500
525
  // Remove flag & value from args so they don't look like mission filenames
501
- args.splice(modelFlagIndex, modelOverride ? 2 : 1);
526
+ args.splice(modelFlagIndex, inline ? 1 : (modelOverride ? 2 : 1));
502
527
  }
503
528
 
504
529
  // Look for --provider=<id> or --provider <id>
@@ -601,7 +626,8 @@ if (debugFlagIndex >= 0) {
601
626
  let turnsOverride;
602
627
  const turnsFlagIndex = args.findIndex(a => a === '--turns' || a.startsWith('--turns='));
603
628
  if (turnsFlagIndex >= 0) {
604
- if (args[turnsFlagIndex].includes('=')) {
629
+ const inline = args[turnsFlagIndex].includes('=');
630
+ if (inline) {
605
631
  turnsOverride = args[turnsFlagIndex].split('=')[1];
606
632
  } else if (args[turnsFlagIndex + 1]) {
607
633
  turnsOverride = args[turnsFlagIndex + 1];
@@ -618,7 +644,7 @@ if (turnsFlagIndex >= 0) {
618
644
  }
619
645
 
620
646
  // Remove flag & value so they aren’t treated as filenames
621
- args.splice(turnsFlagIndex, turnsOverride ? 2 : 1);
647
+ args.splice(turnsFlagIndex, inline ? 1 : (turnsOverride ? 2 : 1));
622
648
  }
623
649
 
624
650
  // Look for --retry_limit / --retry-limit
@@ -631,7 +657,8 @@ const retryFlagIndex = args.findIndex(a =>
631
657
  );
632
658
  if (retryFlagIndex >= 0) {
633
659
  const rawArg = args[retryFlagIndex];
634
- if (rawArg.includes('=')) {
660
+ const inline = rawArg.includes('=');
661
+ if (inline) {
635
662
  retryOverride = rawArg.split('=')[1];
636
663
  } else if (args[retryFlagIndex + 1]) {
637
664
  retryOverride = args[retryFlagIndex + 1];
@@ -649,7 +676,7 @@ if (retryFlagIndex >= 0) {
649
676
  }
650
677
 
651
678
  // Remove flag & value so they aren’t treated as filenames
652
- args.splice(retryFlagIndex, retryOverride ? 2 : 1);
679
+ args.splice(retryFlagIndex, inline ? 1 : (retryOverride ? 2 : 1));
653
680
  }
654
681
 
655
682
  // Look for --human-input / --no-human-input / --disable-human-input
@@ -697,7 +724,8 @@ const humanInputTimeoutFlagIndex = args.findIndex(a =>
697
724
  );
698
725
  if (humanInputTimeoutFlagIndex >= 0) {
699
726
  const rawArg = args[humanInputTimeoutFlagIndex];
700
- if (rawArg.includes('=')) {
727
+ const inline = rawArg.includes('=');
728
+ if (inline) {
701
729
  humanInputTimeoutOverride = rawArg.split('=')[1];
702
730
  } else if (args[humanInputTimeoutFlagIndex + 1]) {
703
731
  humanInputTimeoutOverride = args[humanInputTimeoutFlagIndex + 1];
@@ -713,12 +741,10 @@ if (humanInputTimeoutFlagIndex >= 0) {
713
741
  }
714
742
  }
715
743
 
716
- args.splice(humanInputTimeoutFlagIndex, humanInputTimeoutOverride ? 2 : 1);
744
+ args.splice(humanInputTimeoutFlagIndex, inline ? 1 : (humanInputTimeoutOverride ? 2 : 1));
717
745
  }
718
746
 
719
747
  const allResults = [];
720
- const runId = `run_${Date.now()}`;
721
- const startTime = new Date();
722
748
 
723
749
  const HELP_TEXT = `
724
750
  πŸ§‘β€πŸš€ testronaut - Autonomous Agent Mission Runner
@@ -738,6 +764,8 @@ Options:
738
764
  --turns=<n> Override max turns for this run (e.g., --turns=30)
739
765
  --debug[=<bool>] Enable verbose debug logs (or set TESTRONAUT_DEBUG=1)
740
766
  --provider=<id> Override LLM provider (openai, gemini, or anthropic)
767
+ --model=<id> Override the provider model for this run
768
+ --session=<mode> Browser auth reuse: isolated (default) or shared-auth
741
769
  -o, --options key=value Set run options, such as mfa=github-test-mfa or inbox=github-staging
742
770
  --dev Use the staging API base URL
743
771
  --vercel-bypass=<secret> Send Vercel protection bypass header for protected deployments
@@ -849,13 +877,16 @@ if (args.length === 1 && args[0] === 'list') {
849
877
  return;
850
878
  }
851
879
  const listedMissions = [];
880
+ const listTags = cliTags ?? normalizeTags(tagConfig?.tags, { allowUntagged: true });
881
+ const listTagMatch = normalizeTagMatch(cliTagMatch ?? tagConfig?.tagMatch);
852
882
  if (!jsonOutput) console.log(`Missions in ${path.relative(process.cwd(), missionsRoot) || '.'}:`);
853
883
  for (const file of discoveredMissions) {
854
884
  try {
855
885
  const mission = await loadMissionModule(path.resolve(missionsRoot, file));
856
886
  const tags = normalizeTags(mission.tags);
857
- listedMissions.push({ file, tags });
858
- if (!jsonOutput) console.log(` ${file}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
887
+ const selected = matchesTagFilter(tags, listTags, listTagMatch);
888
+ listedMissions.push({ file, tags, selected });
889
+ if (!jsonOutput) console.log(` ${selected ? 'βœ“' : 'Β·'} ${file}${tags.length ? ` [${tags.join(', ')}]` : ''}`);
859
890
  } catch (error) {
860
891
  console.log(` ${file} [could not load: ${error.message}]`);
861
892
  process.exitCode = 1;
@@ -907,7 +938,7 @@ if (dryRun) {
907
938
  invalid = true;
908
939
  }
909
940
  }
910
- writeOutput({ dryRun: true, outputDir, provider: resolveProviderModel({ cwd: process.cwd() }), screenshots: screenshotsEnabled, missions });
941
+ writeOutput({ dryRun: true, outputDir, provider: resolveProviderModel({ cwd: process.cwd() }), session: { mode: sessionMode }, screenshots: screenshotsEnabled, missions });
911
942
  if (invalid) process.exitCode = 1;
912
943
  return;
913
944
  }
@@ -1008,6 +1039,7 @@ const report = {
1008
1039
  provider: llmProvider,
1009
1040
  model: llmModel,
1010
1041
  },
1042
+ session: { mode: sessionMode },
1011
1043
  summary: {
1012
1044
  totalMissions: flatMissions.length,
1013
1045
  passed: flatMissions.filter(m => m.status === 'passed').length,
package/bin/init.js CHANGED
@@ -122,12 +122,17 @@ export async function initializeTestronautProject() {
122
122
  name: 'geminiModel',
123
123
  message: 'Select a Google Gemini model:',
124
124
  choices: [
125
- { title: 'Gemini 2.5 Pro (general, high quality)', value: 'gemini-2.5-pro' },
126
- { title: 'Gemini 2.5 Flash (fast, cost-efficient)', value: 'gemini-2.5-flash' },
127
- { title: 'Gemini 2.5 Flash-8B (lightweight)', value: 'gemini-2.5-flash-8b' },
125
+ { title: 'Gemini 3.8 Flash (latest stable, recommended)', value: 'gemini-3.8-flash' },
126
+ { title: 'Gemini 3.7 Flash (previous stable)', value: 'gemini-3.7-flash' },
127
+ { title: 'Gemini 3.6 Flash (stable, token-efficient)', value: 'gemini-3.6-flash' },
128
+ { title: 'Gemini 3.5 Flash-Lite (fastest, cost-efficient)', value: 'gemini-3.5-flash-lite' },
129
+ { title: 'Gemini 3.1 Pro Preview (complex reasoning)', value: 'gemini-3.1-pro-preview' },
130
+ { title: 'Gemini 2.5 Pro (legacy)', value: 'gemini-2.5-pro' },
131
+ { title: 'Gemini 2.5 Flash (legacy)', value: 'gemini-2.5-flash' },
132
+ { title: 'Gemini 2.5 Flash-Lite (legacy)', value: 'gemini-2.5-flash-lite' },
128
133
  ],
129
- // Keep prior selection if present; default to fast/cost-effective Flash.
130
- initial: pickInitialIndex(models, config.model, 'gemini-2.5-flash')
134
+ // Keep prior selection if present; otherwise use the latest stable model.
135
+ initial: pickInitialIndex(models, config.model, 'gemini-3.8-flash')
131
136
  });
132
137
 
133
138
  config.model = geminiModel;
@@ -60,6 +60,7 @@ export function defaultConfig(rootBasename) {
60
60
  addTags: [],
61
61
  dom: {
62
62
  listItemLimit: 3,
63
+ automaticLimit: 15000,
63
64
  },
64
65
  resourceGuard: {
65
66
  enabled: true,
@@ -115,9 +116,14 @@ export function openAIModels() {
115
116
  */
116
117
  export function geminiModels() {
117
118
  return [
118
- 'gemini-2.5-pro', // best reasoning, tool use
119
- 'gemini-2.5-flash', // fast, cost-effective
120
- 'gemini-2.5-flash-lite', // lightweight / high throughput
119
+ 'gemini-3.8-flash', // latest stable agentic model
120
+ 'gemini-3.7-flash', // previous stable agentic model
121
+ 'gemini-3.6-flash', // stable, token-efficient
122
+ 'gemini-3.5-flash-lite', // lightweight / high throughput
123
+ 'gemini-3.1-pro-preview', // highest-quality preview
124
+ 'gemini-2.5-pro', // legacy reasoning model
125
+ 'gemini-2.5-flash', // legacy fast model
126
+ 'gemini-2.5-flash-lite', // legacy lightweight model
121
127
  ];
122
128
  }
123
129
 
package/core/agent.js CHANGED
@@ -25,9 +25,10 @@
25
25
  import 'dotenv/config';
26
26
  import { ChromeBrowser } from '../tools/chromeBrowser.js';
27
27
  import { turnLoop } from './turnLoop.js';
28
- import { createEmptyGroundControl, summarizeGroundControlForPrompt } from '../tools/contextControl.js';
28
+ import { applyGroundControlUpdate, createEmptyGroundControl, summarizeGroundControlForPrompt } from '../tools/contextControl.js';
29
29
  import fs from 'fs';
30
30
  import path from 'path';
31
+ import { hasCompletedLaunchProtocol, markLaunchProtocolCompleted } from './launchProtocol.js';
31
32
 
32
33
  /**
33
34
  * Execute goals with a browser agent.
@@ -36,7 +37,7 @@ import path from 'path';
36
37
  * @param {string} missionName
37
38
  * @param {number} [maxTurns=20] - upper bound for turnLoop per goal
38
39
  * @param {number} [retryLimit]
39
- * @param {{ domListLimit?: number|typeof Infinity, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] }, humanInput?: { enabled:boolean, timeoutSeconds:number } }} [opts]
40
+ * @param {{ domListLimit?: number|typeof Infinity, automaticDomLimit?: number, storageStatePath?: string, debug?: boolean, resourceGuard?: { enabled:boolean, hrefIncludes:string[], dataTypes:string[] }, humanInput?: { enabled:boolean, timeoutSeconds:number } }} [opts]
40
41
  * @returns {Promise<Array<{missionName:string, submissionType:string, submissionName:string|null, status:'passed'|'failed', steps:any[], startTime:number, endTime:number}>>}
41
42
  */
42
43
  export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, opts = {}) {
@@ -44,6 +45,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
44
45
  domListLimit: opts.domListLimit,
45
46
  debug: opts.debug,
46
47
  resourceGuard: opts.resourceGuard,
48
+ storageStatePath: opts.storageStatePath,
47
49
  });
48
50
  await browser.start();
49
51
  let result;
@@ -57,6 +59,7 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
57
59
  const groundControl = createEmptyGroundControl();
58
60
 
59
61
  for (const goal of goals) {
62
+ const telemetryStart = groundControl.telemetry.length;
60
63
  const steps = [];
61
64
  const stepsArchive = [];
62
65
  // Unique-ish JSONL file for this mission’s steps
@@ -67,6 +70,69 @@ export async function runAgent(goals, missionName, maxTurns = 20, retryLimit, op
67
70
  // Start clean
68
71
  fs.writeFileSync(stepFile, '');
69
72
 
73
+ let protocolResult = null;
74
+ const protocol = goal.launchProtocol;
75
+ const sharedAuth = process.env.TESTRONAUT_SESSION_MODE === 'shared-auth';
76
+ if (protocol && sharedAuth && hasCompletedLaunchProtocol(protocol.id)) {
77
+ let probeResult;
78
+ try {
79
+ probeResult = await browser.probe(protocol.probe);
80
+ } catch (error) {
81
+ probeResult = { passed: false, reason: error?.message || String(error) };
82
+ }
83
+
84
+ if (probeResult.passed) {
85
+ const currentUrl = probeResult.url || protocol.probe.url || null;
86
+ applyGroundControlUpdate(groundControl, {
87
+ app: {
88
+ baseUrl: protocol.probe.url || currentUrl,
89
+ currentUrl,
90
+ routeRole: 'authenticated',
91
+ },
92
+ session: { loggedIn: true },
93
+ navigation: { currentLabel: `Launch protocol ${protocol.id} verified` },
94
+ constraints: { stayWithinBaseUrl: true },
95
+ });
96
+ const now = Date.now();
97
+ const step = {
98
+ turn: 0,
99
+ events: [`πŸ“‘ Launch protocol probe passed: ${protocol.id}`],
100
+ result: 'βœ… Mission Success',
101
+ summary: `Reused launch protocol ${protocol.id}`,
102
+ missionName,
103
+ tokensUsed: 0,
104
+ inputTokens: 0,
105
+ outputTokens: 0,
106
+ };
107
+ fs.appendFileSync(stepFile, JSON.stringify(step) + '\n');
108
+ missionResults.push({
109
+ missionName,
110
+ submissionType: goal.submissionType || goal.label,
111
+ submissionName: goal.submissionName || null,
112
+ status: 'passed',
113
+ steps: [step],
114
+ stepFile,
115
+ startTime: now,
116
+ endTime: now,
117
+ telemetry: [],
118
+ launchProtocol: { id: protocol.id, status: 'reused', probe: 'passed' },
119
+ });
120
+ continue;
121
+ }
122
+ protocolResult = {
123
+ id: protocol.id,
124
+ status: 'executed',
125
+ probe: 'failed',
126
+ reason: probeResult.reason,
127
+ };
128
+ } else if (protocol) {
129
+ protocolResult = {
130
+ id: protocol.id,
131
+ status: 'executed',
132
+ probe: sharedAuth ? 'not-needed' : 'disabled',
133
+ };
134
+ }
135
+
70
136
  // Ensure user message is a string (functions/objects β†’ toString fallback)
71
137
  const userContent =
72
138
  typeof goal.goal === 'string'
@@ -197,6 +263,7 @@ Email invitation and magic links:
197
263
  groundControl,
198
264
  resourceGuard: opts.resourceGuard,
199
265
  humanInput: opts.humanInput,
266
+ automaticDomLimit: opts.automaticDomLimit,
200
267
  onStep: (s) => {
201
268
  // Append each step as a JSON line
202
269
  try {
@@ -232,8 +299,14 @@ Email invitation and magic links:
232
299
  stepFile, // full history is in this JSONL (one step per line)
233
300
  startTime: Date.now() - 1, // TODO: wire actual timings if needed
234
301
  endTime: Date.now(),
302
+ telemetry: JSON.parse(JSON.stringify(groundControl.telemetry.slice(telemetryStart))),
303
+ ...(protocolResult ? { launchProtocol: protocolResult } : {}),
235
304
  });
236
305
 
306
+ if (protocol && result?.success) {
307
+ markLaunchProtocolCompleted(protocol.id);
308
+ }
309
+
237
310
  if (!result?.success) {
238
311
  console.log('πŸ›‘ Agent stopped due to failed goal.\n');
239
312
  return missionResults;
package/core/config.js CHANGED
@@ -130,6 +130,22 @@ export function getDomListLimit(cfg, fallback = 3) {
130
130
  return { ...fbParsed, source: 'default' };
131
131
  }
132
132
 
133
+ /** Resolve the character limit for automatic DOM snapshots injected after actions. */
134
+ export function getAutomaticDomLimit(cfg, fallback = 15000) {
135
+ const parse = raw => {
136
+ if (raw === undefined || raw === null || String(raw).trim() === '') return null;
137
+ const value = Number(raw);
138
+ if (!Number.isFinite(value)) return null;
139
+ const clamped = Math.min(100000, Math.max(1000, Math.trunc(value)));
140
+ return { value: clamped, clamped: clamped !== value };
141
+ };
142
+ const env = parse(process.env.TESTRONAUT_AUTO_DOM_LIMIT);
143
+ if (env) return { ...env, source: 'env' };
144
+ const configured = parse(cfg?.dom?.automaticLimit ?? cfg?.automaticDomLimit);
145
+ if (configured) return { ...configured, source: 'config' };
146
+ return { ...parse(fallback), source: 'default' };
147
+ }
148
+
133
149
  /**
134
150
  * Resolve resource guard settings for generic list/table harvesting.
135
151
  * - Patterns are used to detect resource anchors/data attributes.
@@ -0,0 +1,42 @@
1
+ const completedProtocols = new Set();
2
+
3
+ /**
4
+ * Add optional shared-session reuse metadata to an ordinary mission goal.
5
+ * The wrapped goal remains string-coercible for backward compatibility.
6
+ */
7
+ export function launchProtocol(goal, { id, probe } = {}) {
8
+ const protocolId = String(id || '').trim();
9
+ if (!protocolId) throw new Error('launchProtocol requires a non-empty id.');
10
+ if (!probe || (!probe.selector && !probe.text)) {
11
+ throw new Error('launchProtocol requires a probe selector or text value.');
12
+ }
13
+
14
+ return Object.freeze({
15
+ __testronautLaunchProtocol: true,
16
+ goal,
17
+ protocol: Object.freeze({
18
+ id: protocolId,
19
+ probe: Object.freeze({ ...probe }),
20
+ }),
21
+ toString() {
22
+ return typeof goal === 'string' ? goal : goal?.toString?.() ?? String(goal);
23
+ },
24
+ });
25
+ }
26
+
27
+ export function unwrapLaunchProtocol(value) {
28
+ if (!value?.__testronautLaunchProtocol) return { goal: value, protocol: null };
29
+ return { goal: value.goal, protocol: value.protocol };
30
+ }
31
+
32
+ export function hasCompletedLaunchProtocol(id) {
33
+ return completedProtocols.has(id);
34
+ }
35
+
36
+ export function markLaunchProtocolCompleted(id) {
37
+ completedProtocols.add(id);
38
+ }
39
+
40
+ export function resetLaunchProtocols() {
41
+ completedProtocols.clear();
42
+ }
package/core/turnLoop.js CHANGED
@@ -190,14 +190,15 @@ function formatBytes(n) {
190
190
  * @param {object} agentMemory - Memory object storing recent agent state.
191
191
  * @param {{skipIfLastTool?: string[]}} opts - Optional tool skip conditions.
192
192
  */
193
- const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool } = {}) => {
193
+ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool, limit = 15000 } = {}) => {
194
194
  // Avoid redundant DOM pushes immediately following tools that already include DOM context.
195
195
  if (skipIfLastTool && skipIfLastTool.includes(messages.at(-1)?.name)) {
196
196
  console.log(`[skip] Skipping DOM push after redundant tool: ${messages.at(-1)?.name}`);
197
- return;
197
+ return { injected: false, domLength: 0 };
198
198
  }
199
199
 
200
200
  const domCallId = `get_dom_${Date.now()}`;
201
+ const automaticDomLimit = limit;
201
202
 
202
203
  // Inject a synthetic assistant tool call
203
204
  messages.push({
@@ -208,13 +209,17 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
208
209
  type: 'function',
209
210
  function: {
210
211
  name: 'get_dom',
211
- arguments: JSON.stringify({ limit: 100000, exclude: true }),
212
+ arguments: JSON.stringify({ limit: automaticDomLimit, exclude: true }),
212
213
  },
213
214
  },
214
215
  ],
215
216
  });
216
217
 
217
- const domHtml = await CHROME_TOOL_MAP.get_dom(browser, { limit: 100000, exclude: true }, agentMemory);
218
+ const domHtml = await CHROME_TOOL_MAP.get_dom(
219
+ browser,
220
+ { limit: automaticDomLimit, exclude: true },
221
+ agentMemory
222
+ );
218
223
  await tokenEstimate(MODEL_ID, domHtml);
219
224
 
220
225
  // Push the corresponding tool response back into the conversation
@@ -225,6 +230,11 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
225
230
  type: 'function',
226
231
  content: typeof domHtml === 'string' ? domHtml : JSON.stringify(domHtml),
227
232
  });
233
+
234
+ return {
235
+ injected: true,
236
+ domLength: typeof domHtml === 'string' ? domHtml.length : JSON.stringify(domHtml).length,
237
+ };
228
238
  };
229
239
 
230
240
  /**
@@ -427,6 +437,8 @@ export const turnLoop = async (
427
437
  const tokensUsed = usage.total_tokens || 0;
428
438
  console.log(`πŸ“Š Token Usage This Turn β†’ Total: ${tokensUsed}`);
429
439
  step.tokensUsed = tokensUsed;
440
+ step.inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? usage.providerRaw?.input_tokens ?? usage.providerRaw?.prompt_tokens;
441
+ step.outputTokens = usage.output_tokens ?? usage.completion_tokens ?? usage.providerRaw?.output_tokens ?? usage.providerRaw?.completion_tokens;
430
442
  recordTokenUsage(turnTimestamps, tokensUsed);
431
443
  ({ turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(turnTimestamps));
432
444
  console.log(`πŸ“ˆ Running Total Tokens Used (Rolling 60s): ${totalTokensUsed}`);
@@ -449,13 +461,14 @@ export const turnLoop = async (
449
461
  console.log('Processing tool calls...');
450
462
  const toolResponses = [];
451
463
  let hadToolIssues = false;
464
+ let domRefreshSource = null;
452
465
 
453
466
  for (const call of msg.tool_calls) {
454
467
  const fnName = call.function.name;
455
468
  const args = JSON.parse(call.function.arguments || '{}');
456
469
  const safeArgs = redactArgs(fnName, args);
457
470
  console.log(`[model] β†’ ${fnName}`, safeArgs);
458
- step.events.push(`[model] β†’ ${fnName} ${safeArgs}`);
471
+ step.events.push(`[model] β†’ ${fnName} ${JSON.stringify(safeArgs)}`);
459
472
 
460
473
  let result;
461
474
  let errorMessage = null;
@@ -796,26 +809,30 @@ export const turnLoop = async (
796
809
  content: contentForModel,
797
810
  });
798
811
 
799
- // After interactive DOM actions, refresh model context
800
- if (['click_text', 'click', 'expand_menu'].includes(fnName)) {
801
- console.log(`[auto] β†’ Injecting DOM after ${fnName}...`);
802
- step.events.push(`[auto] β†’ Injecting DOM after ${fnName}...`);
803
- const domHtml = await CHROME_TOOL_MAP.get_dom(browser, {
804
- limit: 100000,
805
- exclude: true,
806
- focus: [],
807
- }, agentMemory);
808
- await tokenEstimate(MODEL_ID, domHtml);
809
- await pushDOMAssistant(browser, messages, agentMemory, {
810
- skipIfLastTool: ['get_dom', 'check_text'],
811
- });
812
- console.log(`[auto] β†’ DOM size after ${fnName}: ${domHtml.length} chars`);
813
- step.events.push(`[auto] β†’ DOM size after ${fnName}: ${domHtml.length} chars`);
812
+ // Defer automatic DOM injection until the originating assistant call
813
+ // and all of its function responses are in history. Gemini requires a
814
+ // model functionCall to immediately follow user content or a
815
+ // functionResponse; inserting it before `msg` would create adjacent,
816
+ // reversed model function-call turns.
817
+ if (!errorMessage && ['click_text', 'click', 'expand_menu'].includes(fnName)) {
818
+ domRefreshSource = fnName;
814
819
  }
815
820
  }
816
821
 
817
822
  // Merge new assistant + tool responses back into conversation
818
823
  messages.push(msg, ...toolResponses);
824
+ if (domRefreshSource && !hadToolIssues) {
825
+ console.log(`[auto] β†’ Injecting DOM after ${domRefreshSource}...`);
826
+ step.events.push(`[auto] β†’ Injecting DOM after ${domRefreshSource}...`);
827
+ const domRefresh = await pushDOMAssistant(browser, messages, agentMemory, {
828
+ skipIfLastTool: ['get_dom', 'check_text'],
829
+ limit: ctx.automaticDomLimit,
830
+ });
831
+ if (domRefresh?.injected) {
832
+ console.log(`[auto] β†’ DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
833
+ step.events.push(`[auto] β†’ DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
834
+ }
835
+ }
819
836
  if (hadToolIssues && turnRetries < retryLimitClamped) {
820
837
  step.result = '⏳ Retrying turn';
821
838
  const retryNumber = attempt - 1;
@@ -870,6 +887,7 @@ export const turnLoop = async (
870
887
  // ─────────────────────────────────────────────
871
888
  await pushDOMAssistant(browser, messages, agentMemory, {
872
889
  skipIfLastTool: ['get_dom', 'check_text'],
890
+ limit: ctx.automaticDomLimit,
873
891
  });
874
892
  console.log(`[auto] β†’ Injected DOM for next reasoning step`);
875
893
  step.events.push(`[auto] β†’ Injected DOM for next reasoning step`);
@@ -112,6 +112,8 @@ export class AnthropicProvider {
112
112
 
113
113
  const usage = {
114
114
  total_tokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
115
+ input_tokens: response.usage?.input_tokens,
116
+ output_tokens: response.usage?.output_tokens,
115
117
  providerRaw: response.usage,
116
118
  };
117
119
  return { message: fromAnthropicMessage(response), usage };