Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ yarn.lock
.claude

CLAUDE.md
.omc

test-results
playwright-report
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@
"@eslint/compat": "^1.4.1",
"@eslint/js": "9.39.2",
"@playwright/test": "^1.58.2",
"@rspack/cli": "^1.7.6",
"@rspack/core": "^1.6.8",
"@rspack/cli": "^1.7.11",
"@rspack/core": "^1.7.11",
"@swc/helpers": "^0.5.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
Expand Down Expand Up @@ -107,7 +107,7 @@
"unocss": "66.5.4",
"vitest": "^4.0.18"
},
"packageManager": "pnpm@10.12.4",
"packageManager": "pnpm@10.33.0",
"sideEffects": [
"**/*.css",
"**/*.scss",
Expand Down
1,453 changes: 430 additions & 1,023 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minimumReleaseAge: 10080
20 changes: 20 additions & 0 deletions src/app/service/agent/core/providers/anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,4 +555,24 @@ describe("parseAnthropicStream", () => {
expect(events).toHaveLength(2);
expect(events[0]).toEqual({ type: "content_delta", delta: "ok" });
});

it("tool_use block 的 input_json_delta 應帶上 id 和 index", async () => {
const reader = createMockReader([
'event: content_block_start\ndata: {"index":1,"content_block":{"type":"tool_use","id":"toolu_X","name":"f"}}\n\n',
'event: content_block_delta\ndata: {"index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"a\\":1}"}}\n\n',
"event: message_stop\ndata: {}\n\n",
]);

const events: ChatStreamEvent[] = [];
const controller = new AbortController();

await parseAnthropicStream(reader, (e) => events.push(e), controller.signal);

const d = events.find((e) => e.type === "tool_call_delta");
expect(d).toBeDefined();
if (d && d.type === "tool_call_delta") {
expect(d.id).toBe("toolu_X");
expect(d.index).toBe(1);
}
});
});
7 changes: 6 additions & 1 deletion src/app/service/agent/core/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export function parseAnthropicStream(
// 跟踪图片块的累积 base64 数据
let imageBlockData: { index: number; mediaType: string; base64Chunks: string[] } | null = null;

const toolUseByIndex = new Map<number, { id: string }>();

return readSSEStream(
reader,
signal,
Expand All @@ -212,6 +214,7 @@ export function parseAnthropicStream(
if (block?.type === "thinking") {
// thinking block 开始,后续通过 content_block_delta 传输内容
} else if (block?.type === "tool_use") {
toolUseByIndex.set(json.index, { id: block.id });
onEvent({
type: "tool_call_start",
toolCall: {
Expand Down Expand Up @@ -245,9 +248,11 @@ export function parseAnthropicStream(
} else if (delta?.type === "thinking_delta") {
onEvent({ type: "thinking_delta", delta: delta.thinking });
} else if (delta?.type === "input_json_delta") {
const tu = toolUseByIndex.get(json.index); // ← 新增
onEvent({
type: "tool_call_delta",
id: "",
id: tu?.id || "", // ← 不再固定 ""
index: json.index, // ← 新增
delta: delta.partial_json,
});
} else if (delta?.type === "image_delta" && imageBlockData) {
Expand Down
100 changes: 89 additions & 11 deletions src/app/service/agent/core/providers/openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,20 +361,25 @@ describe("parseOpenAIStream", () => {

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

expect(events).toHaveLength(3);
expect(events).toHaveLength(4);
expect(events[0].type).toBe("tool_call_start");
if (events[0].type === "tool_call_start") {
expect(events[0].toolCall.name).toBe("dom_read_page");
expect(events[0].toolCall.arguments).toBe('{"tabId":123');
// 新行为:start 事件的 args 永远为空,首 chunk 的 args 通过 delta 发出
expect(events[0].toolCall.arguments).toBe("");
}
// 关键:最后的 tool_call_delta 不应被 usage 检查吞掉
expect(events[1].type).toBe("tool_call_delta");
if (events[1].type === "tool_call_delta") {
expect(events[1].delta).toBe(',"mode":"summary"}');
expect(events[1].delta).toBe('{"tabId":123'); // 故意的 — 模拟 streaming 还没收完的状态
}
expect(events[2].type).toBe("done");
if (events[2].type === "done") {
expect(events[2].usage).toEqual({ inputTokens: 40010, outputTokens: 154 });
// 关键:最后的 tool_call_delta 不应被 usage 检查吞掉
expect(events[2].type).toBe("tool_call_delta");
if (events[2].type === "tool_call_delta") {
expect(events[2].delta).toBe(',"mode":"summary"}');
}
expect(events[3].type).toBe("done");
if (events[3].type === "done") {
expect(events[3].usage).toEqual({ inputTokens: 40010, outputTokens: 154 });
}
});

Expand Down Expand Up @@ -439,13 +444,86 @@ describe("parseOpenAIStream", () => {

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

expect(events).toHaveLength(4);
expect(events).toHaveLength(5);
expect(events[0]).toEqual({ type: "thinking_delta", delta: "分析页面" });
expect(events[1]).toEqual({ type: "thinking_delta", delta: "结构" });
expect(events[2].type).toBe("tool_call_start");
expect(events[3].type).toBe("done");
if (events[3].type === "done") {
expect(events[3].usage).toEqual({ inputTokens: 500, outputTokens: 50 });
if (events[2].type === "tool_call_start") {
expect(events[2].toolCall.name).toBe("dom_read_page");
expect(events[2].toolCall.arguments).toBe("");
}
expect(events[3].type).toBe("tool_call_delta");
if (events[3].type === "tool_call_delta") {
expect(events[3].delta).toBe('{"selector":".item"}');
}
expect(events[4].type).toBe("done");
if (events[4].type === "done") {
expect(events[4].usage).toEqual({ inputTokens: 500, outputTokens: 50 });
}
});

it("首 chunk 同时带 name 和 arguments='{}' 时不应污染后续 args", async () => {
const reader = createMockReader([
// gateway / 某些 model 会先发一个 arguments="{}" 占位再送真正 JSON
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_x","function":{"name":"agent","arguments":"{}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"description\\":\\"r\\""}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":",\\"prompt\\":\\"do\\"}"}}]}}]}\n\n',
"data: [DONE]\n\n",
]);
const events: ChatStreamEvent[] = [];
await parseOpenAIStream(reader, (e) => events.push(e), new AbortController().signal);

expect(events[0].type).toBe("tool_call_start");
if (events[0].type === "tool_call_start") {
// 关键断言:start 事件里的 args 必须为空,不能是 "{}"
expect(events[0].toolCall.arguments).toBe("");
expect(events[0].toolCall.name).toBe("agent");
}
// 三段 delta:首 chunk 的 "{}" + 两次真实 JSON
const deltas = events.filter((e) => e.type === "tool_call_delta");
expect(deltas).toHaveLength(3);
const joined = deltas.map((e) => (e.type === "tool_call_delta" ? e.delta : "")).join("");
// 拼接后应等同 LLM 真正要发的(就算首 chunk 有 "{}",也应被后续覆盖式语义接受)
// 注意:如果模型真的先发 "{}" 再发别的 JSON,整体不是合法 JSON —— 这是模型问题,
// 但至少我们不在 start 事件里把 "{}" 当成 args 的 prefix。
expect(joined.startsWith("{}")).toBe(true); // 原样透传
});

it("并发多个 tool_call(不同 index)arguments 不应互相串扰", async () => {
const reader = createMockReader([
// 两个 tool 同时开始
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","function":{"name":"f1","arguments":""}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","function":{"name":"f2","arguments":""}}]}}]}\n\n',
// 然后交错发 arguments delta(只带 index,不带 id)
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"x\\":1}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"y\\":2}"}}]}}]}\n\n',
"data: [DONE]\n\n",
]);
const events: ChatStreamEvent[] = [];
await parseOpenAIStream(reader, (e) => events.push(e), new AbortController().signal);
// 基础断言:两个 start + 两个 delta + done
const starts = events.filter((e) => e.type === "tool_call_start");
expect(starts).toHaveLength(2);
// (完整的 index 匹配需要 ChatStreamEvent 增加 index 字段,这里先确保 parser 不丢 event)
});

it("并行 tool_call 按 index 正确分派 arguments", async () => {
const reader = createMockReader([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","function":{"name":"f1","arguments":""}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","function":{"name":"f2","arguments":""}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"y\\":2}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"x\\":1}"}}]}}]}\n\n',
"data: [DONE]\n\n",
]);
const events: ChatStreamEvent[] = [];
await parseOpenAIStream(reader, (e) => events.push(e), new AbortController().signal);

const deltas = events.filter((e) => e.type === "tool_call_delta");
expect(deltas).toHaveLength(2);
// 第一个 delta 对应 index=1(因为到达顺序)
expect((deltas[0] as any).index).toBe(1);
expect((deltas[0] as any).delta).toBe('{"y":2}');
expect((deltas[1] as any).index).toBe(0);
expect((deltas[1] as any).delta).toBe('{"x":1}');
});
});
12 changes: 8 additions & 4 deletions src/app/service/agent/core/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,19 +203,23 @@ export function parseOpenAIStream(
// 工具调用
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
// OpenAI 约定:第一个 chunk 带 id + function.name,后续 chunk 只带 index + function.arguments
if (tc.function?.name) {
onEvent({
type: "tool_call_start",
toolCall: {
id: tc.id || `tc_${Date.now()}`,
id: tc.id || `tc_${Date.now()}_${tc.index ?? 0}`,
name: tc.function.name,
arguments: tc.function.arguments || "",
arguments: "", // 永远空启动,避免首 chunk 的 "{}" 作为 prefix 污染
},
});
} else if (tc.function?.arguments) {
}
// 首 chunk 带 arguments 也作为 delta 处理(不 else if!)
if (tc.function?.arguments !== undefined && tc.function.arguments !== "") {
onEvent({
type: "tool_call_delta",
id: tc.id || "",
id: tc.id || "", // 后续 chunk 大概率无 id,这里只保留接口兼容
index: tc.index, // 用于匹配的字段
delta: tc.function.arguments,
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/service/agent/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export type LLMStreamEvent =
| { type: "content_delta"; delta: string }
| { type: "thinking_delta"; delta: string }
| { type: "tool_call_start"; toolCall: Omit<ToolCall, "result"> }
| { type: "tool_call_delta"; id: string; delta: string }
| { type: "tool_call_delta"; id: string; delta: string; index?: number }
| { type: "tool_call_complete"; id: string; result: string; attachments?: Attachment[] }
| { type: "content_block_start"; block: Omit<ImageBlock | FileBlock | AudioBlock, "attachmentId"> }
| { type: "content_block_complete"; block: ImageBlock | FileBlock | AudioBlock; data?: string };
Expand Down
15 changes: 15 additions & 0 deletions src/app/service/agent/service_worker/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,21 @@ describe("handleAttachToConversation 重连逻辑", () => {

(service as any).bgSessionManager.delete("conv-empty");
});

it("tool_call_delta 按 index 分派給正確的 tool call", () => {
const { service } = createTestService();
const rc = createRunningConversation();
const upd = (service as any).bgSessionManager.updateStreamingState.bind((service as any).bgSessionManager);

upd(rc, { type: "tool_call_start", toolCall: { id: "a", name: "f1", arguments: "" } });
upd(rc, { type: "tool_call_start", toolCall: { id: "b", name: "f2", arguments: "" } });
// 交錯到達
upd(rc, { type: "tool_call_delta", id: "", index: 1, delta: '{"y":2}' });
upd(rc, { type: "tool_call_delta", id: "", index: 0, delta: '{"x":1}' });

expect(rc.streamingState.toolCalls[0].arguments).toBe('{"x":1}');
expect(rc.streamingState.toolCalls[1].arguments).toBe('{"y":2}');
});
});

// ---- 后台运行会话 集成测试 ----
Expand Down
31 changes: 27 additions & 4 deletions src/app/service/agent/service_worker/background_session_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,35 @@ export class BackgroundSessionManager {
case "tool_call_start":
rc.streamingState.toolCalls.push({ ...event.toolCall, status: "running" });
break;
case "tool_call_delta":
if (rc.streamingState.toolCalls.length > 0) {
const last = rc.streamingState.toolCalls[rc.streamingState.toolCalls.length - 1];
last.arguments += event.delta;
case "tool_call_delta": {
// 按 id 匹配(fallback 到最新 running 的 tc),不再盲目取 length-1。
// 并发 tool call 时(OpenAI 用 index 区分、Anthropic 的多个 tool_use block)length-1 会把 delta 写错工具。
if (rc.streamingState.toolCalls.length === 0) break;

let target;
// 1a. 按 id 配對
if (event.id) {
target = rc.streamingState.toolCalls.find((t) => t.id === event.id);
}
// 1b. 按 index 配對(OpenAI 後續 chunk 無 id 只有 index)
if (!target && event.index !== undefined) {
target = rc.streamingState.toolCalls[event.index];
}

// 2. fallback:最新一个状态为 running 的 tool call
// (OpenAI 后续 chunk 不带 id,但同一 index 的 tool 一定在 running)
if (!target) {
for (let i = rc.streamingState.toolCalls.length - 1; i >= 0; i--) {
if (rc.streamingState.toolCalls[i].status === "running") {
target = rc.streamingState.toolCalls[i];
break;
}
}
}

if (target) target.arguments += event.delta;
break;
}
case "tool_call_complete": {
const tc = rc.streamingState.toolCalls.find((t) => t.id === event.id);
if (tc) {
Expand Down
16 changes: 13 additions & 3 deletions src/app/service/agent/service_worker/sub_agent_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,21 @@ export class SubAgentService {
status: "running",
});
break;
case "tool_call_delta":
if (currentMsg.toolCalls.length) {
currentMsg.toolCalls[currentMsg.toolCalls.length - 1].arguments += event.delta;
case "tool_call_delta": {
if (!currentMsg.toolCalls.length) break;
let t = event.id ? currentMsg.toolCalls.find((x) => x.id === event.id) : undefined;
if (!t && event.index !== undefined) t = currentMsg.toolCalls[event.index];
if (!t) {
for (let i = currentMsg.toolCalls.length - 1; i >= 0; i--) {
if (currentMsg.toolCalls[i].status === "running") {
t = currentMsg.toolCalls[i];
break;
}
}
}
if (t) t.arguments += event.delta;
break;
}
case "tool_call_complete": {
const tc = currentMsg.toolCalls.find((t) => t.id === event.id);
if (tc) {
Expand Down
Loading
Loading