testronaut 1.2.4 β†’ 1.3.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
@@ -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
@@ -810,16 +810,22 @@ async function serveLatestReport() {
810
810
  await new Promise((resolve, reject) => {
811
811
  server.once('error', reject);
812
812
  server.listen(0, '127.0.0.1', resolve);
813
-
814
- process.on('SIGINT', () => {
815
- console.log('\nπŸ›‘ Shutting down…');
816
- server.close(() => {
817
- resolve();
818
- process.exit(0);
819
- });
820
- });
821
813
  });
822
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
+
823
829
  const address = server.address();
824
830
  const port = typeof address === 'object' && address ? address.port : 0;
825
831
  const baseUrl = `http://127.0.0.1:${port}`;
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,7 @@
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
29
  import fs from 'fs';
29
30
  import path from 'path';
30
31
 
@@ -46,6 +47,9 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
46
47
  const tmpDir = path.resolve(process.cwd(), 'missions/tmp');
47
48
  fs.mkdirSync(tmpDir, { recursive: true });
48
49
 
50
+ // πŸ”­ Shared Ground Control state across all goals/missions
51
+ const groundControl = createEmptyGroundControl();
52
+
49
53
  for (const goal of goals) {
50
54
  const steps = [];
51
55
  // Unique-ish JSONL file for this mission’s steps
@@ -62,22 +66,79 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
62
66
  ? goal.goal
63
67
  : goal?.goal?.toString?.() ?? String(goal.goal);
64
68
 
65
- const messages = [
66
- {
67
- role: 'system',
68
- content: `
69
- You are an autonomous web agent. Use function calls to complete the user's goal.
70
- If you are unsure of the selectors for inputs or buttons, call 'get_dom' to retrieve page HTML,
71
- analyze it, then make your best guess based on labels, names, types, and placeholder values.
72
- If you want to click a button labeled "Sign out", prefer:
69
+ // πŸ”­ Build a compact Ground Control snapshot for the prompt
70
+ const groundSummary = summarizeGroundControlForPrompt(groundControl);
71
+ let systemContent = `
72
+ You are an autonomous web agent piloting a browser for end-to-end testing.
73
+
74
+ Core behavior:
75
+ - Use function calls to complete the user's goal.
76
+ - If you are unsure of selectors for inputs or buttons, call 'get_dom' to retrieve page HTML,
77
+ analyze it, then choose selectors based on labels, names, types, and placeholder values.
78
+ - If you want to click a button labeled "Sign out", prefer:
79
+
80
+ click_text({ text: "Sign out" })
81
+
82
+ Do NOT use CSS selectors like 'button:contains("Sign out")' β€” they are invalid.
83
+
84
+ After completing the goal, respond with a final plain-text message starting with
85
+ either "SUCCESS:" or "FAILURE:" followed by a short explanation.
86
+
87
+ ──── Ground Control (persistent mission state) ────
88
+ You also have special tools to manage a compact, non-pruned state called "Ground Control".
89
+ Ground Control tracks high-level truths about the app and session, such as:
73
90
 
74
- click_text({ text: "Sign out" })
91
+ - app: { baseUrl, currentUrl, routeRole } // where the app lives and where you are now
92
+ - session: { loggedIn, userLabel, tenant } // login state and identity
93
+ - navigation:{ currentLabel } // human label for the current view
94
+ - constraints:{ stayWithinBaseUrl } // domain / navigation constraints
95
+ - telemetry: [{ ts, kind, text, status, turn, extra }] // optional breadcrumbs
75
96
 
76
- Do NOT use CSS selectors like 'button:contains("Sign out")' β€” they are invalid.
97
+ You have two Ground Control tools:
77
98
 
78
- After completing the goal, respond with a final plain-text message starting with SUCCESS or FAILURE.
79
- `.trim(),
80
- },
99
+ 1) establish_ground_control
100
+ - Use this EARLY in a mission/phase once you understand key facts, e.g.:
101
+ - The base URL (e.g. "https://ult.ultimarii.app")
102
+ - The current URL and what the page represents (routeRole: "login", "chat", "dashboard", etc.)
103
+ - Whether you are logged in or not.
104
+ - Call it once per mission phase (premission / mission / postmission) when you first
105
+ have a clear picture of where you are and what the app state is.
106
+ - Provide as many known fields as you can, but do not guess wildly. Unknown fields can be omitted.
107
+
108
+ 2) update_ground_control
109
+ - Use this when important truths change or become clearer, for example:
110
+ - URL or routeRole changes after navigation or login.
111
+ - Login state changes (loggedIn false β†’ true or vice versa).
112
+ - You discover the displayed user name or tenant.
113
+ - You identify a new page role (e.g. "chat", "settings", "feedback-form").
114
+ - Only update fields that changed or that you now know with more confidence.
115
+ - Optionally add short telemetry breadcrumbs about major milestones,
116
+ e.g. "Logged in and reached chat workspace".
117
+
118
+ General rules:
119
+ - Keep Ground Control high-level and stable; do NOT spam small, noisy updates.
120
+ - Before leaving a page for a new flow, make sure Ground Control reflects the currentUrl
121
+ and routeRole so you can reason about where you are and where you’ve been.
122
+ - Respect constraints (e.g. stayWithinBaseUrl = true) when deciding whether it is safe to navigate.
123
+
124
+ Use Ground Control as your persistent mission memory about:
125
+ - Where the app lives (baseUrl),
126
+ - Where you are (currentUrl, routeRole),
127
+ - Who you are logged in as (session),
128
+ - Any constraints about staying on the correct site.
129
+ `.trim();
130
+
131
+ if (groundSummary) {
132
+ systemContent +=
133
+ '\n\n' +
134
+ `
135
+ ──── Ground Control Snapshot ────
136
+ πŸ›°οΈ ${JSON.stringify(groundSummary, null, 2)}
137
+ `.trim();
138
+ }
139
+
140
+ const messages = [
141
+ { role: 'system', content: systemContent },
81
142
  { role: 'user', content: userContent },
82
143
  ];
83
144
 
@@ -92,6 +153,7 @@ export async function runAgent(goals, missionName, maxTurns = 20) {
92
153
  {
93
154
  steps,
94
155
  missionName,
156
+ groundControl,
95
157
  onStep: (s) => {
96
158
  // Append each step as a JSON line
97
159
  try {
package/core/turnLoop.js CHANGED
@@ -47,6 +47,13 @@ import { resolveProviderModel } from '../llm/modelResolver.js';
47
47
  import { getLLM } from '../llm/llmFactory.js';
48
48
  import { summarizeTurnIntentFromMessage } from './turnIntent.js';
49
49
  import { redactArgs } from './redaction.js';
50
+ import {
51
+ sanitizeHeavyToolHistory,
52
+ pruneConversationContext,
53
+ createEmptyGroundControl,
54
+ applyGroundControlUpdate,
55
+ recordGroundTelemetry,
56
+ } from '../tools/contextControl.js';
50
57
 
51
58
  // ─────────────────────────────────────────────
52
59
  // STEP 0: Resolve provider + model and initialize adapter
@@ -60,7 +67,26 @@ const llm = getLLM(PROVIDER_ID);
60
67
  let totalTokensUsed = 0;
61
68
  let turnTimestamps = [];
62
69
  let shouldBackoff;
63
- // let steps = [];
70
+
71
+ // Certain tools mutate the browser or produce side effects that are
72
+ // *useful for humans* (reports/screenshots), but do not carry semantic
73
+ // information that the LLM needs on subsequent turns.
74
+ // For these, we respond to the tool_call with a tiny stub ("OK"/error)
75
+ // instead of the full result payload to avoid bloating context.
76
+ //
77
+ // NOTE: We *still* log the full result in `step.events` and step metadata.
78
+ const FIRE_AND_FORGET_TOOLS = new Set([
79
+ 'screenshot',
80
+ 'click',
81
+ 'click_text',
82
+ // 'expand_menu',
83
+ 'fill',
84
+ 'upload_file',
85
+ 'download_file',
86
+ 'click_and_follow_popup',
87
+ 'switch_to_page',
88
+ 'close_current_page',
89
+ ]);
64
90
 
65
91
  /**
66
92
  * Utility: format byte counts into human-readable strings.
@@ -143,9 +169,10 @@ export const turnLoop = async (
143
169
  currentTurn = 0,
144
170
  retryCount = 0,
145
171
  currentStep = {},
146
- ctx = {} // { steps, missionName }
172
+ ctx = {} // { steps, missionName, groundControl }
147
173
  ) => {
148
- const { steps = [], missionName } = ctx;
174
+ const { steps = [], missionName, groundControl = createEmptyGroundControl() } = ctx;
175
+ ctx.groundControl = groundControl;
149
176
  let agentMemory = { lastMenuExpanded: false };
150
177
 
151
178
  // Centralized recorder: push to in-memory buffer AND fire optional callback for streaming
@@ -173,11 +200,9 @@ export const turnLoop = async (
173
200
  ({ totalTokensUsed, turnTimestamps, shouldBackoff } =
174
201
  await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
175
202
  if (shouldBackoff) {
176
- // step.result = '🟑 In Progress';
177
203
  recordStep(step); // βœ… write the partial step before sleeping
178
204
  turn -= 1; // retry same turn index
179
205
  continue;
180
- // return await turnLoop(browser, messages, maxTurns, turn, currentStep);
181
206
  }
182
207
 
183
208
  // Verify message structure integrity before requesting next model step
@@ -185,7 +210,6 @@ export const turnLoop = async (
185
210
  console.error('❌ Tool call structure invalid and no placeholders inserted.');
186
211
  step.events.push('❌ Tool call structure invalid and no placeholders inserted.');
187
212
  step.result = '❌ Failure';
188
- steps.push(step);
189
213
  recordStep(step);
190
214
  return steps;
191
215
  }
@@ -206,11 +230,22 @@ export const turnLoop = async (
206
230
  console.dir(unrespondedCalls, { depth: 5 });
207
231
  step.events.push('πŸ›‘ Detected assistant tool calls without matching tool responses');
208
232
  step.result = '❌ Failure';
209
- steps.push(step);
210
233
  recordStep(step);
211
234
  return steps;
212
235
  }
213
236
 
237
+ // Before calling the model, trim/sanitize the conversation context.
238
+ // 1) Replace older heavy tool payloads with small stubs.
239
+ sanitizeHeavyToolHistory(messages, { keepRecentPerTool: 2 });
240
+
241
+ // 2) Hard-cap total history length (system messages + last N others).
242
+ {
243
+ const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 40 });
244
+ messages.length = 0;
245
+ messages.push(...pruned);
246
+ }
247
+
248
+
214
249
  // ─────────────────────────────────────────────
215
250
  // STEP 2: Request next reasoning turn from model
216
251
  // ─────────────────────────────────────────────
@@ -230,7 +265,6 @@ export const turnLoop = async (
230
265
  const delay = Math.min(60000, 2 ** retryCount * 2000);
231
266
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
232
267
  step.events.push(`⏳ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
233
- // step.result = '🟑 In Progress';
234
268
  recordStep(step); // βœ… persist this step before sleeping
235
269
  await wait(delay);
236
270
 
@@ -238,12 +272,9 @@ export const turnLoop = async (
238
272
  console.error('❌ Too many retries. Exiting.');
239
273
  step.events.push('❌ Too many retries. Exiting.');
240
274
  step.result = '❌ Failure';
241
- steps.push(step);
242
275
  recordStep(step);
243
276
  return steps;
244
277
  }
245
- // return await turnLoop(browser, messages, maxTurns, turn, retryCount + 1, step);
246
- // reattempt same turn after delay; keep same loop, bump retryCount
247
278
  retryCount += 1;
248
279
  turn -= 1;
249
280
  continue;
@@ -251,7 +282,6 @@ export const turnLoop = async (
251
282
  console.error('❌ Bad request:', err.message);
252
283
  step.events.push(`❌ Bad request: ${err.message}`);
253
284
  step.result = '❌ Failure';
254
- steps.push(step);
255
285
  recordStep(step);
256
286
  return steps;
257
287
  } else {
@@ -307,7 +337,7 @@ export const turnLoop = async (
307
337
  result = errorMessage;
308
338
  }
309
339
 
310
- // Capture and log any file upload/download events
340
+ // Capture and log any file upload/download events (for reports)
311
341
  try {
312
342
  const maybeJson = JSON.parse(result);
313
343
  if (maybeJson && maybeJson._testronaut_file_event) {
@@ -319,14 +349,16 @@ export const turnLoop = async (
319
349
  step.events.push(msgLine);
320
350
  console.log(msgLine);
321
351
  }
322
- } catch { /* non-JSON results ignored */ }
352
+ } catch {
353
+ // non-JSON results ignored
354
+ }
323
355
 
324
356
  console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : 'βœ… Success');
325
357
  const truncated = result.length > 1000 ? result.slice(0, 1000) + '…' : result;
326
358
  step.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : 'βœ… Success'}`);
327
359
  step.events.push(`[tool ] ← ${truncated}`);
328
360
 
329
- // Screenshot detection
361
+ // Screenshot detection (for report metadata only)
330
362
  if (fnName === 'screenshot') {
331
363
  const match = result.match(/screenshot.*?saved at: (.+\.png)/i);
332
364
  if (match && match[1]) {
@@ -335,13 +367,33 @@ export const turnLoop = async (
335
367
  }
336
368
  }
337
369
 
338
- // Push corresponding tool message back to LLM
370
+ if (fnName === 'set_ground_control_state') {
371
+ applyGroundControlUpdate(groundControl, args);
372
+ result = JSON.stringify({ ok: true, groundControl });
373
+ }
374
+
375
+ if (fnName === 'record_mission_telemetry') {
376
+ const recorded = recordGroundTelemetry(groundControl, args, { turn });
377
+ result = JSON.stringify({ ok: true, recorded });
378
+ }
379
+
380
+ // Decide what to send back to the LLM for this tool.
381
+ // - For "fire-and-forget" tools, send a tiny stub (OK/error) to avoid
382
+ // bloating context with large payloads (file JSON, etc.).
383
+ // - For semantic tools (get_dom, check_text, etc.), send full result.
384
+ let contentForModel;
385
+ if (FIRE_AND_FORGET_TOOLS.has(fnName)) {
386
+ contentForModel = errorMessage || 'OK';
387
+ } else {
388
+ contentForModel = result;
389
+ }
390
+
339
391
  toolResponses.push({
340
392
  role: 'tool',
341
393
  tool_call_id: call.id,
342
394
  name: fnName,
343
395
  type: 'function',
344
- content: result,
396
+ content: contentForModel,
345
397
  });
346
398
 
347
399
  // After interactive DOM actions, refresh model context
@@ -365,7 +417,6 @@ export const turnLoop = async (
365
417
  // Merge new assistant + tool responses back into conversation
366
418
  messages.push(msg, ...toolResponses);
367
419
  step.result = 'βœ… Passed';
368
- steps.push(step);
369
420
  recordStep(step);
370
421
  continue;
371
422
  }
@@ -377,7 +428,6 @@ export const turnLoop = async (
377
428
  if (finalResponse !== null) {
378
429
  step.events.push(finalResponse.finalMessage);
379
430
  step.result = finalResponse.success ? 'βœ… Mission Success' : '❌ Mission Failure';
380
- steps.push(step);
381
431
  recordStep(step);
382
432
  return { success: finalResponse.finalMessage, steps };
383
433
  }
@@ -109,6 +109,19 @@ function safeJsonParse(s) {
109
109
  try { return JSON.parse(s ?? '{}'); } catch { return null; }
110
110
  }
111
111
 
112
+ // Gemini's functionDeclarations reject JSON Schema's `additionalProperties`; prune it recursively.
113
+ function stripAdditionalProperties(obj) {
114
+ if (!obj || typeof obj !== 'object') return obj;
115
+ if (Array.isArray(obj)) return obj.map(stripAdditionalProperties);
116
+
117
+ const out = {};
118
+ for (const [k, v] of Object.entries(obj)) {
119
+ if (k === 'additionalProperties') continue; // Gemini function declarations do not support this key
120
+ out[k] = stripAdditionalProperties(v);
121
+ }
122
+ return out;
123
+ }
124
+
112
125
  /**
113
126
  * Convert a Gemini candidate β†’ OpenAI-like assistant message.
114
127
  * - Collects text parts into `content`.
@@ -160,7 +173,7 @@ export class GeminiProvider {
160
173
  functionDeclarations: tools.map(t => ({
161
174
  name: t.function?.name ?? t.name,
162
175
  description: t.description ?? '',
163
- parameters: t.function?.parameters ?? t.parameters ?? {}, // JSON schema
176
+ parameters: stripAdditionalProperties(t.function?.parameters ?? t.parameters ?? {}), // JSON schema sans additionalProperties (unsupported by Gemini)
164
177
  })),
165
178
  }]
166
179
  : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -77,6 +77,35 @@ describe('GeminiProvider', () => {
77
77
  expect(shared.getModelOpts.tools[0].functionDeclarations.map(d => d.name)).toEqual(['foo', 'bar']);
78
78
  });
79
79
 
80
+ it('strips additionalProperties from tool schemas (unsupported by Gemini)', async () => {
81
+ const prov = new GeminiProvider({ apiKey: 'gk-123' });
82
+ const tools = [
83
+ {
84
+ type: 'function',
85
+ function: {
86
+ name: 'set_ground_control_state',
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ app: {
91
+ type: 'object',
92
+ additionalProperties: false,
93
+ properties: { baseUrl: { type: 'string' } },
94
+ },
95
+ },
96
+ additionalProperties: false,
97
+ },
98
+ },
99
+ },
100
+ ];
101
+
102
+ await prov.chat({ model: 'gemini-2.5-flash', messages: [{ role: 'user', content: 'go' }], tools });
103
+
104
+ const decl = shared.getModelOpts.tools[0].functionDeclarations[0];
105
+ expect(decl.parameters.additionalProperties).toBeUndefined();
106
+ expect(decl.parameters.properties.app.additionalProperties).toBeUndefined();
107
+ });
108
+
80
109
  it('converts OpenAI-like messages β†’ Gemini contents (system prefix + tool calls + tool results)', async () => {
81
110
  const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
82
111