Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app-card> c12`. Reads: page.
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions packages/devtools/src/__tests__/component-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 30 additions & 1 deletion packages/devtools/src/__tests__/defer-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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/);
Expand Down
10 changes: 7 additions & 3 deletions packages/devtools/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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}` : ''}`,
),
};
},
});
Expand Down Expand Up @@ -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',
Expand Down
112 changes: 112 additions & 0 deletions packages/devtools/src/rpc/__tests__/component-detail-text.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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]');
});
});
112 changes: 112 additions & 0 deletions packages/devtools/src/rpc/component-detail-text.ts
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const list = detail[key] as unknown[];
if (list.length <= keep) continue;
hidden.push(`${list.length - keep} more ${key}`);
(short as Record<ListKey, unknown[]>)[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);
}
Loading
Loading