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.
@@ -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
+ });