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
34 changes: 23 additions & 11 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh
---

<ngmd-hero title="Tools" logo="https://cdn.simpleicons.org/modelcontextprotocol/71717A" gradient>
Fifty-eight tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel.
Fifty-nine tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel.
</ngmd-hero>

# Tools
Expand Down Expand Up @@ -352,20 +352,32 @@ It finds impure pipes used inside `@for`, `| json` left in templates, pure pipes

`explain-pipe` explains one pipe: where it is declared or used, whether it is pure, live instance and call counts, the last input and output, a stale-value warning, `| async` usages that resubscribe on every check or subscribe to the same source as another usage, and lint findings. Reads: source, plus the page for live counts.

| Argument | Required | Value |
| -------- | -------- | ----------------------------------------------- |
| `name` | yes | The pipe name as used after `\|` in a template. |
| Argument | Required | Value |
| -------- | -------- | -------------------------------------------------- |
| `name` | yes | The pipe name as used after `\|` in a template. |
| `page` | no | The tab to read. Defaults to the most recent page. |

Live counts, input and output appear when recording is on in the [Pipes inspector](../inspectors/pipes.md).
Live data comes from one tab: the one `page` names, or the most recent. When more than one tab reports pipes, the answer starts with the tab it read. Live counts, input and output appear while recording is on. Start it with [`record-pipes`](#record-pipe-calls) or with **Record calls** in the [Pipes inspector](../inspectors/pipes.md).

The **Live** line tells these cases apart:

| Case | What the tool says |
| ----------------------------------- | --------------------------------------------------------------------- |
| No page is connected | There is no live data. The stdio server always reports this. |
| A page is connected, pipe not used | No rendered component on the page uses the pipe. |
| Pipe in use, recording off | Instance count and components, plus a hint to click **Record calls**. |
| Pipe in use, recording on, no calls | Instance count and components, and that no calls are recorded yet. |
| Case | What the tool says |
| ----------------------------------- | ------------------------------------------------------------------ |
| No page is connected | There is no live data. The stdio server always reports this. |
| A page is connected, pipe not used | No rendered component on the page uses the pipe. |
| Pipe in use, recording off | Instance count and components, plus a hint to start recording. |
| Pipe in use, recording on, no calls | Instance count and components, and that no calls are recorded yet. |

### Record pipe calls

`record-pipes` starts or stops call recording on a page, the same switch as **Record calls** in the panel. Reads: page.

| Argument | Required | Value |
| -------- | -------- | ---------------------------------------------------------------------------------------------- |
| `action` | yes | `start` or `stop`. |
| `page` | no | The tab to record on. Defaults to the most recent page for `start`, and every page for `stop`. |

`start` records on the tab `page` names, or on the most recent tab. Other tabs stay unpatched. `stop` stops that tab, or every tab without `page`. Each recording starts from zero, and the calls recorded so far stay after a stop. Call `record-pipes` with `action: "start"`, use the app, then call `explain-pipe`. Recording is instrumentation, like the `record` argument of `change-detection`, so `agent.readOnly` and `actions` leave the tool in place. `agent.tools.pipes: false` hides it.

## SSR & HTTP

Expand Down
5 changes: 3 additions & 2 deletions apps/docs/src/content/inspectors/pipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ Look for **resubscribing** rows in **Async subscriptions**, and for `async-on-ca
| `pangular:get-pipes` | no | Custom pipes and the built-in pipes in use. |
| `pangular:lint-pipes` | no | Runs the lint rules above. |
| `pangular:explain-pipe` | partly | One pipe by `name`: where it is declared or used, purity, live counts, last input and output, the stale warning, resubscribing `\| async` usages and the lint findings. |
| `pangular:record-pipes` | yes | Starts or stops call recording with `action`, like **Record calls**. |

Agents can't turn recording on. To give `explain-pipe` call data, click **Record calls** in the panel first. See [Tools](../agents/tools.md).
`explain-pipe` and `record-pipes` take `page` to pick a tab, and default to the most recent one, except that `record-pipes` with `action: "stop"` and no `page` stops recording on every tab. To give `explain-pipe` call data, call `record-pipes` with `action: "start"` or click **Record calls** in the panel, then use the app. See [Tools](../agents/tools.md#pipes).

## Limits and gotchas

Expand All @@ -154,7 +155,7 @@ Recording is off by default. Reload the page and it is off again.

<ngmd-accordion>
<ngmd-accordion-item title="Why does explain-pipe return no call data?">
Recording is off. Agents can't turn it on. Click <strong>Record calls</strong> in the panel first.
Recording is off. Call <code>pangular:record-pipes</code> with <code>action: "start"</code>, or click <strong>Record calls</strong> in the panel, then use the app.
</ngmd-accordion-item>
<ngmd-accordion-item title="Why isn't a pipe from another package listed as built-in?">
The built-in list covers the <code>&#64;angular/common</code> pipes only.
Expand Down
16 changes: 16 additions & 0 deletions packages/devtools/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,22 @@ describe('agent tool and RPC registration', () => {
expect(tools).toContain('list-routes');
});

it('keeps record-pipes with its inspector, not behind a write action', async () => {
const pipeTools = ['explain-pipe', 'record-pipes'];
expect((await boot()).tools).toEqual(expect.arrayContaining(pipeTools));
// Recording is instrumentation, like change detection recording, so
// blocking actions or read-only mode leaves it in place.
const blocked = await boot({ actions: false, agent: { readOnly: true } });
expect(blocked.tools).toEqual(expect.arrayContaining(pipeTools));
expect(blocked.actionTools).not.toContain('record-pipes');
const hidden = await boot({ agent: { tools: { pipes: false } } });
expect(hidden.tools.filter((id) => pipeTools.includes(id))).toEqual([]);
expect(hidden.rpc).toContain('request-instrument-pipes');
const off = await boot({ inspectors: { pipes: false } });
expect(off.tools.filter((id) => pipeTools.includes(id))).toEqual([]);
expect(off.rpc).not.toContain('request-instrument-pipes');
});

it('hides one inspector from the agent while its panel keeps working', async () => {
const { rpc, tools, resources } = await boot({ agent: { tools: { router: false } } });
expect(tools.some((id) => AGENT_INSPECTOR[id] === 'router')).toBe(false);
Expand Down
4 changes: 4 additions & 0 deletions packages/devtools/src/__tests__/docs-tool-count.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const WORDS = [
'Fifty-six',
'Fifty-seven',
'Fifty-eight',
'Fifty-nine',
'Sixty',
'Sixty-one',
'Sixty-two',
];

it('states on the tools page how many tools the server registers', async () => {
Expand Down
147 changes: 136 additions & 11 deletions packages/devtools/src/__tests__/pipes-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,25 @@ import { createHostContext } from 'devframe/node';
import { afterEach, describe, expect, it, vi } from 'vitest';
import pangular from '../devframe.ts';
import { isPipePageReport, mergePipePageReport, type PipePageReport } from '../rpc/pipes-tools.ts';
import { fixtureDir } from '../rpc/__tests__/fixture-dir.ts';

async function boot() {
const host = {
mountStatic: () => {},
resolveOrigin: () => 'http://localhost',
getStorageDir: () => '',
};
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never });
// An empty project: explain-pipe scans the source on every call, and these
// tests are about live data only.
const cwd = fixtureDir('pangular-pipes-pages-');
const ctx = await createHostContext({ cwd, mode: 'dev', host: host as never });
await pangular.setup(ctx as never);
const invoke = (name: string, ...args: unknown[]) =>
ctx.rpc.invokeLocal(`pangular:${name}` as never, ...(args as never));
const explain = async () =>
(
(await ctx.agent.invoke('pangular:explain-pipe', { name: 'async' })) as {
markdown: string;
}
).markdown;
return { ctx, invoke, explain };
const tool = async (id: string, args: Record<string, unknown>) =>
((await ctx.agent.invoke(`pangular:${id}`, args)) as { markdown: string }).markdown;
const explain = (page?: string) => tool('explain-pipe', { name: 'async', page });
return { ctx, invoke, explain, tool };
}

function page(pageId: string): PipePageReport {
Expand Down Expand Up @@ -68,17 +69,18 @@ describe('pipe pages and their connections', () => {
await invoke('push-pipes', page('tab-1'));
host.getCurrentRpcSession = () => ({ meta: { id: 2 } });
await invoke('push-pipes', page('tab-2'));
expect(await explain()).toContain('6 instance(s)');
expect(await explain('tab-1')).toContain('3 instance(s)');

host._emitSessionDisconnected({ id: 1 });
expect(await explain()).toContain('3 instance(s)');
expect(await explain('tab-1')).toMatch(/^No page `tab-1` is reporting pipe usage/);
expect(await explain('tab-2')).toContain('3 instance(s)');

// A reconnect re-binds the page to its new connection, so the old
// connection closing afterwards must not drop it.
host.getCurrentRpcSession = () => ({ meta: { id: 3 } });
await invoke('push-pipes', page('tab-2'));
host._emitSessionDisconnected({ id: 2 });
expect(await explain()).toContain('3 instance(s)');
expect(await explain('tab-2')).toContain('3 instance(s)');
});
});

Expand Down Expand Up @@ -181,3 +183,126 @@ describe('pipe recording requests', () => {
}
});
});

function pageWith(pageId: string, instanceCount: number, callCount?: number): PipePageReport {
const base = page(pageId);
return {
...base,
pipes: [
{
...base.pipes[0],
instanceCount,
components: [{ name: 'ExamplePage', count: instanceCount }],
...(callCount === undefined
? {}
: { call: { callCount, lastArgs: [pageId], lastResult: pageId } }),
},
],
instrumented: callCount !== undefined,
};
}

describe('explain-pipe page scoping', () => {
it('answers from the most recent page, or the one `page` names, without mixing tabs', async () => {
vi.useFakeTimers();
try {
const { invoke, explain } = await boot();
await invoke('push-pipes', pageWith('tab-1', 2, 5));
await vi.advanceTimersByTimeAsync(1000);
await invoke('push-pipes', pageWith('tab-2', 4));

const latest = await explain();
expect(latest).toMatch(/^Live data from page `tab-2`; 2 pages report pipe usage/);
expect(latest).toContain('4 instance(s)');
expect(latest).toContain('recording is off');
expect(latest).not.toContain('6 instance(s)');

const first = await explain('tab-1');
expect(first).toContain('Live data from page `tab-1`');
expect(first).toContain('2 instance(s)');
expect(first).toContain('Called 5 time(s)');
expect(first).toContain('Last input: `[tab-1]`');
} finally {
vi.useRealTimers();
}
});

it('names the reporting pages when `page` is unknown', async () => {
const { invoke, explain } = await boot();
expect(await explain('gone')).toBe(
'No page `gone` is reporting pipe usage. No page reports pipe usage right now.',
);
await invoke('push-pipes', page('tab-1'));
expect(await explain('gone')).toBe(
'No page `gone` is reporting pipe usage. Pages that report pipe usage: `tab-1`. Pass one of these ids as `page`, or leave `page` out for the most recent.',
);
});

it('adds no page line when only one page reports', async () => {
const { invoke, explain } = await boot();
await invoke('push-pipes', page('tab-1'));
const text = await explain();
expect(text).not.toContain('Live data from page');
expect(text).toContain('3 instance(s)');
});
});

describe('record-pipes agent tool', () => {
const recordings = (broadcast: { mock: { calls: unknown[][] } }) =>
broadcast.mock.calls
.map(([options]) => options as { method: string; args: unknown[] })
.filter((options) => options.method === 'pangular:instrument-pipes')
.map((options) => options.args[0]);

it('starts and stops recording through the same request the panel sends', async () => {
vi.useFakeTimers();
try {
const { ctx, invoke, tool } = await boot();
const broadcast = vi.spyOn(ctx.rpc, 'broadcast');
await invoke('push-pipes', page('tab-1'));
await vi.advanceTimersByTimeAsync(1000);
await invoke('push-pipes', page('tab-2'));

expect(await tool('record-pipes', { action: 'start' })).toMatch(
/^Recording pipe calls on page `tab-2`\./,
);
expect(await tool('record-pipes', { action: 'start', page: 'tab-1' })).toMatch(
/^Recording pipe calls on page `tab-1`\./,
);
expect(await tool('record-pipes', { action: 'stop', page: 'tab-1' })).toMatch(
/^Recording stopped on page `tab-1`\./,
);
expect(await tool('record-pipes', { action: 'stop' })).toMatch(
/^Recording stopped on 2 page\(s\)\./,
);
expect(recordings(broadcast)).toEqual([
{ pageId: 'tab-2', on: true },
{ pageId: 'tab-1', on: true },
{ pageId: 'tab-1', on: false },
{ on: false },
]);
} finally {
vi.useRealTimers();
}
});

it('sends nothing for an unknown page, a missing action or before any page reports', async () => {
const { ctx, invoke, tool } = await boot();
const broadcast = vi.spyOn(ctx.rpc, 'broadcast');
expect(await tool('record-pipes', { action: 'start' })).toMatch(
/^No page reports pipe usage, so there is nothing to record/,
);
await invoke('push-pipes', page('tab-1'));
expect(await tool('record-pipes', { action: 'start', page: 'gone' })).toMatch(
/^No page `gone` is reporting pipe usage\. Pages that report pipe usage: `tab-1`/,
);
expect(await tool('record-pipes', {})).toBe('Pass `action`: "start" or "stop".');
expect(recordings(broadcast)).toEqual([]);
});

it('points explain-pipe at the agent tool while recording is off', async () => {
const { invoke, explain } = await boot();
await invoke('push-pipes', page('tab-1'));
expect(await explain()).toContain('Start it with pangular:record-pipes');
});
});
2 changes: 2 additions & 0 deletions packages/devtools/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ export const AGENT_INSPECTOR: Record<string, PangularInspector> = {
navigate: 'router',
'lint-pipes': 'pipes',
'explain-pipe': 'pipes',
'record-pipes': 'pipes',
'list-ssr-requests': 'http',
'explain-ssr-request': 'http',
'list-http-calls': 'http',
Expand Down Expand Up @@ -581,6 +582,7 @@ export const PAGE_AGENT_ENTRIES: readonly string[] = [
'router-config',
'export-navigation',
'navigate',
'record-pipes',
'analog-current-page',
'analog-server-calls',
'analog-call-api',
Expand Down
54 changes: 36 additions & 18 deletions packages/devtools/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getComponents } from './rpc/get-components.ts';
import { getPipes } from './rpc/get-pipes.ts';
import { inspectProvidersText } from './rpc/injector-tools.ts';
import { lintPipes, lintPipesText } from './rpc/pipe-lint.ts';
import { explainPipeText } from './rpc/pipe-explain.ts';
import { explainPipeOnPage, pipeRecordRequest, pipeRecordText } from './rpc/pipes-agent.ts';
import { trackPageSessions } from './rpc/page-sessions.ts';
import { getBuildMeta } from './rpc/build-meta.ts';
import { getSignals } from './rpc/get-signals.ts';
Expand Down Expand Up @@ -452,18 +452,21 @@ const pangular = defineDevframe({
},
});

// Shared by the panel's Record calls button and the record-pipes agent tool.
const requestPipeRecording = (message: unknown) => {
const request = pipeInstrumentRequest(message, pipePages);
// No page has reported yet: starting now would patch every connected tab.
if (request.on && !request.pageId) return { request, pages: { pages: 0 } };
void my.rpc.broadcast({ method: 'instrument-pipes', args: [request], optional: true });
if (!request.pageId) return { request, pages: { pages: pipePages.size } };
return { request, pages: { pages: pipePages.has(request.pageId) ? 1 : 0 } };
};

register({
name: 'request-instrument-pipes',
type: 'action',
jsonSerializable: true,
handler: (message: unknown) => {
const request = pipeInstrumentRequest(message, pipePages);
// No page has reported yet: starting now would patch every connected tab.
if (request.on && !request.pageId) return { pages: 0 };
void my.rpc.broadcast({ method: 'instrument-pipes', args: [request], optional: true });
if (!request.pageId) return { pages: pipePages.size };
return { pages: pipePages.has(request.pageId) ? 1 : 0 };
},
handler: (message: unknown) => requestPipeRecording(message).pages,
});

register({
Expand Down Expand Up @@ -2599,19 +2602,34 @@ const pangular = defineDevframe({
type: 'object',
properties: {
name: { type: 'string', description: 'The pipe name as used after `|` in a template.' },
page: PAGE_ARGUMENT,
},
required: ['name'],
},
handler: async (args: { name?: string }) => {
handler: async (args: { name?: string; page?: string }) => {
if (!args?.name) return { markdown: 'Pass a pipe `name`.' };
return {
markdown: explainPipeText(
args.name,
ctx.cwd,
pipesState.value() as PipesState,
pipePages.size,
),
};
return { markdown: explainPipeOnPage(args.name, ctx.cwd, pipePages, pageArgument(args)) };
},
});

agent.registerTool({
id: 'pangular:record-pipes',
description:
"Start or stop recording pipe calls on the running page, the same switch as Record calls in the Pipes panel. Recording wraps each pipe's transform() to count calls and keep the last input, output and caller, and turns on the experimental stale-argument check; pangular:explain-pipe then shows them. `start` records on the page `page` names, or on the most recent page; `stop` stops that page, or every page without `page`. Calls recorded so far stay after a stop.",
safety: 'read',
inputSchema: {
type: 'object',
properties: {
action: { type: 'string', enum: ['start', 'stop'] },
page: PAGE_ARGUMENT,
},
required: ['action'],
},
handler: async (args: { action?: string; page?: string }) => {
const asked = pipeRecordRequest(args?.action, pipePages, pageArgument(args));
if ('markdown' in asked) return asked;
const sent = requestPipeRecording(asked.request);
return { markdown: pipeRecordText(sent.request, sent.pages.pages) };
},
});

Expand Down
Loading
Loading