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.
@@ -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
+ });