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,229 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import { StudioServer } from '../src/studio/server';
|
|
3
|
+
import { createServer, IncomingMessage, ServerResponse } from 'http';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
6
|
+
import * as http from 'http';
|
|
7
|
+
|
|
8
|
+
const TEST_PORT = 14789;
|
|
9
|
+
const TEST_DIR = join(__dirname, '__studio_test_fixture__');
|
|
10
|
+
const STATIC_DIR = join(TEST_DIR, 'studio-ui');
|
|
11
|
+
|
|
12
|
+
function fetch(path: string, method = 'GET', body?: string): Promise<{ status: number; headers: any; body: string }> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const opts: http.RequestOptions = {
|
|
15
|
+
hostname: 'localhost',
|
|
16
|
+
port: TEST_PORT,
|
|
17
|
+
path,
|
|
18
|
+
method,
|
|
19
|
+
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
20
|
+
};
|
|
21
|
+
const req = http.request(opts, (res) => {
|
|
22
|
+
let data = '';
|
|
23
|
+
res.on('data', (c) => (data += c));
|
|
24
|
+
res.on('end', () => resolve({ status: res.statusCode!, headers: res.headers, body: data }));
|
|
25
|
+
});
|
|
26
|
+
req.on('error', reject);
|
|
27
|
+
if (body) req.write(body);
|
|
28
|
+
req.end();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('StudioServer', () => {
|
|
33
|
+
let server: StudioServer;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
// Create test fixture
|
|
37
|
+
mkdirSync(STATIC_DIR, { recursive: true });
|
|
38
|
+
writeFileSync(join(STATIC_DIR, 'index.html'), '<html><body>OPC Studio</body></html>');
|
|
39
|
+
writeFileSync(join(STATIC_DIR, 'app.js'), 'console.log("hello")');
|
|
40
|
+
writeFileSync(join(STATIC_DIR, 'style.css'), 'body { margin: 0; }');
|
|
41
|
+
writeFileSync(join(STATIC_DIR, 'logo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
42
|
+
writeFileSync(join(STATIC_DIR, 'icon.svg'), '<svg></svg>');
|
|
43
|
+
writeFileSync(join(TEST_DIR, 'package.json'), JSON.stringify({ name: 'test-agent', version: '1.2.3', description: 'Test agent' }));
|
|
44
|
+
|
|
45
|
+
server = new StudioServer({
|
|
46
|
+
port: TEST_PORT,
|
|
47
|
+
agentDir: TEST_DIR,
|
|
48
|
+
staticDir: STATIC_DIR,
|
|
49
|
+
});
|
|
50
|
+
await server.start();
|
|
51
|
+
// Give server time to bind
|
|
52
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await server.stop();
|
|
57
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Test 1: Constructor defaults
|
|
61
|
+
it('should have default config values', () => {
|
|
62
|
+
const s = new StudioServer();
|
|
63
|
+
const cfg = s.getConfig();
|
|
64
|
+
expect(cfg.port).toBe(4000);
|
|
65
|
+
expect(cfg.agentDir).toBe(process.cwd());
|
|
66
|
+
expect(cfg.staticDir).toContain('studio-ui');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Test 2: Constructor with custom port
|
|
70
|
+
it('should accept custom config', () => {
|
|
71
|
+
const s = new StudioServer({ port: 5555, agentDir: '/tmp/test' });
|
|
72
|
+
const cfg = s.getConfig();
|
|
73
|
+
expect(cfg.port).toBe(5555);
|
|
74
|
+
expect(cfg.agentDir).toBe('/tmp/test');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Test 3: /api/agent/info returns agent info
|
|
78
|
+
it('GET /api/agent/info returns agent info', async () => {
|
|
79
|
+
const res = await fetch('/api/agent/info');
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
const data = JSON.parse(res.body);
|
|
82
|
+
expect(data.name).toBe('test-agent');
|
|
83
|
+
expect(data.version).toBe('1.2.3');
|
|
84
|
+
expect(data.status).toBe('running');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Test 4: /api/tools/list returns tools
|
|
88
|
+
it('GET /api/tools/list returns tools array', async () => {
|
|
89
|
+
const res = await fetch('/api/tools/list');
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
const data = JSON.parse(res.body);
|
|
92
|
+
expect(data).toHaveProperty('tools');
|
|
93
|
+
expect(Array.isArray(data.tools)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Test 5: /api/doctor/check runs
|
|
97
|
+
it('GET /api/doctor/check returns result', async () => {
|
|
98
|
+
const res = await fetch('/api/doctor/check');
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
const data = JSON.parse(res.body);
|
|
101
|
+
expect(data).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Test 6: unknown API returns 404
|
|
105
|
+
it('GET /api/unknown returns 404', async () => {
|
|
106
|
+
const res = await fetch('/api/nonexistent/route');
|
|
107
|
+
expect(res.status).toBe(404);
|
|
108
|
+
const data = JSON.parse(res.body);
|
|
109
|
+
expect(data.error).toBe('Not found');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Test 7: Static file serving - HTML
|
|
113
|
+
it('serves index.html at root', async () => {
|
|
114
|
+
const res = await fetch('/');
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
expect(res.headers['content-type']).toBe('text/html');
|
|
117
|
+
expect(res.body).toContain('OPC Studio');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Test 8: Static file serving - JS with correct MIME
|
|
121
|
+
it('serves .js with correct MIME type', async () => {
|
|
122
|
+
const res = await fetch('/app.js');
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
expect(res.headers['content-type']).toBe('application/javascript');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Test 9: Static file serving - CSS with correct MIME
|
|
128
|
+
it('serves .css with correct MIME type', async () => {
|
|
129
|
+
const res = await fetch('/style.css');
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
expect(res.headers['content-type']).toBe('text/css');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Test 10: CORS headers present on API responses
|
|
135
|
+
it('API responses include CORS headers', async () => {
|
|
136
|
+
const res = await fetch('/api/agent/info');
|
|
137
|
+
expect(res.headers['access-control-allow-origin']).toBe('*');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Test 11: SPA fallback for unknown static paths
|
|
141
|
+
it('falls back to index.html for unknown paths', async () => {
|
|
142
|
+
const res = await fetch('/some/deep/route');
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
expect(res.headers['content-type']).toBe('text/html');
|
|
145
|
+
expect(res.body).toContain('OPC Studio');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Test 12: /api/analytics/overview
|
|
149
|
+
it('GET /api/analytics/overview returns analytics', async () => {
|
|
150
|
+
const res = await fetch('/api/analytics/overview');
|
|
151
|
+
expect(res.status).toBe(200);
|
|
152
|
+
const data = JSON.parse(res.body);
|
|
153
|
+
expect(data).toHaveProperty('totalMessages');
|
|
154
|
+
expect(data).toHaveProperty('totalSessions');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Test 13: /api/security/approvals
|
|
158
|
+
it('GET /api/security/approvals returns empty approvals', async () => {
|
|
159
|
+
const res = await fetch('/api/security/approvals');
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
const data = JSON.parse(res.body);
|
|
162
|
+
expect(data.approvals).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Test 14: /api/logs/recent returns lines array
|
|
166
|
+
it('GET /api/logs/recent returns lines', async () => {
|
|
167
|
+
const res = await fetch('/api/logs/recent');
|
|
168
|
+
expect(res.status).toBe(200);
|
|
169
|
+
const data = JSON.parse(res.body);
|
|
170
|
+
expect(Array.isArray(data.lines)).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Test 15: SVG MIME type
|
|
174
|
+
it('serves .svg with correct MIME type', async () => {
|
|
175
|
+
const res = await fetch('/icon.svg');
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
expect(res.headers['content-type']).toBe('image/svg+xml');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Test 16: /api/modules returns module status
|
|
181
|
+
it('GET /api/modules returns module status', async () => {
|
|
182
|
+
const res = await fetch('/api/modules');
|
|
183
|
+
expect(res.status).toBe(200);
|
|
184
|
+
const data = JSON.parse(res.body);
|
|
185
|
+
expect(data).toHaveProperty('modules');
|
|
186
|
+
expect(data.modules).toHaveLength(3);
|
|
187
|
+
expect(data.modules[0]).toHaveProperty('name', 'DeepBrain');
|
|
188
|
+
expect(data.modules[0]).toHaveProperty('running');
|
|
189
|
+
expect(data.modules[0]).toHaveProperty('port', 4001);
|
|
190
|
+
expect(data.modules[1]).toHaveProperty('name', 'AgentKits');
|
|
191
|
+
expect(data.modules[2]).toHaveProperty('name', 'Workstation');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Test 17: Proxy returns 502 when module not running
|
|
195
|
+
it('proxy returns 502 with friendly message when module not running', async () => {
|
|
196
|
+
const res = await fetch('/brain/');
|
|
197
|
+
expect(res.status).toBe(502);
|
|
198
|
+
expect(res.body).toContain('Module not running');
|
|
199
|
+
expect(res.body).toContain('DeepBrain');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Test 18: Proxy routes are configured for all modules
|
|
203
|
+
it('proxy routes configured for all modules', async () => {
|
|
204
|
+
const brainRes = await fetch('/brain/');
|
|
205
|
+
const kitsRes = await fetch('/kits/');
|
|
206
|
+
const wsRes = await fetch('/workstation/');
|
|
207
|
+
// All should get 502 (not 200/SPA fallback) since no modules running
|
|
208
|
+
expect(brainRes.status).toBe(502);
|
|
209
|
+
expect(kitsRes.status).toBe(502);
|
|
210
|
+
expect(wsRes.status).toBe(502);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Test 19: Module nav items in real index.html
|
|
214
|
+
it('real index.html contains module nav items', () => {
|
|
215
|
+
const realHtml = readFileSync(join(__dirname, '../src/studio-ui/index.html'), 'utf-8');
|
|
216
|
+
expect(realHtml).toContain('data-page="brain-module"');
|
|
217
|
+
expect(realHtml).toContain('data-page="kits-module"');
|
|
218
|
+
expect(realHtml).toContain('data-page="workstation-module"');
|
|
219
|
+
expect(realHtml).toContain('data-page="modules"');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Test 20: Iframe src correct in real index.html
|
|
223
|
+
it('real index.html contains correct iframe srcs', () => {
|
|
224
|
+
const realHtml = readFileSync(join(__dirname, '../src/studio-ui/index.html'), 'utf-8');
|
|
225
|
+
expect(realHtml).toContain('src="/brain/"');
|
|
226
|
+
expect(realHtml).toContain('src="/kits/"');
|
|
227
|
+
expect(realHtml).toContain('src="/workstation/"');
|
|
228
|
+
});
|
|
229
|
+
});
|
package/tests/subagent.test.ts
CHANGED
|
@@ -115,6 +115,69 @@ describe('SubAgentManager', () => {
|
|
|
115
115
|
});
|
|
116
116
|
expect(result.status).toBe('completed');
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
it('should spawn with custom systemPrompt', async () => {
|
|
120
|
+
const result = await manager.spawn({
|
|
121
|
+
name: 'custom-prompt',
|
|
122
|
+
task: 'test',
|
|
123
|
+
systemPrompt: 'You are a custom assistant',
|
|
124
|
+
});
|
|
125
|
+
expect(result.status).toBe('completed');
|
|
126
|
+
expect(result.name).toBe('custom-prompt');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should spawn with custom model', async () => {
|
|
130
|
+
const result = await manager.spawn({
|
|
131
|
+
name: 'custom-model',
|
|
132
|
+
task: 'test',
|
|
133
|
+
model: 'gpt-4',
|
|
134
|
+
});
|
|
135
|
+
expect(result.status).toBe('completed');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('spawnParallel returns all results even with mix', async () => {
|
|
139
|
+
const results = await manager.spawnParallel([
|
|
140
|
+
{ name: 'a', task: 't1' },
|
|
141
|
+
{ name: 'b', task: 't2' },
|
|
142
|
+
]);
|
|
143
|
+
expect(results).toHaveLength(2);
|
|
144
|
+
expect(results.every(r => r.result === 'mock response')).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('kill returns false for unknown id', () => {
|
|
148
|
+
expect(manager.kill('unknown-id-xyz')).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('list shows correct status after completion', async () => {
|
|
152
|
+
await manager.spawn({ name: 'done', task: 'task' });
|
|
153
|
+
const list = manager.list();
|
|
154
|
+
expect(list.every(a => a.status === 'completed')).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('sub-agent name is preserved in result', async () => {
|
|
158
|
+
const result = await manager.spawn({ name: 'my-special-name', task: 'x' });
|
|
159
|
+
expect(result.name).toBe('my-special-name');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('multiple sequential spawns tracked correctly', async () => {
|
|
163
|
+
await manager.spawn({ name: 's1', task: 't' });
|
|
164
|
+
await manager.spawn({ name: 's2', task: 't' });
|
|
165
|
+
await manager.spawn({ name: 's3', task: 't' });
|
|
166
|
+
const list = manager.list();
|
|
167
|
+
expect(list).toHaveLength(3);
|
|
168
|
+
expect(new Set(list.map(a => a.id)).size).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('each spawn gets unique id', async () => {
|
|
172
|
+
const r1 = await manager.spawn({ name: 'x', task: 't' });
|
|
173
|
+
const r2 = await manager.spawn({ name: 'x', task: 't' });
|
|
174
|
+
expect(r1.id).not.toBe(r2.id);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('result duration is non-negative', async () => {
|
|
178
|
+
const result = await manager.spawn({ name: 'dur', task: 'test' });
|
|
179
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
180
|
+
});
|
|
118
181
|
});
|
|
119
182
|
|
|
120
183
|
describe('BaseAgent subagent methods', () => {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
Tracer,
|
|
4
|
+
ConsoleExporter,
|
|
5
|
+
FileExporter,
|
|
6
|
+
generateTraceId,
|
|
7
|
+
generateSpanId,
|
|
8
|
+
} from '../src/telemetry';
|
|
9
|
+
import { BaseAgent } from '../src/core/agent';
|
|
10
|
+
|
|
11
|
+
describe('Telemetry', () => {
|
|
12
|
+
let tracer: Tracer;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tracer = new Tracer({ maxSpans: 100 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('startSpan creates span with correct fields', () => {
|
|
19
|
+
const span = tracer.startSpan('test-op', {
|
|
20
|
+
attributes: { foo: 'bar' },
|
|
21
|
+
kind: 'server',
|
|
22
|
+
});
|
|
23
|
+
expect(span.name).toBe('test-op');
|
|
24
|
+
expect(span.kind).toBe('server');
|
|
25
|
+
expect(span.status).toBe('unset');
|
|
26
|
+
expect(span.traceId).toHaveLength(32);
|
|
27
|
+
expect(span.spanId).toHaveLength(16);
|
|
28
|
+
expect(span.attributes.foo).toBe('bar');
|
|
29
|
+
expect(span.events).toEqual([]);
|
|
30
|
+
expect(span.startTime).toBeGreaterThan(0);
|
|
31
|
+
expect(span.endTime).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('endSpan sets endTime and status', () => {
|
|
35
|
+
const span = tracer.startSpan('op');
|
|
36
|
+
tracer.endSpan(span, 'ok');
|
|
37
|
+
expect(span.endTime).toBeGreaterThanOrEqual(span.startTime);
|
|
38
|
+
expect(span.status).toBe('ok');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('endSpan defaults to ok status', () => {
|
|
42
|
+
const span = tracer.startSpan('op');
|
|
43
|
+
tracer.endSpan(span);
|
|
44
|
+
expect(span.status).toBe('ok');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('addEvent adds to span events', () => {
|
|
48
|
+
const span = tracer.startSpan('op');
|
|
49
|
+
tracer.addEvent(span, 'checkpoint', { key: 'val' });
|
|
50
|
+
expect(span.events).toHaveLength(1);
|
|
51
|
+
expect(span.events[0].name).toBe('checkpoint');
|
|
52
|
+
expect(span.events[0].attributes?.key).toBe('val');
|
|
53
|
+
expect(span.events[0].timestamp).toBeGreaterThan(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('getSpans with filters', () => {
|
|
57
|
+
const s1 = tracer.startSpan('alpha', { attributes: {} });
|
|
58
|
+
const s2 = tracer.startSpan('beta', { attributes: {} });
|
|
59
|
+
tracer.endSpan(s1);
|
|
60
|
+
tracer.endSpan(s2);
|
|
61
|
+
|
|
62
|
+
expect(tracer.getSpans({ name: 'alpha' })).toHaveLength(1);
|
|
63
|
+
expect(tracer.getSpans({ traceId: s1.traceId })).toHaveLength(1);
|
|
64
|
+
expect(tracer.getSpans({ limit: 1 })).toHaveLength(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('getTrace returns all spans for traceId', () => {
|
|
68
|
+
const parent = tracer.startSpan('root');
|
|
69
|
+
const child = tracer.startSpan('child', { parent });
|
|
70
|
+
tracer.endSpan(child);
|
|
71
|
+
tracer.endSpan(parent);
|
|
72
|
+
|
|
73
|
+
const trace = tracer.getTrace(parent.traceId);
|
|
74
|
+
expect(trace).toHaveLength(2);
|
|
75
|
+
expect(trace[0].spanId).toBe(parent.spanId);
|
|
76
|
+
expect(trace[1].parentSpanId).toBe(parent.spanId);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('getStats returns correct stats', () => {
|
|
80
|
+
const s1 = tracer.startSpan('op1');
|
|
81
|
+
tracer.endSpan(s1, 'ok');
|
|
82
|
+
const s2 = tracer.startSpan('op2');
|
|
83
|
+
tracer.endSpan(s2, 'error');
|
|
84
|
+
|
|
85
|
+
const stats = tracer.getStats();
|
|
86
|
+
expect(stats.totalSpans).toBe(2);
|
|
87
|
+
expect(stats.totalTraces).toBe(2);
|
|
88
|
+
expect(stats.errorRate).toBe(0.5);
|
|
89
|
+
expect(stats.spansByName).toEqual({ op1: 1, op2: 1 });
|
|
90
|
+
expect(stats.p50Latency).toBeGreaterThanOrEqual(0);
|
|
91
|
+
expect(stats.p95Latency).toBeGreaterThanOrEqual(0);
|
|
92
|
+
expect(stats.p99Latency).toBeGreaterThanOrEqual(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('increment/gauge/histogram add metrics', () => {
|
|
96
|
+
tracer.increment('requests', 1, { method: 'GET' });
|
|
97
|
+
tracer.gauge('connections', 5);
|
|
98
|
+
tracer.histogram('latency', 42.5, { route: '/api' });
|
|
99
|
+
|
|
100
|
+
const metrics = tracer.getMetrics();
|
|
101
|
+
expect(metrics).toHaveLength(3);
|
|
102
|
+
expect(metrics[0]).toMatchObject({ name: 'requests', type: 'counter', value: 1 });
|
|
103
|
+
expect(metrics[1]).toMatchObject({ name: 'connections', type: 'gauge', value: 5 });
|
|
104
|
+
expect(metrics[2]).toMatchObject({ name: 'latency', type: 'histogram', value: 42.5 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('maxSpans eviction', () => {
|
|
108
|
+
const t = new Tracer({ maxSpans: 5 });
|
|
109
|
+
for (let i = 0; i < 10; i++) {
|
|
110
|
+
t.startSpan(`span-${i}`);
|
|
111
|
+
}
|
|
112
|
+
const spans = t.getSpans();
|
|
113
|
+
expect(spans.length).toBe(5);
|
|
114
|
+
// oldest evicted, newest 5 kept (span-5 through span-9)
|
|
115
|
+
const names = spans.map(s => s.name).sort();
|
|
116
|
+
expect(names).toEqual(['span-5', 'span-6', 'span-7', 'span-8', 'span-9']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('ConsoleExporter exports to console', async () => {
|
|
120
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
121
|
+
const exporter = new ConsoleExporter();
|
|
122
|
+
const span = tracer.startSpan('test');
|
|
123
|
+
tracer.endSpan(span);
|
|
124
|
+
await exporter.export([span]);
|
|
125
|
+
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(consoleSpy.mock.calls[0][0]).toContain('[TRACE]');
|
|
127
|
+
consoleSpy.mockRestore();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('FileExporter exports to file', async () => {
|
|
131
|
+
const fs = require('fs');
|
|
132
|
+
const tmpFile = require('path').join(require('os').tmpdir(), `opc-test-${Date.now()}.ndjson`);
|
|
133
|
+
const exporter = new FileExporter(tmpFile);
|
|
134
|
+
const span = tracer.startSpan('file-test');
|
|
135
|
+
tracer.endSpan(span);
|
|
136
|
+
await exporter.export([span]);
|
|
137
|
+
const content = fs.readFileSync(tmpFile, 'utf-8');
|
|
138
|
+
expect(content).toContain('file-test');
|
|
139
|
+
fs.unlinkSync(tmpFile);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('generateTraceId returns 32 hex chars', () => {
|
|
143
|
+
const id = generateTraceId();
|
|
144
|
+
expect(id).toHaveLength(32);
|
|
145
|
+
expect(/^[0-9a-f]{32}$/.test(id)).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('generateSpanId returns 16 hex chars', () => {
|
|
149
|
+
const id = generateSpanId();
|
|
150
|
+
expect(id).toHaveLength(16);
|
|
151
|
+
expect(/^[0-9a-f]{16}$/.test(id)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('exportOTLP produces OTel-compatible format', () => {
|
|
155
|
+
const span = tracer.startSpan('otlp-test', { attributes: { key: 'val' } });
|
|
156
|
+
tracer.endSpan(span);
|
|
157
|
+
const otlp = tracer.exportOTLP() as any;
|
|
158
|
+
expect(otlp.resourceSpans).toHaveLength(1);
|
|
159
|
+
expect(otlp.resourceSpans[0].scopeSpans[0].spans).toHaveLength(1);
|
|
160
|
+
const s = otlp.resourceSpans[0].scopeSpans[0].spans[0];
|
|
161
|
+
expect(s.name).toBe('otlp-test');
|
|
162
|
+
expect(s.traceId).toBe(span.traceId);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('clear removes all spans and metrics', () => {
|
|
166
|
+
tracer.startSpan('x');
|
|
167
|
+
tracer.increment('m');
|
|
168
|
+
tracer.clear();
|
|
169
|
+
expect(tracer.getSpans()).toHaveLength(0);
|
|
170
|
+
expect(tracer.getMetrics()).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('agent integration: tracer on BaseAgent', () => {
|
|
174
|
+
const t = new Tracer();
|
|
175
|
+
const agent = new BaseAgent({ name: 'test', tracer: t });
|
|
176
|
+
expect(agent.getTracer()).toBe(t);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('agent integration: setTracer works', () => {
|
|
180
|
+
const agent = new BaseAgent({ name: 'test' });
|
|
181
|
+
expect(agent.getTracer()).toBeUndefined();
|
|
182
|
+
const t = new Tracer();
|
|
183
|
+
agent.setTracer(t);
|
|
184
|
+
expect(agent.getTracer()).toBe(t);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { getBuiltinTools, getBuiltinToolsByName, fileTool, datetimeTool, shellTool, webTool } from '../../src/tools/builtin';
|
|
6
|
+
|
|
7
|
+
describe('getBuiltinTools', () => {
|
|
8
|
+
it('returns 4 tools', () => {
|
|
9
|
+
const tools = getBuiltinTools();
|
|
10
|
+
expect(tools).toHaveLength(4);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns copies (not same array)', () => {
|
|
14
|
+
const a = getBuiltinTools();
|
|
15
|
+
const b = getBuiltinTools();
|
|
16
|
+
expect(a).not.toBe(b);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('contains file_operations tool', () => {
|
|
20
|
+
const tools = getBuiltinTools();
|
|
21
|
+
expect(tools.some(t => t.name === 'file_operations')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('contains datetime tool', () => {
|
|
25
|
+
const tools = getBuiltinTools();
|
|
26
|
+
expect(tools.some(t => t.name === 'datetime')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('getBuiltinToolsByName', () => {
|
|
31
|
+
it('returns all when no names given', () => {
|
|
32
|
+
expect(getBuiltinToolsByName()).toHaveLength(4);
|
|
33
|
+
expect(getBuiltinToolsByName([])).toHaveLength(4);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('filters by name correctly', () => {
|
|
37
|
+
const tools = getBuiltinToolsByName(['datetime']);
|
|
38
|
+
expect(tools).toHaveLength(1);
|
|
39
|
+
expect(tools[0].name).toBe('datetime');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns empty for unknown names', () => {
|
|
43
|
+
const tools = getBuiltinToolsByName(['nonexistent']);
|
|
44
|
+
expect(tools).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns multiple matching tools', () => {
|
|
48
|
+
const tools = getBuiltinToolsByName(['datetime', 'file_operations']);
|
|
49
|
+
expect(tools).toHaveLength(2);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('fileTool', () => {
|
|
54
|
+
let tmpDir: string;
|
|
55
|
+
let origCwd: string;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'builtin-test-'));
|
|
59
|
+
origCwd = process.cwd();
|
|
60
|
+
process.chdir(tmpDir);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
process.chdir(origCwd);
|
|
65
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('has name file_operations', () => {
|
|
69
|
+
expect(fileTool.name).toBe('file_operations');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('read existing file', async () => {
|
|
73
|
+
fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'hello world');
|
|
74
|
+
const result = await fileTool.execute({ action: 'read', path: 'test.txt' });
|
|
75
|
+
expect(result.content).toContain('hello world');
|
|
76
|
+
expect(result.isError).toBeFalsy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('read non-existent returns error', async () => {
|
|
80
|
+
const result = await fileTool.execute({ action: 'read', path: 'nonexistent.txt' });
|
|
81
|
+
expect(result.isError).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('write creates file', async () => {
|
|
85
|
+
const result = await fileTool.execute({ action: 'write', path: 'out.txt', content: 'data' });
|
|
86
|
+
expect(result.isError).toBeFalsy();
|
|
87
|
+
expect(fs.readFileSync(path.join(tmpDir, 'out.txt'), 'utf-8')).toBe('data');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('list directory', async () => {
|
|
91
|
+
fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'a');
|
|
92
|
+
fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'b');
|
|
93
|
+
const result = await fileTool.execute({ action: 'list', path: '.' });
|
|
94
|
+
expect(result.content).toContain('a.txt');
|
|
95
|
+
expect(result.content).toContain('b.txt');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('exists returns true for existing file', async () => {
|
|
99
|
+
fs.writeFileSync(path.join(tmpDir, 'exists.txt'), 'yes');
|
|
100
|
+
const result = await fileTool.execute({ action: 'exists', path: 'exists.txt' });
|
|
101
|
+
expect(result.content).toContain('true');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('exists returns false for missing file', async () => {
|
|
105
|
+
const result = await fileTool.execute({ action: 'exists', path: 'nope.txt' });
|
|
106
|
+
expect(result.content).toContain('false');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('path escape blocked (../)', async () => {
|
|
110
|
+
const result = await fileTool.execute({ action: 'read', path: '../../../etc/passwd' });
|
|
111
|
+
expect(result.isError).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('datetimeTool', () => {
|
|
116
|
+
it('has name datetime', () => {
|
|
117
|
+
expect(datetimeTool.name).toBe('datetime');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns valid ISO string', async () => {
|
|
121
|
+
const result = await datetimeTool.execute({});
|
|
122
|
+
expect(result.isError).toBeFalsy();
|
|
123
|
+
// Should contain a date-like string
|
|
124
|
+
expect(result.content).toMatch(/\d{4}/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('shellTool', () => {
|
|
129
|
+
it('has name shell_exec', () => {
|
|
130
|
+
expect(shellTool.name).toBe('shell_exec');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('webTool', () => {
|
|
135
|
+
it('has name web_fetch', () => {
|
|
136
|
+
expect(webTool.name).toBe('web_fetch');
|
|
137
|
+
});
|
|
138
|
+
});
|