focus/crates/focus-providers/tests/openai_tests.rs

870 lines
41 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! OpenAI providerResponses + 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_callsarguments 为 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<ToolResult, focus_core::CoreError> {
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<ToolResult, focus_core::CoreError> {
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<ToolResult, focus_core::CoreError> {
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 轮的 SSEreasoning + 两个 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 轮的 SSEreasoning + 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<ToolResult, CoreError> {
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
);
}