@dsh-overdrive/gateway 0.2.0 → 0.3.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/commands.d.ts +5 -0
- package/dist/commands.js +14 -4
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +57 -8
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts +10 -0
- package/dist/memory.js +43 -0
- package/dist/memory.js.map +1 -1
- package/dist/mention.d.ts +13 -0
- package/dist/mention.js +43 -0
- package/dist/mention.js.map +1 -0
- package/dist/pending-buttons.d.ts +5 -1
- package/dist/pending-buttons.js +26 -8
- package/dist/pending-buttons.js.map +1 -1
- package/dist/text.d.ts +2 -0
- package/dist/text.js +24 -0
- package/dist/text.js.map +1 -0
- package/package.json +9 -4
- package/src/adapter.ts +0 -42
- package/src/adapters/cli.ts +0 -37
- package/src/adapters/dingtalk.ts +0 -206
- package/src/adapters/discord.ts +0 -127
- package/src/adapters/feishu.ts +0 -224
- package/src/adapters/slack.ts +0 -123
- package/src/adapters/telegram.ts +0 -142
- package/src/adapters/wechat.ts +0 -247
- package/src/adapters/wecom.ts +0 -218
- package/src/adapters/whatsapp.ts +0 -249
- package/src/asr.ts +0 -83
- package/src/commands.ts +0 -89
- package/src/config.ts +0 -104
- package/src/feed.ts +0 -190
- package/src/index.ts +0 -456
- package/src/memory.ts +0 -133
- package/src/pending-buttons.ts +0 -45
- package/src/session.ts +0 -23
- package/src/setup.ts +0 -252
- package/src/status.ts +0 -63
- package/src/trajectory.ts +0 -45
- package/test/adapters.dingtalk.test.ts +0 -64
- package/test/adapters.discord.test.ts +0 -41
- package/test/adapters.feishu.test.ts +0 -66
- package/test/adapters.slack.test.ts +0 -45
- package/test/adapters.telegram.test.ts +0 -37
- package/test/adapters.wechat.test.ts +0 -78
- package/test/adapters.wecom.test.ts +0 -62
- package/test/adapters.whatsapp.test.ts +0 -138
- package/test/asr.test.ts +0 -77
- package/test/commands.test.ts +0 -43
- package/test/config.test.ts +0 -30
- package/test/feed.test.ts +0 -111
- package/test/memory.test.ts +0 -79
- package/test/multi.test.ts +0 -258
- package/test/outbound.test.ts +0 -29
- package/test/pending-buttons.test.ts +0 -61
- package/test/session.test.ts +0 -26
- package/test/status.test.ts +0 -41
- package/test/streaming.test.ts +0 -162
- package/test/trajectory.test.ts +0 -58
- package/tsconfig.json +0 -5
package/test/multi.test.ts
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { mediaKindFromPath, remindSchedule, wireAdapter } from '../src/index.js';
|
|
3
|
-
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
4
|
-
import { MemoryStore } from '../src/memory.js';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import type { Adapter, NormalizedMessage, OutboundPayload, ReplySender } from '../src/adapter.js';
|
|
9
|
-
|
|
10
|
-
/** 可编程 FakeAdapter:验证 wiring 逻辑(白名单/会话键/错误兜底)。 */
|
|
11
|
-
class FakeAdapter implements Adapter {
|
|
12
|
-
readonly id: string;
|
|
13
|
-
readonly sent: Array<{ chatId: string; payload: OutboundPayload }> = [];
|
|
14
|
-
private messageCb?: (msg: NormalizedMessage) => Promise<void> | void;
|
|
15
|
-
private replyCb?: (buttonId: string, sender: ReplySender) => Promise<void> | void;
|
|
16
|
-
constructor(id: string) { this.id = id; }
|
|
17
|
-
async connect(): Promise<void> {}
|
|
18
|
-
async send(chatId: string, payload: OutboundPayload): Promise<void> { this.sent.push({ chatId, payload }); }
|
|
19
|
-
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
20
|
-
onReply(cb: (buttonId: string, sender: ReplySender) => void): void { this.replyCb = cb; }
|
|
21
|
-
/** 测试助手:返回处理器 Promise,测试 await 以确保 async 接线(upsert→sendMessage / catch 兜底)跑完 */
|
|
22
|
-
emit(msg: NormalizedMessage): Promise<void> | void { return this.messageCb?.(msg); }
|
|
23
|
-
click(buttonId: string, sender: ReplySender = { chatId: 'C1', userId: 'U1' }): Promise<void> | void {
|
|
24
|
-
return this.replyCb?.(buttonId, sender);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 假 DSH:记录调用。 */
|
|
29
|
-
function fakeClient() {
|
|
30
|
-
const upserts: Array<{ platform: string; channel: string; user: string }> = [];
|
|
31
|
-
const messages: Array<{ sessionId: string; text: string }> = [];
|
|
32
|
-
const approvals: Array<{ reqId: string; decision: string }> = [];
|
|
33
|
-
const client = {
|
|
34
|
-
upsertSession: async (req: { platform: string; channel: string; user: string }) => {
|
|
35
|
-
upserts.push(req);
|
|
36
|
-
return { sessionId: `${req.platform}:${req.channel}:${req.user}` };
|
|
37
|
-
},
|
|
38
|
-
sendMessage: async (sessionId: string, req: { text: string }) => {
|
|
39
|
-
messages.push({ sessionId, text: req.text });
|
|
40
|
-
return { runId: 'r1' };
|
|
41
|
-
},
|
|
42
|
-
resolveApproval: async (reqId: string, decision: 'approve' | 'reject') => {
|
|
43
|
-
approvals.push({ reqId, decision });
|
|
44
|
-
return { ok: true };
|
|
45
|
-
},
|
|
46
|
-
listTasks: async () => ({ tasks: [] }),
|
|
47
|
-
// wireAdapter 末尾会订阅事件流(client.connect),假客户端需实现完整接口
|
|
48
|
-
connect: async () => () => undefined,
|
|
49
|
-
} as unknown as GatewayClient;
|
|
50
|
-
return { client, upserts, messages, approvals };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe('wireAdapter(多适配器装配核心)', () => {
|
|
54
|
-
it('白名单拦截并回错误文本', async () => {
|
|
55
|
-
const adapter = new FakeAdapter('telegram');
|
|
56
|
-
const { client } = fakeClient();
|
|
57
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
58
|
-
|
|
59
|
-
await adapter.emit({ chatId: '999', userId: '999', text: 'hi' });
|
|
60
|
-
expect(adapter.sent[0].payload.text).toContain('⛔');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('白名单内消息 → upsert + sendMessage', async () => {
|
|
64
|
-
const adapter = new FakeAdapter('telegram');
|
|
65
|
-
const { client, upserts, messages } = fakeClient();
|
|
66
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
67
|
-
|
|
68
|
-
await adapter.emit({ chatId: '111', userId: '222', text: 'hello' });
|
|
69
|
-
expect(upserts).toEqual([{ platform: 'telegram', channel: '111', user: '222' }]);
|
|
70
|
-
expect(messages).toEqual([{ sessionId: 'telegram:111:222', text: 'hello' }]);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('按钮点击(白名单内)→ resolveApproval', async () => {
|
|
74
|
-
const adapter = new FakeAdapter('discord');
|
|
75
|
-
const { client, approvals } = fakeClient();
|
|
76
|
-
await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
|
|
77
|
-
|
|
78
|
-
await adapter.click('approve:r1', { chatId: 'C1', userId: 'U1' });
|
|
79
|
-
expect(approvals).toEqual([{ reqId: 'r1', decision: 'approve' }]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('按钮点击(白名单外)→ 拒绝批准并回错误文本', async () => {
|
|
83
|
-
const adapter = new FakeAdapter('discord');
|
|
84
|
-
const { client, approvals } = fakeClient();
|
|
85
|
-
await wireAdapter(adapter, client, { allowlist: ['discord:C1:U1'] });
|
|
86
|
-
|
|
87
|
-
await adapter.click('approve:r1', { chatId: 'C1', userId: 'EVIL' });
|
|
88
|
-
expect(approvals).toEqual([]); // 未授权用户不能批准
|
|
89
|
-
expect(adapter.sent[0].payload.text).toContain('⛔');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('空白名单 fail-closed:点击一律拒绝', async () => {
|
|
93
|
-
const adapter = new FakeAdapter('telegram');
|
|
94
|
-
const { client, approvals } = fakeClient();
|
|
95
|
-
await wireAdapter(adapter, client, { allowlist: [] });
|
|
96
|
-
|
|
97
|
-
await adapter.click('reject:r1', { chatId: 'C1', userId: 'U1' });
|
|
98
|
-
expect(approvals).toEqual([]);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('DSH 调用失败 → 回错误文本(不崩溃)', async () => {
|
|
102
|
-
const adapter = new FakeAdapter('slack');
|
|
103
|
-
const client = {
|
|
104
|
-
upsertSession: async () => { throw new Error('dsh down'); },
|
|
105
|
-
connect: async () => () => undefined,
|
|
106
|
-
} as unknown as GatewayClient;
|
|
107
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
108
|
-
|
|
109
|
-
await adapter.emit({ chatId: 'C1', userId: 'U1', text: 'hi' });
|
|
110
|
-
expect(adapter.sent[0].payload.text).toContain('❌');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('相关记忆自动注入到发给 agent 的文本(OpenClaw 式)', async () => {
|
|
114
|
-
const adapter = new FakeAdapter('telegram');
|
|
115
|
-
const { client, messages } = fakeClient();
|
|
116
|
-
const memory = new MemoryStore();
|
|
117
|
-
memory.add('telegram:222', '用户喜欢美式咖啡');
|
|
118
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
119
|
-
|
|
120
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '帮我点一杯咖啡' });
|
|
121
|
-
expect(messages[0].text).toContain('帮我点一杯咖啡');
|
|
122
|
-
expect(messages[0].text).toContain('📌 相关记忆');
|
|
123
|
-
expect(messages[0].text).toContain('用户喜欢美式咖啡');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('无相关记忆时不注入', async () => {
|
|
127
|
-
const adapter = new FakeAdapter('telegram');
|
|
128
|
-
const { client, messages } = fakeClient();
|
|
129
|
-
const memory = new MemoryStore();
|
|
130
|
-
memory.add('telegram:222', '用户喜欢美式咖啡');
|
|
131
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
132
|
-
|
|
133
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '今天天气如何' });
|
|
134
|
-
expect(messages[0].text).toBe('今天天气如何');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('/remember 命令写入记忆并回执', async () => {
|
|
138
|
-
const adapter = new FakeAdapter('telegram');
|
|
139
|
-
const { client } = fakeClient();
|
|
140
|
-
const memory = new MemoryStore();
|
|
141
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
142
|
-
|
|
143
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/remember 用户住在杭州' });
|
|
144
|
-
expect(memory.count('telegram:222')).toBe(1);
|
|
145
|
-
expect(adapter.sent[0].payload.text).toContain('已记住');
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('自动记忆:自我事实消息自动沉淀', async () => {
|
|
149
|
-
const adapter = new FakeAdapter('telegram');
|
|
150
|
-
const { client } = fakeClient();
|
|
151
|
-
const memory = new MemoryStore();
|
|
152
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
153
|
-
|
|
154
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '你好,我叫小明,我住在杭州' });
|
|
155
|
-
expect(memory.count('telegram:222')).toBe(2);
|
|
156
|
-
expect(memory.list('telegram:222').map((e) => e.text)).toContain('我叫小明');
|
|
157
|
-
expect(memory.list('telegram:222').map((e) => e.text)).toContain('我住在杭州');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('persona:每条消息前置人设', async () => {
|
|
161
|
-
const adapter = new FakeAdapter('telegram');
|
|
162
|
-
const { client, messages } = fakeClient();
|
|
163
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], persona: '你是一个毒舌助理' });
|
|
164
|
-
|
|
165
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '你好' });
|
|
166
|
-
expect(messages[0].text).toBe('【人设】你是一个毒舌助理\n你好');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('file.created 事件:agent 产出的文件自动发回聊天并清理临时文件', async () => {
|
|
170
|
-
const adapter = new FakeAdapter('telegram');
|
|
171
|
-
let onEvent: ((ev: ServerEvent) => void) | undefined;
|
|
172
|
-
const client = {
|
|
173
|
-
upsertSession: async () => ({ sessionId: 'telegram:111:222' }),
|
|
174
|
-
connect: async (cb: (ev: ServerEvent) => void) => { onEvent = cb; return () => {}; },
|
|
175
|
-
} as unknown as GatewayClient;
|
|
176
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
177
|
-
|
|
178
|
-
const bytes = Buffer.from('fake-image-bytes');
|
|
179
|
-
onEvent!({
|
|
180
|
-
type: 'file.created', sessionId: 'telegram:111:222', ts: Date.now(),
|
|
181
|
-
name: 'chart.png', kind: 'image', data: bytes.toString('base64'),
|
|
182
|
-
});
|
|
183
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
184
|
-
expect(adapter.sent[0].payload.text).toContain('chart.png');
|
|
185
|
-
expect(adapter.sent[0].payload.media).toMatchObject({ kind: 'image', caption: 'chart.png' });
|
|
186
|
-
expect(existsSync(adapter.sent[0].payload.media!.path)).toBe(false); // 临时文件已清理
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('/send <path> 读取文件并以媒体载荷发送', async () => {
|
|
190
|
-
const adapter = new FakeAdapter('telegram');
|
|
191
|
-
const { client } = fakeClient();
|
|
192
|
-
const tmp = join(tmpdir(), `dsh-send-test-${Date.now()}.png`);
|
|
193
|
-
writeFileSync(tmp, Buffer.from([1, 2, 3]));
|
|
194
|
-
try {
|
|
195
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
196
|
-
await adapter.emit({ chatId: '111', userId: '222', text: `/send ${tmp}` });
|
|
197
|
-
expect(adapter.sent[0].payload.media).toMatchObject({ kind: 'image', path: tmp });
|
|
198
|
-
expect(adapter.sent[0].payload.text).toContain('📎');
|
|
199
|
-
} finally {
|
|
200
|
-
rmSync(tmp, { force: true });
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('/send 不存在的文件回错误文本', async () => {
|
|
205
|
-
const adapter = new FakeAdapter('telegram');
|
|
206
|
-
const { client } = fakeClient();
|
|
207
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
208
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/send /no/such/file.png' });
|
|
209
|
-
expect(adapter.sent[0].payload.text).toContain('❌');
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('/status 返回适配器/记忆/定时任务概览', async () => {
|
|
213
|
-
const adapter = new FakeAdapter('telegram');
|
|
214
|
-
const { client } = fakeClient();
|
|
215
|
-
const memory = new MemoryStore();
|
|
216
|
-
memory.add('telegram:222', 'x');
|
|
217
|
-
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
218
|
-
await adapter.emit({ chatId: '111', userId: '222', text: '/status' });
|
|
219
|
-
expect(adapter.sent[0].payload.text).toContain('📊 状态');
|
|
220
|
-
expect(adapter.sent[0].payload.text).toContain('telegram');
|
|
221
|
-
expect(adapter.sent[0].payload.text).toContain('记忆: 1 条');
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
describe('remindSchedule', () => {
|
|
226
|
-
it('相对分钟 → cron 5 字段', () => {
|
|
227
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
228
|
-
expect(remindSchedule(10, null, now)).toBe('15 10 20 8 *');
|
|
229
|
-
});
|
|
230
|
-
it('定点时间 → cron;已过则推到明天', () => {
|
|
231
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
232
|
-
expect(remindSchedule(0, '14:30', now)).toBe('30 14 20 8 *');
|
|
233
|
-
expect(remindSchedule(0, '09:00', now)).toBe('0 9 21 8 *');
|
|
234
|
-
});
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
describe('mediaKindFromPath', () => {
|
|
238
|
-
it('图片/语音/其他文件分类', () => {
|
|
239
|
-
expect(mediaKindFromPath('a.png')).toBe('image');
|
|
240
|
-
expect(mediaKindFromPath('b.JPG')).toBe('image');
|
|
241
|
-
expect(mediaKindFromPath('c.webp')).toBe('image');
|
|
242
|
-
expect(mediaKindFromPath('v.ogg')).toBe('voice');
|
|
243
|
-
expect(mediaKindFromPath('r.pdf')).toBe('file');
|
|
244
|
-
expect(mediaKindFromPath('x.zip')).toBe('file');
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe('remindSchedule', () => {
|
|
249
|
-
it('相对分钟 → cron 5 字段', () => {
|
|
250
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
251
|
-
expect(remindSchedule(10, null, now)).toBe('15 10 20 8 *');
|
|
252
|
-
});
|
|
253
|
-
it('定点时间 → cron;已过则推到明天', () => {
|
|
254
|
-
const now = new Date('2026-08-20T10:05:00');
|
|
255
|
-
expect(remindSchedule(0, '14:30', now)).toBe('30 14 20 8 *');
|
|
256
|
-
expect(remindSchedule(0, '09:00', now)).toBe('0 9 21 8 *');
|
|
257
|
-
});
|
|
258
|
-
});
|
package/test/outbound.test.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { planOutbound } from '../src/index.js';
|
|
3
|
-
import type { ServerEvent } from '@dsh-overdrive/sdk';
|
|
4
|
-
|
|
5
|
-
describe('planOutbound(事件 → 平台输出)', () => {
|
|
6
|
-
it('message.complete → 纯文本', () => {
|
|
7
|
-
const ev: ServerEvent = { type: 'message.complete', sessionId: 'cli:cli:local', ts: 1, text: '结果' };
|
|
8
|
-
expect(planOutbound(ev)?.payload).toEqual({ text: '结果' });
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('trajectory.step → 带图标的轨迹行', () => {
|
|
12
|
-
const ev: ServerEvent = { type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 1, step: { kind: 'tool', label: 'grep' } };
|
|
13
|
-
expect(planOutbound(ev)?.payload.text).toBe('🛠️ grep');
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('approval.request → 文本 + 同意/拒绝两个按钮', () => {
|
|
17
|
-
const ev: ServerEvent = { type: 'approval.request', sessionId: 'cli:cli:local', ts: 1, reqId: 'r1', summary: '删除文件', timeoutMs: 60000 };
|
|
18
|
-
const out = planOutbound(ev)!;
|
|
19
|
-
expect(out.payload.text).toContain('删除文件');
|
|
20
|
-
expect(out.payload.buttons).toHaveLength(2);
|
|
21
|
-
expect(out.payload.buttons![0].id).toBe('approve:r1');
|
|
22
|
-
expect(out.payload.buttons![1].id).toBe('reject:r1');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('message.delta 不输出(MVP 等 complete)', () => {
|
|
26
|
-
const ev: ServerEvent = { type: 'message.delta', sessionId: 'cli:cli:local', ts: 1, text: '…' };
|
|
27
|
-
expect(planOutbound(ev)).toBeNull();
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { PendingButtons, PENDING_BUTTONS_TTL_MS } from '../src/pending-buttons.js';
|
|
3
|
-
|
|
4
|
-
describe('PendingButtons', () => {
|
|
5
|
-
it('数字回复命中并消费', () => {
|
|
6
|
-
const pb = new PendingButtons();
|
|
7
|
-
pb.set('chat-1', [
|
|
8
|
-
{ id: 'approve:1', label: '同意' },
|
|
9
|
-
{ id: 'reject:1', label: '拒绝' },
|
|
10
|
-
]);
|
|
11
|
-
expect(pb.match('chat-1', '2')).toEqual({ id: 'reject:1', label: '拒绝' });
|
|
12
|
-
expect(pb.match('chat-1', '1')).toBeUndefined(); // 已消费
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('非数字 / 越界不消费,按钮仍有效', () => {
|
|
16
|
-
const pb = new PendingButtons();
|
|
17
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
18
|
-
expect(pb.match('chat-1', 'hello')).toBeUndefined();
|
|
19
|
-
expect(pb.match('chat-1', '5')).toBeUndefined();
|
|
20
|
-
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('过期后自动失效(不吞后续数字消息)', () => {
|
|
24
|
-
vi.useFakeTimers();
|
|
25
|
-
try {
|
|
26
|
-
const pb = new PendingButtons(1000);
|
|
27
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
28
|
-
vi.advanceTimersByTime(1001);
|
|
29
|
-
expect(pb.match('chat-1', '1')).toBeUndefined();
|
|
30
|
-
} finally {
|
|
31
|
-
vi.useRealTimers();
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('TTL 内不失效', () => {
|
|
36
|
-
vi.useFakeTimers();
|
|
37
|
-
try {
|
|
38
|
-
const pb = new PendingButtons(PENDING_BUTTONS_TTL_MS);
|
|
39
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
40
|
-
vi.advanceTimersByTime(PENDING_BUTTONS_TTL_MS - 1);
|
|
41
|
-
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
42
|
-
} finally {
|
|
43
|
-
vi.useRealTimers();
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('consume 删除 pending(原生按钮点击)', () => {
|
|
48
|
-
const pb = new PendingButtons();
|
|
49
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
50
|
-
pb.consume('chat-1');
|
|
51
|
-
expect(pb.match('chat-1', '1')).toBeUndefined();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('不同 chat 互不影响', () => {
|
|
55
|
-
const pb = new PendingButtons();
|
|
56
|
-
pb.set('chat-1', [{ id: 'a', label: '同意' }]);
|
|
57
|
-
pb.set('chat-2', [{ id: 'b', label: '拒绝' }]);
|
|
58
|
-
expect(pb.match('chat-2', '1')).toEqual({ id: 'b', label: '拒绝' });
|
|
59
|
-
expect(pb.match('chat-1', '1')).toEqual({ id: 'a', label: '同意' });
|
|
60
|
-
});
|
|
61
|
-
});
|
package/test/session.test.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { Allowlist, buildSessionKey } from '../src/session.js';
|
|
3
|
-
|
|
4
|
-
describe('buildSessionKey', () => {
|
|
5
|
-
it('用 adapterId + chatId + userId 拼会话键', () => {
|
|
6
|
-
expect(buildSessionKey('whatsapp', { chatId: '60123', userId: '60123' })).toBe('whatsapp:60123:60123');
|
|
7
|
-
});
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
describe('Allowlist', () => {
|
|
11
|
-
it('空列表 fail-closed:拒绝所有(生产默认)', () => {
|
|
12
|
-
const allow = new Allowlist([]);
|
|
13
|
-
expect(allow.allows('anything:any:any')).toBe(false);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('非空列表只放行白名单条目', () => {
|
|
17
|
-
const allow = new Allowlist(['whatsapp:60123:60123']);
|
|
18
|
-
expect(allow.allows('whatsapp:60123:60123')).toBe(true);
|
|
19
|
-
expect(allow.allows('whatsapp:99999:99999')).toBe(false);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('ALLOW_ALL 显式放行所有(开发逃生口)', () => {
|
|
23
|
-
const allow = new Allowlist([], true);
|
|
24
|
-
expect(allow.allows('anything:any:any')).toBe(true);
|
|
25
|
-
});
|
|
26
|
-
});
|
package/test/status.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import { createStatusServer } from '../src/status.js';
|
|
3
|
-
import type { Adapter } from '../src/adapter.js';
|
|
4
|
-
import { GatewayClient } from '@dsh-overdrive/sdk';
|
|
5
|
-
|
|
6
|
-
describe('createStatusServer', () => {
|
|
7
|
-
let server: ReturnType<typeof createStatusServer> | undefined;
|
|
8
|
-
let port = 0;
|
|
9
|
-
afterEach(async () => { await server?.close(); server = undefined; });
|
|
10
|
-
|
|
11
|
-
async function start(fakeAdapters: Adapter[]): Promise<string> {
|
|
12
|
-
const client = {
|
|
13
|
-
health: async () => ({ status: 'ok' as const, version: '0.1.0' }),
|
|
14
|
-
} as unknown as GatewayClient;
|
|
15
|
-
server = createStatusServer({ adapters: fakeAdapters, client, version: '0.1.0' });
|
|
16
|
-
port = await server.listen(0);
|
|
17
|
-
return `http://127.0.0.1:${port}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
it('/api/status 返回 dsh 健康与适配器状态', async () => {
|
|
21
|
-
const url = await start([
|
|
22
|
-
{ id: 'telegram', status: () => ({ connected: true }) },
|
|
23
|
-
{ id: 'whatsapp', status: () => ({ connected: false }) },
|
|
24
|
-
] as unknown as Adapter[]);
|
|
25
|
-
const res = await fetch(`${url}/api/status`);
|
|
26
|
-
expect(res.status).toBe(200);
|
|
27
|
-
const body = await res.json();
|
|
28
|
-
expect(body.dsh.status).toBe('ok');
|
|
29
|
-
expect(body.adapters).toEqual([
|
|
30
|
-
{ id: 'telegram', connected: true },
|
|
31
|
-
{ id: 'whatsapp', connected: false },
|
|
32
|
-
]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('GET / 返回 HTML 控制台页', async () => {
|
|
36
|
-
const url = await start([]);
|
|
37
|
-
const res = await fetch(`${url}/`);
|
|
38
|
-
expect(res.status).toBe(200);
|
|
39
|
-
expect((await res.text())).toContain('dsh-overdrive');
|
|
40
|
-
});
|
|
41
|
-
});
|
package/test/streaming.test.ts
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { DeltaTracker, planOutbound, wireAdapter } from '../src/index.js';
|
|
3
|
-
import { HELP_TEXT } from '../src/commands.js';
|
|
4
|
-
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
5
|
-
import type { Adapter, NormalizedMessage, OutboundPayload } from '../src/adapter.js';
|
|
6
|
-
|
|
7
|
-
describe('DeltaTracker(message.delta → 打字指示,complete → 终稿)', () => {
|
|
8
|
-
it('首个 delta 触发 typing,重复 delta 不重复触发', () => {
|
|
9
|
-
const t = new DeltaTracker();
|
|
10
|
-
const typings: string[] = [];
|
|
11
|
-
const outputs: string[] = [];
|
|
12
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
13
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
14
|
-
expect(typings).toEqual(['s1']);
|
|
15
|
-
void outputs;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('complete 后同会话下一个 turn 的 delta 可再次触发 typing', () => {
|
|
19
|
-
const t = new DeltaTracker();
|
|
20
|
-
const typings: string[] = [];
|
|
21
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
22
|
-
t.onComplete('s1');
|
|
23
|
-
t.onDelta('s1', () => typings.push('s1'));
|
|
24
|
-
expect(typings).toEqual(['s1', 's1']);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('planOutbound(trajectory.summary 摘要卡片渲染)', () => {
|
|
29
|
-
it('trajectory.summary → formatTrajectorySummary 文本', () => {
|
|
30
|
-
const ev: ServerEvent = {
|
|
31
|
-
type: 'trajectory.summary', sessionId: 'cli:cli:local', ts: 1,
|
|
32
|
-
steps: [{ kind: 'thought', label: '分析' }, { kind: 'tool', label: 'bash' }],
|
|
33
|
-
};
|
|
34
|
-
const out = planOutbound(ev)!;
|
|
35
|
-
expect(out.payload.text).toContain('📋 轨迹(2 步)');
|
|
36
|
-
expect(out.payload.text).toContain('🧠 分析');
|
|
37
|
-
expect(out.payload.text).toContain('🛠️ bash');
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
/** 可编程 FakeAdapter(带 sendTyping 探测)。 */
|
|
42
|
-
class FakeAdapter implements Adapter {
|
|
43
|
-
readonly id: string;
|
|
44
|
-
readonly sent: Array<{ chatId: string; payload: OutboundPayload }> = [];
|
|
45
|
-
readonly typings: string[] = [];
|
|
46
|
-
private messageCb?: (msg: NormalizedMessage) => Promise<void> | void;
|
|
47
|
-
private replyCb?: (buttonId: string) => Promise<void> | void;
|
|
48
|
-
constructor(id: string) { this.id = id; }
|
|
49
|
-
async connect(): Promise<void> {}
|
|
50
|
-
async send(chatId: string, payload: OutboundPayload): Promise<void> { this.sent.push({ chatId, payload }); }
|
|
51
|
-
async sendTyping(chatId: string): Promise<void> { this.typings.push(chatId); }
|
|
52
|
-
onMessage(cb: (msg: NormalizedMessage) => void): void { this.messageCb = cb; }
|
|
53
|
-
onReply(cb: (buttonId: string) => void): void { this.replyCb = cb; }
|
|
54
|
-
emit(msg: NormalizedMessage): Promise<void> | void { return this.messageCb?.(msg); }
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** 假 DSH 客户端:可编程事件推流 + 记录调用。 */
|
|
58
|
-
function fakeClient() {
|
|
59
|
-
const createTasks: Array<{ sessionId: string; kind: string; prompt: string; schedule?: string }> = [];
|
|
60
|
-
const resets: string[] = [];
|
|
61
|
-
let eventCb: ((ev: ServerEvent) => void) | undefined;
|
|
62
|
-
const client = {
|
|
63
|
-
upsertSession: async (req: { platform: string; channel: string; user: string }) =>
|
|
64
|
-
({ sessionId: `${req.platform}:${req.channel}:${req.user}` }),
|
|
65
|
-
sendMessage: async () => ({ runId: 'r1' }),
|
|
66
|
-
resolveApproval: async () => ({ ok: true }),
|
|
67
|
-
createTask: async (req: { sessionId: string; kind: 'subagent' | 'cron'; prompt: string; schedule?: string }) => {
|
|
68
|
-
createTasks.push(req);
|
|
69
|
-
return { taskId: 't1' };
|
|
70
|
-
},
|
|
71
|
-
resetSession: async (sessionId: string) => { resets.push(sessionId); return { ok: true }; },
|
|
72
|
-
connect: async (cb: (ev: ServerEvent) => void) => { eventCb = cb; return () => undefined; },
|
|
73
|
-
};
|
|
74
|
-
const push = (ev: ServerEvent): void => eventCb?.(ev);
|
|
75
|
-
return { client, createTasks, resets, push } as unknown as {
|
|
76
|
-
client: GatewayClient; createTasks: typeof createTasks; resets: string[];
|
|
77
|
-
push: (ev: ServerEvent) => void;
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
describe('wireAdapter(命令分发 + delta 打字指示 + 轨迹聚合接线)', () => {
|
|
82
|
-
it('message.delta → sendTyping 一次;complete 后下一 turn 再触发', async () => {
|
|
83
|
-
const adapter = new FakeAdapter('cli');
|
|
84
|
-
const { client, push } = fakeClient();
|
|
85
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
86
|
-
|
|
87
|
-
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 1, text: '…' });
|
|
88
|
-
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 2, text: '…' });
|
|
89
|
-
expect(adapter.typings).toEqual(['cli']); // sendTyping 目标是 chatId(无消息时回退到 channel)
|
|
90
|
-
expect(adapter.sent).toHaveLength(0); // delta 不产出文本
|
|
91
|
-
|
|
92
|
-
push({ type: 'message.complete', sessionId: 'cli:cli:local', ts: 3, text: '结果' });
|
|
93
|
-
push({ type: 'message.delta', sessionId: 'cli:cli:local', ts: 4, text: '…' });
|
|
94
|
-
expect(adapter.typings).toEqual(['cli', 'cli']);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('trajectory.step 不实时输出,idle 时以 trajectory.summary 摘要输出', async () => {
|
|
98
|
-
const adapter = new FakeAdapter('cli');
|
|
99
|
-
const { client, push } = fakeClient();
|
|
100
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
101
|
-
|
|
102
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' });
|
|
103
|
-
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 2, step: { kind: 'thought', label: '分析' } });
|
|
104
|
-
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 3, step: { kind: 'tool', label: 'bash' } });
|
|
105
|
-
expect(adapter.sent).toHaveLength(0); // 单步不推
|
|
106
|
-
|
|
107
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 4, status: 'idle' });
|
|
108
|
-
const summary = adapter.sent.find((s) => s.payload.text.includes('📋 轨迹'));
|
|
109
|
-
expect(summary?.payload.text).toContain('🧠 分析');
|
|
110
|
-
expect(summary?.payload.text).toContain('🛠️ bash');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('/help → HELP_TEXT 原样输出', async () => {
|
|
114
|
-
const adapter = new FakeAdapter('cli');
|
|
115
|
-
const { client } = fakeClient();
|
|
116
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
117
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/help' });
|
|
118
|
-
expect(adapter.sent[0].payload.text).toBe(HELP_TEXT);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('/task 派子任务并回执;/cron 注册定时任务并回执', async () => {
|
|
122
|
-
const adapter = new FakeAdapter('cli');
|
|
123
|
-
const { client, createTasks } = fakeClient();
|
|
124
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
125
|
-
|
|
126
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/task 调研竞品' });
|
|
127
|
-
expect(createTasks).toContainEqual({ sessionId: 'cli:cli:local', kind: 'subagent', prompt: '调研竞品' });
|
|
128
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('🤖 子任务已派出');
|
|
129
|
-
|
|
130
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/cron 0 8 * * * 每日汇报' });
|
|
131
|
-
expect(createTasks).toContainEqual({ sessionId: 'cli:cli:local', kind: 'cron', prompt: '每日汇报', schedule: '0 8 * * *' });
|
|
132
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('⏰ 定时任务已注册');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('/new 走 resetSession 端点并回执;/trace 显示最近摘要,无则提示', async () => {
|
|
136
|
-
const adapter = new FakeAdapter('cli');
|
|
137
|
-
const { client, resets, push } = fakeClient();
|
|
138
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
139
|
-
|
|
140
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/trace' });
|
|
141
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('暂无轨迹');
|
|
142
|
-
|
|
143
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' });
|
|
144
|
-
push({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 2, step: { kind: 'thought', label: '分析' } });
|
|
145
|
-
push({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 3, status: 'idle' });
|
|
146
|
-
|
|
147
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/trace' });
|
|
148
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('🧠 分析');
|
|
149
|
-
|
|
150
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/new' });
|
|
151
|
-
expect(resets).toEqual(['cli:cli:local']);
|
|
152
|
-
expect(adapter.sent.at(-1)!.payload.text).toContain('🆕 会话已重置');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('/agents 返回简化回执', async () => {
|
|
156
|
-
const adapter = new FakeAdapter('cli');
|
|
157
|
-
const { client } = fakeClient();
|
|
158
|
-
await wireAdapter(adapter, client, { allowlist: [], allowAll: true });
|
|
159
|
-
await adapter.emit({ chatId: 'cli', userId: 'local', text: '/agents' });
|
|
160
|
-
expect(adapter.sent[0].payload.text).toContain('/task 派发');
|
|
161
|
-
});
|
|
162
|
-
});
|
package/test/trajectory.test.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { TrajectoryAggregator, formatTrajectorySummary } from '../src/trajectory.js';
|
|
3
|
-
import type { ServerEvent, TrajectoryStep } from '@dsh-overdrive/sdk';
|
|
4
|
-
|
|
5
|
-
// 注意:ServerEvent 联合类型尚无 trajectory.summary(Task 2 协议修改才加入),
|
|
6
|
-
// 故本文件对 summary 事件统一用结构断言 + as 断言,不修改 sdk 协议。
|
|
7
|
-
describe('TrajectoryAggregator', () => {
|
|
8
|
-
it('聚合 turn 内轨迹,turn 结束产出摘要', () => {
|
|
9
|
-
const agg = new TrajectoryAggregator();
|
|
10
|
-
const events: ServerEvent[] = [];
|
|
11
|
-
agg.onEvent({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' }, (ev) => events.push(ev));
|
|
12
|
-
agg.onEvent({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 2, step: { kind: 'thought', label: '分析' } }, (ev) => events.push(ev));
|
|
13
|
-
agg.onEvent({ type: 'trajectory.step', sessionId: 'cli:cli:local', ts: 3, step: { kind: 'tool', label: 'bash' } }, (ev) => events.push(ev));
|
|
14
|
-
agg.onEvent({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 4, status: 'idle' }, (ev) => events.push(ev));
|
|
15
|
-
|
|
16
|
-
const summary = events.find((e) => (e as { type: string }).type === 'trajectory.summary') as
|
|
17
|
-
| { type: 'trajectory.summary'; steps: TrajectoryStep[] }
|
|
18
|
-
| undefined;
|
|
19
|
-
expect(summary).toBeDefined();
|
|
20
|
-
// 直接比较结构(Task 2 的协议变体含 steps,渲染由 formatTrajectorySummary 负责)
|
|
21
|
-
expect(summary!.steps).toEqual([
|
|
22
|
-
{ kind: 'thought', label: '分析' },
|
|
23
|
-
{ kind: 'tool', label: 'bash' },
|
|
24
|
-
]);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('busy 状态在摘要前透传,idle 后清空缓冲', () => {
|
|
28
|
-
const agg = new TrajectoryAggregator();
|
|
29
|
-
const events: ServerEvent[] = [];
|
|
30
|
-
agg.onEvent({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 1, status: 'busy' }, (ev) => events.push(ev));
|
|
31
|
-
expect(events).toHaveLength(1);
|
|
32
|
-
expect(events[0].type).toBe('agent.status');
|
|
33
|
-
|
|
34
|
-
agg.onEvent({ type: 'agent.status', sessionId: 'cli:cli:local', ts: 2, status: 'idle' }, (ev) => events.push(ev));
|
|
35
|
-
expect(events).toHaveLength(2);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('其他事件(message.complete 等)原样透传', () => {
|
|
39
|
-
const agg = new TrajectoryAggregator();
|
|
40
|
-
const events: ServerEvent[] = [];
|
|
41
|
-
agg.onEvent({ type: 'message.complete', sessionId: 's', ts: 1, text: '结果' }, (ev) => events.push(ev));
|
|
42
|
-
expect(events).toEqual([{ type: 'message.complete', sessionId: 's', ts: 1, text: '结果' }]);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('formatTrajectorySummary(摘要卡片渲染,Task 2 接线复用)', () => {
|
|
47
|
-
it('按 kind 渲染图标 + 标签,含步数标题', () => {
|
|
48
|
-
const text = formatTrajectorySummary([
|
|
49
|
-
{ kind: 'thought', label: '分析' },
|
|
50
|
-
{ kind: 'tool', label: 'bash' },
|
|
51
|
-
{ kind: 'subagent', label: '子任务' },
|
|
52
|
-
]);
|
|
53
|
-
expect(text).toContain('🧠 分析');
|
|
54
|
-
expect(text).toContain('🛠️ bash');
|
|
55
|
-
expect(text).toContain('🤖 子任务');
|
|
56
|
-
expect(text).toContain('📋 轨迹(3 步)');
|
|
57
|
-
});
|
|
58
|
-
});
|