foliko 2.0.20 → 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.
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+
3
+ // 测试 chat.js 的 _dedupeToolCallIds 私有方法
4
+ // 通过 require + 拿到 AgentChatHandler 类的 prototype
5
+
6
+ const { z } = require('zod');
7
+ const { AgentChatHandler } = require('../../src/agent/chat.js');
8
+
9
+ function makeHandler() {
10
+ // 最小化构造,跳过框架依赖
11
+ const handler = Object.create(AgentChatHandler.prototype);
12
+ return handler;
13
+ }
14
+
15
+ describe('_dedupeToolCallIds 私有方法', () => {
16
+ let handler;
17
+ beforeEach(() => {
18
+ handler = makeHandler();
19
+ });
20
+
21
+ it('空数组返回 0', () => {
22
+ expect(handler._dedupeToolCallIds([])).toBe(0);
23
+ });
24
+
25
+ it('非数组输入返回 0', () => {
26
+ expect(handler._dedupeToolCallIds(null)).toBe(0);
27
+ expect(handler._dedupeToolCallIds(undefined)).toBe(0);
28
+ });
29
+
30
+ it('没有重复时返回 0(保持原样)', () => {
31
+ const messages = [
32
+ {
33
+ role: 'assistant',
34
+ content: [
35
+ { type: 'tool-call', toolCallId: 'call_A_1', toolName: 'read', input: {} },
36
+ { type: 'tool-call', toolCallId: 'call_B_1', toolName: 'write', input: {} },
37
+ ],
38
+ },
39
+ {
40
+ role: 'tool',
41
+ content: [
42
+ { type: 'tool-result', toolCallId: 'call_A_1', toolName: 'read', output: { type: 'text', value: 'ok' } },
43
+ { type: 'tool-result', toolCallId: 'call_B_1', toolName: 'write', output: { type: 'text', value: 'ok' } },
44
+ ],
45
+ },
46
+ ];
47
+ expect(handler._dedupeToolCallIds(messages)).toBe(0);
48
+ expect(messages[0].content[0].toolCallId).toBe('call_A_1');
49
+ expect(messages[0].content[1].toolCallId).toBe('call_B_1');
50
+ });
51
+
52
+ it('★ 关键:去重重复的 tool-call ID + 同步 result', () => {
53
+ // 模拟真实场景:DeepSeek 在不同 LLM 调用中为相同 tool 生成相同 ID
54
+ const messages = [
55
+ // 第 1 轮:assistant 调 schedule_list,ID = X
56
+ {
57
+ role: 'assistant',
58
+ content: [
59
+ { type: 'tool-call', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', input: {} },
60
+ ],
61
+ },
62
+ {
63
+ role: 'tool',
64
+ content: [
65
+ { type: 'tool-result', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', output: { type: 'text', value: 'first' } },
66
+ ],
67
+ },
68
+ // 第 2 轮:assistant 又调 schedule_list,ID 还是 X(DeepSeek 复用)
69
+ {
70
+ role: 'assistant',
71
+ content: [
72
+ { type: 'tool-call', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', input: {} },
73
+ ],
74
+ },
75
+ {
76
+ role: 'tool',
77
+ content: [
78
+ { type: 'tool-result', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', output: { type: 'text', value: 'second' } },
79
+ ],
80
+ },
81
+ ];
82
+
83
+ const fixed = handler._dedupeToolCallIds(messages);
84
+ expect(fixed).toBe(2); // 1 个 call + 1 个 result 改名
85
+
86
+ // 第 1 轮的 call/result 保持原 ID
87
+ expect(messages[0].content[0].toolCallId).toBe('h_call_function_t08sdzj01nre_1');
88
+ expect(messages[1].content[0].toolCallId).toBe('h_call_function_t08sdzj01nre_1');
89
+
90
+ // 第 2 轮的 call/result 被改名
91
+ expect(messages[2].content[0].toolCallId).toMatch(/^h_call_function_t08sdzj01nre_1_dup\d+_\d+$/);
92
+ expect(messages[3].content[0].toolCallId).toBe(messages[2].content[0].toolCallId);
93
+ });
94
+
95
+ it('同一 assistant 消息里有多个 call,且都重复', () => {
96
+ const messages = [
97
+ {
98
+ role: 'assistant',
99
+ content: [
100
+ { type: 'tool-call', toolCallId: 'h_X_1', toolName: 'a', input: {} },
101
+ { type: 'tool-call', toolCallId: 'h_X_2', toolName: 'b', input: {} },
102
+ ],
103
+ },
104
+ {
105
+ role: 'tool',
106
+ content: [
107
+ { type: 'tool-result', toolCallId: 'h_X_1', toolName: 'a', output: {} },
108
+ { type: 'tool-result', toolCallId: 'h_X_2', toolName: 'b', output: {} },
109
+ ],
110
+ },
111
+ // 第二轮同样的两个 call
112
+ {
113
+ role: 'assistant',
114
+ content: [
115
+ { type: 'tool-call', toolCallId: 'h_X_1', toolName: 'a', input: {} },
116
+ { type: 'tool-call', toolCallId: 'h_X_2', toolName: 'b', input: {} },
117
+ ],
118
+ },
119
+ {
120
+ role: 'tool',
121
+ content: [
122
+ { type: 'tool-result', toolCallId: 'h_X_1', toolName: 'a', output: {} },
123
+ { type: 'tool-result', toolCallId: 'h_X_2', toolName: 'b', output: {} },
124
+ ],
125
+ },
126
+ ];
127
+
128
+ const fixed = handler._dedupeToolCallIds(messages);
129
+ expect(fixed).toBe(4);
130
+
131
+ // 第一轮保留
132
+ expect(messages[0].content[0].toolCallId).toBe('h_X_1');
133
+ expect(messages[0].content[1].toolCallId).toBe('h_X_2');
134
+
135
+ // 第二轮都改名
136
+ expect(messages[2].content[0].toolCallId).not.toBe('h_X_1');
137
+ expect(messages[2].content[1].toolCallId).not.toBe('h_X_2');
138
+ expect(messages[2].content[0].toolCallId).toMatch(/dup/);
139
+ expect(messages[2].content[1].toolCallId).toMatch(/dup/);
140
+ // 第二轮的两个 call 改名后仍不同
141
+ expect(messages[2].content[0].toolCallId).not.toBe(messages[2].content[1].toolCallId);
142
+
143
+ // 第二轮的 result 跟随同名 call
144
+ expect(messages[3].content[0].toolCallId).toBe(messages[2].content[0].toolCallId);
145
+ expect(messages[3].content[1].toolCallId).toBe(messages[2].content[1].toolCallId);
146
+ });
147
+
148
+ it('★ 真实场景模拟:weixin jsonl 文件中的重复 ID', () => {
149
+ // 模拟用户报告的真实场景:
150
+ // 3 个"对话"(conv 2、3、4)都有相同的 tool-call ID
151
+ // getBranch(leafId) 会把它们都装进 messages 数组
152
+ const messages = [
153
+ { role: 'user', content: 'conv 2 user' },
154
+ {
155
+ role: 'assistant',
156
+ content: [
157
+ { type: 'tool-call', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', input: {} },
158
+ { type: 'tool-call', toolCallId: 'h_call_function_t08sdzj01nre_2', toolName: 'read_directory', input: { path: '/app/.foliko/skills/takumi-card' } },
159
+ ],
160
+ },
161
+ {
162
+ role: 'tool',
163
+ content: [
164
+ { type: 'tool-result', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', output: { type: 'text', value: 'A' } },
165
+ { type: 'tool-result', toolCallId: 'h_call_function_t08sdzj01nre_2', toolName: 'read_directory', output: { type: 'text', value: 'A' } },
166
+ ],
167
+ },
168
+ { role: 'user', content: 'conv 3 user' },
169
+ {
170
+ role: 'assistant',
171
+ content: [
172
+ { type: 'tool-call', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', input: {} },
173
+ { type: 'tool-call', toolCallId: 'h_call_function_t08sdzj01nre_2', toolName: 'read_directory', input: { path: '/app/.foliko/skills/takumi-card' } },
174
+ ],
175
+ },
176
+ {
177
+ role: 'tool',
178
+ content: [
179
+ { type: 'tool-result', toolCallId: 'h_call_function_t08sdzj01nre_1', toolName: 'schedule_list', output: { type: 'text', value: 'B' } },
180
+ { type: 'tool-result', toolCallId: 'h_call_function_t08sdzj01nre_2', toolName: 'read_directory', output: { type: 'text', value: 'B' } },
181
+ ],
182
+ },
183
+ ];
184
+
185
+ const fixed = handler._dedupeToolCallIds(messages);
186
+ // 2 个 call (conv 3) + 2 个 result (conv 3) = 4
187
+ expect(fixed).toBe(4);
188
+
189
+ // 收集所有 call IDs
190
+ const callIds = new Set();
191
+ for (const m of messages) {
192
+ if (m.role === 'assistant') {
193
+ for (const c of m.content) {
194
+ if (c.type === 'tool-call') callIds.add(c.toolCallId);
195
+ }
196
+ }
197
+ }
198
+ // 期望:4 个 call 都是不同的 ID(2 原始 + 2 改名)
199
+ expect(callIds.size).toBe(4);
200
+
201
+ // 收集所有 result IDs
202
+ const resultIds = new Set();
203
+ for (const m of messages) {
204
+ if (m.role === 'tool') {
205
+ for (const c of m.content) {
206
+ if (c.type === 'tool-result') resultIds.add(c.toolCallId);
207
+ }
208
+ }
209
+ }
210
+ expect(resultIds.size).toBe(4);
211
+
212
+ // ★ 关键:每个 result 都能在 call 中找到匹配
213
+ for (const resultId of resultIds) {
214
+ expect(callIds.has(resultId)).toBe(true);
215
+ }
216
+ });
217
+
218
+ it('部分 result 缺失也能正确处理(不影响已有配对)', () => {
219
+ const messages = [
220
+ {
221
+ role: 'assistant',
222
+ content: [
223
+ { type: 'tool-call', toolCallId: 'h_X_1', toolName: 'a', input: {} },
224
+ ],
225
+ },
226
+ // 没有 result(模拟加载时被截断)
227
+ {
228
+ role: 'assistant',
229
+ content: [
230
+ { type: 'tool-call', toolCallId: 'h_X_1', toolName: 'a', input: {} },
231
+ ],
232
+ },
233
+ {
234
+ role: 'tool',
235
+ content: [
236
+ { type: 'tool-result', toolCallId: 'h_X_1', toolName: 'a', output: {} },
237
+ ],
238
+ },
239
+ ];
240
+
241
+ const fixed = handler._dedupeToolCallIds(messages);
242
+ expect(fixed).toBeGreaterThan(0);
243
+
244
+ // 第一个 call 保留,第二个改名
245
+ expect(messages[0].content[0].toolCallId).toBe('h_X_1');
246
+ expect(messages[1].content[0].toolCallId).toMatch(/dup/);
247
+ // 最后一个 result 还是匹配第一个 call(因为它出现在第一个 call 之后)
248
+ // 实际上看 _renameFollowingToolResult 的实现:它从 afterMsgIdx 之后找,
249
+ // 所以 [0] call 后的 result 是 [2] tool message 的 [0],会被改成第一个 call 的 ID
250
+ // 但第一个 call 已经是原始 ID,所以 result 也是原始 ID
251
+ // 这里我们只检查 IDs 都不重复
252
+ const ids = new Set();
253
+ for (const m of messages) {
254
+ for (const c of m.content || []) {
255
+ if (c.toolCallId) ids.add(c.toolCallId);
256
+ }
257
+ }
258
+ expect(ids.size).toBe(2);
259
+ });
260
+ });
@@ -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
+ });