//! Anthropic provider 的录制/回放集成测试。 //! Recorded/replayed integration tests for the Anthropic provider. mod common; use focus_core::model::*; use focus_core::provider::{ProviderRequest, StreamEvent}; use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; use focus_providers::anthropic::AnthropicProvider; use focus_providers::config::ProviderConfig; use focus_transport::TransportError; use std::sync::Arc; fn provider(mock: common::MockTransport) -> AnthropicProvider { AnthropicProvider::with_transport(ProviderConfig::new("sk-test"), Arc::new(mock)) } fn request() -> ProviderRequest { ProviderRequest { model: "claude-sonnet-4".into(), system_prompt: "You are helpful.".into(), messages: vec![Message::user_text("hello")], tools: focus_json::JsonValue::arr(), max_tokens: Some(1024), temperature: None, } } /// 事件序列化简:只保留 (标签, 文本/名称) 供断言。 /// Event-sequence digest: keep only (label, text/name) for assertions. fn digest(events: &[StreamEvent]) -> Vec<(&'static str, String)> { events .iter() .map(|e| match e { StreamEvent::Start { .. } => ("start", String::new()), StreamEvent::TextStart { .. } => ("text_start", String::new()), StreamEvent::TextDelta { delta, .. } => ("text_delta", delta.clone()), StreamEvent::TextEnd { .. } => ("text_end", String::new()), StreamEvent::ThinkingStart { .. } => ("thinking_start", String::new()), StreamEvent::ThinkingDelta { delta, .. } => ("thinking_delta", delta.clone()), StreamEvent::ThinkingEnd { .. } => ("thinking_end", String::new()), StreamEvent::ToolCallStart { .. } => ("tool_start", String::new()), StreamEvent::ToolCallDelta { delta, .. } => ("tool_delta", delta.clone()), StreamEvent::ToolCallEnd { tool_call, .. } => ("tool_end", tool_call.name.clone()), StreamEvent::Done { message } => ("done", message.stop_reason.as_str().into()), StreamEvent::Error { error } => { ("error", error.error_message.clone().unwrap_or_default()) } }) .collect() } #[test] fn streams_text_response() { let mut mock = common::MockTransport::new(); mock.push_body( "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":4,\"cache_read_input_tokens\":2}}}\n\n\ event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\ event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n\ event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n\ event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":6}}\n\n\ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", ); let provider = provider(mock); let events = common::collect(&provider, &request()); let d = digest(&events); assert_eq!(d[0].0, "start"); assert_eq!(d[1].0, "text_start"); assert_eq!(d[2], ("text_delta", "Hello".into())); assert_eq!(d[3], ("text_delta", " world".into())); assert_eq!(d[4].0, "text_end"); assert_eq!(d[5].0, "done"); assert_eq!(d[5].1, "stop"); // 最终消息内容与用量。 // Final message content and usage. let done = events .iter() .find_map(|e| match e { StreamEvent::Done { message } => Some(message), _ => None, }) .expect("done event"); let text = done .content .iter() .find_map(|c| c.as_text()) .expect("text block"); assert_eq!(text.text, "Hello world"); assert_eq!(done.usage.input_tokens, 10); assert_eq!(done.usage.output_tokens, 6); assert_eq!(done.usage.cache_write_tokens, 4); assert_eq!(done.usage.cache_read_tokens, 2); assert_eq!(done.stop_reason, StopReason::Stop); } #[test] fn streams_thinking_then_text() { let mut mock = common::MockTransport::new(); mock.push_body( "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5}}}\n\n\ event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"sig-1\"}}\n\n\ event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Let me think\"}}\n\n\ event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\ event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"Answer: 42\"}}\n\n\ event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n\ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", ); let provider = provider(mock); let events = common::collect(&provider, &request()); let d = digest(&events); assert!(d.contains(&("thinking_start", String::new()))); assert!(d.contains(&("thinking_delta", "Let me think".into()))); assert!(d.contains(&("thinking_end", String::new()))); let done = events .iter() .find_map(|e| match e { StreamEvent::Done { message } => Some(message), _ => None, }) .unwrap(); assert_eq!(done.content.len(), 2); match &done.content[0] { ContentBlock::Thinking(t) => { assert_eq!(t.thinking, "Let me think"); assert_eq!(t.signature.as_deref(), Some("sig-1")); } other => panic!("expected thinking block, got {:?}", other), } let text = done.content[1].as_text().expect("text block"); assert_eq!(text.text, "Answer: 42"); } #[test] fn streams_tool_use_with_partial_json() { let mut mock = common::MockTransport::new(); mock.push_body( "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":7}}}\n\n\ event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"read\",\"input\":{}}}\n\n\ event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"a\"}}\n\n\ event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"bc.txt\\\"}\"}}\n\n\ event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":9}}\n\n\ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", ); let provider = provider(mock); let events = common::collect(&provider, &request()); let done = events .iter() .find_map(|e| match e { StreamEvent::Done { message } => Some(message), _ => None, }) .unwrap(); assert_eq!(done.stop_reason, StopReason::ToolUse); let tc = done.content[0].as_tool_call().expect("tool call"); assert_eq!(tc.id, "toolu_1"); assert_eq!(tc.name, "read"); assert_eq!(tc.arguments.get_str("path"), Some("abc.txt")); // ToolCallEnd 携带最终的 tool call。 // ToolCallEnd carries the final tool call. let end = events .iter() .find_map(|e| match e { StreamEvent::ToolCallEnd { tool_call, .. } => Some(tool_call), _ => None, }) .unwrap(); assert_eq!(end.arguments.get_str("path"), Some("abc.txt")); } #[test] fn encodes_api_error_event() { let mut mock = common::MockTransport::new(); mock.push_body( "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n", ); let provider = provider(mock); let events = common::collect(&provider, &request()); match events.last().unwrap() { StreamEvent::Error { error } => { assert_eq!(error.stop_reason, StopReason::Error); let msg = error.error_message.clone().unwrap_or_default(); assert!(msg.contains("overloaded"), "got: {}", msg); } other => panic!("expected error event, got {:?}", other), } } #[test] fn encodes_http_error() { let mut mock = common::MockTransport::new(); mock.push_error(TransportError::Http { status: 429, body: "rate limited".into(), }); let provider = provider(mock); 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("429"), "got: {}", msg); } other => panic!("expected error event, got {:?}", other), } } #[test] fn request_is_well_formed() { let mut mock = common::MockTransport::new(); mock.push_body("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"); let provider = provider(mock.clone()); let _ = common::collect(&provider, &request()); let req = mock.last_request(); assert_eq!(req.host, "api.anthropic.com"); assert_eq!(req.path, "/v1/messages"); let headers: Vec<(String, String)> = req.headers.clone(); assert!(headers .iter() .any(|(k, v)| k == "x-api-key" && v == "sk-test")); assert!(headers .iter() .any(|(k, v)| k == "anthropic-version" && v == "2023-06-01")); let body: focus_json::JsonValue = focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); assert_eq!(body.get_str("model"), Some("claude-sonnet-4")); assert_eq!(body.get_bool("stream"), Some(true)); assert_eq!(body.get_str("system"), Some("You are helpful.")); } /// 请求体必须完整携带会话:思考块签名回传、tool_use 块、多个 tool_result /// 合并进一条 user 消息、工具翻译成 input_schema(回归:原为内联私有函数测试)。 /// The request body must carry the full transcript: thinking signatures, /// tool_use blocks, multiple tool_results merged into one user message, and /// tools translated to input_schema (regression: was an inline private-fn test). #[test] fn request_body_carries_full_transcript() { #[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")) } } let rich_request = ProviderRequest { model: "claude-sonnet-4".into(), system_prompt: "You are helpful.".into(), messages: vec![ Message::user_text("hi"), Message::Assistant(AssistantMessage { content: vec![ ContentBlock::Thinking(ThinkingContent { thinking: "hmm".into(), signature: Some("sig".into()), redacted: false, }), ContentBlock::ToolCall(ToolCall { id: "call_1".into(), name: "echo".into(), arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(), }), ], model: "claude-sonnet-4".into(), usage: Usage::default(), stop_reason: StopReason::ToolUse, error_message: None, timestamp: 0, }), Message::ToolResult(ToolResultMessage { tool_call_id: "call_1".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(1024), temperature: Some(0.5), }; let mut mock = common::MockTransport::new(); mock.push_body("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"); let provider = provider(mock.clone()); 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(); // 工具翻译成 input_schema。 // Tools translated with input_schema. let tools = body.get_arr("tools").unwrap(); assert_eq!(tools[0].get_str("name"), Some("echo")); assert!(tools[0].get("input_schema").is_some()); // 思考块带签名回传。 // Thinking blocks replayed with their signature. let messages = body.get_arr("messages").unwrap(); let assistant = &messages[1]; let content = assistant.get_arr("content").unwrap(); assert_eq!(content[0].get_str("type"), Some("thinking")); assert_eq!(content[0].get_str("signature"), Some("sig")); assert_eq!(content[1].get_str("type"), Some("tool_use")); assert_eq!(content[1].get_str("id"), Some("call_1")); // tool_result 落在一条 user 消息里。 // tool_result lands inside one user message. let tool_msg = &messages[2]; assert_eq!(tool_msg.get_str("role"), Some("user")); let blocks = tool_msg.get_arr("content").unwrap(); assert_eq!(blocks[0].get_str("type"), Some("tool_result")); assert_eq!(blocks[0].get_str("tool_use_id"), Some("call_1")); } /// stop_reason 映射:max_tokens → Length(回归:原为内联私有函数测试)。 /// Stop-reason mapping: max_tokens → Length (regression: was an inline /// private-fn test). #[test] fn maps_max_tokens_stop_reason() { let mut mock = common::MockTransport::new(); mock.push_body( "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{}}}\n\n\ event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\ event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n\ event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\ event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"},\"usage\":{}}\n\n\ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", ); let provider = provider(mock); let events = common::collect(&provider, &request()); let done = events .iter() .find_map(|e| match e { StreamEvent::Done { message } => Some(message), _ => None, }) .expect("done event"); assert_eq!(done.stop_reason, StopReason::Length); }