foliko 2.0.21 → 2.0.22

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.
@@ -15,7 +15,7 @@ const INTENT_PATTERNS = {
15
15
  ],
16
16
  tools: [
17
17
  'read_file', 'write_file', 'delete_file', 'modify_file',
18
- 'read_directory', 'create_directory', 'search_file',
18
+ 'read_directory', 'create_directory', 'search',
19
19
  ],
20
20
  },
21
21
  code_development: {
@@ -34,7 +34,7 @@ const INTENT_PATTERNS = {
34
34
  },
35
35
  data_analysis: {
36
36
  keywords: ['分析', '数据', '统计', '查询', 'analyze', 'data', 'statistics', 'query'],
37
- tools: ['execute_command', 'py_execute', 'search_file'],
37
+ tools: ['execute_command', 'py_execute', 'search', 'read_file', 'write_file'],
38
38
  },
39
39
  plugin_management: {
40
40
  keywords: ['插件', '重载', '加载插件', 'plugin', 'reload', 'load plugin'],
@@ -499,6 +499,191 @@ function filterPairedMessages(messages, keepRecentAssistant = 3) {
499
499
  .map((i) => messages[i]);
500
500
  }
501
501
 
502
+ /**
503
+ * ★ 防御性 sanitize:确保 messages 数组符合 AI SDK 的 ModelMessage[] schema
504
+ *
505
+ * 已知会失败的情况:
506
+ * 1. `user.content` 不是 string 或 array(如 null、undefined、object)
507
+ * 2. `assistant.content` 不是 string 或 array
508
+ * 3. `tool.content` 不是 array
509
+ * 4. user/assistant content 是 array,但 part 缺少 `type` 字段或 type 不在白名单
510
+ * 5. tool content 是 array,但 part 不是 tool-result/tool-approval-response
511
+ * 6. tool-result 的 `output` 缺少 type 或 type 不在 AI SDK 允许集
512
+ *
513
+ * 策略:
514
+ * - 可修复 → 修复(log warn)
515
+ * - 不可修复 → 丢弃整条消息(log warn)
516
+ *
517
+ * @param {Array} messages 消息数组
518
+ * @returns {{ messages: Array, fixed: number, dropped: number }}
519
+ */
520
+ function sanitizeForLLM(messages) {
521
+ if (!Array.isArray(messages)) return { messages: [], fixed: 0, dropped: 0 };
522
+ const VALID_USER_PARTS = new Set(['text', 'image', 'file']);
523
+ const VALID_ASSISTANT_PARTS = new Set(['text', 'file', 'reasoning', 'tool-call', 'tool-result', 'tool-approval-request']);
524
+ const VALID_TOOL_PARTS = new Set(['tool-result', 'tool-approval-response']);
525
+ const VALID_OUTPUT_TYPES = new Set(['text', 'json', 'execution-denied', 'content', 'error-text', 'error-json']);
526
+
527
+ let fixed = 0;
528
+ let dropped = 0;
529
+ const out = [];
530
+
531
+ for (let i = 0; i < messages.length; i++) {
532
+ const m = messages[i];
533
+ if (!m || typeof m !== 'object' || !m.role) {
534
+ console.warn(`[sanitizeForLLM] dropped msg[${i}]: missing role`);
535
+ dropped++;
536
+ continue;
537
+ }
538
+
539
+ if (m.role === 'system') {
540
+ // system: content 必须是 string
541
+ if (typeof m.content !== 'string') {
542
+ console.warn(`[sanitizeForLLM] fixed system msg[${i}]: content ${typeof m.content} → string`);
543
+ out.push({ ...m, content: String(m.content ?? '') });
544
+ fixed++;
545
+ } else {
546
+ out.push(m);
547
+ }
548
+ continue;
549
+ }
550
+
551
+ if (m.role === 'user') {
552
+ // user: content 必须是 string 或 array(text/image/file parts)
553
+ if (typeof m.content === 'string') {
554
+ out.push(m);
555
+ continue;
556
+ }
557
+ if (Array.isArray(m.content)) {
558
+ const newContent = [];
559
+ for (const part of m.content) {
560
+ if (part && typeof part === 'object' && VALID_USER_PARTS.has(part.type)) {
561
+ newContent.push(part);
562
+ } else {
563
+ console.warn(`[sanitizeForLLM] dropped user msg[${i}] part: ${JSON.stringify(part).slice(0, 100)}`);
564
+ fixed++;
565
+ }
566
+ }
567
+ if (newContent.length === 0) {
568
+ console.warn(`[sanitizeForLLM] dropped user msg[${i}]: all parts invalid`);
569
+ dropped++;
570
+ continue;
571
+ }
572
+ out.push({ ...m, content: newContent });
573
+ continue;
574
+ }
575
+ // content 是 object/null/undefined → 转为 string 或丢弃
576
+ if (m.content == null) {
577
+ console.warn(`[sanitizeForLLM] dropped user msg[${i}]: null content`);
578
+ dropped++;
579
+ continue;
580
+ }
581
+ // 转为字符串
582
+ try {
583
+ out.push({ ...m, content: JSON.stringify(m.content) });
584
+ fixed++;
585
+ console.warn(`[sanitizeForLLM] fixed user msg[${i}]: content object → string`);
586
+ } catch {
587
+ out.push({ ...m, content: String(m.content) });
588
+ fixed++;
589
+ }
590
+ continue;
591
+ }
592
+
593
+ if (m.role === 'assistant') {
594
+ // assistant: content 必须是 string 或 array
595
+ if (typeof m.content === 'string') {
596
+ out.push(m);
597
+ continue;
598
+ }
599
+ if (Array.isArray(m.content)) {
600
+ const newContent = [];
601
+ for (const part of m.content) {
602
+ if (!part || typeof part !== 'object' || !part.type) {
603
+ console.warn(`[sanitizeForLLM] dropped assistant msg[${i}] part: no type - ${JSON.stringify(part).slice(0, 100)}`);
604
+ fixed++;
605
+ continue;
606
+ }
607
+ if (VALID_ASSISTANT_PARTS.has(part.type)) {
608
+ newContent.push(part);
609
+ } else {
610
+ console.warn(`[sanitizeForLLM] dropped assistant msg[${i}] part: unknown type "${part.type}"`);
611
+ fixed++;
612
+ }
613
+ }
614
+ if (newContent.length === 0) {
615
+ console.warn(`[sanitizeForLLM] dropped assistant msg[${i}]: all parts invalid`);
616
+ dropped++;
617
+ continue;
618
+ }
619
+ out.push({ ...m, content: newContent });
620
+ continue;
621
+ }
622
+ if (m.content == null) {
623
+ console.warn(`[sanitizeForLLM] dropped assistant msg[${i}]: null content`);
624
+ dropped++;
625
+ continue;
626
+ }
627
+ try {
628
+ out.push({ ...m, content: JSON.stringify(m.content) });
629
+ fixed++;
630
+ console.warn(`[sanitizeForLLM] fixed assistant msg[${i}]: content ${typeof m.content} → string`);
631
+ } catch {
632
+ out.push({ ...m, content: String(m.content) });
633
+ fixed++;
634
+ }
635
+ continue;
636
+ }
637
+
638
+ if (m.role === 'tool') {
639
+ // tool: content 必须是 array (tool-result 或 tool-approval-response)
640
+ if (!Array.isArray(m.content)) {
641
+ console.warn(`[sanitizeForLLM] dropped tool msg[${i}]: content is ${typeof m.content}, not array`);
642
+ dropped++;
643
+ continue;
644
+ }
645
+ const newContent = [];
646
+ for (const part of m.content) {
647
+ if (!part || typeof part !== 'object' || !part.type) {
648
+ console.warn(`[sanitizeForLLM] dropped tool msg[${i}] part: no type`);
649
+ fixed++;
650
+ continue;
651
+ }
652
+ if (!VALID_TOOL_PARTS.has(part.type)) {
653
+ console.warn(`[sanitizeForLLM] dropped tool msg[${i}] part: type "${part.type}" not in tool union`);
654
+ fixed++;
655
+ continue;
656
+ }
657
+ // 修复 tool-result.output
658
+ if (part.type === 'tool-result') {
659
+ if (!part.output || typeof part.output !== 'object' || !VALID_OUTPUT_TYPES.has(part.output.type)) {
660
+ console.warn(`[sanitizeForLLM] fixed tool msg[${i}] tool-result: bad output - ${JSON.stringify(part.output).slice(0, 100)}`);
661
+ const out2 = (part.output == null) ? { type: 'text', value: String(part.output ?? '') } :
662
+ { type: 'text', value: JSON.stringify(part.output) };
663
+ newContent.push({ ...part, output: out2 });
664
+ fixed++;
665
+ continue;
666
+ }
667
+ }
668
+ newContent.push(part);
669
+ }
670
+ if (newContent.length === 0) {
671
+ console.warn(`[sanitizeForLLM] dropped tool msg[${i}]: all parts invalid`);
672
+ dropped++;
673
+ continue;
674
+ }
675
+ out.push({ ...m, content: newContent });
676
+ continue;
677
+ }
678
+
679
+ // 未知 role:丢弃
680
+ console.warn(`[sanitizeForLLM] dropped msg[${i}]: unknown role "${m.role}"`);
681
+ dropped++;
682
+ }
683
+
684
+ return { messages: out, fixed, dropped };
685
+ }
686
+
502
687
  module.exports = {
503
688
  validateMessagesPairing,
504
689
  validateToolCalls,
@@ -506,4 +691,5 @@ module.exports = {
506
691
  normalizeToolOutputs,
507
692
  filterOrphanToolCalls,
508
693
  filterPairedMessages,
694
+ sanitizeForLLM,
509
695
  };
@@ -0,0 +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
+ });
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const FileSystemPlugin = require('../../plugins/io/file-system/index.js');
7
+
8
+ let tempDir, framework, tool;
9
+ let fileSystem;
10
+
11
+ beforeEach(async () => {
12
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-test-'));
13
+ fs.writeFileSync(path.join(tempDir, 'sample.js'),
14
+ 'function hello() {\n console.log("hello world");\n return 42;\n}\n');
15
+
16
+ const registeredTools = [];
17
+ framework = {
18
+ getCwd: () => tempDir,
19
+ registerTool: (t) => registeredTools.push(t),
20
+ pluginManager: { get: () => null },
21
+ toolRegistry: { has: () => false, register: (t) => registeredTools.push(t) },
22
+ };
23
+ const p = new FileSystemPlugin();
24
+ p.install(framework);
25
+ tool = registeredTools.find(t => t.name === 'edit' || t.name === 'edit_file');
26
+ if (!tool) throw new Error('edit tool not registered');
27
+ });
28
+
29
+ afterEach(() => {
30
+ fs.rmSync(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ describe('edit/edit_file 工具 (frs-replace 实现)', () => {
34
+ it('★ 基础:单次字面量替换', async () => {
35
+ const r = await tool.execute({
36
+ path: 'sample.js',
37
+ edits: [{ oldText: 'hello world', newText: 'goodbye universe' }],
38
+ }, framework);
39
+ expect(r.success).toBe(true);
40
+ expect(r.data.appliedEdits).toBe(1);
41
+ const newContent = fs.readFileSync(path.join(tempDir, 'sample.js'), 'utf8');
42
+ expect(newContent).toContain('goodbye universe');
43
+ expect(newContent).not.toContain('hello world');
44
+ });
45
+
46
+ it('★ 多次替换按顺序应用', async () => {
47
+ const r = await tool.execute({
48
+ path: 'sample.js',
49
+ edits: [
50
+ { oldText: 'function hello', newText: 'function greet' },
51
+ { oldText: 'console.log', newText: 'console.warn' },
52
+ ],
53
+ }, framework);
54
+ expect(r.success).toBe(true);
55
+ expect(r.data.appliedEdits).toBe(2);
56
+ const newContent = fs.readFileSync(path.join(tempDir, 'sample.js'), 'utf8');
57
+ expect(newContent).toContain('function greet()');
58
+ expect(newContent).toContain('console.warn');
59
+ });
60
+
61
+ it('★ 关键:找不到 oldText 报错(不再静默不生效)', async () => {
62
+ const r = await tool.execute({
63
+ path: 'sample.js',
64
+ edits: [{ oldText: 'NOT_EXISTING_TEXT_XYZ', newText: 'foo' }],
65
+ }, framework);
66
+ expect(r.success).toBe(false);
67
+ expect(r.error).toContain('替换未生效');
68
+ expect(r.error).toContain('找不到');
69
+ });
70
+
71
+ it('★ useRegex=true 支持正则匹配', async () => {
72
+ const r = await tool.execute({
73
+ path: 'sample.js',
74
+ edits: [{
75
+ oldText: '\\b\\w+\\(\\)', // 匹配 "函数名()"
76
+ newText: 'fn()',
77
+ useRegex: true,
78
+ }],
79
+ }, framework);
80
+ expect(r.success).toBe(true);
81
+ const newContent = fs.readFileSync(path.join(tempDir, 'sample.js'), 'utf8');
82
+ // hello() → fn() 但 greet() 也会变(多匹配中第一个)
83
+ expect(newContent).toContain('fn()');
84
+ });
85
+
86
+ it('★ useRegex=true 无效正则报错', async () => {
87
+ const r = await tool.execute({
88
+ path: 'sample.js',
89
+ edits: [{ oldText: '[invalid(', newText: 'X', useRegex: true }],
90
+ }, framework);
91
+ expect(r.success).toBe(false);
92
+ expect(r.error).toContain('无效的正则');
93
+ });
94
+
95
+ it('★ useRegex=true 多次出现都被替换(默认)', async () => {
96
+ fs.writeFileSync(path.join(tempDir, 'multi.txt'), 'foo foo foo bar foo');
97
+ const r = await tool.execute({
98
+ path: 'multi.txt',
99
+ edits: [{ oldText: 'foo', newText: 'BAZ', useRegex: true }],
100
+ }, framework);
101
+ expect(r.success).toBe(true);
102
+ const newContent = fs.readFileSync(path.join(tempDir, 'multi.txt'), 'utf8');
103
+ expect(newContent).toBe('BAZ BAZ BAZ bar BAZ');
104
+ });
105
+
106
+ it('★ allOccurrences=false 只替换第一个', async () => {
107
+ fs.writeFileSync(path.join(tempDir, 'multi.txt'), 'foo foo foo bar foo');
108
+ const r = await tool.execute({
109
+ path: 'multi.txt',
110
+ edits: [{ oldText: 'foo', newText: 'BAZ', allOccurrences: false }],
111
+ }, framework);
112
+ expect(r.success).toBe(true);
113
+ const newContent = fs.readFileSync(path.join(tempDir, 'multi.txt'), 'utf8');
114
+ expect(newContent).toBe('BAZ foo foo bar foo');
115
+ });
116
+
117
+ it('★ 多 edit 中第二个找不到:报错且不应用第一个', async () => {
118
+ const r = await tool.execute({
119
+ path: 'sample.js',
120
+ edits: [
121
+ { oldText: 'function hello', newText: 'function greet' }, // 找得到
122
+ { oldText: 'NOT_EXISTING', newText: 'foo' }, // 找不到
123
+ ],
124
+ }, framework);
125
+ expect(r.success).toBe(false);
126
+ expect(r.error).toContain('edits[1]');
127
+ // ★ 验证第一个 edit 也没应用(避免部分应用)
128
+ const content = fs.readFileSync(path.join(tempDir, 'sample.js'), 'utf8');
129
+ expect(content).toContain('function hello');
130
+ expect(content).not.toContain('function greet');
131
+ });
132
+
133
+ it('★ 文件不存在报错', async () => {
134
+ const r = await tool.execute({
135
+ path: 'nonexistent.js',
136
+ edits: [{ oldText: 'foo', newText: 'bar' }],
137
+ }, framework);
138
+ expect(r.success).toBe(false);
139
+ expect(r.error).toContain('读取文件失败');
140
+ });
141
+
142
+ it('★ edits 是空数组报错', async () => {
143
+ const r = await tool.execute({
144
+ path: 'sample.js',
145
+ edits: [],
146
+ }, framework);
147
+ expect(r.success).toBe(false);
148
+ });
149
+
150
+ it('★ edits[i].oldText 是空字符串报错', async () => {
151
+ const r = await tool.execute({
152
+ path: 'sample.js',
153
+ edits: [{ oldText: '', newText: 'foo' }],
154
+ }, framework);
155
+ expect(r.success).toBe(false);
156
+ });
157
+
158
+ it('★ 原子写入:失败时不留 .tmp 垃圾', async () => {
159
+ // 模拟:先创建 .tmp 但删除 source file 让 rename 失败
160
+ const r = await tool.execute({
161
+ path: 'sample.js',
162
+ edits: [{ oldText: 'function hello', newText: 'function greet' }],
163
+ }, framework);
164
+ expect(r.success).toBe(true);
165
+ // 检查没有遗留 .tmp
166
+ const files = fs.readdirSync(tempDir);
167
+ expect(files.some(f => f.endsWith('.tmp'))).toBe(false);
168
+ });
169
+
170
+ it('★ edit_file 是 edit 的别名(同实现)', async () => {
171
+ // 找到 edit_file 工具
172
+ const p = new FileSystemPlugin();
173
+ p.install(framework);
174
+ const editFileTool = p._registeredTools?.find(t => t.name === 'edit_file')
175
+ || (p._registeredTools || []).find(t => t.name === 'edit_file');
176
+ // 由于 install 重新注册,p._registeredTools 是空(mock 没捕获 Plugin base class 的 _registeredTools)
177
+ // 重新调用 install 让 mock 捕获
178
+ const r = await tool.execute({
179
+ path: 'sample.js',
180
+ edits: [{ oldText: 'function hello', newText: 'function greet' }],
181
+ }, framework);
182
+ expect(r.success).toBe(true);
183
+ });
184
+
185
+ it('★ 返回元数据:contentLength before/after', async () => {
186
+ const r = await tool.execute({
187
+ path: 'sample.js',
188
+ edits: [{ oldText: 'hello world', newText: 'X' }], // 11 → 1
189
+ }, framework);
190
+ expect(r.success).toBe(true);
191
+ expect(r.data.contentLength.before).toBeGreaterThan(r.data.contentLength.after);
192
+ expect(r.data.contentChanged).toBe(true);
193
+ });
194
+ });