diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index fa20b842..90044857 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, 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 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..1cc79687 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).toBeLessThanOrEqual(MAX_TOOL_CHARS); + 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 ee86c9f2..d646c02f 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -59,6 +59,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, @@ -1680,7 +1681,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', @@ -1724,7 +1725,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}` : ''}`, + ), }; }, }); @@ -1764,7 +1768,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..47982ff2 --- /dev/null +++ b/packages/devtools/src/rpc/__tests__/component-detail-text.test.ts @@ -0,0 +1,112 @@ +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('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\)$/, + ); + }); + + 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..931a0018 --- /dev/null +++ b/packages/devtools/src/rpc/component-detail-text.ts @@ -0,0 +1,112 @@ +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, 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 }; + 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); + } + 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 }; +} + +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..fb801c3a 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,10 +426,14 @@ export function json(value: unknown): string { return code(text.length > 500 ? `${text.slice(0, 500)}…` : text); } -export function capped(text: string): string { - return text.length > MAX_TOOL_CHARS - ? `${text.slice(0, MAX_TOOL_CHARS)}… (truncated; pass \`page\`, \`url\` or a smaller \`limit\`)` - : text; +/** + * 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 { + 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. */