use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::json::{JsonError, JsonValue}; use crate::usage::TokenUsage; const SESSION_VERSION: u32 = 1; const ROTATE_AFTER_BYTES: u64 = 256 * 1024; const MAX_ROTATED_FILES: usize = 3; static SESSION_ID_COUNTER: AtomicU64 = AtomicU64::new(0); /// Speaker role associated with a persisted conversation message. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageRole { System, User, Assistant, Tool, } /// Structured message content stored inside a [`Session`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ContentBlock { Text { text: String, }, ToolUse { id: String, name: String, input: String, }, ToolResult { tool_use_id: String, tool_name: String, output: String, is_error: bool, }, } /// One conversation message with optional token-usage metadata. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConversationMessage { pub role: MessageRole, pub blocks: Vec, pub usage: Option, } /// Metadata describing the latest compaction that summarized a session. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionCompaction { pub count: u32, pub removed_message_count: usize, pub summary: String, } /// Provenance recorded when a session is forked from another session. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionFork { pub parent_session_id: String, pub branch_name: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct SessionPersistence { path: PathBuf, } /// Persisted conversational state for the runtime and CLI session manager. #[derive(Debug, Clone)] pub struct Session { pub version: u32, pub session_id: String, pub created_at_ms: u64, pub updated_at_ms: u64, pub messages: Vec, pub compaction: Option, pub fork: Option, persistence: Option, } impl PartialEq for Session { fn eq(&self, other: &Self) -> bool { self.version == other.version && self.session_id == other.session_id && self.created_at_ms == other.created_at_ms && self.updated_at_ms == other.updated_at_ms && self.messages == other.messages && self.compaction == other.compaction && self.fork == other.fork } } impl Eq for Session {} /// Errors raised while loading, parsing, or saving sessions. #[derive(Debug)] pub enum SessionError { Io(std::io::Error), Json(JsonError), Format(String), } impl Display for SessionError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::Io(error) => write!(f, "{error}"), Self::Json(error) => write!(f, "{error}"), Self::Format(error) => write!(f, "{error}"), } } } impl std::error::Error for SessionError {} impl From for SessionError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } impl From for SessionError { fn from(value: JsonError) -> Self { Self::Json(value) } } impl Session { #[must_use] pub fn new() -> Self { let now = current_time_millis(); Self { version: SESSION_VERSION, session_id: generate_session_id(), created_at_ms: now, updated_at_ms: now, messages: Vec::new(), compaction: None, fork: None, persistence: None, } } #[must_use] pub fn with_persistence_path(mut self, path: impl Into) -> Self { self.persistence = Some(SessionPersistence { path: path.into() }); self } #[must_use] pub fn persistence_path(&self) -> Option<&Path> { self.persistence.as_ref().map(|value| value.path.as_path()) } pub fn save_to_path(&self, path: impl AsRef) -> Result<(), SessionError> { let path = path.as_ref(); let snapshot = self.render_jsonl_snapshot()?; rotate_session_file_if_needed(path)?; write_atomic(path, &snapshot)?; cleanup_rotated_logs(path)?; Ok(()) } pub fn load_from_path(path: impl AsRef) -> Result { let path = path.as_ref(); let contents = fs::read_to_string(path)?; let session = match JsonValue::parse(&contents) { Ok(value) if value .as_object() .is_some_and(|object| object.contains_key("messages")) => { Self::from_json(&value)? } Err(_) | Ok(_) => Self::from_jsonl(&contents)?, }; Ok(session.with_persistence_path(path.to_path_buf())) } pub fn push_message(&mut self, message: ConversationMessage) -> Result<(), SessionError> { self.touch(); self.messages.push(message); let persist_result = { let message_ref = self.messages.last().ok_or_else(|| { SessionError::Format("message was just pushed but missing".to_string()) })?; self.append_persisted_message(message_ref) }; if let Err(error) = persist_result { self.messages.pop(); return Err(error); } Ok(()) } pub fn push_user_text(&mut self, text: impl Into) -> Result<(), SessionError> { self.push_message(ConversationMessage::user_text(text)) } pub fn record_compaction(&mut self, summary: impl Into, removed_message_count: usize) { self.touch(); let count = self.compaction.as_ref().map_or(1, |value| value.count + 1); self.compaction = Some(SessionCompaction { count, removed_message_count, summary: summary.into(), }); } #[must_use] pub fn fork(&self, branch_name: Option) -> Self { let now = current_time_millis(); Self { version: self.version, session_id: generate_session_id(), created_at_ms: now, updated_at_ms: now, messages: self.messages.clone(), compaction: self.compaction.clone(), fork: Some(SessionFork { parent_session_id: self.session_id.clone(), branch_name: normalize_optional_string(branch_name), }), persistence: None, } } pub fn to_json(&self) -> Result { let mut object = BTreeMap::new(); object.insert( "version".to_string(), JsonValue::Number(i64::from(self.version)), ); object.insert( "session_id".to_string(), JsonValue::String(self.session_id.clone()), ); object.insert( "created_at_ms".to_string(), JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")?), ); object.insert( "updated_at_ms".to_string(), JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")?), ); object.insert( "messages".to_string(), JsonValue::Array( self.messages .iter() .map(ConversationMessage::to_json) .collect(), ), ); if let Some(compaction) = &self.compaction { object.insert("compaction".to_string(), compaction.to_json()?); } if let Some(fork) = &self.fork { object.insert("fork".to_string(), fork.to_json()); } Ok(JsonValue::Object(object)) } pub fn from_json(value: &JsonValue) -> Result { let object = value .as_object() .ok_or_else(|| SessionError::Format("session must be an object".to_string()))?; let version = object .get("version") .and_then(JsonValue::as_i64) .ok_or_else(|| SessionError::Format("missing version".to_string()))?; let version = u32::try_from(version) .map_err(|_| SessionError::Format("version out of range".to_string()))?; let messages = object .get("messages") .and_then(JsonValue::as_array) .ok_or_else(|| SessionError::Format("missing messages".to_string()))? .iter() .map(ConversationMessage::from_json) .collect::, _>>()?; let now = current_time_millis(); let session_id = object .get("session_id") .and_then(JsonValue::as_str) .map_or_else(generate_session_id, ToOwned::to_owned); let created_at_ms = object .get("created_at_ms") .map(|value| required_u64_from_value(value, "created_at_ms")) .transpose()? .unwrap_or(now); let updated_at_ms = object .get("updated_at_ms") .map(|value| required_u64_from_value(value, "updated_at_ms")) .transpose()? .unwrap_or(created_at_ms); let compaction = object .get("compaction") .map(SessionCompaction::from_json) .transpose()?; let fork = object.get("fork").map(SessionFork::from_json).transpose()?; Ok(Self { version, session_id, created_at_ms, updated_at_ms, messages, compaction, fork, persistence: None, }) } fn from_jsonl(contents: &str) -> Result { let mut version = SESSION_VERSION; let mut session_id = None; let mut created_at_ms = None; let mut updated_at_ms = None; let mut messages = Vec::new(); let mut compaction = None; let mut fork = None; for (line_number, raw_line) in contents.lines().enumerate() { let line = raw_line.trim(); if line.is_empty() { continue; } let value = JsonValue::parse(line).map_err(|error| { SessionError::Format(format!( "invalid JSONL record at line {}: {}", line_number + 1, error )) })?; let object = value.as_object().ok_or_else(|| { SessionError::Format(format!( "JSONL record at line {} must be an object", line_number + 1 )) })?; match object .get("type") .and_then(JsonValue::as_str) .ok_or_else(|| { SessionError::Format(format!( "JSONL record at line {} missing type", line_number + 1 )) })? { "session_meta" => { version = required_u32(object, "version")?; session_id = Some(required_string(object, "session_id")?); created_at_ms = Some(required_u64(object, "created_at_ms")?); updated_at_ms = Some(required_u64(object, "updated_at_ms")?); fork = object.get("fork").map(SessionFork::from_json).transpose()?; } "message" => { let message_value = object.get("message").ok_or_else(|| { SessionError::Format(format!( "JSONL record at line {} missing message", line_number + 1 )) })?; messages.push(ConversationMessage::from_json(message_value)?); } "compaction" => { compaction = Some(SessionCompaction::from_json(&JsonValue::Object( object.clone(), ))?); } other => { return Err(SessionError::Format(format!( "unsupported JSONL record type at line {}: {other}", line_number + 1 ))) } } } let now = current_time_millis(); Ok(Self { version, session_id: session_id.unwrap_or_else(generate_session_id), created_at_ms: created_at_ms.unwrap_or(now), updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)), messages, compaction, fork, persistence: None, }) } fn render_jsonl_snapshot(&self) -> Result { let mut lines = vec![self.meta_record()?.render()]; if let Some(compaction) = &self.compaction { lines.push(compaction.to_jsonl_record()?.render()); } lines.extend( self.messages .iter() .map(|message| message_record(message).render()), ); let mut rendered = lines.join("\n"); rendered.push('\n'); Ok(rendered) } fn append_persisted_message(&self, message: &ConversationMessage) -> Result<(), SessionError> { let Some(path) = self.persistence_path() else { return Ok(()); }; let needs_bootstrap = !path.exists() || fs::metadata(path)?.len() == 0; if needs_bootstrap { self.save_to_path(path)?; return Ok(()); } let mut file = OpenOptions::new().append(true).open(path)?; writeln!(file, "{}", message_record(message).render())?; Ok(()) } fn meta_record(&self) -> Result { let mut object = BTreeMap::new(); object.insert( "type".to_string(), JsonValue::String("session_meta".to_string()), ); object.insert( "version".to_string(), JsonValue::Number(i64::from(self.version)), ); object.insert( "session_id".to_string(), JsonValue::String(self.session_id.clone()), ); object.insert( "created_at_ms".to_string(), JsonValue::Number(i64_from_u64(self.created_at_ms, "created_at_ms")?), ); object.insert( "updated_at_ms".to_string(), JsonValue::Number(i64_from_u64(self.updated_at_ms, "updated_at_ms")?), ); if let Some(fork) = &self.fork { object.insert("fork".to_string(), fork.to_json()); } Ok(JsonValue::Object(object)) } fn touch(&mut self) { self.updated_at_ms = current_time_millis(); } } impl Default for Session { fn default() -> Self { Self::new() } } impl ConversationMessage { #[must_use] pub fn user_text(text: impl Into) -> Self { Self { role: MessageRole::User, blocks: vec![ContentBlock::Text { text: text.into() }], usage: None, } } #[must_use] pub fn assistant(blocks: Vec) -> Self { Self { role: MessageRole::Assistant, blocks, usage: None, } } #[must_use] pub fn assistant_with_usage(blocks: Vec, usage: Option) -> Self { Self { role: MessageRole::Assistant, blocks, usage, } } #[must_use] pub fn tool_result( tool_use_id: impl Into, tool_name: impl Into, output: impl Into, is_error: bool, ) -> Self { Self { role: MessageRole::Tool, blocks: vec![ContentBlock::ToolResult { tool_use_id: tool_use_id.into(), tool_name: tool_name.into(), output: output.into(), is_error, }], usage: None, } } #[must_use] pub fn to_json(&self) -> JsonValue { let mut object = BTreeMap::new(); object.insert( "role".to_string(), JsonValue::String( match self.role { MessageRole::System => "system", MessageRole::User => "user", MessageRole::Assistant => "assistant", MessageRole::Tool => "tool", } .to_string(), ), ); object.insert( "blocks".to_string(), JsonValue::Array(self.blocks.iter().map(ContentBlock::to_json).collect()), ); if let Some(usage) = self.usage { object.insert("usage".to_string(), usage_to_json(usage)); } JsonValue::Object(object) } fn from_json(value: &JsonValue) -> Result { let object = value .as_object() .ok_or_else(|| SessionError::Format("message must be an object".to_string()))?; let role = match object .get("role") .and_then(JsonValue::as_str) .ok_or_else(|| SessionError::Format("missing role".to_string()))? { "system" => MessageRole::System, "user" => MessageRole::User, "assistant" => MessageRole::Assistant, "tool" => MessageRole::Tool, other => { return Err(SessionError::Format(format!( "unsupported message role: {other}" ))) } }; let blocks = object .get("blocks") .and_then(JsonValue::as_array) .ok_or_else(|| SessionError::Format("missing blocks".to_string()))? .iter() .map(ContentBlock::from_json) .collect::, _>>()?; let usage = object.get("usage").map(usage_from_json).transpose()?; Ok(Self { role, blocks, usage, }) } } impl ContentBlock { #[must_use] pub fn to_json(&self) -> JsonValue { let mut object = BTreeMap::new(); match self { Self::Text { text } => { object.insert("type".to_string(), JsonValue::String("text".to_string())); object.insert("text".to_string(), JsonValue::String(text.clone())); } Self::ToolUse { id, name, input } => { object.insert( "type".to_string(), JsonValue::String("tool_use".to_string()), ); object.insert("id".to_string(), JsonValue::String(id.clone())); object.insert("name".to_string(), JsonValue::String(name.clone())); object.insert("input".to_string(), JsonValue::String(input.clone())); } Self::ToolResult { tool_use_id, tool_name, output, is_error, } => { object.insert( "type".to_string(), JsonValue::String("tool_result".to_string()), ); object.insert( "tool_use_id".to_string(), JsonValue::String(tool_use_id.clone()), ); object.insert( "tool_name".to_string(), JsonValue::String(tool_name.clone()), ); object.insert("output".to_string(), JsonValue::String(output.clone())); object.insert("is_error".to_string(), JsonValue::Bool(*is_error)); } } JsonValue::Object(object) } fn from_json(value: &JsonValue) -> Result { let object = value .as_object() .ok_or_else(|| SessionError::Format("block must be an object".to_string()))?; match object .get("type") .and_then(JsonValue::as_str) .ok_or_else(|| SessionError::Format("missing block type".to_string()))? { "text" => Ok(Self::Text { text: required_string(object, "text")?, }), "tool_use" => Ok(Self::ToolUse { id: required_string(object, "id")?, name: required_string(object, "name")?, input: required_string(object, "input")?, }), "tool_result" => Ok(Self::ToolResult { tool_use_id: required_string(object, "tool_use_id")?, tool_name: required_string(object, "tool_name")?, output: required_string(object, "output")?, is_error: object .get("is_error") .and_then(JsonValue::as_bool) .ok_or_else(|| SessionError::Format("missing is_error".to_string()))?, }), other => Err(SessionError::Format(format!( "unsupported block type: {other}" ))), } } } impl SessionCompaction { pub fn to_json(&self) -> Result { let mut object = BTreeMap::new(); object.insert( "count".to_string(), JsonValue::Number(i64::from(self.count)), ); object.insert( "removed_message_count".to_string(), JsonValue::Number(i64_from_usize( self.removed_message_count, "removed_message_count", )?), ); object.insert( "summary".to_string(), JsonValue::String(self.summary.clone()), ); Ok(JsonValue::Object(object)) } pub fn to_jsonl_record(&self) -> Result { let mut object = BTreeMap::new(); object.insert( "type".to_string(), JsonValue::String("compaction".to_string()), ); object.insert( "count".to_string(), JsonValue::Number(i64::from(self.count)), ); object.insert( "removed_message_count".to_string(), JsonValue::Number(i64_from_usize( self.removed_message_count, "removed_message_count", )?), ); object.insert( "summary".to_string(), JsonValue::String(self.summary.clone()), ); Ok(JsonValue::Object(object)) } fn from_json(value: &JsonValue) -> Result { let object = value .as_object() .ok_or_else(|| SessionError::Format("compaction must be an object".to_string()))?; Ok(Self { count: required_u32(object, "count")?, removed_message_count: required_usize(object, "removed_message_count")?, summary: required_string(object, "summary")?, }) } } impl SessionFork { #[must_use] pub fn to_json(&self) -> JsonValue { let mut object = BTreeMap::new(); object.insert( "parent_session_id".to_string(), JsonValue::String(self.parent_session_id.clone()), ); if let Some(branch_name) = &self.branch_name { object.insert( "branch_name".to_string(), JsonValue::String(branch_name.clone()), ); } JsonValue::Object(object) } fn from_json(value: &JsonValue) -> Result { let object = value .as_object() .ok_or_else(|| SessionError::Format("fork metadata must be an object".to_string()))?; Ok(Self { parent_session_id: required_string(object, "parent_session_id")?, branch_name: object .get("branch_name") .and_then(JsonValue::as_str) .map(ToOwned::to_owned), }) } } fn message_record(message: &ConversationMessage) -> JsonValue { let mut object = BTreeMap::new(); object.insert("type".to_string(), JsonValue::String("message".to_string())); object.insert("message".to_string(), message.to_json()); JsonValue::Object(object) } fn usage_to_json(usage: TokenUsage) -> JsonValue { let mut object = BTreeMap::new(); object.insert( "input_tokens".to_string(), JsonValue::Number(i64::from(usage.input_tokens)), ); object.insert( "output_tokens".to_string(), JsonValue::Number(i64::from(usage.output_tokens)), ); object.insert( "cache_creation_input_tokens".to_string(), JsonValue::Number(i64::from(usage.cache_creation_input_tokens)), ); object.insert( "cache_read_input_tokens".to_string(), JsonValue::Number(i64::from(usage.cache_read_input_tokens)), ); JsonValue::Object(object) } fn usage_from_json(value: &JsonValue) -> Result { let object = value .as_object() .ok_or_else(|| SessionError::Format("usage must be an object".to_string()))?; Ok(TokenUsage { input_tokens: required_u32(object, "input_tokens")?, output_tokens: required_u32(object, "output_tokens")?, cache_creation_input_tokens: required_u32(object, "cache_creation_input_tokens")?, cache_read_input_tokens: required_u32(object, "cache_read_input_tokens")?, }) } fn required_string( object: &BTreeMap, key: &str, ) -> Result { object .get(key) .and_then(JsonValue::as_str) .map(ToOwned::to_owned) .ok_or_else(|| SessionError::Format(format!("missing {key}"))) } fn required_u32(object: &BTreeMap, key: &str) -> Result { let value = object .get(key) .and_then(JsonValue::as_i64) .ok_or_else(|| SessionError::Format(format!("missing {key}")))?; u32::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range"))) } fn required_u64(object: &BTreeMap, key: &str) -> Result { let value = object .get(key) .ok_or_else(|| SessionError::Format(format!("missing {key}")))?; required_u64_from_value(value, key) } fn required_u64_from_value(value: &JsonValue, key: &str) -> Result { let value = value .as_i64() .ok_or_else(|| SessionError::Format(format!("missing {key}")))?; u64::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range"))) } fn required_usize(object: &BTreeMap, key: &str) -> Result { let value = object .get(key) .and_then(JsonValue::as_i64) .ok_or_else(|| SessionError::Format(format!("missing {key}")))?; usize::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range"))) } fn i64_from_u64(value: u64, key: &str) -> Result { i64::try_from(value) .map_err(|_| SessionError::Format(format!("{key} out of range for JSON number"))) } fn i64_from_usize(value: usize, key: &str) -> Result { i64::try_from(value) .map_err(|_| SessionError::Format(format!("{key} out of range for JSON number"))) } fn normalize_optional_string(value: Option) -> Option { value.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) } fn current_time_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) .unwrap_or_default() } fn generate_session_id() -> String { let millis = current_time_millis(); let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed); format!("session-{millis}-{counter}") } fn write_atomic(path: &Path, contents: &str) -> Result<(), SessionError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let temp_path = temporary_path_for(path); fs::write(&temp_path, contents)?; fs::rename(temp_path, path)?; Ok(()) } fn temporary_path_for(path: &Path) -> PathBuf { let file_name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or("session"); path.with_file_name(format!( "{file_name}.tmp-{}-{}", current_time_millis(), SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed) )) } fn rotate_session_file_if_needed(path: &Path) -> Result<(), SessionError> { let Ok(metadata) = fs::metadata(path) else { return Ok(()); }; if metadata.len() < ROTATE_AFTER_BYTES { return Ok(()); } let rotated_path = rotated_log_path(path); fs::rename(path, rotated_path)?; Ok(()) } fn rotated_log_path(path: &Path) -> PathBuf { let stem = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or("session"); path.with_file_name(format!("{stem}.rot-{}.jsonl", current_time_millis())) } fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> { let Some(parent) = path.parent() else { return Ok(()); }; let stem = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or("session"); let prefix = format!("{stem}.rot-"); let mut rotated_paths = fs::read_dir(parent)? .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|entry_path| { entry_path .file_name() .and_then(|value| value.to_str()) .is_some_and(|name| { name.starts_with(&prefix) && Path::new(name) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")) }) }) .collect::>(); rotated_paths.sort_by_key(|entry_path| { fs::metadata(entry_path) .and_then(|metadata| metadata.modified()) .unwrap_or(UNIX_EPOCH) }); let remove_count = rotated_paths.len().saturating_sub(MAX_ROTATED_FILES); for stale_path in rotated_paths.into_iter().take(remove_count) { fs::remove_file(stale_path)?; } Ok(()) } #[cfg(test)] mod tests { use super::{ cleanup_rotated_logs, rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session, SessionFork, }; use crate::json::JsonValue; use crate::usage::TokenUsage; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn persists_and_restores_session_jsonl() { let mut session = Session::new(); session .push_user_text("hello") .expect("user message should append"); session .push_message(ConversationMessage::assistant_with_usage( vec![ ContentBlock::Text { text: "thinking".to_string(), }, ContentBlock::ToolUse { id: "tool-1".to_string(), name: "bash".to_string(), input: "echo hi".to_string(), }, ], Some(TokenUsage { input_tokens: 10, output_tokens: 4, cache_creation_input_tokens: 1, cache_read_input_tokens: 2, }), )) .expect("assistant message should append"); session .push_message(ConversationMessage::tool_result( "tool-1", "bash", "hi", false, )) .expect("tool result should append"); let path = temp_session_path("jsonl"); session.save_to_path(&path).expect("session should save"); let restored = Session::load_from_path(&path).expect("session should load"); fs::remove_file(&path).expect("temp file should be removable"); assert_eq!(restored, session); assert_eq!(restored.messages[2].role, MessageRole::Tool); assert_eq!( restored.messages[1].usage.expect("usage").total_tokens(), 17 ); assert_eq!(restored.session_id, session.session_id); } #[test] fn loads_legacy_session_json_object() { let path = temp_session_path("legacy"); let legacy = JsonValue::Object( [ ("version".to_string(), JsonValue::Number(1)), ( "messages".to_string(), JsonValue::Array(vec![ConversationMessage::user_text("legacy").to_json()]), ), ] .into_iter() .collect(), ); fs::write(&path, legacy.render()).expect("legacy file should write"); let restored = Session::load_from_path(&path).expect("legacy session should load"); fs::remove_file(&path).expect("temp file should be removable"); assert_eq!(restored.messages.len(), 1); assert_eq!( restored.messages[0], ConversationMessage::user_text("legacy") ); assert!(!restored.session_id.is_empty()); } #[test] fn appends_messages_to_persisted_jsonl_session() { let path = temp_session_path("append"); let mut session = Session::new().with_persistence_path(path.clone()); session .save_to_path(&path) .expect("initial save should succeed"); session .push_user_text("hi") .expect("user append should succeed"); session .push_message(ConversationMessage::assistant(vec![ContentBlock::Text { text: "hello".to_string(), }])) .expect("assistant append should succeed"); let restored = Session::load_from_path(&path).expect("session should replay from jsonl"); fs::remove_file(&path).expect("temp file should be removable"); assert_eq!(restored.messages.len(), 2); assert_eq!(restored.messages[0], ConversationMessage::user_text("hi")); } #[test] fn persists_compaction_metadata() { let path = temp_session_path("compaction"); let mut session = Session::new(); session .push_user_text("before") .expect("message should append"); session.record_compaction("summarized earlier work", 4); session.save_to_path(&path).expect("session should save"); let restored = Session::load_from_path(&path).expect("session should load"); fs::remove_file(&path).expect("temp file should be removable"); let compaction = restored.compaction.expect("compaction metadata"); assert_eq!(compaction.count, 1); assert_eq!(compaction.removed_message_count, 4); assert!(compaction.summary.contains("summarized")); } #[test] fn forks_sessions_with_branch_metadata_and_persists_it() { let path = temp_session_path("fork"); let mut session = Session::new(); session .push_user_text("before fork") .expect("message should append"); let forked = session .fork(Some("investigation".to_string())) .with_persistence_path(path.clone()); forked .save_to_path(&path) .expect("forked session should save"); let restored = Session::load_from_path(&path).expect("forked session should load"); fs::remove_file(&path).expect("temp file should be removable"); assert_ne!(restored.session_id, session.session_id); assert_eq!( restored.fork, Some(SessionFork { parent_session_id: session.session_id, branch_name: Some("investigation".to_string()), }) ); assert_eq!(restored.messages, forked.messages); } #[test] fn rotates_and_cleans_up_large_session_logs() { // given let path = temp_session_path("rotation"); let oversized_length = usize::try_from(super::ROTATE_AFTER_BYTES + 10).expect("rotate threshold should fit"); fs::write(&path, "x".repeat(oversized_length)).expect("oversized file should write"); // when rotate_session_file_if_needed(&path).expect("rotation should succeed"); // then assert!( !path.exists(), "original path should be rotated away before rewrite" ); for _ in 0..5 { let rotated = super::rotated_log_path(&path); fs::write(&rotated, "old").expect("rotated file should write"); } cleanup_rotated_logs(&path).expect("cleanup should succeed"); let rotated_count = rotation_files(&path).len(); assert!(rotated_count <= super::MAX_ROTATED_FILES); for rotated in rotation_files(&path) { fs::remove_file(rotated).expect("rotated file should be removable"); } } #[test] fn rejects_jsonl_record_without_type() { // given let path = write_temp_session_file( "missing-type", r#"{"message":{"role":"user","blocks":[{"type":"text","text":"hello"}]}}"#, ); // when let error = Session::load_from_path(&path) .expect_err("session should reject JSONL records without a type"); // then assert!(error.to_string().contains("missing type")); fs::remove_file(path).expect("temp file should be removable"); } #[test] fn rejects_jsonl_message_record_without_message_payload() { // given let path = write_temp_session_file("missing-message", r#"{"type":"message"}"#); // when let error = Session::load_from_path(&path) .expect_err("session should reject JSONL message records without message payload"); // then assert!(error.to_string().contains("missing message")); fs::remove_file(path).expect("temp file should be removable"); } #[test] fn rejects_jsonl_record_with_unknown_type() { // given let path = write_temp_session_file("unknown-type", r#"{"type":"mystery"}"#); // when let error = Session::load_from_path(&path) .expect_err("session should reject unknown JSONL record types"); // then assert!(error.to_string().contains("unsupported JSONL record type")); fs::remove_file(path).expect("temp file should be removable"); } #[test] fn rejects_legacy_session_json_without_messages() { // given let session = JsonValue::Object( [("version".to_string(), JsonValue::Number(1))] .into_iter() .collect(), ); // when let error = Session::from_json(&session) .expect_err("legacy session objects should require messages"); // then assert!(error.to_string().contains("missing messages")); } #[test] fn normalizes_blank_fork_branch_name_to_none() { // given let session = Session::new(); // when let forked = session.fork(Some(" ".to_string())); // then assert_eq!(forked.fork.expect("fork metadata").branch_name, None); } #[test] fn rejects_unknown_content_block_type() { // given let block = JsonValue::Object( [("type".to_string(), JsonValue::String("unknown".to_string()))] .into_iter() .collect(), ); // when let error = ContentBlock::from_json(&block) .expect_err("content blocks should reject unknown types"); // then assert!(error.to_string().contains("unsupported block type")); } fn temp_session_path(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("runtime-session-{label}-{nanos}.json")) } fn write_temp_session_file(label: &str, contents: &str) -> PathBuf { let path = temp_session_path(label); fs::write(&path, format!("{contents}\n")).expect("temp session file should write"); path } fn rotation_files(path: &Path) -> Vec { let stem = path .file_stem() .and_then(|value| value.to_str()) .expect("temp path should have file stem") .to_string(); fs::read_dir(path.parent().expect("temp path should have parent")) .expect("temp dir should read") .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|entry_path| { entry_path .file_name() .and_then(|value| value.to_str()) .is_some_and(|name| { name.starts_with(&format!("{stem}.rot-")) && Path::new(name) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")) }) }) .collect() } }