test: add systematic test coverage for 7 bug pattern groups (#852)
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>
This commit is contained in:
parent
b43e9eb325
commit
7f03ab77a9
9 changed files with 2272 additions and 8 deletions
303
tests/provider_schema.rs
Normal file
303
tests/provider_schema.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
//! 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());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue