testronaut 1.9.3 → 1.9.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/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.
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;
@@ -115,9 +115,14 @@ export function openAIModels() {
115
115
  */
116
116
  export function geminiModels() {
117
117
  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
118
+ 'gemini-3.8-flash', // latest stable agentic model
119
+ 'gemini-3.7-flash', // previous stable agentic model
120
+ 'gemini-3.6-flash', // stable, token-efficient
121
+ 'gemini-3.5-flash-lite', // lightweight / high throughput
122
+ 'gemini-3.1-pro-preview', // highest-quality preview
123
+ 'gemini-2.5-pro', // legacy reasoning model
124
+ 'gemini-2.5-flash', // legacy fast model
125
+ 'gemini-2.5-flash-lite', // legacy lightweight model
121
126
  ];
122
127
  }
123
128
 
package/core/turnLoop.js CHANGED
@@ -194,10 +194,11 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
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 = 15000;
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
  /**
@@ -449,6 +459,7 @@ export const turnLoop = async (
449
459
  console.log('Processing tool calls...');
450
460
  const toolResponses = [];
451
461
  let hadToolIssues = false;
462
+ let domRefreshSource = null;
452
463
 
453
464
  for (const call of msg.tool_calls) {
454
465
  const fnName = call.function.name;
@@ -796,26 +807,29 @@ export const turnLoop = async (
796
807
  content: contentForModel,
797
808
  });
798
809
 
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`);
810
+ // Defer automatic DOM injection until the originating assistant call
811
+ // and all of its function responses are in history. Gemini requires a
812
+ // model functionCall to immediately follow user content or a
813
+ // functionResponse; inserting it before `msg` would create adjacent,
814
+ // reversed model function-call turns.
815
+ if (!errorMessage && ['click_text', 'click', 'expand_menu'].includes(fnName)) {
816
+ domRefreshSource = fnName;
814
817
  }
815
818
  }
816
819
 
817
820
  // Merge new assistant + tool responses back into conversation
818
821
  messages.push(msg, ...toolResponses);
822
+ if (domRefreshSource && !hadToolIssues) {
823
+ console.log(`[auto] → Injecting DOM after ${domRefreshSource}...`);
824
+ step.events.push(`[auto] → Injecting DOM after ${domRefreshSource}...`);
825
+ const domRefresh = await pushDOMAssistant(browser, messages, agentMemory, {
826
+ skipIfLastTool: ['get_dom', 'check_text'],
827
+ });
828
+ if (domRefresh?.injected) {
829
+ console.log(`[auto] → DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
830
+ step.events.push(`[auto] → DOM size after ${domRefreshSource}: ${domRefresh.domLength} chars`);
831
+ }
832
+ }
819
833
  if (hadToolIssues && turnRetries < retryLimitClamped) {
820
834
  step.result = '⏳ Retrying turn';
821
835
  const retryNumber = attempt - 1;
@@ -28,6 +28,13 @@
28
28
 
29
29
  import { GoogleGenerativeAI } from '@google/generative-ai';
30
30
 
31
+ // Gemini 3 validates thought signatures on function calls in the current turn.
32
+ // Calls synthesized by Testronaut (for example, automatic get_dom calls) were
33
+ // not emitted by Gemini and therefore have no real signature to preserve.
34
+ // Google documents this sentinel specifically for deterministic client-injected
35
+ // function-call history.
36
+ const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
37
+
31
38
  /**
32
39
  * Convert OpenAI-like messages → Gemini "contents" array.
33
40
  * - System messages are coalesced and injected as a prefix into the next user turn.
@@ -49,29 +56,41 @@ function toGeminiContents(messages) {
49
56
  continue;
50
57
  }
51
58
 
59
+ // Gemini 3 requires thought signatures to be returned exactly where the
60
+ // model emitted them. Prefer the untouched provider parts when available.
61
+ const geminiParts = m.provider_metadata?.gemini?.parts;
62
+ if (m.role === 'assistant' && Array.isArray(geminiParts) && geminiParts.length) {
63
+ contents.push({ role: 'model', parts: geminiParts });
64
+ continue;
65
+ }
66
+
52
67
  // Assistant tool calls → Gemini functionCall parts on a model turn
53
68
  if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
54
69
  contents.push({
55
70
  role: 'model',
56
- parts: m.tool_calls.map(tc => ({
71
+ parts: m.tool_calls.map((tc, index) => ({
57
72
  functionCall: {
58
73
  name: tc.function?.name,
59
74
  args: safeJsonParse(tc.function?.arguments) ?? {},
60
75
  },
76
+ // For parallel calls Gemini expects the signature only on the first
77
+ // functionCall part. Genuine provider parts take the branch above and
78
+ // retain their original signatures instead of receiving this sentinel.
79
+ ...(index === 0 ? { thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE } : {}),
61
80
  })),
62
81
  });
63
82
  continue;
64
83
  }
65
84
 
66
- // Tool results → encode as a user turn with a JSON part the model can parse
85
+ // Tool results → Gemini function responses. This keeps function-calling
86
+ // history valid for Gemini 3 and gives earlier models structured results.
67
87
  if (m.role === 'tool') {
88
+ const parsed = safeJsonParse(m.content);
68
89
  const parts = [{
69
- text: JSON.stringify({
70
- _tool_result: true,
71
- tool_call_id: m.tool_call_id,
90
+ functionResponse: {
72
91
  name: m.name,
73
- content: m.content,
74
- }),
92
+ response: parsed ?? { result: m.content },
93
+ },
75
94
  }];
76
95
  contents.push({ role: 'user', parts });
77
96
  continue;
@@ -148,7 +167,14 @@ function fromGeminiCandidate(cand) {
148
167
  }
149
168
 
150
169
  const content = texts.join('');
151
- return { role: 'assistant', content, tool_calls: tool_calls.length ? tool_calls : undefined };
170
+ return {
171
+ role: 'assistant',
172
+ content,
173
+ tool_calls: tool_calls.length ? tool_calls : undefined,
174
+ // Retain all parts, including Gemini 3 thought signatures and function IDs,
175
+ // so the next request can replay them without lossy format conversion.
176
+ provider_metadata: { gemini: { parts } },
177
+ };
152
178
  }
153
179
 
154
180
  function cryptoRandomId() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.9.3",
3
+ "version": "1.9.4",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -15,13 +15,19 @@ describe('model helpers', () => {
15
15
  'gpt-5.6-luna',
16
16
  ]));
17
17
  expect(openAIModels()).not.toContain('gpt-5.3');
18
- expect(geminiModels()).toContain('gemini-2.5-flash');
18
+ expect(geminiModels()).toEqual(expect.arrayContaining([
19
+ 'gemini-3.8-flash',
20
+ 'gemini-3.5-flash-lite',
21
+ 'gemini-3.1-pro-preview',
22
+ 'gemini-2.5-flash',
23
+ ]));
19
24
  expect(anthropicModels()).toContain('claude-sonnet-5');
20
25
  });
21
26
 
22
27
  it('recognizes known models by provider', () => {
23
28
  expect(isKnownModel('openai', 'gpt-4.1-mini')).toBe(true);
24
29
  expect(isKnownModel('gemini', 'gpt-4.1-mini')).toBe(false);
30
+ expect(isKnownModel('gemini', 'gemini-3.8-flash')).toBe(true);
25
31
  expect(isKnownModel('anthropic', 'claude-sonnet-5')).toBe(true);
26
32
  });
27
33
 
@@ -205,6 +205,27 @@ describe('turnLoop', () => {
205
205
  expect(step1.result).toBe('✅ Passed');
206
206
  expect(shared.chromeToolSpies.click_text).toHaveBeenCalledTimes(1);
207
207
  expect(shared.chromeToolSpies.get_dom).toHaveBeenCalled();
208
+ expect(shared.chromeToolSpies.get_dom).toHaveBeenCalledWith(
209
+ browser,
210
+ expect.objectContaining({ limit: 15000, exclude: true }),
211
+ expect.any(Object)
212
+ );
213
+
214
+ const secondRequestMessages = shared.chatMock.mock.calls[1][0].messages;
215
+ const clickCallIndex = secondRequestMessages.findIndex(message =>
216
+ message.role === 'assistant' && message.tool_calls?.[0]?.function?.name === 'click_text'
217
+ );
218
+ expect(secondRequestMessages.slice(clickCallIndex, clickCallIndex + 4).map(message => ({
219
+ role: message.role,
220
+ tool: message.tool_calls?.[0]?.function?.name ?? message.name,
221
+ }))).toEqual([
222
+ { role: 'assistant', tool: 'click_text' },
223
+ { role: 'tool', tool: 'click_text' },
224
+ { role: 'assistant', tool: 'get_dom' },
225
+ { role: 'tool', tool: 'get_dom' },
226
+ ]);
227
+ expect(JSON.parse(secondRequestMessages[clickCallIndex + 2].tool_calls[0].function.arguments))
228
+ .toEqual({ limit: 15000, exclude: true });
208
229
 
209
230
  const step2 = res.steps[1];
210
231
  expect(step2.result).toMatch(/Success/);
@@ -146,14 +146,17 @@ describe('GeminiProvider', () => {
146
146
 
147
147
  // 2) Assistant tool call → model role with functionCall
148
148
  expect(c[1].role).toBe('model');
149
- expect(c[1].parts[0].functionCall).toEqual({ name: 'search', args: { q: 'x' } });
149
+ expect(c[1].parts[0]).toEqual({
150
+ functionCall: { name: 'search', args: { q: 'x' } },
151
+ thoughtSignature: 'skip_thought_signature_validator',
152
+ });
150
153
 
151
- // 3) Tool result → user role with JSON marker
154
+ // 3) Tool result → structured Gemini function response
152
155
  expect(c[2].role).toBe('user');
153
- const toolJson = JSON.parse(c[2].parts[0].text);
154
- expect(toolJson._tool_result).toBe(true);
155
- expect(toolJson.tool_call_id).toBe('1');
156
- expect(toolJson.name).toBe('search');
156
+ expect(c[2].parts[0].functionResponse).toEqual({
157
+ name: 'search',
158
+ response: { results: [1, 2, 3] },
159
+ });
157
160
 
158
161
  // 4) Final assistant text → model role with text
159
162
  expect(c[3].role).toBe('model');
@@ -168,7 +171,10 @@ describe('GeminiProvider', () => {
168
171
  {
169
172
  content: {
170
173
  parts: [
171
- { functionCall: { name: 'open', args: { url: 'https://example.com' } } },
174
+ {
175
+ functionCall: { name: 'open', args: { url: 'https://example.com' } },
176
+ thoughtSignature: 'encrypted-signature',
177
+ },
172
178
  { text: 'Ok!' },
173
179
  ],
174
180
  },
@@ -189,11 +195,87 @@ describe('GeminiProvider', () => {
189
195
  expect(Array.isArray(message.tool_calls)).toBe(true);
190
196
  expect(message.tool_calls[0].function.name).toBe('open');
191
197
  expect(JSON.parse(message.tool_calls[0].function.arguments)).toEqual({ url: 'https://example.com' });
198
+ expect(message.provider_metadata.gemini.parts[0].thoughtSignature).toBe('encrypted-signature');
192
199
 
193
200
  expect(usage.total_tokens).toBe(999);
194
201
  expect(usage.providerRaw).toEqual({ totalTokenCount: 999, other: 'meta' });
195
202
  });
196
203
 
204
+ it('replays Gemini response parts so thought signatures survive tool loops', async () => {
205
+ const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
206
+ const signedPart = {
207
+ functionCall: { name: 'click', args: { selector: '#submit' } },
208
+ thoughtSignature: 'opaque-signature',
209
+ };
210
+
211
+ await prov.chat({
212
+ model: 'gemini-3.8-flash',
213
+ messages: [
214
+ { role: 'user', content: 'Submit the form' },
215
+ {
216
+ role: 'assistant',
217
+ content: '',
218
+ tool_calls: [{ type: 'function', function: { name: 'click', arguments: '{"selector":"#submit"}' } }],
219
+ provider_metadata: { gemini: { parts: [signedPart] } },
220
+ },
221
+ { role: 'tool', name: 'click', content: 'Clicked' },
222
+ ],
223
+ });
224
+
225
+ expect(shared.lastContents[1]).toEqual({ role: 'model', parts: [signedPart] });
226
+ expect(shared.lastContents[2].parts[0].functionResponse).toEqual({
227
+ name: 'click',
228
+ response: { result: 'Clicked' },
229
+ });
230
+ });
231
+
232
+ it('signs deterministic client-injected function calls for Gemini 3 history', async () => {
233
+ const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
234
+
235
+ await prov.chat({
236
+ model: 'gemini-3.8-flash',
237
+ messages: [
238
+ { role: 'user', content: 'Open the dashboard' },
239
+ {
240
+ role: 'assistant',
241
+ content: '',
242
+ tool_calls: [{
243
+ id: 'synthetic-dom',
244
+ type: 'function',
245
+ function: { name: 'get_dom', arguments: '{"selector":"main"}' },
246
+ }],
247
+ },
248
+ { role: 'tool', tool_call_id: 'synthetic-dom', name: 'get_dom', content: '<main>Dashboard</main>' },
249
+ ],
250
+ });
251
+
252
+ expect(shared.lastContents[1].parts[0]).toEqual({
253
+ functionCall: { name: 'get_dom', args: { selector: 'main' } },
254
+ thoughtSignature: 'skip_thought_signature_validator',
255
+ });
256
+ });
257
+
258
+ it('adds the synthetic signature only to the first parallel function call', async () => {
259
+ const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
260
+
261
+ await prov.chat({
262
+ model: 'gemini-3.8-flash',
263
+ messages: [
264
+ { role: 'user', content: 'Inspect both regions' },
265
+ {
266
+ role: 'assistant',
267
+ tool_calls: [
268
+ { type: 'function', function: { name: 'get_dom', arguments: '{"selector":"main"}' } },
269
+ { type: 'function', function: { name: 'get_dom', arguments: '{"selector":"nav"}' } },
270
+ ],
271
+ },
272
+ ],
273
+ });
274
+
275
+ expect(shared.lastContents[1].parts[0].thoughtSignature).toBe('skip_thought_signature_validator');
276
+ expect(shared.lastContents[1].parts[1].thoughtSignature).toBeUndefined();
277
+ });
278
+
197
279
  it('supports image parts (encoded to inlineData)', async () => {
198
280
  const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
199
281
 
@@ -109,6 +109,38 @@ describe('pruneConversationContext', () => {
109
109
  expect(nonSystem.map(m => m.content)).toEqual(['u-2', 'a-2']);
110
110
  });
111
111
 
112
+ it('keeps the latest real user instruction as an anchor for long tool histories', () => {
113
+ const messages = [
114
+ { role: 'system', content: 'system' },
115
+ { role: 'user', content: 'Complete the mission' },
116
+ ];
117
+
118
+ for (let i = 0; i < 25; i += 1) {
119
+ messages.push({
120
+ role: 'assistant',
121
+ tool_calls: [{
122
+ id: `call-${i}`,
123
+ type: 'function',
124
+ function: { name: 'get_dom', arguments: '{}' },
125
+ }],
126
+ });
127
+ messages.push({
128
+ role: 'tool',
129
+ tool_call_id: `call-${i}`,
130
+ name: 'get_dom',
131
+ content: `<main>${i}</main>`,
132
+ });
133
+ }
134
+
135
+ const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 10 });
136
+
137
+ expect(pruned[0]).toEqual({ role: 'system', content: 'system' });
138
+ expect(pruned[1]).toEqual({ role: 'user', content: 'Complete the mission' });
139
+ expect(pruned.slice(2)).toHaveLength(10);
140
+ expect(pruned[2].role).toBe('assistant');
141
+ expect(pruned[3].role).toBe('tool');
142
+ });
143
+
112
144
  it('drops tool messages whose parent assistant tool_calls were pruned', () => {
113
145
  const messages = [
114
146
  // Old assistant + tool (should be pruned)
@@ -592,4 +624,4 @@ describe('summarizeGroundControlForPrompt', () => {
592
624
  expect(snapshot.navigation.routeRole).toBe('dashboard');
593
625
  expect(snapshot.navigation.currentLabel).toBe('Main dashboard');
594
626
  });
595
- });
627
+ });
@@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest';
2
2
  import toolsSchema from '../../tools/toolSchema.js';
3
3
 
4
4
  describe('tools/toolSchema', () => {
5
+ it('does not advertise the unimplemented get_dom_chunk tool', () => {
6
+ expect(toolsSchema.map(tool => tool.function.name)).not.toContain('get_dom_chunk');
7
+ });
5
8
  it('includes resource_progress tool with empty params', () => {
6
9
  const entry = toolsSchema.find(t => t.function?.name === 'resource_progress');
7
10
  expect(entry?.type).toBe('function');
@@ -2,6 +2,22 @@ import { describe, it, expect, vi } from 'vitest';
2
2
  import { finalResponseHandler } from '../../tools/turnLoopUtils.js';
3
3
 
4
4
  describe('tools/turnLoopUtils', () => {
5
+ it.each([
6
+ ['SUCCESS: completed', true],
7
+ ['FAILURE: blocked', false],
8
+ ['RESULT: SUCCESS - all checkpoints passed', true],
9
+ ['RESULT: FAILURE - checkpoint failed', false],
10
+ ['FINAL: success', true],
11
+ ['**RESULT: SUCCESS**', true],
12
+ ])('recognizes supported verdict form %s', (content, success) => {
13
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
14
+ try {
15
+ expect(finalResponseHandler({ content })).toMatchObject({ success });
16
+ } finally {
17
+ logSpy.mockRestore();
18
+ }
19
+ });
20
+
5
21
  it('redacts verification codes before printing and returning final responses', () => {
6
22
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
7
23
 
@@ -44,6 +44,7 @@ export function sanitizeHeavyToolHistory(messages, { keepRecentPerTool = 2 } = {
44
44
  *
45
45
  * Strategy:
46
46
  * - Always keep all system messages.
47
+ * - Always keep the latest real user instruction as a protocol anchor.
47
48
  * - Start from the end and keep the last `maxNonSystemMessages` non-system messages.
48
49
  * - Ensure protocol correctness:
49
50
  * * If a tool message is kept, its parent assistant with matching tool_call_id
@@ -63,6 +64,15 @@ export function pruneConversationContext(messages, { maxNonSystemMessages = 40 }
63
64
  // Take last N non-system messages (raw window)
64
65
  const tail = nonSystem.slice(-maxNonSystemMessages);
65
66
 
67
+ // Gemini function-calling validation locates the current turn by scanning
68
+ // backward for a normal user message (not a function response). Testronaut's
69
+ // internal tool results use role `tool`, so retain the newest role `user`
70
+ // message even when a long agent loop pushes it outside the rolling tail.
71
+ // This is also useful context for OpenAI and Anthropic: it is the instruction
72
+ // the retained tool activity is attempting to satisfy.
73
+ const latestUser = nonSystem.findLast(m => m.role === 'user');
74
+ const userAnchor = latestUser && !tail.includes(latestUser) ? [latestUser] : [];
75
+
66
76
  // Pass 1: build map of tool_call_id -> index of assistant in tail
67
77
  const assistantToolParents = new Map(); // tool_call_id -> index in tail
68
78
  tail.forEach((m, idx) => {
@@ -93,7 +103,7 @@ export function pruneConversationContext(messages, { maxNonSystemMessages = 40 }
93
103
  cleanedTail.push(m);
94
104
  }
95
105
 
96
- return [...systemMessages, ...cleanedTail];
106
+ return [...systemMessages, ...userAnchor, ...cleanedTail];
97
107
  }
98
108
 
99
109
  /* ---------------- Ground Control state helpers ----------------
@@ -85,13 +85,13 @@ function getTokenizer(model) {
85
85
  } catch (_) {
86
86
  const m = String(model || '').toLowerCase();
87
87
 
88
- // Treat Gemini 2.5 like modern long-context models
89
- const isGemini25 = /^gemini-2\.5/.test(m);
88
+ // Treat current Gemini generations like modern long-context models.
89
+ const isGemini = /^gemini-(?:2\.5|3(?:\.|-))/.test(m);
90
90
  const isClaude = /^claude-/.test(m);
91
91
 
92
92
  // OpenAI modern families also map well to o200k_base
93
93
  const useO200k =
94
- isGemini25 ||
94
+ isGemini ||
95
95
  isClaude ||
96
96
  m.startsWith('gpt-5') ||
97
97
  m.startsWith('gpt-4o') ||
@@ -154,21 +154,6 @@ const toolsSchema = [
154
154
  // }
155
155
  // }
156
156
  // },
157
- {
158
- type: 'function',
159
- function: {
160
- name: 'get_dom_chunk',
161
- description: 'Return a specific chunk of the DOM by index',
162
- parameters: {
163
- type: 'object',
164
- properties: {
165
- chunkIndex: { type: 'number' },
166
- totalChunks: { type: 'number' }
167
- },
168
- required: ['chunkIndex', 'totalChunks']
169
- }
170
- }
171
- },
172
157
  {
173
158
  type: 'function',
174
159
  function: {
@@ -3,14 +3,15 @@ import { redactPasswordInText } from '../core/redaction.js';
3
3
  export const finalResponseHandler = (msg) => {
4
4
  const content = String(msg.content ?? '');
5
5
  const safeContent = redactPasswordInText(content);
6
- const final = content.trim().toLowerCase();
7
- if (final?.startsWith('success')) {
6
+ const final = content.trim().replace(/^[#*_`\s]+/, '');
7
+ const verdict = final.match(/^(?:(?:result|final)\s*:\s*)?(success|failure)\b/i)?.[1]?.toLowerCase();
8
+ if (verdict === 'success') {
8
9
  console.log('\n┏━ FINAL AGENT RESPONSE ━━━━━━━━━━━━━━━━━━━');
9
10
  console.log(safeContent);
10
11
  console.log('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
11
12
  return { finalMessage: safeContent, success: true};
12
13
  }
13
- if (final?.startsWith('failure')) {
14
+ if (verdict === 'failure') {
14
15
  console.log('\n┏━ FINAL AGENT RESPONSE ━━━━━━━━━━━━━━━━━━━');
15
16
  console.log(safeContent);
16
17
  console.log('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');