import { describe, it, expect, vi } from 'vitest'; import { composePlugins } from '../src/proxymodel/plugins/compose.js'; import type { ProxyModelPlugin, PluginSessionContext } from '../src/proxymodel/plugin.js'; import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js'; const fakeCtx = {} as PluginSessionContext; function plugin(name: string, hooks: Partial = {}): ProxyModelPlugin { return { name, ...hooks }; } describe('composePlugins', () => { it('returns the single plugin when given one', () => { const p = plugin('only'); expect(composePlugins([p])).toBe(p); }); it('throws when given an empty list', () => { expect(() => composePlugins([])).toThrow(); }); it('chains onSessionCreate / onSessionDestroy in order', async () => { const calls: string[] = []; const a = plugin('a', { onSessionCreate: async () => { calls.push('a-create'); }, onSessionDestroy: async () => { calls.push('a-destroy'); }, }); const b = plugin('b', { onSessionCreate: async () => { calls.push('b-create'); }, onSessionDestroy: async () => { calls.push('b-destroy'); }, }); const composed = composePlugins([a, b]); await composed.onSessionCreate!(fakeCtx); await composed.onSessionDestroy!(fakeCtx); expect(calls).toEqual(['a-create', 'b-create', 'a-destroy', 'b-destroy']); }); it('first non-null onToolCallBefore short-circuits the chain', async () => { const aSpy = vi.fn(async () => null); const bSpy = vi.fn(async (): Promise => ({ jsonrpc: '2.0', id: 1, result: 'B' })); const cSpy = vi.fn(async (): Promise => ({ jsonrpc: '2.0', id: 1, result: 'C' })); const composed = composePlugins([ plugin('a', { onToolCallBefore: aSpy }), plugin('b', { onToolCallBefore: bSpy }), plugin('c', { onToolCallBefore: cSpy }), ]); const req: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'tools/call' }; const res = await composed.onToolCallBefore!('foo', {}, req, fakeCtx); expect(res?.result).toBe('B'); expect(cSpy).not.toHaveBeenCalled(); }); it('onToolsList pipelines through plugins (each transforms the previous output)', async () => { const composed = composePlugins([ plugin('a', { onToolsList: async (tools) => [...tools, { name: 'a-added', description: '' }] }), plugin('b', { onToolsList: async (tools) => [...tools, { name: 'b-added', description: '' }] }), ]); const out = await composed.onToolsList!([{ name: 'orig', description: '' }], fakeCtx); expect(out.map((t) => t.name)).toEqual(['orig', 'a-added', 'b-added']); }); it('does not declare hooks that no plugin provides (no-op composition stays minimal)', () => { const composed = composePlugins([plugin('a'), plugin('b')]); expect(composed.onSessionCreate).toBeUndefined(); expect(composed.onToolsList).toBeUndefined(); }); });