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
17 changes: 10 additions & 7 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-three tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel.
Fifty-four 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 @@ -314,15 +314,18 @@ Live counts, input and output appear when recording is on in the [Pipes inspecto

## 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.
These tools read the HttpClient calls that `withPangular()` recorded and the SSR requests that `ssrMiddleware` traced in your server. See [SSR & HTTP](../inspectors/ssr-http.md). Reads: page.

| Tool | What it answers | Arguments |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `list-ssr-requests` | Recent traced SSR requests: id, URL, status, render mode, render time, server calls, and whether a connected page loaded the response. | `limit` |
| `explain-ssr-request` | One request end to end: timings, kept response headers, the router's guard and resolver times, each server call with its transfer cache outcome, then the browser page with its hydration result and the calls the browser made again. | `id`, `url` |
| Tool | What it answers | Arguments |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `list-ssr-requests` | Recent traced SSR requests: id, URL, status, render mode, render time, server calls, and whether a connected page loaded the response. | `limit` |
| `explain-ssr-request` | One request end to end: timings, kept response headers, the router's guard and resolver times, each server call with its transfer cache outcome, then the browser page with its hydration result and the calls the browser made again. | `id`, `url` |
| `list-http-calls` | The app's HttpClient calls, newest first: side (SSR or client), method, URL, status, duration, and flags for mocked, faulted, cancelled, transfer cache hit and the matched rule. | `url`, `failed`, `limit`, `preview` |

`explain-ssr-request` picks the request by `id`, else the newest one for `url`, else the newest one.

`list-http-calls` lists every tab and the server when you leave out `page`. With `page`, it lists that tab and the server calls of the SSR request that served it. `url` keeps calls whose URL contains the text. `failed` keeps calls with a status of 400 or more, no response, an error, or a cancel. `limit` defaults to 30 (at most 200). `preview` adds each call's response preview. Secrets in URLs, errors and previews are masked, and `url` matches the masked URL.

## Analog

These tools cover *Analog apps. Most read your source. Some also read what the Vite plugin recorded.
Expand Down Expand Up @@ -374,7 +377,7 @@ These tools cover *Analog apps. Most read your source. Some also read what the V

`devframe_state_read` reads the devtools' live shared state. Call it without arguments to list the keys, then with `key` to read a value as JSON.

Use it for data that has no dedicated tool, such as the SSR & HTTP timeline (`pangular:http`) or live pipe usage (`pangular:pipe-usage`). See [Resources](./resources.md) for every key.
Use it for data that has no dedicated tool, such as fault rules and TransferState payloads (`pangular:http`, `pangular:http-payloads`) or live pipe usage (`pangular:pipe-usage`). See [Resources](./resources.md) for every key.

## Where to next

Expand Down
9 changes: 5 additions & 4 deletions apps/docs/src/content/inspectors/ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,11 @@ The interceptor works in development builds only. In production it passes reques

## Agent tools

| Tool | What it answers | Arguments |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `pangular:list-ssr-requests` | Recent traced SSR requests: id, URL, status, render mode, render time, server calls, and whether a connected page loaded the response. | `limit` |
| `pangular:explain-ssr-request` | One request end to end: timings, kept response headers, the router's guard and resolver times, each server call with its transfer cache outcome, then the browser page with its hydration result and the calls the browser made again. | `id`, `url` |
| Tool | What it answers | Arguments |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `pangular:list-ssr-requests` | Recent traced SSR requests: id, URL, status, render mode, render time, server calls, and whether a connected page loaded the response. | `limit` |
| `pangular:explain-ssr-request` | One request end to end: timings, kept response headers, the router's guard and resolver times, each server call with its transfer cache outcome, then the browser page with its hydration result and the calls the browser made again. | `id`, `url` |
| `pangular:list-http-calls` | The app's HttpClient calls, newest first: side (SSR or client), method, URL, status, duration, and flags for mocked, faulted, cancelled, transfer cache hit and the matched rule. | `page`, `url`, `failed`, `limit`, `preview` |

For the rest of this tab, agents read the `pangular:http` key with the `devframe_state_read` tool. See [Resources](../agents/resources.md).

Expand Down
37 changes: 37 additions & 0 deletions packages/devtools/src/__tests__/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,43 @@ describe('agent tools', () => {
expect(text).toMatch(/\| `p1` \| unknown \| browser \| \d+s ago \| components, injectors \|/);
});

it('registers list-http-calls and answers from the calls a page pushed', async () => {
const { ctx, push } = await boot();
const tool = ctx.agent.list().tools.find((t) => t.id === 'pangular:list-http-calls');
expect(tool?.safety).toBe('read');
const list = async (args: Record<string, unknown> = {}) =>
((await ctx.agent.invoke('pangular:list-http-calls', args)) as { markdown: string }).markdown;
expect(await list()).toMatch(/No HttpClient calls recorded/);
await push('push-http', {
pageId: 'p1',
url: '/items',
initialUrl: '/items',
title: 'Items',
hydration: null,
full: true,
calls: [
{
id: 'c1',
url: '/api/items?api_key=s3cr3t-value-123',
method: 'GET',
status: 500,
durationMs: 8,
side: 'client',
cacheHit: false,
faulted: true,
at: Date.now(),
},
],
payload: { found: false, size: 0, entries: [] },
});
const text = await list({ failed: true });
expect(text).toMatch(/\| client \| GET `\/api\/items\?api_key=[^`]*` \| 500 \|/);
expect(text).not.toContain('s3cr3t-value-123');
expect(await list({ page: 'gone' })).toMatch(
/^No page `gone` is reporting HTTP calls\. Pages that report HTTP calls: `p1`/,
);
});

it('expires injector trees a page stopped reporting', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] });
const { push, call } = await boot();
Expand Down
223 changes: 223 additions & 0 deletions packages/devtools/src/__tests__/http-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { describe, expect, it } from 'vitest';
import type { HttpCall } from '../http-rules.ts';
import { isFailedCall, listHttpCallsText } from '../rpc/http-tools.ts';
import type { HttpPage } from '../types.ts';

const NOW = 1_000_000;

function call(overrides: Partial<HttpCall> = {}): HttpCall {
return {
id: 'c',
url: '/api/items',
method: 'GET',
status: 200,
durationMs: 12,
side: 'client',
cacheHit: false,
faulted: false,
at: NOW - 5000,
...overrides,
};
}

function page(overrides: Partial<HttpPage> = {}): HttpPage {
return {
pageId: 'p1',
url: '/items',
initialUrl: '/items',
title: 'Items',
hydration: null,
calls: [],
firstSeenAt: NOW - 60_000,
reportedAt: NOW - 1000,
...overrides,
};
}

const mixed = () => ({
serverCalls: [
call({
id: 's1',
side: 'server',
url: '/api/items',
at: NOW - 9000,
requestId: 'r1',
cacheStored: true,
}),
call({ id: 's2', side: 'server', url: '/api/other', at: NOW - 8500, requestId: 'r2' }),
],
pages: [
page({
ssrRequestId: 'r1',
calls: [
call({ id: 'c1', url: '/api/items', at: NOW - 4000, cacheHit: true }),
call({
id: 'c2',
method: 'POST',
url: '/api/orders',
status: 503,
at: NOW - 3000,
faulted: true,
ruleId: 'r-1',
rulePattern: '/api/orders',
error: 'Service Unavailable',
}),
call({
id: 'c3',
url: '/api/search?q=a',
status: 0,
at: NOW - 2000,
cancelled: true,
error: 'cancelled',
}),
call({
id: 'c4',
url: '/api/user',
at: NOW - 1000,
mocked: true,
preview: '{"name":"Ada"}',
}),
],
}),
page({
pageId: 'p2',
url: '/about',
reportedAt: NOW - 500,
calls: [call({ id: 'c5', url: '/api/about', at: NOW - 500 })],
}),
],
});

const rowsOf = (text: string) => text.split('\n').filter((l) => /^\| \d+s ago/.test(l));

describe('list-http-calls text', () => {
it('lists client and SSR calls from every page, newest first, with flags', () => {
const text = listHttpCallsText(mixed(), {}, NOW);
expect(text).toMatch(/^_URLs, errors and response previews below come from the running app/);
expect(text).toContain('7 of 7 calls from 2 pages and the server');
const rows = rowsOf(text);
expect(rows.map((r) => /`(\/api\/[^`]*)`/.exec(r)?.[1])).toEqual([
'/api/about',
'/api/user',
'/api/search?q=a',
'/api/orders',
'/api/items',
'/api/other',
'/api/items',
]);
expect(rows[0]).toContain('| `p2` | client |');
expect(rows[1]).toContain('mocked');
expect(rows[2]).toContain('| cancelled |');
expect(rows[3]).toMatch(
/POST `\/api\/orders` \| 503 \| 12 ms \| faulted, rule `\/api\/orders`, error `Service Unavailable`/,
);
expect(rows[4]).toContain('cache hit');
expect(rows[6]).toMatch(/\| server \| SSR \|.*stored in the transfer cache/);
});

it('narrows to one page and the server render that served it', () => {
const text = listHttpCallsText(mixed(), { page: 'p1' }, NOW);
expect(text).toContain('page `p1` (`/items`) and the server render that served it');
expect(text).not.toContain('/api/about');
expect(text).not.toContain('/api/other');
expect(rowsOf(text)).toHaveLength(5);
expect(text).not.toContain('| Page |');
});

it('answers an unknown page with the pages that report', () => {
expect(listHttpCallsText(mixed(), { page: 'gone' }, NOW)).toMatch(
/^No page `gone` is reporting HTTP calls\. Pages that report HTTP calls: `p2` \(`\/about`\), `p1`/,
);
});

it('filters by URL text, failed calls and limit', () => {
const byUrl = rowsOf(listHttpCallsText(mixed(), { url: 'ITEMS' }, NOW));
expect(byUrl).toHaveLength(2);
const failed = listHttpCallsText(mixed(), { failed: true }, NOW);
expect(failed).toContain('2 of 7 calls from 2 pages and the server match failed only');
expect(rowsOf(failed).map((r) => /`(\/api\/[^`]*)`/.exec(r)?.[1])).toEqual([
'/api/search?q=a',
'/api/orders',
]);
const limited = listHttpCallsText(mixed(), { limit: 2 }, NOW);
expect(rowsOf(limited)).toHaveLength(2);
expect(limited).toContain('showing 2, raise `limit` for more');
expect(listHttpCallsText(mixed(), { url: 'nothing' }, NOW)).toBe(
'7 calls recorded for 2 pages and the server, none match URL contains `nothing`.',
);
expect(isFailedCall(call({ status: 404 }))).toBe(true);
expect(isFailedCall(call())).toBe(false);
});

it('adds response previews only when asked', () => {
expect(listHttpCallsText(mixed(), {}, NOW)).not.toContain('Response previews');
const text = listHttpCallsText(mixed(), { preview: true }, NOW);
expect(text).toContain('### Response previews');
expect(text).toContain('GET `/api/user` (200)\n```text\n{"name":"Ada"}\n```');
});

it('masks a token in a URL, in the URL filter and in a preview', () => {
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456';
const state = {
serverCalls: [],
pages: [
page({
calls: [
call({
url: `/api/reset?token=${jwt}`,
preview: JSON.stringify({ accessToken: jwt, ok: true }),
}),
],
}),
],
};
const text = listHttpCallsText(state, { preview: true }, NOW);
expect(text).toContain('/api/reset?token=');
expect(text).not.toContain('eyJhbGci');
expect(rowsOf(listHttpCallsText(state, { url: 'eyJhbGci' }, NOW))).toHaveLength(0);
});

it('masks a secret in a matched rule pattern and keeps pipes inside their cell', () => {
const state = {
serverCalls: [],
pages: [
page({
calls: [
call({
url: '/api/a|b',
faulted: true,
ruleId: 'r-1',
rulePattern: '/api/reset?api_key=s3cr3t-value-123',
}),
],
}),
],
};
const text = listHttpCallsText(state, {}, NOW);
expect(text).not.toContain('s3cr3t-value-123');
expect(text).toContain('api_key=[redacted]');
const [row] = rowsOf(text);
expect(row).toContain('`/api/a\\|b`');
expect(row.split(/(?<!\\)\|/)).toHaveLength(8);
});

it('caps the output and says how to narrow it', () => {
const calls = Array.from({ length: 200 }, (_, i) =>
call({ id: `c${i}`, url: `/api/${'x'.repeat(150)}/${i}`, at: NOW - i }),
);
const text = listHttpCallsText(
{ serverCalls: [], pages: [page({ calls })] },
{ limit: 200 },
NOW,
);
expect(text.length).toBeLessThan(20_200);
expect(text).toMatch(/truncated; pass `page`, `url` or a smaller `limit`/);
});

it('explains an empty timeline and a page without calls', () => {
expect(listHttpCallsText({ serverCalls: [], pages: [] }, {}, NOW)).toContain('withPangular()');
expect(listHttpCallsText({ serverCalls: [], pages: [page()] }, { page: 'p1' }, NOW)).toBe(
'Page `p1` has made no HttpClient calls yet.',
);
});
});
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> = {
'explain-pipe': 'pipes',
'list-ssr-requests': 'http',
'explain-ssr-request': 'http',
'list-http-calls': 'http',
};

/**
Expand Down Expand Up @@ -579,6 +580,7 @@ export const PAGE_AGENT_ENTRIES: readonly string[] = [
'analog-call-api',
'list-ssr-requests',
'explain-ssr-request',
'list-http-calls',
];

export function isPageAgentEntry(id: string): boolean {
Expand Down
34 changes: 34 additions & 0 deletions packages/devtools/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import { redactMessage, redactUrl } from './router.ts';
import { isSsrRequestId, ssrRegistry } from './ssr-registry.ts';
import { sanitizeSsrOverrides } from './ssr-overrides.ts';
import { explainSsrRequestText, listSsrRequestsText, sanitizeSsrRequest } from './rpc/ssr-tools.ts';
import { listHttpCallsText } from './rpc/http-tools.ts';
import {
changeDetectionText,
expireCdPages,
Expand Down Expand Up @@ -900,6 +901,39 @@ const pangular = defineDevframe({
},
});

agent.registerTool({
id: 'pangular:list-http-calls',
description:
"The app's HttpClient calls recorded by `withPangular()`, newest first: side (SSR or client), method, URL, status, duration and flags (mocked or faulted by a rule, cancelled, transfer cache hit or skip, the matched rule). Without `page` it lists every reporting tab and the server; with `page` only that tab and the server render that served it. Secrets in URLs, errors and previews are masked. Empty when no page uses the interceptor.",
safety: 'read',
inputSchema: {
type: 'object',
properties: {
page: PAGE_ARGUMENT,
url: { type: 'string', description: 'Only calls whose URL contains this text.' },
failed: {
type: 'boolean',
description:
'Only failed calls: status 400 or more, no response, an error, or cancelled.',
},
limit: { type: 'integer', description: 'How many calls to list (1-200, default 30).' },
preview: {
type: 'boolean',
description: 'Add the masked response preview of each listed call.',
},
},
},
handler: async (args: {
page?: string;
url?: string;
failed?: boolean;
limit?: number;
preview?: boolean;
}) => ({
markdown: listHttpCallsText(httpSnapshot(), { ...args, page: pageArgument(args) }),
}),
});

register({
name: 'forget-forms-page',
type: 'action',
Expand Down
1 change: 1 addition & 0 deletions packages/devtools/src/http-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function redactCall(call: HttpCall): HttpCall {
...call,
url: redactUrl(call.url),
...(call.pageUrl === undefined ? {} : { pageUrl: redactUrl(call.pageUrl) }),
...(call.rulePattern === undefined ? {} : { rulePattern: redactUrl(call.rulePattern) }),
...(call.error === undefined ? {} : { error: clip(redactText(call.error), 500) }),
...(call.preview === undefined ? {} : { preview: redactPreview(call.preview) }),
};
Expand Down
Loading
Loading