opc-agent 1.4.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/CHANGELOG.md +25 -0
- package/README.md +91 -32
- 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/telegram.d.ts +30 -9
- package/dist/channels/telegram.js +125 -33
- 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 +1127 -19
- package/dist/core/a2a.d.ts +17 -0
- package/dist/core/a2a.js +43 -1
- package/dist/core/agent.d.ts +39 -0
- package/dist/core/agent.js +228 -3
- package/dist/core/runtime.d.ts +7 -0
- package/dist/core/runtime.js +205 -2
- package/dist/core/sandbox.d.ts +26 -0
- package/dist/core/sandbox.js +117 -0
- package/dist/core/scheduler.d.ts +52 -0
- package/dist/core/scheduler.js +168 -0
- package/dist/core/subagent.d.ts +28 -0
- package/dist/core/subagent.js +65 -0
- package/dist/core/workflow-graph.d.ts +93 -0
- package/dist/core/workflow-graph.js +247 -0
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +134 -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 +37 -6
- package/dist/index.js +75 -3
- 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/providers/index.d.ts +5 -1
- package/dist/providers/index.js +16 -9
- package/dist/publish/index.d.ts +45 -0
- package/dist/publish/index.js +350 -0
- package/dist/schema/oad.d.ts +859 -67
- package/dist/schema/oad.js +47 -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/skills/auto-learn.d.ts +28 -0
- package/dist/skills/auto-learn.js +257 -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/dist/tools/builtin/datetime.d.ts +3 -0
- package/dist/tools/builtin/datetime.js +44 -0
- package/dist/tools/builtin/file.d.ts +3 -0
- package/dist/tools/builtin/file.js +151 -0
- package/dist/tools/builtin/index.d.ts +15 -0
- package/dist/tools/builtin/index.js +30 -0
- package/dist/tools/builtin/shell.d.ts +3 -0
- package/dist/tools/builtin/shell.js +43 -0
- package/dist/tools/builtin/web.d.ts +3 -0
- package/dist/tools/builtin/web.js +37 -0
- package/dist/tools/mcp-client.d.ts +24 -0
- package/dist/tools/mcp-client.js +119 -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/telegram.ts +212 -90
- package/src/channels/websocket.ts +399 -87
- package/src/channels/wechat.ts +329 -149
- package/src/cli.ts +1201 -20
- package/src/core/a2a.ts +60 -0
- package/src/core/agent.ts +420 -152
- package/src/core/runtime.ts +174 -0
- package/src/core/sandbox.ts +143 -0
- package/src/core/scheduler.ts +187 -0
- package/src/core/subagent.ts +98 -0
- package/src/core/workflow-graph.ts +365 -0
- package/src/daemon.ts +96 -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 +65 -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/providers/index.ts +354 -339
- package/src/publish/index.ts +376 -0
- package/src/schema/oad.ts +204 -154
- package/src/security/approval.ts +131 -0
- package/src/security/index.ts +3 -0
- package/src/security/keys.ts +87 -0
- package/src/skills/auto-learn.ts +262 -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/tools/builtin/datetime.ts +41 -0
- package/src/tools/builtin/file.ts +107 -0
- package/src/tools/builtin/index.ts +28 -0
- package/src/tools/builtin/shell.ts +43 -0
- package/src/tools/builtin/web.ts +35 -0
- package/src/tools/mcp-client.ts +131 -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/auto-learn.test.ts +105 -0
- package/tests/builtin-tools.test.ts +83 -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/cli.test.ts +46 -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 +193 -0
- package/tests/telegram-discord.test.ts +60 -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,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { WebSocketChannel } from '../../src/channels/websocket';
|
|
3
|
+
|
|
4
|
+
// We can't easily test WebSocket server without starting it,
|
|
5
|
+
// but we can test the room management and config logic.
|
|
6
|
+
|
|
7
|
+
describe('WebSocketChannel', () => {
|
|
8
|
+
describe('constructor', () => {
|
|
9
|
+
it('should accept port number', () => {
|
|
10
|
+
const channel = new WebSocketChannel(4000);
|
|
11
|
+
expect(channel.type).toBe('websocket');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should accept config object', () => {
|
|
15
|
+
const channel = new WebSocketChannel({
|
|
16
|
+
port: 4000,
|
|
17
|
+
heartbeatInterval: 15000,
|
|
18
|
+
authTokens: ['secret'],
|
|
19
|
+
maxClientsPerRoom: 50,
|
|
20
|
+
});
|
|
21
|
+
expect(channel.type).toBe('websocket');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should use defaults', () => {
|
|
25
|
+
const channel = new WebSocketChannel();
|
|
26
|
+
expect(channel.type).toBe('websocket');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('getStats', () => {
|
|
31
|
+
it('should return empty stats initially', () => {
|
|
32
|
+
const channel = new WebSocketChannel(4001);
|
|
33
|
+
const stats = channel.getStats();
|
|
34
|
+
expect(stats.clients).toBe(0);
|
|
35
|
+
expect(stats.rooms).toBe(0);
|
|
36
|
+
expect(stats.roomDetails).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('getRooms', () => {
|
|
41
|
+
it('should return empty array initially', () => {
|
|
42
|
+
const channel = new WebSocketChannel(4002);
|
|
43
|
+
expect(channel.getRooms()).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('getRoomMembers', () => {
|
|
48
|
+
it('should return empty for non-existent room', () => {
|
|
49
|
+
const channel = new WebSocketChannel(4003);
|
|
50
|
+
expect(channel.getRoomMembers('nonexistent')).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { WeChatChannel } from '../../src/channels/wechat';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
4
|
+
|
|
5
|
+
describe('WeChatChannel', () => {
|
|
6
|
+
// ── XML Parsing ──────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe('parseXML', () => {
|
|
9
|
+
it('should parse text message XML', () => {
|
|
10
|
+
const xml = `<xml>
|
|
11
|
+
<ToUserName><![CDATA[gh_123456]]></ToUserName>
|
|
12
|
+
<FromUserName><![CDATA[oUser123]]></FromUserName>
|
|
13
|
+
<CreateTime>1348831860</CreateTime>
|
|
14
|
+
<MsgType><![CDATA[text]]></MsgType>
|
|
15
|
+
<Content><![CDATA[Hello World]]></Content>
|
|
16
|
+
<MsgId>1234567890123456</MsgId>
|
|
17
|
+
</xml>`;
|
|
18
|
+
|
|
19
|
+
const msg = WeChatChannel.parseXML(xml);
|
|
20
|
+
expect(msg).not.toBeNull();
|
|
21
|
+
expect(msg!.toUserName).toBe('gh_123456');
|
|
22
|
+
expect(msg!.fromUserName).toBe('oUser123');
|
|
23
|
+
expect(msg!.createTime).toBe(1348831860);
|
|
24
|
+
expect(msg!.msgType).toBe('text');
|
|
25
|
+
expect(msg!.content).toBe('Hello World');
|
|
26
|
+
expect(msg!.msgId).toBe('1234567890123456');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should parse event message XML (subscribe)', () => {
|
|
30
|
+
const xml = `<xml>
|
|
31
|
+
<ToUserName><![CDATA[gh_123456]]></ToUserName>
|
|
32
|
+
<FromUserName><![CDATA[oUser123]]></FromUserName>
|
|
33
|
+
<CreateTime>1348831860</CreateTime>
|
|
34
|
+
<MsgType><![CDATA[event]]></MsgType>
|
|
35
|
+
<Event><![CDATA[subscribe]]></Event>
|
|
36
|
+
</xml>`;
|
|
37
|
+
|
|
38
|
+
const msg = WeChatChannel.parseXML(xml);
|
|
39
|
+
expect(msg).not.toBeNull();
|
|
40
|
+
expect(msg!.msgType).toBe('event');
|
|
41
|
+
expect(msg!.event).toBe('subscribe');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should parse image message XML', () => {
|
|
45
|
+
const xml = `<xml>
|
|
46
|
+
<ToUserName><![CDATA[gh_123456]]></ToUserName>
|
|
47
|
+
<FromUserName><![CDATA[oUser123]]></FromUserName>
|
|
48
|
+
<CreateTime>1348831860</CreateTime>
|
|
49
|
+
<MsgType><![CDATA[image]]></MsgType>
|
|
50
|
+
<MsgId>1234567890123456</MsgId>
|
|
51
|
+
</xml>`;
|
|
52
|
+
|
|
53
|
+
const msg = WeChatChannel.parseXML(xml);
|
|
54
|
+
expect(msg).not.toBeNull();
|
|
55
|
+
expect(msg!.msgType).toBe('image');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return null for invalid XML', () => {
|
|
59
|
+
expect(WeChatChannel.parseXML('')).toBeNull();
|
|
60
|
+
expect(WeChatChannel.parseXML('not xml')).toBeNull();
|
|
61
|
+
expect(WeChatChannel.parseXML('<xml></xml>')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should handle XML with plain text (no CDATA)', () => {
|
|
65
|
+
const xml = `<xml>
|
|
66
|
+
<ToUserName>gh_123456</ToUserName>
|
|
67
|
+
<FromUserName>oUser123</FromUserName>
|
|
68
|
+
<CreateTime>1348831860</CreateTime>
|
|
69
|
+
<MsgType>text</MsgType>
|
|
70
|
+
<Content>Hello</Content>
|
|
71
|
+
</xml>`;
|
|
72
|
+
|
|
73
|
+
const msg = WeChatChannel.parseXML(xml);
|
|
74
|
+
expect(msg).not.toBeNull();
|
|
75
|
+
expect(msg!.content).toBe('Hello');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── XML Response Formatting ──────────────────────
|
|
80
|
+
|
|
81
|
+
describe('formatXMLResponse', () => {
|
|
82
|
+
it('should format valid XML response', () => {
|
|
83
|
+
const xml = WeChatChannel.formatXMLResponse('oUser123', 'gh_123456', 'Hello!');
|
|
84
|
+
expect(xml).toContain('<ToUserName><![CDATA[oUser123]]></ToUserName>');
|
|
85
|
+
expect(xml).toContain('<FromUserName><![CDATA[gh_123456]]></FromUserName>');
|
|
86
|
+
expect(xml).toContain('<Content><![CDATA[Hello!]]></Content>');
|
|
87
|
+
expect(xml).toContain('<MsgType><![CDATA[text]]></MsgType>');
|
|
88
|
+
expect(xml).toContain('<CreateTime>');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle special characters in content', () => {
|
|
92
|
+
const xml = WeChatChannel.formatXMLResponse('u', 'g', 'Hello <world> & "friends"');
|
|
93
|
+
expect(xml).toContain('Hello <world> & "friends"');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Signature Verification ───────────────────────
|
|
98
|
+
|
|
99
|
+
describe('verifySignature', () => {
|
|
100
|
+
it('should verify valid signature', () => {
|
|
101
|
+
const token = 'mytoken123';
|
|
102
|
+
const channel = new WeChatChannel({ appId: 'id', appSecret: 'secret', token });
|
|
103
|
+
|
|
104
|
+
const timestamp = '1348831860';
|
|
105
|
+
const nonce = 'abc123';
|
|
106
|
+
const arr = [token, timestamp, nonce].sort();
|
|
107
|
+
const expectedSignature = crypto.createHash('sha1').update(arr.join('')).digest('hex');
|
|
108
|
+
|
|
109
|
+
expect(channel.verifySignature(expectedSignature, timestamp, nonce)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should reject invalid signature', () => {
|
|
113
|
+
const channel = new WeChatChannel({ appId: 'id', appSecret: 'secret', token: 'mytoken' });
|
|
114
|
+
expect(channel.verifySignature('invalidsig', '123', 'abc')).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle empty inputs', () => {
|
|
118
|
+
const channel = new WeChatChannel({ appId: 'id', appSecret: 'secret', token: 'tok' });
|
|
119
|
+
expect(channel.verifySignature('', '', '')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Message Handling ─────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('handleMessage', () => {
|
|
126
|
+
it('should handle subscribe event', async () => {
|
|
127
|
+
const channel = new WeChatChannel({ appId: 'id', appSecret: 'secret', token: 'tok' });
|
|
128
|
+
const result = await channel.handleMessage({
|
|
129
|
+
toUserName: 'gh_123',
|
|
130
|
+
fromUserName: 'oUser',
|
|
131
|
+
createTime: 1000,
|
|
132
|
+
msgType: 'event',
|
|
133
|
+
event: 'subscribe',
|
|
134
|
+
});
|
|
135
|
+
expect(result).toContain('Welcome');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should handle text message with handler', async () => {
|
|
139
|
+
const channel = new WeChatChannel({ appId: 'id', appSecret: 'secret', token: 'tok' });
|
|
140
|
+
channel.onMessage(async (msg) => ({
|
|
141
|
+
id: 'reply',
|
|
142
|
+
role: 'assistant',
|
|
143
|
+
content: `Echo: ${msg.content}`,
|
|
144
|
+
timestamp: Date.now(),
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const result = await channel.handleMessage({
|
|
148
|
+
toUserName: 'gh_123',
|
|
149
|
+
fromUserName: 'oUser',
|
|
150
|
+
createTime: 1000,
|
|
151
|
+
msgType: 'text',
|
|
152
|
+
content: 'Hello',
|
|
153
|
+
msgId: 'msg1',
|
|
154
|
+
});
|
|
155
|
+
expect(result).toBe('Echo: Hello');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should return empty for text message without handler', async () => {
|
|
159
|
+
const channel = new WeChatChannel({ appId: 'id', appSecret: 'secret', token: 'tok' });
|
|
160
|
+
const result = await channel.handleMessage({
|
|
161
|
+
toUserName: 'gh_123',
|
|
162
|
+
fromUserName: 'oUser',
|
|
163
|
+
createTime: 1000,
|
|
164
|
+
msgType: 'text',
|
|
165
|
+
content: 'Hello',
|
|
166
|
+
});
|
|
167
|
+
expect(result).toBe('');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for CLI chat slash command handling logic.
|
|
5
|
+
* We test the command parsing logic directly since the chat command
|
|
6
|
+
* is tightly coupled to readline/process.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Extract the slash command logic for testing
|
|
10
|
+
function createSlashHandler(options: {
|
|
11
|
+
agentName?: string;
|
|
12
|
+
agentVersion?: string;
|
|
13
|
+
providerName?: string;
|
|
14
|
+
model?: string;
|
|
15
|
+
skillNames?: string[];
|
|
16
|
+
history?: { role: string; content: string }[];
|
|
17
|
+
}) {
|
|
18
|
+
const {
|
|
19
|
+
agentName = 'test-agent',
|
|
20
|
+
agentVersion = '1.0.0',
|
|
21
|
+
providerName = 'openai',
|
|
22
|
+
model = 'gpt-4',
|
|
23
|
+
skillNames = [],
|
|
24
|
+
history = [],
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
const output: string[] = [];
|
|
28
|
+
const log = (msg: string) => output.push(msg);
|
|
29
|
+
|
|
30
|
+
const handleSlashCommand = (cmd: string): boolean => {
|
|
31
|
+
const lower = cmd.toLowerCase().trim();
|
|
32
|
+
if (lower === '/quit' || lower === '/exit') {
|
|
33
|
+
output.push('QUIT');
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (lower === '/help') {
|
|
37
|
+
log('Available commands:');
|
|
38
|
+
log('/help /quit /clear /skills /memory /info');
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (lower === '/clear') {
|
|
42
|
+
history.length = 0;
|
|
43
|
+
log('Conversation history cleared.');
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (lower === '/skills') {
|
|
47
|
+
if (skillNames.length === 0) {
|
|
48
|
+
log('No skills registered.');
|
|
49
|
+
} else {
|
|
50
|
+
log('Registered skills:');
|
|
51
|
+
skillNames.forEach(s => log(`• ${s}`));
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (lower === '/info') {
|
|
56
|
+
log(`Name: ${agentName}`);
|
|
57
|
+
log(`Version: ${agentVersion}`);
|
|
58
|
+
log(`Provider: ${providerName}`);
|
|
59
|
+
log(`Model: ${model}`);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return { handleSlashCommand, output, history };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('Chat CLI slash commands', () => {
|
|
69
|
+
it('/help returns command list', () => {
|
|
70
|
+
const { handleSlashCommand, output } = createSlashHandler({});
|
|
71
|
+
const handled = handleSlashCommand('/help');
|
|
72
|
+
expect(handled).toBe(true);
|
|
73
|
+
expect(output.some(l => l.includes('commands'))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('/clear resets history', () => {
|
|
77
|
+
const history = [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }];
|
|
78
|
+
const { handleSlashCommand } = createSlashHandler({ history });
|
|
79
|
+
handleSlashCommand('/clear');
|
|
80
|
+
expect(history).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('/skills lists skills', () => {
|
|
84
|
+
const { handleSlashCommand, output } = createSlashHandler({ skillNames: ['echo', 'search'] });
|
|
85
|
+
handleSlashCommand('/skills');
|
|
86
|
+
expect(output.some(l => l.includes('echo'))).toBe(true);
|
|
87
|
+
expect(output.some(l => l.includes('search'))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('/skills with no skills shows empty message', () => {
|
|
91
|
+
const { handleSlashCommand, output } = createSlashHandler({ skillNames: [] });
|
|
92
|
+
handleSlashCommand('/skills');
|
|
93
|
+
expect(output.some(l => l.includes('No skills'))).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('/info shows agent info', () => {
|
|
97
|
+
const { handleSlashCommand, output } = createSlashHandler({ agentName: 'MyBot', model: 'gpt-4o' });
|
|
98
|
+
handleSlashCommand('/info');
|
|
99
|
+
expect(output.some(l => l.includes('MyBot'))).toBe(true);
|
|
100
|
+
expect(output.some(l => l.includes('gpt-4o'))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('/quit sets quit flag', () => {
|
|
104
|
+
const { handleSlashCommand, output } = createSlashHandler({});
|
|
105
|
+
handleSlashCommand('/quit');
|
|
106
|
+
expect(output).toContain('QUIT');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('/exit also works as quit', () => {
|
|
110
|
+
const { handleSlashCommand, output } = createSlashHandler({});
|
|
111
|
+
handleSlashCommand('/exit');
|
|
112
|
+
expect(output).toContain('QUIT');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('unknown slash command returns false', () => {
|
|
116
|
+
const { handleSlashCommand } = createSlashHandler({});
|
|
117
|
+
expect(handleSlashCommand('/unknown')).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('non-slash text returns false', () => {
|
|
121
|
+
const { handleSlashCommand } = createSlashHandler({});
|
|
122
|
+
expect(handleSlashCommand('hello world')).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('case insensitive: /HELP works', () => {
|
|
126
|
+
const { handleSlashCommand, output } = createSlashHandler({});
|
|
127
|
+
expect(handleSlashCommand('/HELP')).toBe(true);
|
|
128
|
+
expect(output.length).toBeGreaterThan(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('whitespace trimmed: " /help " works', () => {
|
|
132
|
+
const { handleSlashCommand, output } = createSlashHandler({});
|
|
133
|
+
expect(handleSlashCommand(' /help ')).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('empty input handling', () => {
|
|
137
|
+
const text = ' '.trim();
|
|
138
|
+
expect(text).toBe('');
|
|
139
|
+
// Empty input should be skipped (no slash command)
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('banner would contain agent name', () => {
|
|
143
|
+
const agentName = 'MyTestAgent';
|
|
144
|
+
const banner = `OPC Agent - Interactive Chat - ${agentName}`;
|
|
145
|
+
expect(banner).toContain(agentName);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('/clear then history is empty', () => {
|
|
149
|
+
const history = [{ role: 'user', content: 'msg1' }];
|
|
150
|
+
const { handleSlashCommand } = createSlashHandler({ history });
|
|
151
|
+
expect(history).toHaveLength(1);
|
|
152
|
+
handleSlashCommand('/clear');
|
|
153
|
+
expect(history).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('message without slash goes to agent (not handled by slash handler)', () => {
|
|
157
|
+
const { handleSlashCommand } = createSlashHandler({});
|
|
158
|
+
expect(handleSlashCommand('tell me a joke')).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
describe('CLI: opc chat slash commands', () => {
|
|
6
|
+
// Test that slash commands are recognized patterns
|
|
7
|
+
const slashCommands = ['/help', '/quit', '/exit', '/clear', '/skills', '/memory', '/info'];
|
|
8
|
+
|
|
9
|
+
it('should define all expected slash commands', () => {
|
|
10
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
11
|
+
for (const cmd of slashCommands) {
|
|
12
|
+
expect(cliSource).toContain(`'${cmd}'`);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('chat command should be registered', () => {
|
|
17
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
18
|
+
expect(cliSource).toContain(".command('chat')");
|
|
19
|
+
expect(cliSource).toContain('Interactive CLI chat with the agent');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('init command should generate SOUL.md and CONTEXT.md', () => {
|
|
23
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
24
|
+
expect(cliSource).toContain("'SOUL.md'");
|
|
25
|
+
expect(cliSource).toContain("'CONTEXT.md'");
|
|
26
|
+
expect(cliSource).toContain('# Project Context');
|
|
27
|
+
expect(cliSource).toContain('Personality');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('chat banner should include expected elements', () => {
|
|
31
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
32
|
+
expect(cliSource).toContain('OPC Agent — Interactive Chat');
|
|
33
|
+
expect(cliSource).toContain('/help for commands');
|
|
34
|
+
expect(cliSource).toContain('╔');
|
|
35
|
+
expect(cliSource).toContain('╚');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('chat should load SOUL.md and CONTEXT.md', () => {
|
|
39
|
+
const cliSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8');
|
|
40
|
+
expect(cliSource).toContain("'SOUL.md'");
|
|
41
|
+
expect(cliSource).toContain("'CONTEXT.md'");
|
|
42
|
+
// Should prepend to system prompt
|
|
43
|
+
expect(cliSource).toContain('soulContent');
|
|
44
|
+
expect(cliSource).toContain('contextContent');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
|
|
6
|
+
describe('Daemon', () => {
|
|
7
|
+
const OPC_DIR_NAME = '.opc';
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let opcDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'daemon-test-'));
|
|
13
|
+
opcDir = path.join(tmpDir, OPC_DIR_NAME);
|
|
14
|
+
fs.mkdirSync(opcDir, { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Test the daemon's file-based contract (PID, heartbeat, log)
|
|
22
|
+
it('PID file can be created', () => {
|
|
23
|
+
const pidFile = path.join(opcDir, 'agent.pid');
|
|
24
|
+
fs.writeFileSync(pidFile, '12345');
|
|
25
|
+
expect(fs.existsSync(pidFile)).toBe(true);
|
|
26
|
+
expect(fs.readFileSync(pidFile, 'utf-8')).toBe('12345');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('PID file can be removed', () => {
|
|
30
|
+
const pidFile = path.join(opcDir, 'agent.pid');
|
|
31
|
+
fs.writeFileSync(pidFile, '12345');
|
|
32
|
+
fs.unlinkSync(pidFile);
|
|
33
|
+
expect(fs.existsSync(pidFile)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('status: PID exists and process alive → running', () => {
|
|
37
|
+
const pidFile = path.join(opcDir, 'agent.pid');
|
|
38
|
+
fs.writeFileSync(pidFile, String(process.pid)); // current process is alive
|
|
39
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'));
|
|
40
|
+
let alive = false;
|
|
41
|
+
try { process.kill(pid, 0); alive = true; } catch { alive = false; }
|
|
42
|
+
expect(alive).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('status: no PID file → stopped', () => {
|
|
46
|
+
const pidFile = path.join(opcDir, 'agent.pid');
|
|
47
|
+
expect(fs.existsSync(pidFile)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('stale PID detection: PID file with non-running process', () => {
|
|
51
|
+
const pidFile = path.join(opcDir, 'agent.pid');
|
|
52
|
+
fs.writeFileSync(pidFile, '999999999'); // very unlikely to be running
|
|
53
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8'));
|
|
54
|
+
let alive = false;
|
|
55
|
+
try { process.kill(pid, 0); alive = true; } catch { alive = false; }
|
|
56
|
+
expect(alive).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('heartbeat file written', () => {
|
|
60
|
+
const hbFile = path.join(opcDir, 'heartbeat');
|
|
61
|
+
const now = String(Date.now());
|
|
62
|
+
fs.writeFileSync(hbFile, now);
|
|
63
|
+
expect(fs.readFileSync(hbFile, 'utf-8')).toBe(now);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('heartbeat staleness detection', () => {
|
|
67
|
+
const hbFile = path.join(opcDir, 'heartbeat');
|
|
68
|
+
const old = String(Date.now() - 120_000); // 2 minutes ago
|
|
69
|
+
fs.writeFileSync(hbFile, old);
|
|
70
|
+
const hbTime = parseInt(fs.readFileSync(hbFile, 'utf-8'));
|
|
71
|
+
expect(Date.now() - hbTime).toBeGreaterThan(60_000);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('log file created', () => {
|
|
75
|
+
const logFile = path.join(opcDir, 'agent.log');
|
|
76
|
+
fs.writeFileSync(logFile, '[2026-04-18T10:00:00.000Z] Daemon started\n');
|
|
77
|
+
expect(fs.existsSync(logFile)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('log file appendable', () => {
|
|
81
|
+
const logFile = path.join(opcDir, 'agent.log');
|
|
82
|
+
fs.writeFileSync(logFile, 'line1\n');
|
|
83
|
+
fs.appendFileSync(logFile, 'line2\n');
|
|
84
|
+
const content = fs.readFileSync(logFile, 'utf-8');
|
|
85
|
+
expect(content).toContain('line1');
|
|
86
|
+
expect(content).toContain('line2');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('double-start prevention: PID file already exists', () => {
|
|
90
|
+
const pidFile = path.join(opcDir, 'agent.pid');
|
|
91
|
+
fs.writeFileSync(pidFile, String(process.pid));
|
|
92
|
+
// Simulating: check before starting
|
|
93
|
+
const exists = fs.existsSync(pidFile);
|
|
94
|
+
expect(exists).toBe(true);
|
|
95
|
+
// A real daemon would check and abort
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('started file tracks uptime', () => {
|
|
99
|
+
const startedFile = path.join(opcDir, 'started');
|
|
100
|
+
const startTime = Date.now();
|
|
101
|
+
fs.writeFileSync(startedFile, String(startTime));
|
|
102
|
+
const uptime = Date.now() - parseInt(fs.readFileSync(startedFile, 'utf-8'));
|
|
103
|
+
expect(uptime).toBeGreaterThanOrEqual(0);
|
|
104
|
+
expect(uptime).toBeLessThan(1000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('.opc directory creation is idempotent', () => {
|
|
108
|
+
fs.mkdirSync(opcDir, { recursive: true });
|
|
109
|
+
fs.mkdirSync(opcDir, { recursive: true }); // should not throw
|
|
110
|
+
expect(fs.existsSync(opcDir)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('graceful shutdown cleans up PID file', () => {
|
|
114
|
+
const pidFile = path.join(opcDir, 'agent.pid');
|
|
115
|
+
fs.writeFileSync(pidFile, '12345');
|
|
116
|
+
// Simulate shutdown
|
|
117
|
+
try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
|
|
118
|
+
expect(fs.existsSync(pidFile)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('env file parsing', () => {
|
|
122
|
+
const envFile = path.join(tmpDir, '.env');
|
|
123
|
+
fs.writeFileSync(envFile, 'KEY1=value1\nKEY2=value2\n# comment\n');
|
|
124
|
+
const content = fs.readFileSync(envFile, 'utf-8');
|
|
125
|
+
const vars: Record<string, string> = {};
|
|
126
|
+
for (const line of content.split('\n')) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
129
|
+
const eqIdx = trimmed.indexOf('=');
|
|
130
|
+
if (eqIdx === -1) continue;
|
|
131
|
+
vars[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim();
|
|
132
|
+
}
|
|
133
|
+
expect(vars).toEqual({ KEY1: 'value1', KEY2: 'value2' });
|
|
134
|
+
});
|
|
135
|
+
});
|