//! OpenAI provider(Responses + Chat Completions)的录制/回放集成测试。 //! Recorded/replayed integration tests for the OpenAI provider (both //! Responses and Chat Completions). mod common; use focus_core::model::*; use focus_core::provider::{ProviderRequest, StreamEvent}; use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; use focus_providers::config::ProviderConfig; use focus_providers::openai::{OpenAiProtocol, OpenAiProvider}; use std::sync::Arc; fn provider(mock: common::MockTransport, protocol: OpenAiProtocol) -> OpenAiProvider { OpenAiProvider::with_transport(ProviderConfig::new("sk-test"), protocol, Arc::new(mock)) } fn request() -> ProviderRequest { ProviderRequest { model: "gpt-4o".into(), system_prompt: "Be concise.".into(), messages: vec![Message::user_text("hi")], tools: focus_json::JsonValue::arr(), max_tokens: Some(512), temperature: None, } } fn final_message(events: &[StreamEvent]) -> &AssistantMessage { events .iter() .find_map(|e| match e { StreamEvent::Done { message } => Some(message), _ => None, }) .expect("done event") } // ---- Chat Completions ---- #[test] fn chat_streams_text_and_usage() { let mut mock = common::MockTransport::new(); mock.push_body( "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ data: {\"id\":\"c1\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":5}}\n\n\ data: [DONE]\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); // 事件序列:start → text_start → 两个 text_delta → text_end → done。 // Sequence: start → text_start → two text_deltas → text_end → done. assert!(matches!(events[0], StreamEvent::Start { .. })); assert!(matches!(events[1], StreamEvent::TextStart { .. })); assert!(matches!(events[2], StreamEvent::TextDelta { ref delta, .. } if delta == "Hello")); assert!(matches!(events[3], StreamEvent::TextDelta { ref delta, .. } if delta == " world")); assert!(matches!(events[4], StreamEvent::TextEnd { .. })); let msg = final_message(&events); let text = msg.content[0].as_text().expect("text block"); assert_eq!(text.text, "Hello world"); assert_eq!(msg.stop_reason, StopReason::Stop); assert_eq!(msg.usage.input_tokens, 9); assert_eq!(msg.usage.output_tokens, 5); } #[test] fn chat_streams_interleaved_tool_calls() { let mut mock = common::MockTransport::new(); mock.push_body( // 第一个 chunk 同时打开两个调用;随后参数交错到达。 // First chunk opens both calls; arguments then arrive interleaved. "data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read\",\"arguments\":\"\"}},{\"index\":1,\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"write\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"a\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"b\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n\ data: [DONE]\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.stop_reason, StopReason::ToolUse); assert_eq!(msg.content.len(), 2, "two tool calls"); 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.name, "read"); assert_eq!(tc0.arguments.get_str("path"), Some("a")); assert_eq!(tc1.name, "write"); assert_eq!(tc1.arguments.get_str("path"), Some("b")); // 交错参数必须归位(回归:见 focus-core reducer 修复)。 // Interleaved arguments must land in the right slots (regression: see the // focus-core reducer fix). assert!(tc0.arguments.get_str("path") == Some("a")); } #[test] fn chat_encodes_error_chunk() { let mut mock = common::MockTransport::new(); mock.push_body( "data: {\"error\":{\"message\":\"invalid api key\",\"type\":\"authentication_error\"}}\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); match events.last().unwrap() { StreamEvent::Error { error } => { let msg = error.error_message.clone().unwrap_or_default(); assert!(msg.contains("invalid api key"), "got: {}", msg); } other => panic!("expected error event, got {:?}", other), } } // ---- Responses API ---- #[test] fn responses_streams_text() { let mut mock = common::MockTransport::new(); mock.push_body( "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\",\"status\":\"in_progress\"}}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"it1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[]}]}}\n\n\ event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"it1\",\"output_index\":0,\"delta\":\"Hi\"}\n\n\ event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"it1\",\"output_index\":0,\"delta\":\" there\"}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\",\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":4,\"total_tokens\":16,\"input_tokens_details\":{\"cached_tokens\":3}}}}\n\n", ); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); let msg = final_message(&events); let text = msg.content[0].as_text().expect("text block"); assert_eq!(text.text, "Hi there"); assert_eq!(msg.stop_reason, StopReason::Stop); assert_eq!(msg.usage.input_tokens, 12); assert_eq!(msg.usage.output_tokens, 4); assert_eq!(msg.usage.cache_read_tokens, 3); } #[test] fn responses_streams_function_call() { let mut mock = common::MockTransport::new(); mock.push_body( "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"fc1\",\"type\":\"function_call\",\"call_id\":\"call_x\",\"name\":\"read\",\"arguments\":\"\",\"status\":\"in_progress\"}}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc1\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\\\"x\"}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc1\",\"output_index\":0,\"delta\":\"y.txt\\\"}\"}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":6}}}\n\n", ); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.content.len(), 1); let tc = msg.content[0].as_tool_call().expect("tool call"); assert_eq!(tc.name, "read"); assert_eq!(tc.arguments.get_str("path"), Some("xy.txt")); } #[test] fn responses_encodes_error() { let mut mock = common::MockTransport::new(); mock.push_body( "event: error\ndata: {\"type\":\"error\",\"code\":\"invalid_request_error\",\"message\":\"bad model\"}\n\n", ); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); match events.last().unwrap() { StreamEvent::Error { error } => { let msg = error.error_message.clone().unwrap_or_default(); assert!(msg.contains("bad model"), "got: {}", msg); } other => panic!("expected error event, got {:?}", other), } } #[test] fn request_headers_and_path() { let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); let _ = common::collect(&provider, &request()); let req = mock.last_request(); assert_eq!(req.host, "api.openai.com"); assert_eq!(req.path, "/v1/chat/completions"); let headers: Vec<(String, String)> = req.headers.clone(); assert!(headers .iter() .any(|(k, v)| { k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test" })); } /// Chat Completions 请求体翻译(回归:原为内联私有函数测试)。 /// Chat Completions request-body translation (regression: was an inline /// private-fn test). #[test] fn chat_request_body_translation() { let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); let _ = common::collect(&provider, &rich_request()); let req = mock.last_request(); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); let messages = body.get_arr("messages").unwrap(); // system 提示作为第一条消息。 // The system prompt becomes the first message. assert_eq!(messages[0].get_str("role"), Some("system")); assert_eq!(messages[0].get_str("content"), Some("Be concise.")); // assistant 携带 tool_calls(arguments 为 JSON 字符串)。 // Assistant carries tool_calls (arguments as a JSON string). let assistant = &messages[2]; let calls = assistant.get_arr("tool_calls").unwrap(); assert_eq!(calls[0].get_str("id"), Some("call_abc")); let func = calls[0].get("function").unwrap(); assert_eq!(func.get_str("name"), Some("echo")); assert_eq!(func.get_str("arguments"), Some(r#"{"text":"x"}"#)); // tool 消息带 tool_call_id。 // Tool message carries tool_call_id. assert_eq!(messages[3].get_str("role"), Some("tool")); assert_eq!(messages[3].get_str("tool_call_id"), Some("call_abc")); // tools 被翻译成 function 格式。 // Tools translated into the function format. let tools = body.get_arr("tools").unwrap(); assert_eq!(tools[0].get_str("type"), Some("function")); let f = tools[0].get("function").unwrap(); assert_eq!(f.get_str("name"), Some("echo")); assert!(f.get("parameters").is_some()); assert_eq!(body.get_bool("stream"), Some(true)); assert_eq!(body.get_num("max_completion_tokens"), Some(512.0)); } /// Responses API 请求体翻译(回归:原为内联私有函数测试)。 /// Responses API request-body translation (regression: was an inline /// private-fn test). #[test] fn responses_request_body_translation() { let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::Responses); let _ = common::collect(&provider, &rich_request()); let req = mock.last_request(); assert_eq!(req.path, "/v1/responses"); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); assert_eq!(body.get_str("instructions"), Some("Be concise.")); let input = body.get_arr("input").unwrap(); // 顺序:user → assistant(role) → function_call → function_call_output。 // Order: user → assistant(role) → function_call → function_call_output. assert_eq!(input[0].get_str("role"), Some("user")); assert_eq!(input[1].get_str("role"), Some("assistant")); assert_eq!(input[2].get_str("type"), Some("function_call")); assert_eq!(input[2].get_str("call_id"), Some("call_abc")); assert_eq!(input[3].get_str("type"), Some("function_call_output")); assert_eq!(input[3].get_str("call_id"), Some("call_abc")); let tools = body.get_arr("tools").unwrap(); assert_eq!(tools[0].get_str("name"), Some("echo")); assert_eq!(body.get_num("max_output_tokens"), Some(512.0)); } /// finish_reason 映射:length → Length(回归:原为内联私有函数测试)。 /// Finish-reason mapping: length → Length (regression: was an inline /// private-fn test). #[test] fn maps_chat_length_finish_reason() { let mut mock = common::MockTransport::new(); mock.push_body( "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"length\"}]}\n\n\ data: [DONE]\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.stop_reason, StopReason::Length); } /// 一个携带工具调用与工具结果的更完整请求。 /// A richer request carrying a tool call and its result. fn rich_request() -> ProviderRequest { #[derive(Debug)] struct EchoTool; impl Tool for EchoTool { fn name(&self) -> &str { "echo" } fn description(&self) -> &str { "echoes text" } fn parameters(&self) -> focus_json::JsonValue { let mut o = focus_json::JsonValue::obj(); o.insert("type", "object".into()).ok(); let mut props = focus_json::JsonValue::obj(); props .insert("text", focus_json::JsonValue::Str("the text".into())) .ok(); o.insert("properties", props).ok(); o } fn effects(&self) -> ToolEffects { ToolEffects::READ } fn execute( &self, _id: &str, _args: &focus_json::JsonValue, _u: Option<&ToolUpdateSink>, ) -> Result { Ok(ToolResult::text("ok")) } } ProviderRequest { model: "gpt-4o".into(), system_prompt: "Be concise.".into(), messages: vec![ Message::user_text("hi"), Message::Assistant(AssistantMessage { content: vec![ContentBlock::ToolCall(ToolCall { id: "call_abc".into(), name: "echo".into(), arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(), })], model: "gpt-4o".into(), usage: Usage::default(), stop_reason: StopReason::ToolUse, error_message: None, timestamp: 0, }), Message::ToolResult(ToolResultMessage { tool_call_id: "call_abc".into(), tool_name: "echo".into(), content: vec![ContentBlock::text("ok")], details: focus_json::JsonValue::obj(), is_error: false, timestamp: 0, }), ], tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(), max_tokens: Some(512), temperature: Some(0.2), } } /// DeepSeek 等推理 API:思考内容必须作为 reasoning_content 回传(回归)。 /// Reasoning APIs (e.g. DeepSeek): thinking must be echoed back as /// reasoning_content (regression). #[test] fn chat_replays_reasoning_content() { use focus_core::tool::{ToolEffects, ToolRegistry}; #[derive(Debug)] struct NoopTool; impl Tool for NoopTool { fn name(&self) -> &str { "noop" } fn description(&self) -> &str { "does nothing" } fn parameters(&self) -> focus_json::JsonValue { focus_json::JsonValue::obj() } fn effects(&self) -> ToolEffects { ToolEffects::NONE } fn execute( &self, _id: &str, _args: &focus_json::JsonValue, _u: Option<&ToolUpdateSink>, ) -> Result { Ok(ToolResult::text("ok")) } } let request = ProviderRequest { model: "deepseek-v4-flash".into(), system_prompt: "sys".into(), messages: vec![ Message::user_text("hi"), Message::Assistant(AssistantMessage { content: vec![ ContentBlock::Thinking(ThinkingContent { thinking: "让我先分析一下".into(), signature: None, redacted: false, }), ContentBlock::text("让我看看"), ContentBlock::ToolCall(ToolCall { id: "call_x".into(), name: "shell".into(), arguments: focus_json::parse(r#"{"command":"ls"}"#).unwrap(), }), ], model: "deepseek-v4-flash".into(), usage: Usage::default(), stop_reason: StopReason::ToolUse, error_message: None, timestamp: 0, }), Message::ToolResult(ToolResultMessage { tool_call_id: "call_x".into(), tool_name: "shell".into(), content: vec![ContentBlock::text("ok")], details: focus_json::JsonValue::obj(), is_error: false, timestamp: 0, }), ], tools: ToolRegistry::with(Box::new(NoopTool)).tool_definitions(), max_tokens: Some(512), temperature: None, }; let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); let _ = common::collect(&provider, &request); let req = mock.last_request(); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); let messages = body.get_arr("messages").unwrap(); // [0] = system,[1] = user,[2] = assistant。 // [0] = system, [1] = user, [2] = assistant. let assistant = &messages[2]; // 思考内容作为 reasoning_content 回传(content 只含文本)。 // Thinking echoed as reasoning_content (content holds only text). assert_eq!( assistant.get_str("reasoning_content"), Some("让我先分析一下") ); assert_eq!(assistant.get_str("content"), Some("让我看看")); // 工具调用照常回传。 // Tool calls are still replayed. assert!(assistant.get_arr("tool_calls").is_some()); } /// 部分兼容端点用 reasoning_text 而非 reasoning_content 输出推理——都必须捕获。 /// Some compatible endpoints emit reasoning as reasoning_text instead of /// reasoning_content — both must be captured. #[test] fn chat_captures_reasoning_text() { let mut mock = common::MockTransport::new(); mock.push_body( "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_text\":\"\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_text\":\"让我先\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_text\":\"分析结构\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"答案是\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ data: [DONE]\n\n", ); let provider = provider(mock, OpenAiProtocol::ChatCompletions); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.content.len(), 2, "thinking + text"); match &msg.content[0] { ContentBlock::Thinking(t) => { assert_eq!(t.thinking, "让我先分析结构"); } other => panic!("expected thinking block, got {:?}", other), } let text = msg.content[1].as_text().expect("text block"); assert_eq!(text.text, "答案是"); } /// 回传时同时携带 reasoning_content 与 reasoning_text。 /// Replays carry both reasoning_content and reasoning_text. #[test] fn chat_replays_both_reasoning_field_names() { use focus_core::tool::{ToolEffects, ToolRegistry}; #[derive(Debug)] struct NoopTool; impl Tool for NoopTool { fn name(&self) -> &str { "noop" } fn description(&self) -> &str { "does nothing" } fn parameters(&self) -> focus_json::JsonValue { focus_json::JsonValue::obj() } fn effects(&self) -> ToolEffects { ToolEffects::NONE } fn execute( &self, _id: &str, _args: &focus_json::JsonValue, _u: Option<&ToolUpdateSink>, ) -> Result { Ok(ToolResult::text("ok")) } } let request = ProviderRequest { model: "deepseek-v4-flash".into(), system_prompt: "sys".into(), messages: vec![ Message::user_text("hi"), Message::Assistant(AssistantMessage { content: vec![ContentBlock::Thinking(ThinkingContent { thinking: "思考内容".into(), signature: None, redacted: false, })], model: "deepseek-v4-flash".into(), usage: Usage::default(), stop_reason: StopReason::Stop, error_message: None, timestamp: 0, }), ], tools: ToolRegistry::with(Box::new(NoopTool)).tool_definitions(), max_tokens: Some(512), temperature: None, }; let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions); let _ = common::collect(&provider, &request); let req = mock.last_request(); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); let messages = body.get_arr("messages").unwrap(); // [0] = system,[1] = user,[2] = assistant。 // [0] = system, [1] = user, [2] = assistant. let assistant = &messages[2]; assert_eq!(assistant.get_str("reasoning_content"), Some("思考内容")); assert_eq!(assistant.get_str("reasoning_text"), Some("思考内容")); } /// Responses API 的推理内容(reasoning_text.delta)必须被捕获为思考块(回归, /// 依据真实端点日志:deepseek-v4-flash 走 Responses 协议)。 /// Responses API reasoning (reasoning_text.delta) must be captured as a /// thinking block (regression, based on a real endpoint log). #[test] fn responses_captures_reasoning() { let mut mock = common::MockTransport::new(); mock.push_body( "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"r1\",\"status\":\"in_progress\",\"content\":[],\"summary\":[]},\"output_index\":0}\n\n\ event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\"The user\",\"item_id\":\"r1\",\"output_index\":0}\n\n\ event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\" asks about the project\",\"item_id\":\"r1\",\"output_index\":0}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"f1\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_x\",\"name\":\"shell\"},\"output_index\":1}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"command\\\":\\\"ls\\\"}\",\"item_id\":\"f1\",\"output_index\":1}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":5}}}}\n\n", ); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); let msg = final_message(&events); // 顺序:思考块 → 工具调用。 // Order: thinking block → tool call. assert_eq!(msg.content.len(), 2); match &msg.content[0] { ContentBlock::Thinking(t) => { assert_eq!(t.thinking, "The user asks about the project"); } other => panic!("expected thinking block, got {:?}", other), } let tc = msg.content[1].as_tool_call().expect("tool call"); assert_eq!(tc.name, "shell"); assert_eq!(tc.arguments.get_str("command"), Some("ls")); } /// 历史回传时,assistant 的思考必须以 reasoning item 回传。 /// Replaying history must include the assistant thinking as a reasoning item. #[test] fn responses_replays_reasoning_item() { let request = ProviderRequest { model: "deepseek-v4-flash".into(), system_prompt: "sys".into(), messages: vec![ Message::user_text("hi"), Message::Assistant(AssistantMessage { content: vec![ContentBlock::Thinking(ThinkingContent { thinking: "思考内容".into(), signature: None, redacted: false, })], model: "deepseek-v4-flash".into(), usage: Usage::default(), stop_reason: StopReason::Stop, error_message: None, timestamp: 0, }), ], tools: focus_json::JsonValue::arr(), max_tokens: Some(512), temperature: None, }; let mut mock = common::MockTransport::new(); mock.push_body("data: [DONE]\n\n"); let provider = provider(mock.clone(), OpenAiProtocol::Responses); let _ = common::collect(&provider, &request); let req = mock.last_request(); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); let input = body.get_arr("input").unwrap(); // [0] = user,[1] = reasoning item,[2] = assistant item。 // [0] = user, [1] = reasoning item, [2] = assistant item. let reasoning = &input[1]; assert_eq!(reasoning.get_str("type"), Some("reasoning")); let content = reasoning.get_arr("content").unwrap(); assert_eq!(content[0].get_str("type"), Some("reasoning_text")); assert_eq!(content[0].get_str("text"), Some("思考内容")); } // ---- 基于真实端点日志的回归测试 ---- // ---- regressions built from a real endpoint log (deepseek-v4-flash, // Responses API with reasoning items) -------------------------------- /// 真实日志第 1 轮的 SSE(reasoning + 两个 shell 调用)。 /// Turn 1 of the real log: reasoning + two shell calls. const REAL_TURN1: &str = "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\",\"status\":\"in_progress\"}}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rr1\",\"status\":\"in_progress\",\"content\":[],\"summary\":[]},\"output_index\":0}\n\n\ event: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"rr1\",\"output_index\":0,\"part\":{\"type\":\"reasoning_text\",\"text\":\"\"}}\n\n\ event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\"The user is asking in Chinese: \\\"Hello, what does this project do?\\\" Let me explore\",\"item_id\":\"rr1\",\"output_index\":0}\n\n\ event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\" the repository to understand what it is.\",\"item_id\":\"rr1\",\"output_index\":0}\n\n\ event: response.reasoning_text.done\ndata: {\"type\":\"response.reasoning_text.done\",\"content_index\":0,\"item_id\":\"rr1\",\"output_index\":0,\"text\":\"The user is asking in Chinese: \\\"Hello, what does this project do?\\\" Let me explore the repository to understand what it is.\"}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"f1\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_00_wyQ\",\"name\":\"shell\"},\"output_index\":1}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"command\\\": \\\"ls -la /home/focus/Desktop/Project/focus && cat /home/focus/Desktop/Project/focus/README.md 2>/dev/null\",\"item_id\":\"f1\",\"output_index\":1}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\" | head -50\\\"}\",\"item_id\":\"f1\",\"output_index\":1}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"f2\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_01_wUr\",\"name\":\"shell\"},\"output_index\":2}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"command\\\": \\\"find /home/focus/Desktop/Project/focus -maxdepth 2 -type f | head -50\\\"}\",\"item_id\":\"f2\",\"output_index\":2}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":850,\"input_tokens_details\":{\"cached_tokens\":768},\"output_tokens\":157,\"output_tokens_details\":{\"reasoning_tokens\":27},\"total_tokens\":1007}}}\n\n"; /// 真实日志第 2 轮的 SSE(reasoning + read + shell)。 /// Turn 2 of the real log: reasoning + read + shell. const REAL_TURN2: &str = "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rr2\",\"status\":\"in_progress\",\"content\":[],\"summary\":[]},\"output_index\":0}\n\n\ event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\"Let me look at the Cargo.toml and AGENTS.md to understand what this project is.\",\"item_id\":\"rr2\",\"output_index\":0}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"f3\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_00_nK3\",\"name\":\"read\"},\"output_index\":1}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"path\\\": \\\"/home/focus/Desktop/Project/focus/Cargo.toml\\\"}\",\"item_id\":\"f3\",\"output_index\":1}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"f4\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_01_1IR\",\"name\":\"shell\"},\"output_index\":2}\n\n\ event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"command\\\": \\\"ls /home/focus/Desktop/Project/focus/crates /home/focus/Desktop/Project/focus/docs /home/focus/Desktop/Project/focus/examples\\\"}\",\"item_id\":\"f4\",\"output_index\":2}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1601,\"input_tokens_details\":{\"cached_tokens\":896},\"output_tokens\":142,\"output_tokens_details\":{\"reasoning_tokens\":21},\"total_tokens\":1743}}}\n\n"; /// 终止轮:reasoning + 文本回答。 /// A terminating turn: reasoning + text answer. const REAL_TURN3: &str = "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rr3\",\"status\":\"in_progress\",\"content\":[],\"summary\":[]},\"output_index\":0}\n\n\ event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\"Now I can answer.\",\"item_id\":\"rr3\",\"output_index\":0}\n\n\ event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"m1\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[]}]},\"output_index\":1}\n\n\ event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"m1\",\"output_index\":1,\"delta\":\"这是一个 Rust 项目。\"}\n\n\ event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":900,\"output_tokens\":30}}}\n\n"; /// provider 级:真实第 1 轮 → 捕获推理(思考块)+ 两个工具调用 + usage。 /// Provider level: real turn 1 → reasoning captured (thinking block) + two /// tool calls + usage. #[test] fn real_log_captures_reasoning_and_tools() { let mut mock = common::MockTransport::new(); mock.push_body(REAL_TURN1); let provider = provider(mock, OpenAiProtocol::Responses); let events = common::collect(&provider, &request()); let msg = final_message(&events); assert_eq!(msg.content.len(), 3, "thinking + 2 tool calls"); match &msg.content[0] { ContentBlock::Thinking(t) => { assert_eq!( t.thinking, "The user is asking in Chinese: \"Hello, what does this project do?\" Let me explore the repository to understand what it is." ); } other => panic!("expected thinking block, got {:?}", other), } let tc1 = msg.content[1].as_tool_call().expect("tool call 1"); assert_eq!(tc1.name, "shell"); let cmd1 = tc1.arguments.get_str("command").unwrap_or(""); assert!(cmd1.contains("ls -la"), "got: {}", cmd1); let tc2 = msg.content[2].as_tool_call().expect("tool call 2"); assert_eq!(tc2.name, "shell"); 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); assert_eq!(msg.usage.output_tokens, 157); assert_eq!(msg.usage.cache_read_tokens, 768); } /// agent 级全链路:真实日志的三轮 → 思考进入 transcript + 历史回传 reasoning item。 /// Agent-level end-to-end: three real-log turns → thinking lands in the /// transcript + the replay carries the reasoning item. #[test] fn agent_multi_turn_replays_reasoning() { use focus_core::event::VecSink; use focus_core::tool::{ToolEffects, ToolRegistry}; use focus_core::{Agent, AgentConfig, CoreError}; #[derive(Debug)] struct NoopTool { name: String, } impl Tool for NoopTool { fn name(&self) -> &str { &self.name } fn description(&self) -> &str { "does nothing" } fn parameters(&self) -> focus_json::JsonValue { focus_json::JsonValue::obj() } fn effects(&self) -> ToolEffects { ToolEffects::NONE } fn execute( &self, _id: &str, _args: &focus_json::JsonValue, _u: Option<&ToolUpdateSink>, ) -> Result { Ok(ToolResult::text("ok")) } } let mut mock = common::MockTransport::new(); mock.push_body(REAL_TURN1); mock.push_body(REAL_TURN2); mock.push_body(REAL_TURN3); let provider = OpenAiProvider::with_transport( ProviderConfig::new("sk-test"), OpenAiProtocol::Responses, Arc::new(mock.clone()), ); let config = AgentConfig { model: "deepseek-v4-flash".into(), system_prompt: "sys".into(), max_tokens: Some(4096), ..Default::default() }; let mut registry = ToolRegistry::new(); registry.register(Box::new(NoopTool { name: "shell".into(), })); registry.register(Box::new(NoopTool { name: "read".into(), })); let mut agent = Agent::new(config, Box::new(provider), registry); let mut sink = VecSink::new(); agent .prompt("你好,本项目是干什么的", &mut sink) .expect("run failed"); // 三轮:user + [assistant, tool, tool] ×2 + [assistant(text)]。 // Three turns: user + [assistant, tool, tool] ×2 + [assistant(text)]. let msgs = agent.messages(); assert_eq!(msgs.len(), 8, "1 user + 3 assistant + 4 tool results"); // 第 1 条 assistant 消息:思考块(真实日志文本)在前。 // The first assistant message: a thinking block (real log text) first. let first_assistant = msgs .iter() .find_map(|m| match m { Message::Assistant(a) => Some(a), _ => None, }) .expect("assistant message"); match &first_assistant.content[0] { ContentBlock::Thinking(t) => { assert!( t.thinking.contains("The user is asking in Chinese"), "got: {}", t.thinking ); } other => panic!("expected thinking, got {:?}", other), } // 第 2 条 assistant 消息的思考(真实日志第 2 轮)。 // The second assistant's thinking (real log turn 2). let second_assistant = msgs .iter() .filter_map(|m| match m { Message::Assistant(a) => Some(a), _ => None, }) .nth(1) .expect("second assistant"); match &second_assistant.content[0] { ContentBlock::Thinking(t) => { assert!( t.thinking.contains("Cargo.toml and AGENTS.md"), "got: {}", t.thinking ); } other => panic!("expected thinking, got {:?}", other), } // 三个请求都发出了。 // All three requests were sent. assert_eq!(mock.request_count(), 3); // 第 2 个请求(回放第 1 轮历史)必须携带 reasoning item。 // The second request (replaying turn-1 history) must carry a reasoning // item. let reqs = mock.requests(); let body2: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&reqs[1].body)).unwrap(); let input = body2.get_arr("input").expect("input items"); let reasoning = input .iter() .find(|i| i.get_str("type") == Some("reasoning")) .expect("reasoning item in replay"); let content = reasoning.get_arr("content").unwrap(); let text = content[0].get_str("text").unwrap_or(""); assert!( text.contains("The user is asking in Chinese"), "got: {}", text ); // 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")) .expect("function_call item"); let fco = input .iter() .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 ); }