Skip to content

Commit 4199b4a

Browse files
committed
Merge branch 'release/v1.4-agent' into pnpm/fix/0423-release1.4agent
2 parents db3d4c6 + 6d2b36b commit 4199b4a

10 files changed

Lines changed: 232 additions & 45 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,4 +555,24 @@ describe("parseAnthropicStream", () => {
555555
expect(events).toHaveLength(2);
556556
expect(events[0]).toEqual({ type: "content_delta", delta: "ok" });
557557
});
558+
559+
it("tool_use block 的 input_json_delta 應帶上 id 和 index", async () => {
560+
const reader = createMockReader([
561+
'event: content_block_start\ndata: {"index":1,"content_block":{"type":"tool_use","id":"toolu_X","name":"f"}}\n\n',
562+
'event: content_block_delta\ndata: {"index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"a\\":1}"}}\n\n',
563+
"event: message_stop\ndata: {}\n\n",
564+
]);
565+
566+
const events: ChatStreamEvent[] = [];
567+
const controller = new AbortController();
568+
569+
await parseAnthropicStream(reader, (e) => events.push(e), controller.signal);
570+
571+
const d = events.find((e) => e.type === "tool_call_delta");
572+
expect(d).toBeDefined();
573+
if (d && d.type === "tool_call_delta") {
574+
expect(d.id).toBe("toolu_X");
575+
expect(d.index).toBe(1);
576+
}
577+
});
558578
});

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ export function parseAnthropicStream(
187187
// 跟踪图片块的累积 base64 数据
188188
let imageBlockData: { index: number; mediaType: string; base64Chunks: string[] } | null = null;
189189

190+
const toolUseByIndex = new Map<number, { id: string }>();
191+
190192
return readSSEStream(
191193
reader,
192194
signal,
@@ -212,6 +214,7 @@ export function parseAnthropicStream(
212214
if (block?.type === "thinking") {
213215
// thinking block 开始,后续通过 content_block_delta 传输内容
214216
} else if (block?.type === "tool_use") {
217+
toolUseByIndex.set(json.index, { id: block.id });
215218
onEvent({
216219
type: "tool_call_start",
217220
toolCall: {
@@ -245,9 +248,11 @@ export function parseAnthropicStream(
245248
} else if (delta?.type === "thinking_delta") {
246249
onEvent({ type: "thinking_delta", delta: delta.thinking });
247250
} else if (delta?.type === "input_json_delta") {
251+
const tu = toolUseByIndex.get(json.index);
248252
onEvent({
249253
type: "tool_call_delta",
250-
id: "",
254+
id: tu?.id || "",
255+
index: json.index,
251256
delta: delta.partial_json,
252257
});
253258
} else if (delta?.type === "image_delta" && imageBlockData) {
@@ -274,6 +279,10 @@ export function parseAnthropicStream(
274279
});
275280
imageBlockData = null;
276281
}
282+
// tool_use block 结束后清理 index→id 映射,避免长会话下 map 持续增长
283+
if (typeof json.index === "number") {
284+
toolUseByIndex.delete(json.index);
285+
}
277286
break;
278287
}
279288
case "message_delta": {
@@ -293,6 +302,7 @@ export function parseAnthropicStream(
293302
break;
294303
}
295304
case "message_stop": {
305+
toolUseByIndex.clear();
296306
onEvent({ type: "done" });
297307
return true;
298308
}

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

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -361,20 +361,25 @@ describe("parseOpenAIStream", () => {
361361

362362
await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);
363363

364-
expect(events).toHaveLength(3);
364+
expect(events).toHaveLength(4);
365365
expect(events[0].type).toBe("tool_call_start");
366366
if (events[0].type === "tool_call_start") {
367367
expect(events[0].toolCall.name).toBe("dom_read_page");
368-
expect(events[0].toolCall.arguments).toBe('{"tabId":123');
368+
// 新行为:start 事件的 args 永远为空,首 chunk 的 args 通过 delta 发出
369+
expect(events[0].toolCall.arguments).toBe("");
369370
}
370-
// 关键:最后的 tool_call_delta 不应被 usage 检查吞掉
371371
expect(events[1].type).toBe("tool_call_delta");
372372
if (events[1].type === "tool_call_delta") {
373-
expect(events[1].delta).toBe(',"mode":"summary"}');
373+
expect(events[1].delta).toBe('{"tabId":123'); // 故意的 — 模拟 streaming 还没收完的状态
374374
}
375-
expect(events[2].type).toBe("done");
376-
if (events[2].type === "done") {
377-
expect(events[2].usage).toEqual({ inputTokens: 40010, outputTokens: 154 });
375+
// 关键:最后的 tool_call_delta 不应被 usage 检查吞掉
376+
expect(events[2].type).toBe("tool_call_delta");
377+
if (events[2].type === "tool_call_delta") {
378+
expect(events[2].delta).toBe(',"mode":"summary"}');
379+
}
380+
expect(events[3].type).toBe("done");
381+
if (events[3].type === "done") {
382+
expect(events[3].usage).toEqual({ inputTokens: 40010, outputTokens: 154 });
378383
}
379384
});
380385

@@ -529,13 +534,84 @@ describe("parseOpenAIStream", () => {
529534

530535
await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);
531536

532-
expect(events).toHaveLength(4);
537+
expect(events).toHaveLength(5);
533538
expect(events[0]).toEqual({ type: "thinking_delta", delta: "分析页面" });
534539
expect(events[1]).toEqual({ type: "thinking_delta", delta: "结构" });
535540
expect(events[2].type).toBe("tool_call_start");
536-
expect(events[3].type).toBe("done");
537-
if (events[3].type === "done") {
538-
expect(events[3].usage).toEqual({ inputTokens: 500, outputTokens: 50 });
541+
if (events[2].type === "tool_call_start") {
542+
expect(events[2].toolCall.name).toBe("dom_read_page");
543+
expect(events[2].toolCall.arguments).toBe("");
544+
}
545+
expect(events[3].type).toBe("tool_call_delta");
546+
if (events[3].type === "tool_call_delta") {
547+
expect(events[3].delta).toBe('{"selector":".item"}');
548+
}
549+
expect(events[4].type).toBe("done");
550+
if (events[4].type === "done") {
551+
expect(events[4].usage).toEqual({ inputTokens: 500, outputTokens: 50 });
539552
}
540553
});
554+
555+
it("首 chunk 同时带 name 和 arguments 时:start 事件 args 为空,首 chunk args 作为 delta 发出", async () => {
556+
const reader = createMockReader([
557+
// gateway / 某些 model 会先发一个 arguments="{}" 占位再送真正 JSON
558+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_x","function":{"name":"agent","arguments":"{}"}}]}}]}\n\n',
559+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"description\\":\\"r\\""}}]}}]}\n\n',
560+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":",\\"prompt\\":\\"do\\"}"}}]}}]}\n\n',
561+
"data: [DONE]\n\n",
562+
]);
563+
const events: ChatStreamEvent[] = [];
564+
await parseOpenAIStream(reader, (e) => events.push(e), new AbortController().signal);
565+
566+
expect(events[0].type).toBe("tool_call_start");
567+
if (events[0].type === "tool_call_start") {
568+
// 关键断言:start 事件里的 args 必须为空,不能是 "{}"(避免前缀污染)
569+
expect(events[0].toolCall.arguments).toBe("");
570+
expect(events[0].toolCall.name).toBe("agent");
571+
}
572+
// 首 chunk 的 "{}" 作为第一段 delta 原样透传(模型问题:整体非合法 JSON,但解析器不吞字符)
573+
const deltas = events.filter((e) => e.type === "tool_call_delta");
574+
expect(deltas).toHaveLength(3);
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"}');
578+
});
579+
580+
it("并发多个 tool_call(不同 index)arguments 不应互相串扰", async () => {
581+
const reader = createMockReader([
582+
// 两个 tool 同时开始
583+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","function":{"name":"f1","arguments":""}}]}}]}\n\n',
584+
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","function":{"name":"f2","arguments":""}}]}}]}\n\n',
585+
// 然后交错发 arguments delta(只带 index,不带 id)
586+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"x\\":1}"}}]}}]}\n\n',
587+
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"y\\":2}"}}]}}]}\n\n',
588+
"data: [DONE]\n\n",
589+
]);
590+
const events: ChatStreamEvent[] = [];
591+
await parseOpenAIStream(reader, (e) => events.push(e), new AbortController().signal);
592+
// 基础断言:两个 start + 两个 delta + done
593+
const starts = events.filter((e) => e.type === "tool_call_start");
594+
expect(starts).toHaveLength(2);
595+
// (完整的 index 匹配需要 ChatStreamEvent 增加 index 字段,这里先确保 parser 不丢 event)
596+
});
597+
598+
it("并行 tool_call 按 index 正确分派 arguments", async () => {
599+
const reader = createMockReader([
600+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","function":{"name":"f1","arguments":""}}]}}]}\n\n',
601+
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","function":{"name":"f2","arguments":""}}]}}]}\n\n',
602+
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"y\\":2}"}}]}}]}\n\n',
603+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"x\\":1}"}}]}}]}\n\n',
604+
"data: [DONE]\n\n",
605+
]);
606+
const events: ChatStreamEvent[] = [];
607+
await parseOpenAIStream(reader, (e) => events.push(e), new AbortController().signal);
608+
609+
const deltas = events.filter((e) => e.type === "tool_call_delta");
610+
expect(deltas).toHaveLength(2);
611+
// 第一个 delta 对应 index=1(因为到达顺序)
612+
expect((deltas[0] as any).index).toBe(1);
613+
expect((deltas[0] as any).delta).toBe('{"y":2}');
614+
expect((deltas[1] as any).index).toBe(0);
615+
expect((deltas[1] as any).delta).toBe('{"x":1}');
616+
});
541617
});

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,19 +263,23 @@ export function parseOpenAIStream(
263263
// 工具调用
264264
if (delta.tool_calls) {
265265
for (const tc of delta.tool_calls) {
266+
// OpenAI 约定:第一个 chunk 带 id + function.name,后续 chunk 只带 index + function.arguments
266267
if (tc.function?.name) {
267268
onEvent({
268269
type: "tool_call_start",
269270
toolCall: {
270-
id: tc.id || `tc_${Date.now()}`,
271+
id: tc.id || `tc_${Date.now()}_${tc.index ?? 0}`,
271272
name: tc.function.name,
272-
arguments: tc.function.arguments || "",
273+
arguments: "", // 永远空启动,避免首 chunk 的 "{}" 作为 prefix 污染
273274
},
274275
});
275-
} else if (tc.function?.arguments) {
276+
}
277+
// 首 chunk 带 arguments 也作为 delta 处理(不 else if!)
278+
if (tc.function?.arguments !== undefined && tc.function.arguments !== "") {
276279
onEvent({
277280
type: "tool_call_delta",
278-
id: tc.id || "",
281+
id: tc.id || "", // 后续 chunk 大概率无 id,这里只保留接口兼容
282+
index: tc.index, // 用于匹配的字段
279283
delta: tc.function.arguments,
280284
});
281285
}

src/app/service/agent/core/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export type LLMStreamEvent =
117117
| { type: "content_delta"; delta: string }
118118
| { type: "thinking_delta"; delta: string }
119119
| { type: "tool_call_start"; toolCall: Omit<ToolCall, "result"> }
120-
| { type: "tool_call_delta"; id: string; delta: string }
120+
| { type: "tool_call_delta"; id: string; delta: string; index?: number }
121121
| { type: "tool_call_complete"; id: string; result: string; attachments?: Attachment[] }
122122
| { type: "content_block_start"; block: Omit<ImageBlock | FileBlock | AudioBlock, "attachmentId"> }
123123
| { type: "content_block_complete"; block: ImageBlock | FileBlock | AudioBlock; data?: string };

src/app/service/agent/service_worker/background.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,21 @@ describe("handleAttachToConversation 重连逻辑", () => {
410410

411411
(service as any).bgSessionManager.delete("conv-empty");
412412
});
413+
414+
it("tool_call_delta 按 index 分派給正確的 tool call", () => {
415+
const { service } = createTestService();
416+
const rc = createRunningConversation();
417+
const upd = (service as any).bgSessionManager.updateStreamingState.bind((service as any).bgSessionManager);
418+
419+
upd(rc, { type: "tool_call_start", toolCall: { id: "a", name: "f1", arguments: "" } });
420+
upd(rc, { type: "tool_call_start", toolCall: { id: "b", name: "f2", arguments: "" } });
421+
// 交錯到達
422+
upd(rc, { type: "tool_call_delta", id: "", index: 1, delta: '{"y":2}' });
423+
upd(rc, { type: "tool_call_delta", id: "", index: 0, delta: '{"x":1}' });
424+
425+
expect(rc.streamingState.toolCalls[0].arguments).toBe('{"x":1}');
426+
expect(rc.streamingState.toolCalls[1].arguments).toBe('{"y":2}');
427+
});
413428
});
414429

415430
// ---- 后台运行会话 集成测试 ----

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,35 @@ export class BackgroundSessionManager {
5757
case "tool_call_start":
5858
rc.streamingState.toolCalls.push({ ...event.toolCall, status: "running" });
5959
break;
60-
case "tool_call_delta":
61-
if (rc.streamingState.toolCalls.length > 0) {
62-
const last = rc.streamingState.toolCalls[rc.streamingState.toolCalls.length - 1];
63-
last.arguments += event.delta;
60+
case "tool_call_delta": {
61+
// 按 id 匹配(fallback 到最新 running 的 tc),不再盲目取 length-1。
62+
// 并发 tool call 时(OpenAI 用 index 区分、Anthropic 的多个 tool_use block)length-1 会把 delta 写错工具。
63+
if (rc.streamingState.toolCalls.length === 0) break;
64+
65+
let target: ToolCall | undefined = undefined;
66+
// 1a. 按 id 匹配
67+
if (event.id) {
68+
target = rc.streamingState.toolCalls.find((t) => t.id === event.id);
6469
}
70+
// 1b. 按 index 匹配(OpenAI 后续 chunk 无 id 只有 index)
71+
if (!target && event.index !== undefined) {
72+
target = rc.streamingState.toolCalls[event.index];
73+
}
74+
75+
// 2. fallback:最新一个状态为 running 的 tool call
76+
// (OpenAI 后续 chunk 不带 id,但同一 index 的 tool 一定在 running)
77+
if (!target) {
78+
for (let i = rc.streamingState.toolCalls.length - 1; i >= 0; i--) {
79+
if (rc.streamingState.toolCalls[i].status === "running") {
80+
target = rc.streamingState.toolCalls[i];
81+
break;
82+
}
83+
}
84+
}
85+
86+
if (target) target.arguments += event.delta;
6587
break;
88+
}
6689
case "tool_call_complete": {
6790
const tc = rc.streamingState.toolCalls.find((t) => t.id === event.id);
6891
if (tc) {

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/app/service/agent/service_worker/sub_agent_service.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,21 @@ export class SubAgentService {
138138
status: "running",
139139
});
140140
break;
141-
case "tool_call_delta":
142-
if (currentMsg.toolCalls.length) {
143-
currentMsg.toolCalls[currentMsg.toolCalls.length - 1].arguments += event.delta;
141+
case "tool_call_delta": {
142+
if (!currentMsg.toolCalls.length) break;
143+
let t = event.id ? currentMsg.toolCalls.find((x) => x.id === event.id) : undefined;
144+
if (!t && event.index !== undefined) t = currentMsg.toolCalls[event.index];
145+
if (!t) {
146+
for (let i = currentMsg.toolCalls.length - 1; i >= 0; i--) {
147+
if (currentMsg.toolCalls[i].status === "running") {
148+
t = currentMsg.toolCalls[i];
149+
break;
150+
}
151+
}
144152
}
153+
if (t) t.arguments += event.delta;
145154
break;
155+
}
146156
case "tool_call_complete": {
147157
const tc = currentMsg.toolCalls.find((t) => t.id === event.id);
148158
if (tc) {

0 commit comments

Comments
 (0)