testronaut 1.2.3 β†’ 1.2.5

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
@@ -127,7 +127,7 @@ Current supported providers:
127
127
 
128
128
  | Provider | Example Models |
129
129
  |-----------|----------------|
130
- | **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, etc. |
130
+ | **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, gpt-5.1, etc. |
131
131
  | **Google Gemini** | gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-8b |
132
132
 
133
133
  More providers coming soon (Anthropic, Mistral, etc.).
package/bin/cli.js CHANGED
@@ -51,7 +51,7 @@ import { ensureBrowsers } from '../tools/playwrightSetup.js';
51
51
  // Keep PW browsers inside the project to avoid global cache skew
52
52
  process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || '0';
53
53
 
54
-
54
+ const TMP_DIR = path.resolve('./missions/tmp');
55
55
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
56
56
 
57
57
  // const apiBase = `http://localhost:3002` //
@@ -60,6 +60,52 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
60
60
 
61
61
  const args = process.argv.slice(2);
62
62
 
63
+ function readJsonlSteps(p) {
64
+ if (!p || !fs.existsSync(p)) return null;
65
+ const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter(Boolean);
66
+ const parsed = [];
67
+ for (const line of lines) {
68
+ try { parsed.push(JSON.parse(line)); } catch { /* ignore bad lines */ }
69
+ }
70
+ return parsed;
71
+ }
72
+
73
+ /**
74
+ * Merge duplicate turns without losing information.
75
+ * Strategy:
76
+ * - Keep the latest **non-empty** version for a given turn if both exist
77
+ * - If both are non-empty, keep the later one (last write wins)
78
+ * - Preserve original order by line index as a tiebreaker
79
+ */
80
+ function mergeDuplicateTurns(steps) {
81
+ const out = [];
82
+ const byTurn = new Map(); // turn -> index in out
83
+ for (const s of steps) {
84
+ const turn = Number.isFinite(s.turn) ? s.turn : out.length;
85
+ const hasEvents = Array.isArray(s.events) && s.events.length > 0;
86
+ if (!byTurn.has(turn)) {
87
+ out.push(s);
88
+ byTurn.set(turn, out.length - 1);
89
+ } else {
90
+ const idx = byTurn.get(turn);
91
+ const existing = out[idx];
92
+ const existingHasEvents = Array.isArray(existing.events) && existing.events.length > 0;
93
+ // Prefer the one with events; if both have events, prefer the newer (s)
94
+ if (!existingHasEvents && hasEvents) {
95
+ out[idx] = s;
96
+ } else if (existingHasEvents && hasEvents) {
97
+ out[idx] = s;
98
+ } else {
99
+ // both empty or both sparse β€” keep latest
100
+ out[idx] = s;
101
+ }
102
+ }
103
+ }
104
+ // stable sort by `turn` ascending, then by original order via array index already preserved
105
+ out.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0));
106
+ return out;
107
+ }
108
+
63
109
  // Look for --model=<id> or --model <id>
64
110
  let modelOverride;
65
111
  const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
@@ -236,11 +282,22 @@ const endTime = new Date();
236
282
 
237
283
  const flatMissions = allResults.flatMap(entry => {
238
284
  const result = entry.result;
239
- const missions = Array.isArray(result) ? result : [result]; // normalize
240
- return missions.map(m => ({
241
- ...m,
242
- file: entry.file
243
- }));
285
+ const missions = Array.isArray(result) ? result : [result];
286
+
287
+ return missions.map(m => {
288
+ let steps = m.steps;
289
+ if (m.stepFile && fs.existsSync(m.stepFile)) {
290
+ const fromJsonl = readJsonlSteps(m.stepFile);
291
+ if (fromJsonl && fromJsonl.length) {
292
+ steps = mergeDuplicateTurns(fromJsonl);
293
+ }
294
+ }
295
+ return {
296
+ ...m,
297
+ steps,
298
+ file: entry.file,
299
+ };
300
+ });
244
301
  });
245
302
 
246
303
  // Read provider/model from config (allow env override for model)
@@ -753,16 +810,22 @@ async function serveLatestReport() {
753
810
  await new Promise((resolve, reject) => {
754
811
  server.once('error', reject);
755
812
  server.listen(0, '127.0.0.1', resolve);
756
-
757
- process.on('SIGINT', () => {
758
- console.log('\nπŸ›‘ Shutting down…');
759
- server.close(() => {
760
- resolve();
761
- process.exit(0);
762
- });
763
- });
764
813
  });
765
814
 
815
+ // Move signal handling to after server is listening
816
+ const shutdown = () => {
817
+ console.log('\nπŸ›‘ Shutting down…');
818
+ try {
819
+ server.close(); // best-effort; don’t wait for it
820
+ } catch (_) {
821
+ // ignore
822
+ }
823
+ process.exit(0);
824
+ };
825
+
826
+ process.once('SIGINT', shutdown);
827
+ process.once('SIGTERM', shutdown);
828
+
766
829
  const address = server.address();
767
830
  const port = typeof address === 'object' && address ? address.port : 0;
768
831
  const baseUrl = `http://127.0.0.1:${port}`;
@@ -775,3 +838,14 @@ async function serveLatestReport() {
775
838
  // Best-effort auto-open
776
839
  await openInBrowser(reportUrl).catch(() => {});
777
840
  }
841
+
842
+ try {
843
+ if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
844
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
845
+ console.log('🧹 Cleaned up temporary files.');
846
+ } else {
847
+ console.log('⚠️ Skipped tmp cleanup (TN_KEEP_TMP set).');
848
+ }
849
+ } catch (err) {
850
+ console.warn(`⚠️ Could not remove tmp folder: ${err.message}`);
851
+ }
package/bin/init.js CHANGED
@@ -94,9 +94,10 @@ export async function initializeTestronautProject() {
94
94
  { title: 'o3 (reasoning w/ native tool use)', value: 'o3' },
95
95
  { title: 'o4-mini (reasoning, cost-effective)', value: 'o4-mini' },
96
96
  // GPT-5 family (availability varies)
97
- { title: 'GPT-5 (latest reasoning model)', value: 'gpt-5' },
97
+ { title: 'GPT-5 (previous reasoning model)', value: 'gpt-5' },
98
98
  { title: 'GPT-5 mini (faster)', value: 'gpt-5-mini' },
99
99
  { title: 'GPT-5 nano (lightweight)', value: 'gpt-5-nano' },
100
+ { title: 'GPT-5.1 (latest reasoning model)', value: 'gpt-5.1' },
100
101
  ],
101
102
  // Keep prior selection highlighted if present; fallback to 4.1-mini.
102
103
  initial: pickInitialIndex(models, config.model, 'gpt-4.1-mini')
@@ -92,6 +92,7 @@ export function openAIModels() {
92
92
  'gpt-5',
93
93
  'gpt-5-mini',
94
94
  'gpt-5-nano',
95
+ 'gpt-5.1',
95
96
  ];
96
97
  }
97
98
 
package/core/agent.js CHANGED
@@ -25,6 +25,8 @@
25
25
  import 'dotenv/config';
26
26
  import { ChromeBrowser } from '../tools/chromeBrowser.js';
27
27
  import { turnLoop } from './turnLoop.js';
28
+ import fs from 'fs';
29
+ import path from 'path';
28
30
 
29
31
  /**
30
32
  * Execute goals with a browser agent.
@@ -41,9 +43,18 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
41
43
 
42
44
  try {
43
45
  const missionResults = [];
46
+ const tmpDir = path.resolve(process.cwd(), 'missions/tmp');
47
+ fs.mkdirSync(tmpDir, { recursive: true });
44
48
 
45
49
  for (const goal of goals) {
46
50
  const steps = [];
51
+ // Unique-ish JSONL file for this mission’s steps
52
+ const stepFile = path.join(
53
+ tmpDir,
54
+ `${missionName.replace(/[^\w.-]+/g, '_')}_${Date.now()}_steps.jsonl`
55
+ );
56
+ // Start clean
57
+ fs.writeFileSync(stepFile, '');
47
58
 
48
59
  // Ensure user message is a string (functions/objects β†’ toString fallback)
49
60
  const userContent =
@@ -78,16 +89,44 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
78
89
  0, // currentTurn
79
90
  0, // retryCount
80
91
  null, // currentStep
81
- { steps, missionName }
92
+ {
93
+ steps,
94
+ missionName,
95
+ onStep: (s) => {
96
+ // Append each step as a JSON line
97
+ try {
98
+ fs.appendFileSync(stepFile, JSON.stringify(s) + '\n');
99
+ // Keep memory in check: retain only the last ~20 steps in RAM
100
+ if (steps.length > 20) steps.splice(0, steps.length - 20);
101
+ } catch {} // best-effort; don’t crash the agent
102
+ }
103
+ }
82
104
  );
83
105
 
106
+ function dedupeSteps(steps) {
107
+ const seen = new Set();
108
+ const out = [];
109
+ for (const s of steps) {
110
+ const first = s?.events?.[0] || '';
111
+ const key = `${s.turn}::${s.summary}::${first}`;
112
+ if (!seen.has(key)) {
113
+ seen.add(key);
114
+ out.push(s);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ const compact = dedupeSteps(steps);
121
+
84
122
  // Snapshot steps (avoid retaining references to mutable arrays)
85
123
  missionResults.push({
86
124
  missionName,
87
125
  submissionType: goal.submissionType || goal.label,
88
126
  submissionName: goal.submissionName || null,
89
127
  status: result?.success ? 'passed' : 'failed',
90
- steps: JSON.parse(JSON.stringify(steps)), // deep-clone
128
+ steps: JSON.parse(JSON.stringify(compact)), // last 20 (by memory design)
129
+ stepFile, // full history is in this JSONL (one step per line)
91
130
  startTime: Date.now() - 1, // TODO: wire actual timings if needed
92
131
  endTime: Date.now(),
93
132
  });
package/core/turnLoop.js CHANGED
@@ -60,7 +60,7 @@ const llm = getLLM(PROVIDER_ID);
60
60
  let totalTokensUsed = 0;
61
61
  let turnTimestamps = [];
62
62
  let shouldBackoff;
63
- let steps = [];
63
+ // let steps = [];
64
64
 
65
65
  /**
66
66
  * Utility: format byte counts into human-readable strings.
@@ -148,6 +148,15 @@ export const turnLoop = async (
148
148
  const { steps = [], missionName } = ctx;
149
149
  let agentMemory = { lastMenuExpanded: false };
150
150
 
151
+ // Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
152
+ // Idempotent recorder – prevents accidental double-push
153
+ const recordStep = (step) => {
154
+ if (!step || step.__recorded) return;
155
+ step.__recorded = true;
156
+ try { steps.push(step); } catch {}
157
+ try { ctx?.onStep?.(step); } catch {}
158
+ };
159
+
151
160
  // ─────────────────────────────────────────────
152
161
  // STEP 1: Begin reasoning cycle
153
162
  // ─────────────────────────────────────────────
@@ -163,14 +172,20 @@ export const turnLoop = async (
163
172
  // Adaptive cooldown if nearing provider rate limit
164
173
  ({ totalTokensUsed, turnTimestamps, shouldBackoff } =
165
174
  await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
166
- if (shouldBackoff) return await turnLoop(browser, messages, maxTurns, turn, currentStep);
175
+ if (shouldBackoff) {
176
+ // step.result = '🟑 In Progress';
177
+ recordStep(step); // βœ… write the partial step before sleeping
178
+ turn -= 1; // retry same turn index
179
+ continue;
180
+ // return await turnLoop(browser, messages, maxTurns, turn, currentStep);
181
+ }
167
182
 
168
183
  // Verify message structure integrity before requesting next model step
169
184
  if (!validateAndInsertMissingToolResponses(messages, { insertPlaceholders: true })) {
170
185
  console.error('❌ Tool call structure invalid and no placeholders inserted.');
171
186
  step.events.push('❌ Tool call structure invalid and no placeholders inserted.');
172
187
  step.result = '❌ Failure';
173
- steps.push(step);
188
+ recordStep(step);
174
189
  return steps;
175
190
  }
176
191
 
@@ -190,7 +205,7 @@ export const turnLoop = async (
190
205
  console.dir(unrespondedCalls, { depth: 5 });
191
206
  step.events.push('πŸ›‘ Detected assistant tool calls without matching tool responses');
192
207
  step.result = '❌ Failure';
193
- steps.push(step);
208
+ recordStep(step);
194
209
  return steps;
195
210
  }
196
211
 
@@ -212,21 +227,28 @@ export const turnLoop = async (
212
227
  try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
213
228
  const delay = Math.min(60000, 2 ** retryCount * 2000);
214
229
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
230
+ step.events.push(`⏳ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
231
+ // step.result = '🟑 In Progress';
232
+ recordStep(step); // βœ… persist this step before sleeping
215
233
  await wait(delay);
216
234
 
217
235
  if (retryCount >= 5) {
218
236
  console.error('❌ Too many retries. Exiting.');
219
237
  step.events.push('❌ Too many retries. Exiting.');
220
238
  step.result = '❌ Failure';
221
- steps.push(step);
239
+ recordStep(step);
222
240
  return steps;
223
241
  }
224
- return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
242
+ // return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
243
+ // reattempt same turn after delay; keep same loop, bump retryCount
244
+ retryCount += 1;
245
+ turn -= 1;
246
+ continue;
225
247
  } else if (err.status === 400) {
226
248
  console.error('❌ Bad request:', err.message);
227
249
  step.events.push(`❌ Bad request: ${err.message}`);
228
250
  step.result = '❌ Failure';
229
- steps.push(step);
251
+ recordStep(step);
230
252
  return steps;
231
253
  } else {
232
254
  throw err;
@@ -296,7 +318,7 @@ export const turnLoop = async (
296
318
  } catch { /* non-JSON results ignored */ }
297
319
 
298
320
  console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : 'βœ… Success');
299
- const truncated = result.length > 100 ? result.slice(0, 100) + '…' : result;
321
+ const truncated = result.length > 1000 ? result.slice(0, 1000) + '…' : result;
300
322
  step.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : 'βœ… Success'}`);
301
323
  step.events.push(`[tool ] ← ${truncated}`);
302
324
 
@@ -339,7 +361,7 @@ export const turnLoop = async (
339
361
  // Merge new assistant + tool responses back into conversation
340
362
  messages.push(msg, ...toolResponses);
341
363
  step.result = 'βœ… Passed';
342
- steps.push(step);
364
+ recordStep(step);
343
365
  continue;
344
366
  }
345
367
 
@@ -350,7 +372,7 @@ export const turnLoop = async (
350
372
  if (finalResponse !== null) {
351
373
  step.events.push(finalResponse.finalMessage);
352
374
  step.result = finalResponse.success ? 'βœ… Mission Success' : '❌ Mission Failure';
353
- steps.push(step);
375
+ recordStep(step);
354
376
  return { success: finalResponse.finalMessage, steps };
355
377
  }
356
378
 
@@ -362,5 +384,8 @@ export const turnLoop = async (
362
384
  });
363
385
  console.log(`[auto] β†’ Injected DOM for next reasoning step`);
364
386
  step.events.push(`[auto] β†’ Injected DOM for next reasoning step`);
387
+ // This is a meaningful turn even without tool calls β€” record it.
388
+ step.result = step.result || '🟑 In Progress';
389
+ recordStep(step);
365
390
  }
366
391
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -35,6 +35,7 @@ import { wait } from './turnLoopUtils.js';
35
35
  */
36
36
  const DEFAULT_LIMITS = [
37
37
  // ── OpenAI (newer first) ────────────────────────────────────────────────
38
+ { test: /^gpt-5.1(-|$)/i, tpm: 120000 },
38
39
  { test: /^gpt-5(-|$)/i, tpm: 90000 },
39
40
  { test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
40
41
  { test: /^gpt-5-nano(-|$)/i, tpm: 600000 },