Add ~105 test cases across 7 test groups identified in issue #852: TG1 - Provider resolution (27 tests): Factory resolution, alias mapping, custom URLs, auth styles, credential wiring TG2 - Config persistence (18 tests): Config defaults, TOML roundtrip, agent/memory config, workspace dirs TG3 - Channel routing (14 tests): ChannelMessage identity contracts, SendMessage construction, Channel trait send/listen roundtrip TG4 - Agent loop robustness (12 integration + 14 inline tests): Malformed tool calls, failing tools, iteration limits, empty responses, unicode TG5 - Memory restart (14 tests): Dedup on same key, restart persistence, session scoping, recall, concurrent stores, categories TG6 - Channel message splitting (8+8 inline tests): Code blocks at boundary, long words, emoji, CJK chars, whitespace edge cases TG7 - Provider schema (21 tests): ChatMessage/ToolCall/ChatResponse serialization, tool_call_id preservation, auth style variants Also fixes a bug in split_message_for_telegram() where byte-based indexing could panic on multi-byte characters (emoji, CJK). Now uses char_indices() consistent with the Discord split implementation. Closes #852 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
303 lines
12 KiB
Rust
303 lines
12 KiB
Rust
//! TG7: Provider Schema Conformance Tests
|
|
//!
|
|
//! Prevents: Pattern 7 — External schema compatibility bugs (7% of user bugs).
|
|
//! Issues: #769, #843
|
|
//!
|
|
//! Tests request/response serialization to verify required fields are present
|
|
//! for each provider's API specification. Validates ChatMessage, ChatResponse,
|
|
//! ToolCall, and AuthStyle serialization contracts.
|
|
|
|
use zeroclaw::providers::compatible::AuthStyle;
|
|
use zeroclaw::providers::traits::{ChatMessage, ChatResponse, ToolCall};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ChatMessage serialization
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn chat_message_system_role_correct() {
|
|
let msg = ChatMessage::system("You are a helpful assistant");
|
|
assert_eq!(msg.role, "system");
|
|
assert_eq!(msg.content, "You are a helpful assistant");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_message_user_role_correct() {
|
|
let msg = ChatMessage::user("Hello");
|
|
assert_eq!(msg.role, "user");
|
|
assert_eq!(msg.content, "Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_message_assistant_role_correct() {
|
|
let msg = ChatMessage::assistant("Hi there!");
|
|
assert_eq!(msg.role, "assistant");
|
|
assert_eq!(msg.content, "Hi there!");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_message_tool_role_correct() {
|
|
let msg = ChatMessage::tool("tool result");
|
|
assert_eq!(msg.role, "tool");
|
|
assert_eq!(msg.content, "tool result");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_message_serializes_to_json_with_required_fields() {
|
|
let msg = ChatMessage::user("test message");
|
|
let json = serde_json::to_value(&msg).unwrap();
|
|
|
|
assert!(json.get("role").is_some(), "JSON must have 'role' field");
|
|
assert!(
|
|
json.get("content").is_some(),
|
|
"JSON must have 'content' field"
|
|
);
|
|
assert_eq!(json["role"], "user");
|
|
assert_eq!(json["content"], "test message");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_message_json_roundtrip() {
|
|
let original = ChatMessage::assistant("response text");
|
|
let json_str = serde_json::to_string(&original).unwrap();
|
|
let parsed: ChatMessage = serde_json::from_str(&json_str).unwrap();
|
|
|
|
assert_eq!(parsed.role, original.role);
|
|
assert_eq!(parsed.content, original.content);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ToolCall serialization (#843 - tool_call_id field)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn tool_call_has_required_fields() {
|
|
let tc = ToolCall {
|
|
id: "call_abc123".into(),
|
|
name: "web_search".into(),
|
|
arguments: r#"{"query": "rust programming"}"#.into(),
|
|
};
|
|
|
|
let json = serde_json::to_value(&tc).unwrap();
|
|
assert!(json.get("id").is_some(), "ToolCall must have 'id' field");
|
|
assert!(json.get("name").is_some(), "ToolCall must have 'name' field");
|
|
assert!(
|
|
json.get("arguments").is_some(),
|
|
"ToolCall must have 'arguments' field"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_call_id_preserved_in_serialization() {
|
|
let tc = ToolCall {
|
|
id: "call_deepseek_42".into(),
|
|
name: "shell".into(),
|
|
arguments: r#"{"command": "ls"}"#.into(),
|
|
};
|
|
|
|
let json_str = serde_json::to_string(&tc).unwrap();
|
|
let parsed: ToolCall = serde_json::from_str(&json_str).unwrap();
|
|
|
|
assert_eq!(parsed.id, "call_deepseek_42", "tool_call_id must survive roundtrip");
|
|
assert_eq!(parsed.name, "shell");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_call_arguments_contain_valid_json() {
|
|
let tc = ToolCall {
|
|
id: "call_1".into(),
|
|
name: "file_write".into(),
|
|
arguments: r#"{"path": "/tmp/test.txt", "content": "hello"}"#.into(),
|
|
};
|
|
|
|
// Arguments should parse as valid JSON
|
|
let args: serde_json::Value = serde_json::from_str(&tc.arguments)
|
|
.expect("tool call arguments should be valid JSON");
|
|
assert!(args.get("path").is_some());
|
|
assert!(args.get("content").is_some());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tool message with tool_call_id (DeepSeek requirement)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn tool_response_message_can_embed_tool_call_id() {
|
|
// DeepSeek requires tool_call_id in tool response messages.
|
|
// The tool message content can embed the tool_call_id as JSON.
|
|
let tool_response = ChatMessage::tool(
|
|
r#"{"tool_call_id": "call_abc123", "content": "search results here"}"#,
|
|
);
|
|
|
|
let parsed: serde_json::Value = serde_json::from_str(&tool_response.content)
|
|
.expect("tool response content should be valid JSON");
|
|
|
|
assert!(
|
|
parsed.get("tool_call_id").is_some(),
|
|
"tool response should include tool_call_id for DeepSeek compatibility"
|
|
);
|
|
assert_eq!(parsed["tool_call_id"], "call_abc123");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ChatResponse structure
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn chat_response_text_only() {
|
|
let resp = ChatResponse {
|
|
text: Some("Hello world".into()),
|
|
tool_calls: vec![],
|
|
};
|
|
|
|
assert_eq!(resp.text_or_empty(), "Hello world");
|
|
assert!(!resp.has_tool_calls());
|
|
}
|
|
|
|
#[test]
|
|
fn chat_response_with_tool_calls() {
|
|
let resp = ChatResponse {
|
|
text: Some(String::new()),
|
|
tool_calls: vec![ToolCall {
|
|
id: "tc_1".into(),
|
|
name: "echo".into(),
|
|
arguments: "{}".into(),
|
|
}],
|
|
};
|
|
|
|
assert!(resp.has_tool_calls());
|
|
assert_eq!(resp.tool_calls.len(), 1);
|
|
assert_eq!(resp.tool_calls[0].name, "echo");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_response_text_or_empty_handles_none() {
|
|
let resp = ChatResponse {
|
|
text: None,
|
|
tool_calls: vec![],
|
|
};
|
|
|
|
assert_eq!(resp.text_or_empty(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_response_multiple_tool_calls() {
|
|
let resp = ChatResponse {
|
|
text: None,
|
|
tool_calls: vec![
|
|
ToolCall {
|
|
id: "tc_1".into(),
|
|
name: "shell".into(),
|
|
arguments: r#"{"command": "ls"}"#.into(),
|
|
},
|
|
ToolCall {
|
|
id: "tc_2".into(),
|
|
name: "file_read".into(),
|
|
arguments: r#"{"path": "test.txt"}"#.into(),
|
|
},
|
|
],
|
|
};
|
|
|
|
assert!(resp.has_tool_calls());
|
|
assert_eq!(resp.tool_calls.len(), 2);
|
|
// Each tool call should have a distinct id
|
|
assert_ne!(resp.tool_calls[0].id, resp.tool_calls[1].id);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AuthStyle variants
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn auth_style_bearer_is_constructible() {
|
|
let style = AuthStyle::Bearer;
|
|
assert!(matches!(style, AuthStyle::Bearer));
|
|
}
|
|
|
|
#[test]
|
|
fn auth_style_xapikey_is_constructible() {
|
|
let style = AuthStyle::XApiKey;
|
|
assert!(matches!(style, AuthStyle::XApiKey));
|
|
}
|
|
|
|
#[test]
|
|
fn auth_style_custom_header() {
|
|
let style = AuthStyle::Custom("X-Custom-Auth".into());
|
|
if let AuthStyle::Custom(header) = style {
|
|
assert_eq!(header, "X-Custom-Auth");
|
|
} else {
|
|
panic!("expected AuthStyle::Custom");
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Provider naming consistency
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn provider_construction_with_different_names() {
|
|
use zeroclaw::providers::compatible::OpenAiCompatibleProvider;
|
|
|
|
// Construction with various names should succeed
|
|
let _p1 = OpenAiCompatibleProvider::new(
|
|
"DeepSeek",
|
|
"https://api.deepseek.com",
|
|
Some("test-key"),
|
|
AuthStyle::Bearer,
|
|
);
|
|
let _p2 = OpenAiCompatibleProvider::new(
|
|
"deepseek",
|
|
"https://api.test.com",
|
|
None,
|
|
AuthStyle::Bearer,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_construction_with_different_auth_styles() {
|
|
use zeroclaw::providers::compatible::OpenAiCompatibleProvider;
|
|
|
|
let _bearer = OpenAiCompatibleProvider::new("Test", "https://api.test.com", Some("key"), AuthStyle::Bearer);
|
|
let _xapi = OpenAiCompatibleProvider::new("Test", "https://api.test.com", Some("key"), AuthStyle::XApiKey);
|
|
let _custom = OpenAiCompatibleProvider::new("Test", "https://api.test.com", Some("key"), AuthStyle::Custom("X-My-Auth".into()));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Conversation history message ordering
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn chat_messages_maintain_role_sequence() {
|
|
let history = vec![
|
|
ChatMessage::system("You are helpful"),
|
|
ChatMessage::user("What is Rust?"),
|
|
ChatMessage::assistant("Rust is a systems programming language"),
|
|
ChatMessage::user("Tell me more"),
|
|
ChatMessage::assistant("It emphasizes safety and performance"),
|
|
];
|
|
|
|
assert_eq!(history[0].role, "system");
|
|
assert_eq!(history[1].role, "user");
|
|
assert_eq!(history[2].role, "assistant");
|
|
assert_eq!(history[3].role, "user");
|
|
assert_eq!(history[4].role, "assistant");
|
|
}
|
|
|
|
#[test]
|
|
fn chat_messages_with_tool_calls_maintain_sequence() {
|
|
let history = vec![
|
|
ChatMessage::system("You are helpful"),
|
|
ChatMessage::user("Search for Rust"),
|
|
ChatMessage::assistant("I'll search for that"),
|
|
ChatMessage::tool(r#"{"tool_call_id": "tc_1", "content": "search results"}"#),
|
|
ChatMessage::assistant("Based on the search results..."),
|
|
];
|
|
|
|
assert_eq!(history.len(), 5);
|
|
assert_eq!(history[3].role, "tool");
|
|
assert_eq!(history[4].role, "assistant");
|
|
|
|
// Verify tool message content is valid JSON with tool_call_id
|
|
let tool_content: serde_json::Value = serde_json::from_str(&history[3].content).unwrap();
|
|
assert!(tool_content.get("tool_call_id").is_some());
|
|
}
|