From 19a845335e2158e65e62d113c3907a2ba6b50fd6 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 10 Oct 2026 22:34:16 +0000 Subject: [PATCH 1/2] fix(pipes): tell apart no page, unused pipe and no calls in explain-pipe explain-pipe answered "Live: unknown, recording is off" whenever a pipe was not seen live and recording was off. Pipe discovery is always on, so on a connected page that meant the pipe is simply not in use, and over the stdio server no page can connect at all, so the Record calls hint pointed agents at a button that could not help. The live line now has three distinct answers: no page is connected, the pipe is not in use on the connected page, or the pipe is in use but no calls were recorded (with the Record calls hint only when recording is off). The handler passes the number of reporting pages so the first two cases can be told apart. For async, the collector's duplicate flag (shown in the panel as "duplicate subscription") was dropped. A "Duplicate subscriptions" line now lists the components whose async usages share a source. --- apps/docs/src/content/agents/tools.md | 11 +- .../src/__tests__/pipes-collector.test.ts | 2 + packages/devtools/src/devframe.ts | 11 +- .../src/rpc/__tests__/pipe-explain.test.ts | 137 +++++++++++++++--- packages/devtools/src/rpc/pipe-explain.ts | 50 +++++-- 5 files changed, 171 insertions(+), 40 deletions(-) diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index c1d2bd3d..1b4f3121 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -304,7 +304,7 @@ 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 | | -------- | -------- | ----------------------------------------------- | @@ -312,6 +312,15 @@ It finds impure pipes used inside `@for`, `| json` left in templates, pure pipes 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. diff --git a/packages/devtools/src/__tests__/pipes-collector.test.ts b/packages/devtools/src/__tests__/pipes-collector.test.ts index d116c243..bafcf460 100644 --- a/packages/devtools/src/__tests__/pipes-collector.test.ts +++ b/packages/devtools/src/__tests__/pipes-collector.test.ts @@ -544,6 +544,7 @@ describe('pipes collector', () => { 'json', '/nonexistent', mergePipePageReport(new Map(), report, 0), + 1, ); expect(markdown).toContain('[redacted]'); expect(markdown).not.toContain('hunter3'); @@ -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'); diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 3b2801d3..b91fe24b 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -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', @@ -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, + ), + }; }, }); diff --git a/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts b/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts index e0df37a6..bad2bc1e 100644 --- a/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts +++ b/packages/devtools/src/rpc/__tests__/pipe-explain.test.ts @@ -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-'); @@ -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'); }); }); diff --git a/packages/devtools/src/rpc/pipe-explain.ts b/packages/devtools/src/rpc/pipe-explain.ts index 627fdfee..ff3bba22 100644 --- a/packages/devtools/src/rpc/pipe-explain.ts +++ b/packages/devtools/src/rpc/pipe-explain.ts @@ -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); @@ -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}). Each subscription runs the source again, 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(', '); @@ -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'}.`, @@ -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( From 0ba64c74e4446e3e7aa72427cec84cf5c8dd24b3 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 10 Oct 2026 22:51:09 +0000 Subject: [PATCH 2/2] fix(pipes): qualify the duplicate subscription note in explain-pipe A duplicate | async usage repeats work only when the source is cold and unshared, such as an HTTP request. A Subject or shared source does not run again per subscriber, so say so instead of claiming every subscription reruns the source. --- packages/devtools/src/rpc/pipe-explain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools/src/rpc/pipe-explain.ts b/packages/devtools/src/rpc/pipe-explain.ts index ff3bba22..0477874c 100644 --- a/packages/devtools/src/rpc/pipe-explain.ts +++ b/packages/devtools/src/rpc/pipe-explain.ts @@ -57,7 +57,7 @@ export function explainPipeText( 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}). Each subscription runs the source again, for example a second HTTP request. Subscribe once with \`@let\`, share the source with \`shareReplay\`, or read it through \`toSignal()\`.`, + `**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);