import { describe, it, expect } from 'vitest'; import type { ProxyModelPlugin, PluginSessionContext } from '../src/proxymodel/plugin.js'; import { PluginRegistry, resolveInheritance, loadPlugins } from '../src/proxymodel/plugin-loader.js'; function stubCtx(): PluginSessionContext { return {} as PluginSessionContext; } describe('PluginRegistry', () => { it('registers and resolves plugins', () => { const registry = new PluginRegistry(); const plugin: ProxyModelPlugin = { name: 'test' }; registry.register({ name: 'test', plugin, source: 'built-in' }); expect(registry.resolve('test')).toBe(plugin); expect(registry.has('test')).toBe(true); expect(registry.resolve('nonexistent')).toBeNull(); expect(registry.has('nonexistent')).toBe(false); }); it('lists all registered plugins', () => { const registry = new PluginRegistry(); registry.register({ name: 'a', plugin: { name: 'a' }, source: 'built-in' }); registry.register({ name: 'b', plugin: { name: 'b' }, source: 'local' }); const list = registry.list(); expect(list).toHaveLength(2); expect(list.map((e) => e.name)).toEqual(['a', 'b']); }); }); describe('resolveInheritance', () => { it('returns plugin unchanged when no extends', () => { const registry = new PluginRegistry(); const plugin: ProxyModelPlugin = { name: 'simple' }; const resolved = resolveInheritance(plugin, registry); expect(resolved.name).toBe('simple'); }); it('inherits hooks from single parent', async () => { const registry = new PluginRegistry(); const parent: ProxyModelPlugin = { name: 'parent', async onToolsList(tools) { return tools.filter((t) => t.name !== 'hidden'); }, }; registry.register({ name: 'parent', plugin: parent, source: 'built-in' }); const child: ProxyModelPlugin = { name: 'child', extends: ['parent'] }; registry.register({ name: 'child', plugin: child, source: 'built-in' }); const resolved = resolveInheritance(child, registry); expect(resolved.onToolsList).toBeDefined(); const tools = [ { name: 'visible', description: 'yes', inputSchema: {} }, { name: 'hidden', description: 'no', inputSchema: {} }, ]; const result = await resolved.onToolsList!(tools, stubCtx()); expect(result).toHaveLength(1); expect(result[0]!.name).toBe('visible'); }); it('child hook overrides parent hook', async () => { const registry = new PluginRegistry(); const parent: ProxyModelPlugin = { name: 'parent', async onToolsList(tools) { return tools.filter((t) => t.name !== 'hidden'); }, }; registry.register({ name: 'parent', plugin: parent, source: 'built-in' }); const child: ProxyModelPlugin = { name: 'child', extends: ['parent'], async onToolsList(tools) { return [...tools, { name: 'added', description: 'new', inputSchema: {} }]; }, }; registry.register({ name: 'child', plugin: child, source: 'built-in' }); const resolved = resolveInheritance(child, registry); const result = await resolved.onToolsList!( [{ name: 'existing', description: 'yes', inputSchema: {} }], stubCtx(), ); expect(result).toHaveLength(2); expect(result.map((t) => t.name)).toEqual(['existing', 'added']); }); it('detects conflict when two parents define the same non-chainable hook', () => { const registry = new PluginRegistry(); const parentA: ProxyModelPlugin = { name: 'parent-a', async onToolsList(tools) { return tools; }, }; const parentB: ProxyModelPlugin = { name: 'parent-b', async onToolsList(tools) { return tools; }, }; registry.register({ name: 'parent-a', plugin: parentA, source: 'built-in' }); registry.register({ name: 'parent-b', plugin: parentB, source: 'built-in' }); const child: ProxyModelPlugin = { name: 'child', extends: ['parent-a', 'parent-b'], }; registry.register({ name: 'child', plugin: child, source: 'built-in' }); expect(() => resolveInheritance(child, registry)).toThrow(/onToolsList.*parent-a.*parent-b/); }); it('resolves conflict when child overrides the conflicting hook', async () => { const registry = new PluginRegistry(); const parentA: ProxyModelPlugin = { name: 'parent-a', async onToolsList(tools) { return tools.slice(0, 1); }, }; const parentB: ProxyModelPlugin = { name: 'parent-b', async onToolsList(tools) { return tools.slice(1); }, }; registry.register({ name: 'parent-a', plugin: parentA, source: 'built-in' }); registry.register({ name: 'parent-b', plugin: parentB, source: 'built-in' }); const child: ProxyModelPlugin = { name: 'child', extends: ['parent-a', 'parent-b'], async onToolsList(tools) { return tools; }, }; registry.register({ name: 'child', plugin: child, source: 'built-in' }); const resolved = resolveInheritance(child, registry); const tools = [ { name: 'a', description: '', inputSchema: {} }, { name: 'b', description: '', inputSchema: {} }, ]; const result = await resolved.onToolsList!(tools, stubCtx()); expect(result).toHaveLength(2); }); it('chains lifecycle hooks from multiple parents', async () => { const registry = new PluginRegistry(); const order: string[] = []; const parentA: ProxyModelPlugin = { name: 'parent-a', async onSessionCreate() { order.push('a'); }, }; const parentB: ProxyModelPlugin = { name: 'parent-b', async onSessionCreate() { order.push('b'); }, }; registry.register({ name: 'parent-a', plugin: parentA, source: 'built-in' }); registry.register({ name: 'parent-b', plugin: parentB, source: 'built-in' }); const child: ProxyModelPlugin = { name: 'child', extends: ['parent-a', 'parent-b'], }; registry.register({ name: 'child', plugin: child, source: 'built-in' }); const resolved = resolveInheritance(child, registry); await resolved.onSessionCreate!(stubCtx()); expect(order).toEqual(['a', 'b']); }); it('detects circular inheritance', () => { const registry = new PluginRegistry(); const a: ProxyModelPlugin = { name: 'a', extends: ['b'] }; const b: ProxyModelPlugin = { name: 'b', extends: ['a'] }; registry.register({ name: 'a', plugin: a, source: 'built-in' }); registry.register({ name: 'b', plugin: b, source: 'built-in' }); expect(() => resolveInheritance(a, registry)).toThrow(/Circular/); }); it('errors when extending unknown parent', () => { const registry = new PluginRegistry(); const child: ProxyModelPlugin = { name: 'child', extends: ['nonexistent'] }; registry.register({ name: 'child', plugin: child, source: 'built-in' }); expect(() => resolveInheritance(child, registry)).toThrow(/unknown plugin 'nonexistent'/); }); it('resolves deep inheritance (grandparent)', async () => { const registry = new PluginRegistry(); const grandparent: ProxyModelPlugin = { name: 'grandparent', async onToolCallAfter(_toolName, _args, response) { return response; }, }; const parent: ProxyModelPlugin = { name: 'parent', extends: ['grandparent'] }; const child: ProxyModelPlugin = { name: 'child', extends: ['parent'] }; registry.register({ name: 'grandparent', plugin: grandparent, source: 'built-in' }); registry.register({ name: 'parent', plugin: parent, source: 'built-in' }); registry.register({ name: 'child', plugin: child, source: 'built-in' }); const resolved = resolveInheritance(child, registry); expect(resolved.onToolCallAfter).toBeDefined(); }); }); describe('loadPlugins', () => { it('loads built-in plugins into registry', async () => { const pluginA: ProxyModelPlugin = { name: 'a' }; const pluginB: ProxyModelPlugin = { name: 'b', extends: ['a'] }; const registry = await loadPlugins([pluginA, pluginB], '/tmp/nonexistent-plugins-dir'); expect(registry.has('a')).toBe(true); expect(registry.has('b')).toBe(true); expect(registry.list()).toHaveLength(2); }); it('resolves inheritance during load', async () => { const parent: ProxyModelPlugin = { name: 'parent', async onToolsList(tools) { return tools; }, }; const child: ProxyModelPlugin = { name: 'child', extends: ['parent'] }; const registry = await loadPlugins([parent, child], '/tmp/nonexistent-plugins-dir'); const resolved = registry.resolve('child'); expect(resolved).toBeDefined(); expect(resolved!.onToolsList).toBeDefined(); }); });