opc-agent 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels/email.d.ts +32 -26
- package/dist/channels/email.js +239 -62
- package/dist/channels/feishu.d.ts +21 -6
- package/dist/channels/feishu.js +225 -126
- package/dist/channels/websocket.d.ts +46 -3
- package/dist/channels/websocket.js +306 -37
- package/dist/channels/wechat.d.ts +33 -13
- package/dist/channels/wechat.js +229 -42
- package/dist/cli.js +712 -11
- package/dist/core/a2a.d.ts +17 -0
- package/dist/core/a2a.js +43 -1
- package/dist/core/agent.d.ts +16 -0
- package/dist/core/agent.js +108 -0
- package/dist/core/runtime.d.ts +6 -0
- package/dist/core/runtime.js +161 -2
- package/dist/core/sandbox.d.ts +26 -0
- package/dist/core/sandbox.js +117 -0
- package/dist/core/workflow-graph.d.ts +93 -0
- package/dist/core/workflow-graph.js +247 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +183 -0
- package/dist/eval/index.d.ts +65 -0
- package/dist/eval/index.js +191 -0
- package/dist/index.d.ts +30 -6
- package/dist/index.js +60 -4
- package/dist/plugins/content-filter.d.ts +7 -0
- package/dist/plugins/content-filter.js +25 -0
- package/dist/plugins/index.d.ts +42 -0
- package/dist/plugins/index.js +108 -2
- package/dist/plugins/logger.d.ts +6 -0
- package/dist/plugins/logger.js +20 -0
- package/dist/plugins/rate-limiter.d.ts +7 -0
- package/dist/plugins/rate-limiter.js +35 -0
- package/dist/protocols/a2a/client.d.ts +25 -0
- package/dist/protocols/a2a/client.js +115 -0
- package/dist/protocols/a2a/index.d.ts +6 -0
- package/dist/protocols/a2a/index.js +12 -0
- package/dist/protocols/a2a/server.d.ts +41 -0
- package/dist/protocols/a2a/server.js +295 -0
- package/dist/protocols/a2a/types.d.ts +91 -0
- package/dist/protocols/a2a/types.js +15 -0
- package/dist/protocols/a2a/utils.d.ts +6 -0
- package/dist/protocols/a2a/utils.js +47 -0
- package/dist/protocols/agui/client.d.ts +10 -0
- package/dist/protocols/agui/client.js +75 -0
- package/dist/protocols/agui/index.d.ts +4 -0
- package/dist/protocols/agui/index.js +25 -0
- package/dist/protocols/agui/server.d.ts +37 -0
- package/dist/protocols/agui/server.js +191 -0
- package/dist/protocols/agui/types.d.ts +107 -0
- package/dist/protocols/agui/types.js +17 -0
- package/dist/protocols/index.d.ts +2 -0
- package/dist/protocols/index.js +19 -0
- package/dist/protocols/mcp/agent-tools.d.ts +11 -0
- package/dist/protocols/mcp/agent-tools.js +129 -0
- package/dist/protocols/mcp/index.d.ts +5 -0
- package/dist/protocols/mcp/index.js +11 -0
- package/dist/protocols/mcp/server.d.ts +31 -0
- package/dist/protocols/mcp/server.js +248 -0
- package/dist/protocols/mcp/types.d.ts +92 -0
- package/dist/protocols/mcp/types.js +17 -0
- package/dist/publish/index.d.ts +45 -0
- package/dist/publish/index.js +350 -0
- package/dist/schema/oad.d.ts +682 -65
- package/dist/schema/oad.js +36 -3
- package/dist/security/approval.d.ts +36 -0
- package/dist/security/approval.js +113 -0
- package/dist/security/index.d.ts +4 -0
- package/dist/security/index.js +8 -0
- package/dist/security/keys.d.ts +16 -0
- package/dist/security/keys.js +117 -0
- package/dist/studio/server.d.ts +63 -0
- package/dist/studio/server.js +625 -0
- package/dist/studio-ui/index.html +662 -0
- package/dist/telemetry/index.d.ts +93 -0
- package/dist/telemetry/index.js +285 -0
- package/package.json +5 -3
- package/scripts/install.ps1 +31 -0
- package/scripts/install.sh +40 -0
- package/src/channels/email.ts +351 -177
- package/src/channels/feishu.ts +349 -236
- package/src/channels/websocket.ts +399 -87
- package/src/channels/wechat.ts +329 -149
- package/src/cli.ts +783 -12
- package/src/core/a2a.ts +60 -0
- package/src/core/agent.ts +125 -0
- package/src/core/runtime.ts +127 -0
- package/src/core/sandbox.ts +143 -0
- package/src/core/workflow-graph.ts +365 -0
- package/src/doctor.ts +156 -0
- package/src/eval/index.ts +211 -0
- package/src/eval/suites/basic.json +16 -0
- package/src/eval/suites/memory.json +12 -0
- package/src/eval/suites/safety.json +14 -0
- package/src/index.ts +54 -6
- package/src/plugins/content-filter.ts +23 -0
- package/src/plugins/index.ts +133 -2
- package/src/plugins/logger.ts +18 -0
- package/src/plugins/rate-limiter.ts +38 -0
- package/src/protocols/a2a/client.ts +132 -0
- package/src/protocols/a2a/index.ts +8 -0
- package/src/protocols/a2a/server.ts +333 -0
- package/src/protocols/a2a/types.ts +88 -0
- package/src/protocols/a2a/utils.ts +50 -0
- package/src/protocols/agui/client.ts +83 -0
- package/src/protocols/agui/index.ts +4 -0
- package/src/protocols/agui/server.ts +218 -0
- package/src/protocols/agui/types.ts +153 -0
- package/src/protocols/index.ts +2 -0
- package/src/protocols/mcp/agent-tools.ts +134 -0
- package/src/protocols/mcp/index.ts +8 -0
- package/src/protocols/mcp/server.ts +262 -0
- package/src/protocols/mcp/types.ts +69 -0
- package/src/publish/index.ts +376 -0
- package/src/schema/oad.ts +39 -2
- package/src/security/approval.ts +131 -0
- package/src/security/index.ts +3 -0
- package/src/security/keys.ts +87 -0
- package/src/studio/server.ts +629 -0
- package/src/studio-ui/index.html +662 -0
- package/src/telemetry/index.ts +324 -0
- package/src/types/agent-workstation.d.ts +2 -0
- package/tests/a2a-protocol.test.ts +285 -0
- package/tests/agui-protocol.test.ts +246 -0
- package/tests/channels/discord.test.ts +79 -0
- package/tests/channels/email.test.ts +148 -0
- package/tests/channels/feishu.test.ts +123 -0
- package/tests/channels/telegram.test.ts +129 -0
- package/tests/channels/websocket.test.ts +53 -0
- package/tests/channels/wechat.test.ts +170 -0
- package/tests/chat-cli.test.ts +160 -0
- package/tests/daemon.test.ts +135 -0
- package/tests/deepbrain-wire.test.ts +234 -0
- package/tests/doctor.test.ts +38 -0
- package/tests/eval.test.ts +173 -0
- package/tests/init-role.test.ts +124 -0
- package/tests/mcp-client.test.ts +92 -0
- package/tests/mcp-server.test.ts +178 -0
- package/tests/plugin-a2a-enhanced.test.ts +230 -0
- package/tests/publish.test.ts +231 -0
- package/tests/scheduler.test.ts +200 -0
- package/tests/security-enhanced.test.ts +233 -0
- package/tests/skill-learner.test.ts +161 -0
- package/tests/studio.test.ts +229 -0
- package/tests/subagent.test.ts +63 -0
- package/tests/telemetry.test.ts +186 -0
- package/tests/tools/builtin-extended.test.ts +138 -0
- package/tests/workflow-graph.test.ts +279 -0
- package/tutorial/customer-service-agent/README.md +612 -0
- package/tutorial/customer-service-agent/SOUL.md +26 -0
- package/tutorial/customer-service-agent/agent.yaml +63 -0
- package/tutorial/customer-service-agent/package.json +19 -0
- package/tutorial/customer-service-agent/src/index.ts +69 -0
- package/tutorial/customer-service-agent/src/skills/faq.ts +27 -0
- package/tutorial/customer-service-agent/src/skills/ticket.ts +22 -0
- package/tutorial/customer-service-agent/tsconfig.json +14 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MCPServer } from '../src/protocols/mcp/server';
|
|
3
|
+
import { agentToMCPTools, agentToMCPResources } from '../src/protocols/mcp/agent-tools';
|
|
4
|
+
import type { MCPServerToolDefinition, JsonRpcRequest } from '../src/protocols/mcp/types';
|
|
5
|
+
|
|
6
|
+
function makeRequest(method: string, params?: any, id: number = 1): JsonRpcRequest {
|
|
7
|
+
return { jsonrpc: '2.0', id, method, params };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('MCPServer', () => {
|
|
11
|
+
let server: MCPServer;
|
|
12
|
+
const echoTool: MCPServerToolDefinition = {
|
|
13
|
+
name: 'echo',
|
|
14
|
+
description: 'Echo back input',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: { text: { type: 'string' } },
|
|
18
|
+
required: ['text'],
|
|
19
|
+
},
|
|
20
|
+
handler: async (args) => `Echo: ${args.text}`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
server = new MCPServer({ name: 'test-server', version: '1.0.0', tools: [echoTool] });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('initialize response has capabilities', async () => {
|
|
28
|
+
const res = await server.handleMessage(makeRequest('initialize', {
|
|
29
|
+
protocolVersion: '2024-11-05',
|
|
30
|
+
capabilities: {},
|
|
31
|
+
clientInfo: { name: 'test', version: '1.0' },
|
|
32
|
+
}));
|
|
33
|
+
expect(res).toBeDefined();
|
|
34
|
+
expect(res!.result.protocolVersion).toBe('2024-11-05');
|
|
35
|
+
expect(res!.result.capabilities.tools).toBeDefined();
|
|
36
|
+
expect(res!.result.serverInfo.name).toBe('test-server');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('tools/list returns registered tools', async () => {
|
|
40
|
+
const res = await server.handleMessage(makeRequest('tools/list'));
|
|
41
|
+
expect(res!.result.tools).toHaveLength(1);
|
|
42
|
+
expect(res!.result.tools[0].name).toBe('echo');
|
|
43
|
+
expect(res!.result.tools[0].description).toBe('Echo back input');
|
|
44
|
+
expect(res!.result.tools[0].inputSchema).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('tools/call executes handler and returns content', async () => {
|
|
48
|
+
const res = await server.handleMessage(makeRequest('tools/call', {
|
|
49
|
+
name: 'echo', arguments: { text: 'hello' },
|
|
50
|
+
}));
|
|
51
|
+
expect(res!.result.content).toHaveLength(1);
|
|
52
|
+
expect(res!.result.content[0].text).toBe('Echo: hello');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('tools/call returns error for missing tool', async () => {
|
|
56
|
+
const res = await server.handleMessage(makeRequest('tools/call', {
|
|
57
|
+
name: 'nonexistent', arguments: {},
|
|
58
|
+
}));
|
|
59
|
+
expect(res!.error).toBeDefined();
|
|
60
|
+
expect(res!.error!.code).toBe(-32001);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('tools/call validates required params', async () => {
|
|
64
|
+
const res = await server.handleMessage(makeRequest('tools/call', {
|
|
65
|
+
name: 'echo', arguments: {},
|
|
66
|
+
}));
|
|
67
|
+
expect(res!.error).toBeDefined();
|
|
68
|
+
expect(res!.error!.code).toBe(-32602);
|
|
69
|
+
expect(res!.error!.message).toContain('text');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('resources/list returns registered resources', async () => {
|
|
73
|
+
server.addResource({
|
|
74
|
+
uri: 'test://doc', name: 'TestDoc', description: 'A doc', mimeType: 'text/plain',
|
|
75
|
+
handler: async () => 'Hello content',
|
|
76
|
+
});
|
|
77
|
+
const res = await server.handleMessage(makeRequest('resources/list'));
|
|
78
|
+
expect(res!.result.resources).toHaveLength(1);
|
|
79
|
+
expect(res!.result.resources[0].uri).toBe('test://doc');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('resources/read returns resource content', async () => {
|
|
83
|
+
server.addResource({
|
|
84
|
+
uri: 'test://doc', name: 'TestDoc', mimeType: 'text/plain',
|
|
85
|
+
handler: async () => 'Hello content',
|
|
86
|
+
});
|
|
87
|
+
const res = await server.handleMessage(makeRequest('resources/read', { uri: 'test://doc' }));
|
|
88
|
+
expect(res!.result.contents[0].text).toBe('Hello content');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('resources/read returns error for unknown resource', async () => {
|
|
92
|
+
const res = await server.handleMessage(makeRequest('resources/read', { uri: 'nope://x' }));
|
|
93
|
+
expect(res!.error).toBeDefined();
|
|
94
|
+
expect(res!.error!.code).toBe(-32002);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('unknown method returns METHOD_NOT_FOUND', async () => {
|
|
98
|
+
const res = await server.handleMessage(makeRequest('foo/bar'));
|
|
99
|
+
expect(res!.error).toBeDefined();
|
|
100
|
+
expect(res!.error!.code).toBe(-32601);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('JSON-RPC format: response has jsonrpc 2.0 and matching id', async () => {
|
|
104
|
+
const res = await server.handleMessage(makeRequest('tools/list', {}, 42));
|
|
105
|
+
expect(res!.jsonrpc).toBe('2.0');
|
|
106
|
+
expect(res!.id).toBe(42);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('notification (no id) returns null', async () => {
|
|
110
|
+
const res = await server.handleMessage({
|
|
111
|
+
jsonrpc: '2.0', method: 'notifications/initialized', params: {},
|
|
112
|
+
} as any);
|
|
113
|
+
expect(res).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('prompts/list returns prompts', async () => {
|
|
117
|
+
server.addPrompt({
|
|
118
|
+
name: 'summarize', description: 'Summarize text',
|
|
119
|
+
arguments: [{ name: 'text', required: true }],
|
|
120
|
+
});
|
|
121
|
+
const res = await server.handleMessage(makeRequest('prompts/list'));
|
|
122
|
+
expect(res!.result.prompts).toHaveLength(1);
|
|
123
|
+
expect(res!.result.prompts[0].name).toBe('summarize');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('addTool/removeTool works dynamically', () => {
|
|
127
|
+
expect(server.getToolCount()).toBe(1);
|
|
128
|
+
server.addTool({ name: 'test2', description: 't', inputSchema: {}, handler: async () => 'ok' });
|
|
129
|
+
expect(server.getToolCount()).toBe(2);
|
|
130
|
+
server.removeTool('test2');
|
|
131
|
+
expect(server.getToolCount()).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('agentToMCPTools', () => {
|
|
136
|
+
it('generates correct tools from agent', () => {
|
|
137
|
+
const agent = { name: 'test-agent' };
|
|
138
|
+
const tools = agentToMCPTools(agent);
|
|
139
|
+
expect(tools.length).toBeGreaterThanOrEqual(3);
|
|
140
|
+
const names = tools.map(t => t.name);
|
|
141
|
+
expect(names).toContain('chat');
|
|
142
|
+
expect(names).toContain('memory_search');
|
|
143
|
+
expect(names).toContain('memory_store');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('chat tool has correct schema', () => {
|
|
147
|
+
const tools = agentToMCPTools({ name: 'bot' });
|
|
148
|
+
const chat = tools.find(t => t.name === 'chat')!;
|
|
149
|
+
expect(chat.inputSchema.required).toContain('message');
|
|
150
|
+
expect(chat.description).toContain('bot');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('exposes agent skills as tools', () => {
|
|
154
|
+
const agent = {
|
|
155
|
+
name: 'skilled',
|
|
156
|
+
skills: [{ name: 'translate', description: 'Translate text', execute: async () => 'done' }],
|
|
157
|
+
};
|
|
158
|
+
const tools = agentToMCPTools(agent);
|
|
159
|
+
const skillTool = tools.find(t => t.name === 'skill_translate');
|
|
160
|
+
expect(skillTool).toBeDefined();
|
|
161
|
+
expect(skillTool!.description).toContain('Translate');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('agentToMCPResources', () => {
|
|
166
|
+
it('returns empty array for non-existent dir', () => {
|
|
167
|
+
const resources = agentToMCPResources({}, '/tmp/nonexistent-agent-dir-xyz');
|
|
168
|
+
expect(resources).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('exposes agent files that exist', () => {
|
|
172
|
+
// Use current project dir which has files
|
|
173
|
+
const path = require('path');
|
|
174
|
+
const resources = agentToMCPResources({}, path.resolve(__dirname, '..'));
|
|
175
|
+
// At minimum no crash; might find some files
|
|
176
|
+
expect(Array.isArray(resources)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { PluginManager } from '../src/plugins';
|
|
3
|
+
import type { Plugin } from '../src/plugins';
|
|
4
|
+
import { loggerPlugin } from '../src/plugins/logger';
|
|
5
|
+
import { rateLimiterPlugin, createRateLimiterPlugin } from '../src/plugins/rate-limiter';
|
|
6
|
+
import { contentFilterPlugin, createContentFilterPlugin } from '../src/plugins/content-filter';
|
|
7
|
+
import { AgentCardRegistry } from '../src/core/a2a';
|
|
8
|
+
import type { AgentCard } from '../src/core/a2a';
|
|
9
|
+
|
|
10
|
+
// ── PluginManager Enhanced Tests ────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe('PluginManager (enhanced middleware)', () => {
|
|
13
|
+
let pm: PluginManager;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
pm = new PluginManager();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should register and list enhanced plugins', () => {
|
|
20
|
+
const p: Plugin = { name: 'test', version: '1.0.0' };
|
|
21
|
+
pm.registerEnhanced(p);
|
|
22
|
+
expect(pm.has('test')).toBe(true);
|
|
23
|
+
expect(pm.listEnhanced()).toHaveLength(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should unregister enhanced plugins', () => {
|
|
27
|
+
pm.registerEnhanced({ name: 'tmp', version: '1.0.0' });
|
|
28
|
+
pm.unregisterEnhanced('tmp');
|
|
29
|
+
expect(pm.getEnhanced('tmp')).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should get enhanced plugin by name', () => {
|
|
33
|
+
pm.registerEnhanced({ name: 'foo', version: '2.0.0' });
|
|
34
|
+
expect(pm.getEnhanced('foo')?.version).toBe('2.0.0');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should run message middleware chain in order', async () => {
|
|
38
|
+
const order: number[] = [];
|
|
39
|
+
pm.registerEnhanced({
|
|
40
|
+
name: 'p1', version: '1.0.0',
|
|
41
|
+
onMessage: async (msg, next) => { order.push(1); return next({ ...msg, p1: true }); },
|
|
42
|
+
});
|
|
43
|
+
pm.registerEnhanced({
|
|
44
|
+
name: 'p2', version: '1.0.0',
|
|
45
|
+
onMessage: async (msg, next) => { order.push(2); return next({ ...msg, p2: true }); },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = await pm.runMessageMiddleware({ content: 'hi' });
|
|
49
|
+
expect(order).toEqual([1, 2]);
|
|
50
|
+
expect(result.p1).toBe(true);
|
|
51
|
+
expect(result.p2).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should run response middleware chain', async () => {
|
|
55
|
+
pm.registerEnhanced({
|
|
56
|
+
name: 'r1', version: '1.0.0',
|
|
57
|
+
onResponse: async (res, next) => next({ ...res, tagged: true }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await pm.runResponseMiddleware({ content: 'reply' });
|
|
61
|
+
expect(result.tagged).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should pass through when no middleware registered', async () => {
|
|
65
|
+
const msg = { content: 'hello' };
|
|
66
|
+
const result = await pm.runMessageMiddleware(msg);
|
|
67
|
+
expect(result).toEqual(msg);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('middleware can short-circuit by not calling next', async () => {
|
|
71
|
+
pm.registerEnhanced({
|
|
72
|
+
name: 'blocker', version: '1.0.0',
|
|
73
|
+
onMessage: async (_msg, _next) => ({ content: 'blocked' }),
|
|
74
|
+
});
|
|
75
|
+
pm.registerEnhanced({
|
|
76
|
+
name: 'never', version: '1.0.0',
|
|
77
|
+
onMessage: async (msg, next) => { throw new Error('should not reach'); },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = await pm.runMessageMiddleware({ content: 'test' });
|
|
81
|
+
expect(result.content).toBe('blocked');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('initAll calls onInit for enhanced plugins', async () => {
|
|
85
|
+
const inited: string[] = [];
|
|
86
|
+
pm.registerEnhanced({
|
|
87
|
+
name: 'init-test', version: '1.0.0',
|
|
88
|
+
onInit: async () => { inited.push('init-test'); },
|
|
89
|
+
});
|
|
90
|
+
await pm.initAll({});
|
|
91
|
+
expect(inited).toContain('init-test');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('shutdownAll calls onShutdown for enhanced plugins', async () => {
|
|
95
|
+
const shutdown: string[] = [];
|
|
96
|
+
pm.registerEnhanced({
|
|
97
|
+
name: 'sd-test', version: '1.0.0',
|
|
98
|
+
onShutdown: async () => { shutdown.push('sd-test'); },
|
|
99
|
+
});
|
|
100
|
+
await pm.shutdownAll();
|
|
101
|
+
expect(shutdown).toContain('sd-test');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('list() includes both legacy and enhanced plugins', () => {
|
|
105
|
+
pm.register({ name: 'legacy', version: '1.0.0' });
|
|
106
|
+
pm.registerEnhanced({ name: 'enhanced', version: '1.0.0' });
|
|
107
|
+
expect(pm.list()).toHaveLength(2);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Built-in Plugin Tests ───────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe('loggerPlugin', () => {
|
|
114
|
+
it('should log and pass through messages', async () => {
|
|
115
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
116
|
+
const msg = { content: 'hello world' };
|
|
117
|
+
const result = await loggerPlugin.onMessage!(msg, async (m) => m);
|
|
118
|
+
expect(spy).toHaveBeenCalled();
|
|
119
|
+
expect(result.content).toBe('hello world');
|
|
120
|
+
spy.mockRestore();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should log and pass through responses', async () => {
|
|
124
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
125
|
+
const res = { content: 'response text' };
|
|
126
|
+
const result = await loggerPlugin.onResponse!(res, async (r) => r);
|
|
127
|
+
expect(spy).toHaveBeenCalled();
|
|
128
|
+
expect(result.content).toBe('response text');
|
|
129
|
+
spy.mockRestore();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('rateLimiterPlugin', () => {
|
|
134
|
+
it('should allow messages under limit', async () => {
|
|
135
|
+
const plugin = createRateLimiterPlugin(5);
|
|
136
|
+
const msg = { content: 'test', id: 'session-1' };
|
|
137
|
+
for (let i = 0; i < 5; i++) {
|
|
138
|
+
await plugin.onMessage!(msg, async (m) => m);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should reject messages over limit', async () => {
|
|
143
|
+
const plugin = createRateLimiterPlugin(2);
|
|
144
|
+
const msg = { content: 'test', id: 'session-rl' };
|
|
145
|
+
await plugin.onMessage!(msg, async (m) => m);
|
|
146
|
+
await plugin.onMessage!(msg, async (m) => m);
|
|
147
|
+
await expect(plugin.onMessage!(msg, async (m) => m)).rejects.toThrow('Rate limit exceeded');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('contentFilterPlugin', () => {
|
|
152
|
+
it('should pass clean messages', async () => {
|
|
153
|
+
const plugin = createContentFilterPlugin(['spam', 'abuse']);
|
|
154
|
+
const result = await plugin.onMessage!({ content: 'hello' }, async (m) => m);
|
|
155
|
+
expect(result.content).toBe('hello');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should block messages with blocked words', async () => {
|
|
159
|
+
const plugin = createContentFilterPlugin(['spam']);
|
|
160
|
+
await expect(
|
|
161
|
+
plugin.onMessage!({ content: 'this is spam' }, async (m) => m)
|
|
162
|
+
).rejects.toThrow('Content blocked');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── AgentCardRegistry Tests ─────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe('AgentCardRegistry', () => {
|
|
169
|
+
let registry: AgentCardRegistry;
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
registry = new AgentCardRegistry();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should register and list agent cards', () => {
|
|
176
|
+
registry.register({ name: 'bot', description: 'A bot', capabilities: ['chat'] });
|
|
177
|
+
expect(registry.list()).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should unregister agent cards', () => {
|
|
181
|
+
registry.register({ name: 'tmp', description: 'Temp', capabilities: [] });
|
|
182
|
+
registry.unregister('tmp');
|
|
183
|
+
expect(registry.list()).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should get agent by name', () => {
|
|
187
|
+
registry.register({ name: 'a1', description: 'Agent 1', capabilities: [] });
|
|
188
|
+
expect(registry.get('a1')?.name).toBe('a1');
|
|
189
|
+
expect(registry.get('missing')).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should find by name', () => {
|
|
193
|
+
registry.register({ name: 'search-bot', description: 'Searches', capabilities: ['search'] });
|
|
194
|
+
registry.register({ name: 'chat-bot', description: 'Chats', capabilities: ['chat'] });
|
|
195
|
+
expect(registry.find('search')).toHaveLength(1);
|
|
196
|
+
expect(registry.find('search')[0].name).toBe('search-bot');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should find by capability', () => {
|
|
200
|
+
registry.register({ name: 'translator', description: 'Translates', capabilities: ['translate', 'detect-lang'] });
|
|
201
|
+
const found = registry.find('translate');
|
|
202
|
+
expect(found).toHaveLength(1);
|
|
203
|
+
expect(found[0].name).toBe('translator');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should find by description', () => {
|
|
207
|
+
registry.register({ name: 'x', description: 'Summarizes documents', capabilities: [] });
|
|
208
|
+
expect(registry.find('summarize')).toHaveLength(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should send to local handler', async () => {
|
|
212
|
+
registry.register({
|
|
213
|
+
name: 'echo',
|
|
214
|
+
description: 'Echo agent',
|
|
215
|
+
capabilities: ['echo'],
|
|
216
|
+
handler: async (msg) => `echo: ${msg}`,
|
|
217
|
+
});
|
|
218
|
+
const result = await registry.send('echo', 'hello');
|
|
219
|
+
expect(result).toBe('echo: hello');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should throw when sending to unknown agent', async () => {
|
|
223
|
+
await expect(registry.send('ghost', 'hi')).rejects.toThrow("Agent 'ghost' not found");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should throw when agent has no handler or endpoint', async () => {
|
|
227
|
+
registry.register({ name: 'empty', description: 'No handler', capabilities: [] });
|
|
228
|
+
await expect(registry.send('empty', 'hi')).rejects.toThrow('has no handler or endpoint');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as crypto from 'crypto';
|
|
6
|
+
import { AgentPackager, AgentPublisher, AgentInstaller } from '../src/publish';
|
|
7
|
+
|
|
8
|
+
function makeTempDir(): string {
|
|
9
|
+
const dir = path.join(os.tmpdir(), `opc-test-${crypto.randomBytes(6).toString('hex')}`);
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeAgentYaml(dir: string, overrides: Record<string, any> = {}) {
|
|
15
|
+
const config = {
|
|
16
|
+
apiVersion: 'opc/v1',
|
|
17
|
+
kind: 'Agent',
|
|
18
|
+
metadata: { name: 'test-agent', version: '1.0.0', description: 'Test agent', author: 'Test', license: 'MIT', ...overrides.metadata },
|
|
19
|
+
spec: { model: 'gpt-4', provider: { default: 'openai' }, channels: [{ type: 'web' }], skills: [{ name: 'echo' }], tools: [], ...overrides.spec },
|
|
20
|
+
};
|
|
21
|
+
const yaml = require('js-yaml');
|
|
22
|
+
fs.writeFileSync(path.join(dir, 'agent.yaml'), yaml.dump(config));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writePackageJson(dir: string, overrides: Record<string, any> = {}) {
|
|
26
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'test-agent', version: '1.0.0', ...overrides }, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setupValidProject(dir: string) {
|
|
30
|
+
writeAgentYaml(dir);
|
|
31
|
+
writePackageJson(dir);
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'SOUL.md'), '# Soul');
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# Readme');
|
|
34
|
+
fs.writeFileSync(path.join(dir, 'src.ts'), 'console.log("hello")');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('AgentPackager', () => {
|
|
38
|
+
let tmpDir: string;
|
|
39
|
+
const packager = new AgentPackager();
|
|
40
|
+
|
|
41
|
+
beforeEach(() => { tmpDir = makeTempDir(); });
|
|
42
|
+
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
|
43
|
+
|
|
44
|
+
// 1. validate: missing agent.yaml → error
|
|
45
|
+
it('validate: missing agent.yaml produces error', async () => {
|
|
46
|
+
writePackageJson(tmpDir);
|
|
47
|
+
const result = await packager.validate(tmpDir);
|
|
48
|
+
expect(result.valid).toBe(false);
|
|
49
|
+
expect(result.errors).toContain('Missing agent.yaml');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 2. validate: missing package.json → error
|
|
53
|
+
it('validate: missing package.json produces error', async () => {
|
|
54
|
+
writeAgentYaml(tmpDir);
|
|
55
|
+
const result = await packager.validate(tmpDir);
|
|
56
|
+
expect(result.valid).toBe(false);
|
|
57
|
+
expect(result.errors).toContain('Missing package.json');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// 3. validate: valid project → no errors
|
|
61
|
+
it('validate: valid project has no errors', async () => {
|
|
62
|
+
setupValidProject(tmpDir);
|
|
63
|
+
const result = await packager.validate(tmpDir);
|
|
64
|
+
expect(result.valid).toBe(true);
|
|
65
|
+
expect(result.errors).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// 4. validate: missing SOUL.md → warning
|
|
69
|
+
it('validate: missing SOUL.md produces warning', async () => {
|
|
70
|
+
writeAgentYaml(tmpDir);
|
|
71
|
+
writePackageJson(tmpDir);
|
|
72
|
+
const result = await packager.validate(tmpDir);
|
|
73
|
+
expect(result.warnings).toContain('Missing SOUL.md (recommended)');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 5. validate: missing README.md → warning
|
|
77
|
+
it('validate: missing README.md produces warning', async () => {
|
|
78
|
+
writeAgentYaml(tmpDir);
|
|
79
|
+
writePackageJson(tmpDir);
|
|
80
|
+
const result = await packager.validate(tmpDir);
|
|
81
|
+
expect(result.warnings).toContain('Missing README.md');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 6. validate: uppercase name → error
|
|
85
|
+
it('validate: uppercase name in agent.yaml produces error', async () => {
|
|
86
|
+
writeAgentYaml(tmpDir, { metadata: { name: 'MyAgent', version: '1.0.0' } });
|
|
87
|
+
writePackageJson(tmpDir);
|
|
88
|
+
const result = await packager.validate(tmpDir);
|
|
89
|
+
expect(result.valid).toBe(false);
|
|
90
|
+
expect(result.errors.some(e => e.includes('lowercase'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 7. validate: invalid version → error
|
|
94
|
+
it('validate: invalid version format produces error', async () => {
|
|
95
|
+
writeAgentYaml(tmpDir, { metadata: { name: 'test', version: 'bad' } });
|
|
96
|
+
writePackageJson(tmpDir);
|
|
97
|
+
const result = await packager.validate(tmpDir);
|
|
98
|
+
expect(result.valid).toBe(false);
|
|
99
|
+
expect(result.errors.some(e => e.includes('version'))).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// 8. listFiles: excludes node_modules
|
|
103
|
+
it('listFiles: excludes node_modules', async () => {
|
|
104
|
+
setupValidProject(tmpDir);
|
|
105
|
+
fs.mkdirSync(path.join(tmpDir, 'node_modules', 'foo'), { recursive: true });
|
|
106
|
+
fs.writeFileSync(path.join(tmpDir, 'node_modules', 'foo', 'index.js'), '');
|
|
107
|
+
const files = await packager.listFiles(tmpDir);
|
|
108
|
+
expect(files.every(f => !f.includes('node_modules'))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// 9. listFiles: excludes .env
|
|
112
|
+
it('listFiles: excludes .env', async () => {
|
|
113
|
+
setupValidProject(tmpDir);
|
|
114
|
+
fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET=x');
|
|
115
|
+
const files = await packager.listFiles(tmpDir);
|
|
116
|
+
expect(files).not.toContain('.env');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 10. listFiles: excludes .git
|
|
120
|
+
it('listFiles: excludes .git directory', async () => {
|
|
121
|
+
setupValidProject(tmpDir);
|
|
122
|
+
fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
|
|
123
|
+
fs.writeFileSync(path.join(tmpDir, '.git', 'config'), '');
|
|
124
|
+
const files = await packager.listFiles(tmpDir);
|
|
125
|
+
expect(files.every(f => !f.includes('.git'))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 11. listFiles: respects .opcignore
|
|
129
|
+
it('listFiles: respects .opcignore', async () => {
|
|
130
|
+
setupValidProject(tmpDir);
|
|
131
|
+
fs.writeFileSync(path.join(tmpDir, 'secret.txt'), 'hidden');
|
|
132
|
+
fs.writeFileSync(path.join(tmpDir, '.opcignore'), 'secret.txt\n');
|
|
133
|
+
const files = await packager.listFiles(tmpDir);
|
|
134
|
+
expect(files).not.toContain('secret.txt');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 12. pack: creates .opc.tgz file
|
|
138
|
+
it('pack: creates .opc.tgz file', async () => {
|
|
139
|
+
setupValidProject(tmpDir);
|
|
140
|
+
const result = await packager.pack(tmpDir);
|
|
141
|
+
expect(fs.existsSync(result.path)).toBe(true);
|
|
142
|
+
expect(result.path).toMatch(/\.opc\.tgz$/);
|
|
143
|
+
// cleanup
|
|
144
|
+
fs.unlinkSync(result.path);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// 13. pack: manifest contains correct fields
|
|
148
|
+
it('pack: manifest contains correct fields', async () => {
|
|
149
|
+
setupValidProject(tmpDir);
|
|
150
|
+
const result = await packager.pack(tmpDir);
|
|
151
|
+
expect(result.manifest.name).toBe('test-agent');
|
|
152
|
+
expect(result.manifest.version).toBe('1.0.0');
|
|
153
|
+
expect(result.manifest.author).toBe('Test');
|
|
154
|
+
expect(result.manifest.license).toBe('MIT');
|
|
155
|
+
expect(result.manifest.agent.model).toBe('gpt-4');
|
|
156
|
+
expect(result.manifest.agent.provider).toBe('openai');
|
|
157
|
+
expect(result.manifest.agent.channels).toContain('web');
|
|
158
|
+
expect(result.manifest.files.length).toBeGreaterThan(0);
|
|
159
|
+
expect(result.manifest.checksum).toBeTruthy();
|
|
160
|
+
expect(result.manifest.createdAt).toBeTruthy();
|
|
161
|
+
fs.unlinkSync(result.path);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// 14. manifest checksum is valid sha256
|
|
165
|
+
it('pack: checksum is valid sha256', async () => {
|
|
166
|
+
setupValidProject(tmpDir);
|
|
167
|
+
const result = await packager.pack(tmpDir);
|
|
168
|
+
expect(result.manifest.checksum).toMatch(/^[a-f0-9]{64}$/);
|
|
169
|
+
// Verify checksum matches file
|
|
170
|
+
const fileHash = crypto.createHash('sha256').update(fs.readFileSync(result.path)).digest('hex');
|
|
171
|
+
expect(result.manifest.checksum).toBe(fileHash);
|
|
172
|
+
fs.unlinkSync(result.path);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// 15. pack: validation failure throws
|
|
176
|
+
it('pack: throws on invalid project', async () => {
|
|
177
|
+
// Empty dir — no agent.yaml
|
|
178
|
+
await expect(packager.pack(tmpDir)).rejects.toThrow('Validation failed');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('AgentPublisher', () => {
|
|
183
|
+
// 16. dry-run doesn't throw
|
|
184
|
+
it('dry-run returns success without error', async () => {
|
|
185
|
+
const publisher = new AgentPublisher();
|
|
186
|
+
const manifest = {
|
|
187
|
+
name: 'test', version: '1.0.0', description: '', author: '', license: 'MIT',
|
|
188
|
+
agent: { model: '', provider: '', channels: [], skills: [], tools: [] },
|
|
189
|
+
files: ['a.ts'], checksum: 'abc', createdAt: new Date().toISOString(),
|
|
190
|
+
};
|
|
191
|
+
const result = await publisher.publish('/fake/path.opc.tgz', manifest, { dryRun: true });
|
|
192
|
+
expect(result.success).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('AgentInstaller', () => {
|
|
197
|
+
let tmpDir: string;
|
|
198
|
+
let installDir: string;
|
|
199
|
+
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
tmpDir = makeTempDir();
|
|
202
|
+
installDir = makeTempDir();
|
|
203
|
+
});
|
|
204
|
+
afterEach(() => {
|
|
205
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
206
|
+
fs.rmSync(installDir, { recursive: true, force: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// 17. install from tarball extracts correctly
|
|
210
|
+
it('install from .opc.tgz extracts files', async () => {
|
|
211
|
+
// Create a valid project and pack it
|
|
212
|
+
setupValidProject(tmpDir);
|
|
213
|
+
const packager = new AgentPackager();
|
|
214
|
+
const { path: pkgPath } = await packager.pack(tmpDir);
|
|
215
|
+
|
|
216
|
+
const installer = new AgentInstaller();
|
|
217
|
+
await installer.install(pkgPath, installDir);
|
|
218
|
+
|
|
219
|
+
// Check that files were extracted
|
|
220
|
+
const extractedFiles = fs.readdirSync(installDir);
|
|
221
|
+
expect(extractedFiles.length).toBeGreaterThan(0);
|
|
222
|
+
|
|
223
|
+
fs.unlinkSync(pkgPath);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// 18. install from missing file throws
|
|
227
|
+
it('install from missing file throws', async () => {
|
|
228
|
+
const installer = new AgentInstaller();
|
|
229
|
+
await expect(installer.install('/nonexistent/pkg.opc.tgz', installDir)).rejects.toThrow();
|
|
230
|
+
});
|
|
231
|
+
});
|