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.
- package/.editorconfig +56 -56
- package/.lintstagedrc +7 -7
- package/.prettierignore +29 -29
- package/.prettierrc +11 -11
- package/Dockerfile +63 -63
- package/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +3 -5
- package/plugins/core/skill-manager/index.js +34 -23
- package/plugins/core/sub-agent/index.js +103 -14
- package/plugins/core/workflow/context.js +941 -941
- package/plugins/core/workflow/engine.js +66 -66
- package/plugins/core/workflow/js-runner.js +318 -318
- package/plugins/core/workflow/json-runner.js +323 -323
- package/plugins/core/workflow/stages/choice.js +74 -74
- package/plugins/core/workflow/stages/each.js +123 -123
- package/plugins/core/workflow/stages/parallel.js +69 -69
- package/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/skills/find-skills/SKILL.md +133 -133
- package/src/agent/chat.js +207 -222
- package/src/agent/sub.js +29 -26
- package/src/agent/tool-loop.js +648 -0
- package/src/llm/provider.js +12 -28
- package/src/plugin/base.js +17 -14
- package/src/plugin/manager.js +19 -0
- 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/disable-thinking.test.js +64 -0
- package/tests/core/edit-file.test.js +194 -0
- package/tests/core/ext-call-empty-args.test.js +136 -0
- package/tests/core/reasoning-content.test.js +129 -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/strip-stale-tool-calls.test.js +154 -0
- package/tests/core/sub-agent-parse.test.js +247 -0
- package/tests/core/sub-agent-skills.test.js +197 -0
- package/tests/core/tool-loop.test.js +208 -0
- package/weixin_o9cq80zgZqKPA2-s59PN43GdDy1w@im.wechat.jsonl +0 -76
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// 测试 _stripStaleToolCalls:处理"过时"的 tool_call 消息
|
|
4
|
+
// 场景:旧 session 文件(修复前保存)的 assistant(tool_call) 消息没有 reasoning part
|
|
5
|
+
// 截断/压缩后这些消息会被发给 DeepSeek,触发 "reasoning_content must be passed back"
|
|
6
|
+
|
|
7
|
+
const { AgentChatHandler } = require('../../src/agent/chat.js');
|
|
8
|
+
|
|
9
|
+
function makeHandler({ thinkingMode = true } = {}) {
|
|
10
|
+
const h = Object.create(AgentChatHandler.prototype);
|
|
11
|
+
h._thinkingMode = thinkingMode;
|
|
12
|
+
return h;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('_stripStaleToolCalls 处理"过时"消息', () => {
|
|
16
|
+
it('★ 关键:thinking 模式下,缺 reasoning 的 assistant 消息移除 tool_calls', () => {
|
|
17
|
+
const h = makeHandler({ thinkingMode: true });
|
|
18
|
+
const messages = [{
|
|
19
|
+
role: 'assistant',
|
|
20
|
+
content: [
|
|
21
|
+
{ type: 'text', text: '我先看看文件' },
|
|
22
|
+
{ type: 'tool-call', toolCallId: 'call_abc', toolName: 'read', input: {} },
|
|
23
|
+
],
|
|
24
|
+
}];
|
|
25
|
+
h._stripStaleToolCalls(messages);
|
|
26
|
+
expect(messages[0].content).toHaveLength(1);
|
|
27
|
+
expect(messages[0].content[0].type).toBe('text');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('★ 关键:有 reasoning 的消息保留 tool_calls', () => {
|
|
31
|
+
const h = makeHandler({ thinkingMode: true });
|
|
32
|
+
const messages = [{
|
|
33
|
+
role: 'assistant',
|
|
34
|
+
content: [
|
|
35
|
+
{ type: 'reasoning', text: 'thinking...' },
|
|
36
|
+
{ type: 'tool-call', toolCallId: 'call_abc', toolName: 'read', input: {} },
|
|
37
|
+
],
|
|
38
|
+
}];
|
|
39
|
+
h._stripStaleToolCalls(messages);
|
|
40
|
+
expect(messages[0].content).toHaveLength(2); // 保留
|
|
41
|
+
expect(messages[0].content[1].type).toBe('tool-call');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('★ 非 thinking 模式:不处理', () => {
|
|
45
|
+
const h = makeHandler({ thinkingMode: false });
|
|
46
|
+
const messages = [{
|
|
47
|
+
role: 'assistant',
|
|
48
|
+
content: [
|
|
49
|
+
{ type: 'tool-call', toolCallId: 'call_abc', toolName: 'read', input: {} },
|
|
50
|
+
],
|
|
51
|
+
}];
|
|
52
|
+
h._stripStaleToolCalls(messages);
|
|
53
|
+
// 保留 tool_call
|
|
54
|
+
expect(messages[0].content).toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('★ 多个 tool_call 部分都被清理', () => {
|
|
58
|
+
const h = makeHandler({ thinkingMode: true });
|
|
59
|
+
const messages = [{
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
content: [
|
|
62
|
+
{ type: 'tool-call', toolCallId: 'c1', toolName: 'a', input: {} },
|
|
63
|
+
{ type: 'text', text: '中间文字' },
|
|
64
|
+
{ type: 'tool-call', toolCallId: 'c2', toolName: 'b', input: {} },
|
|
65
|
+
{ type: 'tool-call', toolCallId: 'c3', toolName: 'c', input: {} },
|
|
66
|
+
],
|
|
67
|
+
}];
|
|
68
|
+
const n = h._stripStaleToolCalls(messages);
|
|
69
|
+
expect(n).toBe(3);
|
|
70
|
+
expect(messages[0].content).toEqual([{ type: 'text', text: '中间文字' }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('★ 处理 OpenAI 兼容格式的 msg.tool_calls(顶层字段)', () => {
|
|
74
|
+
const h = makeHandler({ thinkingMode: true });
|
|
75
|
+
const messages = [{
|
|
76
|
+
role: 'assistant',
|
|
77
|
+
content: [{ type: 'text', text: '我需要先读文件' }],
|
|
78
|
+
tool_calls: [
|
|
79
|
+
{ id: 'call_1', type: 'function', function: { name: 'read', arguments: '{}' } },
|
|
80
|
+
{ id: 'call_2', type: 'function', function: { name: 'write', arguments: '{}' } },
|
|
81
|
+
],
|
|
82
|
+
}];
|
|
83
|
+
const n = h._stripStaleToolCalls(messages);
|
|
84
|
+
expect(n).toBe(2);
|
|
85
|
+
expect(messages[0].tool_calls).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('★ 不影响非 assistant 消息', () => {
|
|
89
|
+
const h = makeHandler({ thinkingMode: true });
|
|
90
|
+
const messages = [
|
|
91
|
+
{ role: 'user', content: 'hi' },
|
|
92
|
+
{
|
|
93
|
+
role: 'tool',
|
|
94
|
+
content: [
|
|
95
|
+
{ type: 'tool-result', toolCallId: 'abc', toolName: 'x', output: { type: 'text', value: 'ok' } },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
const n = h._stripStaleToolCalls(messages);
|
|
100
|
+
expect(n).toBe(0);
|
|
101
|
+
expect(messages[1].content).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('★ 模拟 _createPrepareStep 截断后调用', () => {
|
|
105
|
+
// 模拟实际场景:12 条消息截断到 5 条,其中包含"旧"assistant(tool_call) 消息
|
|
106
|
+
const messages = [
|
|
107
|
+
{ role: 'user', content: '问题1' },
|
|
108
|
+
// 截断时这部分会被丢掉
|
|
109
|
+
{ role: 'assistant', content: '旧回复1' },
|
|
110
|
+
{ role: 'user', content: '问题2' },
|
|
111
|
+
{ role: 'assistant', content: '旧回复2' },
|
|
112
|
+
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'old1', toolName: 'x', output: { type: 'text', value: 'ok' } }] },
|
|
113
|
+
{ role: 'user', content: '问题3' },
|
|
114
|
+
{ role: 'assistant', content: '旧回复3' },
|
|
115
|
+
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'old2', toolName: 'x', output: { type: 'text', value: 'ok' } }] },
|
|
116
|
+
// 截断后保留的
|
|
117
|
+
{ role: 'user', content: '问题4' },
|
|
118
|
+
{ role: 'assistant', content: [
|
|
119
|
+
{ type: 'text', text: '我先看看' },
|
|
120
|
+
{ type: 'tool-call', toolCallId: 'recent1', toolName: 'read', input: {} },
|
|
121
|
+
] },
|
|
122
|
+
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'recent1', toolName: 'read', output: { type: 'text', value: 'data' } }] },
|
|
123
|
+
{ role: 'user', content: '问题5' },
|
|
124
|
+
];
|
|
125
|
+
// 截断:保留最后 5 条 = [7, 8, 9, 10, 11]
|
|
126
|
+
const trimmed = messages.slice(-5);
|
|
127
|
+
messages.length = 0;
|
|
128
|
+
messages.push(...trimmed);
|
|
129
|
+
|
|
130
|
+
// 跑 _stripStaleToolCalls
|
|
131
|
+
const h = makeHandler({ thinkingMode: true });
|
|
132
|
+
const n = h._stripStaleToolCalls(messages);
|
|
133
|
+
expect(n).toBe(1);
|
|
134
|
+
// messages[2] 是 assistant(缺 reasoning 的 tool_call 被移除)
|
|
135
|
+
expect(messages[2].content).toEqual([{ type: 'text', text: '我先看看' }]);
|
|
136
|
+
// 其他消息不动
|
|
137
|
+
expect(messages).toHaveLength(5);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('★ 返回值:清理的工具调用数量', () => {
|
|
141
|
+
const h = makeHandler({ thinkingMode: true });
|
|
142
|
+
const messages = [
|
|
143
|
+
{
|
|
144
|
+
role: 'assistant',
|
|
145
|
+
content: [
|
|
146
|
+
{ type: 'tool-call', toolCallId: 'c1', toolName: 'a', input: {} },
|
|
147
|
+
{ type: 'tool-call', toolCallId: 'c2', toolName: 'b', input: {} },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
const n = h._stripStaleToolCalls(messages);
|
|
152
|
+
expect(n).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
});
|