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
12 changes: 9 additions & 3 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,11 @@ Without `page`, it reads the page that is recording, and `record` goes to every

The signal graph the page reported: nodes (`signal`, `computed`, `linkedSignal`, `effect`), dependency edges, the component or injector they belong to, and recent value history per node. Reads: page.

| Argument | Required | Value |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `selector` | yes | Host tag, class name or instance id of the component, like `app-root`. Or `root`, or a route path like `/admin` or `Route: admin`. |
| Argument | Required | Value |
| ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `selector` | yes | Host tag, class name or instance id of the component, like `app-root`. Or `root`, or a route path like `/admin` or `Route: admin`. |
| `node` | no | A node id or label. The answer holds only that node, or every node with that label, with its direct producers and consumers and their resources and history. |
| `history` | no | `false` leaves out the value history. Defaults to `true`. |

The answer also holds:

Expand All @@ -129,6 +131,8 @@ The answer also holds:
| `changes` | On a node or resource, every change since the page first saw it. The history keeps the last 50. |
| `nodeCount` | Set when Angular reported more than the 400 nodes the page keeps. |

The answer is cut at 20,000 characters. A graph that is too long first keeps fewer history entries per node, then keeps the first nodes that fit. A note under the JSON says how many history entries and nodes it left out. Pass `node` or `history: false` to see the rest.

With `root` or a route path, the tool switches the page's graph to the effects of that injector and waits up to 1.5 seconds for it. If no injector matches, the answer lists the ones the page knows. On Angular 20.0, the answer says the live graph needs Angular 20.1 or later.

<ngmd-callout type="tip" title="One graph per page">
Expand Down Expand Up @@ -214,6 +218,8 @@ Use `explain-navigation` for "why was I redirected". Pass `perf: true` for "why
| `instrument` | Turns per-guard and per-resolver recording on or off. | `on` |
| `resolve-lazy` | Reads the routes of an unloaded lazy route without registering them. | `routeId`, from `list-routes` |

The page's result comes back as a JSON block cut at 15,000 characters. A cut result ends with a note saying so.

`action` is required. Only same-origin URLs that start with `/` are accepted. With `actions.router` set to `false`, `navigate`, `abort`, `replay` and `probe` answer **Navigating is turned off in the devtools config (actions.router).** `instrument` and `resolve-lazy` still work. `agent.readOnly` drops the whole tool.

`waitFor` sets when `navigate` answers:
Expand Down
18 changes: 18 additions & 0 deletions packages/devtools/src/__tests__/router-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,24 @@ describe('router MCP tools', () => {
expect(broadcast).toHaveBeenCalledOnce();
});

it('navigate ends a result it cuts with a note', async () => {
const { ctx, push, call } = await boot();
await push('push-router', report());
vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: never) => {
const { requestId } = (options as { args: [{ requestId: string }] }).args[0];
await push('router-action-result', {
requestId,
result: { outcome: 'succeeded', rows: 'x'.repeat(20_000) },
});
return undefined as never;
}) as never);
const text = await call('navigate', { action: 'probe', url: '/' });
expect(text).toMatch(
/```\n\n_Cut at 15,000 of 20,\d{3} characters, so the JSON above is incomplete and does not parse\._$/,
);
expect(text.length).toBeLessThan(15_300);
});

it('sends a panel router action to one page when no page is named', async () => {
const { ctx, push, call } = await boot();
await push('push-router', report());
Expand Down
191 changes: 191 additions & 0 deletions packages/devtools/src/__tests__/signal-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { createHostContext } from 'devframe/node';
import { describe, expect, it } from 'vitest';
import pangular from '../devframe.ts';
import { routerActionText } from '../rpc/router-tools.ts';
import { SIGNAL_TOOL_MAX, signalGraphText } from '../rpc/signal-tools.ts';
import type { SignalChange, SignalGraph } from '../types.ts';

async function boot() {
const host = {
mountStatic: () => {},
resolveOrigin: () => 'http://localhost',
getStorageDir: () => '',
};
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never });
await pangular.setup(ctx as never);
const push = (name: string, payload: unknown) =>
ctx.rpc.invokeLocal(`pangular:${name}` as never, ...([payload] as never));
const call = async (tool: string, args: Record<string, unknown>) =>
((await ctx.agent.invoke(`pangular:${tool}`, args)) as { markdown: string }).markdown;
return { ctx, push, call };
}

const component = { id: 'c1', name: 'App', tag: 'app-root', path: 'app-root' };

/** A chain s0 <- s1 <- s2 ... where each node consumes the one before it. */
function bigGraph(nodes = 400, perNode = 50): SignalGraph {
const history: Record<string, SignalChange[]> = {};
for (let i = 0; i < nodes; i++) {
history[`n${i}`] = Array.from({ length: perNode }, (_, k) => ({
epoch: k,
value: { label: `value ${k} of node ${i}`, items: [k, k + 1, k + 2] },
at: 1_700_000_000_000 + k,
source: 'write' as const,
}));
}
return {
nodes: Array.from({ length: nodes }, (_, i) => ({
id: `n${i}`,
kind: i % 2 ? ('computed' as const) : ('signal' as const),
label: `s${i}`,
epoch: perNode,
value: { text: 'x'.repeat(40), i },
})),
edges: Array.from({ length: nodes - 1 }, (_, i) => ({ consumer: i + 1, producer: i })),
componentSelector: 'app-root',
component,
history,
};
}

function smallGraph(): SignalGraph {
// count <- doubled <- label, and count <- logEffect; unrelated stays apart.
return {
nodes: [
{ id: 'a', kind: 'signal', label: 'count', epoch: 2, value: 2 },
{ id: 'b', kind: 'computed', label: 'doubled', epoch: 2, value: 4 },
{ id: 'c', kind: 'computed', label: 'label', epoch: 2, value: 'four' },
{ id: 'd', kind: 'effect', label: 'logEffect', epoch: 2 },
{ id: 'e', kind: 'signal', label: 'unrelated', epoch: 0, value: true },
],
edges: [
{ consumer: 1, producer: 0 },
{ consumer: 2, producer: 1 },
{ consumer: 3, producer: 0 },
],
componentSelector: 'app-root',
component,
resources: [
{ id: 'r1', name: 'user', named: true, epoch: 1, nodeIds: ['e'] },
{ id: 'r2', name: 'cart', named: true, epoch: 1, nodeIds: ['c'] },
],
history: {
a: [{ epoch: 1, value: 1, at: 1, source: 'write' }],
c: [{ epoch: 1, value: 'two', at: 1, source: 'write' }],
e: [{ epoch: 0, value: true, at: 1, source: 'initial' }],
r1: [{ epoch: 1, value: 'resolved', at: 1, source: 'write' }],
},
};
}

describe('signalGraphText', () => {
it('returns a small graph as plain JSON, unchanged', () => {
const graph = smallGraph();
expect(JSON.parse(signalGraphText(graph))).toEqual(graph);
});

it('cuts a 400 node graph with long histories under the cap and says what it left out', () => {
const graph = bigGraph();
const full = JSON.stringify(graph, null, 2);
expect(full.length).toBeGreaterThan(1_000_000);
const text = signalGraphText(graph);
expect(text.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX);
expect(text).toMatch(/Cut to fit 20,000 characters/);
expect(text).toMatch(/left out all 20000 history entries/);
expect(text).toMatch(/kept the first \d+ of 400 nodes and left out the other \d+/);
expect(text).toContain('Pass `node`');
// What is kept still parses and its edges point at kept nodes.
const kept = JSON.parse(text.slice(0, text.lastIndexOf('\n\n_Cut'))) as SignalGraph;
expect(kept.nodes.length).toBeGreaterThan(0);
for (const e of kept.edges) {
expect(kept.nodes[e.consumer]).toBeDefined();
expect(kept.nodes[e.producer]).toBeDefined();
}
});

it('keeps fewer history entries before it drops nodes', () => {
const text = signalGraphText(bigGraph(20, 50));
expect(text).toMatch(/kept the last \d+ history entries per node and left out \d+ older ones/);
expect(text).not.toMatch(/nodes and left out/);
const kept = JSON.parse(text.slice(0, text.lastIndexOf('\n\n_Cut'))) as SignalGraph;
expect(kept.nodes).toHaveLength(20);
});

it('narrows to one node and its direct producers and consumers', () => {
const view = JSON.parse(signalGraphText(smallGraph(), { node: 'doubled' })) as SignalGraph;
expect(view.nodes.map((n) => n.label)).toEqual(['count', 'doubled', 'label']);
expect(view.edges).toEqual([
{ consumer: 1, producer: 0 },
{ consumer: 2, producer: 1 },
]);
expect(view.resources?.map((r) => r.id)).toEqual(['r2']);
expect(Object.keys(view.history ?? {})).toEqual(['a', 'c']);
});

it('matches `node` by id, keeps only edges that touch it, and names nodes when none match', () => {
const view = JSON.parse(signalGraphText(smallGraph(), { node: 'a' })) as SignalGraph;
expect(view.nodes.map((n) => n.id)).toEqual(['a', 'b', 'd']);
expect(view.edges).toEqual([
{ consumer: 1, producer: 0 },
{ consumer: 2, producer: 0 },
]);
const missing = signalGraphText(smallGraph(), { node: 'nope' });
expect(missing).toMatch(/No signal node `nope`/);
expect(missing).toContain('`doubled`');
});

it('drops resource references to nodes it left out', () => {
const graph = smallGraph();
graph.resources![1]!.nodeIds = ['c', 'e'];
const view = JSON.parse(signalGraphText(graph, { node: 'doubled' })) as SignalGraph;
expect(view.resources).toEqual([expect.objectContaining({ id: 'r2', nodeIds: ['c'] })]);
});

it('keeps a no-match answer within the budget', () => {
expect(signalGraphText(smallGraph(), { node: 'x'.repeat(30_000) }).length).toBe(
SIGNAL_TOOL_MAX,
);
});

it('drops history with `history: false`', () => {
const view = JSON.parse(signalGraphText(smallGraph(), { history: false })) as SignalGraph;
expect(view.history).toBeUndefined();
expect(view.nodes).toHaveLength(5);
const big = JSON.parse(
signalGraphText(bigGraph(400, 50), { node: 's10', history: false }),
) as SignalGraph;
expect(big.history).toBeUndefined();
expect(big.nodes.map((n) => n.label)).toEqual(['s9', 's10', 's11']);
});
});

describe('inspect-signals tool', () => {
it('passes `node` and `history` through and caps a large graph', async () => {
const { push, call } = await boot();
await push('push-signal-graph', { pageId: 'p1', ...bigGraph() });
const capped = await call('inspect-signals', { selector: 'app-root' });
expect(capped.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX);
expect(capped).toMatch(/Cut to fit 20,000 characters/);
const one = JSON.parse(
await call('inspect-signals', { selector: 'app-root', node: 'n5', history: false }),
) as SignalGraph;
expect(one.nodes.map((n) => n.id)).toEqual(['n4', 'n5', 'n6']);
expect(one.history).toBeUndefined();
});

it('caps the graph it falls back to when the selector does not match', async () => {
const { push, call } = await boot();
await push('push-signal-graph', { pageId: 'p1', ...bigGraph() });
const text = await call('inspect-signals', { selector: 'app-other' });
expect(text).toMatch(/^No signal graph for `app-other`/);
expect(text.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX);
expect(text).toMatch(/Cut to fit/);
});
});

describe('navigate answer', () => {
it('leaves a short result whole', () => {
const text = routerActionText({ ok: true });
expect(text).toMatch(/```json\n\{\n {2}"ok": true\n\}\n```$/);
});
});
28 changes: 20 additions & 8 deletions packages/devtools/src/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { explainPipeText } from './rpc/pipe-explain.ts';
import { trackPageSessions } from './rpc/page-sessions.ts';
import { getBuildMeta } from './rpc/build-meta.ts';
import { getSignals } from './rpc/get-signals.ts';
import { SIGNAL_TOOL_MAX, signalGraphText, type InspectSignalsArgs } from './rpc/signal-tools.ts';
import { injectorMatches, isEnvironmentRequest } from './signal-graph.ts';
import { getProviders } from './rpc/get-providers.ts';
import { getNgrxStore, scanNgrxStore } from './rpc/get-ngrx-store.ts';
Expand Down Expand Up @@ -102,6 +103,7 @@ import {
isRouterReport,
mergeRouterReport,
noPage,
routerActionText,
routerResourceText,
touchRouterPage,
type RouterPage,
Expand Down Expand Up @@ -1744,7 +1746,7 @@ const pangular = defineDevframe({
agent.registerTool({
id: 'pangular:inspect-signals',
description:
"Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path) or `injector` (an environment injector), `resources` (each resource(), httpResource() or rxResource() folded into one entry with status, isLoading, params, value, error and the ids of its internal nodes), `environments` (root and route injectors the page can report), and `history` (recent value changes per node id and status changes per resource id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). `changes` on a node or resource counts every change since the page first saw it, past the 50 kept entries. `nodeCount` is set when Angular reported more nodes than the 400 kept. Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via pangular:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. Pass `root` for effects in root services, or a route path (`/admin` or `Route: admin`) for effects in that route's providers; this switches the page's graph to that injector. A component selector that does not match the reported graph returns what is available instead; call pangular:highlight with it first to switch the graph to it.",
"Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path) or `injector` (an environment injector), `resources` (each resource(), httpResource() or rxResource() folded into one entry with status, isLoading, params, value, error and the ids of its internal nodes), `environments` (root and route injectors the page can report), and `history` (recent value changes per node id and status changes per resource id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). `changes` on a node or resource counts every change since the page first saw it, past the 50 kept entries. `nodeCount` is set when Angular reported more nodes than the 400 kept. Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via pangular:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. Pass `root` for effects in root services, or a route path (`/admin` or `Route: admin`) for effects in that route's providers; this switches the page's graph to that injector. A component selector that does not match the reported graph returns what is available instead; call pangular:highlight with it first to switch the graph to it. Pass `node` (a node id or label) to get only that node with its direct producers and consumers, and `history: false` to leave out value history. The answer is cut at 20,000 characters, first by keeping fewer history entries per node, then fewer nodes, with a note saying what was left out.",
safety: 'read',
inputSchema: {
type: 'object',
Expand All @@ -1754,11 +1756,20 @@ const pangular = defineDevframe({
description:
'Host tag, class name or instance id of the component (e.g. app-root), `root`, or a route path (e.g. /admin).',
},
node: {
type: 'string',
description:
'Node id or label. Returns only that node (every node with that label) and its direct producers and consumers, with their resources and history.',
},
history: {
type: 'boolean',
description: 'False leaves out the value history of every node. Defaults to true.',
},
page: PAGE_ARGUMENT,
},
required: ['selector'],
},
handler: async (args: { selector: string; page?: string }) => {
handler: async (args: { selector: string; page?: string } & InspectSignalsArgs) => {
// `broadcast` resolves with nothing, so the page cannot answer a
// question. Read the graph the overlay pushes into shared state.
const page = pageArgument(args);
Expand Down Expand Up @@ -1802,18 +1813,21 @@ const pangular = defineDevframe({
markdown: `The page runs an Angular version whose signal graph has no node ids, so there is no live graph. The live signal graph needs Angular 20.1 or later. The get-signals source scan still works.`,
};
}
const json = JSON.stringify(graph, null, 2);
const view = { node: args.node, history: args.history };
if (!matches(graph)) {
const known = (graph.environments ?? []).map((e) => `\`${e.name}\``).join(', ');
const covers = graph.componentSelector ?? graph.injector?.name ?? 'another target';
const hint = isEnvironmentRequest(args.selector)
? ` No environment injector on the page matches it${known ? `; the page knows ${known}` : ''}.`
: '';
const head = `No signal graph for \`${args.selector}\`.${hint} The live graph covers \`${covers}\`:\n\n`;
return {
markdown: `No signal graph for \`${args.selector}\`.${hint} The live graph covers \`${covers}\`:\n\n${json}`,
markdown: (
head + signalGraphText(graph, view, Math.max(0, SIGNAL_TOOL_MAX - head.length))
).slice(0, SIGNAL_TOOL_MAX),
};
}
return { markdown: json };
return { markdown: signalGraphText(graph, view) };
},
});

Expand Down Expand Up @@ -2107,9 +2121,7 @@ const pangular = defineDevframe({
? { action: 'resolve-lazy', id: args.routeId }
: { action: args.action };
const result = await requestRouterAction(target.pageId, request);
return {
markdown: `_Result from the running page (untrusted data):_\n\n\`\`\`json\n${JSON.stringify(result, null, 2).slice(0, 15_000)}\n\`\`\``,
};
return { markdown: routerActionText(result) };
},
});

Expand Down
Loading
Loading