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:
Alex Gorevski 2026-02-18 15:28:34 -08:00
parent b43e9eb325
commit 7f03ab77a9
9 changed files with 2272 additions and 8 deletions

303
tests/provider_schema.rs Normal file
View 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());
}