165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
|
|
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<string, unknown> }>;
|
||
|
|
_warnings: string[];
|
||
|
|
} {
|
||
|
|
const registered: VirtualServer[] = [];
|
||
|
|
const unregistered: string[] = [];
|
||
|
|
const postCalls: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||
|
|
const warnings: string[] = [];
|
||
|
|
const state = new Map<string, unknown>();
|
||
|
|
|
||
|
|
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<string, unknown>) => {
|
||
|
|
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<typeof mockCtx>;
|
||
|
|
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']);
|
||
|
|
});
|
||
|
|
});
|