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.
- package/package.json +2 -1
- package/plugins/core/skill-manager/index.js +34 -23
- package/plugins/core/sub-agent/index.js +103 -14
- package/plugins/executors/extension/extension-registry.js +72 -1
- package/plugins/executors/extension/index.js +68 -9
- package/plugins/io/file-system/index.js +377 -153
- package/src/agent/chat.js +19 -1
- package/src/agent/sub.js +1 -1
- package/src/tool/router.js +2 -2
- package/src/utils/message-validator.js +186 -0
- package/tests/core/chat-tool.test.js +187 -0
- package/tests/core/edit-file.test.js +194 -0
- package/tests/core/ext-call-empty-args.test.js +136 -0
- package/tests/core/sanitize-for-llm.test.js +152 -0
- package/tests/core/search.test.js +212 -0
- package/tests/core/skill-input-schema.test.js +150 -0
- package/tests/core/sub-agent-parse.test.js +247 -0
- package/tests/core/sub-agent-skills.test.js +197 -0
- package/weixin_o9cq80zgZqKPA2-s59PN43GdDy1w@im.wechat.jsonl +0 -76
|
@@ -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,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
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
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, plugin, tool;
|
|
9
|
+
let fileSystem;
|
|
10
|
+
|
|
11
|
+
function makeTree() {
|
|
12
|
+
// 创建测试目录结构:
|
|
13
|
+
// tempDir/
|
|
14
|
+
// ├── src/foo.js (含 "TODO" 和 "function")
|
|
15
|
+
// ├── src/bar.js (含 "TODO")
|
|
16
|
+
// ├── docs/readme.md (含 "TODO")
|
|
17
|
+
// ├── dist/bundle.js (含 "TODO" — 默认应被 ignore)
|
|
18
|
+
// ├── big.log (2MB — 应被 maxFileSize 跳过)
|
|
19
|
+
// ├── binary.png (二进制)
|
|
20
|
+
// ├── .gitignore (含 "node_modules_test/")
|
|
21
|
+
// ├── node_modules/foo.js (含 "TODO" — 默认应被 ignore)
|
|
22
|
+
// └── node_modules_test/x.js (含 "TODO" — 应被 .gitignore 匹配)
|
|
23
|
+
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
|
|
24
|
+
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(tempDir, 'dist'), { recursive: true });
|
|
26
|
+
fs.mkdirSync(path.join(tempDir, 'node_modules'), { recursive: true });
|
|
27
|
+
fs.mkdirSync(path.join(tempDir, 'node_modules_test'), { recursive: true });
|
|
28
|
+
|
|
29
|
+
fs.writeFileSync(path.join(tempDir, 'src', 'foo.js'), 'function foo() {\n // TODO\n}\n');
|
|
30
|
+
fs.writeFileSync(path.join(tempDir, 'src', 'bar.js'), 'function bar() {\n // TODO\n}\n');
|
|
31
|
+
fs.writeFileSync(path.join(tempDir, 'docs', 'readme.md'), '# README\n\nTODO: write docs\n');
|
|
32
|
+
fs.writeFileSync(path.join(tempDir, 'dist', 'bundle.js'), '// TODO dist\n');
|
|
33
|
+
fs.writeFileSync(path.join(tempDir, 'big.log'), 'x'.repeat(2 * 1024 * 1024)); // 2MB
|
|
34
|
+
fs.writeFileSync(path.join(tempDir, 'binary.png'), Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'JFIF']));
|
|
35
|
+
fs.writeFileSync(path.join(tempDir, '.gitignore'), 'node_modules_test/\n');
|
|
36
|
+
fs.writeFileSync(path.join(tempDir, 'node_modules', 'foo.js'), '// TODO in node_modules\n');
|
|
37
|
+
fs.writeFileSync(path.join(tempDir, 'node_modules_test', 'x.js'), '// TODO in test dir\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'search-test-'));
|
|
42
|
+
makeTree();
|
|
43
|
+
|
|
44
|
+
fileSystem = FileSystemPlugin.prototype;
|
|
45
|
+
const registeredTools = [];
|
|
46
|
+
framework = {
|
|
47
|
+
getCwd: () => tempDir,
|
|
48
|
+
registerTool: (t) => registeredTools.push(t),
|
|
49
|
+
pluginManager: { get: () => null },
|
|
50
|
+
// ★ Plugin base class 的 tool.register 会先 has() 再 register(),
|
|
51
|
+
// mock 必须两个都实现
|
|
52
|
+
toolRegistry: {
|
|
53
|
+
has: () => false,
|
|
54
|
+
register: (t) => registeredTools.push(t),
|
|
55
|
+
},
|
|
56
|
+
createSubAgent: () => ({ chat: async () => ({ success: true, message: 'x' }) }),
|
|
57
|
+
};
|
|
58
|
+
plugin = new FileSystemPlugin();
|
|
59
|
+
plugin.install(framework);
|
|
60
|
+
tool = registeredTools.find(t => t.name === 'search');
|
|
61
|
+
if (!tool) throw new Error('search_file tool not registered');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('search_file 基础搜索(向后兼容)', () => {
|
|
69
|
+
it('★ 简单关键字搜索:搜到 src/foo.js 和 src/bar.js', async () => {
|
|
70
|
+
const r = await tool.execute({ pattern: 'function' }, framework);
|
|
71
|
+
expect(r.success).toBe(true);
|
|
72
|
+
expect(r.data.length).toBeGreaterThanOrEqual(2);
|
|
73
|
+
const files = r.data.map(m => m.file);
|
|
74
|
+
expect(files.some(f => f.includes('foo.js'))).toBe(true);
|
|
75
|
+
expect(files.some(f => f.includes('bar.js'))).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('★ 默认排除 dist/ 和 node_modules/(不再误匹配)', async () => {
|
|
79
|
+
const r = await tool.execute({ pattern: 'TODO' }, framework);
|
|
80
|
+
expect(r.success).toBe(true);
|
|
81
|
+
const files = r.data.map(m => m.file);
|
|
82
|
+
expect(files.some(f => f.includes('dist/'))).toBe(false);
|
|
83
|
+
expect(files.some(f => f.includes('node_modules/'))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('★ fileType 参数兼容(搜 .md)', async () => {
|
|
87
|
+
const r = await tool.execute({ pattern: 'README', fileType: 'md' }, framework);
|
|
88
|
+
expect(r.success).toBe(true);
|
|
89
|
+
expect(r.data.length).toBeGreaterThan(0);
|
|
90
|
+
expect(r.data[0].file).toContain('readme.md');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('search_file glob 模式(新增)', () => {
|
|
95
|
+
it('★ 关键:glob="**/*.js" 只搜 .js', async () => {
|
|
96
|
+
const r = await tool.execute({ pattern: 'TODO', glob: '**/*.js' }, framework);
|
|
97
|
+
expect(r.success).toBe(true);
|
|
98
|
+
const files = r.data.map(m => m.file);
|
|
99
|
+
expect(files.every(f => f.endsWith('.js'))).toBe(true);
|
|
100
|
+
expect(files.some(f => f.includes('readme.md'))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('★ glob="src/**/*.js" 只搜 src/', async () => {
|
|
104
|
+
const r = await tool.execute({ pattern: 'TODO', glob: 'src/**/*.js' }, framework);
|
|
105
|
+
expect(r.success).toBe(true);
|
|
106
|
+
const files = r.data.map(m => m.file);
|
|
107
|
+
expect(files.every(f => f.includes('src/'))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('search_file .gitignore 感知(新增)', () => {
|
|
112
|
+
it('★ 关键:node_modules_test/ 被 .gitignore 排除', async () => {
|
|
113
|
+
// node_modules_test/ 在 .gitignore 里
|
|
114
|
+
// 应该不被搜到(即使不在默认 excludeDirs 里)
|
|
115
|
+
const r = await tool.execute({ pattern: 'TODO' }, framework);
|
|
116
|
+
expect(r.success).toBe(true);
|
|
117
|
+
const files = r.data.map(m => m.file);
|
|
118
|
+
expect(files.some(f => f.includes('node_modules_test/'))).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('★ gitignore: false 时强制不排除(用户明确控制)', async () => {
|
|
122
|
+
const r = await tool.execute({ pattern: 'TODO', gitignore: false, excludeDirs: [] }, framework);
|
|
123
|
+
// 这里应该会搜到 node_modules_test/(因为 .gitignore 被禁用)
|
|
124
|
+
// 但仍受默认 excludeDirs 影响?让我们看实现
|
|
125
|
+
// 实际:gitignore: false 时只追加默认 excludeDirs
|
|
126
|
+
// 用户可以传 excludeDirs: [] 完全禁用默认
|
|
127
|
+
expect(r.success).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('search_file 大文件保护(新增)', () => {
|
|
132
|
+
it('★ 关键:maxFileSizeKB=1024 跳过 2MB 的 big.log', async () => {
|
|
133
|
+
const r = await tool.execute({ pattern: 'x', maxFileSizeKB: 1024 }, framework);
|
|
134
|
+
expect(r.success).toBe(true);
|
|
135
|
+
const files = r.data.map(m => m.file);
|
|
136
|
+
// big.log 内容全是 'x',如果被搜到会返回
|
|
137
|
+
// 但因文件 2MB > 1MB,应被跳过
|
|
138
|
+
expect(files.some(f => f.includes('big.log'))).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('search_file 异步 IO + 元数据', () => {
|
|
143
|
+
it('★ 元数据含 filesScanned / filesWithMatches', async () => {
|
|
144
|
+
const r = await tool.execute({ pattern: 'TODO' }, framework);
|
|
145
|
+
expect(r.success).toBe(true);
|
|
146
|
+
expect(r.metadata.filesScanned).toBeGreaterThan(0);
|
|
147
|
+
expect(r.metadata.filesWithMatches).toBeGreaterThan(0);
|
|
148
|
+
expect(r.metadata.totalMatches).toBeGreaterThan(0);
|
|
149
|
+
expect(r.metadata.pattern).toBe('TODO');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('★ 搜索不存在的 pattern 返回空(不报错)', async () => {
|
|
153
|
+
const r = await tool.execute({ pattern: 'xyzzz_does_not_exist_12345' }, framework);
|
|
154
|
+
expect(r.success).toBe(true);
|
|
155
|
+
expect(r.data).toEqual([]);
|
|
156
|
+
expect(r.metadata.filesWithMatches).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('search_file 单文件模式', () => {
|
|
161
|
+
it('★ file= 指定单文件', async () => {
|
|
162
|
+
const r = await tool.execute({
|
|
163
|
+
pattern: 'function',
|
|
164
|
+
file: 'src/foo.js',
|
|
165
|
+
}, framework);
|
|
166
|
+
expect(r.success).toBe(true);
|
|
167
|
+
expect(r.data.length).toBeGreaterThan(0);
|
|
168
|
+
expect(r.data[0].file).toContain('foo.js');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('★ file= 不存在的文件报错', async () => {
|
|
172
|
+
const r = await tool.execute({
|
|
173
|
+
pattern: 'function',
|
|
174
|
+
file: 'nonexistent.js',
|
|
175
|
+
}, framework);
|
|
176
|
+
expect(r.success).toBe(false);
|
|
177
|
+
expect(r.error).toContain('文件不存在');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('search_file 正则支持', () => {
|
|
182
|
+
it('★ useRegex=true 支持正则', async () => {
|
|
183
|
+
const r = await tool.execute({
|
|
184
|
+
pattern: 'function (foo|bar)',
|
|
185
|
+
useRegex: true,
|
|
186
|
+
}, framework);
|
|
187
|
+
expect(r.success).toBe(true);
|
|
188
|
+
expect(r.data.length).toBeGreaterThanOrEqual(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('★ 无效正则报错', async () => {
|
|
192
|
+
const r = await tool.execute({
|
|
193
|
+
pattern: '[invalid(',
|
|
194
|
+
useRegex: true,
|
|
195
|
+
}, framework);
|
|
196
|
+
expect(r.success).toBe(false);
|
|
197
|
+
expect(r.error).toContain('正则');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('search_file input 校验', () => {
|
|
202
|
+
it('★ 空 pattern 报错', async () => {
|
|
203
|
+
const r = await tool.execute({ pattern: '' }, framework);
|
|
204
|
+
expect(r.success).toBe(false);
|
|
205
|
+
expect(r.error).toContain('pattern');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('★ 空格-only pattern 报错', async () => {
|
|
209
|
+
const r = await tool.execute({ pattern: ' ' }, framework);
|
|
210
|
+
expect(r.success).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
const { z } = require('zod');
|
|
3
|
+
|
|
4
|
+
// 测试 buildInputSchemaFromOptions 是否真的返回 zod schema
|
|
5
|
+
// 这是修复"args:{} 不报错"bug 的核心
|
|
6
|
+
|
|
7
|
+
// 动态加载 skill-manager 的内部函数(需要先 patch require)
|
|
8
|
+
// 由于 buildInputSchemaFromOptions 是局部函数,我们通过 export 路径来测
|
|
9
|
+
// 暂时直接重写测试逻辑
|
|
10
|
+
|
|
11
|
+
function buildInputSchemaFromOptions(options) {
|
|
12
|
+
// 复制 skill-manager 的实现
|
|
13
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
14
|
+
return z.object({}).passthrough();
|
|
15
|
+
}
|
|
16
|
+
const shape = {};
|
|
17
|
+
for (const opt of options) {
|
|
18
|
+
const name = extractOptionName(opt.flags);
|
|
19
|
+
if (!name) continue;
|
|
20
|
+
const hasValue = optionHasValue(opt.flags);
|
|
21
|
+
const isRequired = opt.required === true && hasValue;
|
|
22
|
+
const desc = opt.description || '';
|
|
23
|
+
const defaultVal = opt.defaultValue;
|
|
24
|
+
|
|
25
|
+
let zodField;
|
|
26
|
+
if (opt.schema && typeof opt.schema.parse === 'function') {
|
|
27
|
+
zodField = opt.schema;
|
|
28
|
+
} else {
|
|
29
|
+
const type = (opt.type || (hasValue ? 'string' : 'boolean')).toLowerCase();
|
|
30
|
+
if (type === 'number' || type === 'int' || type === 'integer') zodField = z.number();
|
|
31
|
+
else if (type === 'boolean' || type === 'bool') zodField = z.boolean();
|
|
32
|
+
else if (type === 'array') zodField = z.array(z.any());
|
|
33
|
+
else if (type === 'object') zodField = z.object({}).passthrough();
|
|
34
|
+
else zodField = z.string();
|
|
35
|
+
}
|
|
36
|
+
if (!isRequired) zodField = zodField.optional();
|
|
37
|
+
if (defaultVal !== undefined && defaultVal !== null) zodField = zodField.default(defaultVal);
|
|
38
|
+
zodField = zodField.describe(desc + (isRequired ? '(必填)' : ''));
|
|
39
|
+
shape[name] = zodField;
|
|
40
|
+
}
|
|
41
|
+
if (Object.keys(shape).length === 0) return z.object({}).passthrough();
|
|
42
|
+
return z.object(shape).passthrough();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractOptionName(flags) {
|
|
46
|
+
if (typeof flags !== 'string') return null;
|
|
47
|
+
const match = flags.match(/--([a-zA-Z][\w-]*)/);
|
|
48
|
+
if (match) return match[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
49
|
+
const short = flags.match(/(?:^|\s)-([a-zA-Z])(?:\s|,|$)/);
|
|
50
|
+
if (short) return short[1];
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function optionHasValue(flags) {
|
|
55
|
+
if (typeof flags !== 'string') return false;
|
|
56
|
+
return /<\w+>|\[\w+\]/.test(flags);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('★ 关键:buildInputSchemaFromOptions 必须返回 zod schema', () => {
|
|
60
|
+
it('★ 返回值必须有 .safeParse 方法(之前 bug:返回 JSON Schema 对象没有此方法)', () => {
|
|
61
|
+
const options = [
|
|
62
|
+
{ flags: '-i, --id <value>', description: '视频ID', required: true },
|
|
63
|
+
{ flags: '-t, --title <value>', description: '主标题', defaultValue: '' },
|
|
64
|
+
];
|
|
65
|
+
const schema = buildInputSchemaFromOptions(options);
|
|
66
|
+
expect(typeof schema.safeParse).toBe('function'); // ★ 关键
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('★ safeParse({}) → 报错(id 是必填)', () => {
|
|
70
|
+
const options = [
|
|
71
|
+
{ flags: '-i, --id <value>', description: '视频ID', required: true },
|
|
72
|
+
];
|
|
73
|
+
const schema = buildInputSchemaFromOptions(options);
|
|
74
|
+
const r = schema.safeParse({});
|
|
75
|
+
expect(r.success).toBe(false);
|
|
76
|
+
expect(r.error.issues[0].path).toEqual(['id']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('★ safeParse({id: "abc"}) → 通过', () => {
|
|
80
|
+
const options = [
|
|
81
|
+
{ flags: '-i, --id <value>', description: '视频ID', required: true },
|
|
82
|
+
];
|
|
83
|
+
const schema = buildInputSchemaFromOptions(options);
|
|
84
|
+
const r = schema.safeParse({ id: 'abc' });
|
|
85
|
+
expect(r.success).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('★ 关键 bug 复现:addCover(id 必填) + LLM 传 args:{}', () => {
|
|
89
|
+
// 模拟 addCover 的 options(来自 video-creator/index.js)
|
|
90
|
+
const addCoverOptions = [
|
|
91
|
+
{ flags: '-i, --id <value>', description: '视频ID(必填)', required: true },
|
|
92
|
+
{ flags: '-t, --title <value>', description: '主标题', defaultValue: '' },
|
|
93
|
+
{ flags: '-s, --subtitle <value>', description: '副标题', defaultValue: '' },
|
|
94
|
+
{ flags: '-d, --duration <value>', description: '时长(秒)', type: 'number', defaultValue: 3 },
|
|
95
|
+
{ flags: '-b, --background <value>', description: '背景色', defaultValue: '#1a1a2e' },
|
|
96
|
+
{ flags: '-x, --transition <value>', description: '转场效果', defaultValue: 'CrossZoom' },
|
|
97
|
+
{ flags: '-f, --font <value>', description: '字体', defaultValue: '微软雅黑粗体' },
|
|
98
|
+
];
|
|
99
|
+
const schema = buildInputSchemaFromOptions(addCoverOptions);
|
|
100
|
+
|
|
101
|
+
// LLM 忘记传 args(args = {})
|
|
102
|
+
const r1 = schema.safeParse({});
|
|
103
|
+
expect(r1.success).toBe(false);
|
|
104
|
+
// 错误明确指出 id 必填
|
|
105
|
+
const idIssue = r1.error.issues.find(i => i.path[0] === 'id');
|
|
106
|
+
expect(idIssue).toBeTruthy();
|
|
107
|
+
expect(idIssue.code).toBe('invalid_type');
|
|
108
|
+
|
|
109
|
+
// LLM 正确传 args
|
|
110
|
+
const r2 = schema.safeParse({ id: 'sni8zbah', title: '测试' });
|
|
111
|
+
expect(r2.success).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('★ 空 options 数组:返回空的 zod schema(而不是 JSON Schema)', () => {
|
|
115
|
+
const r1 = buildInputSchemaFromOptions([]);
|
|
116
|
+
expect(typeof r1.safeParse).toBe('function');
|
|
117
|
+
expect(r1.safeParse({}).success).toBe(true);
|
|
118
|
+
expect(r1.safeParse({extra: 1}).success).toBe(true); // passthrough
|
|
119
|
+
|
|
120
|
+
const r2 = buildInputSchemaFromOptions(null);
|
|
121
|
+
expect(typeof r2.safeParse).toBe('function');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('★ 数字类型:type=number 字段被解析为 ZodNumber', () => {
|
|
125
|
+
const schema = buildInputSchemaFromOptions([
|
|
126
|
+
{ flags: '-d, --duration <value>', description: '时长', type: 'number' },
|
|
127
|
+
]);
|
|
128
|
+
const r = schema.safeParse({ duration: 'abc' });
|
|
129
|
+
expect(r.success).toBe(false); // 字符串不能赋值给 number
|
|
130
|
+
const r2 = schema.safeParse({ duration: 30 });
|
|
131
|
+
expect(r2.success).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('★ 布尔类型:type=boolean 字段被解析为 ZodBoolean', () => {
|
|
135
|
+
const schema = buildInputSchemaFromOptions([
|
|
136
|
+
{ flags: '-v, --verbose', description: 'verbose' }, // 无 <value> → boolean
|
|
137
|
+
]);
|
|
138
|
+
expect(schema.safeParse({ verbose: true }).success).toBe(true);
|
|
139
|
+
expect(schema.safeParse({ verbose: 'yes' }).success).toBe(false); // 字符串不能赋值给 boolean
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('★ 可选字段 + default:使用 default 值时通过', () => {
|
|
143
|
+
const schema = buildInputSchemaFromOptions([
|
|
144
|
+
{ flags: '-t, --title <value>', description: '标题', defaultValue: 'Untitled' },
|
|
145
|
+
]);
|
|
146
|
+
const r = schema.safeParse({}); // 不传 title
|
|
147
|
+
expect(r.success).toBe(true);
|
|
148
|
+
expect(r.data.title).toBe('Untitled');
|
|
149
|
+
});
|
|
150
|
+
});
|