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.
@@ -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
- this._compressionInProgress = false;
198
- this._compressionPromise = null;
199
- this._compressionTimeoutId = null;
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
- if (this._compressionInProgress && this._compressionPromise) {
217
- logger.debug('Compression already in progress, waiting...');
218
- return this._compressionPromise;
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._compressionInProgress = true;
224
- this._compressionPromise = this._executeWithTimeout(sessionId, messages, messageStore).finally(() => {
225
- this._compressionInProgress = false;
226
- this._compressionPromise = null;
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
- return this._compressionPromise;
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
- this._compressionTimeoutId = setTimeout(() => {
238
- this._compressionTimeoutId = null;
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
- if (this._compressionTimeoutId) { clearTimeout(this._compressionTimeoutId); this._compressionTimeoutId = null; }
251
- this._compressionInProgress = false;
252
- this._compressionPromise = null;
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._compressionInProgress };
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
- manager = new SessionManager(cwd, sessionDir, sessionFile, true);
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);
@@ -594,8 +594,18 @@ class PluginManager {
594
594
  try {
595
595
  const stateFile = this._getStateFile();
596
596
  if (fs.existsSync(stateFile)) {
597
- this._stateCache = safeJsonParse(fs.readFileSync(stateFile, 'utf-8'), {});
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);
@@ -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
- fs.writeFileSync(this.sessionFile, content);
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
- fs.writeFileSync(this.sessionFile, batch);
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
- return buildSessionContext(this.getEntries(), this.leafId, this.byId);
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
  /**
@@ -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
- fs.writeFileSync(this.filePath, lines);
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
+ });