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,329 @@
1
+ // tests/toolsTests/domControl.test.js
2
+ /**
3
+ * domControl.test.js
4
+ * -------------------
5
+ * Purpose:
6
+ * Unit tests for DOM normalization helpers in domControl.js.
7
+ * Ensures we preserve semantic structure/text while stripping noise
8
+ * and collapsing long/repetitive content.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import * as cheerio from 'cheerio';
13
+
14
+ import {
15
+ removeNonContentTags,
16
+ collapseLongContent,
17
+ chunkBySection,
18
+ reduceRepeatedDivs,
19
+ removeObfuscatedClassNames,
20
+ removeAutoGeneratedClasses,
21
+ minifyDomWhitespace,
22
+ } from '../../tools/domControl.js'; // ← adjust path as needed
23
+
24
+ describe('domControl helpers', () => {
25
+ describe('removeNonContentTags', () => {
26
+ it('removes non-content tags and non-whitelisted attributes', async () => {
27
+ const html = `
28
+ <html>
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <script>console.log('hi');</script>
32
+ <style>.foo { color: red; }</style>
33
+ <link rel="stylesheet" href="style.css" />
34
+ </head>
35
+ <body data-theme="dark">
36
+ <!-- comment -->
37
+ <div id="main" data-testid="main" onclick="alert('x')">
38
+ <iframe src="frame.html"></iframe>
39
+ <canvas></canvas>
40
+ <svg></svg>
41
+ <a href="/home" rel="noopener" target="_blank">Home</a>
42
+ <input id="email" name="email" type="email" data-tracking="abc" />
43
+ </div>
44
+ </body>
45
+ </html>
46
+ `;
47
+ const $ = cheerio.load(html);
48
+
49
+ await removeNonContentTags($);
50
+ const result = $.html();
51
+
52
+ // Non-content tags removed
53
+ expect(result).not.toContain('<script');
54
+ expect(result).not.toContain('<style');
55
+ expect(result).not.toContain('<meta');
56
+ expect(result).not.toContain('<link');
57
+ expect(result).not.toContain('<iframe');
58
+ expect(result).not.toContain('<canvas');
59
+ expect(result).not.toContain('<svg');
60
+ expect(result).not.toContain('<!-- comment -->');
61
+
62
+ // Body-level non-whitelisted attribute stripped
63
+ expect($('body').attr('data-theme')).toBeUndefined();
64
+
65
+ // Whitelisted attrs preserved
66
+ const main = $('#main');
67
+ expect(main.attr('id')).toBe('main');
68
+ expect(main.attr('data-testid')).toBeUndefined();
69
+ expect(main.attr('onclick')).toBeUndefined();
70
+
71
+ const input = $('#email');
72
+ expect(input.attr('id')).toBe('email');
73
+ expect(input.attr('name')).toBe('email');
74
+ expect(input.attr('type')).toBe('email');
75
+ expect(input.attr('data-tracking')).toBeUndefined();
76
+
77
+ const link = $('a[href="/home"]');
78
+ expect(link.attr('href')).toBe('/home');
79
+ // rel/target should be stripped (not whitelisted)
80
+ expect(link.attr('rel')).toBeUndefined();
81
+ expect(link.attr('target')).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe('collapseLongContent', () => {
86
+ it('collapses long lists and appends a truncated marker', async () => {
87
+ const html = `
88
+ <ul id="list">
89
+ <li>Item 1</li>
90
+ <li>Item 2</li>
91
+ <li>Item 3</li>
92
+ <li>Item 4</li>
93
+ </ul>
94
+ <div id="cards">
95
+ <div class="card">A</div>
96
+ <div class="card">B</div>
97
+ <div class="card">C</div>
98
+ <div class="card">D</div>
99
+ </div>
100
+ `;
101
+ const $ = cheerio.load(html);
102
+
103
+ await collapseLongContent($, 2);
104
+
105
+ const listItems = $('#list').children('li');
106
+ expect(listItems.length).toBe(3); // 2 kept + 1 truncated
107
+ expect(listItems.eq(0).text()).toBe('Item 1');
108
+ expect(listItems.eq(1).text()).toBe('Item 2');
109
+ expect(listItems.eq(2).attr('data-collapsed')).toBe('true');
110
+
111
+ const cardChildren = $('#cards').children();
112
+ expect(cardChildren.length).toBe(3); // 2 cards + truncated div
113
+ expect(cardChildren.eq(0).hasClass('card')).toBe(true);
114
+ expect(cardChildren.eq(1).hasClass('card')).toBe(true);
115
+ expect(cardChildren.eq(2).attr('data-collapsed')).toBe('true');
116
+ });
117
+ });
118
+
119
+ describe('chunkBySection', () => {
120
+ it('returns section HTML for header/nav/main/footer/aside/body', async () => {
121
+ const html = `
122
+ <html>
123
+ <body>
124
+ <header><h1>Title</h1></header>
125
+ <nav><a href="/a">A</a></nav>
126
+ <main><p>Content</p></main>
127
+ <aside><p>Side</p></aside>
128
+ <footer><p>Foot</p></footer>
129
+ </body>
130
+ </html>
131
+ `;
132
+ const $ = cheerio.load(html);
133
+
134
+ const chunks = await chunkBySection($);
135
+
136
+ expect(chunks.header).toContain('Title');
137
+ expect(chunks.nav).toContain('href="/a"');
138
+ expect(chunks.main).toContain('Content');
139
+ expect(chunks.aside).toContain('Side');
140
+ expect(chunks.footer).toContain('Foot');
141
+ expect(chunks.body).toContain('<header>');
142
+ });
143
+
144
+ it('honors the focus list and only returns requested keys', async () => {
145
+ const html = `
146
+ <body>
147
+ <header>H</header>
148
+ <main>M</main>
149
+ <footer>F</footer>
150
+ </body>
151
+ `;
152
+ const $ = cheerio.load(html);
153
+
154
+ const chunks = await chunkBySection($, ['main', 'footer']);
155
+
156
+ expect(Object.keys(chunks).sort()).toEqual(['footer', 'main']);
157
+ expect(chunks.main).toContain('M');
158
+ expect(chunks.footer).toContain('F');
159
+ });
160
+ });
161
+
162
+ describe('reduceRepeatedDivs', () => {
163
+ it('reduces repeated divs by class and appends a collapsed marker', () => {
164
+ const html = `
165
+ <div id="root">
166
+ <div class="card">1</div>
167
+ <div class="card">2</div>
168
+ <div class="card">3</div>
169
+ <div class="card">4</div>
170
+ <div class="other">X</div>
171
+ </div>
172
+ `;
173
+ const $ = cheerio.load(html);
174
+
175
+ reduceRepeatedDivs($, 2);
176
+
177
+ const rootChildren = $('#root').children('div');
178
+ const cardDivs = $('#root').children('div.card');
179
+ const collapsedMarkers = $('#root').children('div[data-collapsed="true"]');
180
+
181
+ // Only 2 card divs should remain
182
+ expect(cardDivs.length).toBe(2);
183
+ expect(cardDivs.eq(0).text()).toBe('1');
184
+ expect(cardDivs.eq(1).text()).toBe('2');
185
+
186
+ // One collapsed marker added for the "card" class
187
+ expect(collapsedMarkers.length).toBe(1);
188
+ expect(collapsedMarkers.attr('data-class')).toBe('card');
189
+
190
+ // "other" div should still be present
191
+ const other = $('#root').children('div.other');
192
+ expect(other.length).toBe(1);
193
+ expect(other.text().trim()).toBe('X');
194
+
195
+ // Total children: 2 cards + 1 other + 1 marker
196
+ expect(rootChildren.length).toBe(4);
197
+ });
198
+ });
199
+
200
+ describe('removeObfuscatedClassNames', () => {
201
+ it('removes short and hashed-looking class names', () => {
202
+ const html = `
203
+ <div id="foo" class="btn a abcd12 AbCdE1 custom-class"></div>
204
+ `;
205
+ const $ = cheerio.load(html);
206
+
207
+ removeObfuscatedClassNames($);
208
+
209
+ const classAttr = $('#foo').attr('class') || '';
210
+ const classes = classAttr.split(/\s+/).filter(Boolean);
211
+
212
+ // 'btn' + 'custom-class' should remain, short "a" and hashed ones removed
213
+ expect(classes).toContain('btn');
214
+ expect(classes).toContain('custom-class');
215
+ expect(classes).not.toContain('a');
216
+ expect(classes).not.toContain('abcd12');
217
+ expect(classes).not.toContain('AbCdE1');
218
+ });
219
+
220
+ it('removes class attribute entirely if nothing remains', () => {
221
+ const html = `<div id="bar" class="a abcd12 AbCdE1"></div>`;
222
+ const $ = cheerio.load(html);
223
+
224
+ removeObfuscatedClassNames($);
225
+
226
+ expect($('#bar').attr('class')).toBeUndefined();
227
+ });
228
+ });
229
+
230
+ describe('removeAutoGeneratedClasses', () => {
231
+ it('removes known utility classes while keeping semantic ones', () => {
232
+ const html = `
233
+ <button
234
+ id="btn"
235
+ class="text-sm bg-red-500 hover:bg-red-600 flex items-center btn-primary custom-class"
236
+ >
237
+ Click
238
+ </button>
239
+ `;
240
+ const $ = cheerio.load(html);
241
+
242
+ removeAutoGeneratedClasses($);
243
+
244
+ const classAttr = $('#btn').attr('class') || '';
245
+ const classes = classAttr.split(/\s+/).filter(Boolean);
246
+
247
+ // Utility classes should be gone
248
+ expect(classes).not.toContain('text-sm');
249
+ expect(classes).not.toContain('bg-red-500');
250
+ expect(classes).not.toContain('hover:bg-red-600');
251
+ expect(classes).not.toContain('flex');
252
+ expect(classes).not.toContain('items-center');
253
+
254
+ // Semantic classes should remain
255
+ expect(classes).toContain('btn-primary');
256
+ expect(classes).toContain('custom-class');
257
+ });
258
+
259
+ it('removes class attribute entirely if all classes are utilities', () => {
260
+ const html = `<div id="box" class="flex items-center text-sm"></div>`;
261
+ const $ = cheerio.load(html);
262
+
263
+ removeAutoGeneratedClasses($);
264
+
265
+ expect($('#box').attr('class')).toBeUndefined();
266
+ });
267
+ });
268
+
269
+ describe('minifyDomWhitespace', () => {
270
+ it('collapses whitespace in text nodes while preserving words', () => {
271
+ const html = `
272
+ <div id="content">
273
+ Hello
274
+ world,
275
+ this is
276
+ a test.
277
+ </div>
278
+ `;
279
+ const $ = cheerio.load(html);
280
+
281
+ minifyDomWhitespace($);
282
+
283
+ const text = $('#content').text();
284
+ // We only care that internal runs are collapsed and words are separated
285
+ expect(text).toContain('Hello world, this is a test.');
286
+ // No stray newlines or multiple spaces
287
+ expect(/\s{2,}/.test(text)).toBe(false);
288
+ });
289
+
290
+ it('preserves whitespace inside pre/code/textarea', () => {
291
+ const html = `
292
+ <pre id="code-block">
293
+ line 1
294
+ line 2 with spaces
295
+ </pre>
296
+ <textarea id="ta">
297
+ line A
298
+ line B
299
+ </textarea>
300
+ `;
301
+ const $ = cheerio.load(html);
302
+
303
+ const beforePre = $('#code-block').text();
304
+ const beforeTa = $('#ta').text();
305
+
306
+ minifyDomWhitespace($);
307
+
308
+ const afterPre = $('#code-block').text();
309
+ const afterTa = $('#ta').text();
310
+
311
+ expect(afterPre).toBe(beforePre);
312
+ expect(afterTa).toBe(beforeTa);
313
+ });
314
+
315
+ it('keeps at least one space for pure-whitespace text nodes', () => {
316
+ const html = `<p>Foo <span>bar</span> baz</p>`;
317
+ const $ = cheerio.load(html);
318
+
319
+ minifyDomWhitespace($);
320
+
321
+ const text = $('p').text();
322
+ // We don’t want "Foobarbaz"
323
+ expect(text.includes('Foo')).toBe(true);
324
+ expect(text.includes('bar')).toBe(true);
325
+ expect(text.includes('baz')).toBe(true);
326
+ expect(text).toMatch(/Foo.*bar.*baz/);
327
+ });
328
+ });
329
+ });
@@ -17,6 +17,7 @@ import {
17
17
  removeAutoGeneratedClasses,
18
18
  removeNonContentTags,
19
19
  removeObfuscatedClassNames,
20
+ minifyDomWhitespace
20
21
  } from './domControl.js';
21
22
  import fs from 'fs';
22
23
  import path from 'path';
@@ -870,6 +871,9 @@ export class ChromeBrowser {
870
871
  // $ = await removeAutoGeneratedClasses($);
871
872
  }
872
873
 
874
+ // Minify whitespace *after* structural cleanup
875
+ $ = minifyDomWhitespace($);
876
+
873
877
  // (Optional: If you'd like to use `focus`, you could filter here by selectors/keywords.)
874
878
 
875
879
  // --- 5) Return trimmed HTML ---
@@ -0,0 +1,307 @@
1
+ // Tools whose *content* tends to be extremely large (DOM, etc.).
2
+ // We keep their existence in context, but can safely stub older payloads.
3
+ const HEAVY_SEMANTIC_TOOLS = new Set([
4
+ 'get_dom',
5
+ ]);
6
+
7
+ /**
8
+ * Safely prune/sanitize older heavy tool messages in the conversation.
9
+ *
10
+ * Goals:
11
+ * - Keep recent DOM snapshots intact (last few).
12
+ * - For older ones, keep the tool message but replace content with a stub.
13
+ *
14
+ * This preserves conversational structure while shrinking token load.
15
+ *
16
+ * @param {Array} messages - conversation history (mutated in-place).
17
+ * @param {object} opts
18
+ * @param {number} opts.keepRecentPerTool - how many latest tool results to keep verbatim
19
+ */
20
+ export function sanitizeHeavyToolHistory(messages, { keepRecentPerTool = 2 } = {}) {
21
+ // Track latest indices per tool name
22
+ const indicesByTool = new Map();
23
+
24
+ messages.forEach((m, idx) => {
25
+ if (m.role === 'tool' && m.name && HEAVY_SEMANTIC_TOOLS.has(m.name)) {
26
+ if (!indicesByTool.has(m.name)) indicesByTool.set(m.name, []);
27
+ indicesByTool.get(m.name).push(idx);
28
+ }
29
+ });
30
+
31
+ // For each tool, stub all but the last `keepRecentPerTool`
32
+ for (const [toolName, indices] of indicesByTool.entries()) {
33
+ const toStub = indices.slice(0, Math.max(0, indices.length - keepRecentPerTool));
34
+ for (const i of toStub) {
35
+ const msg = messages[i];
36
+ msg.content = `[${toolName} output omitted for brevity – older snapshot]`;
37
+ }
38
+ }
39
+ }
40
+
41
+
42
+ /**
43
+ * Prune overall conversation context to keep it within a bounded window.
44
+ *
45
+ * Strategy:
46
+ * - Always keep all system messages.
47
+ * - Start from the end and keep the last `maxNonSystemMessages` non-system messages.
48
+ * - Ensure protocol correctness:
49
+ * * If a tool message is kept, its parent assistant with matching tool_call_id
50
+ * must also be present in the kept window. Otherwise, drop that tool message.
51
+ *
52
+ * This avoids "tool message without preceding tool_calls" API errors.
53
+ *
54
+ * @param {Array} messages - conversation history
55
+ * @param {object} opts
56
+ * @param {number} opts.maxNonSystemMessages
57
+ * @returns {Array} pruned messages (new array)
58
+ */
59
+ export function pruneConversationContext(messages, { maxNonSystemMessages = 40 } = {}) {
60
+ const systemMessages = messages.filter(m => m.role === 'system');
61
+ const nonSystem = messages.filter(m => m.role !== 'system');
62
+
63
+ // Take last N non-system messages (raw window)
64
+ const tail = nonSystem.slice(-maxNonSystemMessages);
65
+
66
+ // Pass 1: build map of tool_call_id -> index of assistant in tail
67
+ const assistantToolParents = new Map(); // tool_call_id -> index in tail
68
+ tail.forEach((m, idx) => {
69
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
70
+ for (const tc of m.tool_calls) {
71
+ if (tc && tc.id) {
72
+ assistantToolParents.set(tc.id, idx);
73
+ }
74
+ }
75
+ }
76
+ });
77
+
78
+ // Pass 2: filter out tool messages that have no parent assistant in the tail
79
+ const cleanedTail = [];
80
+ for (const m of tail) {
81
+ if (m.role === 'tool') {
82
+ const toolId = m.tool_call_id;
83
+ if (!toolId) {
84
+ // Defensive: if there's no tool_call_id at all, drop it.
85
+ continue;
86
+ }
87
+ if (!assistantToolParents.has(toolId)) {
88
+ // Parent assistant with this tool_call_id not present in tail,
89
+ // so keeping this tool message would violate the protocol. Drop it.
90
+ continue;
91
+ }
92
+ }
93
+ cleanedTail.push(m);
94
+ }
95
+
96
+ return [...systemMessages, ...cleanedTail];
97
+ }
98
+
99
+ /* ---------------- Ground Control state helpers ----------------
100
+ * These helpers back the "set_ground_control_state" and "record_mission_telemetry"
101
+ * tools. They are designed to:
102
+ * - Keep durable mission facts (URL, login state, constraints) outside
103
+ * the pruned chat window.
104
+ * - Accumulate a lightweight telemetry trail (breadcrumbs, assertions,
105
+ * issues, and notes) that can be surfaced in reports.
106
+ */
107
+
108
+ /**
109
+ * Create a normalized, empty Ground Control state object.
110
+ * Safe to reuse per mission and to serialize into reports.
111
+ *
112
+ * @returns {object} groundControl
113
+ */
114
+ export function createEmptyGroundControl() {
115
+ return {
116
+ app: {
117
+ baseUrl: null,
118
+ currentUrl: null,
119
+ routeRole: null, // e.g. "login", "chat", "dashboard"
120
+ },
121
+ session: {
122
+ loggedIn: null, // true/false/null (unknown)
123
+ userLabel: null, // "Demo user", "Shane Fast", etc.
124
+ tenant: null, // workspace / tenant label if applicable
125
+ },
126
+ navigation: {
127
+ currentLabel: null, // human label for where we are, e.g. "Main chat page"
128
+ },
129
+ constraints: {
130
+ stayWithinBaseUrl: null, // if true, avoid leaving app.baseUrl unless told to
131
+ },
132
+ telemetry: [], // array of { kind, text, status, ts, turn? }
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Apply a partial update from the `set_ground_control_state` tool to the
138
+ * existing Ground Control state.
139
+ *
140
+ * - Mutates the provided `groundControl` in-place for simplicity.
141
+ * - Only merges known sections/keys; unknown keys are ignored defensively.
142
+ *
143
+ * @param {object} groundControl - existing state (will be mutated)
144
+ * @param {object} payload - tool arguments from set_ground_control_state
145
+ * @returns {object} the same groundControl reference (for chaining)
146
+ */
147
+ export function applyGroundControlUpdate(groundControl, payload = {}) {
148
+ if (!groundControl) {
149
+ throw new Error('applyGroundControlUpdate called without an existing groundControl state');
150
+ }
151
+
152
+ // 🔐 Ensure sub-objects exist so we can safely assign into them
153
+ groundControl.app = groundControl.app || {};
154
+ groundControl.session = groundControl.session || {};
155
+ groundControl.navigation = groundControl.navigation || {};
156
+ groundControl.constraints = groundControl.constraints || {};
157
+
158
+ const { app, session, navigation, constraints } = payload;
159
+
160
+ if (app && typeof app === 'object') {
161
+ groundControl.app.baseUrl = app.baseUrl ?? groundControl.app.baseUrl;
162
+ groundControl.app.currentUrl = app.currentUrl ?? groundControl.app.currentUrl;
163
+ groundControl.app.routeRole = app.routeRole ?? groundControl.app.routeRole;
164
+ }
165
+
166
+ if (session && typeof session === 'object') {
167
+ if (typeof session.loggedIn === 'boolean' || session.loggedIn === null) {
168
+ groundControl.session.loggedIn = session.loggedIn;
169
+ }
170
+ if (typeof session.userLabel === 'string') {
171
+ groundControl.session.userLabel = session.userLabel;
172
+ }
173
+ if (typeof session.tenant === 'string') {
174
+ groundControl.session.tenant = session.tenant;
175
+ }
176
+ }
177
+
178
+ if (navigation && typeof navigation === 'object') {
179
+ if (typeof navigation.currentLabel === 'string') {
180
+ groundControl.navigation.currentLabel = navigation.currentLabel;
181
+ }
182
+ }
183
+
184
+ if (constraints && typeof constraints === 'object') {
185
+ if (typeof constraints.stayWithinBaseUrl === 'boolean' || constraints.stayWithinBaseUrl === null) {
186
+ groundControl.constraints.stayWithinBaseUrl = constraints.stayWithinBaseUrl;
187
+ }
188
+ }
189
+
190
+ return groundControl;
191
+ }
192
+
193
+ /**
194
+ * Append a telemetry entry to Ground Control from the `record_mission_telemetry` tool.
195
+ *
196
+ * Each entry is small and durable, and can be surfaced in final mission reports.
197
+ *
198
+ * @param {object} groundControl - existing state (mutated in-place)
199
+ * @param {object} entry - { kind, text, status? } from tool args
200
+ * @param {object} meta - optional metadata (e.g., { turn })
201
+ * @returns {object} the appended telemetry object
202
+ */
203
+ export function recordGroundTelemetry(groundControl, entry, meta = {}) {
204
+ if (!groundControl) {
205
+ throw new Error('recordGroundTelemetry called without an existing groundControl state');
206
+ }
207
+
208
+ if (!groundControl.telemetry) {
209
+ groundControl.telemetry = [];
210
+ }
211
+
212
+ const { kind, text, status } = entry || {};
213
+ if (!kind || !text) {
214
+ // Defensive: only record well-formed entries
215
+ return null;
216
+ }
217
+
218
+ const telemetry = {
219
+ kind, // 'breadcrumb' | 'assertion' | 'issue' | 'note'
220
+ text,
221
+ status: status || 'n/a', // 'passed' | 'failed' | 'n/a'
222
+ ts: new Date().toISOString(),
223
+ ...meta, // e.g., { turn: 3 }
224
+ };
225
+
226
+ groundControl.telemetry.push(telemetry);
227
+ return telemetry;
228
+ }
229
+
230
+ /**
231
+ * Build a compact, model-friendly snapshot of Ground Control for use in prompts.
232
+ *
233
+ * Goals:
234
+ * - Keep *critical* durable facts (URL, login state, rough route/area)
235
+ * available between missions without dragging the full state into context.
236
+ * - Emit a very small object that can be safely JSON.stringified and injected
237
+ * into a system message or "Ground Control" note.
238
+ * - Return `null` when there is no meaningful signal yet, so callers can skip
239
+ * adding noise to the prompt.
240
+ *
241
+ * Shape of the snapshot:
242
+ * {
243
+ * app: {
244
+ * baseUrl: string | null,
245
+ * currentUrl: string | null,
246
+ * },
247
+ * session: {
248
+ * loggedIn: boolean, // coerced with `!!` (null/undefined → false)
249
+ * userLabel: string | null,
250
+ * },
251
+ * navigation: {
252
+ * routeRole: string | null, // high-level role like "login", "chat", etc.
253
+ * currentLabel: string | null, // human label like "Main chat page"
254
+ * },
255
+ * telemetryLines: string[] // up to 5 last telemetry entries, pre-formatted
256
+ * }
257
+ *
258
+ * @param {object} gc - Full Ground Control state as maintained by the tools.
259
+ * @returns {object|null} A compact snapshot or `null` if there is no signal.
260
+ */
261
+ export function summarizeGroundControlForPrompt(gc = {}) {
262
+ if (!gc || typeof gc !== 'object') return null;
263
+
264
+ const app = gc.app || {};
265
+ const session = gc.session || {};
266
+ const navigation = gc.navigation || {};
267
+ const telemetry = Array.isArray(gc.telemetry) ? gc.telemetry : [];
268
+
269
+ const routeRole = typeof navigation.routeRole === 'string'
270
+ ? navigation.routeRole
271
+ : (typeof app.routeRole === 'string' ? app.routeRole : null);
272
+ const currentLabel = typeof navigation.currentLabel === 'string' ? navigation.currentLabel : null;
273
+
274
+ // Only emit snapshot if we have at least *some* meaningful info
275
+ const hasSignal =
276
+ app.baseUrl ||
277
+ app.currentUrl ||
278
+ typeof session.loggedIn === 'boolean' ||
279
+ routeRole ||
280
+ currentLabel ||
281
+ telemetry.length > 0;
282
+
283
+ if (!hasSignal) return null;
284
+
285
+ const telemetryLines = telemetry
286
+ .slice(-5) // last 5 breadcrumbs max
287
+ .map(t => {
288
+ const statusSuffix = t.status && t.status !== 'n/a' ? ` (${t.status})` : '';
289
+ return `- [${t.kind}]${statusSuffix} ${t.text}`;
290
+ });
291
+
292
+ return {
293
+ app: {
294
+ baseUrl: app.baseUrl || null,
295
+ currentUrl: app.currentUrl || null,
296
+ },
297
+ session: {
298
+ loggedIn: !!session.loggedIn,
299
+ userLabel: session.userLabel || null,
300
+ },
301
+ navigation: {
302
+ routeRole,
303
+ currentLabel,
304
+ },
305
+ telemetryLines,
306
+ };
307
+ }