testronaut 1.2.3 β†’ 1.2.4

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/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)
@@ -775,3 +832,14 @@ async function serveLatestReport() {
775
832
  // Best-effort auto-open
776
833
  await openInBrowser(reportUrl).catch(() => {});
777
834
  }
835
+
836
+ try {
837
+ if (!process.env.TN_KEEP_TMP && fs.existsSync(TMP_DIR)) {
838
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
839
+ console.log('🧹 Cleaned up temporary files.');
840
+ } else {
841
+ console.log('⚠️ Skipped tmp cleanup (TN_KEEP_TMP set).');
842
+ }
843
+ } catch (err) {
844
+ console.warn(`⚠️ Could not remove tmp folder: ${err.message}`);
845
+ }
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,7 +172,13 @@ 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 })) {
@@ -171,6 +186,7 @@ export const turnLoop = async (
171
186
  step.events.push('❌ Tool call structure invalid and no placeholders inserted.');
172
187
  step.result = '❌ Failure';
173
188
  steps.push(step);
189
+ recordStep(step);
174
190
  return steps;
175
191
  }
176
192
 
@@ -191,6 +207,7 @@ export const turnLoop = async (
191
207
  step.events.push('πŸ›‘ Detected assistant tool calls without matching tool responses');
192
208
  step.result = '❌ Failure';
193
209
  steps.push(step);
210
+ recordStep(step);
194
211
  return steps;
195
212
  }
196
213
 
@@ -212,6 +229,9 @@ export const turnLoop = async (
212
229
  try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
213
230
  const delay = Math.min(60000, 2 ** retryCount * 2000);
214
231
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
232
+ step.events.push(`⏳ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
233
+ // step.result = '🟑 In Progress';
234
+ recordStep(step); // βœ… persist this step before sleeping
215
235
  await wait(delay);
216
236
 
217
237
  if (retryCount >= 5) {
@@ -219,14 +239,20 @@ export const turnLoop = async (
219
239
  step.events.push('❌ Too many retries. Exiting.');
220
240
  step.result = '❌ Failure';
221
241
  steps.push(step);
242
+ recordStep(step);
222
243
  return steps;
223
244
  }
224
- return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
245
+ // return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
246
+ // reattempt same turn after delay; keep same loop, bump retryCount
247
+ retryCount += 1;
248
+ turn -= 1;
249
+ continue;
225
250
  } else if (err.status === 400) {
226
251
  console.error('❌ Bad request:', err.message);
227
252
  step.events.push(`❌ Bad request: ${err.message}`);
228
253
  step.result = '❌ Failure';
229
254
  steps.push(step);
255
+ recordStep(step);
230
256
  return steps;
231
257
  } else {
232
258
  throw err;
@@ -296,7 +322,7 @@ export const turnLoop = async (
296
322
  } catch { /* non-JSON results ignored */ }
297
323
 
298
324
  console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : 'βœ… Success');
299
- const truncated = result.length > 100 ? result.slice(0, 100) + '…' : result;
325
+ const truncated = result.length > 1000 ? result.slice(0, 1000) + '…' : result;
300
326
  step.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : 'βœ… Success'}`);
301
327
  step.events.push(`[tool ] ← ${truncated}`);
302
328
 
@@ -340,6 +366,7 @@ export const turnLoop = async (
340
366
  messages.push(msg, ...toolResponses);
341
367
  step.result = 'βœ… Passed';
342
368
  steps.push(step);
369
+ recordStep(step);
343
370
  continue;
344
371
  }
345
372
 
@@ -351,6 +378,7 @@ export const turnLoop = async (
351
378
  step.events.push(finalResponse.finalMessage);
352
379
  step.result = finalResponse.success ? 'βœ… Mission Success' : '❌ Mission Failure';
353
380
  steps.push(step);
381
+ recordStep(step);
354
382
  return { success: finalResponse.finalMessage, steps };
355
383
  }
356
384
 
@@ -362,5 +390,8 @@ export const turnLoop = async (
362
390
  });
363
391
  console.log(`[auto] β†’ Injected DOM for next reasoning step`);
364
392
  step.events.push(`[auto] β†’ Injected DOM for next reasoning step`);
393
+ // This is a meaningful turn even without tool calls β€” record it.
394
+ step.result = step.result || '🟑 In Progress';
395
+ recordStep(step);
365
396
  }
366
397
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"