foliko 2.0.21 → 2.0.23

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.
Files changed (42) hide show
  1. package/.editorconfig +56 -56
  2. package/.lintstagedrc +7 -7
  3. package/.prettierignore +29 -29
  4. package/.prettierrc +11 -11
  5. package/Dockerfile +63 -63
  6. package/install.ps1 +129 -129
  7. package/install.sh +121 -121
  8. package/package.json +3 -5
  9. package/plugins/core/skill-manager/index.js +34 -23
  10. package/plugins/core/sub-agent/index.js +103 -14
  11. package/plugins/core/workflow/context.js +941 -941
  12. package/plugins/core/workflow/engine.js +66 -66
  13. package/plugins/core/workflow/js-runner.js +318 -318
  14. package/plugins/core/workflow/json-runner.js +323 -323
  15. package/plugins/core/workflow/stages/choice.js +74 -74
  16. package/plugins/core/workflow/stages/each.js +123 -123
  17. package/plugins/core/workflow/stages/parallel.js +69 -69
  18. package/plugins/executors/extension/extension-registry.js +72 -1
  19. package/plugins/executors/extension/index.js +68 -9
  20. package/plugins/io/file-system/index.js +377 -153
  21. package/skills/find-skills/SKILL.md +133 -133
  22. package/src/agent/chat.js +207 -222
  23. package/src/agent/sub.js +29 -26
  24. package/src/agent/tool-loop.js +648 -0
  25. package/src/llm/provider.js +12 -28
  26. package/src/plugin/base.js +17 -14
  27. package/src/plugin/manager.js +19 -0
  28. package/src/tool/router.js +2 -2
  29. package/src/utils/message-validator.js +186 -0
  30. package/tests/core/chat-tool.test.js +187 -0
  31. package/tests/core/disable-thinking.test.js +64 -0
  32. package/tests/core/edit-file.test.js +194 -0
  33. package/tests/core/ext-call-empty-args.test.js +136 -0
  34. package/tests/core/reasoning-content.test.js +129 -0
  35. package/tests/core/sanitize-for-llm.test.js +152 -0
  36. package/tests/core/search.test.js +212 -0
  37. package/tests/core/skill-input-schema.test.js +150 -0
  38. package/tests/core/strip-stale-tool-calls.test.js +154 -0
  39. package/tests/core/sub-agent-parse.test.js +247 -0
  40. package/tests/core/sub-agent-skills.test.js +197 -0
  41. package/tests/core/tool-loop.test.js +208 -0
  42. package/weixin_o9cq80zgZqKPA2-s59PN43GdDy1w@im.wechat.jsonl +0 -76
@@ -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
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ const { z } = require('zod');
3
+ const ExtensionExecutorPlugin = require('../../plugins/executors/extension/index.js');
4
+
5
+ function makePlugin(tools = {}) {
6
+ const p = Object.create(ExtensionExecutorPlugin.prototype);
7
+ p._framework = {
8
+ emit: () => {},
9
+ pluginManager: { get: () => null },
10
+ };
11
+ p._registry = {
12
+ getPlugin(name) {
13
+ if (tools[name]) return { name, tools: tools[name].tools || [] };
14
+ return null;
15
+ },
16
+ getTool: () => null,
17
+ };
18
+ return p;
19
+ }
20
+
21
+ describe('_emptyArgsError 专门处理 args={} 的情况', () => {
22
+ it('★ 关键:args={} + 工具必填 id → 返回明确错误提示用 ext_skill', () => {
23
+ const addCoverTool = {
24
+ name: 'addCover',
25
+ description: '添加片头',
26
+ inputSchema: z.object({
27
+ id: z.string().describe('视频ID(必填)'),
28
+ title: z.string().optional(),
29
+ }),
30
+ execute: async () => 'ok',
31
+ };
32
+ const p = makePlugin({ 'skill:creator': { tools: [addCoverTool] } });
33
+ // 新签名:(plugin, tool, toolArgs, toolDef, zodError)
34
+ const r = p._emptyArgsError('skill:creator', 'addCover', {}, addCoverTool, {
35
+ issues: [{ path: ['id'], message: 'Required' }],
36
+ });
37
+ expect(r.success).toBe(false);
38
+ expect(r.error).toContain('参数缺失');
39
+ expect(r.error).toContain('args 为空对象');
40
+ expect(r.receivedArgs).toEqual({});
41
+ expect(r.action).toBe('call_ext_skill_first');
42
+ expect(r.hint).toContain('ext_skill');
43
+ expect(r.hint).toContain('id'); // 必填字段应被提及
44
+ });
45
+
46
+ it('★ 关键:从 zodError.issues 提取必填字段(即使 schema shape 解析失败)', () => {
47
+ const tool = {
48
+ name: 'foo',
49
+ inputSchema: z.object({ x: z.string(), y: z.string(), z: z.string().optional() }),
50
+ execute: async () => '',
51
+ };
52
+ const p = makePlugin();
53
+ const r = p._emptyArgsError('plugin', 'foo', {}, tool, {
54
+ issues: [
55
+ { path: ['x'], message: 'Required' },
56
+ { path: ['y'], message: 'Required' },
57
+ ],
58
+ });
59
+ expect(r.hint).toContain('x');
60
+ expect(r.hint).toContain('y');
61
+ expect(r.hint).not.toContain('z'); // z 是 optional,不应被提及
62
+ });
63
+
64
+ it('★ 关键:error 中要明确"为什么失败"和"如何修复"', () => {
65
+ const tool = {
66
+ name: 'publish',
67
+ inputSchema: z.object({ id: z.string() }),
68
+ execute: async () => '',
69
+ };
70
+ const p = makePlugin();
71
+ const r = p._emptyArgsError('skill:baidu', 'publish', {}, tool, {
72
+ issues: [{ path: ['id'], message: 'Required' }],
73
+ });
74
+ // 错误信息要包含可操作的具体指引
75
+ expect(r.error).toMatch(/参数缺失.*publish/);
76
+ expect(r.hint).toMatch(/ext_skill.*plugin.*必填字段/);
77
+ });
78
+ });
79
+
80
+ describe('_doExecute 集成:空 args 走特殊错误路径', () => {
81
+ it('★ 关键:ext_call({plugin, tool, args:{}}) → 返回 emptyArgsError', async () => {
82
+ const addCoverTool = {
83
+ name: 'addCover',
84
+ description: '添加片头',
85
+ inputSchema: z.object({
86
+ id: z.string().describe('视频ID(必填)'),
87
+ }),
88
+ execute: async (args) => {
89
+ if (!args.id) throw new Error('视频项目不存在: undefined');
90
+ return 'ok';
91
+ },
92
+ };
93
+ const p = makePlugin({ 'skill:creator': { tools: [addCoverTool] } });
94
+ const r = await p._executeExtensionTool('skill:creator', 'addCover', {});
95
+ expect(r.success).toBe(false);
96
+ expect(r.action).toBe('call_ext_skill_first');
97
+ expect(r.error).toContain('参数缺失');
98
+ expect(r.hint).toContain('ext_skill');
99
+ });
100
+
101
+ it('★ 关键:ext_call({plugin, tool, args: {id: "abc"}}) → 正常执行', async () => {
102
+ let receivedArgs = null;
103
+ const tool = {
104
+ name: 'addCover',
105
+ inputSchema: z.object({ id: z.string() }),
106
+ execute: async (args) => {
107
+ receivedArgs = args;
108
+ return 'ok';
109
+ },
110
+ };
111
+ const p = makePlugin({ 'skill:creator': { tools: [tool] } });
112
+ const r = await p._executeExtensionTool('skill:creator', 'addCover', { id: 'abc' });
113
+ expect(r.success).toBe(true);
114
+ expect(receivedArgs).toEqual({ id: 'abc' });
115
+ });
116
+ });
117
+
118
+ describe('ext_call 工具描述', () => {
119
+ it('★ 描述里必须强调 args 必填 + 工作流(先 ext_skill)', () => {
120
+ const p = new ExtensionExecutorPlugin();
121
+ const extCall = p._createExtCallTool();
122
+ expect(extCall.description).toContain('args');
123
+ expect(extCall.description).toContain('必填');
124
+ expect(extCall.description).toContain('ext_skill');
125
+ // 给具体的多参数示例
126
+ expect(extCall.description).toMatch(/id:\s*['"]/);
127
+ });
128
+
129
+ it('★ inputSchema.args.description 必须强调"先查 ext_skill"', () => {
130
+ const p = new ExtensionExecutorPlugin();
131
+ const extCall = p._createExtCallTool();
132
+ const argDesc = extCall.inputSchema.shape.args.description;
133
+ expect(argDesc).toContain('必填');
134
+ expect(argDesc).toContain('ext_skill');
135
+ });
136
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ // 测试 _injectReasoningContent:DeepSeek thinking mode 的关键修复
4
+ // 关键:reasoning 必须作为 {type: 'reasoning', text} part 放入 content 数组
5
+ // 不能直接 msg.reasoning_content = '...'(AI SDK 不认识这个字段,丢弃后报错)
6
+
7
+ const path = require('path');
8
+ const src = require('fs').readFileSync(
9
+ path.join(__dirname, '../../src/agent/chat.js'),
10
+ 'utf-8'
11
+ );
12
+
13
+ // 静态检查:方法已定义
14
+ describe('★ _injectReasoningContent 静态检查(关键修复)', () => {
15
+ it('★ chat.js 包含 _injectReasoningContent 方法', () => {
16
+ expect(src).toMatch(/_injectReasoningContent\s*\(/);
17
+ });
18
+
19
+ it('★ 不应再使用 msg.reasoning_content = 这种直接赋值(排除注释行)', () => {
20
+ // 关键:原始 bug 代码是 msg.reasoning_content = reasoningContent
21
+ // 修复后应该是 _injectReasoningContent(msg, ...)
22
+ // 允许的例外:delete msg.reasoning_content(清理旧字段);typeof 检查;=== 比较
23
+ // 逐行检查,跳过 // 注释行
24
+ const lines = src.split('\n');
25
+ const directAssignments = lines.filter(line => {
26
+ const trimmed = line.trim();
27
+ if (trimmed.startsWith('//')) return false; // 跳过注释
28
+ if (trimmed.startsWith('*')) return false; // 跳过 JSDoc
29
+ if (trimmed.startsWith('delete ')) return false; // 允许 delete
30
+ if (trimmed.startsWith('typeof ')) return false; // 允许 typeof 检查
31
+ // 只匹配 "msg.reasoning_content =" 后面不是 =(即 === 排除)
32
+ return /msg\.reasoning_content\s*=[^=]/.test(line);
33
+ });
34
+ expect(directAssignments).toEqual([]);
35
+ });
36
+
37
+ it('★ 修复版使用 ToolLoop 处理 reasoning(不再需要 injectReasoningContent)', () => {
38
+ // ToolLoop 内部自动处理 reasoning_content,不需要在 chat.js 里 inject
39
+ // _injectReasoningContent 仍保留用于旧 session 兼容
40
+ expect(src).toMatch(/_injectReasoningContent\s*\(/);
41
+ });
42
+ });
43
+
44
+ // 动态测试:实际验证 _injectReasoningContent 的行为
45
+ // 通过创建最小化的 AgentChatHandler 实例调用方法
46
+ describe('_injectReasoningContent 行为测试', () => {
47
+ // 动态 require chat.js 并取 AgentChatHandler 类
48
+ const { AgentChatHandler } = require('../../src/agent/chat.js');
49
+
50
+ function makeHandler() {
51
+ // 创建不调用构造函数的实例(只取方法)
52
+ return Object.create(AgentChatHandler.prototype);
53
+ }
54
+
55
+ it('★ 关键:content 是 string 时,转为 array 并加 reasoning part 在前', () => {
56
+ const handler = makeHandler();
57
+ const msg = { role: 'assistant', content: 'hello' };
58
+ handler._injectReasoningContent(msg, 'thinking text');
59
+ expect(Array.isArray(msg.content)).toBe(true);
60
+ expect(msg.content[0]).toEqual({ type: 'reasoning', text: 'thinking text' });
61
+ expect(msg.content[1]).toEqual({ type: 'text', text: 'hello' });
62
+ });
63
+
64
+ it('★ 关键:content 是 array 时,unshift reasoning part', () => {
65
+ const handler = makeHandler();
66
+ const msg = {
67
+ role: 'assistant',
68
+ content: [
69
+ { type: 'text', text: 'first' },
70
+ { type: 'tool-call', toolCallId: 'tc1', toolName: 'foo', input: {} },
71
+ ],
72
+ };
73
+ handler._injectReasoningContent(msg, 'reasoning text');
74
+ expect(msg.content[0]).toEqual({ type: 'reasoning', text: 'reasoning text' });
75
+ expect(msg.content[1]).toEqual({ type: 'text', text: 'first' });
76
+ expect(msg.content[2].type).toBe('tool-call');
77
+ });
78
+
79
+ it('★ 关键:已有 reasoning part 时合并(不重复)', () => {
80
+ const handler = makeHandler();
81
+ const msg = {
82
+ role: 'assistant',
83
+ content: [
84
+ { type: 'reasoning', text: 'OLD' },
85
+ { type: 'text', text: 'hello' },
86
+ ],
87
+ };
88
+ handler._injectReasoningContent(msg, 'NEW');
89
+ expect(msg.content).toHaveLength(2);
90
+ expect(msg.content[0]).toEqual({ type: 'reasoning', text: 'NEW' });
91
+ expect(msg.content[1]).toEqual({ type: 'text', text: 'hello' });
92
+ });
93
+
94
+ it('★ 关键:空 reasoningText 不修改消息', () => {
95
+ const handler = makeHandler();
96
+ const msg = { role: 'assistant', content: 'hello' };
97
+ handler._injectReasoningContent(msg, '');
98
+ expect(msg.content).toBe('hello'); // 未修改
99
+
100
+ handler._injectReasoningContent(msg, null);
101
+ expect(msg.content).toBe('hello'); // 未修改
102
+ });
103
+
104
+ it('★ content 是 undefined 时新建 array', () => {
105
+ const handler = makeHandler();
106
+ const msg = { role: 'assistant' };
107
+ handler._injectReasoningContent(msg, 'reasoning');
108
+ expect(msg.content).toEqual([{ type: 'reasoning', text: 'reasoning' }]);
109
+ });
110
+
111
+ it('★ 验证:ToolLoop 在 _buildApiMessages 中保留 reasoning(不丢失 tool-call 的配对)', () => {
112
+ // ToolLoop 的 _buildApiMessages 应该把 assistant(tool-call+reasoning) 转为 OpenAI 格式
113
+ // 不再依赖 AI SDK 的 convertToOpenAICompatibleMessages
114
+ const { ToolLoop } = require('../../src/agent/tool-loop.js');
115
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
116
+
117
+ const messages = [
118
+ { role: 'user', content: 'hi' },
119
+ { role: 'assistant', content: [{ type: 'text', text: 'I think' }, { type: 'tool-call', toolCallId: 'call_1', toolName: 'read', input: {} }] },
120
+ ];
121
+ const api = loop._buildApiMessages(messages, 'you are a bot');
122
+ // 第二个消息应转为 OpenAI 格式:text content + tool_calls
123
+ expect(api[1].role).toBe('user');
124
+ expect(api[2].role).toBe('assistant');
125
+ expect(api[2].tool_calls).toBeDefined();
126
+ expect(api[2].tool_calls[0].id).toBe('call_1');
127
+ expect(api[2].content).toBe('I think');
128
+ });
129
+ });
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ const { sanitizeForLLM } = require('../../src/utils/message-validator.js');
3
+
4
+ describe('sanitizeForLLM 防御性清洗', () => {
5
+ it('空数组返回空', () => {
6
+ const r = sanitizeForLLM([]);
7
+ expect(r.messages).toEqual([]);
8
+ expect(r.fixed).toBe(0);
9
+ expect(r.dropped).toBe(0);
10
+ });
11
+
12
+ it('非数组输入返回空', () => {
13
+ expect(sanitizeForLLM(null).messages).toEqual([]);
14
+ expect(sanitizeForLLM(undefined).messages).toEqual([]);
15
+ });
16
+
17
+ it('正常消息保持原样', () => {
18
+ const messages = [
19
+ { role: 'user', content: 'hello' },
20
+ { role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
21
+ { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'x', toolName: 'y', output: { type: 'json', value: 1 } }] },
22
+ ];
23
+ const r = sanitizeForLLM(messages);
24
+ expect(r.messages).toEqual(messages);
25
+ expect(r.fixed).toBe(0);
26
+ expect(r.dropped).toBe(0);
27
+ });
28
+
29
+ it('★ 关键:user.content = null 应丢弃', () => {
30
+ const r = sanitizeForLLM([{ role: 'user', content: null }]);
31
+ expect(r.messages).toEqual([]);
32
+ expect(r.dropped).toBe(1);
33
+ });
34
+
35
+ it('★ 关键:user.content = undefined 应丢弃', () => {
36
+ const r = sanitizeForLLM([{ role: 'user' }]);
37
+ expect(r.messages).toEqual([]);
38
+ expect(r.dropped).toBe(1);
39
+ });
40
+
41
+ it('★ 关键:tool.content = null 应丢弃', () => {
42
+ const r = sanitizeForLLM([{ role: 'tool', content: null }]);
43
+ expect(r.messages).toEqual([]);
44
+ expect(r.dropped).toBe(1);
45
+ });
46
+
47
+ it('★ 关键:tool.content = "string" 应丢弃', () => {
48
+ const r = sanitizeForLLM([{ role: 'tool', content: 'not array' }]);
49
+ expect(r.messages).toEqual([]);
50
+ expect(r.dropped).toBe(1);
51
+ });
52
+
53
+ it('assistant.content = object 应转为 string', () => {
54
+ const r = sanitizeForLLM([{ role: 'assistant', content: { foo: 'bar' } }]);
55
+ expect(r.messages[0].content).toBe('{"foo":"bar"}');
56
+ expect(r.fixed).toBe(1);
57
+ });
58
+
59
+ it('assistant content array 中 part 缺 type 应丢弃', () => {
60
+ const r = sanitizeForLLM([{
61
+ role: 'assistant',
62
+ content: [
63
+ { type: 'text', text: 'ok' },
64
+ { noType: 'broken' },
65
+ null,
66
+ ],
67
+ }]);
68
+ expect(r.messages[0].content).toEqual([{ type: 'text', text: 'ok' }]);
69
+ expect(r.fixed).toBe(2);
70
+ });
71
+
72
+ it('user content array 中 part.type 不在白名单应丢弃', () => {
73
+ const r = sanitizeForLLM([{
74
+ role: 'user',
75
+ content: [
76
+ { type: 'text', text: 'ok' },
77
+ { type: 'tool-call', toolCallId: 'x' }, // user 不允许 tool-call
78
+ { type: 'image', image: 'data:...' }, // image 允许
79
+ ],
80
+ }]);
81
+ expect(r.messages[0].content).toHaveLength(2);
82
+ expect(r.fixed).toBe(1);
83
+ });
84
+
85
+ it('tool content array 中 part.type 不在白名单应丢弃', () => {
86
+ const r = sanitizeForLLM([{
87
+ role: 'tool',
88
+ content: [
89
+ { type: 'tool-result', toolCallId: 'x', toolName: 'y', output: { type: 'json', value: 1 } },
90
+ { type: 'text', text: 'should be dropped' },
91
+ ],
92
+ }]);
93
+ expect(r.messages[0].content).toHaveLength(1);
94
+ expect(r.fixed).toBe(1);
95
+ });
96
+
97
+ it('tool-result output 缺 type 应修复为 text', () => {
98
+ const r = sanitizeForLLM([{
99
+ role: 'tool',
100
+ content: [{
101
+ type: 'tool-result',
102
+ toolCallId: 'x',
103
+ toolName: 'y',
104
+ output: { value: 1 } // 缺 type
105
+ }],
106
+ }]);
107
+ expect(r.messages[0].content[0].output).toEqual({ type: 'text', value: '{"value":1}' });
108
+ expect(r.fixed).toBe(1);
109
+ });
110
+
111
+ it('tool-result output.type 不在白名单应降级为 text', () => {
112
+ const r = sanitizeForLLM([{
113
+ role: 'tool',
114
+ content: [{
115
+ type: 'tool-result',
116
+ toolCallId: 'x',
117
+ toolName: 'y',
118
+ output: { type: 'unknown-type', value: 1 }
119
+ }],
120
+ }]);
121
+ expect(r.messages[0].content[0].output.type).toBe('text');
122
+ expect(r.fixed).toBe(1);
123
+ });
124
+
125
+ it('未知 role 应丢弃', () => {
126
+ const r = sanitizeForLLM([
127
+ { role: 'compactionSummary', content: 'foo' },
128
+ { role: 'user', content: 'hi' },
129
+ ]);
130
+ expect(r.messages).toHaveLength(1);
131
+ expect(r.dropped).toBe(1);
132
+ });
133
+
134
+ it('★ 实战:cli_default 场景 模拟消息(确保全部通过 sanitize)', () => {
135
+ // 模拟 cli_default.jsonl 中典型消息 + 一些异常
136
+ const messages = [
137
+ { role: 'user', content: '请帮我做视频' },
138
+ { role: 'assistant', content: [
139
+ { type: 'text', text: '好的' },
140
+ { type: 'tool-call', toolCallId: 'h_x_1', toolName: 'ext_call', input: { plugin: 'creator' } },
141
+ ]},
142
+ { role: 'tool', content: [
143
+ { type: 'tool-result', toolCallId: 'h_x_1', toolName: 'ext_call', output: { type: 'json', value: { success: true } } },
144
+ ]},
145
+ { role: 'user', content: null }, // 异常
146
+ { role: 'assistant', content: '完成' },
147
+ ];
148
+ const r = sanitizeForLLM(messages);
149
+ expect(r.messages).toHaveLength(4);
150
+ expect(r.dropped).toBe(1);
151
+ });
152
+ });