import { describe, it, expect, vi } from 'vitest'; import { createAgentsPlugin } from '../src/proxymodel/plugins/agents.js'; import type { PluginSessionContext, VirtualServer } from '../src/proxymodel/plugin.js'; import type { ToolDefinition } from '../src/proxymodel/types.js'; function mockCtx(opts: { agents?: Array<{ id: string; name: string; description: string }> | Error; upstreamTools?: ToolDefinition[]; postResponse?: unknown; } = {}): PluginSessionContext & { _registered: VirtualServer[]; _unregistered: string[]; _postCalls: Array<{ path: string; body: Record }>; _warnings: string[]; } { const registered: VirtualServer[] = []; const unregistered: string[] = []; const postCalls: Array<{ path: string; body: Record }> = []; const warnings: string[] = []; const state = new Map(); const ctx = { sessionId: 'sess-1', projectName: 'mcpctl-dev', state, llm: {} as PluginSessionContext['llm'], cache: {} as PluginSessionContext['cache'], log: { debug: () => undefined, info: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined, }, registerTool: vi.fn(), unregisterTool: vi.fn(), registerServer: vi.fn((s: VirtualServer) => { registered.push(s); }), unregisterServer: vi.fn((name: string) => { unregistered.push(name); }), queueNotification: vi.fn(), discoverTools: vi.fn(async () => opts.upstreamTools ?? []), routeToUpstream: vi.fn(), fetchPromptIndex: vi.fn(async () => []), getSystemPrompt: vi.fn(async (_: string, fallback: string) => fallback), processContent: vi.fn(), postToMcpd: vi.fn(async (path: string, body: Record) => { postCalls.push({ path, body }); return opts.postResponse ?? { assistant: 'hi back', threadId: 'thread-1', turnIndex: 1 }; }), getFromMcpd: vi.fn(async (_path: string) => { if (opts.agents instanceof Error) throw opts.agents; return opts.agents ?? []; }), emitAuditEvent: vi.fn(), _registered: registered, _unregistered: unregistered, _postCalls: postCalls, _warnings: warnings, } as unknown as ReturnType; return ctx; } describe('agents plugin', () => { it('registers a virtual server per agent on session create', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: [ { id: 'a1', name: 'reviewer', description: 'I review security design' }, { id: 'a2', name: 'deployer', description: 'I help you deploy' }, ], }); await plugin.onSessionCreate!(ctx); expect(ctx._registered.map((s) => s.name)).toEqual(['agent-reviewer', 'agent-deployer']); // Tool description carries the agent's description. expect(ctx._registered[0]!.tools[0]!.definition.description).toBe('I review security design'); }); it('falls back to a generic description when agent.description is empty', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: [{ id: 'a1', name: 'silent', description: '' }], }); await plugin.onSessionCreate!(ctx); expect(ctx._registered[0]!.tools[0]!.definition.description).toBe('Chat with agent silent'); }); it('skips agents whose namespace collides with an upstream MCP server', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: [{ id: 'a1', name: 'colliding', description: '' }], upstreamTools: [{ name: 'agent-colliding/something', description: '' }], }); await plugin.onSessionCreate!(ctx); expect(ctx._registered).toHaveLength(0); expect(ctx._warnings.some((w) => /namespace collision/.test(w))).toBe(true); }); it('does nothing when the project has no agents', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: [] }); await plugin.onSessionCreate!(ctx); expect(ctx._registered).toEqual([]); }); it('logs and continues when fetching agents from mcpd fails', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: new Error('mcpd unreachable') }); await plugin.onSessionCreate!(ctx); expect(ctx._registered).toEqual([]); expect(ctx._warnings.some((w) => /mcpd unreachable/.test(w))).toBe(true); }); it('chat tool POSTs to /api/v1/agents/:name/chat and returns the assistant text', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: [{ id: 'a1', name: 'reviewer', description: 'I review' }], }); await plugin.onSessionCreate!(ctx); const handler = ctx._registered[0]!.tools[0]!.handler; const result = await handler({ message: 'security check?', temperature: 0.3 }, ctx); expect(ctx._postCalls).toHaveLength(1); expect(ctx._postCalls[0]!.path).toBe('/api/v1/agents/reviewer/chat'); expect(ctx._postCalls[0]!.body).toMatchObject({ message: 'security check?', temperature: 0.3, stream: false, }); expect(result).toMatchObject({ content: [{ type: 'text', text: 'hi back' }], _meta: { threadId: 'thread-1' }, }); }); it('chat tool surfaces an mcpd error response as an isError content block', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: [{ id: 'a1', name: 'reviewer', description: '' }], postResponse: { error: 'agent unhappy' }, }); await plugin.onSessionCreate!(ctx); const handler = ctx._registered[0]!.tools[0]!.handler; const result = await handler({ message: 'hi' }, ctx) as { isError: boolean; content: Array<{ text: string }> }; expect(result.isError).toBe(true); expect(result.content[0]!.text).toContain('agent unhappy'); }); it('onSessionDestroy unregisters every server it registered', async () => { const plugin = createAgentsPlugin(); const ctx = mockCtx({ agents: [ { id: 'a1', name: 'one', description: '' }, { id: 'a2', name: 'two', description: '' }, ], }); await plugin.onSessionCreate!(ctx); await plugin.onSessionDestroy!(ctx); expect(ctx._unregistered.sort()).toEqual(['agent-one', 'agent-two']); }); });