diff --git a/packages/core/src/debug/ai/index.ts b/packages/core/src/debug/ai/index.ts new file mode 100644 index 00000000000..50b6fa26df2 --- /dev/null +++ b/packages/core/src/debug/ai/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {registerAiTools} from './registration'; diff --git a/packages/core/src/debug/ai/registration.ts b/packages/core/src/debug/ai/registration.ts new file mode 100644 index 00000000000..2314c636d74 --- /dev/null +++ b/packages/core/src/debug/ai/registration.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signalGraphTool} from './signal_graph'; +import {DevtoolsToolDiscoveryEvent} from './tool_definitions'; + +/** + * Registers Angular AI tools with Chrome DevTools. + * + * This function listens for the `devtoolstooldiscovery` event and responds with + * the available Angular-specific tools. + * + * @returns A callback function to unregister the tools. + */ +export function registerAiTools(): () => void { + // No-op in non-browser environments. + if (typeof window === 'undefined') return () => {}; + + function listener(inputEvent: Event): void { + const event = inputEvent as DevtoolsToolDiscoveryEvent; + event.respondWith({ + name: 'Angular', + tools: [signalGraphTool], + }); + } + + window.addEventListener('devtoolstooldiscovery', listener); + return () => { + window.removeEventListener('devtoolstooldiscovery', listener); + }; +} diff --git a/packages/core/src/debug/ai/signal_graph.ts b/packages/core/src/debug/ai/signal_graph.ts new file mode 100644 index 00000000000..a033549c328 --- /dev/null +++ b/packages/core/src/debug/ai/signal_graph.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {NullInjector} from '../../di/null_injector'; +import {getInjector} from '../../render3/util/discovery_utils'; +import {DebugSignalGraph, getSignalGraph} from '../../render3/util/signal_debug'; +import {ToolDefinition} from './tool_definitions'; + +// Omit `debuggableFn` and `id` from returned signal graph to AI agent. +type AiSignalGraph = Omit & { + nodes: Array>; +}; + +/** + * Tool that exposes Angular's signal dependency graph to AI agents. + */ +export const signalGraphTool: ToolDefinition<{target: HTMLElement}, AiSignalGraph> = { + name: 'angular:signal_graph', + // tslint:disable-next-line:no-toplevel-property-access + description: ` +Exposes the Angular signal dependency graph for a given DOM element. + +This tool extracts the reactive dependency graph (signals, computeds, and effects) that +are transitive dependencies of the effects of that element. It will include signals +authored in other components/services and depended upon by the target component, but +will *not* include signals only used in descendant components effects. + +Params: +- \`target\`: The element to get the signal graph for. Must be the host element of an + Angular component. + +Returns: +- \`nodes\`: An array of reactive nodes discovered in the context. Each node contains: + - \`kind\`: The type of reactive node ('signal', 'computed', 'effect', or 'template' + for component template effects). + - \`value\`: The current evaluated value of the node (if applicable). + - \`label\`: The symbol name of the associated signal if available (ex. + \`const foo = signal(0);\` has \`label: 'foo'\`). + - \`epoch\`: The internal version number of the node's value. +- \`edges\`: An array of dependency links representing which nodes read from which other + nodes. + - \`consumer\`: The index in the \`nodes\` array of the node that depends on the value. + - \`producer\`: The index in the \`nodes\` array of the node that provides the value. + +Example: An edge with \`{consumer: 2, producer: 0}\` means that \`nodes[2]\` (e.g. an +\`effect\`) reads the value of \`nodes[0]\` (e.g. a \`signal\`). + `.trim(), + inputSchema: { + type: 'object', + properties: { + target: { + type: 'object', + description: 'The element to get the signal graph for.', + 'x-mcp-type': 'HTMLElement', + }, + }, + required: ['target'], + }, + execute: async ({target}: {target: HTMLElement}) => { + if (!(target instanceof HTMLElement)) { + throw new Error('Invalid input: "target" must be an HTMLElement.'); + } + + const injector = getInjector(target); + if (injector instanceof NullInjector) { + throw new Error('Invalid input: "target" is not the host element of an Angular component.'); + } + + const graph = getSignalGraph(injector); + return { + // Filter out unneeded data. + nodes: graph.nodes.map(({id, debuggableFn, ...node}) => node), + edges: graph.edges, + }; + }, +}; diff --git a/packages/core/src/debug/ai/tool_definitions.ts b/packages/core/src/debug/ai/tool_definitions.ts new file mode 100644 index 00000000000..e29f316e10f --- /dev/null +++ b/packages/core/src/debug/ai/tool_definitions.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Definition of an AI tool that can be exposed to Chrome DevTools. + */ +export interface ToolDefinition { + /** Name of the tool, should be namespaced (e.g., 'angular:di_graph'). */ + name: string; + + /** Human-readable description of what the tool does. */ + description: string; + + /** JSON schema for the tool's input arguments. */ + inputSchema: Record; + + /** Function that executes the tool. */ + execute: (args: T) => R | Promise; +} + +/** + * A group of related AI tools. + */ +export interface ToolGroup { + /** Name of the tool group. */ + name: string; + + /** List of tools in this group. */ + tools: ToolDefinition[]; +} + +/** + * Event dispatched by Chrome DevTools to discover tools in the page. + */ +export interface DevtoolsToolDiscoveryEvent extends CustomEvent { + /** Callback to register tools with DevTools. */ + respondWith(toolGroup: ToolGroup): void; +} diff --git a/packages/core/src/platform/platform.ts b/packages/core/src/platform/platform.ts index e6dd381ddb4..a77fb7af347 100644 --- a/packages/core/src/platform/platform.ts +++ b/packages/core/src/platform/platform.ts @@ -24,8 +24,10 @@ import {RuntimeError, RuntimeErrorCode} from '../errors'; import {PlatformRef} from './platform_ref'; import {PLATFORM_DESTROY_LISTENERS} from './platform_destroy_listeners'; +import {registerAiTools} from '../debug/ai'; let _platformInjector: Injector | null = null; +let _unregisterAiTools: (() => void) | null = null; /** * Creates a platform. @@ -43,6 +45,9 @@ export function createPlatform(injector: Injector): PlatformRef { publishDefaultGlobalUtils(); publishSignalConfiguration(); + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + _unregisterAiTools = registerAiTools(); + } // During SSR, using this setting and using an injector from the global can cause the // injector to be used for a different requjest due to concurrency. @@ -151,6 +156,11 @@ export function getPlatform(): PlatformRef | null { * @publicApi */ export function destroyPlatform(): void { + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + _unregisterAiTools?.(); + _unregisterAiTools = null; + } + getPlatform()?.destroy(); } @@ -165,6 +175,9 @@ export function createOrReusePlatformInjector(providers: StaticProvider[] = []): if (_platformInjector) return _platformInjector; publishDefaultGlobalUtils(); + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + _unregisterAiTools = registerAiTools(); + } // Otherwise, setup a new platform injector and run platform initializers. const injector = createPlatformInjector(providers); diff --git a/packages/core/test/debug/ai/registration_spec.ts b/packages/core/test/debug/ai/registration_spec.ts new file mode 100644 index 00000000000..63432c34c7a --- /dev/null +++ b/packages/core/test/debug/ai/registration_spec.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signalGraphTool} from '../../../src/debug/ai/signal_graph'; +import {registerAiTools} from '../../../src/debug/ai'; +import {DevtoolsToolDiscoveryEvent} from '../../../src/debug/ai/tool_definitions'; + +describe('registration', () => { + describe('registerAiTools', () => { + it('should register the tools', () => { + const unregister = registerAiTools(); + try { + // Verify Angular responds to the event. + const event = new CustomEvent('devtoolstooldiscovery') as DevtoolsToolDiscoveryEvent; + event.respondWith = jasmine.createSpy('respondWith'); + window.dispatchEvent(event); + expect(event.respondWith).toHaveBeenCalledOnceWith({ + name: 'Angular', + + // Just check one tool. + tools: jasmine.arrayContaining([signalGraphTool]), + }); + } finally { + unregister(); + } + + // After unregistering, Angular should not react to the event. + const event = new CustomEvent('devtoolstooldiscovery') as DevtoolsToolDiscoveryEvent; + event.respondWith = jasmine.createSpy('respondWith'); + window.dispatchEvent(event); + expect(event.respondWith).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/debug/ai/signal_graph_spec.ts b/packages/core/test/debug/ai/signal_graph_spec.ts new file mode 100644 index 00000000000..4b01de3017a --- /dev/null +++ b/packages/core/test/debug/ai/signal_graph_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, computed, effect, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {signalGraphTool} from '../../../src/debug/ai/signal_graph'; +import {setupFrameworkInjectorProfiler} from '../../../src/render3/debug/framework_injector_profiler'; + +describe('signalGraphTool', () => { + beforeEach(() => { + setupFrameworkInjectorProfiler(); + }); + + it('should discover signal graph from targeted element', async () => { + @Component({ + selector: 'signal-graph-test-root', + template: '
Signals test: {{ double() }}
', + standalone: true, + }) + class SignalGraphTestComponent { + private readonly count = signal(1, {debugName: 'count'}); + protected readonly double = computed(() => this.count() * 2, {debugName: 'double'}); + + constructor() { + effect( + () => { + this.double(); + }, + {debugName: 'consumer'}, + ); + } + } + + const fixture = TestBed.createComponent(SignalGraphTestComponent); + await fixture.whenStable(); + + const rootElement = fixture.nativeElement; + + const result = await signalGraphTool.execute({target: rootElement}); + + expect(result.nodes).toEqual( + jasmine.arrayWithExactContents([ + jasmine.objectContaining({kind: 'signal', label: 'count', value: 1}), + jasmine.objectContaining({kind: 'computed', label: 'double', value: 2}), + jasmine.objectContaining({kind: 'effect', label: 'consumer'}), + jasmine.objectContaining({kind: 'template'}), + ]), + ); + + const countIndex = result.nodes.findIndex((n) => n.label === 'count'); + const doubleIndex = result.nodes.findIndex((n) => n.label === 'double'); + const consumerIndex = result.nodes.findIndex((n) => n.label === 'consumer'); + const templateIndex = result.nodes.findIndex((n) => n.kind === 'template'); + + expect(result.edges).toEqual( + jasmine.arrayWithExactContents([ + {consumer: consumerIndex, producer: doubleIndex}, + {consumer: doubleIndex, producer: countIndex}, + {consumer: templateIndex, producer: doubleIndex}, + ]), + ); + }); + + it('should throw an error if target is not an HTMLElement', async () => { + await expectAsync( + signalGraphTool.execute({target: {} as unknown as HTMLElement}), + ).toBeRejectedWithError(/must be an HTMLElement/); + }); + + it('should throw an error if target is not an Angular component', async () => { + await expectAsync( + signalGraphTool.execute({target: document.createElement('div')}), + ).toBeRejectedWithError(/not the host element of an Angular component/); + }); +}); diff --git a/packages/platform-browser/test/browser/bootstrap_spec.ts b/packages/platform-browser/test/browser/bootstrap_spec.ts index 3af1e1070c7..0f80f470536 100644 --- a/packages/platform-browser/test/browser/bootstrap_spec.ts +++ b/packages/platform-browser/test/browser/bootstrap_spec.ts @@ -817,6 +817,20 @@ describe('bootstrap factory method', () => { ); }); + it('should register AI tools', async () => { + const evt = new Event('devtoolstooldiscovery') as any; + evt.respondWith = jasmine.createSpy<() => void>('respondWith'); + window.dispatchEvent(evt); + expect(evt.respondWith).not.toHaveBeenCalled(); + + await bootstrap(HelloRootCmp); + window.dispatchEvent(evt); + expect(evt.respondWith).toHaveBeenCalledOnceWith({ + name: 'Angular', + tools: jasmine.any(Array), + }); + }); + describe('change detection', () => { const log: string[] = [];