use std::path::PathBuf;
use crate::session::Session;
fn session_dir() -> PathBuf {
dirs_path().join("sessions")
}
#[cfg(not(test))]
fn home_fallback() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
pub(crate) fn dirs_path() -> PathBuf {
if let Some(dir) = std::env::var_os("DIRGE_DATA_DIR") {
return PathBuf::from(dir);
}
#[cfg(test)]
{
return std::env::temp_dir().join(format!("dirge-test-data-{}", std::process::id()));
}
#[cfg(not(test))]
{
let base = dirs::data_dir().unwrap_or_else(home_fallback);
base.join("dirge")
}
}
pub(crate) fn global_memory_db_path() -> PathBuf {
dirs_path().join("global-memory.db")
}
pub(crate) fn config_path() -> PathBuf {
config_path_from(
std::env::var_os("DIRGE_CONFIG_DIR"),
std::env::var_os("XDG_CONFIG_HOME"),
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
)
}
fn config_path_from(
dirge_config_dir: Option<std::ffi::OsString>,
xdg_config_home: Option<std::ffi::OsString>,
home: PathBuf,
) -> PathBuf {
if let Some(dir) = dirge_config_dir.filter(|d| !d.is_empty()) {
return PathBuf::from(dir);
}
if let Some(xdg) = xdg_config_home.filter(|d| !d.is_empty()) {
let xdg = PathBuf::from(xdg);
if xdg.is_absolute() {
return xdg.join("dirge");
}
}
home.join(".config").join("dirge")
}
pub(crate) fn validate_session_id(id: &str) -> anyhow::Result<()> {
if id.is_empty() {
anyhow::bail!("session id is empty");
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
anyhow::bail!("session id contains disallowed characters: {:?}", id);
}
if id == "." || id == ".." || id.contains("/") || id.contains("\\") {
anyhow::bail!("session id resolves outside the session dir: {:?}", id);
}
Ok(())
}
pub fn save_session(session: &mut Session) -> anyhow::Result<()> {
validate_session_id(&session.id)?;
if let Some(file_version) = session.loaded_from_newer_version {
anyhow::bail!(
"refusing to save session {}: it was loaded from a newer schema (file version {}, this dirge supports {}). Upgrade dirge, or copy the session to a new id to write a fresh file.",
session.id,
file_version,
crate::session::SCHEMA_VERSION,
);
}
let dir = session_dir();
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
session.todo_list = crate::agent::tools::todo::snapshot();
session.modified_files = crate::agent::tools::modified::recent(256)
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let path = dir.join(format!("{}.json", session.id));
let json = serde_json::to_string_pretty(session)?;
if let Some(loaded_mtime) = session.loaded_mtime
&& let Ok(meta) = std::fs::metadata(&path)
&& let Ok(disk_mtime) = meta.modified()
&& disk_mtime > loaded_mtime
{
let ts = crate::time_util::now_unix_secs();
let conflict_path = dir.join(format!("{}.conflict-{}.json", session.id, ts));
crate::fs_atomic::atomic_write_sync(&conflict_path, json.as_bytes())?;
anyhow::bail!(
"session {} was modified by another dirge instance; your changes saved to {} so neither copy is lost. Reload the session to see the other instance's state.",
session.id,
conflict_path.display()
);
}
crate::fs_atomic::atomic_write_sync(&path, json.as_bytes())?;
if let Ok(meta) = std::fs::metadata(&path) {
session.loaded_mtime = meta.modified().ok();
}
Ok(())
}
pub fn load_session(id: &str) -> anyhow::Result<Session> {
validate_session_id(id)?;
let dir = session_dir();
let path = dir.join(format!("{}.json", id));
let loaded_mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
let json = std::fs::read_to_string(&path)?;
let mut session: Session = serde_json::from_str(&json).map_err(|e| {
anyhow::anyhow!("failed to parse {}: {e}", path.display())
})?;
session.loaded_mtime = loaded_mtime;
if session.schema_version < crate::session::SCHEMA_VERSION {
migrate_session(&mut session);
session.schema_version = crate::session::SCHEMA_VERSION;
} else if session.schema_version > crate::session::SCHEMA_VERSION {
tracing::warn!(
target: "dirge::session",
path = %path.display(),
file_version = session.schema_version,
our_version = crate::session::SCHEMA_VERSION,
"session file is from a newer dirge version; unknown fields will default. Upgrade dirge to read it fully."
);
session.loaded_from_newer_version = Some(session.schema_version.into());
}
Ok(session)
}
fn migrate_session(session: &mut Session) {
if session.schema_version < 2 {
session.recompute_all_estimates();
}
}
pub fn delete_session(id: &str) -> anyhow::Result<()> {
validate_session_id(id)?;
let dir = session_dir();
let path = dir.join(format!("{}.json", id));
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
#[test]
fn config_path_prefers_dirge_config_dir() {
let p = config_path_from(
Some(OsString::from("/explicit/dirge")),
Some(OsString::from("/xdg")),
PathBuf::from("/home/u"),
);
assert_eq!(p, PathBuf::from("/explicit/dirge"));
}
#[test]
fn config_path_honors_xdg_config_home() {
let p = config_path_from(
None,
Some(OsString::from("/xdg/cfg")),
PathBuf::from("/home/u"),
);
assert_eq!(p, PathBuf::from("/xdg/cfg/dirge"));
}
#[test]
fn config_path_ignores_relative_xdg_and_falls_back_to_home() {
let p = config_path_from(
None,
Some(OsString::from("relative/cfg")),
PathBuf::from("/home/u"),
);
assert_eq!(p, PathBuf::from("/home/u/.config/dirge"));
}
#[test]
fn config_path_defaults_to_home_config_dirge() {
let p = config_path_from(None, None, PathBuf::from("/home/u"));
assert_eq!(p, PathBuf::from("/home/u/.config/dirge"));
}
#[test]
fn config_path_treats_empty_overrides_as_unset() {
let p = config_path_from(
Some(OsString::from("")),
Some(OsString::from("")),
PathBuf::from("/home/u"),
);
assert_eq!(p, PathBuf::from("/home/u/.config/dirge"));
}
#[test]
fn test_data_dir_is_isolated_to_temp() {
if std::env::var_os("DIRGE_DATA_DIR").is_some() {
return; }
let p = dirs_path();
assert!(
p.starts_with(std::env::temp_dir()),
"test data dir must be under temp, got {p:?}"
);
assert!(p.to_string_lossy().contains("dirge-test-data"), "got {p:?}");
assert!(
!session_dir()
.to_string_lossy()
.contains("Application Support/dirge")
&& !session_dir()
.to_string_lossy()
.ends_with(".local/share/dirge/sessions"),
"session_dir leaked to the real data dir: {:?}",
session_dir()
);
}
#[test]
fn load_session_tip_resolves_to_newest_in_chain() {
use crate::session::{MessageRole, Session};
let origin = format!("origin-{}", uuid::Uuid::new_v4().simple());
let mut old = Session::new("p", "m", 128_000);
old.id = compact_str::CompactString::new(origin.clone());
old.add_message(MessageRole::User, "the original ask");
old.updated_at = compact_str::CompactString::new("2026-01-01T00:00:00+00:00");
save_session(&mut old).unwrap();
let mut tip = Session::new("p", "m", 128_000);
tip.id =
compact_str::CompactString::new(format!("compacted-{}", uuid::Uuid::new_v4().simple()));
tip.origin_id = Some(compact_str::CompactString::new(origin.clone()));
tip.add_message(MessageRole::User, "newer state");
tip.updated_at = compact_str::CompactString::new("2026-06-01T00:00:00+00:00");
save_session(&mut tip).unwrap();
let resolved = load_session_tip(&origin).unwrap();
assert_eq!(
resolved.id, tip.id,
"resuming the original id must land on the chain tip"
);
let resolved2 = load_session_tip(tip.id.as_str()).unwrap();
assert_eq!(resolved2.id, tip.id);
let mut unrelated = Session::new("p", "m", 128_000);
unrelated.id =
compact_str::CompactString::new(format!("other-{}", uuid::Uuid::new_v4().simple()));
unrelated.add_message(MessageRole::User, "different conversation");
unrelated.updated_at = compact_str::CompactString::new("2030-01-01T00:00:00+00:00");
save_session(&mut unrelated).unwrap();
let resolved3 = load_session_tip(&origin).unwrap();
assert_eq!(
resolved3.id, tip.id,
"a newer session from a different origin must not be chosen as the tip"
);
}
#[test]
fn dedup_by_origin_keeps_tip_per_conversation() {
use crate::session::Session;
let mk = |id: &str, origin: Option<&str>, updated: &str| {
let mut s = Session::new("p", "m", 0);
s.id = compact_str::CompactString::new(id);
s.origin_id = origin.map(compact_str::CompactString::new);
s.updated_at = compact_str::CompactString::new(updated);
s
};
let input = vec![
mk("compacted-tip", Some("conv"), "2026-06-01T00:00:00+00:00"),
mk("standalone", None, "2026-05-01T00:00:00+00:00"),
mk("conv", None, "2026-01-01T00:00:00+00:00"), ];
let out = dedup_by_origin(input);
let ids: Vec<&str> = out.iter().map(|s| s.id.as_str()).collect();
assert_eq!(
ids,
vec!["compacted-tip", "standalone"],
"the chain collapses to its tip; standalone stays"
);
}
#[test]
fn recent_project_sessions_filters_and_orders() {
use crate::session::{MessageRole, Session, SessionMessage};
let dir = session_dir();
let _ = std::fs::create_dir_all(&dir);
let proj = format!(
"/tmp/dirge-hist-proj-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let other = "/tmp/dirge-hist-other-project";
let seed = |id: &str, origin: Option<&str>, updated: &str, wd: &str, msgs: &[&str]| {
let mut s = Session::new("p", "m", 0);
s.id = compact_str::CompactString::new(id);
s.origin_id = origin.map(compact_str::CompactString::new);
s.updated_at = compact_str::CompactString::new(updated);
s.working_dir = compact_str::CompactString::new(wd);
s.messages = msgs
.iter()
.map(|t| SessionMessage {
role: MessageRole::User,
content: compact_str::CompactString::new(*t),
estimated_tokens: 0,
id: compact_str::CompactString::new("m"),
timestamp: 0,
tool_calls: Vec::new(),
})
.collect();
let path = dir.join(format!("{id}.json"));
std::fs::write(&path, serde_json::to_string(&s).unwrap()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
id.to_string()
};
let t1 = "2026-01-01T00:00:00+00:00";
let t2 = "2026-02-01T00:00:00+00:00";
let t3 = "2026-03-01T00:00:00+00:00";
let t4 = "2026-04-01T00:00:00+00:00";
let t5 = "2026-05-01T00:00:00+00:00";
let a = seed("hist-a", None, t1, &proj, &["a1"]);
let b = seed("hist-b", None, t2, &proj, &["b1", "b2"]);
let c = seed("hist-c", None, t3, &proj, &["c1"]);
let _d = seed("hist-d", None, t4, other, &["d1"]);
let _f = seed("hist-f", Some("hist-current"), t5, &proj, &["f1"]);
let mut current = Session::new("p", "m", 0);
current.id = compact_str::CompactString::new("hist-current");
current.working_dir = compact_str::CompactString::new(&proj);
let out = recent_project_sessions(¤t, 2);
let ids: Vec<&str> = out.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["hist-b", "hist-c"]);
assert_eq!(
out[0]
.messages
.iter()
.map(|m| m.content.as_str())
.collect::<Vec<_>>(),
vec!["b1", "b2"],
);
let out_all = recent_project_sessions(¤t, 10);
let ids_all: Vec<&str> = out_all.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids_all, vec!["hist-a", "hist-b", "hist-c"]);
assert!(recent_project_sessions(¤t, 0).is_empty());
for id in [a.as_str(), b.as_str(), c.as_str(), "hist-d", "hist-f"] {
let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
}
}
#[test]
fn recent_project_sessions_collapses_fold_chains() {
use crate::session::{MessageRole, Session, SessionMessage};
let dir = session_dir();
let _ = std::fs::create_dir_all(&dir);
let proj = format!(
"/tmp/dirge-hist-fold-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let seed = |id: &str, origin: Option<&str>, updated: &str, wd: &str, msgs: &[&str]| {
let mut s = Session::new("p", "m", 0);
s.id = compact_str::CompactString::new(id);
s.origin_id = origin.map(compact_str::CompactString::new);
s.updated_at = compact_str::CompactString::new(updated);
s.working_dir = compact_str::CompactString::new(wd);
s.messages = msgs
.iter()
.map(|t| SessionMessage {
role: MessageRole::User,
content: compact_str::CompactString::new(*t),
estimated_tokens: 0,
id: compact_str::CompactString::new("m"),
timestamp: 0,
tool_calls: Vec::new(),
})
.collect();
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_string(&s).unwrap(),
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
id.to_string()
};
let t1 = "2026-01-01T00:00:00+00:00";
let t2 = "2026-02-01T00:00:00+00:00";
let t3 = "2026-03-01T00:00:00+00:00";
let r1 = seed("fold-r1", None, t1, &proj, &["old"]);
let r2 = seed("fold-r2", Some("fold-r1"), t2, &proj, &["mid"]);
let r3 = seed("fold-r3", Some("fold-r1"), t3, &proj, &["tip"]);
let mut current = Session::new("p", "m", 0);
current.id = compact_str::CompactString::new("fold-current");
current.working_dir = compact_str::CompactString::new(&proj);
let out = recent_project_sessions(¤t, 10);
let ids: Vec<&str> = out.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["fold-r3"], "fold chain collapses to its tip");
for id in [r1.as_str(), r2.as_str(), r3.as_str()] {
let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
}
}
#[test]
fn validate_session_id_accepts_uuids() {
assert!(validate_session_id("a1b2c3d4-e5f6-7890-abcd-ef1234567890").is_ok());
assert!(validate_session_id("plain-id").is_ok());
assert!(validate_session_id("2024.session").is_ok());
assert!(validate_session_id("session_42").is_ok());
}
#[test]
fn v1_to_v2_recomputes_under_counted_estimates() {
use crate::session::{MessageRole, Session, SessionMessage, ToolCallEntry, ToolCallState};
let mut s = Session::new("p", "m", 128_000);
let tc = ToolCallEntry {
id: "t1".to_string(),
name: "bash".to_string(),
args: serde_json::json!({"command": "..."}),
state: ToolCallState::Completed {
result: "x".repeat(8000),
},
};
let msg = SessionMessage {
role: MessageRole::Assistant,
content: compact_str::CompactString::new("hello"),
estimated_tokens: 1, id: compact_str::CompactString::new("m1"),
timestamp: 1,
tool_calls: vec![tc],
};
s.messages.push(msg.clone());
s.message_store
.insert(compact_str::CompactString::new("m1"), msg);
s.total_estimated_tokens = 1;
s.schema_version = 1;
migrate_session(&mut s);
assert!(
s.total_estimated_tokens >= 1900,
"migration must recompute estimates; got {}",
s.total_estimated_tokens,
);
assert!(s.messages[0].estimated_tokens >= 1900);
}
#[test]
fn load_session_migrates_pre_f8_files() {
let id = format!("dirge-test-load-{}", std::process::id());
let dir = session_dir();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{}.json", id));
std::fs::write(
&path,
r#"{
"id": "dirge-test-load-pre-f8",
"name": "",
"messages": [],
"compactions": [],
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"total_tokens": 0,
"total_cost": 0.0,
"total_estimated_tokens": 0,
"context_window": 100000,
"model": "test-model",
"provider": "test",
"working_dir": "/tmp"
}"#,
)
.unwrap();
let result = load_session(&id);
let _ = std::fs::remove_file(&path);
let session = result.expect("pre-F8 file must load");
assert_eq!(
session.schema_version,
crate::session::SCHEMA_VERSION,
"migration must bump schema_version",
);
assert_eq!(session.model, "test-model");
}
#[test]
fn load_session_corrupted_file_includes_path_in_error() {
let id = format!("dirge-test-corrupt-{}", std::process::id());
let dir = session_dir();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{}.json", id));
std::fs::write(&path, r#"{"id": "x", "name":"#).unwrap();
let err = load_session(&id).expect_err("truncated file must error");
let _ = std::fs::remove_file(&path);
let msg = format!("{:?}", err);
assert!(
msg.contains(&id) || msg.contains("failed to parse"),
"error must reference the file: {msg}",
);
}
#[test]
fn validate_session_id_rejects_traversal() {
assert!(validate_session_id("../../../etc/passwd").is_err());
assert!(validate_session_id("..\\windows").is_err());
assert!(validate_session_id("..").is_err());
assert!(validate_session_id(".").is_err());
assert!(validate_session_id("a/b").is_err());
assert!(validate_session_id("a\\b").is_err());
assert!(validate_session_id("").is_err());
assert!(validate_session_id("foo bar").is_err());
assert!(validate_session_id("foo\nbar").is_err());
}
#[test]
fn save_session_diverts_to_conflict_on_concurrent_write() {
use crate::session::Session;
let id = format!(
"test-conflict-{}",
std::process::id() as u64 * 1000
+ std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos() as u64
);
let mut sess = Session::new("openrouter", "test-model", 128_000);
sess.id = compact_str::CompactString::from(id.clone());
save_session(&mut sess).expect("first save");
sess.loaded_mtime = Some(std::time::SystemTime::now() - std::time::Duration::from_secs(60));
let result = save_session(&mut sess);
assert!(result.is_err(), "expected conflict error");
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("modified by another"), "got: {err_msg}");
assert!(err_msg.contains(".conflict-"), "got: {err_msg}");
let dir = session_dir();
let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(&format!("{id}.conflict-")))
.unwrap_or(false)
{
let _ = std::fs::remove_file(&p);
}
}
}
#[test]
fn save_session_fresh_no_conflict_check() {
use crate::session::Session;
let id = format!(
"test-fresh-{}",
std::process::id() as u64 * 1000
+ std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos() as u64
);
let mut sess = Session::new("openrouter", "test-model", 128_000);
sess.id = compact_str::CompactString::from(id.clone());
assert!(sess.loaded_mtime.is_none());
save_session(&mut sess).expect("fresh save must succeed");
let dir = session_dir();
let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
}
#[test]
fn save_refreshes_loaded_mtime_to_written_file() {
use crate::session::Session;
let id = format!(
"test-mtime-{}",
std::process::id() as u64 * 1000
+ std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos() as u64
);
let mut sess = Session::new("openrouter", "test-model", 128_000);
sess.id = compact_str::CompactString::from(id.clone());
let dir = session_dir();
let path = dir.join(format!("{id}.json"));
save_session(&mut sess).expect("save");
let disk = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_eq!(
sess.loaded_mtime,
Some(disk),
"loaded_mtime must be refreshed to the just-written file's mtime",
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn resumed_session_keeps_saving_without_self_conflict() {
use crate::session::{MessageRole, Session};
let id = format!(
"test-resume-{}",
std::process::id() as u64 * 1000
+ std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos() as u64
);
let mut sess = Session::new("openrouter", "test-model", 128_000);
sess.id = compact_str::CompactString::from(id.clone());
let dir = session_dir();
let path = dir.join(format!("{id}.json"));
save_session(&mut sess).expect("seed save");
let mut resumed = load_session(&id).expect("load");
assert!(resumed.loaded_mtime.is_some(), "load records mtime");
for i in 0..3 {
resumed.add_message(MessageRole::User, &format!("msg {i}"));
save_session(&mut resumed)
.unwrap_or_else(|e| panic!("resumed save {i} must succeed; got: {e}"));
}
let conflicts = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(&format!("{id}.conflict-")))
.unwrap_or(false)
})
.count();
assert_eq!(
conflicts, 0,
"resumed saves must not divert to conflict files"
);
let reloaded = load_session(&id).expect("reload");
assert!(
reloaded
.messages
.iter()
.any(|m| m.content.contains("msg 2")),
"the real session file must hold the latest message",
);
let _ = std::fs::remove_file(&path);
}
fn unique_test_id(prefix: &str) -> String {
format!(
"test-{prefix}-{}",
std::process::id() as u64 * 1000
+ std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos() as u64
)
}
fn cleanup_session(id: &str) {
let dir = session_dir();
let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(id))
.unwrap_or(false)
{
let _ = std::fs::remove_file(&p);
}
}
}
#[test]
fn roundtrip_session_with_messages_survives_save_and_load() {
use crate::session::{MessageRole, Session};
let id = unique_test_id("roundtrip-msgs");
let mut s = Session::new("anthropic", "claude-opus", 200_000);
s.id = compact_str::CompactString::from(id.clone());
s.add_message(MessageRole::User, "what is the answer?");
s.add_message(MessageRole::Assistant, "the answer is 42");
let orig_msg_count = s.messages.len();
let orig_tokens = s.total_estimated_tokens;
save_session(&mut s).expect("save");
let loaded = load_session(&id).expect("load");
cleanup_session(&id);
assert_eq!(loaded.messages.len(), orig_msg_count);
assert_eq!(loaded.messages[0].role, MessageRole::User);
assert_eq!(loaded.messages[0].content.as_str(), "what is the answer?");
assert_eq!(loaded.messages[1].role, MessageRole::Assistant);
assert_eq!(loaded.messages[1].content.as_str(), "the answer is 42");
assert_eq!(loaded.total_estimated_tokens, orig_tokens);
assert_eq!(loaded.model, "claude-opus");
assert_eq!(loaded.provider, "anthropic");
assert_eq!(loaded.context_window, 200_000);
assert!(loaded.loaded_mtime.is_some(), "load must record mtime");
}
#[test]
fn roundtrip_tool_calls_survive_save_and_load() {
use crate::session::{MessageRole, Session, ToolCallEntry, ToolCallState};
let id = unique_test_id("roundtrip-tools");
let mut s = Session::new("openai", "gpt-4", 128_000);
s.id = compact_str::CompactString::from(id.clone());
s.add_message(MessageRole::User, "read the file");
s.add_message_with_tool_calls(
MessageRole::Assistant,
"let me check",
vec![ToolCallEntry {
id: "call_abc".to_string(),
name: "read".to_string(),
args: serde_json::json!({"path": "/tmp/data.txt"}),
state: ToolCallState::Completed {
result: "hello world\n".to_string(),
},
}],
);
save_session(&mut s).expect("save");
let loaded = load_session(&id).expect("load");
cleanup_session(&id);
let last = loaded.messages.last().unwrap();
assert_eq!(last.tool_calls.len(), 1);
assert_eq!(last.tool_calls[0].id, "call_abc");
assert_eq!(last.tool_calls[0].name, "read");
match &last.tool_calls[0].state {
ToolCallState::Completed { result } => {
assert_eq!(result, "hello world\n");
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[test]
fn roundtrip_permission_allowlist_survives() {
use crate::session::{PermissionAllowEntry, Session};
let id = unique_test_id("roundtrip-perm");
let mut s = Session::new("openrouter", "test", 100_000);
s.id = compact_str::CompactString::from(id.clone());
s.permission_allowlist.push(PermissionAllowEntry {
tool: "bash".to_string(),
pattern: "cargo *".to_string(),
});
s.permission_allowlist.push(PermissionAllowEntry {
tool: "read".to_string(),
pattern: "/tmp/**".to_string(),
});
save_session(&mut s).expect("save");
let loaded = load_session(&id).expect("load");
cleanup_session(&id);
assert_eq!(loaded.permission_allowlist.len(), 2);
assert_eq!(loaded.permission_allowlist[0].tool, "bash");
assert_eq!(loaded.permission_allowlist[0].pattern, "cargo *");
assert_eq!(loaded.permission_allowlist[1].tool, "read");
assert_eq!(loaded.permission_allowlist[1].pattern, "/tmp/**");
}
#[test]
fn roundtrip_compaction_persists() {
use crate::session::{MessageRole, Session};
let id = unique_test_id("roundtrip-compact");
let mut s = Session::new("p", "m", 100_000);
s.id = compact_str::CompactString::from(id.clone());
s.add_message(MessageRole::User, "long conversation part 1");
s.add_message(MessageRole::Assistant, "reply 1");
s.add_message(MessageRole::User, "long conversation part 2");
s.add_message(MessageRole::Assistant, "reply 2");
s.compress("summary of first 4 messages".to_string(), 4, 50);
save_session(&mut s).expect("save");
let loaded = load_session(&id).expect("load");
cleanup_session(&id);
assert_eq!(loaded.compactions.len(), 1);
assert_eq!(
loaded.compactions[0].summary.as_str(),
"summary of first 4 messages"
);
assert_eq!(loaded.compactions[0].summarized_count, 4);
assert_eq!(loaded.messages[0].role, MessageRole::System);
assert!(
loaded.messages[0]
.content
.contains("summary of first 4 messages")
);
}
#[test]
fn roundtrip_tree_and_message_store_survives() {
use crate::session::{MessageRole, Session};
let id = unique_test_id("roundtrip-tree");
let mut s = Session::new("p", "m", 100_000);
s.id = compact_str::CompactString::from(id.clone());
s.add_message(MessageRole::User, "root question");
s.add_message(MessageRole::Assistant, "first answer");
let fork_target = s.messages[1].id.clone();
s.fork_at(&fork_target).expect("fork");
s.add_message(MessageRole::Assistant, "alternate answer");
save_session(&mut s).expect("save");
let loaded = load_session(&id).expect("load");
cleanup_session(&id);
assert_eq!(
loaded.tree.entries.len(),
3,
"tree must hold all 3 nodes: got {}",
loaded.tree.entries.len(),
);
assert_eq!(
loaded.messages.len(),
2,
"active branch has 2 messages: got {}",
loaded.messages.len(),
);
assert_eq!(
loaded.message_store.len(),
3,
"store must hold all 3 messages (root + fork original + alternate): got {}",
loaded.message_store.len(),
);
assert_eq!(loaded.tree.leaf_id.as_ref(), Some(&loaded.messages[1].id));
assert!(loaded.message_store.contains_key(&fork_target));
}
#[test]
fn roundtrip_plugin_entries_survives() {
use crate::session::Session;
let id = unique_test_id("roundtrip-plugin");
let mut s = Session::new("p", "m", 100_000);
s.id = compact_str::CompactString::from(id.clone());
s.append_plugin_entry("bookmark", "save point 1", true);
s.append_plugin_entry("stats", r#"{"tokens": 500}"#, false);
save_session(&mut s).expect("save");
let loaded = load_session(&id).expect("load");
cleanup_session(&id);
assert_eq!(loaded.extra_entries.len(), 2);
assert_eq!(loaded.extra_entries[0].custom_type, "bookmark");
assert_eq!(loaded.extra_entries[0].data, "save point 1");
assert!(loaded.extra_entries[0].display);
assert_eq!(loaded.extra_entries[1].custom_type, "stats");
assert!(!loaded.extra_entries[1].display);
assert!(loaded.extra_entries[0].seq < loaded.extra_entries[1].seq);
assert!(loaded.next_entry_seq >= 2);
}
#[test]
fn roundtrip_resave_preserves_metadata() {
use crate::session::Session;
let id = unique_test_id("roundtrip-resave");
let mut s = Session::new("openrouter", "test", 100_000);
s.id = compact_str::CompactString::from(id.clone());
let orig_created = s.created_at.clone();
save_session(&mut s).expect("save");
let mut loaded = load_session(&id).expect("load");
loaded.add_message(crate::session::MessageRole::User, "added after reload");
save_session(&mut loaded).expect("resave");
let reloaded = load_session(&id).expect("reload");
cleanup_session(&id);
assert_eq!(reloaded.id, loaded.id);
assert_eq!(reloaded.created_at, orig_created);
assert_eq!(reloaded.messages.len(), 1);
assert_eq!(reloaded.messages[0].content.as_str(), "added after reload");
assert!(!reloaded.updated_at.is_empty());
}
#[test]
fn delete_session_removes_file() {
use crate::session::Session;
let id = unique_test_id("roundtrip-delete");
let mut s = Session::new("p", "m", 100_000);
s.id = compact_str::CompactString::from(id.clone());
save_session(&mut s).expect("save");
delete_session(&id).expect("delete");
let err = load_session(&id).expect_err("load after delete must fail");
cleanup_session(&id);
let msg = format!("{:?}", err);
assert!(
msg.contains("o such file") || msg.contains("No such file"),
"error must mention missing file: {msg}"
);
}
#[test]
fn roundtrip_branch_summaries_survive() {
use crate::session::{BranchSummary, Session};
let id = unique_test_id("roundtrip-branch");
let mut s = Session::new("p", "m", 100_000);
s.id = compact_str::CompactString::from(id.clone());
s.branch_summaries.push(BranchSummary {
root_id: compact_str::CompactString::from("root-1"),
parent_id: compact_str::CompactString::from("parent-1"),
message_count: 12,
preview: "alternative approach...".to_string(),
created_at: "2026-05-01T00:00:00Z".to_string(),
});
save_session(&mut s).expect("save");
let loaded = load_session(&id).expect("load");
cleanup_session(&id);
assert_eq!(loaded.branch_summaries.len(), 1);
assert_eq!(loaded.branch_summaries[0].root_id, "root-1");
assert_eq!(loaded.branch_summaries[0].parent_id, "parent-1");
assert_eq!(loaded.branch_summaries[0].message_count, 12);
assert_eq!(
loaded.branch_summaries[0].preview,
"alternative approach..."
);
}
}
fn dedup_by_origin(sessions: Vec<Session>) -> Vec<Session> {
let mut seen = std::collections::HashSet::new();
sessions
.into_iter()
.filter(|s| seen.insert(s.effective_origin().to_string()))
.collect()
}
pub fn load_session_tip(id: &str) -> anyhow::Result<Session> {
let requested = load_session(id)?;
let origin = requested.effective_origin().to_string();
let dir = session_dir();
if !dir.exists() {
return Ok(requested);
}
#[derive(serde::Deserialize)]
struct TipMeta {
id: String,
#[serde(default)]
origin_id: Option<String>,
#[serde(default)]
updated_at: String,
}
let mut tip_id = requested.id.to_string();
let mut tip_updated = requested.updated_at.to_string();
for entry in std::fs::read_dir(&dir)? {
let Ok(entry) = entry else { continue };
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& let Ok(json) = std::fs::read_to_string(&path)
&& let Ok(meta) = serde_json::from_str::<TipMeta>(&json)
{
let meta_origin = meta.origin_id.as_deref().unwrap_or(&meta.id);
if meta_origin == origin && meta.updated_at > tip_updated {
tip_updated = meta.updated_at;
tip_id = meta.id;
}
}
}
if tip_id.as_str() == requested.id.as_str() {
Ok(requested)
} else {
Ok(load_session(&tip_id).unwrap_or(requested))
}
}
pub fn find_sessions_by_prefix(prefix: &str) -> anyhow::Result<Vec<Session>> {
if prefix.is_empty() {
anyhow::bail!("session prefix must not be empty");
}
let dir = session_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut sessions: Vec<Session> = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& stem.starts_with(prefix)
&& let Ok(json) = std::fs::read_to_string(&path)
&& let Ok(session) = serde_json::from_str::<Session>(&json)
{
sessions.push(session);
}
}
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(dedup_by_origin(sessions))
}
pub fn find_recent_sessions(limit: usize) -> anyhow::Result<Vec<Session>> {
let dir = session_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
entries.push((path, mtime));
}
entries.sort_by_key(|e| std::cmp::Reverse(e.1));
entries.truncate(limit);
let mut sessions: Vec<Session> = Vec::with_capacity(entries.len());
for (path, _) in entries {
if let Ok(json) = std::fs::read_to_string(&path)
&& let Ok(session) = serde_json::from_str::<Session>(&json)
{
sessions.push(session);
}
}
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(dedup_by_origin(sessions))
}
pub fn recent_project_sessions(current: &Session, max_sessions: usize) -> Vec<Session> {
if max_sessions == 0 {
return Vec::new();
}
let dir = session_dir();
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let current_origin = current.effective_origin();
let current_wd = current.working_dir.as_str();
#[derive(serde::Deserialize)]
struct ProjMeta {
id: String,
#[serde(default)]
origin_id: Option<String>,
#[serde(default)]
working_dir: Option<String>,
}
let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
files.push((path, mtime));
}
files.sort_by_key(|f| std::cmp::Reverse(f.1));
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
seen.insert(current_origin.to_string());
let mut picked: Vec<std::path::PathBuf> = Vec::new();
for (path, _) in files {
if picked.len() == max_sessions {
break;
}
let Ok(json) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(meta) = serde_json::from_str::<ProjMeta>(&json) else {
continue;
};
if meta.working_dir.as_deref() != Some(current_wd) {
continue;
}
let origin = meta.origin_id.unwrap_or(meta.id);
if !seen.insert(origin) {
continue;
}
picked.push(path);
}
picked.reverse();
let mut out = Vec::with_capacity(picked.len());
for path in picked {
if let Ok(json) = std::fs::read_to_string(&path)
&& let Ok(session) = serde_json::from_str::<Session>(&json)
{
out.push(session);
}
}
out
}
pub fn agents_path() -> PathBuf {
config_path().join("agent").join("AGENTS.md")
}