229 lines
8.4 KiB
TypeScript
229 lines
8.4 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|