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 +1 -1
- package/bin/cli.js +14 -8
- package/bin/init.js +2 -1
- package/bin/initHelpers.js +1 -0
- package/core/agent.js +75 -13
- package/core/turnLoop.js +69 -19
- package/llm/gemini/geminiProvider.js +14 -1
- package/package.json +1 -1
- package/tests/llmTests/geminiProvider.test.js +29 -0
- package/tests/toolsTests/contextControl.test.js +595 -0
- package/tests/toolsTests/domControl.test.js +329 -0
- package/tools/chromeBrowser.js +4 -0
- package/tools/contextControl.js +307 -0
- package/tools/domControl.js +129 -3
- package/tools/tokenControl.js +14 -2
- package/tools/toolSchema.js +101 -0
- package/tools/turnLoopUtils.js +0 -1
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// tests/toolsTests/contextControl.test.js
|
|
2
|
+
/**
|
|
3
|
+
* contextControl.test.js
|
|
4
|
+
* ----------------------
|
|
5
|
+
* Purpose:
|
|
6
|
+
* Unit tests for conversation context utilities:
|
|
7
|
+
* - sanitizeHeavyToolHistory: stubs older heavy tool results to shrink token load.
|
|
8
|
+
* - pruneConversationContext: trims message history while preserving tool-call structure.
|
|
9
|
+
*
|
|
10
|
+
* Design goals:
|
|
11
|
+
* - Verify that heavy tool messages (e.g., get_dom) are preserved for recent calls
|
|
12
|
+
* and stubbed for older ones.
|
|
13
|
+
* - Ensure pruning never produces a "tool without parent tool_calls" shape that
|
|
14
|
+
* would violate the OpenAI-style tool-calling protocol.
|
|
15
|
+
*
|
|
16
|
+
* Related tests:
|
|
17
|
+
* Located in `tests/toolsTests/`
|
|
18
|
+
*
|
|
19
|
+
* Used by:
|
|
20
|
+
* - core/turnLoop.js (before calling llm.chat)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import {
|
|
25
|
+
sanitizeHeavyToolHistory,
|
|
26
|
+
pruneConversationContext,
|
|
27
|
+
createEmptyGroundControl,
|
|
28
|
+
applyGroundControlUpdate,
|
|
29
|
+
recordGroundTelemetry,
|
|
30
|
+
summarizeGroundControlForPrompt
|
|
31
|
+
} from '../../tools/contextControl.js';
|
|
32
|
+
|
|
33
|
+
describe('sanitizeHeavyToolHistory', () => {
|
|
34
|
+
it('stubs older get_dom tool messages but keeps the latest N intact', () => {
|
|
35
|
+
const messages = [
|
|
36
|
+
{ role: 'tool', name: 'get_dom', content: '<html>old-1</html>' }, // should stub
|
|
37
|
+
{ role: 'tool', name: 'get_dom', content: '<html>old-2</html>' }, // should stub
|
|
38
|
+
{ role: 'tool', name: 'get_dom', content: '<html>keep-1</html>' }, // keep
|
|
39
|
+
{ role: 'tool', name: 'get_dom', content: '<html>keep-2</html>' }, // keep
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
sanitizeHeavyToolHistory(messages, { keepRecentPerTool: 2 });
|
|
43
|
+
|
|
44
|
+
// Older ones should be replaced with a stub
|
|
45
|
+
expect(messages[0].content).toBe('[get_dom output omitted for brevity – older snapshot]');
|
|
46
|
+
expect(messages[1].content).toBe('[get_dom output omitted for brevity – older snapshot]');
|
|
47
|
+
|
|
48
|
+
// Latest ones should be preserved
|
|
49
|
+
expect(messages[2].content).toBe('<html>keep-1</html>');
|
|
50
|
+
expect(messages[3].content).toBe('<html>keep-2</html>');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not touch non-heavy tool messages', () => {
|
|
54
|
+
const messages = [
|
|
55
|
+
{ role: 'tool', name: 'get_dom', content: '<html>dom</html>' },
|
|
56
|
+
{ role: 'tool', name: 'screenshot', content: 'Screenshot saved at: ./screenshots/foo.png' },
|
|
57
|
+
{ role: 'assistant', content: 'Something else' },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
sanitizeHeavyToolHistory(messages, { keepRecentPerTool: 1 });
|
|
61
|
+
|
|
62
|
+
// get_dom may be stubbed depending on index, but screenshot must not be altered
|
|
63
|
+
expect(messages[1].content).toBe(
|
|
64
|
+
'Screenshot saved at: ./screenshots/foo.png',
|
|
65
|
+
);
|
|
66
|
+
// Sanity check: roles unchanged
|
|
67
|
+
expect(messages[2].role).toBe('assistant');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is safe when there are no heavy tool messages', () => {
|
|
71
|
+
const messages = [
|
|
72
|
+
{ role: 'assistant', content: 'hello' },
|
|
73
|
+
{ role: 'tool', name: 'screenshot', content: 'Screenshot saved at: ./screenshots/foo.png' },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const clone = JSON.parse(JSON.stringify(messages));
|
|
77
|
+
sanitizeHeavyToolHistory(messages, { keepRecentPerTool: 2 });
|
|
78
|
+
|
|
79
|
+
// No mutation expected because there are no get_dom messages
|
|
80
|
+
expect(messages).toEqual(clone);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('handles empty conversations gracefully', () => {
|
|
84
|
+
const messages = [];
|
|
85
|
+
sanitizeHeavyToolHistory(messages, { keepRecentPerTool: 2 });
|
|
86
|
+
expect(messages).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('pruneConversationContext', () => {
|
|
91
|
+
it('always keeps all system messages, even if over the non-system limit', () => {
|
|
92
|
+
const messages = [
|
|
93
|
+
{ role: 'system', content: 'sys-1' },
|
|
94
|
+
{ role: 'system', content: 'sys-2' },
|
|
95
|
+
{ role: 'user', content: 'u-1' },
|
|
96
|
+
{ role: 'assistant', content: 'a-1' },
|
|
97
|
+
{ role: 'user', content: 'u-2' },
|
|
98
|
+
{ role: 'assistant', content: 'a-2' },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 2 });
|
|
102
|
+
|
|
103
|
+
// All system messages must be present
|
|
104
|
+
expect(pruned.filter(m => m.role === 'system').length).toBe(2);
|
|
105
|
+
|
|
106
|
+
// Only the last 2 non-system messages should be kept: a-2 and u-2 or similar ordering
|
|
107
|
+
const nonSystem = pruned.filter(m => m.role !== 'system');
|
|
108
|
+
expect(nonSystem.length).toBeLessThanOrEqual(2);
|
|
109
|
+
expect(nonSystem.map(m => m.content)).toEqual(['u-2', 'a-2']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('drops tool messages whose parent assistant tool_calls were pruned', () => {
|
|
113
|
+
const messages = [
|
|
114
|
+
// Old assistant + tool (should be pruned)
|
|
115
|
+
{
|
|
116
|
+
role: 'assistant',
|
|
117
|
+
content: 'old tool call',
|
|
118
|
+
tool_calls: [
|
|
119
|
+
{ id: 'call-old', type: 'function', function: { name: 'get_dom', arguments: '{}' } },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
role: 'tool',
|
|
124
|
+
tool_call_id: 'call-old',
|
|
125
|
+
name: 'get_dom',
|
|
126
|
+
content: '<html>old</html>',
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// Newer assistant + tool (should survive)
|
|
130
|
+
{
|
|
131
|
+
role: 'assistant',
|
|
132
|
+
content: 'new tool call',
|
|
133
|
+
tool_calls: [
|
|
134
|
+
{ id: 'call-new', type: 'function', function: { name: 'get_dom', arguments: '{}' } },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
role: 'tool',
|
|
139
|
+
tool_call_id: 'call-new',
|
|
140
|
+
name: 'get_dom',
|
|
141
|
+
content: '<html>new</html>',
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// Limit window so only the newer pair fits in the tail
|
|
146
|
+
const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 3 });
|
|
147
|
+
|
|
148
|
+
// The tool referencing "call-old" should be gone
|
|
149
|
+
const toolCallIds = pruned
|
|
150
|
+
.filter(m => m.role === 'tool')
|
|
151
|
+
.map(m => m.tool_call_id);
|
|
152
|
+
|
|
153
|
+
expect(toolCallIds).toContain('call-new');
|
|
154
|
+
expect(toolCallIds).not.toContain('call-old');
|
|
155
|
+
|
|
156
|
+
// The assistant for "call-new" must still be present
|
|
157
|
+
const hasNewAssistant = pruned.some(
|
|
158
|
+
m => m.role === 'assistant' &&
|
|
159
|
+
Array.isArray(m.tool_calls) &&
|
|
160
|
+
m.tool_calls.some(tc => tc.id === 'call-new'),
|
|
161
|
+
);
|
|
162
|
+
expect(hasNewAssistant).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('drops tool messages without tool_call_id defensively', () => {
|
|
166
|
+
const messages = [
|
|
167
|
+
{ role: 'assistant', content: 'plain reply' },
|
|
168
|
+
{ role: 'tool', name: 'get_dom', content: '<html>no id</html>' }, // invalid shape
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 5 });
|
|
172
|
+
|
|
173
|
+
// Tool with no tool_call_id should not be present
|
|
174
|
+
expect(pruned.some(m => m.role === 'tool')).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps a valid assistant+tool pair when both fit in the tail', () => {
|
|
178
|
+
const messages = [
|
|
179
|
+
{ role: 'user', content: 'hi' },
|
|
180
|
+
{
|
|
181
|
+
role: 'assistant',
|
|
182
|
+
content: 'tool call',
|
|
183
|
+
tool_calls: [
|
|
184
|
+
{ id: 'call-1', type: 'function', function: { name: 'get_dom', arguments: '{}' } },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
role: 'tool',
|
|
189
|
+
tool_call_id: 'call-1',
|
|
190
|
+
name: 'get_dom',
|
|
191
|
+
content: '<html>dom</html>',
|
|
192
|
+
},
|
|
193
|
+
{ role: 'assistant', content: 'follow-up' },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const pruned = pruneConversationContext(messages, { maxNonSystemMessages: 4 });
|
|
197
|
+
|
|
198
|
+
// Both assistant and tool should be present
|
|
199
|
+
const hasAssistant = pruned.some(
|
|
200
|
+
m => m.role === 'assistant' &&
|
|
201
|
+
Array.isArray(m.tool_calls) &&
|
|
202
|
+
m.tool_calls.some(tc => tc.id === 'call-1'),
|
|
203
|
+
);
|
|
204
|
+
const hasTool = pruned.some(
|
|
205
|
+
m => m.role === 'tool' && m.tool_call_id === 'call-1',
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(hasAssistant).toBe(true);
|
|
209
|
+
expect(hasTool).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('handles empty and small conversations without modification surprises', () => {
|
|
213
|
+
const empty = [];
|
|
214
|
+
const prunedEmpty = pruneConversationContext(empty, { maxNonSystemMessages: 5 });
|
|
215
|
+
expect(prunedEmpty).toEqual([]);
|
|
216
|
+
|
|
217
|
+
const small = [
|
|
218
|
+
{ role: 'system', content: 'sys' },
|
|
219
|
+
{ role: 'user', content: 'hi' },
|
|
220
|
+
{ role: 'assistant', content: 'hello' },
|
|
221
|
+
];
|
|
222
|
+
const prunedSmall = pruneConversationContext(small, { maxNonSystemMessages: 10 });
|
|
223
|
+
expect(prunedSmall).toEqual(small);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('createEmptyGroundControl', () => {
|
|
228
|
+
it('creates a normalized empty structure with expected top-level keys', () => {
|
|
229
|
+
const gc = createEmptyGroundControl();
|
|
230
|
+
|
|
231
|
+
expect(gc).toBeTruthy();
|
|
232
|
+
expect(gc).toHaveProperty('app');
|
|
233
|
+
expect(gc).toHaveProperty('session');
|
|
234
|
+
expect(gc).toHaveProperty('navigation');
|
|
235
|
+
expect(gc).toHaveProperty('constraints');
|
|
236
|
+
expect(gc).toHaveProperty('telemetry');
|
|
237
|
+
|
|
238
|
+
expect(gc.app).toEqual({
|
|
239
|
+
baseUrl: null,
|
|
240
|
+
currentUrl: null,
|
|
241
|
+
routeRole: null,
|
|
242
|
+
});
|
|
243
|
+
expect(gc.session).toEqual({
|
|
244
|
+
loggedIn: null,
|
|
245
|
+
userLabel: null,
|
|
246
|
+
tenant: null,
|
|
247
|
+
});
|
|
248
|
+
expect(gc.navigation).toEqual({
|
|
249
|
+
currentLabel: null,
|
|
250
|
+
});
|
|
251
|
+
expect(gc.constraints).toEqual({
|
|
252
|
+
stayWithinBaseUrl: null,
|
|
253
|
+
});
|
|
254
|
+
expect(Array.isArray(gc.telemetry)).toBe(true);
|
|
255
|
+
expect(gc.telemetry.length).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('applyGroundControlUpdate', () => {
|
|
260
|
+
it('applies a full update across app, session, navigation, and constraints', () => {
|
|
261
|
+
const gc = createEmptyGroundControl();
|
|
262
|
+
|
|
263
|
+
const payload = {
|
|
264
|
+
app: {
|
|
265
|
+
baseUrl: 'https://example.com',
|
|
266
|
+
currentUrl: 'https://example.com/dashboard',
|
|
267
|
+
routeRole: 'dashboard',
|
|
268
|
+
},
|
|
269
|
+
session: {
|
|
270
|
+
loggedIn: true,
|
|
271
|
+
userLabel: 'Demo User',
|
|
272
|
+
tenant: 'acme-tenant',
|
|
273
|
+
},
|
|
274
|
+
navigation: {
|
|
275
|
+
currentLabel: 'Main dashboard',
|
|
276
|
+
},
|
|
277
|
+
constraints: {
|
|
278
|
+
stayWithinBaseUrl: true,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const result = applyGroundControlUpdate(gc, payload);
|
|
283
|
+
|
|
284
|
+
// Should mutate the same reference and return it for chaining
|
|
285
|
+
expect(result).toBe(gc);
|
|
286
|
+
|
|
287
|
+
expect(gc.app).toEqual({
|
|
288
|
+
baseUrl: 'https://example.com',
|
|
289
|
+
currentUrl: 'https://example.com/dashboard',
|
|
290
|
+
routeRole: 'dashboard',
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(gc.session).toEqual({
|
|
294
|
+
loggedIn: true,
|
|
295
|
+
userLabel: 'Demo User',
|
|
296
|
+
tenant: 'acme-tenant',
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(gc.navigation).toEqual({
|
|
300
|
+
currentLabel: 'Main dashboard',
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(gc.constraints).toEqual({
|
|
304
|
+
stayWithinBaseUrl: true,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('merges partial updates without clobbering existing values', () => {
|
|
309
|
+
const gc = createEmptyGroundControl();
|
|
310
|
+
|
|
311
|
+
applyGroundControlUpdate(gc, {
|
|
312
|
+
app: {
|
|
313
|
+
baseUrl: 'https://example.com',
|
|
314
|
+
currentUrl: 'https://example.com/login',
|
|
315
|
+
},
|
|
316
|
+
session: {
|
|
317
|
+
loggedIn: false,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Second update only changes currentUrl + navigation label
|
|
322
|
+
applyGroundControlUpdate(gc, {
|
|
323
|
+
app: {
|
|
324
|
+
currentUrl: 'https://example.com/chat',
|
|
325
|
+
},
|
|
326
|
+
navigation: {
|
|
327
|
+
currentLabel: 'Chat page',
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(gc.app.baseUrl).toBe('https://example.com');
|
|
332
|
+
expect(gc.app.currentUrl).toBe('https://example.com/chat'); // updated
|
|
333
|
+
expect(gc.app.routeRole).toBeNull(); // untouched
|
|
334
|
+
|
|
335
|
+
expect(gc.session.loggedIn).toBe(false); // preserved
|
|
336
|
+
expect(gc.session.userLabel).toBeNull(); // untouched
|
|
337
|
+
|
|
338
|
+
expect(gc.navigation.currentLabel).toBe('Chat page');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('ignores unknown keys in the payload defensively', () => {
|
|
342
|
+
const gc = createEmptyGroundControl();
|
|
343
|
+
|
|
344
|
+
applyGroundControlUpdate(gc, {
|
|
345
|
+
app: {
|
|
346
|
+
baseUrl: 'https://example.com',
|
|
347
|
+
},
|
|
348
|
+
// @ts-expect-error - unknown key should be ignored
|
|
349
|
+
unknownSection: {
|
|
350
|
+
foo: 'bar',
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(gc.app.baseUrl).toBe('https://example.com');
|
|
355
|
+
// No accidental additions
|
|
356
|
+
// @ts-expect-error - unknownSection should not exist
|
|
357
|
+
expect(gc.unknownSection).toBeUndefined();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('throws if called without an existing groundControl object', () => {
|
|
361
|
+
expect(() => applyGroundControlUpdate(null, { app: { baseUrl: 'x' } }))
|
|
362
|
+
.toThrow(/applyGroundControlUpdate called without an existing groundControl state/i);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('accepts explicit null for loggedIn and stayWithinBaseUrl', () => {
|
|
366
|
+
const gc = createEmptyGroundControl();
|
|
367
|
+
|
|
368
|
+
applyGroundControlUpdate(gc, {
|
|
369
|
+
session: {
|
|
370
|
+
loggedIn: null,
|
|
371
|
+
},
|
|
372
|
+
constraints: {
|
|
373
|
+
stayWithinBaseUrl: null,
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
expect(gc.session.loggedIn).toBeNull();
|
|
378
|
+
expect(gc.constraints.stayWithinBaseUrl).toBeNull();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe('recordGroundTelemetry', () => {
|
|
383
|
+
it('records a well-formed telemetry entry with default status and timestamp', () => {
|
|
384
|
+
const gc = createEmptyGroundControl();
|
|
385
|
+
|
|
386
|
+
const entry = {
|
|
387
|
+
kind: 'breadcrumb',
|
|
388
|
+
text: 'Landed on main dashboard',
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const meta = { turn: 3 };
|
|
392
|
+
|
|
393
|
+
const recorded = recordGroundTelemetry(gc, entry, meta);
|
|
394
|
+
|
|
395
|
+
expect(recorded).toBeTruthy();
|
|
396
|
+
expect(recorded.kind).toBe('breadcrumb');
|
|
397
|
+
expect(recorded.text).toBe('Landed on main dashboard');
|
|
398
|
+
expect(recorded.status).toBe('n/a'); // default
|
|
399
|
+
expect(recorded.turn).toBe(3);
|
|
400
|
+
|
|
401
|
+
// ts should look like an ISO string
|
|
402
|
+
expect(typeof recorded.ts).toBe('string');
|
|
403
|
+
expect(recorded.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
404
|
+
|
|
405
|
+
expect(gc.telemetry.length).toBe(1);
|
|
406
|
+
expect(gc.telemetry[0]).toEqual(recorded);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('honors explicit status and supports multiple telemetry entries', () => {
|
|
410
|
+
const gc = createEmptyGroundControl();
|
|
411
|
+
|
|
412
|
+
const first = recordGroundTelemetry(gc, {
|
|
413
|
+
kind: 'assertion',
|
|
414
|
+
text: 'User appears logged in',
|
|
415
|
+
status: 'passed',
|
|
416
|
+
}, { turn: 1 });
|
|
417
|
+
|
|
418
|
+
const second = recordGroundTelemetry(gc, {
|
|
419
|
+
kind: 'issue',
|
|
420
|
+
text: 'Unexpected redirect away from base URL',
|
|
421
|
+
status: 'failed',
|
|
422
|
+
}, { turn: 4 });
|
|
423
|
+
|
|
424
|
+
expect(gc.telemetry.length).toBe(2);
|
|
425
|
+
|
|
426
|
+
expect(first.status).toBe('passed');
|
|
427
|
+
expect(second.status).toBe('failed');
|
|
428
|
+
|
|
429
|
+
// Ensure both entries are in the telemetry array in order
|
|
430
|
+
expect(gc.telemetry[0]).toEqual(first);
|
|
431
|
+
expect(gc.telemetry[1]).toEqual(second);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('returns null and does not push when kind or text is missing', () => {
|
|
435
|
+
const gc = createEmptyGroundControl();
|
|
436
|
+
|
|
437
|
+
const badNoKind = recordGroundTelemetry(gc, {
|
|
438
|
+
// @ts-expect-error - intentional malformed entry
|
|
439
|
+
text: 'Missing kind',
|
|
440
|
+
}, { turn: 1 });
|
|
441
|
+
|
|
442
|
+
const badNoText = recordGroundTelemetry(gc, {
|
|
443
|
+
// @ts-expect-error - intentional malformed entry
|
|
444
|
+
kind: 'note',
|
|
445
|
+
}, { turn: 2 });
|
|
446
|
+
|
|
447
|
+
expect(badNoKind).toBeNull();
|
|
448
|
+
expect(badNoText).toBeNull();
|
|
449
|
+
expect(gc.telemetry.length).toBe(0);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('throws if called without an existing groundControl object', () => {
|
|
453
|
+
expect(() => recordGroundTelemetry(null, {
|
|
454
|
+
kind: 'note',
|
|
455
|
+
text: 'Should fail',
|
|
456
|
+
})).toThrow(/recordGroundTelemetry called without an existing groundControl state/i);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe('summarizeGroundControlForPrompt', () => {
|
|
461
|
+
it('returns null when there is no meaningful signal', () => {
|
|
462
|
+
expect(summarizeGroundControlForPrompt(undefined)).toBeNull();
|
|
463
|
+
expect(summarizeGroundControlForPrompt(null)).toBeNull();
|
|
464
|
+
expect(summarizeGroundControlForPrompt({})).toBeNull();
|
|
465
|
+
|
|
466
|
+
// Empty but correctly-shaped GC should also yield null
|
|
467
|
+
const emptyGc = {
|
|
468
|
+
app: {},
|
|
469
|
+
session: {},
|
|
470
|
+
navigation: {},
|
|
471
|
+
telemetry: [],
|
|
472
|
+
};
|
|
473
|
+
expect(summarizeGroundControlForPrompt(emptyGc)).toBeNull();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('emits a compact snapshot when baseUrl is present', () => {
|
|
477
|
+
const gc = {
|
|
478
|
+
app: {
|
|
479
|
+
baseUrl: 'https://example.com',
|
|
480
|
+
currentUrl: 'https://example.com/login',
|
|
481
|
+
routeRole: 'login',
|
|
482
|
+
},
|
|
483
|
+
session: {
|
|
484
|
+
loggedIn: true,
|
|
485
|
+
userLabel: 'Demo User',
|
|
486
|
+
},
|
|
487
|
+
navigation: {
|
|
488
|
+
currentLabel: 'Login page',
|
|
489
|
+
},
|
|
490
|
+
telemetry: [
|
|
491
|
+
{ kind: 'breadcrumb', text: 'Loaded login page', status: 'n/a' },
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const snapshot = summarizeGroundControlForPrompt(gc);
|
|
496
|
+
expect(snapshot).toBeTruthy();
|
|
497
|
+
|
|
498
|
+
expect(snapshot.app).toEqual({
|
|
499
|
+
baseUrl: 'https://example.com',
|
|
500
|
+
currentUrl: 'https://example.com/login',
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(snapshot.session).toEqual({
|
|
504
|
+
loggedIn: true,
|
|
505
|
+
userLabel: 'Demo User',
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(snapshot.navigation).toEqual({
|
|
509
|
+
routeRole: 'login',
|
|
510
|
+
currentLabel: 'Login page',
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(Array.isArray(snapshot.telemetryLines)).toBe(true);
|
|
514
|
+
expect(snapshot.telemetryLines.length).toBe(1);
|
|
515
|
+
expect(snapshot.telemetryLines[0]).toContain('[breadcrumb]');
|
|
516
|
+
expect(snapshot.telemetryLines[0]).toContain('Loaded login page');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('limits telemetryLines to the last 5 entries and formats status correctly', () => {
|
|
520
|
+
const telemetry = [];
|
|
521
|
+
for (let i = 0; i < 7; i++) {
|
|
522
|
+
telemetry.push({
|
|
523
|
+
kind: 'assertion',
|
|
524
|
+
text: `check-${i}`,
|
|
525
|
+
status: i % 2 === 0 ? 'passed' : 'failed',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const gc = {
|
|
530
|
+
app: { baseUrl: 'https://example.com' },
|
|
531
|
+
session: {},
|
|
532
|
+
navigation: {},
|
|
533
|
+
telemetry,
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const snapshot = summarizeGroundControlForPrompt(gc);
|
|
537
|
+
expect(snapshot.telemetryLines.length).toBe(5);
|
|
538
|
+
|
|
539
|
+
// Should be last 5: indices 2..6
|
|
540
|
+
snapshot.telemetryLines.forEach((line, idx) => {
|
|
541
|
+
const expectedIndex = idx + 2;
|
|
542
|
+
expect(line).toContain(`check-${expectedIndex}`);
|
|
543
|
+
expect(line).toContain('[assertion]');
|
|
544
|
+
|
|
545
|
+
const shouldShowStatus = telemetry[expectedIndex].status !== 'n/a';
|
|
546
|
+
if (shouldShowStatus) {
|
|
547
|
+
expect(line).toContain(`(${telemetry[expectedIndex].status})`);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('normalizes loggedIn to a boolean in the snapshot', () => {
|
|
553
|
+
const gcTrue = {
|
|
554
|
+
app: { baseUrl: 'https://example.com' },
|
|
555
|
+
session: { loggedIn: true },
|
|
556
|
+
navigation: {},
|
|
557
|
+
telemetry: [],
|
|
558
|
+
};
|
|
559
|
+
const gcFalse = {
|
|
560
|
+
app: { baseUrl: 'https://example.com' },
|
|
561
|
+
session: { loggedIn: false },
|
|
562
|
+
navigation: {},
|
|
563
|
+
telemetry: [],
|
|
564
|
+
};
|
|
565
|
+
const gcNull = {
|
|
566
|
+
app: { baseUrl: 'https://example.com' },
|
|
567
|
+
session: { loggedIn: null },
|
|
568
|
+
navigation: {},
|
|
569
|
+
telemetry: [],
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
expect(summarizeGroundControlForPrompt(gcTrue)?.session.loggedIn).toBe(true);
|
|
573
|
+
expect(summarizeGroundControlForPrompt(gcFalse)?.session.loggedIn).toBe(false);
|
|
574
|
+
// null/undefined → false due to `!!` coercion
|
|
575
|
+
expect(summarizeGroundControlForPrompt(gcNull)?.session.loggedIn).toBe(false);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('uses routeRole from app and currentLabel from navigation', () => {
|
|
579
|
+
const gc = {
|
|
580
|
+
app: {
|
|
581
|
+
baseUrl: 'https://example.com',
|
|
582
|
+
routeRole: 'dashboard',
|
|
583
|
+
},
|
|
584
|
+
session: {},
|
|
585
|
+
navigation: {
|
|
586
|
+
currentLabel: 'Main dashboard',
|
|
587
|
+
},
|
|
588
|
+
telemetry: [],
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const snapshot = summarizeGroundControlForPrompt(gc);
|
|
592
|
+
expect(snapshot.navigation.routeRole).toBe('dashboard');
|
|
593
|
+
expect(snapshot.navigation.currentLabel).toBe('Main dashboard');
|
|
594
|
+
});
|
|
595
|
+
});
|