From 9ba89c9b18f7bb0d0858fd6a10f07cbfef18204a Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 04:05:13 +0000 Subject: [PATCH 1/2] fix(components): cap inspect-component and defer-blocks answers inspect-component returned the whole component detail as JSON, so a component with large inputs or properties could send hundreds of kilobytes to an agent. Past 20,000 characters it now cuts each value longer than 1,000 characters to the start of its JSON, then keeps fewer entries per list, and names what it left out. The component-tree resource still has the full detail. defer-blocks listed every block of every page (up to 500 per page). It now lists 50 blocks per page in the table and 50 under Needs attention, says how many more are not shown and how to narrow, and cuts the answer with the shared capped() helper, which takes an optional hint on how to narrow. --- apps/docs/src/content/agents/tools.md | 4 + .../src/__tests__/component-agent.test.ts | 27 +++++ .../src/__tests__/defer-blocks.test.ts | 31 +++++- packages/devtools/src/devframe.ts | 10 +- .../__tests__/component-detail-text.test.ts | 90 ++++++++++++++++ .../devtools/src/rpc/component-detail-text.ts | 100 ++++++++++++++++++ packages/devtools/src/rpc/component-tools.ts | 36 ++++++- packages/devtools/src/rpc/router-tools.ts | 8 +- 8 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 packages/devtools/src/rpc/__tests__/component-detail-text.test.ts create mode 100644 packages/devtools/src/rpc/component-detail-text.ts diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 7d1eee05..7ab78c52 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -93,6 +93,8 @@ The live detail of one component instance: inputs, outputs and whether a parent A class name or tag that matches several instances answers for the first and lists the ids of all of them. When nothing matches and the page's tree stopped at a [limit](../inspectors/components.md#selection), `inspect-component` and `highlight` say which one, since the instance can be past it. Secret-looking values are redacted. +The answer stops at 20,000 characters. Past that, each value longer than 1,000 characters shows only the start of its JSON, then each list (inputs, outputs, properties, listeners, directives, injected services) keeps fewer entries. A note names the cut values and says how many entries are not shown. The `detail` of the `component-tree` resource has every value in full. + ### list-components The live component tree of one page as an indented outline, one line per instance: class name, host tag and instance id, as in `ProductCard c12`. Reads: page. @@ -122,6 +124,8 @@ The `@defer` blocks of each page: the component that holds each one, its state, Without a development build, the page has no util to read defer blocks, and the answer says so. +Each page lists at most 50 blocks in the table and 50 under **Needs attention**, with a note on how many more are not shown. The whole answer stops at 20,000 characters. If several tabs report, pass `page` to read one. The **Defer blocks** list on the [Components](../inspectors/components.md#defer-blocks) page has every block. + ### change-detection Change detection cycles recorded with Angular's profiler: the slowest components by self time, the most often checked components, and the latest cycles with their duration, component checks, sync passes and the output that ran before each one. Reads: page. Needs Angular 20 or later. diff --git a/packages/devtools/src/__tests__/component-agent.test.ts b/packages/devtools/src/__tests__/component-agent.test.ts index c30a6602..b3e97a4c 100644 --- a/packages/devtools/src/__tests__/component-agent.test.ts +++ b/packages/devtools/src/__tests__/component-agent.test.ts @@ -97,6 +97,33 @@ describe('component agent tools', () => { expect(again).toContain('2 instances match `app-card`'); }); + it('inspect-component cuts a detail over 20,000 characters and says what it left out', async () => { + const { ctx, push, call } = await boot(); + await push('push-component-tree', { pageId: 'p1', roots, count: 3, detail: null }); + const huge: ComponentDetail = { + ...detailOf('c2'), + properties: Array.from({ length: 60 }, (_, n) => ({ + name: `p${n}`, + prop: `p${n}`, + value: 'x'.repeat(5_000), + })), + }; + vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: Broadcast) => { + if (!options.method.endsWith('inspect-component-in-page')) return; + const { pageId } = options.args[0] as { pageId: string }; + setTimeout( + () => void push('push-component-tree', { pageId, roots, count: 3, detail: huge }), + 10, + ); + }) as never); + + const text = await call('inspect-component', { selector: 'c2' }); + expect(text.length).toBeLessThanOrEqual(20_000); + expect(text).toContain('instance `c2` on page `p1`'); + expect(text).toContain('60 values are longer than 1,000 characters'); + expect(text).toMatch(/Each list shows its first \d+ entries: \d+ more properties not shown\./); + }); + it('inspect-component says when nothing matches or the page does not answer', async () => { const { ctx, push, call } = await boot(); expect(await call('inspect-component', { selector: 'Card' })).toMatch( diff --git a/packages/devtools/src/__tests__/defer-blocks.test.ts b/packages/devtools/src/__tests__/defer-blocks.test.ts index 88e36c45..506e1af3 100644 --- a/packages/devtools/src/__tests__/defer-blocks.test.ts +++ b/packages/devtools/src/__tests__/defer-blocks.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createDeferTracker, type DeferDebugNg } from '../defer-blocks.ts'; import { elementById } from '../element-id.ts'; -import { deferBlocksText, toComponentPage } from '../rpc/component-tools.ts'; +import { DEFER_ROWS, deferBlocksText, toComponentPage } from '../rpc/component-tools.ts'; +import { MAX_TOOL_CHARS } from '../rpc/router-tools.ts'; import type { ComponentPage, DeferBlockInfo } from '../types.ts'; class _TripReviews {} @@ -167,6 +168,34 @@ describe('deferBlocksText', () => { expect(text).toMatch(/\| `d5` .*hydrate never: the server HTML stays static/); }); + it('lists at most DEFER_ROWS blocks per page and says how many more are not shown', () => { + const blocks = Array.from({ length: DEFER_ROWS + 7 }, (_, n) => + info({ id: `d${n}`, state: 'error' }), + ); + const text = deferBlocksText([report(blocks)], 30_000); + expect(text).toContain(`${DEFER_ROWS + 7} defer blocks`); + expect(text).toContain(`| \`d${DEFER_ROWS - 1}\` |`); + expect(text).not.toContain(`| \`d${DEFER_ROWS}\` |`); + expect(text).toContain( + '7 more blocks not shown. The Defer blocks list on the Components page of the panel has every block.', + ); + expect(text).toContain('7 more that need attention not shown.'); + expect(text).not.toContain(`- \`d${DEFER_ROWS}\` in`); + + const two = deferBlocksText([report(blocks), { ...report(blocks), pageId: 'p2' }], 30_000); + expect(two).toContain('7 more blocks not shown. Pass `page` to read one tab. The Defer blocks'); + }); + + it('cuts the whole answer at the shared tool size and says how to narrow it', () => { + const pages = Array.from({ length: 8 }, (_, n) => ({ + ...report(Array.from({ length: DEFER_ROWS }, (_, i) => info({ id: `d${i}` }))), + pageId: `p${n}`, + })); + const text = deferBlocksText(pages); + expect(text.length).toBeLessThan(MAX_TOOL_CHARS + 100); + expect(text).toMatch(/… \(truncated; pass `page` to read one tab\)$/); + }); + it('says when a page has no util, no blocks, or no page reported', () => { expect(deferBlocksText([])).toMatch(/no component tree has been reported/i); expect(deferBlocksText([report()])).toMatch(/exposes no defer block util/); diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index b2124a4a..a1844215 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -58,6 +58,7 @@ import { truncationText, } from './rpc/component-tools.ts'; import { LIST_COMPONENTS_DESCRIPTION, listComponentsText } from './rpc/component-outline.ts'; +import { inspectComponentText } from './rpc/component-detail-text.ts'; import { explainFormsText, formsResourceText, @@ -1671,7 +1672,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-component', description: - 'Get the live detail of one component instance: inputs, outputs with whether a parent listens, other own properties (signals and resources unwrapped), DOM listeners, host directives, change detection, encapsulation, host path, injected services and the source file and line of the class when a dev build has them. Pass an instance id from the pangular:component-tree resource, a class name or a host tag. This selects the instance on its page (the panel follows) and waits for the page to report it. A class name or tag that matches several instances answers for the first and lists the ids of all of them. Secret-looking values are redacted.', + 'Get the live detail of one component instance: inputs, outputs with whether a parent listens, other own properties (signals and resources unwrapped), DOM listeners, host directives, change detection, encapsulation, host path, injected services and the source file and line of the class when a dev build has them. Pass an instance id from the pangular:component-tree resource, a class name or a host tag. This selects the instance on its page (the panel follows) and waits for the page to report it. A class name or tag that matches several instances answers for the first and lists the ids of all of them. Secret-looking values are redacted. Past 20,000 characters, values longer than 1,000 characters show only their start, then each list keeps fewer entries, with a note saying what was left out; the `detail` of the pangular:component-tree resource has it all.', safety: 'read', inputSchema: { type: 'object', @@ -1715,7 +1716,10 @@ const pangular = defineDevframe({ }; } return { - markdown: `Live detail of \`${detail.name}\` (\`<${detail.tag}>\`, instance \`${detail.id}\` on page \`${hit.pageId}\`), read from the running page.${others ? `\n\n${others}` : ''}\n\n${JSON.stringify(detail, null, 2)}`, + markdown: inspectComponentText( + detail, + `Live detail of \`${detail.name}\` (\`<${detail.tag}>\`, instance \`${detail.id}\` on page \`${hit.pageId}\`), read from the running page.${others ? `\n\n${others}` : ''}`, + ), }; }, }); @@ -1755,7 +1759,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:defer-blocks', description: - "List the `@defer` blocks the running page renders, read live through Angular's debug API: the owning component, state (placeholder, loading, complete, error), incremental hydration state (dehydrated, hydrated), triggers and whether it has @loading, @placeholder and @error blocks. Flags blocks that failed to load, blocks still on their placeholder after 10 seconds, blocks still dehydrated and `hydrate never` blocks. Each open tab reports its own list; `page` picks one. Says so when the page has no defer block util (production builds).", + "List the `@defer` blocks the running page renders, read live through Angular's debug API: the owning component, state (placeholder, loading, complete, error), incremental hydration state (dehydrated, hydrated), triggers and whether it has @loading, @placeholder and @error blocks. Flags blocks that failed to load, blocks still on their placeholder after 10 seconds, blocks still dehydrated and `hydrate never` blocks. Each open tab reports its own list; `page` picks one. Lists at most 50 blocks per page in the table and 50 under Needs attention, says how many more are not shown, and cuts the answer at 20,000 characters. Says so when the page has no defer block util (production builds).", safety: 'read', inputSchema: { type: 'object', diff --git a/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts b/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts new file mode 100644 index 00000000..82dc6392 --- /dev/null +++ b/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import type { ComponentDetail, ComponentProp } from '../../types.ts'; +import { DETAIL_VALUE_MAX, inspectComponentText } from '../component-detail-text.ts'; +import { MAX_TOOL_CHARS } from '../router-tools.ts'; + +const detail = (overrides: Partial = {}): ComponentDetail => ({ + id: 'c2', + name: 'Card', + tag: 'app-card', + path: 'app-root > app-card', + inputs: [{ name: 'title', prop: 'title', value: 'Rome' }], + outputs: [], + properties: [], + listeners: [], + directives: [], + dependencies: [], + ...overrides, +}); + +const HEADING = 'Live detail of `Card`.'; +const body = (text: string) => JSON.parse(text.slice(text.indexOf('\n{') + 1)) as ComponentDetail; +const prop = (name: string, value: unknown): ComponentProp => ({ name, prop: name, value }); + +describe('inspectComponentText', () => { + it('returns the heading and the whole detail when it fits', () => { + const text = inspectComponentText(detail(), HEADING); + expect(text.startsWith(`${HEADING}\n\n{`)).toBe(true); + expect(body(text)).toEqual(detail()); + expect(text).not.toMatch(/not shown|only the start/); + }); + + it('cuts long values to their start and names them', () => { + const big = 'x'.repeat(30_000); + const text = inspectComponentText( + detail({ + inputs: [prop('title', 'Rome'), prop('rows', big)], + properties: [prop('cache', { blob: big })], + directives: [{ name: 'Tooltip', inputs: [prop('text', big)], outputs: [] }], + }), + HEADING, + ); + expect(text.length).toBeLessThanOrEqual(MAX_TOOL_CHARS); + expect(text).toContain( + '3 values are longer than 1,000 characters and show only the start of their JSON: `rows`, `cache`, `Tooltip.text`.', + ); + expect(text).toContain( + 'The `detail` of the pangular:component-tree resource has every value in full.', + ); + const json = body(text); + expect(json.inputs[0].value).toBe('Rome'); + expect(json.inputs[1].value).toBe(`${JSON.stringify(big).slice(0, DETAIL_VALUE_MAX)}…`); + expect(json.directives[0].inputs[0].value).toMatch(/…$/); + }); + + it('keeps fewer entries per list and says how many are not shown', () => { + const properties = Array.from({ length: 60 }, (_, n) => prop(`p${n}`, 'v'.repeat(900))); + const dependencies = Array.from({ length: 60 }, (_, n) => ({ + from: 'Card', + token: `Service${n}`, + flags: [], + providedBy: 'r1', + })); + const text = inspectComponentText(detail({ properties, dependencies }), HEADING); + expect(text.length).toBeLessThanOrEqual(MAX_TOOL_CHARS); + expect(text).toContain( + 'Each list shows its first 10 entries: 50 more properties, 50 more dependencies not shown.', + ); + const json = body(text); + expect(json.properties).toHaveLength(10); + expect(json.dependencies).toHaveLength(10); + expect(json.inputs).toHaveLength(1); + }); + + it('falls back to the shared cut with a pointer to the full detail', () => { + const text = inspectComponentText(detail({ path: 'a > '.repeat(10_000) }), HEADING); + expect(text).toMatch( + /… \(truncated; read the `detail` of the pangular:component-tree resource for every value in full\)$/, + ); + }); + + it('keeps redacted values as the page sent them', () => { + const text = inspectComponentText( + detail({ + inputs: [prop('token', '[redacted]'), prop('rows', 'x'.repeat(30_000))], + }), + HEADING, + ); + expect(body(text).inputs[0].value).toBe('[redacted]'); + }); +}); diff --git a/packages/devtools/src/rpc/component-detail-text.ts b/packages/devtools/src/rpc/component-detail-text.ts new file mode 100644 index 00000000..c4ccc783 --- /dev/null +++ b/packages/devtools/src/rpc/component-detail-text.ts @@ -0,0 +1,100 @@ +import type { ComponentDetail, ComponentProp } from '../types.ts'; +import { clip } from '../text.ts'; +import { MAX_TOOL_CHARS, capped } from './router-tools.ts'; + +/** Longest JSON one input or property value keeps once the answer is over {@link MAX_TOOL_CHARS}. */ +export const DETAIL_VALUE_MAX = 1_000; + +/** Entries kept per list, tried in order until the answer fits. */ +const LIST_STEPS = [40, 20, 10, 5, 1]; + +const FULL_DETAIL = 'The `detail` of the pangular:component-tree resource has every value in full.'; +const NARROW = 'read the `detail` of the pangular:component-tree resource for every value in full'; + +type ListKey = 'inputs' | 'outputs' | 'properties' | 'listeners' | 'directives' | 'dependencies'; + +const LISTS: ListKey[] = [ + 'inputs', + 'outputs', + 'properties', + 'listeners', + 'directives', + 'dependencies', +]; + +function clipValues(props: ComponentProp[], prefix: string, cut: string[]): ComponentProp[] { + return props.map((prop) => { + if (!('value' in prop)) return prop; + const json = JSON.stringify(prop.value) ?? 'undefined'; + if (json.length <= DETAIL_VALUE_MAX) return prop; + cut.push(`${prefix}${prop.name}`); + return { ...prop, value: clip(json, DETAIL_VALUE_MAX) }; + }); +} + +/** The detail with every long value replaced by the start of its JSON, and the names of those values. */ +function withClippedValues(detail: ComponentDetail) { + const cut: string[] = []; + const clipped: ComponentDetail = { + ...detail, + inputs: clipValues(detail.inputs, '', cut), + properties: clipValues(detail.properties, '', cut), + directives: detail.directives.map((directive) => ({ + ...directive, + inputs: clipValues(directive.inputs, `${directive.name}.`, cut), + })), + }; + return { clipped, cut }; +} + +/** The detail with each list cut to its first `keep` entries, and how many each list lost. */ +function withShortLists(detail: ComponentDetail, keep: number) { + const hidden: string[] = []; + const short = { ...detail }; + for (const key of LISTS) { + const list = detail[key] as unknown[]; + if (list.length <= keep) continue; + hidden.push(`${list.length - keep} more ${key}`); + (short as Record)[key] = list.slice(0, keep); + } + return { short, hidden }; +} + +function quoted(names: string[]): string { + const shown = names.slice(0, 10).map((name) => `\`${name}\``); + return `${shown.join(', ')}${names.length > 10 ? ` and ${names.length - 10} more` : ''}`; +} + +/** + * The inspect-component answer: a heading line and the detail as JSON. Past + * {@link MAX_TOOL_CHARS} it cuts long values to {@link DETAIL_VALUE_MAX} + * characters, then keeps fewer entries per list, and says what it left out. + * Values arrive serialized and redacted by the page; this only shortens them. + */ +export function inspectComponentText(detail: ComponentDetail, heading: string): string { + const answer = (value: ComponentDetail, notes: string[]) => + `${heading}${notes.length ? `\n\n${notes.join(' ')}` : ''}\n\n${JSON.stringify(value, null, 2)}`; + const full = answer(detail, []); + if (full.length <= MAX_TOOL_CHARS) return full; + + const { clipped, cut } = withClippedValues(detail); + const notes = cut.length + ? [ + `${cut.length} ${cut.length === 1 ? 'value is' : 'values are'} longer than ${DETAIL_VALUE_MAX.toLocaleString('en-US')} characters and ${cut.length === 1 ? 'shows' : 'show'} only the start of ${cut.length === 1 ? 'its' : 'their'} JSON: ${quoted(cut)}.`, + ] + : []; + let text = answer(clipped, [...notes, FULL_DETAIL]); + if (text.length <= MAX_TOOL_CHARS) return text; + + for (const keep of LIST_STEPS) { + const { short, hidden } = withShortLists(clipped, keep); + if (!hidden.length) continue; + text = answer(short, [ + ...notes, + `Each list shows its first ${keep} ${keep === 1 ? 'entry' : 'entries'}: ${hidden.join(', ')} not shown.`, + FULL_DETAIL, + ]); + if (text.length <= MAX_TOOL_CHARS) return text; + } + return capped(text, NARROW); +} diff --git a/packages/devtools/src/rpc/component-tools.ts b/packages/devtools/src/rpc/component-tools.ts index 59b2a831..a49673f4 100644 --- a/packages/devtools/src/rpc/component-tools.ts +++ b/packages/devtools/src/rpc/component-tools.ts @@ -1,6 +1,7 @@ import type { ComponentPage, DeferBlockInfo, LiveComponentNode } from '../types.ts'; import { redactMessage, redactUrl } from '../router.ts'; import { PAGE_TTL_MS, fixedTtl, type PageTtl } from './page-ttl.ts'; +import { capped } from './router-tools.ts'; export const COMPONENT_PAGE_TTL = PAGE_TTL_MS; @@ -133,12 +134,22 @@ function deferNotes(block: DeferBlockInfo, now: number): string[] { return notes; } -/** Markdown answer for the defer-blocks agent tool, one section per page. */ +/** Table rows, and entries under "Needs attention", the defer-blocks tool lists per page. */ +export const DEFER_ROWS = 50; + +const PANEL = 'The Defer blocks list on the Components page of the panel has every block'; + +/** + * Markdown answer for the defer-blocks agent tool, one section per page. Each + * page lists at most {@link DEFER_ROWS} blocks, and the whole answer is cut + * by {@link capped}. + */ export function deferBlocksText(pages: ComponentPage[], now = Date.now()): string { if (!pages.length) { return 'No component tree has been reported. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio server has no page attached and only ever reports this.'; } const cell = (text: string) => text.replace(/\|/g, '\\|'); + const narrow = `${pages.length > 1 ? ' Pass `page` to read one tab.' : ''} ${PANEL}.`; const sections = pages.map((page) => { const title = `Page \`${page.pageId}\`${page.url ? ` (${page.url})` : ''}`; if (!page.deferBlocks) { @@ -156,14 +167,31 @@ export function deferBlocksText(pages: ComponentPage[], now = Date.now()): strin } return `| \`${block.id}\` | ${cell(owner)} | ${block.state} | ${block.hydration} | ${cell(block.triggers.join(', ') || 'none')} | ${cell(notes.join(', '))} |`; }); + const more = (hidden: number, what: string) => + hidden > 0 ? ['', `${hidden} more ${what} not shown.${narrow}`] : []; + const hiddenRows = rows.length - DEFER_ROWS; return [ `${title}: ${page.deferBlocks.length} defer ${page.deferBlocks.length === 1 ? 'block' : 'blocks'}.`, '', '| Block | Owner | State | Hydration | Triggers | Notes |', '| --- | --- | --- | --- | --- | --- |', - ...rows, - ...(attention.length ? ['', 'Needs attention:', '', ...attention] : []), + ...rows.slice(0, DEFER_ROWS), + ...more(hiddenRows, hiddenRows === 1 ? 'block' : 'blocks'), + ...(attention.length + ? [ + '', + 'Needs attention:', + '', + ...attention.slice(0, DEFER_ROWS), + ...more(attention.length - DEFER_ROWS, 'that need attention'), + ] + : []), ].join('\n'); }); - return sections.join('\n\n'); + return capped( + sections.join('\n\n'), + pages.length > 1 + ? 'pass `page` to read one tab' + : 'see the Defer blocks list on the Components page of the panel', + ); } diff --git a/packages/devtools/src/rpc/router-tools.ts b/packages/devtools/src/rpc/router-tools.ts index 99418f44..901e4ee1 100644 --- a/packages/devtools/src/rpc/router-tools.ts +++ b/packages/devtools/src/rpc/router-tools.ts @@ -52,7 +52,8 @@ const MAX_NAVIGATIONS = 50; const MAX_PAGES = 20; const MAX_RESOURCE_CHARS = 100_000; const MAX_TEXT = 4_000; -const MAX_TOOL_CHARS = 20_000; +/** Longest markdown a tool answer shows before {@link capped} cuts it. */ +export const MAX_TOOL_CHARS = 20_000; const MAX_ROUTES = 1_000; const MAX_CHILDREN = 200; const MAX_ROUTE_DEPTH = 13; @@ -425,9 +426,10 @@ export function json(value: unknown): string { return code(text.length > 500 ? `${text.slice(0, 500)}…` : text); } -export function capped(text: string): string { +/** Cuts an answer at {@link MAX_TOOL_CHARS} and says how to narrow it (`narrow` defaults to the router arguments). */ +export function capped(text: string, narrow = 'pass `page`, `url` or a smaller `limit`'): string { return text.length > MAX_TOOL_CHARS - ? `${text.slice(0, MAX_TOOL_CHARS)}… (truncated; pass \`page\`, \`url\` or a smaller \`limit\`)` + ? `${text.slice(0, MAX_TOOL_CHARS)}… (truncated; ${narrow})` : text; } From c14e892168892d16f2bae4022490313e8fd3d1c0 Mon Sep 17 00:00:00 2001 From: erKam Date: Sun, 11 Oct 2026 04:32:07 +0000 Subject: [PATCH 2/2] fix(components): keep capped answers within the limit and shorten directive inputs capped() cut the text at MAX_TOOL_CHARS and then added its note, so a cut answer ran past the stated limit. It now reserves room for the note, so the whole answer is at most MAX_TOOL_CHARS. inspect-component never shortened the inputs and outputs of a directive, so one directive with hundreds of inputs fell through to the final cut with no count of what was left out. Each reduction step now shortens them too and names the omitted counts in the note. --- apps/docs/src/content/agents/tools.md | 2 +- .../src/__tests__/defer-blocks.test.ts | 2 +- .../__tests__/component-detail-text.test.ts | 22 +++++++++++++++++++ .../devtools/src/rpc/component-detail-text.ts | 14 +++++++++++- packages/devtools/src/rpc/router-tools.ts | 11 ++++++---- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 7ab78c52..a10d5f18 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -93,7 +93,7 @@ The live detail of one component instance: inputs, outputs and whether a parent A class name or tag that matches several instances answers for the first and lists the ids of all of them. When nothing matches and the page's tree stopped at a [limit](../inspectors/components.md#selection), `inspect-component` and `highlight` say which one, since the instance can be past it. Secret-looking values are redacted. -The answer stops at 20,000 characters. Past that, each value longer than 1,000 characters shows only the start of its JSON, then each list (inputs, outputs, properties, listeners, directives, injected services) keeps fewer entries. A note names the cut values and says how many entries are not shown. The `detail` of the `component-tree` resource has every value in full. +The answer stops at 20,000 characters. Past that, each value longer than 1,000 characters shows only the start of its JSON, then each list (inputs, outputs, properties, listeners, directives, injected services, and the inputs and outputs of each directive) keeps fewer entries. A note names the cut values and says how many entries are not shown. The `detail` of the `component-tree` resource has every value in full. ### list-components diff --git a/packages/devtools/src/__tests__/defer-blocks.test.ts b/packages/devtools/src/__tests__/defer-blocks.test.ts index 506e1af3..1cc79687 100644 --- a/packages/devtools/src/__tests__/defer-blocks.test.ts +++ b/packages/devtools/src/__tests__/defer-blocks.test.ts @@ -192,7 +192,7 @@ describe('deferBlocksText', () => { pageId: `p${n}`, })); const text = deferBlocksText(pages); - expect(text.length).toBeLessThan(MAX_TOOL_CHARS + 100); + expect(text.length).toBeLessThanOrEqual(MAX_TOOL_CHARS); expect(text).toMatch(/… \(truncated; pass `page` to read one tab\)$/); }); diff --git a/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts b/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts index 82dc6392..47982ff2 100644 --- a/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts +++ b/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts @@ -71,8 +71,30 @@ describe('inspectComponentText', () => { expect(json.inputs).toHaveLength(1); }); + it('shortens the inputs and outputs of a directive and counts what it left out', () => { + const inputs = Array.from({ length: 400 }, (_, n) => prop(`i${n}`, 'v'.repeat(200))); + const outputs = Array.from({ length: 300 }, (_, n) => ({ + name: `o${n}`, + prop: `o${n}`, + listened: false, + })); + const text = inspectComponentText( + detail({ directives: [{ name: 'Tooltip', inputs, outputs }] }), + HEADING, + ); + expect(text.length).toBeLessThanOrEqual(MAX_TOOL_CHARS); + expect(text).not.toContain('(truncated;'); + expect(text).toContain( + 'Each list shows its first 40 entries: 360 more Tooltip inputs, 260 more Tooltip outputs not shown.', + ); + const [directive] = body(text).directives; + expect(directive.inputs).toHaveLength(40); + expect(directive.outputs).toHaveLength(40); + }); + it('falls back to the shared cut with a pointer to the full detail', () => { const text = inspectComponentText(detail({ path: 'a > '.repeat(10_000) }), HEADING); + expect(text.length).toBeLessThanOrEqual(MAX_TOOL_CHARS); expect(text).toMatch( /… \(truncated; read the `detail` of the pangular:component-tree resource for every value in full\)$/, ); diff --git a/packages/devtools/src/rpc/component-detail-text.ts b/packages/devtools/src/rpc/component-detail-text.ts index c4ccc783..931a0018 100644 --- a/packages/devtools/src/rpc/component-detail-text.ts +++ b/packages/devtools/src/rpc/component-detail-text.ts @@ -47,7 +47,10 @@ function withClippedValues(detail: ComponentDetail) { return { clipped, cut }; } -/** The detail with each list cut to its first `keep` entries, and how many each list lost. */ +/** + * The detail with each list, and the inputs and outputs of each directive it + * keeps, cut to their first `keep` entries, and how many each list lost. + */ function withShortLists(detail: ComponentDetail, keep: number) { const hidden: string[] = []; const short = { ...detail }; @@ -57,6 +60,15 @@ function withShortLists(detail: ComponentDetail, keep: number) { hidden.push(`${list.length - keep} more ${key}`); (short as Record)[key] = list.slice(0, keep); } + short.directives = short.directives.map((directive) => { + const cut = { ...directive }; + for (const key of ['inputs', 'outputs'] as const) { + if (directive[key].length <= keep) continue; + hidden.push(`${directive[key].length - keep} more ${directive.name} ${key}`); + cut[key] = directive[key].slice(0, keep); + } + return cut; + }); return { short, hidden }; } diff --git a/packages/devtools/src/rpc/router-tools.ts b/packages/devtools/src/rpc/router-tools.ts index 901e4ee1..fb801c3a 100644 --- a/packages/devtools/src/rpc/router-tools.ts +++ b/packages/devtools/src/rpc/router-tools.ts @@ -426,11 +426,14 @@ export function json(value: unknown): string { return code(text.length > 500 ? `${text.slice(0, 500)}…` : text); } -/** Cuts an answer at {@link MAX_TOOL_CHARS} and says how to narrow it (`narrow` defaults to the router arguments). */ +/** + * Cuts an answer so that, with the note on how to narrow it (`narrow` + * defaults to the router arguments), it is at most {@link MAX_TOOL_CHARS}. + */ export function capped(text: string, narrow = 'pass `page`, `url` or a smaller `limit`'): string { - return text.length > MAX_TOOL_CHARS - ? `${text.slice(0, MAX_TOOL_CHARS)}… (truncated; ${narrow})` - : text; + if (text.length <= MAX_TOOL_CHARS) return text; + const suffix = `… (truncated; ${narrow})`; + return `${text.slice(0, Math.max(0, MAX_TOOL_CHARS - suffix.length))}${suffix}`; } /** Longest JSON a router action answer shows before it is cut. */