diff --git a/crates/focus-core/src/provider.rs b/crates/focus-core/src/provider.rs index 3424476..4da072f 100644 --- a/crates/focus-core/src/provider.rs +++ b/crates/focus-core/src/provider.rs @@ -276,15 +276,33 @@ impl ProviderEventReducer { out } - /// 在 `index`(provider 局部索引,映射到一个新内容槽)处打开工具调用块。 + /// 在 `index`(provider 局部索引,映射到一个新内容槽)处打开工具调用块, + /// 使用指定的调用 id(API 提供的真实 id)。 /// Open a tool-call block at `index` (provider-local index, mapped to a - /// new content slot). - pub fn tool_call_start(&mut self, index: usize, name: &str) -> Vec { + /// new content slot), using the given call id (the API's real id). + /// + /// 使用 API 的真实 id 至关重要:跨多轮工具调用时,若每轮都从 `call_0` + /// 重新生成,回放历史时 id 会冲突,导致 API 无法配对 function_call 与 + /// function_call_output。空 id 时退回自动生成。 + /// Using the API's real id matters: across multiple tool-using turns, + /// re-generating `call_0` every turn collides on replay, breaking the + /// function_call ↔ function_call_output pairing. Empty ids fall back to + /// auto-generated ones. + pub fn tool_call_start_with_id( + &mut self, + index: usize, + name: &str, + id: &str, + ) -> Vec { let mut out = Vec::new(); if let Some(ev) = self.ensure_started() { out.push(ev); } - let call_id = format!("call_{}", self.tool_call_pos.len()); + let call_id = if id.is_empty() { + format!("call_{}", self.tool_call_pos.len()) + } else { + id.to_string() + }; self.content.push(ContentBlock::ToolCall(ToolCall { id: call_id, name: name.to_string(), @@ -300,6 +318,14 @@ impl ProviderEventReducer { out } + /// 在 `index`(provider 局部索引,映射到一个新内容槽)处打开工具调用块, + /// 自动生成调用 id。 + /// Open a tool-call block at `index` (provider-local index, mapped to a + /// new content slot) with an auto-generated call id. + pub fn tool_call_start(&mut self, index: usize, name: &str) -> Vec { + self.tool_call_start_with_id(index, name, "") + } + /// 向 `index` 处的工具调用追加部分 JSON 参数(按 provider 局部索引寻址)。 /// Append partial JSON arguments to the tool call at `index` (addressed by /// the provider-local index). diff --git a/crates/focus-core/tests/provider_tests.rs b/crates/focus-core/tests/provider_tests.rs index 98b5d17..4140108 100644 --- a/crates/focus-core/tests/provider_tests.rs +++ b/crates/focus-core/tests/provider_tests.rs @@ -92,3 +92,18 @@ fn reducer_thinking_then_text() { .iter() .any(|e| matches!(e, StreamEvent::ThinkingEnd { .. }))); } + +/// 工具调用 id 可显式指定(API 真实 id),空时退回自动生成。 +/// Tool-call ids can be set explicitly (the API's real id); empty falls back +/// to auto-generation. +#[test] +fn reducer_tool_call_id_override() { + let mut r = ProviderEventReducer::new("m"); + let _ = r.tool_call_start_with_id(0, "shell", "call_00_realId"); + let _ = r.tool_call_start_with_id(1, "read", ""); + let msg = r.finish().expect("final message"); + let tc0 = msg.content[0].as_tool_call().expect("call 0"); + let tc1 = msg.content[1].as_tool_call().expect("call 1"); + assert_eq!(tc0.id, "call_00_realId"); + assert_eq!(tc1.id, "call_1"); +} diff --git a/crates/focus-providers/src/openai.rs b/crates/focus-providers/src/openai.rs index 79f3f35..07d76bf 100644 --- a/crates/focus-providers/src/openai.rs +++ b/crates/focus-providers/src/openai.rs @@ -650,7 +650,13 @@ impl OpenAiTurn { if let Some(f) = tc.get("function") { if let Some(name) = f.get_str("name") { if !name.is_empty() && self.seen_calls.insert(idx) { - for ev in self.reducer.tool_call_start(idx, name) { + // 用 API 的真实 id(跨回合唯一)。 + // Use the API's real id (unique across + // turns). + let call_id = tc.get_str("id").unwrap_or(""); + for ev in + self.reducer.tool_call_start_with_id(idx, name, call_id) + { if !common::send(tx, ev) { return Ok(false); } @@ -709,8 +715,12 @@ impl OpenAiTurn { "function_call" => { let idx = json.get_num("output_index").unwrap_or(0.0) as usize; let name = item.get_str("name").unwrap_or("").to_string(); + // 用 API 的真实 call_id(跨回合唯一,回放时避免冲突)。 + // Use the API's real call_id (unique across turns, + // avoiding collisions on replay). + let call_id = item.get_str("call_id").unwrap_or(""); if !name.is_empty() { - for e in self.reducer.tool_call_start(idx, &name) { + for e in self.reducer.tool_call_start_with_id(idx, &name, call_id) { if !common::send(tx, e) { return Ok(false); } diff --git a/crates/focus-providers/tests/openai_tests.rs b/crates/focus-providers/tests/openai_tests.rs index 0a6df6c..83edd9a 100644 --- a/crates/focus-providers/tests/openai_tests.rs +++ b/crates/focus-providers/tests/openai_tests.rs @@ -686,6 +686,12 @@ fn real_log_captures_reasoning_and_tools() { let cmd2 = tc2.arguments.get_str("command").unwrap_or(""); assert!(cmd2.contains("find"), "got: {}", cmd2); + // 调用 id 必须保留 API 的真实 id(跨回合唯一,回放不冲突)。 + // Call ids must keep the API's real ids (unique across turns, no replay + // collisions). + assert_eq!(tc1.id, "call_00_wyQ"); + assert_eq!(tc2.id, "call_01_wUr"); + // usage 映射:input / output / cache-read。 // Usage mapping: input / output / cache-read. assert_eq!(msg.usage.input_tokens, 850); @@ -825,8 +831,9 @@ fn agent_multi_turn_replays_reasoning() { "got: {}", text ); - // function_call 与 function_call_output 的 call_id 一致。 - // The function_call and function_call_output call ids match. + // function_call 与 function_call_output 的 call_id 一致,且为 API 的真实 id。 + // The function_call and function_call_output call ids match, using the + // API's real ids. let fc = input .iter() .find(|i| i.get_str("type") == Some("function_call")) @@ -836,4 +843,27 @@ fn agent_multi_turn_replays_reasoning() { .find(|i| i.get_str("type") == Some("function_call_output")) .expect("function_call_output item"); assert_eq!(fc.get_str("call_id"), fco.get_str("call_id")); + assert_eq!(fc.get_str("call_id"), Some("call_00_wyQ")); + + // 第 3 个请求(回放两轮历史):两轮的 call_id 不得冲突(回归:此前每轮 + // 都从 call_0 重新生成导致跨回合撞号)。 + // The third request (replaying two turns): call ids across turns must not + // collide (regression: ids used to restart at call_0 every turn). + let body3: focus_json::JsonValue = + focus_json::parse(&String::from_utf8_lossy(&reqs[2].body)).unwrap(); + let input3 = body3.get_arr("input").expect("input items"); + let call_ids: Vec<&str> = input3 + .iter() + .filter(|i| i.get_str("type") == Some("function_call")) + .filter_map(|i| i.get_str("call_id")) + .collect(); + let mut unique: Vec<&str> = call_ids.clone(); + unique.sort(); + unique.dedup(); + assert_eq!( + unique.len(), + call_ids.len(), + "call ids must be unique across turns, got {:?}", + call_ids + ); }