Skip to content

Commit d2a2e78

Browse files
committed
🐛 处理 PR #1355 Copilot review 建议及 llm_client 同类问题
- background_session_manager.ts: target 添加 ToolCall | undefined 类型,避免 strict 模式下隐式 any - ChatArea.tsx: 主消息分支 tool_call_delta 同步改为 id/index/running 三级匹配 - openai.test.ts: 重写 "{}" 用例的断言与名称,明确 start 事件 args 为空、首 chunk args 作为 delta - anthropic.ts: content_block_stop 清理对应 index,message_stop 清空 toolUseByIndex,顺带去掉 diff-annotation 注释 - llm_client.ts: 移除单指针 currentToolCall 模型,改为 push-then-match 三级匹配,修复执行路径在并发 tool_call 下的参数串扰
1 parent 2f5b1b5 commit d2a2e78

5 files changed

Lines changed: 54 additions & 32 deletions

File tree

src/app/service/agent/core/providers/anthropic.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,11 @@ export function parseAnthropicStream(
248248
} else if (delta?.type === "thinking_delta") {
249249
onEvent({ type: "thinking_delta", delta: delta.thinking });
250250
} else if (delta?.type === "input_json_delta") {
251-
const tu = toolUseByIndex.get(json.index); // ← 新增
251+
const tu = toolUseByIndex.get(json.index);
252252
onEvent({
253253
type: "tool_call_delta",
254-
id: tu?.id || "", // ← 不再固定 ""
255-
index: json.index, // ← 新增
254+
id: tu?.id || "",
255+
index: json.index,
256256
delta: delta.partial_json,
257257
});
258258
} else if (delta?.type === "image_delta" && imageBlockData) {
@@ -279,6 +279,10 @@ export function parseAnthropicStream(
279279
});
280280
imageBlockData = null;
281281
}
282+
// tool_use block 结束后清理 index→id 映射,避免长会话下 map 持续增长
283+
if (typeof json.index === "number") {
284+
toolUseByIndex.delete(json.index);
285+
}
282286
break;
283287
}
284288
case "message_delta": {
@@ -298,6 +302,7 @@ export function parseAnthropicStream(
298302
break;
299303
}
300304
case "message_stop": {
305+
toolUseByIndex.clear();
301306
onEvent({ type: "done" });
302307
return true;
303308
}

src/app/service/agent/core/providers/openai.test.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,7 @@ describe("parseOpenAIStream", () => {
552552
}
553553
});
554554

555-
it("首 chunk 同时带 name 和 arguments='{}' 时不应污染后续 args", async () => {
555+
it("首 chunk 同时带 name 和 arguments 时:start 事件 args 为空,首 chunk args 作为 delta 发出", async () => {
556556
const reader = createMockReader([
557557
// gateway / 某些 model 会先发一个 arguments="{}" 占位再送真正 JSON
558558
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_x","function":{"name":"agent","arguments":"{}"}}]}}]}\n\n',
@@ -565,18 +565,16 @@ describe("parseOpenAIStream", () => {
565565

566566
expect(events[0].type).toBe("tool_call_start");
567567
if (events[0].type === "tool_call_start") {
568-
// 关键断言:start 事件里的 args 必须为空,不能是 "{}"
568+
// 关键断言:start 事件里的 args 必须为空,不能是 "{}"(避免前缀污染)
569569
expect(events[0].toolCall.arguments).toBe("");
570570
expect(events[0].toolCall.name).toBe("agent");
571571
}
572-
// 三段 delta:首 chunk 的 "{}" + 两次真实 JSON
572+
// 首 chunk 的 "{}" 作为第一段 delta 原样透传(模型问题:整体非合法 JSON,但解析器不吞字符)
573573
const deltas = events.filter((e) => e.type === "tool_call_delta");
574574
expect(deltas).toHaveLength(3);
575-
const joined = deltas.map((e) => (e.type === "tool_call_delta" ? e.delta : "")).join("");
576-
// 拼接后应等同 LLM 真正要发的(就算首 chunk 有 "{}",也应被后续覆盖式语义接受)
577-
// 注意:如果模型真的先发 "{}" 再发别的 JSON,整体不是合法 JSON —— 这是模型问题,
578-
// 但至少我们不在 start 事件里把 "{}" 当成 args 的 prefix。
579-
expect(joined.startsWith("{}")).toBe(true); // 原样透传
575+
expect(deltas[0].type === "tool_call_delta" && deltas[0].delta).toBe("{}");
576+
expect(deltas[1].type === "tool_call_delta" && deltas[1].delta).toBe('{"description":"r"');
577+
expect(deltas[2].type === "tool_call_delta" && deltas[2].delta).toBe(',"prompt":"do"}');
580578
});
581579

582580
it("并发多个 tool_call(不同 index)arguments 不应互相串扰", async () => {

src/app/service/agent/service_worker/background_session_manager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,12 @@ export class BackgroundSessionManager {
6262
// 并发 tool call 时(OpenAI 用 index 区分、Anthropic 的多个 tool_use block)length-1 会把 delta 写错工具。
6363
if (rc.streamingState.toolCalls.length === 0) break;
6464

65-
let target;
66-
// 1a. 按 id 配對
65+
let target: ToolCall | undefined = undefined;
66+
// 1a. 按 id 匹配
6767
if (event.id) {
6868
target = rc.streamingState.toolCalls.find((t) => t.id === event.id);
6969
}
70-
// 1b. 按 index 配對(OpenAI 後續 chunk id 只有 index)
70+
// 1b. 按 index 匹配(OpenAI 后续 chunk id 只有 index)
7171
if (!target && event.index !== undefined) {
7272
target = rc.streamingState.toolCalls[event.index];
7373
}

src/app/service/agent/service_worker/llm_client.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ export class LLMClient {
128128
let content = "";
129129
let thinking = "";
130130
const toolCalls: ToolCall[] = [];
131-
let currentToolCall: ToolCall | null = null;
132131
let usage:
133132
| { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number }
134133
| undefined;
@@ -157,23 +156,34 @@ export class LLMClient {
157156
thinking += event.delta;
158157
break;
159158
case "tool_call_start":
160-
// 如果已有一个正在收集的 tool call,先保存它(多个 tool_use 并行返回时)
161-
if (currentToolCall) {
162-
toolCalls.push(currentToolCall);
163-
}
164-
currentToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" };
159+
// 并发 tool_call 时 parser 会交错发 delta,这里立即 push 到数组,
160+
// 由 tool_call_delta 通过 id/index 定位目标 tool,避免串扰。
161+
toolCalls.push({ ...event.toolCall, arguments: event.toolCall.arguments || "", status: "running" });
165162
break;
166-
case "tool_call_delta":
167-
if (currentToolCall) {
168-
currentToolCall.arguments += event.delta;
163+
case "tool_call_delta": {
164+
if (!toolCalls.length) break;
165+
let target: ToolCall | undefined = undefined;
166+
// 1a. 按 id 匹配
167+
if (event.id) {
168+
target = toolCalls.find((t) => t.id === event.id);
169+
}
170+
// 1b. 按 index 匹配(OpenAI 后续 chunk 无 id 只有 index)
171+
if (!target && event.index !== undefined) {
172+
target = toolCalls[event.index];
169173
}
174+
// 2. fallback:最新一个状态为 running 的 tool call
175+
if (!target) {
176+
for (let i = toolCalls.length - 1; i >= 0; i--) {
177+
if (toolCalls[i].status === "running") {
178+
target = toolCalls[i];
179+
break;
180+
}
181+
}
182+
}
183+
if (target) target.arguments += event.delta;
170184
break;
185+
}
171186
case "done": {
172-
// 保存当前的 tool call
173-
if (currentToolCall) {
174-
toolCalls.push(currentToolCall);
175-
currentToolCall = null;
176-
}
177187
if (event.usage) {
178188
usage = event.usage;
179189
}

src/pages/options/routes/AgentChat/ChatArea.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,12 +281,21 @@ export default function ChatArea({
281281
if (!msg.toolCalls) msg.toolCalls = [];
282282
msg.toolCalls.push({ ...event.toolCall, status: "running" });
283283
break;
284-
case "tool_call_delta":
285-
if (msg.toolCalls?.length) {
286-
const lastTc = msg.toolCalls[msg.toolCalls.length - 1];
287-
lastTc.arguments += event.delta;
284+
case "tool_call_delta": {
285+
if (!msg.toolCalls?.length) break;
286+
let t = event.id ? msg.toolCalls.find((x) => x.id === event.id) : undefined;
287+
if (!t && event.index !== undefined) t = msg.toolCalls[event.index];
288+
if (!t) {
289+
for (let i = msg.toolCalls.length - 1; i >= 0; i--) {
290+
if (msg.toolCalls[i].status === "running") {
291+
t = msg.toolCalls[i];
292+
break;
293+
}
294+
}
288295
}
296+
if (t) t.arguments += event.delta;
289297
break;
298+
}
290299
case "tool_call_complete": {
291300
const tc = msg.toolCalls?.find((t) => t.id === event.id);
292301
if (tc) {

0 commit comments

Comments
 (0)