testronaut 1.9.3 → 1.10.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,38 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChromeBrowser } from '../../tools/chromeBrowser.js';
3
+
4
+ function makeBrowser({ visible = true, content = 'Mission Tasks' } = {}) {
5
+ const locator = {
6
+ first: () => locator,
7
+ waitFor: vi.fn(() => visible ? Promise.resolve() : Promise.reject(new Error('not visible'))),
8
+ textContent: vi.fn().mockResolvedValue(content),
9
+ };
10
+ const browser = Object.create(ChromeBrowser.prototype);
11
+ browser.page = {
12
+ goto: vi.fn().mockResolvedValue(undefined),
13
+ waitForLoadState: vi.fn().mockResolvedValue(undefined),
14
+ locator: vi.fn(() => locator),
15
+ url: vi.fn(() => 'https://example.test/dashboard'),
16
+ };
17
+ return { browser, locator };
18
+ }
19
+
20
+ describe('ChromeBrowser.probe', () => {
21
+ it('passes after navigating when selector and text match', async () => {
22
+ const { browser } = makeBrowser();
23
+ await expect(browser.probe({
24
+ url: 'https://example.test',
25
+ selector: '#dashboard',
26
+ text: 'Mission Tasks',
27
+ })).resolves.toMatchObject({ passed: true });
28
+ expect(browser.page.goto).toHaveBeenCalledWith('https://example.test', expect.any(Object));
29
+ });
30
+
31
+ it('fails without invoking an agent when the selector is not visible', async () => {
32
+ const { browser } = makeBrowser({ visible: false });
33
+ await expect(browser.probe({ selector: '#dashboard' })).resolves.toMatchObject({
34
+ passed: false,
35
+ reason: 'selector not visible: #dashboard',
36
+ });
37
+ });
38
+ });
@@ -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
+ });
@@ -16,6 +16,7 @@ import {
16
16
  collapseLongContent,
17
17
  chunkBySection,
18
18
  reduceRepeatedDivs,
19
+ reduceTableRows,
19
20
  removeObfuscatedClassNames,
20
21
  removeAutoGeneratedClasses,
21
22
  minifyDomWhitespace,
@@ -232,6 +233,17 @@ describe('domControl helpers', () => {
232
233
  });
233
234
  });
234
235
 
236
+ describe('reduceTableRows', () => {
237
+ it('keeps headers, trims body rows, and adds a marker', () => {
238
+ const $ = cheerio.load('<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>A</td></tr><tr><td>B</td></tr><tr><td>C</td></tr></tbody></table>');
239
+ reduceTableRows($, 2);
240
+ expect($('thead tr')).toHaveLength(1);
241
+ expect($('tbody > tr')).toHaveLength(3);
242
+ expect($('tbody > tr').eq(1).text()).toBe('B');
243
+ expect($('tbody > tr').eq(2).attr('data-collapsed')).toBe('true');
244
+ });
245
+ });
246
+
235
247
  describe('removeObfuscatedClassNames', () => {
236
248
  it('removes short and hashed-looking class names', () => {
237
249
  const html = `
@@ -2,6 +2,14 @@ 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
+ });
8
+
9
+ it('allows get_dom calls to be scoped by selector', () => {
10
+ const entry = toolsSchema.find(tool => tool.function.name === 'get_dom');
11
+ expect(entry.function.parameters.properties.selector.type).toBe('string');
12
+ });
5
13
  it('includes resource_progress tool with empty params', () => {
6
14
  const entry = toolsSchema.find(t => t.function?.name === 'resource_progress');
7
15
  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
 
@@ -14,6 +14,7 @@ import * as cheerio from 'cheerio';
14
14
  import {
15
15
  chunkBySection,
16
16
  reduceRepeatedDivs,
17
+ reduceTableRows,
17
18
  removeAutoGeneratedClasses,
18
19
  removeNonContentTags,
19
20
  removeObfuscatedClassNames,
@@ -57,6 +58,7 @@ export class ChromeBrowser {
57
58
  this.domListLimit = options.domListLimit;
58
59
  this.debugEnabled = !!options.debug;
59
60
  this._domLimitLogged = false;
61
+ this.storageStatePath = options.storageStatePath;
60
62
 
61
63
  const defaultResourceGuard = {
62
64
  enabled: true,
@@ -601,9 +603,12 @@ export class ChromeBrowser {
601
603
 
602
604
  ensureDir(FILES_DIR); // ⬅️ make sure missions/files exists
603
605
 
604
- this.context = await this.browser.newContext({
605
- acceptDownloads: true // ⬅️ important for Playwright download handling
606
- });
606
+ const contextOptions = { acceptDownloads: true };
607
+ if (this.storageStatePath && fs.existsSync(this.storageStatePath)) {
608
+ contextOptions.storageState = this.storageStatePath;
609
+ this._debugLog('Restoring shared authentication state', { path: this.storageStatePath });
610
+ }
611
+ this.context = await this.browser.newContext(contextOptions);
607
612
  // this.context = await this.browser.newContext();
608
613
  const first = await this.context.newPage();
609
614
  const id = this._addAndFocus(first);
@@ -636,6 +641,26 @@ export class ChromeBrowser {
636
641
  return `navigated to ${this.page.url()}`;
637
642
  }
638
643
 
644
+ async probe({ url, selector, text, timeout = 5000 } = {}) {
645
+ if (url) {
646
+ await this.page.goto(url, { timeout: 30_000, waitUntil: 'domcontentloaded' });
647
+ await this.page.waitForLoadState('domcontentloaded').catch(() => {});
648
+ }
649
+
650
+ let locator = this.page.locator(selector || 'body').first();
651
+ if (selector) {
652
+ const visible = await locator.waitFor({ state: 'visible', timeout }).then(() => true).catch(() => false);
653
+ if (!visible) return { passed: false, reason: `selector not visible: ${selector}`, url: this.page.url() };
654
+ }
655
+ if (text) {
656
+ const content = await locator.textContent({ timeout }).catch(() => '');
657
+ if (!String(content || '').includes(String(text))) {
658
+ return { passed: false, reason: `text not found: ${text}`, url: this.page.url() };
659
+ }
660
+ }
661
+ return { passed: true, url: this.page.url() };
662
+ }
663
+
639
664
  async navigateEmailLink({ url, allowedHosts = [] }) {
640
665
  const destination = new URL(url);
641
666
  if (destination.protocol !== 'https:' || destination.username || destination.password) {
@@ -859,7 +884,7 @@ export class ChromeBrowser {
859
884
 
860
885
 
861
886
  async get_dom(
862
- { limit = 100000, exclude = true, focus = [], loadState = 'networkidle', timeout = 10000 } = {},
887
+ { limit = 100000, exclude = true, selector, focus = [], loadState = 'networkidle', timeout = 10000 } = {},
863
888
  agentMemory = {}
864
889
  ) {
865
890
  const matchesHrefPattern = (href = '') => {
@@ -985,9 +1010,21 @@ export class ChromeBrowser {
985
1010
  // --- 4) Optional DOM reduction/cleanup (your existing steps) ---
986
1011
  let $ = cheerio.load(html);
987
1012
 
1013
+ if (selector) {
1014
+ let selection;
1015
+ try {
1016
+ selection = $(selector);
1017
+ } catch (error) {
1018
+ throw new Error(`Invalid get_dom selector "${selector}": ${error.message}`);
1019
+ }
1020
+ if (!selection.length) return `[get_dom: no elements matched selector "${selector}"]`;
1021
+ $ = cheerio.load(selection.toString());
1022
+ }
1023
+
988
1024
  if (exclude) {
989
1025
  $ = await removeNonContentTags($);
990
1026
  $ = await reduceRepeatedDivs($, listLimit);
1027
+ reduceTableRows($, listLimit);
991
1028
  // If you later re-enable:
992
1029
  // $ = await removeObfuscatedClassNames($);
993
1030
  // $ = await removeAutoGeneratedClasses($);
@@ -1386,7 +1423,15 @@ export class ChromeBrowser {
1386
1423
 
1387
1424
 
1388
1425
  async close() {
1389
- await this.browser?.close();
1426
+ try {
1427
+ if (this.storageStatePath && this.context) {
1428
+ ensureDir(path.dirname(this.storageStatePath));
1429
+ await this.context.storageState({ path: this.storageStatePath, indexedDB: true });
1430
+ this._debugLog('Saved shared authentication state', { path: this.storageStatePath });
1431
+ }
1432
+ } finally {
1433
+ await this.browser?.close();
1434
+ }
1390
1435
  }
1391
1436
  }
1392
1437
 
@@ -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 ----------------
@@ -123,7 +123,7 @@ export const chunkBySection = async ($, focus) => {
123
123
  footer: $('footer').html() || '',
124
124
  aside: $('aside').html() || '',
125
125
  body: $('body').html() || ''
126
- };
126
+ };
127
127
 
128
128
  if (Array.isArray(focus) && focus.length > 0) {
129
129
  return Object.fromEntries(
@@ -144,6 +144,25 @@ export const chunkBySection = async ($, focus) => {
144
144
 
145
145
  }
146
146
 
147
+ /** Keep table headers and only the first N body rows in each table. */
148
+ export function reduceTableRows($, maxAllowed = 3) {
149
+ let limit = maxAllowed === Infinity ? Infinity : Number(maxAllowed);
150
+ if (limit === Infinity) return;
151
+ limit = Number.isFinite(limit) ? Math.max(0, limit) : 3;
152
+
153
+ $('table').each((_, table) => {
154
+ const $table = $(table);
155
+ const rows = $table.find('tbody > tr');
156
+ const candidates = rows.length ? rows : $table.children('tr');
157
+ if (candidates.length <= limit) return;
158
+ candidates.slice(limit).remove();
159
+ const colspan = Math.max(1, $table.find('tr').first().children('th, td').length);
160
+ const marker = `<tr data-collapsed="true"><td colspan="${colspan}">[...truncated]</td></tr>`;
161
+ if (rows.length) $table.children('tbody').first().append(marker);
162
+ else $table.append(marker);
163
+ });
164
+ }
165
+
147
166
  /**
148
167
  * Reduce repeated sibling <div> blocks that share the same class name.
149
168
  * - For each parent <body> or <div>, group child divs by `class`.
@@ -102,7 +102,7 @@ export function generateHtmlReport(report, outputPath) {
102
102
  ${humanInput}
103
103
  ${planSpan}
104
104
  <span class="step-result ${ok ? 'ok' : 'bad'}" ${resultTooltip ? `title="${esc(resultTooltip)}"` : ''}>${esc(resultRaw)}</span>
105
- <span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} / total: ${esc(step.totalTokensUsed ?? '—')}</span>
105
+ <span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} (in: ${esc(step.inputTokens ?? '—')}, out: ${esc(step.outputTokens ?? '—')}) / rolling: ${esc(step.totalTokensUsed ?? '—')}</span>
106
106
  </summary>
107
107
  <pre class="events">${esc(events.join('\n')) || '(no events)'}</pre>
108
108
  ${imgTag}
@@ -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') ||
@@ -83,6 +83,10 @@ const toolsSchema = [
83
83
  default: true,
84
84
  description: 'Whether to exclude noisy tags like script/style/etc.',
85
85
  },
86
+ selector: {
87
+ type: 'string',
88
+ description: 'Optional CSS selector that scopes the returned DOM to matching elements.',
89
+ },
86
90
  },
87
91
  },
88
92
  },
@@ -154,21 +158,6 @@ const toolsSchema = [
154
158
  // }
155
159
  // }
156
160
  // },
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
161
  {
173
162
  type: 'function',
174
163
  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('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');