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
11 changes: 10 additions & 1 deletion apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,14 +304,23 @@ It finds impure pipes used inside `@for`, `| json` left in templates, pure pipes

### Explain a pipe

`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, and lint findings. Reads: source, plus the page for live counts.
`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. |

Live counts, input and output appear when recording is on 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. |

## SSR & HTTP

These tools read the SSR requests that `ssrMiddleware` traced in your server. See [SSR requests](../inspectors/ssr-http.md#ssr-requests). Reads: page.
Expand Down
2 changes: 2 additions & 0 deletions packages/devtools/src/__tests__/pipes-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ describe('pipes collector', () => {
'json',
'/nonexistent',
mergePipePageReport(new Map(), report, 0),
1,
);
expect(markdown).toContain('[redacted]');
expect(markdown).not.toContain('hunter3');
Expand Down Expand Up @@ -742,6 +743,7 @@ describe('pipes collector', () => {
'async',
'/nonexistent',
mergePipePageReport(new Map(), h.reports().at(-1)!, 0),
1,
);
expect(markdown).toContain('**Resubscribing:** 1');
expect(markdown).toContain('Feed');
Expand Down
11 changes: 9 additions & 2 deletions packages/devtools/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2403,7 +2403,7 @@ const pangular = defineDevframe({
agent.registerTool({
id: 'pangular:explain-pipe',
description:
'Explain one pipe by name: where it is declared or used, whether it is pure, live instance/call counts and last input/output when instrumentation is on, an experimental stale-value warning, `| async` usages that resubscribe on every check (for `async`), and any lint findings. Use this to answer "why is this pipe slow or stale?"',
'Explain one pipe by name: where it is declared or used, whether it is pure, live instance/call counts and last input/output when instrumentation is on, an experimental stale-value warning, `| async` usages that resubscribe on every check or subscribe to the same source as another usage (for `async`), and any lint findings. Use this to answer "why is this pipe slow or stale?"',
safety: 'read',
inputSchema: {
type: 'object',
Expand All @@ -2414,7 +2414,14 @@ const pangular = defineDevframe({
},
handler: async (args: { name?: string }) => {
if (!args?.name) return { markdown: 'Pass a pipe `name`.' };
return { markdown: explainPipeText(args.name, ctx.cwd, pipesState.value() as PipesState) };
return {
markdown: explainPipeText(
args.name,
ctx.cwd,
pipesState.value() as PipesState,
pipePages.size,
),
};
},
});

Expand Down
137 changes: 113 additions & 24 deletions packages/devtools/src/rpc/__tests__/pipe-explain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from 'node:path';
import { fixtureDir } from './fixture-dir.ts';
import { describe, expect, it } from 'vitest';
import { explainPipeText } from '../pipe-explain.ts';
import type { PipesState } from '../pipes-tools.ts';
import type { AsyncUsageInfo, PipeUsageInfo, PipesState } from '../pipes-tools.ts';

function fixture(): string {
const dir = fixtureDir('pangular-pipe-explain-');
Expand All @@ -20,46 +20,135 @@ function fixture(): string {
return dir;
}

function state(instrumented: string[]): PipesState {
return { pipes: [], async: [], reportedAt: 0, instrumented };
function state(
instrumented: string[],
pipes: PipeUsageInfo[] = [],
async: AsyncUsageInfo[] = [],
): PipesState {
return { pipes, async, reportedAt: 0, instrumented };
}

function usage(name: string, className: string): PipeUsageInfo {
return {
name,
className,
isPure: true,
instanceCount: 1,
components: [{ name: 'CartComponent', count: 1 }],
};
}

function liveLine(text: string): string | undefined {
return text.split('\n').find((line) => line.startsWith('**Live:**'));
}

describe('explainPipeText', () => {
it('names the Record calls button when recording is off', () => {
const text = explainPipeText('appPrice', fixture(), state([]));
it('says no page is connected when no page reports pipes', () => {
const text = explainPipeText('appPrice', fixture(), state([]), 0);
expect(text).toContain('`PricePipe`');
expect(text).toContain('**Live:** unknown, recording is off.');
expect(text).toContain('Click **Record calls** in the Pipes panel');
const live = text.split('\n').find((line) => line.startsWith('**Live:**'));
const live = liveLine(text);
expect(live).toContain('no page is connected');
expect(live).toContain('stdio server');
expect(live).not.toContain('Record calls');
expect(live).not.toContain('—');
});

it('says the pipe is not in use when a page is connected but does not use it', () => {
const text = explainPipeText('appPrice', fixture(), state([]), 1);
const live = liveLine(text);
expect(live).toContain('not in use on the connected page');
expect(live).not.toContain('Record calls');
expect(live).not.toContain('recording is off');
});

it('says the pipe is not in use while recording is on, too', () => {
const text = explainPipeText('appPrice', fixture(), state(['page-1']), 1);
expect(liveLine(text)).toContain('not in use on the connected page');
expect(text).not.toContain('Record calls');
});

it('names the Record calls button when the pipe is in use and recording is off', () => {
const text = explainPipeText(
'appPrice',
fixture(),
state([], [usage('appPrice', 'PricePipe')]),
1,
);
const live = liveLine(text);
expect(live).toContain('1 instance(s), used by CartComponent (1).');
expect(live).toContain('No calls recorded, recording is off.');
expect(live).toContain('Click **Record calls** in the Pipes panel');
expect(live).not.toContain('Instrument');
expect(live).not.toContain('—');
});

it('says no calls were recorded yet when the pipe is in use and recording is on', () => {
const text = explainPipeText(
'appPrice',
fixture(),
state(['page-1'], [usage('appPrice', 'PricePipe')]),
1,
);
const live = liveLine(text);
expect(live).toContain('No calls recorded yet.');
expect(live).not.toContain('Record calls');
});

it('shows already-described values as they are, without escaping them again', () => {
const live: PipesState = {
pipes: [
const live = state(
['page-1'],
[
{
name: 'appPrice',
className: 'PricePipe',
isPure: true,
instanceCount: 1,
components: [],
...usage('appPrice', 'PricePipe'),
call: { callCount: 2, lastArgs: ['{"b":2}'], lastResult: '{"a":1}' },
},
],
async: [],
reportedAt: 0,
instrumented: ['page-1'],
};
const text = explainPipeText('appPrice', fixture(), live);
);
const text = explainPipeText('appPrice', fixture(), live, 1);
expect(text).toContain('Last input: `[{"b":2}]`');
expect(text).toContain('Last output: `{"a":1}`');
expect(text).not.toContain('\\"');
});

it('says the pipe was not seen when recording is on', () => {
const text = explainPipeText('appPrice', fixture(), state(['page-1']));
expect(text).toContain('**Live:** not seen on the currently connected page.');
expect(text).not.toContain('Record calls');
it('lists the components with duplicate async subscriptions', () => {
const asyncUsage = (component: string, duplicate: boolean): AsyncUsageInfo => ({
component,
hasSource: true,
duplicate,
});
const text = explainPipeText(
'async',
fixture(),
state(
[],
[usage('async', 'AsyncPipe')],
[
asyncUsage('CartComponent', true),
asyncUsage('CartComponent', true),
asyncUsage('HeaderComponent', true),
asyncUsage('FooterComponent', false),
],
),
1,
);
const line = text.split('\n').find((l) => l.startsWith('**Duplicate subscriptions:**'));
expect(line).toContain('**Duplicate subscriptions:** 3 `| async` usage(s)');
expect(line).toContain('(CartComponent, HeaderComponent)');
expect(line).not.toContain('FooterComponent');
expect(text).not.toContain('**Resubscribing:**');
});

it('leaves out the duplicate line when no async usage is a duplicate', () => {
const text = explainPipeText(
'async',
fixture(),
state(
[],
[usage('async', 'AsyncPipe')],
[{ component: 'CartComponent', hasSource: true, duplicate: false }],
),
1,
);
expect(text).not.toContain('Duplicate subscriptions');
});
});
50 changes: 37 additions & 13 deletions packages/devtools/src/rpc/pipe-explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,17 @@ function capArgs(args: unknown): string {
return Array.isArray(args) ? `[${args.map(capJson).join(', ')}]` : capJson(args);
}

/** Formats everything known about one pipe — static declaration/usage, live
* call counts and last input/output (if instrumented), and any lint findings
* — as markdown for an agent asking "why is this pipe slow or stale?" */
export function explainPipeText(name: string, cwd: string, live: PipesState): string {
/** Formats everything known about one pipe (static declaration/usage, live
* call counts and last input/output if instrumented, and any lint findings)
* as markdown for an agent asking "why is this pipe slow or stale?"
* `connectedPages` is how many pages currently report pipes; with none (the
* stdio server, or no app tab open) there is no live data at all. */
export function explainPipeText(
name: string,
cwd: string,
live: PipesState,
connectedPages: number,
): string {
const declared = scanPipes(cwd).filter((p) => p.name === name);
const runtime = live.pipes.find((p) => p.name === name);
const findings = lintPipes(cwd).filter((f) => f.pipe === name);
Expand All @@ -43,8 +50,16 @@ export function explainPipeText(name: string, cwd: string, live: PipesState): st
}
if (!declared.length) lines.push(`Not found by source scan (only seen live).`);

lines.push('', runtimeText(runtime, live.instrumented.length > 0));
lines.push('', runtimeText(runtime, connectedPages > 0, live.instrumented.length > 0));
if (name === 'async') {
const duplicates = live.async.filter((a) => a.duplicate);
if (duplicates.length) {
const owners = [...new Set(duplicates.map((a) => a.component))].join(', ');
lines.push(
'',
`**Duplicate subscriptions:** ${duplicates.length} \`| async\` usage(s) subscribe to a source that another \`| async\` usage on the same page also subscribes to (${owners}). For a cold, unshared source each subscription repeats the work, for example a second HTTP request. Subscribe once with \`@let\`, share the source with \`shareReplay\`, or read it through \`toSignal()\`.`,
);
}
const resubscribing = live.async.filter((a) => a.resubscribing);
if (resubscribing.length) {
const owners = [...new Set(resubscribing.map((a) => a.component))].join(', ');
Expand All @@ -63,12 +78,21 @@ export function explainPipeText(name: string, cwd: string, live: PipesState): st
return lines.join('\n');
}

function runtimeText(runtime: PipeUsageInfo | undefined, instrumented: boolean): string {
if (!runtime) {
return instrumented
? '**Live:** not seen on the currently connected page.'
: '**Live:** unknown, recording is off. Click **Record calls** in the Pipes panel for call counts, last input/output and stale-argument detection.';
}
const NO_PAGE =
'**Live:** no page is connected, so there is no live data. 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 NOT_IN_USE =
'**Live:** not in use on the connected page(s). No rendered component uses this pipe right now. Pipe discovery is always on, so this does not depend on recording.';

const RECORD_HINT =
'No calls recorded, recording is off. Click **Record calls** in the Pipes panel for call counts, last input/output and stale-argument detection.';

function runtimeText(
runtime: PipeUsageInfo | undefined,
connected: boolean,
instrumented: boolean,
): string {
if (!runtime) return connected ? NOT_IN_USE : NO_PAGE;
const components = runtime.components.map((c) => `${c.name} (${c.count})`).join(', ');
const parts = [
`**Live:** ${runtime.instanceCount} instance(s), used by ${components || 'unknown'}.`,
Expand All @@ -90,8 +114,8 @@ function runtimeText(runtime: PipeUsageInfo | undefined, instrumented: boolean):
.join('; ')}.`,
);
}
} else if (instrumented) {
parts.push('No calls recorded yet.');
} else {
parts.push(instrumented ? 'No calls recorded yet.' : RECORD_HINT);
}
if (runtime.stale) {
parts.push(
Expand Down
Loading