diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 30b0aafe..b3d3b474 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -112,7 +112,7 @@ Markers follow the id: | `[directives: RouterLinkActive, ...]` | The other directives on the host. | | `(+3 below)` | `depth` hides 3 instances under this one. | -The outline carries no input or property values. Pass an id to `inspect-component` for those, or to `highlight` to show the instance. The answer stops at 20,000 characters and says how to narrow it, and it says when the page's tree stopped at a [limit](../inspectors/components.md#selection). +Without `page`, it lists the most recent tab that has a component tree and names the other tabs that have one. The outline carries no input or property values. Pass an id to `inspect-component` for those, or to `highlight` to show the instance. The answer stops at 20,000 characters and says how to narrow it, and it says when the page's tree stopped at a [limit](../inspectors/components.md#selection). ### defer-blocks @@ -135,7 +135,7 @@ Change detection cycles recorded with Angular's profiler: the slowest components | `record` | no | `start` starts a fresh recording, `stop` stops it and keeps the cycles, `clear` empties it. Leave out to read. | | `limit` | no | Rows per list. Default 10, at most 50. | -Without `page`, it reads the page that is recording, and `record` goes to every connected page. The answer starts with the change detection mode of the page it reports on: zoneless, zone.js, or zoneless with zone.js still loaded. Recording is off until the panel or this tool starts it. Call it with `record: "start"`, use the app, then call it again without `record`. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many. +Without `page`, it reads the page that is recording, and `record` goes to every connected page. A `page` that names no connected tab gets the list of connected tabs. A connected tab that has not recorded yet gets a hint to start a recording there. The answer starts with the change detection mode of the page it reports on: zoneless, zone.js, or zoneless with zone.js still loaded. Recording is off until the panel or this tool starts it. Call it with `record: "start"`, use the app, then call it again without `record`. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many. ### inspect-signals diff --git a/packages/devtools/src/__tests__/agent-tools.test.ts b/packages/devtools/src/__tests__/agent-tools.test.ts index 09670764..357837ab 100644 --- a/packages/devtools/src/__tests__/agent-tools.test.ts +++ b/packages/devtools/src/__tests__/agent-tools.test.ts @@ -70,6 +70,73 @@ describe('agent tools', () => { expect(await push('ping-change-detection', 'p1')).toEqual({ known: false }); }); + it('answer change detection for an unknown page with the connected tabs', async () => { + const { push, call } = await boot(); + await push('push-injector-tree', { + pageId: 'live', + roots: [injectorRoot('app-root')], + environment: [], + zone: 'zone', + }); + const reading = await call('change-detection', '', { page: 'gone' }); + expect(reading).toMatch( + /^No page `gone` is reporting change detection\. Pages that report change detection: `live`\./, + ); + // Recording on a closed tab is not reported as started. + const recording = await call('change-detection', '', { page: 'gone', record: 'start' }); + expect(recording).toMatch(/^No page `gone` is reporting change detection/); + expect(recording).not.toMatch(/Recording started/); + // A connected tab that has not recorded yet can still start a recording. + expect(await call('change-detection', '', { page: 'live', record: 'start' })).toMatch( + /Recording started/, + ); + expect(await call('change-detection', '', { page: 'live' })).toContain( + 'Page `live` has no change detection recording yet.', + ); + }); + + it('treat a tab whose connection dropped as unknown to change detection', async () => { + const { ctx, push, call } = await boot(); + const host = ctx.rpc as unknown as { + getCurrentRpcSession: () => unknown; + _emitSessionDisconnected: (meta: { id: number }) => void; + }; + const cdReport = (pageId: string) => ({ + pageId, + supported: true, + recording: true, + startedAt: 1, + dropped: 0, + cycles: [{ id: 1, at: 1, ms: 2, passes: 1, checks: 1, components: [] }], + components: [{ name: 'Cart', checks: 1, ms: 2, maxMs: 2, cycles: 1 }], + hosts: {}, + }); + host.getCurrentRpcSession = () => ({ meta: { id: 1 } }); + await push('push-injector-tree', { + pageId: 'tab-1', + roots: [injectorRoot('app-root')], + environment: [], + zone: 'zone', + }); + await push('push-change-detection', cdReport('tab-1')); + host.getCurrentRpcSession = () => ({ meta: { id: 2 } }); + await push('push-change-detection', cdReport('tab-2')); + + // tab-1 stays loaded but its connection drops: no record request reaches it. + host._emitSessionDisconnected({ id: 1 }); + const start = await call('change-detection', '', { page: 'tab-1', record: 'start' }); + expect(start).toMatch(/^No page `tab-1` is reporting change detection/); + expect(start).toContain('`tab-2`'); + expect(await call('change-detection', '', { page: 'tab-1' })).toMatch(/^No page `tab-1`/); + + // Once it reports again on a new connection, it is known again. + host.getCurrentRpcSession = () => ({ meta: { id: 3 } }); + await push('ping-injector-tree', 'tab-1'); + expect(await call('change-detection', '', { page: 'tab-1', record: 'start' })).toMatch( + /Recording started/, + ); + }); + it('report the zone mode of the page that recorded, not the latest tab', async () => { vi.useFakeTimers(); const { push, call } = await boot(); diff --git a/packages/devtools/src/__tests__/cd-recorder.test.ts b/packages/devtools/src/__tests__/cd-recorder.test.ts index 2ad4e52c..ae3f6e5d 100644 --- a/packages/devtools/src/__tests__/cd-recorder.test.ts +++ b/packages/devtools/src/__tests__/cd-recorder.test.ts @@ -264,4 +264,32 @@ describe('change detection agent text', () => { ); expect(toCdPage({ pageId: 5 }, 10)).toBeNull(); }); + + it('falls back to the default limit when `limit` is not a number', () => { + const components = Array.from({ length: 15 }, (_, i) => ({ + name: `Row${i}`, + checks: i + 1, + ms: i + 1, + maxMs: 1, + cycles: 1, + })); + const page = toCdPage( + { + pageId: 'p1', + supported: true, + recording: true, + cycles: components.map((_, i) => ({ id: i, at: i, ms: 1, passes: 1, checks: 1 })), + components, + }, + 200, + )!; + const state: CdState = { pages: { p1: page } }; + const slowest = (limit: unknown) => + changeDetectionText(state, { limit: limit as never }) + .split('**Slowest components**\n')[1]! + .split('\n\n')[0]! + .split('\n').length; + for (const limit of ['abc', Number.NaN, null, Infinity]) expect(slowest(limit)).toBe(10); + expect(slowest('3')).toBe(3); + }); }); diff --git a/packages/devtools/src/__tests__/http-tools.test.ts b/packages/devtools/src/__tests__/http-tools.test.ts index c59c80d1..2269c62d 100644 --- a/packages/devtools/src/__tests__/http-tools.test.ts +++ b/packages/devtools/src/__tests__/http-tools.test.ts @@ -150,6 +150,16 @@ describe('list-http-calls text', () => { expect(isFailedCall(call())).toBe(false); }); + it('falls back to the default limit when `limit` is not a number', () => { + for (const limit of ['abc', Number.NaN, null, Infinity]) { + const text = listHttpCallsText(mixed(), { limit: limit as never }, NOW); + expect(rowsOf(text)).toHaveLength(7); + expect(text).not.toContain('none match'); + } + expect(rowsOf(listHttpCallsText(mixed(), { limit: '2' as never }, NOW))).toHaveLength(2); + expect(rowsOf(listHttpCallsText(mixed(), { limit: -5 }, NOW))).toHaveLength(1); + }); + it('adds response previews only when asked', () => { expect(listHttpCallsText(mixed(), {}, NOW)).not.toContain('Response previews'); const text = listHttpCallsText(mixed(), { preview: true }, NOW); diff --git a/packages/devtools/src/__tests__/signal-tools.test.ts b/packages/devtools/src/__tests__/signal-tools.test.ts index 16d9de53..ec161528 100644 --- a/packages/devtools/src/__tests__/signal-tools.test.ts +++ b/packages/devtools/src/__tests__/signal-tools.test.ts @@ -148,6 +148,14 @@ describe('signalGraphText', () => { ); }); + it('keeps the note and no stray JSON when the budget is smaller than the note', () => { + const text = signalGraphText(bigGraph(20, 50), {}, 300); + expect(text).toMatch(/^_Cut to fit 300 characters/); + expect(text).toContain('No part of the graph fits.'); + expect(text).toContain('Pass `node`'); + expect(text).not.toContain('"nodes"'); + }); + it('drops history with `history: false`', () => { const view = JSON.parse(signalGraphText(smallGraph(), { history: false })) as SignalGraph; expect(view.history).toBeUndefined(); @@ -188,6 +196,18 @@ describe('inspect-signals tool', () => { }); }); +describe('inspect-signals tool with a long selector', () => { + it('keeps the cut note when the selector it echoes is long', async () => { + const { push, call } = await boot(); + await push('push-signal-graph', { pageId: 'p1', ...bigGraph() }); + const text = await call('inspect-signals', { selector: 'x'.repeat(19_800) }); + expect(text.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX); + // The answer starts with the untrusted-data notice, then the heading. + expect(text).toMatch(/No signal graph for `x{200}…`/); + expect(text).toMatch(/Cut to fit [\d,]+ characters/); + }); +}); + describe('navigate answer', () => { it('leaves a short result whole', () => { const text = routerActionText({ ok: true }); diff --git a/packages/devtools/src/__tests__/ssr-requests.test.ts b/packages/devtools/src/__tests__/ssr-requests.test.ts index e5d007c6..4f65f441 100644 --- a/packages/devtools/src/__tests__/ssr-requests.test.ts +++ b/packages/devtools/src/__tests__/ssr-requests.test.ts @@ -13,7 +13,12 @@ import { pangularHttpInterceptor } from '../http.ts'; import { httpRegistry, type HttpCall } from '../http-rules.ts'; import { createSsrMiddleware, renderModeOf } from '../ssr-middleware.ts'; import { SSR_REQUEST_HEADER, ssrRegistry, type SsrRequest } from '../ssr-registry.ts'; -import { explainSsrRequestText, sanitizeSsrRequest, ssrRequestStory } from '../rpc/ssr-tools.ts'; +import { + explainSsrRequestText, + listSsrRequestsText, + sanitizeSsrRequest, + ssrRequestStory, +} from '../rpc/ssr-tools.ts'; import type { HttpPage, HttpState } from '../types.ts'; class FakeResponse extends EventEmitter { @@ -319,6 +324,19 @@ const page = (overrides: Partial = {}): HttpPage => ({ ...overrides, }); +describe('list-ssr-requests limit', () => { + it('falls back to the default limit when `limit` is not a number', () => { + const requests = Array.from({ length: 25 }, (_, i) => ssrRequest({ id: `r${i}`, at: i })); + const rows = (limit: unknown) => + listSsrRequestsText(requests, [], { limit: limit as never }, 100) + .split('\n') + .filter((line) => /^\| `r\d+` \|/.test(line)).length; + for (const limit of ['abc', Number.NaN, null, Infinity]) expect(rows(limit)).toBe(20); + expect(rows('3')).toBe(3); + expect(rows(-5)).toBe(1); + }); +}); + describe('ssrRequestStory', () => { it('links the server calls and the page, and finds browser refetches', () => { const server = [ diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index d05178ab..b6fc016a 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -151,6 +151,7 @@ import { type SetHttpRuleArgs, } from './rpc/http-rule-tools.ts'; import { + cdUnknownPageText, changeDetectionText, expireCdPages, pickCdPage, @@ -172,6 +173,7 @@ import { unknownPageText, } from './rpc/pages.ts'; import { registerHubDocks } from './hub-docks.ts'; +import { clip } from './text.ts'; import { setRedaction } from './forms-privacy.ts'; import { FORM_WRITE_ACTIONS, @@ -851,6 +853,9 @@ const pangular = defineDevframe({ if (stale.length) forgetSignalPages(stale.map(([id]) => id)); if (expireComponentPages(componentPages, Date.now(), liveTtl)) applyComponentPages(); if (expireCdPages(cdPages, Date.now(), liveTtl)) applyCd(); + for (const id of goneCdPages) { + if (!componentPages.has(id) && !injectorPages.has(id)) goneCdPages.delete(id); + } const staleInjectors = [...injectorPages].filter( ([id, p]) => Date.now() - p.reportedAt > liveTtl(id), ); @@ -1193,6 +1198,26 @@ const pangular = defineDevframe({ }, }); + // A tab whose connection dropped gets no change detection record request, + // so its report goes at once and the tool does not count it as connected + // until it reports again. Bound from the component, injector and change + // detection reports, the ones the tool reads tabs from. + const cdPages = new Map(); + const goneCdPages = new Set(); + let applyCd = () => {}; + const cdSessions = trackPageSessions(ctx.rpc, (pageIds) => { + let removed = false; + for (const pageId of pageIds) { + goneCdPages.add(pageId); + removed = cdPages.delete(pageId) || removed; + } + if (removed) applyCd(); + }); + const cdSeen = (pageId: string) => { + cdSessions.bind(pageId); + goneCdPages.delete(pageId); + }; + register({ name: 'push-component-tree', type: 'action', @@ -1200,16 +1225,16 @@ const pangular = defineDevframe({ handler: (report: unknown) => { if (!isComponentReport(report)) return; componentPages.set(report.pageId, toComponentPage(report)); + cdSeen(report.pageId); applyComponentPages(); for (const check of [...componentWaiters]) check(); }, }); - const cdPages = new Map(); const cdState = await my.rpc.sharedState('change-detection', { initialValue: { pages: {} } as CdState, }); - const applyCd = () => + applyCd = () => cdState.mutate((draft) => { draft.pages = Object.fromEntries(cdPages); }); @@ -1231,6 +1256,7 @@ const pangular = defineDevframe({ const page = toCdPage(report, limits.cdCycles); if (!page) return; cdPages.set(page.pageId, page); + cdSeen(page.pageId); applyCd(); }, }); @@ -1243,6 +1269,7 @@ const pangular = defineDevframe({ const page = typeof pageId === 'string' ? cdPages.get(pageId) : undefined; if (!page) return { known: false }; cdPages.set(page.pageId, { ...page, reportedAt: Date.now() }); + cdSeen(page.pageId); return { known: true }; }, }); @@ -1273,6 +1300,7 @@ const pangular = defineDevframe({ const page = typeof pageId === 'string' ? componentPages.get(pageId) : undefined; if (!page) return { known: false }; componentPages.set(page.pageId, { ...page, reportedAt: Date.now() }); + cdSeen(page.pageId); return { known: true }; }, }); @@ -1472,6 +1500,7 @@ const pangular = defineDevframe({ ...(zone ? { zone } : {}), reportedAt: Date.now(), }); + cdSeen(pageId); applyInjectorPages(); }, }); @@ -1484,6 +1513,7 @@ const pangular = defineDevframe({ const page = typeof pageId === 'string' ? injectorPages.get(pageId) : undefined; if (!page) return { known: false }; injectorPages.set(page.pageId, { ...page, reportedAt: Date.now() }); + cdSeen(page.pageId); return { known: true }; }, }); @@ -1891,6 +1921,16 @@ const pangular = defineDevframe({ }, }, handler: async (args: { record?: string; page?: string; limit?: number }) => { + const unknown = + args?.page && + cdUnknownPageText( + args.page, + cdState.value() as CdState, + [...componentPages.values(), ...injectorPages.values()].filter( + (page) => !goneCdPages.has(page.pageId), + ), + ); + if (unknown) return { markdown: unknown }; if (args?.record === 'start' || args?.record === 'stop' || args?.record === 'clear') { requestCdRecord({ pageId: args.page, @@ -2000,7 +2040,7 @@ const pangular = defineDevframe({ const hint = isEnvironmentRequest(args.selector) ? ` No environment injector on the page matches it${known ? `; the page knows ${known}` : ''}.` : ''; - const head = `No signal graph for \`${args.selector}\`.${hint} The live graph covers \`${covers}\`:\n\n`; + const head = `No signal graph for \`${clip(args.selector, 200)}\`.${hint} The live graph covers \`${covers}\`:\n\n`; return { markdown: untrusted( (head + signalGraphText(graph, view, Math.max(0, budget - head.length))).slice( diff --git a/packages/devtools/src/rpc/__tests__/component-outline.test.ts b/packages/devtools/src/rpc/__tests__/component-outline.test.ts index 85e5f02b..73a49cd6 100644 --- a/packages/devtools/src/rpc/__tests__/component-outline.test.ts +++ b/packages/devtools/src/rpc/__tests__/component-outline.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { ComponentPage, LiveComponentNode } from '../../types.ts'; -import { componentOutlineText, listComponentsText, routedHosts } from '../component-outline.ts'; +import { + LIST_COMPONENTS_DESCRIPTION, + componentOutlineText, + listComponentsText, + routedHosts, +} from '../component-outline.ts'; const node = ( id: string, @@ -148,3 +153,23 @@ describe('list-components answer', () => { ); }); }); + +describe('list-components description', () => { + it('describes the answers the tool gives', () => { + // No page answers with a note, not an empty result. + expect(listComponentsText([], () => undefined)).toMatch(/^No component tree has been reported/); + expect(LIST_COMPONENTS_DESCRIPTION).not.toMatch(/Empty when no page is connected/); + expect(LIST_COMPONENTS_DESCRIPTION).toContain('no component tree has been reported'); + // Without `page` it skips a newer page with no tree and names the others. + const empty = page({ pageId: 'p2', roots: [], reportedAt: 5 }); + const text = listComponentsText( + [page(), empty, page({ pageId: 'p3', reportedAt: 0 })], + () => undefined, + ); + expect(text).toContain('Page `p1`'); + expect(text).toContain('Other pages report a component tree too: `p3`'); + expect(LIST_COMPONENTS_DESCRIPTION).toContain('most recent page that has a component tree'); + expect(LIST_COMPONENTS_DESCRIPTION).toContain('names the other tabs'); + expect(LIST_COMPONENTS_DESCRIPTION).toContain('(+N below)'); + }); +}); diff --git a/packages/devtools/src/rpc/cd-tools.ts b/packages/devtools/src/rpc/cd-tools.ts index f101bf6f..9ff41521 100644 --- a/packages/devtools/src/rpc/cd-tools.ts +++ b/packages/devtools/src/rpc/cd-tools.ts @@ -3,6 +3,7 @@ import type { CdCheck, CdComponentStat, CdCycle } from '../cd-recorder.ts'; import { droppedNote } from '../timeline-limits.ts'; import { code, UNTRUSTED } from './forms-tools.ts'; import { PAGE_TTL_MS, fixedTtl, type PageTtl } from './page-ttl.ts'; +import { unknownPageText, type ReportingPage } from './pages.ts'; export interface CdPage extends CdReport { reportedAt: number; @@ -111,6 +112,32 @@ export function expireCdPages( return changed; } +/** + * The answer when `page` names a tab that is not connected: the shared + * unknown-page text, listing the connected tabs. A connected tab that has not + * recorded yet is not unknown, since `record` can start a recording there. + * Undefined when the page is known. + */ +export function cdUnknownPageText( + pageId: string, + state: CdState, + connected: Iterable, +): string | undefined { + const pages = new Map(); + const all: ReportingPage[] = [...connected, ...Object.values(state.pages)]; + for (const page of all) { + const seen = pages.get(page.pageId); + pages.set(page.pageId, { + pageId: page.pageId, + url: seen?.url ?? page.url, + reportedAt: Math.max(seen?.reportedAt ?? 0, page.reportedAt ?? 0), + }); + } + return pages.has(pageId) + ? undefined + : unknownPageText(pageId, [...pages.values()], 'change detection'); +} + /** The page the change-detection tool reports: the one asked for, else recording, else latest. */ export function pickCdPage(state: CdState, pageId?: string): CdPage | undefined { const pages = Object.values(state.pages); @@ -128,13 +155,15 @@ export function changeDetectionText( const page = pickCdPage(state, args.page); if (!page) { return args.page - ? `No page ${code(args.page)} has a change detection recording.` + ? `Page ${code(args.page)} has no change detection recording yet. Call this tool with \`record: "start"\` and this \`page\`, use the app, then call it again.` : 'No change detection recording yet. Call this tool with `record: "start"`, use the app, then call it again.'; } if (!page.supported) { return `Page ${code(page.pageId)} can't record change detection: it needs Angular 20 or later in a development build.`; } - const limit = Math.max(1, Math.min(Math.floor(args.limit ?? 10), 50)); + // Agents send strings, NaN and null too: anything that is not a finite number is the default. + const asked = Number(args.limit ?? 10); + const limit = Math.max(1, Math.min(Math.floor(Number.isFinite(asked) ? asked : 10), 50)); const status = page.recording ? 'Recording is on.' : 'Recording is off.'; if (!page.cycles.length && !page.components.length) { return `${status} No change detection cycle was recorded on page ${code(page.pageId)} yet. Use the app, then call this tool again.`; diff --git a/packages/devtools/src/rpc/component-outline.ts b/packages/devtools/src/rpc/component-outline.ts index 1749120d..02847e54 100644 --- a/packages/devtools/src/rpc/component-outline.ts +++ b/packages/devtools/src/rpc/component-outline.ts @@ -8,7 +8,7 @@ import { byRecency, unknownPageText } from './pages.ts'; export const OUTLINE_MAX_CHARS = 20_000; export const LIST_COMPONENTS_DESCRIPTION = - 'List the live component tree of a connected page as an indented outline, one line per rendered instance: class name, host tag and instance id, then `[routed ...]` for a component a router outlet shows (its route, and the outlet name when it is not the primary one) and `[directives: ...]` for the other directives on its host. It carries no input or property values: pass an id to pangular:inspect-component for those, or to pangular:highlight to show it on the page. `filter` keeps the instances whose class name, host tag or a host directive contains the text (case-insensitive) plus their ancestors, and `depth` stops the outline at that many levels. Uses the most recent page unless `page` names another tab. Says so when the page stopped collecting at its size limit, and when the outline itself is cut at 20,000 characters. Empty when no page is connected.'; + 'List the live component tree of a connected page as an indented outline, one line per rendered instance: class name, host tag and instance id, then `[routed ...]` for a component a router outlet shows (its route, and the outlet name when it is not the primary one) and `[directives: ...]` for the other directives on its host. It carries no input or property values: pass an id to pangular:inspect-component for those, or to pangular:highlight to show it on the page. `filter` keeps the instances whose class name, host tag or a host directive contains the text (case-insensitive) plus their ancestors, and `depth` stops the outline at that many levels (1 for the roots only; unlimited without it) and counts what it hides as `(+N below)`. Uses the most recent page that has a component tree unless `page` names another tab, and names the other tabs that report one. Says so when the page stopped collecting at its size limit, and when the outline itself is cut at 20,000 characters. Says that no component tree has been reported when no page is connected, and lists the reporting tabs when `page` names an unknown one.'; export interface OutlineOptions { filter?: string; diff --git a/packages/devtools/src/rpc/http-tools.ts b/packages/devtools/src/rpc/http-tools.ts index 98190ca6..1ff55cd7 100644 --- a/packages/devtools/src/rpc/http-tools.ts +++ b/packages/devtools/src/rpc/http-tools.ts @@ -110,7 +110,12 @@ export function listHttpCallsText( .filter((row) => !want || row.call.url.toLowerCase().includes(want)) .filter((row) => !args.failed || isFailedCall(row.call)) .sort((a, b) => b.call.at - a.call.at); - const limit = Math.min(Math.max(Math.floor(args.limit ?? DEFAULT_LIMIT), 1), MAX_LIMIT); + // Agents send strings, NaN and null too: anything that is not a finite number is the default. + const asked = Number(args.limit ?? DEFAULT_LIMIT); + const limit = Math.min( + Math.max(Math.floor(Number.isFinite(asked) ? asked : DEFAULT_LIMIT), 1), + MAX_LIMIT, + ); const shown = matching.slice(0, limit); const filters = [ want ? `URL contains ${code(args.url!)}` : '', diff --git a/packages/devtools/src/rpc/signal-tools.ts b/packages/devtools/src/rpc/signal-tools.ts index aa93ba21..eea6de56 100644 --- a/packages/devtools/src/rpc/signal-tools.ts +++ b/packages/devtools/src/rpc/signal-tools.ts @@ -111,8 +111,9 @@ export function signalGraphText( const full = JSON.stringify(view, null, 2); if (full.length <= budget) return full; - // Leave room for the note that explains the cut. - const room = budget - 600; + // Leave room for the note that explains the cut. A budget smaller than the + // note keeps no JSON at all rather than a negative slice from the end. + const room = Math.max(0, budget - 600); const totalEntries = entries(view.history); let historyKept: number | null = null; let fitted: Graph = view; @@ -169,6 +170,10 @@ export function signalGraphText( sliced = true; } const what = cuts.length ? `: ${cuts.join('; ')}` : ''; - const broken = sliced ? ' The JSON above is cut short and does not parse.' : ''; - return `${text}\n\n_Cut to fit ${budget.toLocaleString('en-US')} characters (the full graph is ${full.length.toLocaleString('en-US')})${what}.${broken} ${NARROW}_`; + const broken = !text + ? ' No part of the graph fits.' + : sliced + ? ' The JSON above is cut short and does not parse.' + : ''; + return `${text ? `${text}\n\n` : ''}_Cut to fit ${budget.toLocaleString('en-US')} characters (the full graph is ${full.length.toLocaleString('en-US')})${what}.${broken} ${NARROW}_`; } diff --git a/packages/devtools/src/rpc/ssr-tools.ts b/packages/devtools/src/rpc/ssr-tools.ts index 6d5f1270..3658fa65 100644 --- a/packages/devtools/src/rpc/ssr-tools.ts +++ b/packages/devtools/src/rpc/ssr-tools.ts @@ -214,7 +214,9 @@ export function listSsrRequestsText( now = Date.now(), ): string { if (!requests.length) return NO_REQUESTS; - const limit = Math.min(Math.max(Math.floor(args.limit ?? 20), 1), 100); + // Agents send strings, NaN and null too: anything that is not a finite number is the default. + const asked = Number(args.limit ?? 20); + const limit = Math.min(Math.max(Math.floor(Number.isFinite(asked) ? asked : 20), 1), 100); const recent = [...requests].sort((a, b) => b.at - a.at).slice(0, limit); const rows = recent.map((r) => { const linked = pages.some((p) => p.ssrRequestId === r.id) ? 'yes' : 'no';