foliko 2.0.19 → 2.0.21
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 +1 -1
- package/plugins/core/planner/PROMPT.md +73 -0
- package/plugins/core/planner/index.js +926 -0
- package/plugins/messaging/weixin/index.js +145 -0
- package/src/agent/chat.js +89 -0
- package/tests/core/dedupe-tool-call-ids.test.js +260 -0
- package/tests/plugins/planner.test.js +630 -0
- package/weixin_o9cq80zgZqKPA2-s59PN43GdDy1w@im.wechat.jsonl +40 -0
|
@@ -689,6 +689,29 @@ class WeixinPlugin extends Plugin {
|
|
|
689
689
|
true)
|
|
690
690
|
}
|
|
691
691
|
break
|
|
692
|
+
// === Planner 集成:/plan 系列命令 ===
|
|
693
|
+
// 完整 planner 工具在 plugins/core/planner/,这里只是命令入口
|
|
694
|
+
case 'plan':
|
|
695
|
+
// /plan <task>:把 task 发给 main agent,让 LLM 按 PROMPT.md 工作流走
|
|
696
|
+
if (!args || !args.trim()) {
|
|
697
|
+
await this._sendMessageBatch(originalMsg, userId, '用法: /plan <任务描述>', true)
|
|
698
|
+
} else {
|
|
699
|
+
await this._processChat(userId, `请进入计划模式完成:${args}`, originalMsg)
|
|
700
|
+
}
|
|
701
|
+
break
|
|
702
|
+
case 'plan_continue':
|
|
703
|
+
// /plan_continue <planId>:恢复计划
|
|
704
|
+
await this._handlePlanContinue(userId, args, originalMsg)
|
|
705
|
+
break
|
|
706
|
+
case 'plan_list':
|
|
707
|
+
await this._handlePlanList(userId, originalMsg)
|
|
708
|
+
break
|
|
709
|
+
case 'plan_show':
|
|
710
|
+
await this._handlePlanShow(userId, args, originalMsg)
|
|
711
|
+
break
|
|
712
|
+
case 'plan_delete':
|
|
713
|
+
await this._handlePlanDelete(userId, args, originalMsg)
|
|
714
|
+
break
|
|
692
715
|
default:
|
|
693
716
|
// 未识别命令,当作普通消息处理
|
|
694
717
|
await this._processChat(userId, text, originalMsg)
|
|
@@ -877,6 +900,128 @@ class WeixinPlugin extends Plugin {
|
|
|
877
900
|
}
|
|
878
901
|
}
|
|
879
902
|
|
|
903
|
+
// ============================================================
|
|
904
|
+
// Planner 命令处理器(/plan_continue / /plan_list / /plan_show / /plan_delete)
|
|
905
|
+
// ============================================================
|
|
906
|
+
|
|
907
|
+
async _handlePlanContinue(userId, args, originalMsg) {
|
|
908
|
+
if (!this._framework) {
|
|
909
|
+
await this._sendMessageBatch(originalMsg, userId, '❌ 框架未就绪', true)
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
const planId = (args || '').trim()
|
|
913
|
+
if (!planId) {
|
|
914
|
+
await this._sendMessageBatch(originalMsg, userId, '用法: /plan_continue <planId>', true)
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const result = await this._framework.executeTool('plan_continue', { planId })
|
|
919
|
+
if (!result.success) {
|
|
920
|
+
await this._sendMessageBatch(originalMsg, userId, `❌ ${result.error}`, true)
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
const plan = result.plan
|
|
924
|
+
const next = plan.steps[result.resumableFrom]
|
|
925
|
+
const lines = [
|
|
926
|
+
`🔄 恢复计划:${plan.title}`,
|
|
927
|
+
'',
|
|
928
|
+
`进度: ${plan.completedSteps}/${plan.totalSteps}(已完成 ${plan.completedSteps},失败 ${plan.failedSteps})`,
|
|
929
|
+
`下一步: Step ${result.resumableFrom + 1} — ${result.nextStepName}`,
|
|
930
|
+
'',
|
|
931
|
+
'完整步骤:',
|
|
932
|
+
...plan.steps.map(s => {
|
|
933
|
+
const box = { completed: '✅', failed: '❌', in_progress: '🔄', skipped: '⏭️', pending: '⬜' }[s.status] || '⬜'
|
|
934
|
+
return `${box} ${s.index + 1}. ${s.name}`
|
|
935
|
+
}),
|
|
936
|
+
]
|
|
937
|
+
await this._sendMessageBatch(originalMsg, userId, lines.join('\n'), true)
|
|
938
|
+
// 把恢复指令作为普通消息发给 agent,让 LLM 继续执行
|
|
939
|
+
const resumeMsg = `请继续执行计划 ${planId} 的 Step ${result.resumableFrom + 1}:${result.nextStepName}`
|
|
940
|
+
await this._processChat(userId, resumeMsg, originalMsg)
|
|
941
|
+
} catch (err) {
|
|
942
|
+
log.error('plan_continue 失败:', err.message)
|
|
943
|
+
await this._sendMessageBatch(originalMsg, userId, `❌ 恢复失败: ${err.message}`, true)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async _handlePlanList(userId, originalMsg) {
|
|
948
|
+
if (!this._framework) {
|
|
949
|
+
await this._sendMessageBatch(originalMsg, userId, '❌ 框架未就绪', true)
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
const result = await this._framework.executeTool('plan_list', {})
|
|
954
|
+
if (!result.success || result.count === 0) {
|
|
955
|
+
await this._sendMessageBatch(originalMsg, userId, '📋 当前没有计划', true)
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
const statusBox = {
|
|
959
|
+
in_progress: '🔄', completed: '✅', failed: '❌', paused: '⏸️',
|
|
960
|
+
}
|
|
961
|
+
const lines = [
|
|
962
|
+
`📋 计划列表(${result.count})`,
|
|
963
|
+
'',
|
|
964
|
+
...result.plans.map(p => [
|
|
965
|
+
`${statusBox[p.status] || '❔'} ${p.title}`,
|
|
966
|
+
` 进度: ${p.completedSteps}/${p.totalSteps} | ${p.status}`,
|
|
967
|
+
` ID: ${p.planId}`,
|
|
968
|
+
` 更新: ${p.updatedAt}`,
|
|
969
|
+
].join('\n')),
|
|
970
|
+
]
|
|
971
|
+
await this._sendMessageBatch(originalMsg, userId, lines.join('\n\n'), true)
|
|
972
|
+
} catch (err) {
|
|
973
|
+
log.error('plan_list 失败:', err.message)
|
|
974
|
+
await this._sendMessageBatch(originalMsg, userId, `❌ 查询失败: ${err.message}`, true)
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async _handlePlanShow(userId, args, originalMsg) {
|
|
979
|
+
if (!this._framework) {
|
|
980
|
+
await this._sendMessageBatch(originalMsg, userId, '❌ 框架未就绪', true)
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
const planId = (args || '').trim()
|
|
984
|
+
if (!planId) {
|
|
985
|
+
await this._sendMessageBatch(originalMsg, userId, '用法: /plan_show <planId>', true)
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
const result = await this._framework.executeTool('plan_show', { planId })
|
|
990
|
+
if (!result.success) {
|
|
991
|
+
await this._sendMessageBatch(originalMsg, userId, `❌ ${result.error}`, true)
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
// markdown 直接发给用户(_sendMessageBatch 会 removeMarkdown)
|
|
995
|
+
await this._sendMessageBatch(originalMsg, userId, result.markdown, true)
|
|
996
|
+
} catch (err) {
|
|
997
|
+
log.error('plan_show 失败:', err.message)
|
|
998
|
+
await this._sendMessageBatch(originalMsg, userId, `❌ ${err.message}`, true)
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async _handlePlanDelete(userId, args, originalMsg) {
|
|
1003
|
+
if (!this._framework) {
|
|
1004
|
+
await this._sendMessageBatch(originalMsg, userId, '❌ 框架未就绪', true)
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
const planId = (args || '').trim()
|
|
1008
|
+
if (!planId) {
|
|
1009
|
+
await this._sendMessageBatch(originalMsg, userId, '用法: /plan_delete <planId>', true)
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
const result = await this._framework.executeTool('plan_delete', { planId })
|
|
1014
|
+
if (!result.success) {
|
|
1015
|
+
await this._sendMessageBatch(originalMsg, userId, `❌ ${result.error || '删除失败'}`, true)
|
|
1016
|
+
return
|
|
1017
|
+
}
|
|
1018
|
+
await this._sendMessageBatch(originalMsg, userId, `✅ 计划已删除: ${planId}`, true)
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
log.error('plan_delete 失败:', err.message)
|
|
1021
|
+
await this._sendMessageBatch(originalMsg, userId, `❌ ${err.message}`, true)
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
880
1025
|
/**
|
|
881
1026
|
* 处理定时提醒
|
|
882
1027
|
*/
|
package/src/agent/chat.js
CHANGED
|
@@ -1073,6 +1073,16 @@ 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
|
+
|
|
1076
1086
|
// 单次遍历完成所有验证 + 规范化(validateAll 内部处理 error-text/error-json)
|
|
1077
1087
|
const validated = validateAll(messages);
|
|
1078
1088
|
if (validated.length !== messages.length) {
|
|
@@ -1106,6 +1116,85 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1106
1116
|
return { messageStore, messages, userMessage };
|
|
1107
1117
|
}
|
|
1108
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* ★ 去重 messages 中重复的 tool-call / tool-result ID
|
|
1121
|
+
* DeepSeek API 严格要求同一请求中 toolCallId 唯一。
|
|
1122
|
+
* 当历史会话中多个 assistant 消息复用了同一 toolCallId(DeepSeek 偶尔会这样生成),
|
|
1123
|
+
* 第二个 tool-result 会找不到匹配 → 报 "tool result's tool id not found"。
|
|
1124
|
+
*
|
|
1125
|
+
* 策略:保留第一次出现的 ID;后续出现重命名为 `<id>_v2`、`<id>_v3`...,
|
|
1126
|
+
* 并同步更新紧随其后的 tool-result ID(保留 call/result 配对)。
|
|
1127
|
+
*
|
|
1128
|
+
* @param {Array} messages 消息数组(原地修改)
|
|
1129
|
+
* @returns {number} 修复的 ID 数量
|
|
1130
|
+
*/
|
|
1131
|
+
_dedupeToolCallIds(messages) {
|
|
1132
|
+
if (!Array.isArray(messages)) return 0;
|
|
1133
|
+
|
|
1134
|
+
let fixedCount = 0;
|
|
1135
|
+
// 跟踪第一次出现某 ID 的位置(用于同步 result 改名)
|
|
1136
|
+
const firstSeen = new Map(); // id → {msgIdx, itemIdx, kind: 'call'|'result'}
|
|
1137
|
+
|
|
1138
|
+
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
|
|
1139
|
+
const msg = messages[msgIdx];
|
|
1140
|
+
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
1141
|
+
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1142
|
+
const item = msg.content[itemIdx];
|
|
1143
|
+
if (!item) continue;
|
|
1144
|
+
if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
|
|
1145
|
+
const id = item.toolCallId;
|
|
1146
|
+
if (firstSeen.has(id)) {
|
|
1147
|
+
// 冲突:生成唯一新 ID(不依赖全局计数器,避免跨调用泄露)
|
|
1148
|
+
const newId = `${id}_dup${msgIdx}_${itemIdx}`;
|
|
1149
|
+
item.toolCallId = newId;
|
|
1150
|
+
// 同步修改紧随其后的 tool-result
|
|
1151
|
+
const renamed = this._renameFollowingToolResult(messages, msgIdx, id, newId);
|
|
1152
|
+
fixedCount += 1 + (renamed ? 1 : 0);
|
|
1153
|
+
// 不更新 firstSeen(原始 ID 已被第一次声明占用)
|
|
1154
|
+
} else {
|
|
1155
|
+
firstSeen.set(id, { msgIdx, itemIdx, kind: 'call' });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
} else if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
1160
|
+
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1161
|
+
const item = msg.content[itemIdx];
|
|
1162
|
+
if (!item) continue;
|
|
1163
|
+
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId) {
|
|
1164
|
+
const id = item.toolCallId;
|
|
1165
|
+
// 记录 tool-result 第一次出现(不与 call 比较,因为可能已被上面的分支处理过)
|
|
1166
|
+
if (!firstSeen.has(id)) {
|
|
1167
|
+
firstSeen.set(id, { msgIdx, itemIdx, kind: 'result' });
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return fixedCount;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* 在 msgIdx 之后的 tool 消息中,找到第一个 toolCallId === oldId 的项,改名为 newId
|
|
1178
|
+
* 遇到下一个 assistant 消息时停止(确保只改当前 assistant 的 result)
|
|
1179
|
+
* @private
|
|
1180
|
+
*/
|
|
1181
|
+
_renameFollowingToolResult(messages, afterMsgIdx, oldId, newId) {
|
|
1182
|
+
for (let i = afterMsgIdx + 1; i < messages.length; i++) {
|
|
1183
|
+
const m = messages[i];
|
|
1184
|
+
if (m.role === 'assistant') return false;
|
|
1185
|
+
if (m.role === 'tool' && Array.isArray(m.content)) {
|
|
1186
|
+
for (const item of m.content) {
|
|
1187
|
+
if (!item) continue;
|
|
1188
|
+
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId === oldId) {
|
|
1189
|
+
item.toolCallId = newId;
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1109
1198
|
/**
|
|
1110
1199
|
* 判断两个 user content 是否等价(用于末尾 user 去重)
|
|
1111
1200
|
* @private
|
|
@@ -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
|
+
});
|