feat: eager vLLM warmup and smart page titles in paginate stage
- Add warmup() to LlmProvider interface for eager subprocess startup - ManagedVllmProvider.warmup() starts vLLM in background on project load - ProviderRegistry.warmupAll() triggers all managed providers - NamedProvider proxies warmup() to inner provider - paginate stage generates LLM-powered descriptive page titles when available, cached by content hash, falls back to generic "Page N" - project-mcp-endpoint calls warmupAll() on router creation so vLLM is loading while the session initializes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
228
src/mcplocal/tests/plugin-loader.test.ts
Normal file
228
src/mcplocal/tests/plugin-loader.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user