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.
- 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 +108 -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/dedupe-tool-call-ids.test.js +260 -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/src/agent/chat.js
CHANGED
|
@@ -19,7 +19,7 @@ const {
|
|
|
19
19
|
} = require('ai');
|
|
20
20
|
const { z } = require('zod');
|
|
21
21
|
const { autoSplitToolResult } = require('../../plugins/executors/data-splitter');
|
|
22
|
-
const { normalizeToolOutputs, validateAll } = require('../utils/message-validator');
|
|
22
|
+
const { normalizeToolOutputs, validateAll, sanitizeForLLM } = require('../utils/message-validator');
|
|
23
23
|
const { cleanResponse } = require('../utils');
|
|
24
24
|
const { ChatQueueManager } = require('../common/queue');
|
|
25
25
|
const { TokenCounter } = require('../llm/tokens');
|
|
@@ -1073,6 +1073,25 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1073
1073
|
this._repairInvalidToolCallsInMessages(messages);
|
|
1074
1074
|
this._repairAndPrefixHistoricalIds(messages);
|
|
1075
1075
|
|
|
1076
|
+
// ★ 关键:去重重复的 tool-call ID
|
|
1077
|
+
// 场景:DeepSeek API 在某些情况下会为不同的 LLM 调用生成相同的 toolCallId
|
|
1078
|
+
// (或者重放历史消息时保留了原始 ID)。同一请求中 tool-call ID 必须唯一,
|
|
1079
|
+
// 否则 DeepSeek 会抛 "invalid params, tool result's tool id(X) not found" 错误。
|
|
1080
|
+
// 策略:保留第一次出现的 ID,后续出现重命名为 <id>_v2/_v3... 并同步更新 tool-result。
|
|
1081
|
+
const deduped = this._dedupeToolCallIds(messages);
|
|
1082
|
+
if (deduped > 0) {
|
|
1083
|
+
logger.warn(`[_prepareSession] 去重 ${deduped} 个重复 tool-call ID`);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ★ 防御性 sanitize:确保所有 message 符合 AI SDK ModelMessage[] schema
|
|
1087
|
+
// 修复/丢弃:content 类型错误、part 缺 type、tool-result.output 异常等
|
|
1088
|
+
const sanitized = sanitizeForLLM(messages);
|
|
1089
|
+
if (sanitized.fixed > 0 || sanitized.dropped > 0) {
|
|
1090
|
+
logger.warn(`[_prepareSession] sanitize: fixed=${sanitized.fixed}, dropped=${sanitized.dropped}`);
|
|
1091
|
+
messages.length = 0;
|
|
1092
|
+
messages.push(...sanitized.messages);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1076
1095
|
// 单次遍历完成所有验证 + 规范化(validateAll 内部处理 error-text/error-json)
|
|
1077
1096
|
const validated = validateAll(messages);
|
|
1078
1097
|
if (validated.length !== messages.length) {
|
|
@@ -1106,6 +1125,85 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1106
1125
|
return { messageStore, messages, userMessage };
|
|
1107
1126
|
}
|
|
1108
1127
|
|
|
1128
|
+
/**
|
|
1129
|
+
* ★ 去重 messages 中重复的 tool-call / tool-result ID
|
|
1130
|
+
* DeepSeek API 严格要求同一请求中 toolCallId 唯一。
|
|
1131
|
+
* 当历史会话中多个 assistant 消息复用了同一 toolCallId(DeepSeek 偶尔会这样生成),
|
|
1132
|
+
* 第二个 tool-result 会找不到匹配 → 报 "tool result's tool id not found"。
|
|
1133
|
+
*
|
|
1134
|
+
* 策略:保留第一次出现的 ID;后续出现重命名为 `<id>_v2`、`<id>_v3`...,
|
|
1135
|
+
* 并同步更新紧随其后的 tool-result ID(保留 call/result 配对)。
|
|
1136
|
+
*
|
|
1137
|
+
* @param {Array} messages 消息数组(原地修改)
|
|
1138
|
+
* @returns {number} 修复的 ID 数量
|
|
1139
|
+
*/
|
|
1140
|
+
_dedupeToolCallIds(messages) {
|
|
1141
|
+
if (!Array.isArray(messages)) return 0;
|
|
1142
|
+
|
|
1143
|
+
let fixedCount = 0;
|
|
1144
|
+
// 跟踪第一次出现某 ID 的位置(用于同步 result 改名)
|
|
1145
|
+
const firstSeen = new Map(); // id → {msgIdx, itemIdx, kind: 'call'|'result'}
|
|
1146
|
+
|
|
1147
|
+
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
|
|
1148
|
+
const msg = messages[msgIdx];
|
|
1149
|
+
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
1150
|
+
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1151
|
+
const item = msg.content[itemIdx];
|
|
1152
|
+
if (!item) continue;
|
|
1153
|
+
if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
|
|
1154
|
+
const id = item.toolCallId;
|
|
1155
|
+
if (firstSeen.has(id)) {
|
|
1156
|
+
// 冲突:生成唯一新 ID(不依赖全局计数器,避免跨调用泄露)
|
|
1157
|
+
const newId = `${id}_dup${msgIdx}_${itemIdx}`;
|
|
1158
|
+
item.toolCallId = newId;
|
|
1159
|
+
// 同步修改紧随其后的 tool-result
|
|
1160
|
+
const renamed = this._renameFollowingToolResult(messages, msgIdx, id, newId);
|
|
1161
|
+
fixedCount += 1 + (renamed ? 1 : 0);
|
|
1162
|
+
// 不更新 firstSeen(原始 ID 已被第一次声明占用)
|
|
1163
|
+
} else {
|
|
1164
|
+
firstSeen.set(id, { msgIdx, itemIdx, kind: 'call' });
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
} else if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
1169
|
+
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1170
|
+
const item = msg.content[itemIdx];
|
|
1171
|
+
if (!item) continue;
|
|
1172
|
+
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId) {
|
|
1173
|
+
const id = item.toolCallId;
|
|
1174
|
+
// 记录 tool-result 第一次出现(不与 call 比较,因为可能已被上面的分支处理过)
|
|
1175
|
+
if (!firstSeen.has(id)) {
|
|
1176
|
+
firstSeen.set(id, { msgIdx, itemIdx, kind: 'result' });
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return fixedCount;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* 在 msgIdx 之后的 tool 消息中,找到第一个 toolCallId === oldId 的项,改名为 newId
|
|
1187
|
+
* 遇到下一个 assistant 消息时停止(确保只改当前 assistant 的 result)
|
|
1188
|
+
* @private
|
|
1189
|
+
*/
|
|
1190
|
+
_renameFollowingToolResult(messages, afterMsgIdx, oldId, newId) {
|
|
1191
|
+
for (let i = afterMsgIdx + 1; i < messages.length; i++) {
|
|
1192
|
+
const m = messages[i];
|
|
1193
|
+
if (m.role === 'assistant') return false;
|
|
1194
|
+
if (m.role === 'tool' && Array.isArray(m.content)) {
|
|
1195
|
+
for (const item of m.content) {
|
|
1196
|
+
if (!item) continue;
|
|
1197
|
+
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId === oldId) {
|
|
1198
|
+
item.toolCallId = newId;
|
|
1199
|
+
return true;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1109
1207
|
/**
|
|
1110
1208
|
* 判断两个 user content 是否等价(用于末尾 user 去重)
|
|
1111
1209
|
* @private
|
|
@@ -1375,6 +1473,15 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1375
1473
|
const repairedCount = this._repairInvalidToolCallsInMessages(inputMessages);
|
|
1376
1474
|
this._repairAndPrefixHistoricalIds(inputMessages);
|
|
1377
1475
|
|
|
1476
|
+
// ★ 防御性 sanitize:确保所有 message 符合 AI SDK ModelMessage[] schema
|
|
1477
|
+
// 每次 LLM step 前都跑一次(messages 数组里可能有 AI SDK 上一步加进来的)
|
|
1478
|
+
const sanitized = sanitizeForLLM(inputMessages);
|
|
1479
|
+
if (sanitized.fixed > 0 || sanitized.dropped > 0) {
|
|
1480
|
+
logger.warn(`[PrepareStep] sanitize: fixed=${sanitized.fixed}, dropped=${sanitized.dropped}`);
|
|
1481
|
+
inputMessages.length = 0;
|
|
1482
|
+
inputMessages.push(...sanitized.messages);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1378
1485
|
// 单次遍历完成所有验证 + 规范化
|
|
1379
1486
|
const validated = validateAll(inputMessages);
|
|
1380
1487
|
if (validated.length !== inputMessages.length) {
|
package/src/agent/sub.js
CHANGED
|
@@ -30,7 +30,7 @@ class SubAgent extends BaseAgent {
|
|
|
30
30
|
this.defaulTools = ['ext_skill', 'skill_load', 'ext_call', 'subagent_call'];
|
|
31
31
|
this.bindTools = {
|
|
32
32
|
read: 'read_file', write: 'write_file', edit: 'modify_file',
|
|
33
|
-
glob: 'read_directory', grep: '
|
|
33
|
+
glob: 'read_directory', grep: 'search', bash: 'shell_exec',
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
this._customSystemPrompt = config.systemPrompt || null;
|
package/src/tool/router.js
CHANGED
|
@@ -15,7 +15,7 @@ const INTENT_PATTERNS = {
|
|
|
15
15
|
],
|
|
16
16
|
tools: [
|
|
17
17
|
'read_file', 'write_file', 'delete_file', 'modify_file',
|
|
18
|
-
'read_directory', 'create_directory', '
|
|
18
|
+
'read_directory', 'create_directory', 'search',
|
|
19
19
|
],
|
|
20
20
|
},
|
|
21
21
|
code_development: {
|
|
@@ -34,7 +34,7 @@ const INTENT_PATTERNS = {
|
|
|
34
34
|
},
|
|
35
35
|
data_analysis: {
|
|
36
36
|
keywords: ['分析', '数据', '统计', '查询', 'analyze', 'data', 'statistics', 'query'],
|
|
37
|
-
tools: ['execute_command', 'py_execute', '
|
|
37
|
+
tools: ['execute_command', 'py_execute', 'search', 'read_file', 'write_file'],
|
|
38
38
|
},
|
|
39
39
|
plugin_management: {
|
|
40
40
|
keywords: ['插件', '重载', '加载插件', 'plugin', 'reload', 'load plugin'],
|
|
@@ -499,6 +499,191 @@ function filterPairedMessages(messages, keepRecentAssistant = 3) {
|
|
|
499
499
|
.map((i) => messages[i]);
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
/**
|
|
503
|
+
* ★ 防御性 sanitize:确保 messages 数组符合 AI SDK 的 ModelMessage[] schema
|
|
504
|
+
*
|
|
505
|
+
* 已知会失败的情况:
|
|
506
|
+
* 1. `user.content` 不是 string 或 array(如 null、undefined、object)
|
|
507
|
+
* 2. `assistant.content` 不是 string 或 array
|
|
508
|
+
* 3. `tool.content` 不是 array
|
|
509
|
+
* 4. user/assistant content 是 array,但 part 缺少 `type` 字段或 type 不在白名单
|
|
510
|
+
* 5. tool content 是 array,但 part 不是 tool-result/tool-approval-response
|
|
511
|
+
* 6. tool-result 的 `output` 缺少 type 或 type 不在 AI SDK 允许集
|
|
512
|
+
*
|
|
513
|
+
* 策略:
|
|
514
|
+
* - 可修复 → 修复(log warn)
|
|
515
|
+
* - 不可修复 → 丢弃整条消息(log warn)
|
|
516
|
+
*
|
|
517
|
+
* @param {Array} messages 消息数组
|
|
518
|
+
* @returns {{ messages: Array, fixed: number, dropped: number }}
|
|
519
|
+
*/
|
|
520
|
+
function sanitizeForLLM(messages) {
|
|
521
|
+
if (!Array.isArray(messages)) return { messages: [], fixed: 0, dropped: 0 };
|
|
522
|
+
const VALID_USER_PARTS = new Set(['text', 'image', 'file']);
|
|
523
|
+
const VALID_ASSISTANT_PARTS = new Set(['text', 'file', 'reasoning', 'tool-call', 'tool-result', 'tool-approval-request']);
|
|
524
|
+
const VALID_TOOL_PARTS = new Set(['tool-result', 'tool-approval-response']);
|
|
525
|
+
const VALID_OUTPUT_TYPES = new Set(['text', 'json', 'execution-denied', 'content', 'error-text', 'error-json']);
|
|
526
|
+
|
|
527
|
+
let fixed = 0;
|
|
528
|
+
let dropped = 0;
|
|
529
|
+
const out = [];
|
|
530
|
+
|
|
531
|
+
for (let i = 0; i < messages.length; i++) {
|
|
532
|
+
const m = messages[i];
|
|
533
|
+
if (!m || typeof m !== 'object' || !m.role) {
|
|
534
|
+
console.warn(`[sanitizeForLLM] dropped msg[${i}]: missing role`);
|
|
535
|
+
dropped++;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (m.role === 'system') {
|
|
540
|
+
// system: content 必须是 string
|
|
541
|
+
if (typeof m.content !== 'string') {
|
|
542
|
+
console.warn(`[sanitizeForLLM] fixed system msg[${i}]: content ${typeof m.content} → string`);
|
|
543
|
+
out.push({ ...m, content: String(m.content ?? '') });
|
|
544
|
+
fixed++;
|
|
545
|
+
} else {
|
|
546
|
+
out.push(m);
|
|
547
|
+
}
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (m.role === 'user') {
|
|
552
|
+
// user: content 必须是 string 或 array(text/image/file parts)
|
|
553
|
+
if (typeof m.content === 'string') {
|
|
554
|
+
out.push(m);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (Array.isArray(m.content)) {
|
|
558
|
+
const newContent = [];
|
|
559
|
+
for (const part of m.content) {
|
|
560
|
+
if (part && typeof part === 'object' && VALID_USER_PARTS.has(part.type)) {
|
|
561
|
+
newContent.push(part);
|
|
562
|
+
} else {
|
|
563
|
+
console.warn(`[sanitizeForLLM] dropped user msg[${i}] part: ${JSON.stringify(part).slice(0, 100)}`);
|
|
564
|
+
fixed++;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (newContent.length === 0) {
|
|
568
|
+
console.warn(`[sanitizeForLLM] dropped user msg[${i}]: all parts invalid`);
|
|
569
|
+
dropped++;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
out.push({ ...m, content: newContent });
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// content 是 object/null/undefined → 转为 string 或丢弃
|
|
576
|
+
if (m.content == null) {
|
|
577
|
+
console.warn(`[sanitizeForLLM] dropped user msg[${i}]: null content`);
|
|
578
|
+
dropped++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// 转为字符串
|
|
582
|
+
try {
|
|
583
|
+
out.push({ ...m, content: JSON.stringify(m.content) });
|
|
584
|
+
fixed++;
|
|
585
|
+
console.warn(`[sanitizeForLLM] fixed user msg[${i}]: content object → string`);
|
|
586
|
+
} catch {
|
|
587
|
+
out.push({ ...m, content: String(m.content) });
|
|
588
|
+
fixed++;
|
|
589
|
+
}
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (m.role === 'assistant') {
|
|
594
|
+
// assistant: content 必须是 string 或 array
|
|
595
|
+
if (typeof m.content === 'string') {
|
|
596
|
+
out.push(m);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (Array.isArray(m.content)) {
|
|
600
|
+
const newContent = [];
|
|
601
|
+
for (const part of m.content) {
|
|
602
|
+
if (!part || typeof part !== 'object' || !part.type) {
|
|
603
|
+
console.warn(`[sanitizeForLLM] dropped assistant msg[${i}] part: no type - ${JSON.stringify(part).slice(0, 100)}`);
|
|
604
|
+
fixed++;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (VALID_ASSISTANT_PARTS.has(part.type)) {
|
|
608
|
+
newContent.push(part);
|
|
609
|
+
} else {
|
|
610
|
+
console.warn(`[sanitizeForLLM] dropped assistant msg[${i}] part: unknown type "${part.type}"`);
|
|
611
|
+
fixed++;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (newContent.length === 0) {
|
|
615
|
+
console.warn(`[sanitizeForLLM] dropped assistant msg[${i}]: all parts invalid`);
|
|
616
|
+
dropped++;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
out.push({ ...m, content: newContent });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (m.content == null) {
|
|
623
|
+
console.warn(`[sanitizeForLLM] dropped assistant msg[${i}]: null content`);
|
|
624
|
+
dropped++;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
out.push({ ...m, content: JSON.stringify(m.content) });
|
|
629
|
+
fixed++;
|
|
630
|
+
console.warn(`[sanitizeForLLM] fixed assistant msg[${i}]: content ${typeof m.content} → string`);
|
|
631
|
+
} catch {
|
|
632
|
+
out.push({ ...m, content: String(m.content) });
|
|
633
|
+
fixed++;
|
|
634
|
+
}
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (m.role === 'tool') {
|
|
639
|
+
// tool: content 必须是 array (tool-result 或 tool-approval-response)
|
|
640
|
+
if (!Array.isArray(m.content)) {
|
|
641
|
+
console.warn(`[sanitizeForLLM] dropped tool msg[${i}]: content is ${typeof m.content}, not array`);
|
|
642
|
+
dropped++;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const newContent = [];
|
|
646
|
+
for (const part of m.content) {
|
|
647
|
+
if (!part || typeof part !== 'object' || !part.type) {
|
|
648
|
+
console.warn(`[sanitizeForLLM] dropped tool msg[${i}] part: no type`);
|
|
649
|
+
fixed++;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (!VALID_TOOL_PARTS.has(part.type)) {
|
|
653
|
+
console.warn(`[sanitizeForLLM] dropped tool msg[${i}] part: type "${part.type}" not in tool union`);
|
|
654
|
+
fixed++;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
// 修复 tool-result.output
|
|
658
|
+
if (part.type === 'tool-result') {
|
|
659
|
+
if (!part.output || typeof part.output !== 'object' || !VALID_OUTPUT_TYPES.has(part.output.type)) {
|
|
660
|
+
console.warn(`[sanitizeForLLM] fixed tool msg[${i}] tool-result: bad output - ${JSON.stringify(part.output).slice(0, 100)}`);
|
|
661
|
+
const out2 = (part.output == null) ? { type: 'text', value: String(part.output ?? '') } :
|
|
662
|
+
{ type: 'text', value: JSON.stringify(part.output) };
|
|
663
|
+
newContent.push({ ...part, output: out2 });
|
|
664
|
+
fixed++;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
newContent.push(part);
|
|
669
|
+
}
|
|
670
|
+
if (newContent.length === 0) {
|
|
671
|
+
console.warn(`[sanitizeForLLM] dropped tool msg[${i}]: all parts invalid`);
|
|
672
|
+
dropped++;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
out.push({ ...m, content: newContent });
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 未知 role:丢弃
|
|
680
|
+
console.warn(`[sanitizeForLLM] dropped msg[${i}]: unknown role "${m.role}"`);
|
|
681
|
+
dropped++;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return { messages: out, fixed, dropped };
|
|
685
|
+
}
|
|
686
|
+
|
|
502
687
|
module.exports = {
|
|
503
688
|
validateMessagesPairing,
|
|
504
689
|
validateToolCalls,
|
|
@@ -506,4 +691,5 @@ module.exports = {
|
|
|
506
691
|
normalizeToolOutputs,
|
|
507
692
|
filterOrphanToolCalls,
|
|
508
693
|
filterPairedMessages,
|
|
694
|
+
sanitizeForLLM,
|
|
509
695
|
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// 测试 file-system 插件的 chat 工具修复
|
|
4
|
+
// 通过读源码 + 模拟 execute 来验证行为
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const FileSystemPlugin = require('../../plugins/io/file-system/index.js');
|
|
10
|
+
|
|
11
|
+
function makeFakeFw(opts = {}) {
|
|
12
|
+
const llmConfig = opts.llmConfig || { model: 'test-model', provider: 'test-provider' };
|
|
13
|
+
const captured = { agentConfig: null, chatArgs: null, registeredTools: [] };
|
|
14
|
+
const fw = {
|
|
15
|
+
getCwd: () => process.cwd(),
|
|
16
|
+
pluginManager: {
|
|
17
|
+
get: (name) => {
|
|
18
|
+
if (name === 'ai') return { getConfig: () => llmConfig };
|
|
19
|
+
return null;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
// ★ Plugin base class 的 tool.register 会先 push 到 _registeredTools,
|
|
23
|
+
// 然后查 registry.has(),has() 返回 false 才调 registry.register()。
|
|
24
|
+
// 所以 mock 必须实现 has() 否则注册流程被中断。
|
|
25
|
+
registerTool: (toolDef) => {
|
|
26
|
+
captured.registeredTools.push(toolDef);
|
|
27
|
+
},
|
|
28
|
+
toolRegistry: {
|
|
29
|
+
has: () => false,
|
|
30
|
+
register: (toolDef) => {
|
|
31
|
+
captured.registeredTools.push(toolDef);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
createSubAgent: (config) => {
|
|
35
|
+
captured.agentConfig = config;
|
|
36
|
+
return {
|
|
37
|
+
chat: async (msg, opts) => {
|
|
38
|
+
captured.chatArgs = { msg, opts };
|
|
39
|
+
return { success: true, message: 'mock reply', steps: 1 };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { fw, captured };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getChatTool(captured) {
|
|
48
|
+
return captured.registeredTools.find(t => t.name === 'chat');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('file-system 插件的 chat 工具', () => {
|
|
52
|
+
it('★ 关键:name 唯一(用 Date.now + random 避免冲突)', async () => {
|
|
53
|
+
const { fw, captured } = makeFakeFw();
|
|
54
|
+
const p = new FileSystemPlugin();
|
|
55
|
+
p.install(fw);
|
|
56
|
+
p.start(fw);
|
|
57
|
+
// 直接调工具的 execute
|
|
58
|
+
// find chat tool in _registeredTools
|
|
59
|
+
const chatTool = getChatTool(captured);
|
|
60
|
+
expect(chatTool).toBeTruthy();
|
|
61
|
+
if (!chatTool) return;
|
|
62
|
+
|
|
63
|
+
const r1 = await chatTool.execute({ message: 'hi' }, fw);
|
|
64
|
+
const r2 = await chatTool.execute({ message: 'hi2' }, fw);
|
|
65
|
+
|
|
66
|
+
expect(captured.agentConfig.name).toMatch(/^file-system-chat-\d+/);
|
|
67
|
+
// 两次调用应产生不同名字
|
|
68
|
+
const name1 = r1.metadata?.agent;
|
|
69
|
+
const name2 = r2.metadata?.agent;
|
|
70
|
+
expect(name1).not.toBe(name2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('★ 关键:sessionId 隔离(避免污染主对话)', async () => {
|
|
74
|
+
const { fw, captured } = makeFakeFw();
|
|
75
|
+
const p = new FileSystemPlugin();
|
|
76
|
+
p.install(fw);
|
|
77
|
+
p.start(fw);
|
|
78
|
+
const chatTool = getChatTool(captured);
|
|
79
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
80
|
+
|
|
81
|
+
// 1. 不传 sessionId → 自动生成
|
|
82
|
+
await chatTool.execute({ message: 'm1' }, fw);
|
|
83
|
+
expect(captured.chatArgs.opts.sessionId).toMatch(/^chat-\d+$/);
|
|
84
|
+
|
|
85
|
+
// 2. 传 sessionId → 使用传入的
|
|
86
|
+
await chatTool.execute({ message: 'm2', sessionId: 'my-session' }, fw);
|
|
87
|
+
expect(captured.chatArgs.opts.sessionId).toBe('my-session');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('★ 关键:跟随主 agent 的 LLM 配置', async () => {
|
|
91
|
+
const llmConfig = {
|
|
92
|
+
model: 'deepseek-chat',
|
|
93
|
+
provider: 'deepseek',
|
|
94
|
+
apiKey: 'sk-test',
|
|
95
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
96
|
+
};
|
|
97
|
+
const { fw, captured } = makeFakeFw({ llmConfig });
|
|
98
|
+
const p = new FileSystemPlugin();
|
|
99
|
+
p.install(fw);
|
|
100
|
+
p.start(fw);
|
|
101
|
+
const chatTool = getChatTool(captured);
|
|
102
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
103
|
+
|
|
104
|
+
await chatTool.execute({ message: 'hi' }, fw);
|
|
105
|
+
expect(captured.agentConfig.model).toBe('deepseek-chat');
|
|
106
|
+
expect(captured.agentConfig.provider).toBe('deepseek');
|
|
107
|
+
expect(captured.agentConfig.apiKey).toBe('sk-test');
|
|
108
|
+
expect(captured.agentConfig.baseURL).toBe('https://api.deepseek.com/v1');
|
|
109
|
+
expect(captured.agentConfig.hidden).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('★ 关键:data 只返回 message 文本(不返回整个对象)', async () => {
|
|
113
|
+
const { fw, captured } = makeFakeFw();
|
|
114
|
+
const p = new FileSystemPlugin();
|
|
115
|
+
p.install(fw);
|
|
116
|
+
p.start(fw);
|
|
117
|
+
const chatTool = getChatTool(captured);
|
|
118
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
119
|
+
|
|
120
|
+
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
121
|
+
expect(r.success).toBe(true);
|
|
122
|
+
expect(r.data).toBe('mock reply'); // 只是字符串,不是整个对象
|
|
123
|
+
expect(typeof r.data).toBe('string');
|
|
124
|
+
// metadata 含元信息
|
|
125
|
+
expect(r.metadata).toBeTruthy();
|
|
126
|
+
expect(r.metadata.steps).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('★ 关键:systemPrompt 默认值合理', async () => {
|
|
130
|
+
const { fw, captured } = makeFakeFw();
|
|
131
|
+
const p = new FileSystemPlugin();
|
|
132
|
+
p.install(fw);
|
|
133
|
+
p.start(fw);
|
|
134
|
+
const chatTool = getChatTool(captured);
|
|
135
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
136
|
+
|
|
137
|
+
// 不传 systemPrompt → 用默认
|
|
138
|
+
await chatTool.execute({ message: 'hi' }, fw);
|
|
139
|
+
expect(captured.agentConfig.systemPrompt).toContain('文件系统');
|
|
140
|
+
|
|
141
|
+
// 传 systemPrompt → 覆盖
|
|
142
|
+
await chatTool.execute({
|
|
143
|
+
message: 'hi',
|
|
144
|
+
systemPrompt: '你是一个翻译助手',
|
|
145
|
+
}, fw);
|
|
146
|
+
expect(captured.agentConfig.systemPrompt).toBe('你是一个翻译助手');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('★ 关键:错误捕获(AI 抛错时不崩溃)', async () => {
|
|
150
|
+
const registeredTools = [];
|
|
151
|
+
const fw = {
|
|
152
|
+
getCwd: () => process.cwd(),
|
|
153
|
+
pluginManager: { get: () => null },
|
|
154
|
+
registerTool: (t) => registeredTools.push(t),
|
|
155
|
+
toolRegistry: { has: () => false, register: (t) => registeredTools.push(t) },
|
|
156
|
+
createSubAgent: () => ({
|
|
157
|
+
chat: async () => { throw new Error('AI 服务不可用'); },
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
const p = new FileSystemPlugin();
|
|
161
|
+
p.install(fw);
|
|
162
|
+
p.start(fw);
|
|
163
|
+
const chatTool = registeredTools.find(t => t.name === 'chat');
|
|
164
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
165
|
+
|
|
166
|
+
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
167
|
+
expect(r.success).toBe(false);
|
|
168
|
+
expect(r.error).toBe('AI 服务不可用');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('★ inputSchema 含 sessionId 字段', () => {
|
|
172
|
+
// 直接读源码验证
|
|
173
|
+
const src = fs.readFileSync(
|
|
174
|
+
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
175
|
+
'utf-8'
|
|
176
|
+
);
|
|
177
|
+
expect(src).toMatch(/sessionId:\s*z\.string\(\)\.optional\(\)/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('★ 删除死代码:const ai = framework.getAI()', () => {
|
|
181
|
+
const src = fs.readFileSync(
|
|
182
|
+
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
183
|
+
'utf-8'
|
|
184
|
+
);
|
|
185
|
+
expect(src).not.toMatch(/const\s+ai\s*=\s*framework\.getAI/);
|
|
186
|
+
});
|
|
187
|
+
});
|