377 lines
16 KiB
Rust
377 lines
16 KiB
Rust
//! 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<ToolResult, focus_core::CoreError> {
|
||
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);
|
||
}
|