pub mod lifecycle;
pub mod orphan;
pub mod reader;
pub mod serialize;
pub mod writer;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::llm::ToolSpec;
use crate::message::Message;
pub use lifecycle::{truncate_transcript_to_turn, SessionLock, SessionLockBusy, TruncateStats};
pub use orphan::OrphanToolCall;
pub use reader::SessionReader;
pub use serialize::{entry_to_message, LoadedEntry, TranscriptEntry};
pub use writer::{SessionPersistenceSink, SessionWriter};
const SESSION_SCHEMA_VERSION: u32 = 1;
pub(crate) const SUPPORTED_SESSION_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionStatus {
#[serde(alias = "success")]
Completed,
#[serde(alias = "incomplete")]
Crashed,
#[serde(alias = "cancelled")]
Interrupted,
Paused,
#[serde(other)]
#[default]
Active,
}
impl std::fmt::Display for SessionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Completed => write!(f, "completed"),
Self::Crashed => write!(f, "crashed"),
Self::Interrupted => write!(f, "interrupted"),
Self::Paused => write!(f, "paused"),
Self::Active => write!(f, "active"),
}
}
}
impl SessionStatus {
pub fn for_finish(reason: &crate::agent::FinishReason) -> Self {
use crate::agent::FinishReason;
match reason {
FinishReason::NoMoreToolCalls => Self::Completed,
FinishReason::BudgetExceeded
| FinishReason::ProviderStop(_)
| FinishReason::Stuck { .. }
| FinishReason::TranscriptLimit { .. }
| FinishReason::PermissionDenialLimit
| FinishReason::WallClockExceeded { .. } => Self::Crashed,
FinishReason::Cancelled => Self::Interrupted,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionFile {
pub schema_version: u32,
pub goal: String,
pub model: String,
pub provider: String,
pub tool_registry_hash: String,
pub steps_consumed: usize,
pub transcript: Vec<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<String>,
}
impl SessionFile {
pub fn new(
goal: String,
model: String,
provider: String,
tool_specs: &[ToolSpec],
steps_consumed: usize,
transcript: Vec<Message>,
) -> Self {
let tool_registry_hash = hash_tool_specs(tool_specs);
Self {
schema_version: SESSION_SCHEMA_VERSION,
goal,
model,
provider,
tool_registry_hash,
steps_consumed,
transcript,
preset: None,
}
}
pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::atomic::atomic_write(path, json.as_bytes())
}
pub fn read_from(path: &Path) -> std::io::Result<Self> {
let bytes = std::fs::read(path)?;
serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn validate_tool_registry(&self, current_specs: &[ToolSpec]) -> Result<(), String> {
let current_hash = hash_tool_specs(current_specs);
if self.tool_registry_hash == current_hash {
Ok(())
} else {
Err(format!(
"tool registry hash mismatch: session has '{}', current is '{}'. \
Tools have changed since the session was saved; cannot resume.",
self.tool_registry_hash, current_hash
))
}
}
pub fn messages(&self) -> &[Message] {
&self.transcript
}
pub fn into_transcript(self) -> Vec<Message> {
self.transcript
}
}
pub fn hash_tool_specs(specs: &[ToolSpec]) -> String {
use std::collections::BTreeMap;
let mut map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
for spec in specs {
let value = serde_json::json!({
"description": spec.description,
"parameters": spec.parameters,
});
map.insert(spec.name.clone(), value);
}
let canonical = serde_json::to_string(&map).unwrap_or_else(|e| {
tracing::error!(error = %e, "hash_tool_specs: unreachable serialization failure — drift detection may be compromised");
String::new()
});
let hash = blake3::hash(canonical.as_bytes());
hash.to_hex().to_string()
}
pub fn default_session_path(workspace: &Path, goal: &str) -> PathBuf {
let ts = filesystem_safe_timestamp();
let prefix: String = goal
.chars()
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
.take(40)
.collect();
let prefix = if prefix.is_empty() {
"unnamed".to_string()
} else {
prefix
};
let dir = crate::paths::user_sessions_dir(workspace)
.unwrap_or_else(|_| workspace.join(".recursive").join("sessions"));
dir.join(format!("{}-{}.json", ts, prefix))
}
pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
let dir = match crate::paths::user_sessions_dir(workspace) {
Ok(d) => d,
Err(_) => workspace.join(".recursive").join("sessions"),
};
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
sessions.push(path);
}
}
sessions.sort();
Ok(sessions)
}
pub(crate) fn filesystem_safe_timestamp() -> String {
chrono_lite_now().replace(':', "-")
}
pub(crate) fn chrono_lite_now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
pub(crate) fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub(crate) fn workspace_slug(workspace: &Path) -> String {
let abs = if workspace.is_absolute() {
workspace.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(workspace)
};
let s: String = abs
.to_string_lossy()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
c
} else {
'-'
}
})
.collect();
let s = s.trim_start_matches('-').to_string();
if s.len() > 80 {
crate::truncate_str(&s, 80).to_string()
} else {
s
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct UsageMeta {
pub input_tokens: u32,
pub output_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
}
impl UsageMeta {
pub fn accumulate(&mut self, other: &UsageMeta) {
self.input_tokens += other.input_tokens;
self.output_tokens += other.output_tokens;
self.cache_creation_tokens = Some(
self.cache_creation_tokens.unwrap_or(0) + other.cache_creation_tokens.unwrap_or(0),
);
self.cache_read_tokens =
Some(self.cache_read_tokens.unwrap_or(0) + other.cache_read_tokens.unwrap_or(0));
self.reasoning_tokens =
Some(self.reasoning_tokens.unwrap_or(0) + other.reasoning_tokens.unwrap_or(0));
}
pub fn from_token_usage(tu: &crate::llm::TokenUsage) -> Self {
UsageMeta {
input_tokens: tu.prompt_tokens,
output_tokens: tu.completion_tokens,
cache_creation_tokens: if tu.cache_miss_tokens > 0 {
Some(tu.cache_miss_tokens)
} else {
None
},
cache_read_tokens: if tu.cache_hit_tokens > 0 {
Some(tu.cache_hit_tokens)
} else {
None
},
reasoning_tokens: Some(tu.reasoning_tokens),
}
}
pub fn is_zero(&self) -> bool {
self.input_tokens == 0 && self.output_tokens == 0
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionCost {
pub total_input_tokens: u64,
pub total_output_tokens: u64,
#[serde(default)]
pub total_cache_creation_tokens: u64,
#[serde(default)]
pub total_cache_read_tokens: u64,
#[serde(default)]
pub total_reasoning_tokens: u64,
}
impl SessionCost {
pub fn accumulate(&mut self, usage: &UsageMeta) {
self.total_input_tokens += usage.input_tokens as u64;
self.total_output_tokens += usage.output_tokens as u64;
self.total_cache_creation_tokens += usage.cache_creation_tokens.unwrap_or(0) as u64;
self.total_cache_read_tokens += usage.cache_read_tokens.unwrap_or(0) as u64;
self.total_reasoning_tokens += usage.reasoning_tokens.unwrap_or(0) as u64;
}
}
fn default_schema_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
pub session_id: String,
pub goal: String,
pub model: String,
pub provider: String,
pub created_at: String,
pub updated_at: String,
pub message_count: u64,
#[serde(default)]
pub status: SessionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_registry_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<SessionCost>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportedTranscript {
pub version: u32,
pub session_id: String,
pub model: String,
pub goal: String,
pub created_at: String,
pub status: SessionStatus,
pub messages: Vec<TranscriptEntry>,
#[serde(default)]
pub compact_boundaries: Vec<ExportedCompactBoundary>,
pub message_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportedCompactBoundary {
pub message_index: usize,
pub turn: Option<u32>,
pub removed: usize,
pub timestamp: String,
}
impl ExportedTranscript {
pub fn from_session_dir(session_dir: &Path) -> std::io::Result<Self> {
let meta = SessionReader::load_meta(session_dir)?;
let loaded = SessionReader::load_full_history(session_dir)?;
let mut messages: Vec<TranscriptEntry> = Vec::new();
let mut compact_boundaries: Vec<ExportedCompactBoundary> = Vec::new();
for entry in loaded {
match entry {
LoadedEntry::Message(m) => messages.push(*m),
LoadedEntry::CompactBoundary { turn, removed } => {
let timestamp = messages
.last()
.map(|m| m.timestamp.clone())
.unwrap_or_default();
compact_boundaries.push(ExportedCompactBoundary {
message_index: messages.len(),
turn,
removed,
timestamp,
});
}
}
}
let message_count = messages.len() as u64;
Ok(Self {
version: 1,
session_id: meta.session_id,
model: meta.model,
goal: meta.goal,
created_at: meta.created_at,
status: meta.status,
messages,
compact_boundaries,
message_count,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Message, Role};
#[test]
fn session_round_trip() {
let goal = "fix the bug".to_string();
let model = "gpt-4o-mini".to_string();
let provider = "openai".to_string();
let tool_specs = vec![
ToolSpec {
name: "Read".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type":"object"}),
},
ToolSpec {
name: "Write".into(),
description: "Write a file".into(),
parameters: serde_json::json!({"type":"object"}),
},
];
let transcript = vec![
Message::system("You are a helpful assistant.".to_string()),
Message::user("fix the bug".to_string()),
Message::assistant("Let me look at the code.".to_string()),
];
let session = SessionFile::new(
goal.clone(),
model.clone(),
provider.clone(),
&tool_specs,
2,
transcript.clone(),
);
let tmp = tempfile::NamedTempFile::new().unwrap();
session.write_to(tmp.path()).unwrap();
let restored = SessionFile::read_from(tmp.path()).unwrap();
assert_eq!(restored.schema_version, SESSION_SCHEMA_VERSION);
assert_eq!(restored.goal, goal);
assert_eq!(restored.model, model);
assert_eq!(restored.provider, provider);
assert_eq!(restored.steps_consumed, 2);
assert_eq!(restored.transcript.len(), 3);
assert_eq!(
restored.transcript[0].content,
"You are a helpful assistant."
);
assert_eq!(restored.transcript[1].content, "fix the bug");
assert_eq!(restored.transcript[2].content, "Let me look at the code.");
}
#[test]
fn resume_validates_tool_registry_hash() {
let tool_specs = vec![ToolSpec {
name: "Read".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type":"object"}),
}];
let session = SessionFile::new(
"test".into(),
"model".into(),
"provider".into(),
&tool_specs,
0,
vec![],
);
assert!(session.validate_tool_registry(&tool_specs).is_ok());
let different_specs = vec![ToolSpec {
name: "Write".into(),
description: "Write a file".into(),
parameters: serde_json::json!({"type":"object"}),
}];
assert!(session.validate_tool_registry(&different_specs).is_err());
}
#[test]
#[cfg_attr(target_os = "windows", ignore)]
fn session_list_finds_files_in_workspace() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let sessions = list_sessions(ws).unwrap();
assert!(sessions.is_empty());
let session = SessionFile::new(
"test".into(),
"model".into(),
"provider".into(),
&[],
0,
vec![],
);
let path = default_session_path(ws, "test");
session.write_to(&path).unwrap();
let sessions = list_sessions(ws).unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(
sessions[0].extension().and_then(|e| e.to_str()),
Some("json")
);
}
#[test]
fn resume_continues_from_seeded_transcript() {
let transcript = vec![
Message::system("sys".to_string()),
Message::user("original goal".to_string()),
Message::assistant("partial work".to_string()),
];
let session = SessionFile::new(
"original goal".into(),
"model".into(),
"provider".into(),
&[],
1,
transcript.clone(),
);
assert_eq!(session.messages().len(), 3);
assert_eq!(session.messages()[0].content, "sys");
assert_eq!(session.messages()[1].content, "original goal");
assert_eq!(session.messages()[2].content, "partial work");
let restored = session.into_transcript();
assert_eq!(restored.len(), 3);
}
#[test]
fn round_trip_with_tool_calls() {
use crate::llm::ToolCall;
let tool_calls = vec![
ToolCall {
id: "call_001".to_string(),
name: "Read".to_string(),
arguments: serde_json::json!({"path": "/tmp/foo.rs"}),
},
ToolCall {
id: "call_002".to_string(),
name: "Write".to_string(),
arguments: serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"}),
},
];
let transcript = vec![
Message::system("You are an agent.".to_string()),
Message::user("refactor the code".to_string()),
Message::assistant_with_tool_calls(
"I'll read the file first.".to_string(),
tool_calls.clone(),
),
Message::tool_result("call_001", "fn main() { println!(\"hello\"); }"),
];
let session = SessionFile::new(
"refactor".into(),
"gpt-4o".into(),
"openai".into(),
&[],
3,
transcript,
);
let tmp = tempfile::NamedTempFile::new().unwrap();
session.write_to(tmp.path()).unwrap();
let restored = SessionFile::read_from(tmp.path()).unwrap();
assert_eq!(restored.transcript.len(), 4);
let assistant_msg = &restored.transcript[2];
assert_eq!(assistant_msg.role, Role::Assistant);
assert_eq!(assistant_msg.content, "I'll read the file first.");
assert_eq!(assistant_msg.tool_calls.len(), 2);
assert_eq!(assistant_msg.tool_calls[0].id, "call_001");
assert_eq!(assistant_msg.tool_calls[0].name, "Read");
assert_eq!(
assistant_msg.tool_calls[0].arguments,
serde_json::json!({"path": "/tmp/foo.rs"})
);
assert_eq!(assistant_msg.tool_calls[1].id, "call_002");
assert_eq!(assistant_msg.tool_calls[1].name, "Write");
assert_eq!(
assistant_msg.tool_calls[1].arguments,
serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"})
);
let tool_msg = &restored.transcript[3];
assert_eq!(tool_msg.role, Role::Tool);
assert_eq!(tool_msg.tool_call_id, Some("call_001".to_string()));
assert_eq!(tool_msg.content, "fn main() { println!(\"hello\"); }");
}
#[test]
fn read_from_nonexistent_file() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let bogus_path = tmp.path().join("does_not_exist.json");
let result = SessionFile::read_from(&bogus_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn read_from_corrupt_json() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "this is not valid json {{{garbage").unwrap();
let result = SessionFile::read_from(tmp.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn validate_tool_registry_mismatch() {
let original_specs = vec![
ToolSpec {
name: "Read".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type":"object"}),
},
ToolSpec {
name: "Write".into(),
description: "Write a file".into(),
parameters: serde_json::json!({"type":"object"}),
},
];
let session = SessionFile::new(
"test".into(),
"model".into(),
"provider".into(),
&original_specs,
0,
vec![],
);
let different_specs = vec![ToolSpec {
name: "execute_command".into(),
description: "Run a shell command".into(),
parameters: serde_json::json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
}];
let result = session.validate_tool_registry(&different_specs);
assert!(result.is_err());
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("mismatch"),
"Expected error to contain 'mismatch', got: {err_msg}"
);
}
#[test]
fn default_session_path_sanitizes_special_chars() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let goal = "fix bug/issue #42 — with spëcial chars™ 日本語";
let path = default_session_path(ws, goal);
let filename = path.file_stem().unwrap().to_str().unwrap();
let goal_suffix = filename
.find("Z-")
.map(|i| &filename[i + 2..])
.expect("filename should contain Z- separator between timestamp and goal");
for ch in goal_suffix.chars() {
assert!(
ch.is_alphanumeric() || ch == '_' || ch == '-',
"Unexpected character '{}' (U+{:04X}) in goal suffix: {}",
ch,
ch as u32,
goal_suffix
);
}
assert!(!goal_suffix.contains(' '));
assert!(!goal_suffix.contains('/'));
assert!(!goal_suffix.contains('#'));
assert!(!goal_suffix.contains('â„¢'));
assert!(!goal_suffix.contains('—'));
let parent = path.parent().expect("session path has parent");
assert_eq!(
parent.file_name().and_then(|n| n.to_str()),
Some("sessions"),
"expected sessions dir as parent, got {}",
path.display()
);
assert_eq!(path.extension().and_then(|e| e.to_str()), Some("json"));
}
#[test]
fn default_session_path_preserves_underscore_and_alphanumeric() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let path = default_session_path(ws, "my_feature");
let filename = path.file_stem().unwrap().to_str().unwrap();
let goal_suffix = filename
.find("Z-")
.map(|i| &filename[i + 2..])
.expect("filename should contain Z- separator");
assert_eq!(
goal_suffix, "my_feature",
"goal prefix must preserve both alphanumeric chars and underscores; got: {goal_suffix}"
);
}
#[test]
fn default_session_path_empty_goal_uses_unnamed() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let goal = "!@#$%^&*()";
let path = default_session_path(ws, goal);
let filename = path.file_stem().unwrap().to_str().unwrap();
let goal_suffix = filename
.find("Z-")
.map(|i| &filename[i + 2..])
.expect("filename should contain Z- separator");
assert_eq!(
goal_suffix, "unnamed",
"empty-goal filter must produce 'unnamed'"
);
}
#[test]
#[cfg_attr(target_os = "windows", ignore)]
fn list_sessions_ignores_non_json_files() {
use std::fs;
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let session = SessionFile::new("test".into(), "m".into(), "p".into(), &[], 0, vec![]);
let json_path = default_session_path(ws, "test");
session.write_to(&json_path).unwrap();
let sessions_dir = json_path.parent().unwrap();
fs::write(sessions_dir.join("README.txt"), b"not a session").unwrap();
fs::write(sessions_dir.join("tmp.bak"), b"backup").unwrap();
let sessions = list_sessions(ws).unwrap();
assert_eq!(
sessions.len(),
1,
"non-json files must be excluded; found: {sessions:?}"
);
assert_eq!(
sessions[0].extension().and_then(|e| e.to_str()),
Some("json"),
);
}
#[test]
fn filesystem_safe_timestamp_has_no_colons() {
let ts = filesystem_safe_timestamp();
assert!(!ts.contains(':'));
assert!(ts.ends_with('Z'));
}
#[test]
fn workspace_slug_matches_expected() {
let p = Path::new("/home/user/projects/my-app");
let slug = workspace_slug(p);
assert!(!slug.starts_with('-'));
assert!(slug.contains("home-user-projects-my-app"));
assert!(slug.len() <= 80);
let p = Path::new(".");
let slug = workspace_slug(p);
assert!(!slug.is_empty());
assert!(slug.len() <= 80);
}
#[test]
fn workspace_slug_preserves_underscore_and_dot() {
let p = Path::new("/home/user_name/project.ver");
let slug = workspace_slug(p);
assert!(
slug.contains('_'),
"workspace_slug must preserve underscore; got: {slug}"
);
assert!(
slug.contains('.'),
"workspace_slug must preserve dot; got: {slug}"
);
}
#[test]
fn workspace_slug_truncates_to_80_chars() {
let long_component = "a".repeat(100);
let p = std::path::PathBuf::from(format!("/home/user/{long_component}"));
let slug = workspace_slug(&p);
assert!(
slug.len() <= 80,
"slug must be capped at 80 chars; got length {}",
slug.len()
);
assert!(!slug.starts_with('-'), "slug must not start with dash");
}
#[test]
fn epoch_day_to_ymd_unix_epoch() {
assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
}
#[test]
fn epoch_day_to_ymd_known_dates() {
assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
assert_eq!(epoch_day_to_ymd(11016), (2000, 2, 29));
assert_eq!(epoch_day_to_ymd(47482), (2100, 1, 1));
}
#[test]
fn epoch_day_to_ymd_century_correction_at_doe_36524() {
assert_eq!(epoch_day_to_ymd(47541), (2100, 3, 1));
}
#[test]
fn epoch_day_to_ymd_negative_epoch_day_pre_ce() {
assert_eq!(epoch_day_to_ymd(-719528), (0, 1, 1));
}
#[test]
fn chrono_lite_now_format() {
let ts = chrono_lite_now();
assert_eq!(ts.len(), 20, "unexpected length: {ts}");
assert_eq!(&ts[4..5], "-", "missing first dash: {ts}");
assert_eq!(&ts[7..8], "-", "missing second dash: {ts}");
assert_eq!(&ts[10..11], "T", "missing T separator: {ts}");
assert_eq!(&ts[13..14], ":", "missing first colon: {ts}");
assert_eq!(&ts[16..17], ":", "missing second colon: {ts}");
assert_eq!(&ts[19..20], "Z", "missing Z suffix: {ts}");
let year: u32 = ts[0..4].parse().expect("year");
let month: u32 = ts[5..7].parse().expect("month");
let day: u32 = ts[8..10].parse().expect("day");
let hour: u32 = ts[11..13].parse().expect("hour");
let min: u32 = ts[14..16].parse().expect("minute");
let sec: u32 = ts[17..19].parse().expect("second");
assert!(year >= 2024, "year looks wrong: {year}");
assert!((1..=12).contains(&month), "month out of range: {month}");
assert!((1..=31).contains(&day), "day out of range: {day}");
assert!(hour < 24, "hour out of range: {hour}");
assert!(min < 60, "minute out of range: {min}");
assert!(sec < 60, "second out of range: {sec}");
}
#[test]
fn session_status_serializes_lowercase() {
for (variant, expected) in [
(SessionStatus::Active, "active"),
(SessionStatus::Completed, "completed"),
(SessionStatus::Crashed, "crashed"),
(SessionStatus::Interrupted, "interrupted"),
(SessionStatus::Paused, "paused"),
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, format!("\"{expected}\""), "mismatch for {variant:?}");
}
}
#[test]
fn session_status_display_matches_serde() {
for variant in [
SessionStatus::Active,
SessionStatus::Completed,
SessionStatus::Crashed,
SessionStatus::Interrupted,
SessionStatus::Paused,
] {
let from_display = format!("{variant}");
let from_serde = serde_json::to_string(&variant)
.unwrap()
.trim_matches('"')
.to_string();
assert_eq!(
from_display, from_serde,
"Display and serde disagree for {variant:?}"
);
}
}
#[test]
fn session_status_legacy_aliases_round_trip() {
let cases = [
("success", SessionStatus::Completed),
("incomplete", SessionStatus::Crashed),
("cancelled", SessionStatus::Interrupted),
("completed", SessionStatus::Completed),
("crashed", SessionStatus::Crashed),
("interrupted", SessionStatus::Interrupted),
("paused", SessionStatus::Paused),
];
for (legacy_str, expected) in cases {
let parsed: SessionStatus = serde_json::from_str(&format!("\"{legacy_str}\""))
.unwrap_or_else(|e| panic!("legacy alias {legacy_str:?} did not deserialize: {e}"));
assert_eq!(parsed, expected, "wrong mapping for {legacy_str:?}");
}
}
#[test]
fn session_meta_unknown_status_deserializes_to_active() {
let json = serde_json::json!({
"session_id": "future",
"goal": "future session",
"model": "gpt-4o-mini",
"provider": "openai",
"created_at": "2099-01-01T00:00:00Z",
"updated_at": "2099-01-01T00:00:00Z",
"message_count": 0,
"status": "stalled"
});
let restored: SessionMeta = serde_json::from_value(json)
.expect("unknown status string must not break SessionMeta deserialization");
assert_eq!(
restored.status,
SessionStatus::Active,
"unknown status should map to Active via #[serde(other)]"
);
}
#[test]
fn session_meta_missing_status_field_defaults_to_active() {
let json = serde_json::json!({
"session_id": "missing-status",
"goal": "no status key",
"model": "gpt-4o-mini",
"provider": "openai",
"created_at": "2099-01-01T00:00:00Z",
"updated_at": "2099-01-01T00:00:00Z",
"message_count": 0
});
let restored: SessionMeta = serde_json::from_value(json)
.expect("missing status field must not break SessionMeta deserialization");
assert_eq!(
restored.status,
SessionStatus::Active,
"missing status key should default to Active via #[serde(default)]"
);
}
#[test]
fn session_meta_status_active_round_trip() {
let meta = SessionMeta {
status: SessionStatus::Active,
..meta_for_test(SUPPORTED_SESSION_SCHEMA_VERSION)
};
let json = serde_json::to_string(&meta).expect("serialize SessionMeta");
let restored: SessionMeta = serde_json::from_str(&json).expect("deserialize SessionMeta");
assert_eq!(restored.status, SessionStatus::Active);
}
#[test]
fn for_finish_maps_exhaustively() {
use crate::agent::FinishReason;
assert_eq!(
SessionStatus::for_finish(&FinishReason::NoMoreToolCalls),
SessionStatus::Completed
);
assert_eq!(
SessionStatus::for_finish(&FinishReason::BudgetExceeded),
SessionStatus::Crashed
);
assert_eq!(
SessionStatus::for_finish(&FinishReason::ProviderStop("rate_limited".into())),
SessionStatus::Crashed
);
assert_eq!(
SessionStatus::for_finish(&FinishReason::Stuck {
repeated_call: "Read".into(),
repeats: 5
}),
SessionStatus::Crashed
);
assert_eq!(
SessionStatus::for_finish(&FinishReason::TranscriptLimit {
chars: 100_000,
limit: 80_000
}),
SessionStatus::Crashed
);
assert_eq!(
SessionStatus::for_finish(&FinishReason::Cancelled),
SessionStatus::Interrupted
);
assert_eq!(
SessionStatus::for_finish(&FinishReason::PermissionDenialLimit),
SessionStatus::Crashed
);
}
fn meta_for_test(schema_version: u32) -> SessionMeta {
SessionMeta {
schema_version,
session_id: "20260611T120000Z-test".into(),
goal: "schema version test".into(),
model: "gpt-4o-mini".into(),
provider: "openai".into(),
created_at: "2026-06-11T12:00:00Z".into(),
updated_at: "2026-06-11T12:00:00Z".into(),
message_count: 0,
status: SessionStatus::Active,
tool_registry_hash: None,
first_prompt: None,
last_prompt: None,
cost: None,
preset: None,
name: None,
}
}
#[test]
fn test_session_meta_round_trip() {
let original = meta_for_test(SUPPORTED_SESSION_SCHEMA_VERSION);
let json = serde_json::to_string(&original).expect("serialize SessionMeta");
let restored: SessionMeta = serde_json::from_str(&json).expect("deserialize SessionMeta");
assert_eq!(restored.schema_version, SUPPORTED_SESSION_SCHEMA_VERSION);
assert_eq!(restored.schema_version, 1);
assert_eq!(restored.session_id, original.session_id);
assert_eq!(restored.goal, original.goal);
}
#[test]
fn test_session_meta_default_schema_version() {
let legacy_json = serde_json::json!({
"session_id": "20260610T120000Z-legacy",
"goal": "legacy session",
"model": "gpt-4o-mini",
"provider": "openai",
"created_at": "2026-06-10T12:00:00Z",
"updated_at": "2026-06-10T12:00:00Z",
"message_count": 0,
"status": "completed"
});
assert!(
legacy_json.get("schema_version").is_none(),
"test fixture must omit schema_version to exercise the default"
);
let restored: SessionMeta =
serde_json::from_value(legacy_json).expect("deserialize legacy SessionMeta");
assert_eq!(restored.schema_version, default_schema_version());
assert_eq!(restored.schema_version, 1);
}
#[test]
fn test_load_rejects_future_schema_version() {
let tmp = tempfile::tempdir().expect("tempdir");
let session_dir = tmp.path();
std::fs::create_dir_all(session_dir).expect("mkdir session dir");
let future = serde_json::json!({
"schema_version": 999,
"session_id": "2099-01-01T00:00:00Z-future",
"goal": "future session",
"model": "gpt-4o-mini",
"provider": "openai",
"created_at": "2099-01-01T00:00:00Z",
"updated_at": "2099-01-01T00:00:00Z",
"message_count": 0,
"status": "active"
});
let meta_path = session_dir.join(".meta.json");
std::fs::write(
&meta_path,
serde_json::to_string_pretty(&future).expect("serialize future meta"),
)
.expect("write future .meta.json");
let err = SessionReader::load_meta(session_dir)
.expect_err("load_meta must reject future schema_version");
let msg = err.to_string();
assert!(
msg.contains("schema_version=999"),
"error must include found version; got: {msg}"
);
assert!(
msg.contains("supported up to 1"),
"error must include supported version; got: {msg}"
);
}
#[test]
fn session_cost_tracks_reasoning_tokens() {
let mut cost = SessionCost::default();
let usage_a = UsageMeta {
reasoning_tokens: Some(1000),
..Default::default()
};
let usage_b = UsageMeta {
reasoning_tokens: Some(500),
..Default::default()
};
cost.accumulate(&usage_a);
cost.accumulate(&usage_b);
assert_eq!(cost.total_reasoning_tokens, 1500);
}
#[test]
fn usage_meta_accumulate_adds_tokens() {
let mut a = UsageMeta {
input_tokens: 10,
output_tokens: 20,
cache_creation_tokens: Some(5),
cache_read_tokens: Some(3),
reasoning_tokens: Some(7),
};
let b = UsageMeta {
input_tokens: 1,
output_tokens: 2,
cache_creation_tokens: Some(3),
cache_read_tokens: Some(4),
reasoning_tokens: Some(5),
};
a.accumulate(&b);
assert_eq!(a.input_tokens, 11, "input_tokens must add");
assert_eq!(a.output_tokens, 22, "output_tokens must add");
assert_eq!(a.cache_creation_tokens, Some(8), "cache_creation must add");
assert_eq!(a.cache_read_tokens, Some(7), "cache_read must add");
assert_eq!(a.reasoning_tokens, Some(12), "reasoning must add");
}
#[test]
fn usage_meta_accumulate_with_none_treats_as_zero() {
let mut a = UsageMeta {
input_tokens: 5,
output_tokens: 10,
cache_creation_tokens: None,
cache_read_tokens: None,
reasoning_tokens: None,
};
let b = UsageMeta {
input_tokens: 3,
output_tokens: 4,
cache_creation_tokens: Some(2),
cache_read_tokens: Some(1),
reasoning_tokens: Some(6),
};
a.accumulate(&b);
assert_eq!(a.input_tokens, 8);
assert_eq!(a.output_tokens, 14);
assert_eq!(a.cache_creation_tokens, Some(2), "None + Some(2) = Some(2)");
assert_eq!(a.cache_read_tokens, Some(1), "None + Some(1) = Some(1)");
assert_eq!(a.reasoning_tokens, Some(6), "None + Some(6) = Some(6)");
}
#[test]
fn usage_meta_from_token_usage_positive_values() {
use crate::llm::TokenUsage;
let tu = TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
cache_miss_tokens: 30,
cache_hit_tokens: 20,
reasoning_tokens: 10,
};
let meta = UsageMeta::from_token_usage(&tu);
assert_eq!(meta.input_tokens, 100);
assert_eq!(meta.output_tokens, 50);
assert_eq!(meta.cache_creation_tokens, Some(30), "positive miss → Some");
assert_eq!(meta.cache_read_tokens, Some(20), "positive hit → Some");
assert_eq!(meta.reasoning_tokens, Some(10));
}
#[test]
fn usage_meta_from_token_usage_zero_cache_is_none() {
use crate::llm::TokenUsage;
let tu = TokenUsage {
prompt_tokens: 5,
completion_tokens: 5,
total_tokens: 10,
cache_miss_tokens: 0,
cache_hit_tokens: 0,
reasoning_tokens: 0,
};
let meta = UsageMeta::from_token_usage(&tu);
assert_eq!(
meta.cache_creation_tokens, None,
"zero cache_miss must produce None"
);
assert_eq!(
meta.cache_read_tokens, None,
"zero cache_hit must produce None"
);
}
#[test]
fn usage_meta_is_zero_both_zero() {
let meta = UsageMeta {
input_tokens: 0,
output_tokens: 0,
..Default::default()
};
assert!(meta.is_zero(), "both zero → is_zero returns true");
}
#[test]
fn usage_meta_is_zero_input_nonzero() {
let meta = UsageMeta {
input_tokens: 1,
output_tokens: 0,
..Default::default()
};
assert!(!meta.is_zero(), "nonzero input → is_zero returns false");
}
#[test]
fn usage_meta_is_zero_output_nonzero() {
let meta = UsageMeta {
input_tokens: 0,
output_tokens: 1,
..Default::default()
};
assert!(!meta.is_zero(), "nonzero output → is_zero returns false");
}
#[test]
fn session_cost_accumulate_all_fields() {
let mut cost = SessionCost::default();
let usage = UsageMeta {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: Some(20),
cache_read_tokens: Some(10),
reasoning_tokens: Some(5),
};
cost.accumulate(&usage);
assert_eq!(cost.total_input_tokens, 100, "input must accumulate");
assert_eq!(cost.total_output_tokens, 50, "output must accumulate");
assert_eq!(
cost.total_cache_creation_tokens, 20,
"cache creation must accumulate"
);
assert_eq!(
cost.total_cache_read_tokens, 10,
"cache read must accumulate"
);
assert_eq!(cost.total_reasoning_tokens, 5, "reasoning must accumulate");
}
#[test]
fn session_cost_accumulate_twice_sums_correctly() {
let mut cost = SessionCost::default();
let u1 = UsageMeta {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: None,
cache_read_tokens: None,
reasoning_tokens: None,
};
let u2 = UsageMeta {
input_tokens: 200,
output_tokens: 75,
cache_creation_tokens: None,
cache_read_tokens: None,
reasoning_tokens: None,
};
cost.accumulate(&u1);
cost.accumulate(&u2);
assert_eq!(cost.total_input_tokens, 300, "300 = 100 + 200");
assert_eq!(cost.total_output_tokens, 125, "125 = 50 + 75");
}
#[test]
fn hash_tool_specs_is_deterministic() {
let specs = vec![
ToolSpec {
name: "Read".into(),
description: "read a file".into(),
parameters: serde_json::json!({"type": "object"}),
},
ToolSpec {
name: "Write".into(),
description: "write a file".into(),
parameters: serde_json::json!({"type": "object"}),
},
];
let h1 = hash_tool_specs(&specs);
let h2 = hash_tool_specs(&specs);
assert_eq!(h1, h2, "same inputs must produce same hash");
assert!(!h1.is_empty(), "hash must be non-empty");
}
#[test]
fn hash_tool_specs_differs_for_different_specs() {
let specs_a = vec![ToolSpec {
name: "Read".into(),
description: "read".into(),
parameters: serde_json::json!({}),
}];
let specs_b = vec![ToolSpec {
name: "Write".into(),
description: "write".into(),
parameters: serde_json::json!({}),
}];
assert_ne!(
hash_tool_specs(&specs_a),
hash_tool_specs(&specs_b),
"different specs must produce different hashes"
);
}
#[test]
fn default_schema_version_is_one() {
assert_eq!(
default_schema_version(),
1,
"default schema version must be 1"
);
}
#[test]
fn export_includes_full_history_and_compact_boundaries() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut w = SessionWriter::create(ws, "export test", "model", "openai").unwrap();
let session_dir = w.session_dir().to_path_buf();
w.append(&Message::user("folded turn".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("folded reply".to_string()), None, None)
.unwrap();
w.write_compact_boundary(1, 2, None).unwrap();
w.append(&Message::system("summary".to_string()), None, None)
.unwrap();
w.append(&Message::user("post turn".to_string()), None, None)
.unwrap();
w.finish(SessionStatus::Completed).unwrap();
let exported = ExportedTranscript::from_session_dir(&session_dir).unwrap();
assert_eq!(exported.messages.len(), 4);
assert_eq!(exported.message_count, 4);
assert_eq!(exported.compact_boundaries.len(), 1);
let b = &exported.compact_boundaries[0];
assert_eq!(b.removed, 2);
assert_eq!(b.turn, Some(1));
assert_eq!(b.message_index, 2, "boundary precedes the summary message");
}
}