840 lines
40 KiB
Rust
840 lines
40 KiB
Rust
//! 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<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 轮的 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);
|
||
|
||
// 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 一致。
|
||
// The function_call and function_call_output call ids match.
|
||
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"));
|
||
}
|