use oxi_store::session::*;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
fn make_user_message(text: &str) -> AgentMessage {
AgentMessage::User {
content: ContentValue::String(text.to_string()),
}
}
fn make_assistant_message(text: &str) -> AgentMessage {
AgentMessage::Assistant {
content: vec![AssistantContentBlock::Text {
text: text.to_string(),
}],
provider: None,
model_id: None,
usage: None,
stop_reason: None,
}
}
#[test]
fn test_session_create_save_load() {
let tmp = TempDir::new().expect("temp dir");
let session_dir = tmp.path().to_string_lossy().to_string();
let mut mgr = SessionManager::create("/tmp/test", Some(&session_dir));
let id1 = mgr.append_message(make_user_message("Hello from user"));
let id2 = mgr.append_message(make_assistant_message("Hi from assistant"));
let id3 = mgr.append_message(make_user_message("Follow-up question"));
let entries = mgr.get_entries();
assert_eq!(entries.len(), 3, "should have 3 entries after appending");
let e1 = mgr.get_entry(&id1).expect("entry 1 exists");
assert!(e1.parent_id.is_none(), "first entry has no parent");
let e2 = mgr.get_entry(&id2).expect("entry 2 exists");
assert_eq!(
e2.parent_id.as_deref(),
Some(id1.as_str()),
"second chains from first"
);
let e3 = mgr.get_entry(&id3).expect("entry 3 exists");
assert_eq!(
e3.parent_id.as_deref(),
Some(id2.as_str()),
"third chains from second"
);
let session_file = mgr.get_session_file().expect("session file was created");
assert!(
Path::new(&session_file).exists(),
"session file should exist on disk"
);
let contents = fs::read_to_string(&session_file).expect("read session file");
let line_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
assert!(
line_count >= 2,
"expected at least 2 lines (header + assistant entry), got {}",
line_count
);
for (i, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|e| panic!("line {} is not valid JSON: {}", i, e));
}
let loaded = SessionManager::open(&session_file, Some(&session_dir), None);
let loaded_entries = loaded.get_entries();
assert!(
!loaded_entries.is_empty(),
"loaded session should have at least one entry"
);
if let Some(e2_loaded) = loaded.get_entry(&id2) {
assert_eq!(e2_loaded.content(), "Hi from assistant");
}
let header = loaded.get_header();
assert!(header.is_some(), "loaded session should have a header");
}
#[test]
fn test_session_in_memory_no_file() {
let mgr = SessionManager::in_memory("/tmp/test");
assert!(!mgr.is_persisted());
assert!(mgr.get_session_file().is_none());
}
#[test]
fn test_session_branch_and_fork() {
let tmp = TempDir::new().expect("temp dir");
let session_dir = tmp.path().to_string_lossy().to_string();
let mut mgr = SessionManager::create("/tmp/test", Some(&session_dir));
let id1 = mgr.append_message(make_user_message("User msg 1"));
let id2 = mgr.append_message(make_assistant_message("Assistant msg 1"));
let _id3 = mgr.append_message(make_user_message("User msg 2"));
let _id4 = mgr.append_message(make_assistant_message("Assistant msg 2"));
mgr.branch(&id2).expect("branch should succeed");
assert_eq!(mgr.get_leaf_id().as_deref(), Some(id2.as_str()));
let id5 = mgr.append_message(make_user_message("Branch user msg"));
let id6 = mgr.append_message(make_assistant_message("Branch assistant msg"));
let e5 = mgr.get_entry(&id5).expect("id5 exists");
assert_eq!(e5.parent_id.as_deref(), Some(id2.as_str()));
let children = mgr.get_children(&id2);
assert!(
children.len() >= 2,
"id2 should have at least 2 children (old + new branch), got {}",
children.len()
);
let branch = mgr.get_branch(Some(&id6));
assert_eq!(
branch.len(),
4,
"branch should have 4 entries (id1→id2→id5→id6)"
);
let session_file = mgr.get_session_file().expect("session file exists");
let tmp2 = TempDir::new().expect("temp dir 2");
let target_dir = tmp2.path().to_string_lossy().to_string();
let forked = SessionManager::fork_from(&session_file, "/tmp/forked", Some(&target_dir))
.expect("fork should succeed");
let forked_entries = forked.get_entries();
assert!(
!forked_entries.is_empty(),
"forked session should have entries from the source"
);
assert!(
forked.get_entry(&id2).is_some(),
"assistant entry id2 should be in fork"
);
}
#[test]
fn test_session_branch_with_summary() {
let mut mgr = SessionManager::in_memory("/tmp/test");
let id1 = mgr.append_message(make_user_message("First"));
let _id2 = mgr.append_message(make_user_message("Second"));
let _id3 = mgr.append_message(make_user_message("Third"));
let summary_id = mgr.branch_with_summary(Some(&id1), "Abandoned branch summary", None, None);
assert!(!summary_id.is_empty(), "should return an entry id");
let leaf = mgr.get_leaf_id().expect("leaf should exist");
let leaf_entry = mgr.get_entry(&leaf).expect("leaf entry exists");
assert_eq!(leaf_entry.parent_id.as_deref(), Some(id1.as_str()));
}
#[test]
fn test_session_branch_nonexistent_entry_fails() {
let mut mgr = SessionManager::in_memory("/tmp/test");
let result = mgr.branch("nonexistent-id-12345");
assert!(result.is_err());
}
#[test]
fn test_session_atomic_write() {
let tmp = TempDir::new().expect("temp dir");
let session_dir = tmp.path().to_string_lossy().to_string();
let mut mgr = SessionManager::create("/tmp/test", Some(&session_dir));
mgr.append_message(make_user_message("Hello"));
mgr.append_message(make_assistant_message("Response")); mgr.append_message(make_user_message("Follow-up"));
let session_file = mgr.get_session_file().expect("session file");
assert!(
Path::new(&session_file).exists(),
"file should exist after flush"
);
let contents = fs::read_to_string(&session_file).expect("read file");
assert!(!contents.is_empty(), "file should not be empty");
for (i, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let parsed: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("line {} is not valid JSONL: {} — content: {}", i, e, line));
if i == 0 {
assert_eq!(
parsed.get("type").and_then(|v| v.as_str()),
Some("session"),
"first line should be a session header"
);
}
}
let tmp_files: Vec<_> = fs::read_dir(&session_dir)
.expect("read session dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext.to_string_lossy().starts_with("tmp"))
.unwrap_or(false)
})
.collect();
assert!(
tmp_files.is_empty(),
"no temp files should remain after atomic write, found: {:?}",
tmp_files
);
}
#[test]
fn test_session_concurrent_access() {
use std::sync::Mutex;
use std::thread;
let tmp = TempDir::new().expect("temp dir");
let session_dir = tmp.path().to_string_lossy().to_string();
let mut mgr = SessionManager::create("/tmp/test", Some(&session_dir));
mgr.append_message(make_user_message("Initial"));
mgr.append_message(make_assistant_message("Response"));
let mgr = Arc::new(Mutex::new(mgr));
let mut handles = Vec::new();
for i in 0..8 {
let mgr_clone = Arc::clone(&mgr);
handles.push(thread::spawn(move || {
let entries = {
let locked = mgr_clone.lock().expect("lock");
locked.get_entries()
};
assert!(!entries.is_empty(), "thread {} should see entries", i);
let new_id = {
let mut locked = mgr_clone.lock().expect("lock for write");
locked.append_message(make_user_message(&format!("Thread {} message", i)))
};
assert!(!new_id.is_empty(), "thread {} should get valid id", i);
let entry = {
let locked = mgr_clone.lock().expect("lock for read");
locked.get_entry(&new_id)
};
assert!(entry.is_some(), "thread {} entry should be readable", i);
new_id
}));
}
let mut all_ids: Vec<String> = Vec::new();
for handle in handles {
all_ids.push(handle.join().expect("thread should not panic"));
}
let entries = mgr.lock().expect("lock").get_entries();
assert_eq!(
entries.len(),
10,
"should have 10 entries total (2 initial + 8 concurrent), got {}",
entries.len()
);
for id in &all_ids {
assert!(
mgr.lock().expect("lock").get_entry(id).is_some(),
"entry {} should exist",
id
);
}
}
#[test]
fn test_session_concurrent_reads_only() {
use std::thread;
let mut mgr = SessionManager::in_memory("/tmp/test");
for i in 0..20 {
mgr.append_message(make_user_message(&format!("Message {}", i)));
}
let mgr = Arc::new(parking_lot::RwLock::new(mgr));
let mut handles = Vec::new();
for _ in 0..16 {
let mgr_clone = Arc::clone(&mgr);
handles.push(thread::spawn(move || {
let locked = mgr_clone.read();
let entries = locked.get_entries();
assert_eq!(entries.len(), 20);
let stats = locked.get_session_stats();
assert_eq!(stats.message_count, 20);
}));
}
for handle in handles {
handle.join().expect("thread should not panic");
}
}
#[test]
fn test_session_tree_traversal() {
let mut mgr = SessionManager::in_memory("/tmp/test");
let id1 = mgr.append_message(make_user_message("Root"));
let id2 = mgr.append_message(make_assistant_message("Child 1"));
let id3 = mgr.append_message(make_user_message("Child 2"));
let path = mgr.get_path_to_root(&id3);
assert_eq!(path.len(), 3, "path should have 3 entries");
assert_eq!(path[0].id, id1, "first in path should be root");
assert_eq!(path[2].id, id3, "last in path should be target");
assert_eq!(mgr.get_depth(&id1), 0, "root depth = 0");
assert_eq!(mgr.get_depth(&id2), 1);
assert_eq!(mgr.get_depth(&id3), 2);
}
#[test]
fn test_session_labels() {
let mut mgr = SessionManager::in_memory("/tmp/test");
let id1 = mgr.append_message(make_user_message("Message 1"));
let _id2 = mgr.append_message(make_user_message("Message 2"));
let label_result = mgr.add_label(&id1, "important");
assert!(label_result.is_ok());
let label = mgr.get_label(&id1);
assert_eq!(label.as_deref(), Some("important"));
let remove_result = mgr.remove_label(&id1);
assert!(remove_result.is_ok());
assert!(mgr.get_label(&id1).is_none());
}
#[test]
fn test_session_label_nonexistent_entry_fails() {
let mut mgr = SessionManager::in_memory("/tmp/test");
let result = mgr.add_label("nonexistent-id", "label");
assert!(result.is_err());
}
#[test]
fn test_session_stats() {
let mut mgr = SessionManager::in_memory("/tmp/test");
mgr.append_message(make_user_message("Hello"));
mgr.append_message(make_assistant_message("World! This is a response."));
mgr.append_message(make_user_message("Another question"));
let stats = mgr.get_session_stats();
assert_eq!(stats.user_message_count, 2);
assert_eq!(stats.assistant_message_count, 1);
assert_eq!(stats.message_count, 3);
assert!(stats.total_chars > 0);
assert!(stats.estimated_tokens > 0);
}
#[test]
fn test_session_name() {
let mut mgr = SessionManager::in_memory("/tmp/test");
assert!(mgr.get_session_name().is_none());
mgr.append_session_info("My Important Session");
let name = mgr.get_session_name();
assert_eq!(name.as_deref(), Some("My Important Session"));
}
#[test]
fn test_session_compaction_append() {
let mut mgr = SessionManager::in_memory("/tmp/test");
mgr.append_message(make_user_message("Before compaction"));
let id = mgr.append_compaction("Summary of compacted content", "entry-1", 5000, None, None);
assert!(!id.is_empty());
let latest = mgr.get_latest_compaction_entry();
assert!(latest.is_some());
let all_compaction = mgr.get_compaction_entries();
assert_eq!(all_compaction.len(), 1);
}
#[test]
fn test_agent_message_content_variants() {
let user_msg = make_user_message("hello");
assert!(user_msg.is_user());
assert!(!user_msg.is_assistant());
assert_eq!(user_msg.content(), "hello");
let assistant_msg = make_assistant_message("world");
assert!(!assistant_msg.is_user());
assert!(assistant_msg.is_assistant());
assert_eq!(assistant_msg.content(), "world");
let system_msg = AgentMessage::System {
content: ContentValue::String("system prompt".to_string()),
};
assert_eq!(system_msg.content(), "system prompt");
let tool_result_msg = AgentMessage::ToolResult {
content: ContentValue::String("tool output".to_string()),
tool_call_id: "call_123".to_string(),
};
assert_eq!(tool_result_msg.content(), "tool output");
}
#[test]
fn test_content_value_variants() {
let string_content = ContentValue::String("text".to_string());
assert_eq!(string_content.as_str(), "text");
let block_content = ContentValue::Blocks(vec![
ContentBlock::Text {
text: "block text".to_string(),
},
ContentBlock::Image {
data: "base64data".to_string(),
media_type: Some("image/png".to_string()),
},
]);
assert_eq!(block_content.as_str(), "block text");
let from_str: ContentValue = "hello".into();
assert_eq!(from_str.as_str(), "hello");
}
#[test]
fn test_session_entry_simple_message() {
let user_entry = SessionEntry::simple_message("user", "Hello world");
assert!(user_entry.message.is_user());
assert!(user_entry.parent_id.is_none());
assert!(!user_entry.id.is_empty());
let assistant_entry = SessionEntry::simple_message("assistant", "Response");
assert!(assistant_entry.message.is_assistant());
let system_entry = SessionEntry::simple_message("system", "System prompt");
assert_eq!(system_entry.content(), "System prompt");
}
#[test]
fn test_session_entry_branched() {
let parent_id = "parent-123";
let entry = SessionEntry::branched(make_user_message("branched msg"), parent_id);
assert_eq!(entry.parent_id.as_deref(), Some(parent_id));
}
#[test]
fn test_session_meta_new() {
let id = uuid::Uuid::new_v4();
let meta = SessionMeta::new(id);
assert_eq!(meta.id, id);
assert!(meta.parent_id.is_none());
assert!(meta.root_id.is_none());
assert!(meta.branch_point.is_none());
assert!(meta.name.is_none());
}
#[test]
fn test_session_meta_branched_from() {
let parent_id = uuid::Uuid::new_v4();
let branch_point = uuid::Uuid::new_v4();
let meta = SessionMeta::branched_from(parent_id, None, branch_point);
assert_eq!(meta.parent_id, Some(parent_id));
assert_eq!(meta.root_id, Some(parent_id)); assert_eq!(meta.branch_point, Some(branch_point));
assert_ne!(meta.id, parent_id); }