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 +2 -2
- package/bin/init.js +10 -5
- package/bin/initHelpers.js +8 -3
- package/core/turnLoop.js +32 -18
- package/llm/gemini/geminiProvider.js +34 -8
- package/package.json +1 -1
- package/tests/binTests/initHelpersTests/models.test.js +7 -1
- package/tests/coreTests/turnLoop.test.js +21 -0
- package/tests/llmTests/geminiProvider.test.js +89 -7
- package/tests/toolsTests/contextControl.test.js +33 -1
- package/tests/toolsTests/toolSchema.test.js +3 -0
- package/tests/toolsTests/turnLoopUtils.test.js +16 -0
- package/tools/contextControl.js +11 -1
- package/tools/tokenControl.js +3 -3
- package/tools/toolSchema.js +0 -15
- package/tools/turnLoopUtils.js +4 -3
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-
|
|
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-
|
|
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
|
|
126
|
-
{ title: 'Gemini
|
|
127
|
-
{ title: 'Gemini
|
|
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;
|
|
130
|
-
initial: pickInitialIndex(models, config.model, 'gemini-
|
|
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;
|
package/bin/initHelpers.js
CHANGED
|
@@ -115,9 +115,14 @@ export function openAIModels() {
|
|
|
115
115
|
*/
|
|
116
116
|
export function geminiModels() {
|
|
117
117
|
return [
|
|
118
|
-
'gemini-
|
|
119
|
-
'gemini-
|
|
120
|
-
'gemini-
|
|
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:
|
|
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(
|
|
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
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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 →
|
|
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
|
-
|
|
70
|
-
_tool_result: true,
|
|
71
|
-
tool_call_id: m.tool_call_id,
|
|
90
|
+
functionResponse: {
|
|
72
91
|
name: m.name,
|
|
73
|
-
|
|
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 {
|
|
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
|
@@ -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()).
|
|
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]
|
|
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 →
|
|
154
|
+
// 3) Tool result → structured Gemini function response
|
|
152
155
|
expect(c[2].role).toBe('user');
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
{
|
|
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
|
|
package/tools/contextControl.js
CHANGED
|
@@ -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 ----------------
|
package/tools/tokenControl.js
CHANGED
|
@@ -85,13 +85,13 @@ function getTokenizer(model) {
|
|
|
85
85
|
} catch (_) {
|
|
86
86
|
const m = String(model || '').toLowerCase();
|
|
87
87
|
|
|
88
|
-
// Treat Gemini
|
|
89
|
-
const
|
|
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
|
-
|
|
94
|
+
isGemini ||
|
|
95
95
|
isClaude ||
|
|
96
96
|
m.startsWith('gpt-5') ||
|
|
97
97
|
m.startsWith('gpt-4o') ||
|
package/tools/toolSchema.js
CHANGED
|
@@ -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: {
|
package/tools/turnLoopUtils.js
CHANGED
|
@@ -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().
|
|
7
|
-
|
|
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 (
|
|
14
|
+
if (verdict === 'failure') {
|
|
14
15
|
console.log('\n┏━ FINAL AGENT RESPONSE ━━━━━━━━━━━━━━━━━━━');
|
|
15
16
|
console.log(safeContent);
|
|
16
17
|
console.log('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|