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
335
tests/memory_restart.rs
Normal file
335
tests/memory_restart.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
//! TG5: Memory Restart Resilience Tests
|
||||
//!
|
||||
//! Prevents: Pattern 5 — Memory & state persistence bugs (10% of user bugs).
|
||||
//! Issues: #430, #693, #802
|
||||
//!
|
||||
//! Tests SqliteMemory deduplication on restart, session scoping, concurrent
|
||||
//! message ordering, and recall behavior after re-initialization.
|
||||
|
||||
use std::sync::Arc;
|
||||
use zeroclaw::memory::sqlite::SqliteMemory;
|
||||
use zeroclaw::memory::traits::{Memory, MemoryCategory};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Deduplication: same key overwrites instead of duplicating (#430)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_store_same_key_deduplicates() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
// Store same key twice with different content
|
||||
mem.store("greeting", "hello world", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("greeting", "hello updated", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should have exactly 1 entry, not 2
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(count, 1, "storing same key twice should not create duplicates");
|
||||
|
||||
// Content should be the latest version
|
||||
let entry = mem.get("greeting").await.unwrap().expect("entry should exist");
|
||||
assert_eq!(entry.content, "hello updated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_store_different_keys_creates_separate_entries() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("key_a", "content a", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("key_b", "content b", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(count, 2, "different keys should create separate entries");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Restart resilience: data persists across memory re-initialization
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_persists_across_reinitialization() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// First "session": store data
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
mem.store("persistent_fact", "Rust is great", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Second "session": re-create memory from same path
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
let entry = mem
|
||||
.get("persistent_fact")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("entry should survive reinitialization");
|
||||
assert_eq!(entry.content, "Rust is great");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_restart_does_not_duplicate_on_rewrite() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// First session: store entries
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
mem.store("fact_1", "original content", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Second session: re-store same keys (simulates channel re-reading history)
|
||||
{
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
mem.store("fact_1", "original content", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(
|
||||
count, 2,
|
||||
"re-storing same keys after restart should not create duplicates"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Session scoping: messages scoped to sessions don't leak
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_session_scoped_store_and_recall() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
// Store in different sessions
|
||||
mem.store(
|
||||
"session_a_fact",
|
||||
"fact from session A",
|
||||
MemoryCategory::Conversation,
|
||||
Some("session_a"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
"session_b_fact",
|
||||
"fact from session B",
|
||||
MemoryCategory::Conversation,
|
||||
Some("session_b"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// List scoped to session_a
|
||||
let session_a_entries = mem
|
||||
.list(Some(&MemoryCategory::Conversation), Some("session_a"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session_a_entries.len(),
|
||||
1,
|
||||
"session_a should have exactly 1 entry"
|
||||
);
|
||||
assert_eq!(session_a_entries[0].content, "fact from session A");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_global_recall_includes_all_sessions() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("global_a", "alpha content", MemoryCategory::Core, Some("s1"))
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("global_b", "beta content", MemoryCategory::Core, Some("s2"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Global count should include all
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(count, 2, "global count should include entries from all sessions");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recall and search behavior
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_recall_returns_relevant_results() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("lang_pref", "User prefers Rust programming", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("food_pref", "User likes sushi for lunch", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = mem.recall("Rust programming", 10, None).await.unwrap();
|
||||
assert!(!results.is_empty(), "recall should find matching entries");
|
||||
// The Rust-related entry should be in results
|
||||
assert!(
|
||||
results.iter().any(|e| e.content.contains("Rust")),
|
||||
"recall for 'Rust' should include the Rust-related entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_recall_respects_limit() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
for i in 0..10 {
|
||||
mem.store(
|
||||
&format!("entry_{i}"),
|
||||
&format!("test content number {i}"),
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let results = mem.recall("test content", 3, None).await.unwrap();
|
||||
assert!(
|
||||
results.len() <= 3,
|
||||
"recall should respect limit of 3, got {}",
|
||||
results.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_recall_empty_query_returns_empty() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("fact", "some content", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let results = mem.recall("", 10, None).await.unwrap();
|
||||
assert!(
|
||||
results.is_empty(),
|
||||
"empty query should return no results"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Forget and health check
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_forget_removes_entry() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("to_forget", "temporary info", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(mem.count().await.unwrap(), 1);
|
||||
|
||||
let removed = mem.forget("to_forget").await.unwrap();
|
||||
assert!(removed, "forget should return true for existing key");
|
||||
assert_eq!(mem.count().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_forget_nonexistent_returns_false() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
let removed = mem.forget("nonexistent_key").await.unwrap();
|
||||
assert!(!removed, "forget should return false for nonexistent key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_health_check_returns_true() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
assert!(mem.health_check().await, "health_check should return true");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Concurrent access
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_concurrent_stores_no_data_loss() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = Arc::new(SqliteMemory::new(tmp.path()).unwrap());
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..5 {
|
||||
let mem_clone = mem.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
mem_clone
|
||||
.store(
|
||||
&format!("concurrent_{i}"),
|
||||
&format!("content from task {i}"),
|
||||
MemoryCategory::Core,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}));
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
|
||||
let count = mem.count().await.unwrap();
|
||||
assert_eq!(
|
||||
count, 5,
|
||||
"all concurrent stores should succeed, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Memory categories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_memory_list_by_category() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
mem.store("core_fact", "core info", MemoryCategory::Core, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("daily_note", "daily note", MemoryCategory::Daily, None)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("conv_msg", "conversation msg", MemoryCategory::Conversation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let core_entries = mem.list(Some(&MemoryCategory::Core), None).await.unwrap();
|
||||
assert_eq!(core_entries.len(), 1, "should have 1 Core entry");
|
||||
assert_eq!(core_entries[0].key, "core_fact");
|
||||
|
||||
let daily_entries = mem.list(Some(&MemoryCategory::Daily), None).await.unwrap();
|
||||
assert_eq!(daily_entries.len(), 1, "should have 1 Daily entry");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue