foliko 2.0.31 → 2.0.32
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/.editorconfig +56 -56
- package/.lintstagedrc +7 -7
- package/.prettierignore +29 -29
- package/.prettierrc +11 -11
- package/Dockerfile +63 -63
- package/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +1 -1
- package/plugins/core/workflow/context.js +941 -941
- package/plugins/core/workflow/engine.js +66 -66
- package/plugins/core/workflow/js-runner.js +318 -318
- package/plugins/core/workflow/json-runner.js +323 -323
- package/plugins/core/workflow/stages/choice.js +74 -74
- package/plugins/core/workflow/stages/each.js +123 -123
- package/plugins/core/workflow/stages/parallel.js +69 -69
- package/skills/find-skills/SKILL.md +133 -133
- package/src/agent/chat.js +31 -9
- package/src/agent/sub.js +17 -3
- package/src/agent/tool-loop.js +119 -25
- package/src/common/atomic-write.js +33 -0
- package/src/context/compressor.js +22 -18
- package/src/framework/framework.js +19 -1
- package/src/plugin/manager.js +12 -2
- package/src/session/session.js +9 -4
- package/src/storage/manager.js +3 -1
- package/tests/core/chat-tool.test.js +187 -187
- package/tests/core/latest-message-not-filtered.test.js +143 -0
- package/tests/core/p0-fixes.test.js +129 -0
- package/tests/core/p1-fixes.test.js +76 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 原子写文件:写 tmp → fsync → rename。
|
|
7
|
+
*
|
|
8
|
+
* 直接 fs.writeFileSync 会先把目标文件截断再写;进程被杀 / 断电落在中途时,
|
|
9
|
+
* 磁盘上会留下被截断的半文件(甚至 0 字节),加载时按行解析要么整段丢、
|
|
10
|
+
* 要么把坏行静默跳过 → 静默丢数据。
|
|
11
|
+
*
|
|
12
|
+
* rename 在同一文件系统上是原子的:崩溃时要么看到旧文件、要么看到完整新文件,
|
|
13
|
+
* 不会出现半截。fsync 进一步保证内容真正落盘(防 rename 先于数据落盘的重排)。
|
|
14
|
+
*
|
|
15
|
+
* @param {string} filePath 目标路径
|
|
16
|
+
* @param {string|Buffer} content 完整内容
|
|
17
|
+
*/
|
|
18
|
+
function atomicWriteFileSync(filePath, content) {
|
|
19
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
20
|
+
let fd;
|
|
21
|
+
try {
|
|
22
|
+
fd = fs.openSync(tmp, 'w');
|
|
23
|
+
fs.writeFileSync(fd, content);
|
|
24
|
+
try { fs.fsyncSync(fd); } catch { /* 某些文件系统/平台不支持 fsync,忽略 */ }
|
|
25
|
+
} finally {
|
|
26
|
+
if (fd !== undefined) {
|
|
27
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
fs.renameSync(tmp, filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { atomicWriteFileSync };
|
|
@@ -194,9 +194,10 @@ class ContextCompressor {
|
|
|
194
194
|
this._compactionSettings = config.compactionSettings || DEFAULT_COMPACTION_SETTINGS;
|
|
195
195
|
|
|
196
196
|
this._compressionCount = 0;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this.
|
|
197
|
+
// ★ 按 sessionId 隔离在途压缩:旧实现用单个全局 promise/flag,
|
|
198
|
+
// session B 在 A 压缩进行中调用会拿到 A 的 promise、对着 A 的 messages → 串味。
|
|
199
|
+
this._compressionPromises = new Map(); // sessionId -> 在途 promise
|
|
200
|
+
this._compressionTimeouts = new Map(); // sessionId -> setTimeout 句柄
|
|
200
201
|
this._maxToolResultSize = config.maxToolResultSize || 4000;
|
|
201
202
|
this._tokenCounter = new TokenCounter();
|
|
202
203
|
}
|
|
@@ -213,20 +214,22 @@ class ContextCompressor {
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
async compress(sessionId, messages, messageStore) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
// 同一 session 的并发压缩复用同一 promise;不同 session 互不干扰。
|
|
218
|
+
const existing = this._compressionPromises.get(sessionId);
|
|
219
|
+
if (existing) {
|
|
220
|
+
logger.debug(`Compression already in progress for ${sessionId}, waiting...`);
|
|
221
|
+
return existing;
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
if (messages.length <= this._keepRecentMessages) return;
|
|
222
225
|
|
|
223
|
-
this.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.
|
|
227
|
-
if (this._compressionTimeoutId) { clearTimeout(this._compressionTimeoutId); this._compressionTimeoutId = null; }
|
|
226
|
+
const p = this._executeWithTimeout(sessionId, messages, messageStore).finally(() => {
|
|
227
|
+
this._compressionPromises.delete(sessionId);
|
|
228
|
+
const t = this._compressionTimeouts.get(sessionId);
|
|
229
|
+
if (t) { clearTimeout(t); this._compressionTimeouts.delete(sessionId); }
|
|
228
230
|
});
|
|
229
|
-
|
|
231
|
+
this._compressionPromises.set(sessionId, p);
|
|
232
|
+
return p;
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
async _executeWithTimeout(sessionId, messages, messageStore) {
|
|
@@ -234,10 +237,11 @@ class ContextCompressor {
|
|
|
234
237
|
return await Promise.race([
|
|
235
238
|
this._doCompress(sessionId, messages, messageStore),
|
|
236
239
|
new Promise((_, reject) => {
|
|
237
|
-
|
|
238
|
-
this.
|
|
240
|
+
const tid = setTimeout(() => {
|
|
241
|
+
this._compressionTimeouts.delete(sessionId);
|
|
239
242
|
reject(new Error(`Compression timeout (${COMPRESSION_TIMEOUT_MS}ms)`));
|
|
240
243
|
}, COMPRESSION_TIMEOUT_MS);
|
|
244
|
+
this._compressionTimeouts.set(sessionId, tid);
|
|
241
245
|
}),
|
|
242
246
|
]);
|
|
243
247
|
} catch (err) {
|
|
@@ -247,9 +251,9 @@ class ContextCompressor {
|
|
|
247
251
|
}
|
|
248
252
|
|
|
249
253
|
cancelCompression() {
|
|
250
|
-
|
|
251
|
-
this.
|
|
252
|
-
this.
|
|
254
|
+
for (const t of this._compressionTimeouts.values()) clearTimeout(t);
|
|
255
|
+
this._compressionTimeouts.clear();
|
|
256
|
+
this._compressionPromises.clear();
|
|
253
257
|
}
|
|
254
258
|
|
|
255
259
|
async _doCompress(sessionId, messages, messageStore) {
|
|
@@ -390,7 +394,7 @@ class ContextCompressor {
|
|
|
390
394
|
}
|
|
391
395
|
|
|
392
396
|
getStats() {
|
|
393
|
-
return { compressionCount: this._compressionCount, inProgress: this.
|
|
397
|
+
return { compressionCount: this._compressionCount, inProgress: this._compressionPromises.size > 0 };
|
|
394
398
|
}
|
|
395
399
|
}
|
|
396
400
|
|
|
@@ -1076,7 +1076,25 @@ class Framework extends EventEmitter {
|
|
|
1076
1076
|
}
|
|
1077
1077
|
if (!manager) {
|
|
1078
1078
|
const { SessionManager } = require('../session/session');
|
|
1079
|
-
|
|
1079
|
+
const fs = require('fs');
|
|
1080
|
+
try {
|
|
1081
|
+
manager = new SessionManager(cwd, sessionDir, sessionFile, true);
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
// ★ 恢复分支同样可能因坏文件在构造时(_loadEntriesFromFile)抛错。
|
|
1084
|
+
// 之前这里不在 try 内 → 直接崩。改为:隔离坏文件后重新新建,最后兜底内存 session。
|
|
1085
|
+
this.logger.warn(`[Framework] Session '${sessionId}' file unreadable (${err.message}); quarantining and starting fresh`);
|
|
1086
|
+
try {
|
|
1087
|
+
if (fs.existsSync(sessionFile)) fs.renameSync(sessionFile, `${sessionFile}.corrupt-${Date.now()}`);
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
this.logger.warn(`[Framework] quarantine failed: ${e.message}`);
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
manager = new SessionManager(cwd, sessionDir, sessionFile, true);
|
|
1093
|
+
} catch (e2) {
|
|
1094
|
+
this.logger.warn(`[Framework] fresh session create failed: ${e2.message}; using in-memory`);
|
|
1095
|
+
manager = SessionManager.inMemory(cwd);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1080
1098
|
}
|
|
1081
1099
|
this._sessionContexts.set(sessionId, manager);
|
|
1082
1100
|
this.touchSession(sessionId);
|
package/src/plugin/manager.js
CHANGED
|
@@ -594,8 +594,18 @@ class PluginManager {
|
|
|
594
594
|
try {
|
|
595
595
|
const stateFile = this._getStateFile();
|
|
596
596
|
if (fs.existsSync(stateFile)) {
|
|
597
|
-
|
|
598
|
-
return this._stateCache;
|
|
597
|
+
const raw = fs.readFileSync(stateFile, 'utf-8');
|
|
598
|
+
if (raw.trim() === '') { this._stateCache = {}; return this._stateCache; }
|
|
599
|
+
try {
|
|
600
|
+
this._stateCache = JSON.parse(raw);
|
|
601
|
+
return this._stateCache;
|
|
602
|
+
} catch (parseErr) {
|
|
603
|
+
// ★ 坏文件不能静默当空对象 → 否则下次 _saveState 覆盖它、
|
|
604
|
+
// 插件启停/配置全丢。先隔离坏文件保留现场,再以默认值启动。
|
|
605
|
+
this._log.error(`Plugin state file corrupt (${parseErr.message}); quarantining`);
|
|
606
|
+
try { fs.renameSync(stateFile, `${stateFile}.corrupt-${Date.now()}`); }
|
|
607
|
+
catch (e) { this._log.error('quarantine state file failed:', e.message); }
|
|
608
|
+
}
|
|
599
609
|
}
|
|
600
610
|
} catch (err) {
|
|
601
611
|
this._log.error('Failed to load state:', err.message);
|
package/src/session/session.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const { JsonlSessionStorage } = require('../storage/jsonl');
|
|
10
|
+
const { atomicWriteFileSync } = require('../common/atomic-write');
|
|
10
11
|
const {
|
|
11
12
|
EntryTypes,
|
|
12
13
|
generateEntryId,
|
|
@@ -202,9 +203,9 @@ class SessionManager {
|
|
|
202
203
|
|
|
203
204
|
_rewriteFile() {
|
|
204
205
|
if (!this.persist || !this.sessionFile) return;
|
|
205
|
-
//
|
|
206
|
+
// 性能优化:单次写入代替多次追加。原子写避免崩溃时留下半截文件。
|
|
206
207
|
const content = this.fileEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
207
|
-
|
|
208
|
+
atomicWriteFileSync(this.sessionFile, content);
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
_persist(entry) {
|
|
@@ -221,7 +222,7 @@ class SessionManager {
|
|
|
221
222
|
// 性能优化:使用批量写入代替多次追加
|
|
222
223
|
if (!this.flushed) {
|
|
223
224
|
const batch = this.fileEntries.map(e => `${JSON.stringify(e)}\n`).join('');
|
|
224
|
-
|
|
225
|
+
atomicWriteFileSync(this.sessionFile, batch);
|
|
225
226
|
this.flushed = true;
|
|
226
227
|
} else {
|
|
227
228
|
fs.appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
@@ -440,7 +441,11 @@ class SessionManager {
|
|
|
440
441
|
* Build the session context (what gets sent to the LLM)
|
|
441
442
|
*/
|
|
442
443
|
buildSessionContext() {
|
|
443
|
-
|
|
444
|
+
// ★ 走活跃分支(leafId 到根的路径),而不是把所有分支的 entries 拍平。
|
|
445
|
+
// 否则被丢弃分支的消息会混进上下文,compaction/model 也可能取到废弃分支的值。
|
|
446
|
+
// entry.js 的 buildSessionContext 只接收单个数组参数,之前多传的 leafId/byId 被静默忽略。
|
|
447
|
+
const pathEntries = this.leafId ? this.getBranch(this.leafId) : this.getEntries();
|
|
448
|
+
return buildSessionContext(pathEntries);
|
|
444
449
|
}
|
|
445
450
|
|
|
446
451
|
/**
|
package/src/storage/manager.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { generateEntryId } = require('../common/id');
|
|
9
|
+
const { atomicWriteFileSync } = require('../common/atomic-write');
|
|
9
10
|
|
|
10
11
|
class StorageEntry {
|
|
11
12
|
constructor(key, value, timestamp) {
|
|
@@ -94,7 +95,8 @@ class StorageManager {
|
|
|
94
95
|
_rewrite() {
|
|
95
96
|
if (!this.persist || !this.filePath) return;
|
|
96
97
|
const lines = this.allEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
97
|
-
|
|
98
|
+
// 原子写:compaction 全量重写时崩溃不会留下半截文件(否则内存已"压缩"、磁盘却残缺)。
|
|
99
|
+
atomicWriteFileSync(this.filePath, lines);
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/**
|
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
|
|
3
|
-
// 测试 file-system 插件的 chat 工具修复
|
|
4
|
-
// 通过读源码 + 模拟 execute 来验证行为
|
|
5
|
-
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
|
|
9
|
-
const FileSystemPlugin = require('../../plugins/io/file-system/index.js');
|
|
10
|
-
|
|
11
|
-
function makeFakeFw(opts = {}) {
|
|
12
|
-
const llmConfig = opts.llmConfig || { model: 'test-model', provider: 'test-provider' };
|
|
13
|
-
const captured = { agentConfig: null, chatArgs: null, registeredTools: [] };
|
|
14
|
-
const fw = {
|
|
15
|
-
getCwd: () => process.cwd(),
|
|
16
|
-
pluginManager: {
|
|
17
|
-
get: (name) => {
|
|
18
|
-
if (name === 'ai') return { getConfig: () => llmConfig };
|
|
19
|
-
return null;
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
// ★ Plugin base class 的 tool.register 会先 push 到 _registeredTools,
|
|
23
|
-
// 然后查 registry.has(),has() 返回 false 才调 registry.register()。
|
|
24
|
-
// 所以 mock 必须实现 has() 否则注册流程被中断。
|
|
25
|
-
registerTool: (toolDef) => {
|
|
26
|
-
captured.registeredTools.push(toolDef);
|
|
27
|
-
},
|
|
28
|
-
toolRegistry: {
|
|
29
|
-
has: () => false,
|
|
30
|
-
register: (toolDef) => {
|
|
31
|
-
captured.registeredTools.push(toolDef);
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
createSubAgent: (config) => {
|
|
35
|
-
captured.agentConfig = config;
|
|
36
|
-
return {
|
|
37
|
-
chat: async (msg, opts) => {
|
|
38
|
-
captured.chatArgs = { msg, opts };
|
|
39
|
-
return { success: true, message: 'mock reply', steps: 1 };
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
return { fw, captured };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function getChatTool(captured) {
|
|
48
|
-
return captured.registeredTools.find(t => t.name === 'chat');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe('file-system 插件的 chat 工具', () => {
|
|
52
|
-
it('★ 关键:name 唯一(用 Date.now + random 避免冲突)', async () => {
|
|
53
|
-
const { fw, captured } = makeFakeFw();
|
|
54
|
-
const p = new FileSystemPlugin();
|
|
55
|
-
p.install(fw);
|
|
56
|
-
p.start(fw);
|
|
57
|
-
// 直接调工具的 execute
|
|
58
|
-
// find chat tool in _registeredTools
|
|
59
|
-
const chatTool = getChatTool(captured);
|
|
60
|
-
expect(chatTool).toBeTruthy();
|
|
61
|
-
if (!chatTool) return;
|
|
62
|
-
|
|
63
|
-
const r1 = await chatTool.execute({ message: 'hi' }, fw);
|
|
64
|
-
const r2 = await chatTool.execute({ message: 'hi2' }, fw);
|
|
65
|
-
|
|
66
|
-
expect(captured.agentConfig.name).toMatch(/^file-system-chat-\d+/);
|
|
67
|
-
// 两次调用应产生不同名字
|
|
68
|
-
const name1 = r1.metadata?.agent;
|
|
69
|
-
const name2 = r2.metadata?.agent;
|
|
70
|
-
expect(name1).not.toBe(name2);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('★ 关键:sessionId 隔离(避免污染主对话)', async () => {
|
|
74
|
-
const { fw, captured } = makeFakeFw();
|
|
75
|
-
const p = new FileSystemPlugin();
|
|
76
|
-
p.install(fw);
|
|
77
|
-
p.start(fw);
|
|
78
|
-
const chatTool = getChatTool(captured);
|
|
79
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
80
|
-
|
|
81
|
-
// 1. 不传 sessionId → 自动生成
|
|
82
|
-
await chatTool.execute({ message: 'm1' }, fw);
|
|
83
|
-
expect(captured.chatArgs.opts.sessionId).toMatch(/^chat-\d+$/);
|
|
84
|
-
|
|
85
|
-
// 2. 传 sessionId → 使用传入的
|
|
86
|
-
await chatTool.execute({ message: 'm2', sessionId: 'my-session' }, fw);
|
|
87
|
-
expect(captured.chatArgs.opts.sessionId).toBe('my-session');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('★ 关键:跟随主 agent 的 LLM 配置', async () => {
|
|
91
|
-
const llmConfig = {
|
|
92
|
-
model: 'deepseek-chat',
|
|
93
|
-
provider: 'deepseek',
|
|
94
|
-
apiKey: 'sk-test',
|
|
95
|
-
baseURL: 'https://api.deepseek.com/v1',
|
|
96
|
-
};
|
|
97
|
-
const { fw, captured } = makeFakeFw({ llmConfig });
|
|
98
|
-
const p = new FileSystemPlugin();
|
|
99
|
-
p.install(fw);
|
|
100
|
-
p.start(fw);
|
|
101
|
-
const chatTool = getChatTool(captured);
|
|
102
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
103
|
-
|
|
104
|
-
await chatTool.execute({ message: 'hi' }, fw);
|
|
105
|
-
expect(captured.agentConfig.model).toBe('deepseek-chat');
|
|
106
|
-
expect(captured.agentConfig.provider).toBe('deepseek');
|
|
107
|
-
expect(captured.agentConfig.apiKey).toBe('sk-test');
|
|
108
|
-
expect(captured.agentConfig.baseURL).toBe('https://api.deepseek.com/v1');
|
|
109
|
-
expect(captured.agentConfig.hidden).toBe(true);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('★ 关键:data 只返回 message 文本(不返回整个对象)', async () => {
|
|
113
|
-
const { fw, captured } = makeFakeFw();
|
|
114
|
-
const p = new FileSystemPlugin();
|
|
115
|
-
p.install(fw);
|
|
116
|
-
p.start(fw);
|
|
117
|
-
const chatTool = getChatTool(captured);
|
|
118
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
119
|
-
|
|
120
|
-
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
121
|
-
expect(r.success).toBe(true);
|
|
122
|
-
expect(r.data).toBe('mock reply'); // 只是字符串,不是整个对象
|
|
123
|
-
expect(typeof r.data).toBe('string');
|
|
124
|
-
// metadata 含元信息
|
|
125
|
-
expect(r.metadata).toBeTruthy();
|
|
126
|
-
expect(r.metadata.steps).toBe(1);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('★ 关键:systemPrompt 默认值合理', async () => {
|
|
130
|
-
const { fw, captured } = makeFakeFw();
|
|
131
|
-
const p = new FileSystemPlugin();
|
|
132
|
-
p.install(fw);
|
|
133
|
-
p.start(fw);
|
|
134
|
-
const chatTool = getChatTool(captured);
|
|
135
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
136
|
-
|
|
137
|
-
// 不传 systemPrompt → 用默认
|
|
138
|
-
await chatTool.execute({ message: 'hi' }, fw);
|
|
139
|
-
expect(captured.agentConfig.systemPrompt).toContain('文件系统');
|
|
140
|
-
|
|
141
|
-
// 传 systemPrompt → 覆盖
|
|
142
|
-
await chatTool.execute({
|
|
143
|
-
message: 'hi',
|
|
144
|
-
systemPrompt: '你是一个翻译助手',
|
|
145
|
-
}, fw);
|
|
146
|
-
expect(captured.agentConfig.systemPrompt).toBe('你是一个翻译助手');
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('★ 关键:错误捕获(AI 抛错时不崩溃)', async () => {
|
|
150
|
-
const registeredTools = [];
|
|
151
|
-
const fw = {
|
|
152
|
-
getCwd: () => process.cwd(),
|
|
153
|
-
pluginManager: { get: () => null },
|
|
154
|
-
registerTool: (t) => registeredTools.push(t),
|
|
155
|
-
toolRegistry: { has: () => false, register: (t) => registeredTools.push(t) },
|
|
156
|
-
createSubAgent: () => ({
|
|
157
|
-
chat: async () => { throw new Error('AI 服务不可用'); },
|
|
158
|
-
}),
|
|
159
|
-
};
|
|
160
|
-
const p = new FileSystemPlugin();
|
|
161
|
-
p.install(fw);
|
|
162
|
-
p.start(fw);
|
|
163
|
-
const chatTool = registeredTools.find(t => t.name === 'chat');
|
|
164
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
165
|
-
|
|
166
|
-
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
167
|
-
expect(r.success).toBe(false);
|
|
168
|
-
expect(r.error).toBe('AI 服务不可用');
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('★ inputSchema 含 sessionId 字段', () => {
|
|
172
|
-
// 直接读源码验证
|
|
173
|
-
const src = fs.readFileSync(
|
|
174
|
-
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
175
|
-
'utf-8'
|
|
176
|
-
);
|
|
177
|
-
expect(src).toMatch(/sessionId:\s*z\.string\(\)\.optional\(\)/);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('★ 删除死代码:const ai = framework.getAI()', () => {
|
|
181
|
-
const src = fs.readFileSync(
|
|
182
|
-
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
183
|
-
'utf-8'
|
|
184
|
-
);
|
|
185
|
-
expect(src).not.toMatch(/const\s+ai\s*=\s*framework\.getAI/);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// 测试 file-system 插件的 chat 工具修复
|
|
4
|
+
// 通过读源码 + 模拟 execute 来验证行为
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const FileSystemPlugin = require('../../plugins/io/file-system/index.js');
|
|
10
|
+
|
|
11
|
+
function makeFakeFw(opts = {}) {
|
|
12
|
+
const llmConfig = opts.llmConfig || { model: 'test-model', provider: 'test-provider' };
|
|
13
|
+
const captured = { agentConfig: null, chatArgs: null, registeredTools: [] };
|
|
14
|
+
const fw = {
|
|
15
|
+
getCwd: () => process.cwd(),
|
|
16
|
+
pluginManager: {
|
|
17
|
+
get: (name) => {
|
|
18
|
+
if (name === 'ai') return { getConfig: () => llmConfig };
|
|
19
|
+
return null;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
// ★ Plugin base class 的 tool.register 会先 push 到 _registeredTools,
|
|
23
|
+
// 然后查 registry.has(),has() 返回 false 才调 registry.register()。
|
|
24
|
+
// 所以 mock 必须实现 has() 否则注册流程被中断。
|
|
25
|
+
registerTool: (toolDef) => {
|
|
26
|
+
captured.registeredTools.push(toolDef);
|
|
27
|
+
},
|
|
28
|
+
toolRegistry: {
|
|
29
|
+
has: () => false,
|
|
30
|
+
register: (toolDef) => {
|
|
31
|
+
captured.registeredTools.push(toolDef);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
createSubAgent: (config) => {
|
|
35
|
+
captured.agentConfig = config;
|
|
36
|
+
return {
|
|
37
|
+
chat: async (msg, opts) => {
|
|
38
|
+
captured.chatArgs = { msg, opts };
|
|
39
|
+
return { success: true, message: 'mock reply', steps: 1 };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { fw, captured };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getChatTool(captured) {
|
|
48
|
+
return captured.registeredTools.find(t => t.name === 'chat');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('file-system 插件的 chat 工具', () => {
|
|
52
|
+
it('★ 关键:name 唯一(用 Date.now + random 避免冲突)', async () => {
|
|
53
|
+
const { fw, captured } = makeFakeFw();
|
|
54
|
+
const p = new FileSystemPlugin();
|
|
55
|
+
p.install(fw);
|
|
56
|
+
p.start(fw);
|
|
57
|
+
// 直接调工具的 execute
|
|
58
|
+
// find chat tool in _registeredTools
|
|
59
|
+
const chatTool = getChatTool(captured);
|
|
60
|
+
expect(chatTool).toBeTruthy();
|
|
61
|
+
if (!chatTool) return;
|
|
62
|
+
|
|
63
|
+
const r1 = await chatTool.execute({ message: 'hi' }, fw);
|
|
64
|
+
const r2 = await chatTool.execute({ message: 'hi2' }, fw);
|
|
65
|
+
|
|
66
|
+
expect(captured.agentConfig.name).toMatch(/^file-system-chat-\d+/);
|
|
67
|
+
// 两次调用应产生不同名字
|
|
68
|
+
const name1 = r1.metadata?.agent;
|
|
69
|
+
const name2 = r2.metadata?.agent;
|
|
70
|
+
expect(name1).not.toBe(name2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('★ 关键:sessionId 隔离(避免污染主对话)', async () => {
|
|
74
|
+
const { fw, captured } = makeFakeFw();
|
|
75
|
+
const p = new FileSystemPlugin();
|
|
76
|
+
p.install(fw);
|
|
77
|
+
p.start(fw);
|
|
78
|
+
const chatTool = getChatTool(captured);
|
|
79
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
80
|
+
|
|
81
|
+
// 1. 不传 sessionId → 自动生成
|
|
82
|
+
await chatTool.execute({ message: 'm1' }, fw);
|
|
83
|
+
expect(captured.chatArgs.opts.sessionId).toMatch(/^chat-\d+$/);
|
|
84
|
+
|
|
85
|
+
// 2. 传 sessionId → 使用传入的
|
|
86
|
+
await chatTool.execute({ message: 'm2', sessionId: 'my-session' }, fw);
|
|
87
|
+
expect(captured.chatArgs.opts.sessionId).toBe('my-session');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('★ 关键:跟随主 agent 的 LLM 配置', async () => {
|
|
91
|
+
const llmConfig = {
|
|
92
|
+
model: 'deepseek-chat',
|
|
93
|
+
provider: 'deepseek',
|
|
94
|
+
apiKey: 'sk-test',
|
|
95
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
96
|
+
};
|
|
97
|
+
const { fw, captured } = makeFakeFw({ llmConfig });
|
|
98
|
+
const p = new FileSystemPlugin();
|
|
99
|
+
p.install(fw);
|
|
100
|
+
p.start(fw);
|
|
101
|
+
const chatTool = getChatTool(captured);
|
|
102
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
103
|
+
|
|
104
|
+
await chatTool.execute({ message: 'hi' }, fw);
|
|
105
|
+
expect(captured.agentConfig.model).toBe('deepseek-chat');
|
|
106
|
+
expect(captured.agentConfig.provider).toBe('deepseek');
|
|
107
|
+
expect(captured.agentConfig.apiKey).toBe('sk-test');
|
|
108
|
+
expect(captured.agentConfig.baseURL).toBe('https://api.deepseek.com/v1');
|
|
109
|
+
expect(captured.agentConfig.hidden).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('★ 关键:data 只返回 message 文本(不返回整个对象)', async () => {
|
|
113
|
+
const { fw, captured } = makeFakeFw();
|
|
114
|
+
const p = new FileSystemPlugin();
|
|
115
|
+
p.install(fw);
|
|
116
|
+
p.start(fw);
|
|
117
|
+
const chatTool = getChatTool(captured);
|
|
118
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
119
|
+
|
|
120
|
+
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
121
|
+
expect(r.success).toBe(true);
|
|
122
|
+
expect(r.data).toBe('mock reply'); // 只是字符串,不是整个对象
|
|
123
|
+
expect(typeof r.data).toBe('string');
|
|
124
|
+
// metadata 含元信息
|
|
125
|
+
expect(r.metadata).toBeTruthy();
|
|
126
|
+
expect(r.metadata.steps).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('★ 关键:systemPrompt 默认值合理', async () => {
|
|
130
|
+
const { fw, captured } = makeFakeFw();
|
|
131
|
+
const p = new FileSystemPlugin();
|
|
132
|
+
p.install(fw);
|
|
133
|
+
p.start(fw);
|
|
134
|
+
const chatTool = getChatTool(captured);
|
|
135
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
136
|
+
|
|
137
|
+
// 不传 systemPrompt → 用默认
|
|
138
|
+
await chatTool.execute({ message: 'hi' }, fw);
|
|
139
|
+
expect(captured.agentConfig.systemPrompt).toContain('文件系统');
|
|
140
|
+
|
|
141
|
+
// 传 systemPrompt → 覆盖
|
|
142
|
+
await chatTool.execute({
|
|
143
|
+
message: 'hi',
|
|
144
|
+
systemPrompt: '你是一个翻译助手',
|
|
145
|
+
}, fw);
|
|
146
|
+
expect(captured.agentConfig.systemPrompt).toBe('你是一个翻译助手');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('★ 关键:错误捕获(AI 抛错时不崩溃)', async () => {
|
|
150
|
+
const registeredTools = [];
|
|
151
|
+
const fw = {
|
|
152
|
+
getCwd: () => process.cwd(),
|
|
153
|
+
pluginManager: { get: () => null },
|
|
154
|
+
registerTool: (t) => registeredTools.push(t),
|
|
155
|
+
toolRegistry: { has: () => false, register: (t) => registeredTools.push(t) },
|
|
156
|
+
createSubAgent: () => ({
|
|
157
|
+
chat: async () => { throw new Error('AI 服务不可用'); },
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
const p = new FileSystemPlugin();
|
|
161
|
+
p.install(fw);
|
|
162
|
+
p.start(fw);
|
|
163
|
+
const chatTool = registeredTools.find(t => t.name === 'chat');
|
|
164
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
165
|
+
|
|
166
|
+
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
167
|
+
expect(r.success).toBe(false);
|
|
168
|
+
expect(r.error).toBe('AI 服务不可用');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('★ inputSchema 含 sessionId 字段', () => {
|
|
172
|
+
// 直接读源码验证
|
|
173
|
+
const src = fs.readFileSync(
|
|
174
|
+
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
175
|
+
'utf-8'
|
|
176
|
+
);
|
|
177
|
+
expect(src).toMatch(/sessionId:\s*z\.string\(\)\.optional\(\)/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('★ 删除死代码:const ai = framework.getAI()', () => {
|
|
181
|
+
const src = fs.readFileSync(
|
|
182
|
+
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
183
|
+
'utf-8'
|
|
184
|
+
);
|
|
185
|
+
expect(src).not.toMatch(/const\s+ai\s*=\s*framework\.getAI/);
|
|
186
|
+
});
|
|
187
|
+
});
|