pub(crate) mod format;
mod meta;
pub mod simple;
mod stateful;
mod store;
pub(crate) mod time;
pub mod traits;
#[cfg(feature = "search")]
pub use meta::search_sessions;
pub use meta::{SessionMeta, import_claude_session, list_sessions};
pub use simple::{SimpleMsg, SimpleRole};
pub use stateful::{StatefulDelta, StatefulSession};
pub use store::{Session, SessionHeader};
pub use traits::{AgentMessage, EntryType, MessageRole};
#[cfg(test)]
pub(crate) mod tests {
use super::format::{make_persisted, parse_entry};
use super::time::{now_iso, truncate_str, truncate_topic, uuid_v7_timestamp};
use super::*;
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum TestRole {
System,
User,
Assistant,
Tool,
}
impl MessageRole for TestRole {
fn system() -> Self {
Self::System
}
fn user() -> Self {
Self::User
}
fn assistant() -> Self {
Self::Assistant
}
fn tool() -> Self {
Self::Tool
}
fn as_str(&self) -> &str {
match self {
Self::System => "system",
Self::User => "user",
Self::Assistant => "assistant",
Self::Tool => "tool",
}
}
fn parse_role(s: &str) -> Option<Self> {
match s {
"system" => Some(Self::System),
"user" => Some(Self::User),
"assistant" => Some(Self::Assistant),
"tool" => Some(Self::Tool),
_ => None,
}
}
}
#[derive(Clone)]
pub(crate) struct TestMsg {
pub role: TestRole,
pub content: String,
}
impl AgentMessage for TestMsg {
type Role = TestRole;
fn new(role: TestRole, content: String) -> Self {
Self { role, content }
}
fn role(&self) -> &TestRole {
&self.role
}
fn content(&self) -> &str {
&self.content
}
}
#[test]
fn entry_type_roundtrip() {
for t in [
EntryType::User,
EntryType::Assistant,
EntryType::System,
EntryType::Tool,
] {
let json = serde_json::to_string(&t).unwrap();
let back: EntryType = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
}
}
#[test]
fn entry_type_rejects_invalid() {
assert!(EntryType::parse("progress").is_none());
assert!(EntryType::parse("file-history-snapshot").is_none());
assert!(EntryType::parse("").is_none());
}
#[test]
fn user_message_serialized_as_plain_string() {
let p = make_persisted(EntryType::User, "hello", "sid", None);
let json: serde_json::Value = serde_json::to_value(&p).unwrap();
assert!(json["message"]["content"].is_string());
assert_eq!(json["message"]["content"].as_str(), Some("hello"));
assert_eq!(json["message"]["role"].as_str(), Some("user"));
}
#[test]
fn assistant_message_serialized_as_blocks() {
let p = make_persisted(EntryType::Assistant, "thinking...", "sid", None);
let json: serde_json::Value = serde_json::to_value(&p).unwrap();
let blocks = json["message"]["content"].as_array().unwrap();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"].as_str(), Some("text"));
assert_eq!(blocks[0]["text"].as_str(), Some("thinking..."));
}
#[test]
fn system_message_serialized_as_plain_string() {
let p = make_persisted(EntryType::System, "you are an agent", "sid", None);
let json: serde_json::Value = serde_json::to_value(&p).unwrap();
assert!(json["message"]["content"].is_string());
}
#[test]
fn parse_entry_claude_code_user_format() {
let entry: serde_json::Value = serde_json::from_str(
r#"{"type":"user","message":{"role":"user","content":"fix the bug"},"uuid":"abc","sessionId":"s1","timestamp":"2026-03-07T10:00:00.000Z"}"#
).unwrap();
let (et, content) = parse_entry(&entry).unwrap();
assert_eq!(et, EntryType::User);
assert_eq!(content, "fix the bug");
}
#[test]
fn parse_entry_claude_code_assistant_format() {
let entry: serde_json::Value = serde_json::from_str(
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking at it..."},{"type":"tool_use","name":"Read","id":"x","input":{}}]},"uuid":"def","sessionId":"s1","timestamp":"2026-03-07T10:00:01.000Z"}"#
).unwrap();
let (et, content) = parse_entry(&entry).unwrap();
assert_eq!(et, EntryType::Assistant);
assert!(content.contains("Looking at it..."));
assert!(content.contains("[tool: Read]"));
}
#[test]
fn parse_entry_skips_thinking_only() {
let entry: serde_json::Value = serde_json::from_str(
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"hmm..."}]},"uuid":"ghi","sessionId":"s1","timestamp":"2026-03-07T10:00:02.000Z"}"#
).unwrap();
assert!(parse_entry(&entry).is_none());
}
#[test]
fn parse_entry_skips_progress() {
let entry: serde_json::Value = serde_json::from_str(
r#"{"type":"progress","data":{"type":"hook_progress"},"uuid":"a"}"#,
)
.unwrap();
assert!(parse_entry(&entry).is_none());
}
#[test]
fn parse_entry_legacy_format() {
let entry: serde_json::Value =
serde_json::from_str(r#"{"role":"user","content":"hello legacy"}"#).unwrap();
let (et, content) = parse_entry(&entry).unwrap();
assert_eq!(et, EntryType::User);
assert_eq!(content, "hello legacy");
}
#[test]
fn now_iso_produces_valid_timestamp() {
let ts = now_iso();
assert!(ts.ends_with('Z'));
assert_eq!(ts.len(), 24);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[10..11], "T");
}
#[test]
fn truncate_str_ascii() {
assert_eq!(truncate_str("hello world", 5), "hello");
assert_eq!(truncate_str("hi", 10), "hi");
}
#[test]
fn truncate_str_utf8_safe() {
let s = "ab\u{00e9}cd\u{00fc}ef";
let t = truncate_str(s, 4);
assert!(t.len() <= 4);
assert_eq!(t, "ab\u{00e9}");
let t2 = truncate_str(s, 3);
assert!(t2.len() <= 3);
assert_eq!(t2, "ab");
}
#[test]
fn truncate_str_emoji() {
let s = "Hello \u{1f30d}\u{1f30d}\u{1f30d}";
let t = truncate_str(s, 8);
assert!(t.len() <= 8);
assert_eq!(t, "Hello ");
}
#[test]
fn truncate_topic_multibyte() {
let long_multibyte = "\u{00e9}".repeat(200);
let topic = truncate_topic(&long_multibyte);
assert!(topic.ends_with("..."));
assert!(topic.len() <= 120);
}
#[test]
fn uuid_v7_is_time_ordered() {
let id1 = uuid::Uuid::now_v7().to_string();
std::thread::sleep(std::time::Duration::from_millis(2));
let id2 = uuid::Uuid::now_v7().to_string();
assert!(
id2 > id1,
"v7 UUIDs should be time-ordered: {} > {}",
id2,
id1
);
}
#[test]
fn uuid_v7_timestamp_extraction() {
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let id = uuid::Uuid::now_v7().to_string();
let after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let ts = uuid_v7_timestamp(&id).unwrap();
assert!(ts >= before && ts <= after);
}
#[test]
fn uuid_v7_timestamp_invalid() {
assert!(uuid_v7_timestamp("not-a-uuid").is_none());
assert!(uuid_v7_timestamp("550e8400-e29b-41d4-a716-446655440000").is_none());
}
#[test]
fn trim_preserves_system_and_recent() {
let dir = std::env::temp_dir().join("baml_mod_test_trim");
let _ = std::fs::remove_dir_all(&dir);
let mut session = Session::<TestMsg>::new(dir.to_str().unwrap(), 10).unwrap();
session.push(TestRole::System, "sys prompt".into());
for i in 0..20 {
let role = if i % 2 == 0 {
TestRole::User
} else {
TestRole::Assistant
};
session.push(role, format!("msg {}", i));
}
assert_eq!(session.len(), 21);
let trimmed = session.trim();
assert!(trimmed > 0);
assert!(session.len() <= 12);
assert_eq!(session.messages()[0].role(), &TestRole::System);
assert!(session.messages()[1].content().contains("trimmed"));
assert_eq!(session.messages().last().unwrap().content(), "msg 19");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn trim_noop_small_history() {
let dir = std::env::temp_dir().join("baml_mod_test_noop");
let _ = std::fs::remove_dir_all(&dir);
let mut session = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
session.push(TestRole::User, "hello".into());
assert_eq!(session.trim(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn persist_and_reload() {
let dir = std::env::temp_dir().join("baml_mod_test_persist");
let _ = std::fs::remove_dir_all(&dir);
let mut session = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
let sid = session.session_id().to_string();
session.push(TestRole::User, "hello world".into());
session.push(TestRole::Assistant, "hi there".into());
let path = session.session_file().to_path_buf();
let loaded = Session::<TestMsg>::resume(&path, dir.to_str().unwrap(), 60);
assert_eq!(loaded.len(), 2);
assert_eq!(loaded.messages()[0].content(), "hello world");
assert_eq!(loaded.messages()[1].role(), &TestRole::Assistant);
assert_eq!(loaded.session_id(), sid);
let raw = std::fs::read_to_string(&path).unwrap();
let first: serde_json::Value = serde_json::from_str(raw.lines().next().unwrap()).unwrap();
assert!(first["message"]["content"].is_string());
let second: serde_json::Value = serde_json::from_str(raw.lines().nth(1).unwrap()).unwrap();
assert!(second["message"]["content"].is_array());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn persist_parent_uuid_chain() {
let dir = std::env::temp_dir().join("baml_mod_test_parent");
let _ = std::fs::remove_dir_all(&dir);
let mut session = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
session.push(TestRole::User, "first".into());
session.push(TestRole::Assistant, "second".into());
session.push(TestRole::User, "third".into());
let raw = std::fs::read_to_string(session.session_file()).unwrap();
let entries: Vec<serde_json::Value> = raw
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
assert!(entries[0]["parentUuid"].is_null());
assert_eq!(
entries[1]["parentUuid"].as_str(),
entries[0]["uuid"].as_str()
);
assert_eq!(
entries[2]["parentUuid"].as_str(),
entries[1]["uuid"].as_str()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn persist_multibyte_content() {
let dir = std::env::temp_dir().join("baml_mod_test_multibyte");
let _ = std::fs::remove_dir_all(&dir);
let mut session = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
session.push(
TestRole::User,
"caf\u{00e9} na\u{00ef}ve r\u{00e9}sum\u{00e9}".into(),
);
session.push(TestRole::Assistant, "got it! \u{1f389}".into());
let path = session.session_file().to_path_buf();
let loaded = Session::<TestMsg>::resume(&path, dir.to_str().unwrap(), 60);
assert_eq!(
loaded.messages()[0].content(),
"caf\u{00e9} na\u{00ef}ve r\u{00e9}sum\u{00e9}"
);
assert_eq!(loaded.messages()[1].content(), "got it! \u{1f389}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_legacy_format() {
let dir = std::env::temp_dir().join("baml_mod_test_legacy");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("session_1234567890.jsonl");
let legacy = [
r#"{"role":"user","content":"hello legacy"}"#,
r#"{"role":"assistant","content":"hi from old format"}"#,
];
std::fs::write(&path, legacy.join("\n")).unwrap();
let loaded = Session::<TestMsg>::resume(&path, dir.to_str().unwrap(), 60);
assert_eq!(loaded.len(), 2);
assert_eq!(loaded.messages()[0].content(), "hello legacy");
assert_eq!(loaded.session_id(), "session_1234567890");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resume_last_finds_latest() {
let dir = std::env::temp_dir().join("baml_mod_test_resume");
let _ = std::fs::remove_dir_all(&dir);
let mut s1 = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s1.push(TestRole::User, "first".into());
std::thread::sleep(std::time::Duration::from_millis(10));
let mut s2 = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s2.push(TestRole::User, "second".into());
let resumed = Session::<TestMsg>::resume_last(dir.to_str().unwrap(), 60).unwrap();
assert_eq!(resumed.messages()[0].content(), "second");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_meta_extracts_topic() {
let dir = std::env::temp_dir().join("baml_mod_test_topic");
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s.push(TestRole::System, "you are an agent".into());
s.push(TestRole::User, "deploy to production".into());
let meta = SessionMeta::from_path(s.session_file()).unwrap();
assert_eq!(meta.topic, "deploy to production");
assert_eq!(meta.message_count, 2);
assert!(meta.size_bytes > 0);
assert!(meta.session_id.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_meta_created_from_uuid_v7() {
let dir = std::env::temp_dir().join("baml_mod_test_uuid_ts");
let _ = std::fs::remove_dir_all(&dir);
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut s = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s.push(TestRole::User, "test".into());
let after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let meta = SessionMeta::from_path(s.session_file()).unwrap();
assert!(meta.created >= before && meta.created <= after);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_sessions_returns_sorted() {
let dir = std::env::temp_dir().join("baml_mod_test_list");
let _ = std::fs::remove_dir_all(&dir);
let mut s1 = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s1.push(TestRole::User, "fix parser bug".into());
std::thread::sleep(std::time::Duration::from_millis(10));
let mut s2 = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s2.push(TestRole::User, "add new feature".into());
let sessions = list_sessions(dir.to_str().unwrap());
assert_eq!(sessions.len(), 2);
assert!(sessions[0].created >= sessions[1].created);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_sessions_empty_dir() {
let dir = std::env::temp_dir().join("baml_mod_test_empty");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
assert!(list_sessions(dir.to_str().unwrap()).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn topic_truncated_for_long_messages() {
let dir = std::env::temp_dir().join("baml_mod_test_long_topic");
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s.push(TestRole::User, "a".repeat(200));
let meta = SessionMeta::from_path(s.session_file()).unwrap();
assert!(meta.topic.len() <= 120);
assert!(meta.topic.ends_with("..."));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "search")]
#[test]
fn search_sessions_fuzzy() {
let dir = std::env::temp_dir().join("baml_mod_test_search");
let _ = std::fs::remove_dir_all(&dir);
let mut s1 = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s1.push(TestRole::User, "fix parser bug in baml".into());
std::thread::sleep(std::time::Duration::from_millis(10));
let mut s2 = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s2.push(TestRole::User, "deploy to production".into());
std::thread::sleep(std::time::Duration::from_millis(10));
let mut s3 = Session::<TestMsg>::new(dir.to_str().unwrap(), 60).unwrap();
s3.push(TestRole::User, "fix loop detection bug".into());
let results = search_sessions(dir.to_str().unwrap(), "fix bug");
assert!(!results.is_empty());
let topics: Vec<&str> = results.iter().map(|(_, m)| m.topic.as_str()).collect();
assert!(topics.iter().any(|t| t.contains("fix")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_claude_session_converts() {
let dir = std::env::temp_dir().join("baml_mod_test_import");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let claude_session = dir.join("claude_session.jsonl");
let lines = [
r#"{"type":"progress","data":{"type":"hook_progress"},"uuid":"a","timestamp":"2026-03-07"}"#,
r#"{"type":"user","message":{"role":"user","content":"fix the parser bug"},"uuid":"b","sessionId":"test-sid","timestamp":"2026-03-07"}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking at the parser..."},{"type":"tool_use","name":"Read","id":"x","input":{}}]},"uuid":"c","sessionId":"test-sid","timestamp":"2026-03-07"}"#,
r#"{"type":"system","message":{"content":"context info"},"uuid":"d","sessionId":"test-sid","timestamp":"2026-03-07"}"#,
];
std::fs::write(&claude_session, lines.join("\n")).unwrap();
let out_dir = dir.join("sessions");
let result = import_claude_session(&claude_session, out_dir.to_str().unwrap());
assert!(result.is_some());
let output = result.unwrap();
let content = std::fs::read_to_string(&output).unwrap();
let entries: Vec<serde_json::Value> = content
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0]["type"].as_str(), Some("user"));
assert_eq!(entries[1]["type"].as_str(), Some("assistant"));
assert_eq!(entries[2]["type"].as_str(), Some("system"));
let sid = entries[0]["sessionId"].as_str().unwrap();
assert_ne!(sid, "test-sid");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_real_claude_session() {
let Ok(home) = std::env::var("HOME") else {
return;
};
let claude_dir = std::path::Path::new(&home).join(".claude/projects");
if !claude_dir.exists() {
return;
}
let Some(project_dir) = std::fs::read_dir(&claude_dir).ok().and_then(|rd| {
rd.filter_map(|e| e.ok()).find(|e| {
e.path().is_dir()
&& std::fs::read_dir(e.path())
.ok()
.map(|rd2| {
rd2.filter_map(|e2| e2.ok())
.any(|e2| e2.path().extension().is_some_and(|ext| ext == "jsonl"))
})
.unwrap_or(false)
})
}) else {
return;
};
let smallest = std::fs::read_dir(project_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
.min_by_key(|e| e.metadata().map(|m| m.len()).unwrap_or(u64::MAX));
let Some(smallest) = smallest else { return };
let out_dir = std::env::temp_dir().join("baml_mod_test_real");
let _ = std::fs::remove_dir_all(&out_dir);
let result = import_claude_session(&smallest.path(), out_dir.to_str().unwrap());
if let Some(output) = result {
let content = std::fs::read_to_string(&output).unwrap();
assert!(content.lines().count() > 0);
for line in content.lines() {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(v["type"].as_str().is_some());
assert!(v["sessionId"].as_str().is_some());
}
}
let _ = std::fs::remove_dir_all(&out_dir);
}
}