@wonanut/dsh-agent-lab 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,800 @@
1
+ /**
2
+ * test/engine.test.mjs · 引擎无头测试
3
+ *
4
+ * 用 node:vm 把 src/00-core.js + 10-engine.js + 20-tools.js + 30-llm.js
5
+ * 按 build.mjs 的顺序拼成一段普通脚本执行,再通过 globalThis.__LAB__ 取回
6
+ * createLab / estimateTokens / LAB_TOOLS 等入口。
7
+ *
8
+ * 运行:node test/engine.test.mjs
9
+ * 输出 PASS / FAIL 行;全部通过 exit 0,任何失败 exit 1。
10
+ *
11
+ * 注意:所有 createLab 都注入 sleep: async () => {},所以测试瞬间完成;
12
+ * 只有「dispose 取消进行中的 turn」用例改用真实的极短 sleep 来制造并发窗口。
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import vm from 'node:vm';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+ const srcDir = path.join(here, '..', 'src');
22
+
23
+ const files = ['00-core.js', '10-engine.js', '20-tools.js', '30-llm.js'];
24
+ const bundle =
25
+ files.map((file) => fs.readFileSync(path.join(srcDir, file), 'utf8')).join('\n\n') +
26
+ '\n;globalThis.__LAB__ = {' +
27
+ ' createLab: createLab, estimateTokens: estimateTokens, LAB_TOOLS: LAB_TOOLS,' +
28
+ ' LAB_BUDGET: LAB_BUDGET, LAB_RECENT_KEEP: LAB_RECENT_KEEP, LAB_MAX_STEPS: LAB_MAX_STEPS,' +
29
+ ' LAB_STORAGE_KEY: LAB_STORAGE_KEY, LAB_SYSTEM_PROMPT: LAB_SYSTEM_PROMPT,' +
30
+ ' LAB_MAX_TRANSCRIPT: LAB_MAX_TRANSCRIPT, LAB_MAX_SESSIONS: LAB_MAX_SESSIONS,' +
31
+ ' LAB_MAX_MESSAGES: LAB_MAX_MESSAGES, LAB_TOOL_SCHEMAS: LAB_TOOL_SCHEMAS' +
32
+ ' };\n';
33
+
34
+ /* vm 的 context 自带 Date / JSON / Math / globalThis 等内置对象;
35
+ * 只需要补上宿主环境里的 console 与定时器即可。 */
36
+ const sandbox = { console, setTimeout, clearTimeout };
37
+ vm.createContext(sandbox);
38
+ new vm.Script(bundle, { filename: 'agent-lab.bundle.vm.js' }).runInContext(sandbox);
39
+
40
+ const LAB = sandbox.__LAB__;
41
+ if (!LAB || typeof LAB.createLab !== 'function') {
42
+ console.error('FAIL 无法从 vm 中取回 __LAB__(引擎脚本加载失败)');
43
+ process.exit(1);
44
+ }
45
+ const {
46
+ createLab,
47
+ estimateTokens,
48
+ LAB_TOOLS,
49
+ LAB_BUDGET,
50
+ LAB_RECENT_KEEP,
51
+ LAB_MAX_STEPS,
52
+ LAB_STORAGE_KEY,
53
+ LAB_SYSTEM_PROMPT,
54
+ LAB_MAX_TRANSCRIPT,
55
+ LAB_MAX_MESSAGES,
56
+ LAB_MAX_SESSIONS,
57
+ LAB_TOOL_SCHEMAS,
58
+ } = LAB;
59
+
60
+ /* ── 测试工具 ── */
61
+
62
+ let passCount = 0;
63
+ let failCount = 0;
64
+ const failures = [];
65
+
66
+ function ok(condition, message) {
67
+ if (!condition) throw new Error(message);
68
+ }
69
+
70
+ function eq(actual, expected, message) {
71
+ if (actual !== expected) {
72
+ throw new Error(message + ':期望 ' + JSON.stringify(expected) + ',实际 ' + JSON.stringify(actual));
73
+ }
74
+ }
75
+
76
+ function includes(haystack, needle, message) {
77
+ if (String(haystack).indexOf(needle) < 0) {
78
+ throw new Error(message + ':未包含 ' + JSON.stringify(needle) + '(实际文本:' + String(haystack).slice(0, 400) + ')');
79
+ }
80
+ }
81
+
82
+ function mockStorage() {
83
+ const map = new Map();
84
+ return {
85
+ getItem(key) {
86
+ return map.has(key) ? map.get(key) : null;
87
+ },
88
+ setItem(key, value) {
89
+ map.set(key, String(value));
90
+ },
91
+ removeItem(key) {
92
+ map.delete(key);
93
+ },
94
+ };
95
+ }
96
+
97
+ function makeLab(extra) {
98
+ return createLab(
99
+ Object.assign({ sleep: async () => {}, storage: mockStorage(), pace: 'fast' }, extra || {}),
100
+ );
101
+ }
102
+
103
+ function transcriptText(snapshot) {
104
+ return snapshot.transcript.map((line) => line.text).join('\n');
105
+ }
106
+
107
+ function lastLineOfKind(snapshot, kind) {
108
+ for (let i = snapshot.transcript.length - 1; i >= 0; i -= 1) {
109
+ if (snapshot.transcript[i].kind === kind) return snapshot.transcript[i].text;
110
+ }
111
+ return '';
112
+ }
113
+
114
+ function linesOfKind(snapshot, kind) {
115
+ return snapshot.transcript.filter((line) => line.kind === kind);
116
+ }
117
+
118
+ function sumSegments(snapshot) {
119
+ return snapshot.context.segments.reduce((total, segment) => total + segment.tokens, 0);
120
+ }
121
+
122
+ async function test(name, fn) {
123
+ try {
124
+ await fn();
125
+ passCount += 1;
126
+ console.log('PASS ' + name);
127
+ } catch (err) {
128
+ failCount += 1;
129
+ failures.push(name + ' · ' + (err && err.message ? err.message : String(err)));
130
+ console.log('FAIL ' + name + ' · ' + (err && err.message ? err.message : String(err)));
131
+ }
132
+ }
133
+
134
+ /* ─────────────────────────────────────────────────────────────
135
+ * 用例 1:常量、token 估算与工具 schema
136
+ * ───────────────────────────────────────────────────────────── */
137
+ await test('token 估算 / 常量 / 工具 schema', async () => {
138
+ eq(estimateTokens(''), 0, '空串 token');
139
+ eq(estimateTokens(null), 0, 'null token');
140
+ eq(estimateTokens(undefined), 0, 'undefined token');
141
+ eq(estimateTokens('你好'), 2, '两个汉字应为 2 token');
142
+ eq(estimateTokens('hello'), 2, '5 个拉丁字符应向上取整为 2 token');
143
+ eq(estimateTokens('a'.repeat(8)), 2, '8 个拉丁字符应为 2 token');
144
+ eq(estimateTokens('中'.repeat(10)), 10, '10 个汉字应为 10 token');
145
+ ok(estimateTokens(LAB_SYSTEM_PROMPT) > 0, 'system prompt token 应大于 0');
146
+
147
+ eq(LAB_BUDGET, 1000, 'LAB_BUDGET 契约值');
148
+ eq(LAB_RECENT_KEEP, 4, 'LAB_RECENT_KEEP 契约值');
149
+ eq(LAB_MAX_STEPS, 4, 'LAB_MAX_STEPS 契约值');
150
+ eq(LAB_STORAGE_KEY, 'dsh.agent-lab.v1', 'LAB_STORAGE_KEY 契约值');
151
+ eq(LAB_MAX_TRANSCRIPT, 200, 'transcript 上限');
152
+ eq(LAB_MAX_MESSAGES, 120, '消息上限');
153
+ eq(LAB_MAX_SESSIONS, 20, '会话上限');
154
+
155
+ eq(LAB_TOOLS.length, 8, '工具数量应为 8');
156
+ eq(
157
+ LAB_TOOLS.map((tool) => tool.name).join(','),
158
+ 'calculator,clock,dice_roll,random_pick,password_generate,unit_convert,memory_write,memory_search',
159
+ '工具名与顺序',
160
+ );
161
+ for (const tool of LAB_TOOLS) {
162
+ ok(typeof tool.name === 'string' && tool.name, '工具 name');
163
+ ok(typeof tool.description === 'string' && tool.description.length > 0, '工具 description');
164
+ eq(tool.parameters && tool.parameters.type, 'object', '工具 parameters.type');
165
+ eq(typeof tool.run, 'function', '工具 run 必须是函数');
166
+ }
167
+ ok(Array.isArray(LAB_TOOL_SCHEMAS) && LAB_TOOL_SCHEMAS.length === 8, 'LAB_TOOL_SCHEMAS 应有 8 项');
168
+ ok(LAB_TOOL_SCHEMAS.every((schema) => schema.run === undefined), 'tool schema 不应携带 run');
169
+ const calc = LAB_TOOLS.find((tool) => tool.name === 'calculator');
170
+ eq(calc.run({ expression: '(12+30)*3' }).display, '126', 'calculator 基本运算');
171
+ eq(calc.run({ expression: '2^10' }).display, '1024', 'calculator 幂运算');
172
+ eq(calc.run({ expression: '2+3*4' }).display, '14', 'calculator 优先级');
173
+ eq(calc.run({ expression: '-(2+3)' }).display, '-5', 'calculator 一元负号');
174
+ eq(calc.run({ expression: '1/0' }).ok, false, 'calculator 除零必须失败');
175
+ includes(calc.run({ expression: '1/0' }).display, '除数', '除零错误信息');
176
+ eq(calc.run({ expression: 'alert(1)' }).ok, false, 'calculator 拒绝非算式');
177
+ eq(calc.run({}).ok, false, 'calculator 缺少表达式必须失败');
178
+ });
179
+
180
+ /* ─────────────────────────────────────────────────────────────
181
+ * 用例 2:算术 → 真实工具调用 → 最终回答
182
+ * ───────────────────────────────────────────────────────────── */
183
+ await test('算术:工具调用链与最终回答', async () => {
184
+ const lab = makeLab();
185
+ const events = [];
186
+ const off = lab.onEvent((event) => events.push(event.type));
187
+ await lab.submit('算一下 (12+30)*3');
188
+ const snap = lab.snapshot();
189
+ const text = transcriptText(snap);
190
+
191
+ /* transcript.text 是纯文本;TUI 会按 kind 加行首符号(⚙ / ←),渲染结果即契约示例 */
192
+ includes(text, 'calculator {"expression":"(12+30)*3"}', 'transcript 应有工具调用行');
193
+ includes(text, '{"expression":"(12+30)*3"}', '工具参数 JSON');
194
+ eq(linesOfKind(snap, 'toolresult')[0].text, '126', 'transcript 应有工具结果行');
195
+ includes(lastLineOfKind(snap, 'agent'), '126', '最终回答应包含 126');
196
+ eq(snap.stats.turns, 1, 'turn 计数');
197
+ eq(snap.stats.toolCalls, 1, '工具调用次数');
198
+ eq(snap.turn.status, 'done', 'turn 状态');
199
+ ok(snap.turn.endedAt !== null, 'turn 应记录结束时间');
200
+ ok(snap.busy === false, '结束后 busy 必须为 false');
201
+
202
+ ok(snap.messages.some((message) => message.role === 'tool'), 'messages 应含 tool 角色');
203
+ ok(
204
+ snap.messages.some((message) => message.role === 'assistant' && message.kind === 'tool_call'),
205
+ 'messages 应含 assistant tool_call',
206
+ );
207
+ ok(
208
+ snap.messages.some((message) => message.role === 'assistant' && message.kind === 'final'),
209
+ 'messages 应含 assistant final',
210
+ );
211
+ ok(
212
+ snap.messages.every((message) => typeof message.tokens === 'number' && message.tokens > 0),
213
+ '每条消息都应有 token 估算',
214
+ );
215
+
216
+ const stageKinds = snap.turn.stages.map((stage) => stage.kind);
217
+ for (const kind of ['input', 'context', 'model', 'tool', 'output']) {
218
+ ok(stageKinds.indexOf(kind) >= 0, 'stages 应包含 ' + kind);
219
+ }
220
+ eq(stageKinds[0], 'input', '第一个 stage 应是 input');
221
+ eq(stageKinds[stageKinds.length - 1], 'output', '最后一个 stage 应是 output');
222
+ const modelStage = snap.turn.stages.find((stage) => stage.kind === 'model');
223
+ ok(modelStage.detail && Array.isArray(modelStage.detail.messages), 'model stage detail.messages 必须是数组');
224
+ eq(modelStage.detail.toolSchemas.length, 8, 'model stage 应带 8 个工具 schema');
225
+ eq(modelStage.detail.step, 1, 'model stage step');
226
+ ok(modelStage.detail.response && modelStage.detail.response.type === 'tool_call', 'model stage response 记录工具调用');
227
+
228
+ eq(sumSegments(snap), snap.context.used, 'context.used 必须等于 segments 之和');
229
+ ok(snap.context.used <= snap.context.limit, 'context.used 不得超过 limit');
230
+ eq(snap.context.segments.length, 5, 'context.segments 应有 5 段');
231
+ eq(
232
+ snap.context.segments.map((segment) => segment.id).join(','),
233
+ 'system,summary,memory,recent,input',
234
+ 'segment 顺序与 id',
235
+ );
236
+
237
+ for (const type of ['turn_start', 'stage', 'context', 'line', 'message', 'tool_call', 'tool_result', 'turn_end']) {
238
+ ok(events.indexOf(type) >= 0, '事件流应包含 ' + type);
239
+ }
240
+ ok(events.indexOf('token') >= 0, '最终回答应通过 token 事件流式输出');
241
+ ok(events.indexOf('state') >= 0, 'turn 结束应发出 state');
242
+ off();
243
+ const before = events.length;
244
+ await lab.submit('帮助');
245
+ eq(events.length, before, '退订后不应再收到事件');
246
+ });
247
+
248
+ /* ─────────────────────────────────────────────────────────────
249
+ * 用例 3:多步任务 —— 先计算,再记住结果(两次真实工具调用)
250
+ * ───────────────────────────────────────────────────────────── */
251
+ await test('多步:计算后记住结果(两次工具调用)', async () => {
252
+ const lab = makeLab();
253
+ await lab.submit('算一下 (12+30)*3 然后记住结果');
254
+ const snap = lab.snapshot();
255
+ const text = transcriptText(snap);
256
+ eq(snap.stats.toolCalls, 2, '应发生两次工具调用');
257
+ eq(linesOfKind(snap, 'tool').length, 2, '应有两行工具调用');
258
+ eq(linesOfKind(snap, 'toolresult').length, 2, '应有两行工具结果');
259
+ includes(text, 'calculator {"expression":"(12+30)*3"}', '第一步调用 calculator');
260
+ includes(text, 'memory_write {"key":"计算结果","value":"126"}', '第二步调用 memory_write');
261
+ includes(text, '记忆已保存 · 计算结果 = 126', '应有记忆写入行');
262
+ includes(text, '126', '最终回答应提到 126');
263
+ const mem = snap.memory.find((item) => item.key === '计算结果');
264
+ ok(mem && mem.value === '126', '长期记忆应保存 计算结果 = 126');
265
+ const modelStages = snap.turn.stages.filter((stage) => stage.kind === 'model');
266
+ eq(modelStages.length, 3, '应为 3 个模型步(计算 → 记忆 → 回答)');
267
+ const memoryLines = linesOfKind(snap, 'memory');
268
+ eq(memoryLines.length, 1, '应有一行 memory transcript');
269
+ eq(snap.turn.status, 'done', 'turn 状态');
270
+ });
271
+
272
+ /* ─────────────────────────────────────────────────────────────
273
+ * 用例 4:记忆跨会话共享
274
+ * ───────────────────────────────────────────────────────────── */
275
+ await test('记忆:写入、跨会话召回、会话隔离', async () => {
276
+ const lab = makeLab();
277
+ await lab.submit('记住 我的项目代号是 Orion');
278
+ let snap = lab.snapshot();
279
+ includes(transcriptText(snap), '记忆已保存 · 项目代号 = Orion', '记忆写入行格式');
280
+ const memoryItem = snap.memory.find((item) => item.key === '项目代号');
281
+ ok(memoryItem && memoryItem.value === 'Orion', 'memory 应保存 项目代号 = Orion');
282
+ ok(typeof memoryItem.source === 'string' && memoryItem.source.length > 0, 'memory.source 应存在');
283
+ const oldSessionId = snap.activeId;
284
+ const oldMessages = snap.messages.length;
285
+
286
+ ok(lab.newSession() === true, 'newSession 应成功');
287
+ snap = lab.snapshot();
288
+ eq(snap.messages.length, 0, '新会话消息应隔离');
289
+ ok(snap.activeId !== oldSessionId, '新会话 activeId 应不同');
290
+ eq(snap.memory.length, 1, '长期记忆应跨会话共享');
291
+ eq(snap.sessions.length, 2, '应有两个会话');
292
+
293
+ await lab.submit('我的项目代号是什么');
294
+ snap = lab.snapshot();
295
+ includes(lastLineOfKind(snap, 'agent'), 'Orion', '跨会话回忆应包含 Orion');
296
+ ok(
297
+ linesOfKind(snap, 'tool').some((line) => line.text.indexOf('memory_search') === 0),
298
+ '回忆应调用 memory_search',
299
+ );
300
+ ok(snap.context.hits.length >= 1, '本轮 context.hits 应命中长期记忆');
301
+ eq(snap.context.hits[0].key, '项目代号', '召回记忆的 key');
302
+
303
+ ok(lab.switchSession(oldSessionId) === true, 'switchSession 应成功');
304
+ snap = lab.snapshot();
305
+ eq(snap.activeId, oldSessionId, '切换后 activeId');
306
+ eq(snap.messages.length, oldMessages, '切换后恢复原会消息');
307
+ });
308
+
309
+ /* ─────────────────────────────────────────────────────────────
310
+ * 用例 5:自动压缩
311
+ * ───────────────────────────────────────────────────────────── */
312
+ await test('压缩:连续 12 轮触发自动压缩', async () => {
313
+ const lab = makeLab({ budget: 420 });
314
+ const events = [];
315
+ const off = lab.onEvent((event) => events.push(event.type));
316
+ for (let i = 0; i < 12; i += 1) await lab.submit('你好');
317
+ const snap = lab.snapshot();
318
+ const text = transcriptText(snap);
319
+ ok(snap.stats.compressions > 0, '应触发自动压缩(compressions > 0)');
320
+ ok(snap.stats.compressions <= 12, '压缩次数应与轮数同量级,不能每步反复压缩');
321
+ ok(snap.context.summary && snap.context.summary.length > 0, 'context.summary 应非空');
322
+ includes(snap.context.summary, '【关键内容】', '结构化摘要应包含关键内容');
323
+ includes(snap.context.summary, '【未完成任务】', '结构化摘要应包含未完成任务');
324
+ includes(snap.context.summary, '【下一个任务】', '结构化摘要应包含下一个任务');
325
+ ok(snap.context.used <= snap.context.limit, '压缩后 context.used 不得超过 limit');
326
+ eq(sumSegments(snap), snap.context.used, 'context.used 必须等于 segments 之和');
327
+ includes(text, '上下文已压缩 · ', 'transcript 应有压缩行');
328
+ ok(events.indexOf('compression') >= 0, '应发出 compression 事件');
329
+ eq(snap.stats.turns, 12, '应完成 12 轮');
330
+ ok(snap.sessions[0].messageCount <= LAB_MAX_MESSAGES, '会话消息数不超过上限');
331
+ ok(snap.transcript.length <= LAB_MAX_TRANSCRIPT, 'transcript 不超过上限');
332
+ ok(snap.stats.tokensSaved > 0, '压缩应真实节省 token');
333
+ /* 压缩行数字:before > after */
334
+ const compressLines = linesOfKind(snap, 'compress');
335
+ ok(compressLines.length > 0, '至少有 1 行压缩记录');
336
+ const ratio = /上下文已压缩 · (\d+) → (\d+) tok/.exec(compressLines[compressLines.length - 1].text);
337
+ ok(ratio && Number(ratio[1]) > Number(ratio[2]), '压缩行应为 before > after');
338
+ off();
339
+ });
340
+
341
+ /* ─────────────────────────────────────────────────────────────
342
+ * 用例 6:会话新建 / 切换 / 删除
343
+ * ───────────────────────────────────────────────────────────── */
344
+ await test('会话:newSession / switchSession / deleteSession', async () => {
345
+ const lab = makeLab();
346
+ await lab.submit('记住 我的项目代号是 Orion');
347
+ await lab.submit('你好');
348
+ let snap = lab.snapshot();
349
+ const firstId = snap.activeId;
350
+ const firstMessageCount = snap.messages.length;
351
+ ok(firstMessageCount > 0, '第一个会话应有消息');
352
+
353
+ eq(lab.newSession(), true, 'newSession 返回 true');
354
+ snap = lab.snapshot();
355
+ eq(snap.sessions.length, 2, '应有 2 个会话');
356
+ eq(snap.messages.length, 0, '新会话无消息');
357
+ await lab.submit('帮助');
358
+ snap = lab.snapshot();
359
+ const secondId = snap.activeId;
360
+ ok(snap.messages.length > 0, '第二个会话应有消息');
361
+
362
+ eq(lab.switchSession(firstId), true, 'switchSession 返回 true');
363
+ snap = lab.snapshot();
364
+ eq(snap.activeId, firstId, '切回第一个会话');
365
+ eq(snap.messages.length, firstMessageCount, '恢复原会话消息数');
366
+ eq(lab.switchSession('not-exist'), false, '切换不存在的会话返回 false');
367
+ eq(lab.switchSession(secondId), true, '再切到第二个会话');
368
+
369
+ eq(lab.deleteSession(firstId), true, 'deleteSession 返回 true');
370
+ snap = lab.snapshot();
371
+ eq(snap.sessions.length, 1, '删除后剩 1 个会话');
372
+ eq(snap.activeId, secondId, '未删当前会话时 activeId 不变');
373
+ eq(lab.deleteSession('not-exist'), false, '删除不存在的会话返回 false');
374
+
375
+ eq(lab.deleteSession(secondId), true, '删除当前会话');
376
+ snap = lab.snapshot();
377
+ eq(snap.sessions.length, 1, '删掉当前会话后自动新建');
378
+ ok(snap.activeId !== secondId, '应切到新会话');
379
+ eq(snap.messages.length, 0, '新会话无消息');
380
+ eq(snap.memory.length, 1, '记忆仍共享');
381
+ eq(lab.newSession(), true, '可继续新建会话');
382
+ });
383
+
384
+ /* ─────────────────────────────────────────────────────────────
385
+ * 用例 7:持久化跨实例恢复
386
+ * ───────────────────────────────────────────────────────────── */
387
+ await test('持久化:dispose 后同 storage 重建可恢复', async () => {
388
+ const storage = mockStorage();
389
+ const lab1 = createLab({ sleep: async () => {}, storage, pace: 'fast' });
390
+ await lab1.submit('记住 我的项目代号是 Orion');
391
+ await lab1.submit('算一下 1+1');
392
+ const before = lab1.snapshot();
393
+ ok(before.sessions.length >= 1, '重建前应有会话');
394
+ ok(before.messages.length > 0, '重建前应有消息');
395
+ ok(before.memory.length >= 1, '重建前应有记忆');
396
+ lab1.dispose();
397
+
398
+ const lab2 = createLab({ sleep: async () => {}, storage, pace: 'fast' });
399
+ const after = lab2.snapshot();
400
+ eq(after.sessions.length, before.sessions.length, '恢复会话数量');
401
+ eq(after.activeId, before.activeId, '恢复 activeId');
402
+ eq(after.memory.length, before.memory.length, '恢复长期记忆数量');
403
+ eq(after.messages.length, before.messages.length, '恢复消息数量');
404
+ eq(after.stats.turns, before.stats.turns, '恢复 stats.turns');
405
+ eq(after.transcript[0].kind, 'banner', '恢复后首行仍是 banner');
406
+ includes(transcriptText(after), '记住 我的项目代号是 Orion', '恢复后 transcript 仍在');
407
+ const mem = after.memory.find((item) => item.key === '项目代号');
408
+ ok(mem && mem.value === 'Orion', '恢复后记忆内容正确');
409
+
410
+ await lab2.submit('我的项目代号是什么');
411
+ includes(lastLineOfKind(lab2.snapshot(), 'agent'), 'Orion', '恢复后仍可召回记忆');
412
+ lab2.dispose();
413
+ });
414
+
415
+ /* ─────────────────────────────────────────────────────────────
416
+ * 用例 8:手动 compact
417
+ * ───────────────────────────────────────────────────────────── */
418
+ await test('手动 compact:折叠历史 / 空会话 info 行', async () => {
419
+ const lab = makeLab();
420
+ await lab.submit('你好');
421
+ await lab.submit('帮助');
422
+ await lab.submit('算一下 1+1');
423
+ const before = lab.snapshot().stats.compressions;
424
+ eq(lab.compact(), true, '手动 compact 应返回 true');
425
+ const snap = lab.snapshot();
426
+ ok(snap.stats.compressions >= before + 1, '手动压缩应增加压缩次数');
427
+ ok(snap.context.summary && snap.context.summary.length > 0, '手动压缩后 summary 非空');
428
+ includes(transcriptText(snap), '上下文已压缩 · ', '手动压缩应写 transcript 行');
429
+ const manualLine = linesOfKind(snap, 'compress').pop();
430
+ const manualMatch = /上下文已压缩 · (\d+) → (\d+) tok/.exec(manualLine.text);
431
+ ok(manualMatch && Number(manualMatch[2]) < Number(manualMatch[1]), '手动 compress 行也应 after < before:' + manualLine.text);
432
+ ok(snap.context.used <= snap.context.limit, '手动压缩后 used <= limit');
433
+
434
+ const fresh = makeLab();
435
+ const freshBefore = fresh.snapshot().transcript.length;
436
+ eq(fresh.compact(), false, '空会话 compact 返回 false');
437
+ const freshSnap = fresh.snapshot();
438
+ eq(freshSnap.stats.compressions, 0, '空会话不增加压缩次数');
439
+ eq(freshSnap.transcript.length, freshBefore + 1, '空会话 compact 只加一行 info');
440
+ includes(transcriptText(freshSnap), '消息太少', '空会话 compact 的 info 文案');
441
+ });
442
+
443
+ /* ─────────────────────────────────────────────────────────────
444
+ * 用例 9:工具失败与时钟 / 查不到记忆
445
+ * ───────────────────────────────────────────────────────────── */
446
+ await test('工具失败与时钟 / 查不到记忆的降级', async () => {
447
+ const lab = makeLab();
448
+ await lab.submit('计算 1/0');
449
+ let snap = lab.snapshot();
450
+ eq(linesOfKind(snap, 'toolresult')[0].text, '错误:除数不能为 0', '除零应产生错误结果行');
451
+ includes(lastLineOfKind(snap, 'agent'), '算不出来', '最终回答应明确提到失败');
452
+ eq(snap.turn.status, 'done', '工具失败不应让 turn 崩溃');
453
+ eq(snap.stats.toolCalls, 1, '失败工具也计入调用次数');
454
+
455
+ await lab.submit('计算 1 + 2 * 3');
456
+ snap = lab.snapshot();
457
+ includes(lastLineOfKind(snap, 'agent'), '7', '算术回答');
458
+
459
+ await lab.submit('现在几点?');
460
+ snap = lab.snapshot();
461
+ ok(
462
+ linesOfKind(snap, 'tool').some((line) => line.text.indexOf('clock') === 0),
463
+ '时间意图应调用 clock',
464
+ );
465
+ includes(lastLineOfKind(snap, 'agent'), '现在是', '时间回答');
466
+
467
+ await lab.submit('我的项目代号是什么');
468
+ snap = lab.snapshot();
469
+ includes(lastLineOfKind(snap, 'agent'), '没有找到', '无记忆时应明确说没有');
470
+ });
471
+
472
+ /* ─────────────────────────────────────────────────────────────
473
+ * 用例 10:意图边界(截止日期不误触 clock / 泛化回忆 / X 是 Y)
474
+ * ───────────────────────────────────────────────────────────── */
475
+ await test('意图边界:截止日期 / 泛化回忆 / X 是 Y', async () => {
476
+ const lab = makeLab();
477
+ await lab.submit('记住 截止日期 = 周五');
478
+ let snap = lab.snapshot();
479
+ ok(
480
+ snap.memory.some((item) => item.key === '截止日期' && item.value === '周五'),
481
+ '「截止日期」应写入记忆,而不是触发 clock',
482
+ );
483
+ eq(
484
+ linesOfKind(snap, 'tool').filter((line) => line.text.indexOf('clock') === 0).length,
485
+ 0,
486
+ '「截止日期」不应调用 clock',
487
+ );
488
+ includes(transcriptText(snap), '记忆已保存 · 截止日期 = 周五', '记忆写入行');
489
+
490
+ await lab.submit('记住 项目 是 阿波罗');
491
+ snap = lab.snapshot();
492
+ ok(
493
+ snap.memory.some((item) => item.key === '项目' && item.value === '阿波罗'),
494
+ '「X 是 Y」应解析出 key/value',
495
+ );
496
+
497
+ await lab.submit('你还记得什么');
498
+ const agent = lastLineOfKind(lab.snapshot(), 'agent');
499
+ includes(agent, '截止日期', '泛化回忆应列出长期记忆');
500
+ includes(agent, '阿波罗', '泛化回忆应列出长期记忆');
501
+ });
502
+
503
+ /* ─────────────────────────────────────────────────────────────
504
+ * 用例 11:snapshot 不可变 / 事件 / dispose
505
+ * ───────────────────────────────────────────────────────────── */
506
+ await test('snapshot 不可变、事件订阅、dispose 取消 turn', async () => {
507
+ const lab = makeLab();
508
+ const first = lab.snapshot();
509
+ const second = lab.snapshot();
510
+ ok(first !== second, 'snapshot 每次返回新对象');
511
+ eq(JSON.stringify(first), JSON.stringify(second), '未变化时两次 snapshot 内容一致');
512
+ ok(first.sessions !== second.sessions, 'snapshot 内部数组也应新建');
513
+ ok(first.transcript !== second.transcript, 'transcript 数组也应新建');
514
+ eq(first.busy, false, '空闲时 busy=false');
515
+ eq(first.turn, null, '空闲时 turn=null');
516
+ eq(first.streaming, null, '空闲时 streaming=null');
517
+ eq(first.pace, 'fast', 'pace 来自 options');
518
+ eq(first.transcript[0].kind, 'banner', '首行是 banner');
519
+ eq(
520
+ first.transcript[0].text,
521
+ 'Agent Lab v0.1 · 模拟 / 真实 DeepSeek 双模式 · 输入 /help 查看命令',
522
+ 'banner 文案',
523
+ );
524
+ eq(first.stats.turns, 0, '初始 turns=0');
525
+ eq(first.stats.toolCalls, 0, '初始 toolCalls=0');
526
+ eq(first.context.used, sumSegments(first), '初始 context.used 一致');
527
+ ok(first.context.used <= first.context.limit, '初始 context.used <= limit');
528
+ eq(first.context.limit, 1000, '默认预算 1000');
529
+
530
+ /* dispose 取消进行中的 turn:用真实但极短的 sleep 制造并发窗口 */
531
+ const slow = createLab({
532
+ sleep: () => new Promise((resolve) => setTimeout(resolve, 1)),
533
+ storage: mockStorage(),
534
+ pace: 'fast',
535
+ });
536
+ const pending = slow.submit('算一下 (12+30)*3');
537
+ ok(slow.snapshot().busy === true, '提交后立即 busy=true');
538
+ slow.dispose();
539
+ await pending;
540
+ const afterDispose = slow.snapshot();
541
+ eq(afterDispose.busy, false, 'dispose 后 busy=false');
542
+ ok(afterDispose.turn && afterDispose.turn.status !== 'running', 'dispose 后 turn 不再是 running');
543
+ ok(Array.isArray(afterDispose.sessions) && afterDispose.sessions.length >= 1, 'dispose 后 snapshot 仍可用');
544
+ const messagesBefore = afterDispose.messages.length;
545
+ await slow.submit('你好');
546
+ eq(slow.snapshot().messages.length, messagesBefore, 'dispose 后 submit 不应再产生消息');
547
+
548
+ lab.setPace('slow');
549
+ eq(lab.snapshot().pace, 'slow', 'setPace 生效');
550
+ const off = lab.onEvent(() => {
551
+ throw new Error('UI 回调异常不应影响引擎');
552
+ });
553
+ await lab.submit('你好');
554
+ off();
555
+ eq(lab.snapshot().turn.status, 'done', 'UI 回调抛异常时引擎仍完成 turn');
556
+ });
557
+
558
+ /* ─────────────────────────────────────────────────────────────
559
+ * 用例 12:并发 submit 拒绝重复 + 空输入
560
+ * ───────────────────────────────────────────────────────────── */
561
+ await test('并发 submit 拒绝重复、空输入直接 resolve', async () => {
562
+ const lab = makeLab();
563
+ const first = lab.submit('你好');
564
+ const second = lab.submit('帮助');
565
+ await first;
566
+ await second;
567
+ const snap = lab.snapshot();
568
+ eq(snap.stats.turns, 1, '并发 submit 只应执行一轮');
569
+ eq(snap.messages.filter((message) => message.role === 'user').length, 1, '只应有一条 user 消息');
570
+ eq(linesOfKind(snap, 'user').length, 1, '只应有一条 user transcript');
571
+ eq(snap.turn.input, '你好', '执行的是第一个输入');
572
+
573
+ const turnsBefore = snap.stats.turns;
574
+ const messagesBefore = snap.messages.length;
575
+ await lab.submit(' ');
576
+ await lab.submit('');
577
+ await lab.submit(null);
578
+ const after = lab.snapshot();
579
+ eq(after.stats.turns, turnsBefore, '空输入不应产生 turn');
580
+ eq(after.messages.length, messagesBefore, '空输入不应产生消息');
581
+ });
582
+
583
+ /* ─────────────────────────────────────────────────────────────
584
+ * 用例 13:Snapshot 结构与字段完整性
585
+ * ───────────────────────────────────────────────────────────── */
586
+ await test('Snapshot 字段完整性与一致性', async () => {
587
+ const lab = makeLab();
588
+ await lab.submit('算一下 (12+30)*3');
589
+ const snap = lab.snapshot();
590
+
591
+ for (const key of [
592
+ 'busy',
593
+ 'pace',
594
+ 'activeId',
595
+ 'sessions',
596
+ 'messages',
597
+ 'transcript',
598
+ 'memory',
599
+ 'context',
600
+ 'turn',
601
+ 'streaming',
602
+ 'stats',
603
+ 'lastInput',
604
+ ]) {
605
+ ok(Object.prototype.hasOwnProperty.call(snap, key), 'snapshot 缺少字段 ' + key);
606
+ }
607
+ eq(snap.lastInput, '算一下 (12+30)*3', 'lastInput');
608
+ eq(snap.sessions[0].id, snap.activeId, 'sessions[0] 是 active');
609
+ for (const key of [
610
+ 'id',
611
+ 'title',
612
+ 'createdAt',
613
+ 'updatedAt',
614
+ 'messageCount',
615
+ 'tokens',
616
+ 'compressions',
617
+ 'turns',
618
+ ]) {
619
+ ok(Object.prototype.hasOwnProperty.call(snap.sessions[0], key), 'session 缺少字段 ' + key);
620
+ }
621
+ for (const message of snap.messages) {
622
+ for (const key of ['id', 'role', 'content', 'tokens', 'at']) {
623
+ ok(Object.prototype.hasOwnProperty.call(message, key), 'message 缺少字段 ' + key);
624
+ }
625
+ ok(['user', 'assistant', 'tool', 'system'].indexOf(message.role) >= 0, 'message.role 合法');
626
+ }
627
+ for (const line of snap.transcript) {
628
+ for (const key of ['id', 'kind', 'text', 'at']) {
629
+ ok(Object.prototype.hasOwnProperty.call(line, key), 'transcript 行缺少字段 ' + key);
630
+ }
631
+ }
632
+ for (const segment of snap.context.segments) {
633
+ for (const key of ['id', 'label', 'tokens']) {
634
+ ok(Object.prototype.hasOwnProperty.call(segment, key), 'segment 缺少字段 ' + key);
635
+ }
636
+ eq(typeof segment.label, 'string', 'segment.label 是字符串');
637
+ }
638
+ for (const stage of snap.turn.stages) {
639
+ for (const key of ['id', 'kind', 'title', 'meta', 'status', 'detail', 'at']) {
640
+ ok(Object.prototype.hasOwnProperty.call(stage, key), 'stage 缺少字段 ' + key);
641
+ }
642
+ ok(['active', 'done', 'error'].indexOf(stage.status) >= 0, 'stage.status 合法');
643
+ ok(['input', 'context', 'compress', 'model', 'tool', 'memory', 'output'].indexOf(stage.kind) >= 0, 'stage.kind 合法');
644
+ }
645
+ eq(snap.turn.stages.every((stage) => stage.status !== 'active'), true, '结束后不应有 active stage');
646
+ eq(typeof snap.turn.startedAt, 'number', 'turn.startedAt');
647
+ ok(typeof snap.turn.endedAt === 'number', 'turn.endedAt');
648
+ for (const key of ['turns', 'toolCalls', 'compressions', 'tokensSaved', 'totalTokens']) {
649
+ ok(typeof snap.stats[key] === 'number', 'stats.' + key + ' 应为数字');
650
+ }
651
+ ok(snap.stats.totalTokens > 0, 'totalTokens 应累计模型消耗');
652
+ });
653
+
654
+ /* ─────────────────────────────────────────────────────────────
655
+ * 用例 14:记忆 API / resetAll / 斜杠命令
656
+ * ───────────────────────────────────────────────────────────── */
657
+ await test('记忆 API / resetAll / 斜杠命令映射', async () => {
658
+ const storage = mockStorage();
659
+ const lab = createLab({ sleep: async () => {}, storage, pace: 'fast' });
660
+ await lab.submit('记住 我的项目代号是 Orion');
661
+ let snap = lab.snapshot();
662
+ eq(snap.memory.length, 1, '写入 1 条记忆');
663
+ const memoryId = snap.memory[0].id;
664
+ eq(lab.deleteMemory('not-exist'), false, '删除不存在的记忆返回 false');
665
+ eq(lab.deleteMemory(memoryId), true, 'deleteMemory 返回 true');
666
+ eq(lab.snapshot().memory.length, 0, '删除后记忆为空');
667
+ await lab.submit('记住 我的爱好是潜水');
668
+ eq(lab.snapshot().memory.length, 1, '再次写入记忆');
669
+ eq(lab.clearMemory(), 1, 'clearMemory 返回清空条数');
670
+ eq(lab.snapshot().memory.length, 0, '清空后记忆为空');
671
+
672
+ await lab.submit('/help');
673
+ includes(lastLineOfKind(lab.snapshot(), 'agent'), '算一下', 'submit(/help) 应直接回答帮助');
674
+ await lab.submit('/compact');
675
+ includes(lastLineOfKind(lab.snapshot(), 'agent'), '压缩', 'submit(/compact) 应解释压缩');
676
+
677
+ eq(lab.resetAll(), true, 'resetAll 返回 true');
678
+ snap = lab.snapshot();
679
+ eq(snap.messages.length, 0, 'resetAll 清空消息');
680
+ eq(snap.memory.length, 0, 'resetAll 清空记忆');
681
+ eq(snap.sessions.length, 1, 'resetAll 重建一个会话');
682
+ eq(snap.stats.turns, 0, 'resetAll 清零统计');
683
+ eq(snap.transcript[0].kind, 'banner', 'resetAll 后首行仍是 banner');
684
+ eq(snap.busy, false, 'resetAll 后空闲');
685
+ const lab2 = createLab({ sleep: async () => {}, storage, pace: 'fast' });
686
+ eq(lab2.snapshot().memory.length, 0, 'resetAll 清空持久化记忆');
687
+ eq(lab2.snapshot().messages.length, 0, 'resetAll 清空持久化消息');
688
+ lab.dispose();
689
+ lab2.dispose();
690
+ });
691
+
692
+ /* ─────────────────────────────────────────────────────────────
693
+ * 用例 15:max steps 未收敛 → error 行 + turn error
694
+ * ───────────────────────────────────────────────────────────── */
695
+ await test('max steps:超过 LAB_MAX_STEPS 未收敛转为错误', async () => {
696
+ /* 通过替换 vm 全局里的 labNextStep 制造一个「永远要调工具」的模型 */
697
+ const originalLabNextStep = sandbox.labNextStep;
698
+ ok(typeof originalLabNextStep === 'function', 'vm 中应能取到 labNextStep');
699
+ sandbox.labNextStep = function () {
700
+ return { type: 'tool_call', name: 'clock', args: {} };
701
+ };
702
+ try {
703
+ const lab = makeLab();
704
+ const events = [];
705
+ lab.onEvent((event) => events.push(event.type));
706
+ await lab.submit('你好');
707
+ const snap = lab.snapshot();
708
+ eq(snap.turn.status, 'error', '未收敛的 turn 应为 error');
709
+ eq(snap.stats.toolCalls, LAB_MAX_STEPS, '每个模型步都执行了工具');
710
+ eq(linesOfKind(snap, 'error').length >= 1, true, '应有 error transcript 行');
711
+ includes(transcriptText(snap), '最大步数', '错误文案应提到最大步数');
712
+ ok(events.indexOf('error') >= 0, '应发出 error 事件');
713
+ ok(events.indexOf('turn_end') >= 0, 'turn_end 仍应发出');
714
+ eq(snap.busy, false, '结束后 busy=false');
715
+ eq(snap.stats.turns, 1, '失败 turn 也计入 turns');
716
+ } finally {
717
+ sandbox.labNextStep = originalLabNextStep;
718
+ }
719
+ /* 恢复后引擎仍正常 */
720
+ const lab = makeLab();
721
+ await lab.submit('你好');
722
+ eq(lab.snapshot().turn.status, 'done', '恢复 labNextStep 后 turn 正常');
723
+ });
724
+
725
+ /* ─────────────────────────────────────────────────────────────
726
+ * 用例 16:进行中 compact 不打断 turn
727
+ * ───────────────────────────────────────────────────────────── */
728
+ await test('进行中 compact:只加 info 行、不打断 turn', async () => {
729
+ const lab = createLab({
730
+ sleep: () => new Promise((resolve) => setTimeout(resolve, 1)),
731
+ storage: mockStorage(),
732
+ pace: 'fast',
733
+ });
734
+ const pending = lab.submit('你好');
735
+ eq(lab.compact(), false, 'busy 时 compact 返回 false');
736
+ await pending;
737
+ const snap = lab.snapshot();
738
+ includes(transcriptText(snap), '本轮还在进行', '忙碌时 compact 给出 info 行');
739
+ eq(snap.turn.status, 'done', '原 turn 应正常完成');
740
+ });
741
+
742
+ /* ─────────────────────────────────────────────────────────────
743
+ * 用例 17:回归 —— 压缩只发生在 turn 的 context 阶段(工具轮不压缩)
744
+ * ───────────────────────────────────────────────────────────── */
745
+ await test('压缩只发生在 context 阶段(含工具轮)且 after < before', async () => {
746
+ const lab = makeLab({ budget: 420 });
747
+ const inputs = [
748
+ '你好',
749
+ '算一下 (12+30)*3 然后记住结果',
750
+ '记住 我的爱好是潜水',
751
+ '现在几点,记下来',
752
+ '我的爱好是什么',
753
+ '帮助',
754
+ '1+1',
755
+ '你有哪些工具',
756
+ ];
757
+ for (let i = 0; i < inputs.length; i += 1) {
758
+ await lab.submit(inputs[i]);
759
+ const snap = lab.snapshot();
760
+ eq(snap.turn.status, 'done', '第 ' + (i + 1) + ' 轮应正常完成');
761
+ ok(snap.context.used <= snap.context.limit, '第 ' + (i + 1) + ' 轮 context.used <= limit');
762
+ const stages = snap.turn.stages;
763
+ const firstModelIndex = stages.findIndex((stage) => stage.kind === 'model');
764
+ ok(firstModelIndex >= 0, '每轮都应有 model stage');
765
+ stages.forEach((stage, index) => {
766
+ if (stage.kind !== 'compress') return;
767
+ ok(
768
+ index < firstModelIndex,
769
+ '第 ' + (i + 1) + ' 轮:compress stage 必须在第一次 model 调用之前(实际位置 ' + index + ' / model ' + firstModelIndex + ')',
770
+ );
771
+ });
772
+ /* 工具 stage 的 meta 不应出现无意义的「0 ms」 */
773
+ stages.forEach((stage) => {
774
+ if (stage.kind !== 'tool') return;
775
+ ok(stage.meta.indexOf(' 0 ms') < 0, 'tool stage meta 不应显示 0 ms(实际:' + stage.meta + ')');
776
+ });
777
+ }
778
+ const snap = lab.snapshot();
779
+ ok(snap.stats.compressions > 0, '8 轮混合输入应至少触发一次自动压缩');
780
+ const compressLines = linesOfKind(snap, 'compress');
781
+ ok(compressLines.length > 0, '应有 compress transcript 行');
782
+ compressLines.forEach((line) => {
783
+ const matched = /上下文已压缩 · (\d+) → (\d+) tok/.exec(line.text);
784
+ ok(matched, '压缩行格式:' + line.text);
785
+ ok(Number(matched[2]) < Number(matched[1]), '自动压缩必须 after < before:' + line.text);
786
+ });
787
+ });
788
+
789
+ /* ─────────────────────────────────────────────────────────────
790
+ * 汇总
791
+ * ───────────────────────────────────────────────────────────── */
792
+ console.log('\n' + '─'.repeat(56));
793
+ if (failCount === 0) {
794
+ console.log(passCount + ' PASS · 0 FAIL · 全部通过 (' + (passCount + failCount) + ' 项)');
795
+ process.exitCode = 0;
796
+ } else {
797
+ console.log(passCount + ' PASS · ' + failCount + ' FAIL (' + (passCount + failCount) + ' 项)');
798
+ for (const failure of failures) console.log(' ✗ ' + failure);
799
+ process.exitCode = 1;
800
+ }