use crate::core::stream_types::SharedMessage;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub model: String,
pub thinking_level: String,
pub system_prompt: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub session_cost: f64,
#[serde(default)]
pub message_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compacted_into: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<Vec<(String, String)>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_stripped: Vec<String>,
pub api_messages: Vec<SharedMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub abort_context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_provenance: Option<crate::prompt::PromptProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction: Option<crate::compaction::CompactionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub model: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub session_cost: f64,
pub message_count: usize,
}
pub fn user_content_for_display(content: &serde_json::Value) -> Option<String> {
if let Some(text) = content.as_str() {
return Some(text.to_string());
}
let parts: Vec<String> = content
.as_array()?
.iter()
.filter_map(|block| match block["type"].as_str() {
Some("text") => block["text"].as_str().map(str::to_string),
Some("image") => Some("[attached image]".to_string()),
Some("document") => {
let name = block["filename"]
.as_str()
.or_else(|| block["title"].as_str())
.unwrap_or("")
.rsplit(['/', '\\'])
.next()
.unwrap_or("");
let name: String = name
.chars()
.filter(|c| {
!c.is_control()
&& !matches!(c, '[' | ']' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
})
.take(120)
.collect();
let name = name.trim();
Some(if name.is_empty() {
"[attached document]".to_string()
} else {
format!("[attached document: {name}]")
})
}
_ => None,
})
.filter(|part| !part.is_empty())
.collect();
(!parts.is_empty()).then(|| parts.join("\n"))
}
impl Session {
pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
let now = Utc::now();
let id = format!(
"{}-{}",
now.format("%Y%m%d-%H%M%S"),
&uuid::Uuid::new_v4().to_string()[..4]
);
Session {
id,
title: String::new(),
name: None,
model: model.to_string(),
thinking_level: thinking_level.to_string(),
system_prompt: system_prompt.map(|s| s.to_string()),
created_at: now,
updated_at: now,
total_input_tokens: 0,
total_output_tokens: 0,
session_cost: 0.0,
message_count: 0,
api_messages: Vec::new(),
abort_context: None,
parent_session: None,
compacted_into: None,
env: None,
env_stripped: Vec::new(),
prompt_provenance: None,
compaction: None,
}
}
pub fn from_compaction_record(
parent: &Session,
summary_text: &str,
record: crate::compaction::CompactionRecord,
) -> Self {
let now = Utc::now();
let id = format!(
"{}-{}",
now.format("%Y%m%d-%H%M%S"),
&uuid::Uuid::new_v4().to_string()[..4]
);
let name = parent.name.clone();
Session {
id,
title: format!(
"↳ {}",
if parent.title.is_empty() {
&parent.id
} else {
&parent.title
}
),
name,
model: parent.model.clone(),
thinking_level: parent.thinking_level.clone(),
system_prompt: parent.system_prompt.clone(),
created_at: now,
updated_at: now,
total_input_tokens: 0,
total_output_tokens: 0,
session_cost: 0.0,
message_count: 0,
api_messages: crate::compaction::compaction_context_messages(summary_text),
abort_context: None,
parent_session: Some(parent.id.clone()),
compacted_into: None,
env: parent.env.clone(),
env_stripped: parent.env_stripped.clone(),
prompt_provenance: None,
compaction: Some(record),
}
}
pub fn auto_title(&mut self) {
if !self.title.is_empty() {
return;
}
for msg in &self.api_messages {
if msg["role"].as_str() == Some("user") {
if let Some(content) = user_content_for_display(&msg["content"]) {
self.title = content.chars().take(80).collect();
return;
}
}
}
}
pub async fn save(&self) -> std::io::Result<()> {
let dir = sessions_dir();
let mode = crate::config::load_config().session_persistence;
let session = self.clone(); let guard = crate::core::session_save_order::acquire(&dir, &session.id).await;
tokio::task::spawn_blocking(move || {
let _guard = guard;
crate::core::session_journal::save_session_in_dir(&dir, &session, mode).map(|_| ())
})
.await
.map_err(std::io::Error::other)?
}
pub async fn save_durable(&self) -> std::io::Result<()> {
let dir = sessions_dir();
let mode = crate::config::load_config().session_persistence;
self.save_durable_to(dir, mode).await
}
async fn save_durable_to(
&self,
dir: PathBuf,
mode: crate::core::session_journal::SessionPersistence,
) -> std::io::Result<()> {
let session = self.clone();
let guard = crate::core::session_save_order::acquire(&dir, &session.id).await;
tokio::task::spawn_blocking(move || {
let _guard = guard;
crate::core::session_journal::save_session_durable_in_dir(&dir, &session, mode)
.map(|_| ())
})
.await
.map_err(std::io::Error::other)?
}
pub async fn recover_durable(id: &str) -> std::io::Result<Self> {
let dir = sessions_dir();
let mode = crate::config::load_config().session_persistence;
let id = id.to_owned();
let guard = crate::core::session_save_order::acquire(&dir, &id).await;
tokio::task::spawn_blocking(move || {
let _guard = guard;
let session = Self::load_from_dir(&dir, &id)?;
crate::core::session_journal::save_session_durable_in_dir(&dir, &session, mode)?;
Ok(session)
})
.await
.map_err(std::io::Error::other)?
}
pub fn load(id: &str) -> std::io::Result<Self> {
Self::load_from_dir(&sessions_dir(), id)
}
pub fn load_from_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Self> {
crate::core::session_journal::load_session_in_dir(dir, id)
}
pub fn info(&self) -> SessionInfo {
SessionInfo {
id: self.id.clone(),
title: self.title.clone(),
name: self.name.clone(),
model: self.model.clone(),
created_at: self.created_at,
updated_at: self.updated_at,
session_cost: self.session_cost,
message_count: self.api_messages.len(),
}
}
pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
validate_name(name).map_err(std::io::Error::other)?;
if self.name.as_deref() == Some(name) {
return Ok(());
}
let sessions = list_sessions()?;
for s in &sessions {
if s.name.as_deref() == Some(name) && s.id != self.id {
return Err(std::io::Error::other(format!(
"name '{}' already used by session {}",
name, s.id
)));
}
}
if crate::core::chain::load_chain(name).is_ok() {
return Err(std::io::Error::other(format!(
"name '{}' conflicts with an existing chain name",
name
)));
}
self.name = Some(name.to_string());
Ok(())
}
pub fn clear_name(&mut self) {
self.name = None;
}
}
#[cfg(test)]
pub(crate) fn save_json_in_dir(
dir: &std::path::Path,
id: &str,
json: &[u8],
) -> std::io::Result<()> {
crate::core::session_journal::write_json_snapshot(dir, id, json)
}
pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
let dir = sessions_dir();
if !dir.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no sessions directory",
));
}
let entries = crate::core::session_journal::session_dir_entries(&dir)?;
if entries
.iter()
.any(|e| e.name == format!("{partial_id}.json"))
{
return Session::load(partial_id);
}
let mut matches: Vec<String> = Vec::new();
for entry in &entries {
if entry.name.ends_with(".json") {
let id = entry.name.trim_end_matches(".json");
if id.contains(partial_id) {
matches.push(id.to_string());
}
}
}
match matches.len() {
0 => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("no session matching '{}'", partial_id),
)),
1 => Session::load(&matches[0]),
_ => Err(std::io::Error::other(format!(
"ambiguous: {} sessions match '{}'",
matches.len(),
partial_id
))),
}
}
pub fn latest_session() -> std::io::Result<Session> {
let dir = sessions_dir();
if !dir.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no sessions found",
));
}
let entries = crate::core::session_journal::session_dir_entries(&dir)?;
let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
let mut newest: Option<(std::time::SystemTime, String)> = None;
for entry in &entries {
let id = if let Some(id) = entry.name.strip_suffix(".json") {
id
} else if let Some(id) = entry.name.strip_suffix(".journal") {
if !names.contains(format!("{id}.json").as_str()) {
continue; }
id
} else {
continue;
};
if let Some(mtime) = entry.mtime {
if newest.as_ref().map_or(true, |(t, _)| mtime > *t) {
newest = Some((mtime, id.to_string()));
}
}
}
let id = newest
.map(|(_, id)| id)
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))?;
Session::load(&id)
}
pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
let dir = sessions_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut sessions: Vec<SessionInfo> = Vec::new();
for entry in crate::core::session_journal::session_dir_entries(&dir)? {
if entry.name.ends_with(".json") {
if let Some(info) = parse_session_header(&dir, &entry.name) {
sessions.push(info);
}
}
}
sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
Ok(sessions)
}
pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
let dir = sessions_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let entries = crate::core::session_journal::session_dir_entries(&dir)?;
let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
let mut by_snapshot: std::collections::HashMap<String, std::time::SystemTime> =
std::collections::HashMap::new();
for entry in &entries {
let id = if let Some(id) = entry.name.strip_suffix(".json") {
id
} else if let Some(id) = entry.name.strip_suffix(".journal") {
if !names.contains(format!("{id}.json").as_str()) {
continue;
}
id
} else {
continue;
};
if let Some(mtime) = entry.mtime {
let slot = by_snapshot
.entry(id.to_string())
.or_insert(std::time::SystemTime::UNIX_EPOCH);
if mtime > *slot {
*slot = mtime;
}
}
}
let mut files: Vec<(std::time::SystemTime, String)> =
by_snapshot.into_iter().map(|(id, t)| (t, id)).collect();
files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
files.truncate(limit);
let mut sessions: Vec<SessionInfo> = Vec::new();
for (_, id) in files {
if let Some(info) = parse_session_header(&dir, &format!("{id}.json")) {
sessions.push(info);
}
}
Ok(sessions)
}
fn parse_session_header(dir: &std::path::Path, file_name: &str) -> Option<SessionInfo> {
#[derive(Deserialize)]
struct SessionMetadata {
#[serde(default)]
_journal_generation: Option<String>,
id: String,
#[serde(default)]
title: String,
#[serde(default)]
name: Option<String>,
model: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
#[serde(default)]
session_cost: f64,
#[serde(default)]
message_count: usize,
}
let header = read_session_header(dir, file_name)?;
let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
let mut info = SessionInfo {
id: meta.id,
title: meta.title,
name: meta.name,
model: meta.model,
created_at: meta.created_at,
updated_at: meta.updated_at,
session_cost: meta.session_cost,
message_count: meta.message_count,
};
if let Some(tail) = crate::core::session_journal::journal_meta_tail_for_generation(
dir,
&info.id,
meta._journal_generation.as_deref(),
) {
if tail.updated_at > info.updated_at {
info.updated_at = tail.updated_at;
info.session_cost = tail.session_cost;
if let Some(count) = tail.message_count {
info.message_count = count;
}
}
}
Some(info)
}
fn read_session_header(dir: &std::path::Path, file_name: &str) -> Option<String> {
let file = crate::core::session_journal::confined_open(dir, file_name).ok()??;
read_session_header_from_file(file)
}
pub(crate) fn read_session_header_from_file(mut file: std::fs::File) -> Option<String> {
use std::io::Read;
const KEY: &[u8] = b"\"api_messages\"";
const MAX_HEADER: usize = 256 * 1024;
let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
let mut chunk = [0u8; 16 * 1024];
let mut cut: Option<usize> = None;
while buf.len() <= MAX_HEADER {
let n = file.read(&mut chunk).ok()?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
cut = Some(pos);
break;
}
}
let end = cut.unwrap_or(buf.len());
let trimmed = String::from_utf8_lossy(&buf[..end]);
let mut s = trimmed.trim_end().to_string();
if s.ends_with(',') {
s.pop();
}
if !s.ends_with('}') {
s.push('}');
}
Some(s)
}
fn sessions_dir() -> PathBuf {
crate::config::get_active_config_dir().join("sessions")
}
pub fn delete_session_file(id: &str) -> std::io::Result<()> {
crate::core::session_journal::delete_session_files_in_dir(&sessions_dir(), id)
}
pub fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("name cannot be empty".into());
}
if name.len() > 40 {
return Err(format!("invalid name '{}': must be 40 chars or less", name));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(format!(
"invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
name
));
}
Ok(())
}
pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
let dir = sessions_dir();
for entry in crate::core::session_journal::session_dir_entries(&dir)? {
if !entry.name.ends_with(".json") {
continue;
}
if let Some(info) = parse_session_header(&dir, &entry.name) {
if info.name.as_deref() == Some(name) {
return Session::load(&info.id);
}
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("no session named '{}'", name),
))
}
#[derive(Debug)]
pub struct ResolvedSession {
pub session: Session,
pub compaction_notice: Option<String>,
}
const MAX_COMPACTION_HOPS: usize = 32;
pub fn follow_compaction_chain(session: Session) -> std::io::Result<ResolvedSession> {
if session.compacted_into.is_none() {
return Ok(ResolvedSession {
session,
compaction_notice: None,
});
}
let original_id = session.id.clone();
let mut current = session;
let mut visited = std::collections::HashSet::new();
visited.insert(current.id.clone());
while let Some(ref successor_id) = current.compacted_into {
let succ_id = successor_id.clone();
if !visited.insert(succ_id.clone()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"compaction cycle detected: session {} loops back to {}",
current.id, succ_id
),
));
}
if visited.len() > MAX_COMPACTION_HOPS {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"compaction chain too long (>{} hops) starting from {}",
MAX_COMPACTION_HOPS, original_id
),
));
}
current = Session::load(&succ_id).map_err(|e| {
std::io::Error::new(
e.kind(),
format!(
"session {} was compacted into {}, but the successor failed to load: {}",
current.id, succ_id, e
),
)
})?;
}
let notice = format!(
"{} was compacted into {} \u{2014} continuing there",
original_id, current.id
);
tracing::info!("{}", notice);
Ok(ResolvedSession {
session: current,
compaction_notice: Some(notice),
})
}
pub fn read_compacted_into(id: &str) -> std::io::Result<Option<String>> {
read_compacted_into_in_dir(&sessions_dir(), id)
}
pub fn read_compacted_into_in_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Option<String>> {
let file_name = format!("{}.json", id);
let header = read_session_header(dir, &file_name).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("no session file for '{}'", id),
)
})?;
#[derive(serde::Deserialize)]
struct Hdr {
#[serde(default)]
compacted_into: Option<String>,
}
let hdr: Hdr = serde_json::from_str(&header).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
})?;
Ok(hdr.compacted_into)
}
pub fn resolve_session(query: &str) -> std::io::Result<Session> {
if let Ok(ptr) = crate::core::chain::load_chain(query) {
match Session::load(&ptr.head) {
Ok(s) => {
tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
return Ok(s);
}
Err(e) => {
return Err(std::io::Error::new(
e.kind(),
format!(
"chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
query, ptr.head, e, query
),
));
}
}
}
if let Ok(s) = find_session_by_name(query) {
tracing::info!("resolved '{}' via session name → {}", query, s.id);
return Ok(s);
}
find_session(query)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn test_record(parent: &Session) -> crate::compaction::CompactionRecord {
crate::compaction::CompactionRecord {
schema_version: crate::compaction::COMPACTION_SUMMARY_SCHEMA_VERSION,
source_session: parent.id.clone(),
source_message_count: parent.api_messages.len(),
source_range_digest: crate::compaction::message_range_digest(&parent.api_messages),
summary_provider: "anthropic".into(),
summary_model: "claude-sonnet-4-6".into(),
created_at: Utc::now(),
prompt_stack_digest: crate::compaction::prompt_stack_digest(&["test"]),
included_classes: crate::compaction::ContentClass::ALL.to_vec(),
excluded_classes: Vec::new(),
redaction_policy: crate::compaction::RedactionPolicy::TruncationOnly,
prior_system_prompt: parent.system_prompt.clone(),
}
}
#[cfg(unix)]
#[tokio::test]
async fn durable_dispatch_checkpoints_json_and_journal_without_global_config() {
use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp
.path()
.canonicalize()
.unwrap()
.join("profile/nested/sessions");
let mut session = Session::new("m", "medium", None);
session.api_messages.push(std::sync::Arc::new(
json!({"role":"user","content":"checkpoint"}),
));
session.save_durable_to(dir.clone(), mode).await.unwrap();
let loaded = Session::load_from_dir(&dir, &session.id).unwrap();
assert_eq!(loaded.api_messages, session.api_messages);
session.api_messages.push(std::sync::Arc::new(
json!({"role":"assistant","content":"old tail"}),
));
save_session_in_dir(&dir, &session, SessionPersistence::Journal).unwrap();
session.api_messages.truncate(1);
session.save_durable_to(dir.clone(), mode).await.unwrap();
assert_eq!(
Session::load_from_dir(&dir, &session.id)
.unwrap()
.api_messages,
session.api_messages
);
let snapshot: Session = serde_json::from_slice(
&std::fs::read(dir.join(format!("{}.json", session.id))).unwrap(),
)
.unwrap();
assert_eq!(snapshot.api_messages, session.api_messages);
assert_eq!(
dir.join(format!("{}.journal", session.id)).exists(),
mode == SessionPersistence::Journal
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn durable_dispatch_returns_io_errors_and_refuses_symlinks() {
use crate::core::session_journal::SessionPersistence;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
let victim = root.join("victim");
std::fs::write(&victim, b"unchanged").unwrap();
let dir = root.join("sessions");
std::fs::create_dir(&dir).unwrap();
let session = Session::new("m", "medium", None);
std::os::unix::fs::symlink(&victim, dir.join(format!("{}.json", session.id))).unwrap();
for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
assert!(session.save_durable_to(dir.clone(), mode).await.is_err());
}
assert_eq!(std::fs::read(&victim).unwrap(), b"unchanged");
}
#[cfg(unix)]
#[tokio::test]
async fn durable_dispatch_reports_post_commit_cleanup_error() {
use crate::core::session_journal::SessionPersistence;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().canonicalize().unwrap();
let mut session = Session::new("m", "medium", None);
session
.save_durable_to(dir.clone(), SessionPersistence::Journal)
.await
.unwrap();
session.title = "published candidate".into();
std::fs::create_dir(dir.join(format!("{}.journal.tmp", session.id))).unwrap();
assert!(session
.save_durable_to(dir.clone(), SessionPersistence::Journal)
.await
.is_err());
assert_eq!(
Session::load_from_dir(&dir, &session.id).unwrap().title,
session.title
);
}
#[test]
fn test_session_new() {
let session = Session::new("gpt-4", "brief", Some("test prompt"));
assert_eq!(session.model, "gpt-4");
assert_eq!(session.thinking_level, "brief");
assert_eq!(session.system_prompt, Some("test prompt".to_string()));
assert!(!session.id.is_empty());
assert_eq!(session.title, "");
assert_eq!(session.total_input_tokens, 0);
assert_eq!(session.total_output_tokens, 0);
assert_eq!(session.session_cost, 0.0);
assert!(session.api_messages.is_empty());
let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
assert_eq!(session_no_prompt.thinking_level, "normal");
assert_eq!(session_no_prompt.system_prompt, None);
}
#[test]
fn test_session_auto_title() {
let mut session = Session::new("gpt-4", "brief", None);
session.api_messages.push(std::sync::Arc::new(json!({
"role": "user",
"content": "hello world"
})));
session.auto_title();
assert_eq!(session.title, "hello world");
session.title = "existing title".to_string();
session.auto_title();
assert_eq!(session.title, "existing title");
let mut empty_session = Session::new("gpt-4", "brief", None);
empty_session.auto_title();
assert_eq!(empty_session.title, "");
let mut session_no_user = Session::new("gpt-4", "brief", None);
session_no_user
.api_messages
.push(std::sync::Arc::new(json!({
"role": "assistant",
"content": "response"
})));
session_no_user.auto_title();
assert_eq!(session_no_user.title, "");
let mut session_long = Session::new("gpt-4", "brief", None);
let long_content = "a".repeat(100);
session_long.api_messages.push(std::sync::Arc::new(json!({
"role": "user",
"content": long_content
})));
session_long.auto_title();
assert_eq!(session_long.title.len(), 80);
assert_eq!(session_long.title, "a".repeat(80));
}
#[test]
fn attachment_projection_and_auto_title_never_read_sources_or_tool_results() {
let content = json!([
{"type": "text", "text": "Review"},
{"type": "image", "source": {"type": "base64", "data": "IMAGE_SENTINEL"}},
{"type": "document", "title": "a.txt",
"source": {"type": "text", "data": "DOCUMENT_TEXT_SENTINEL"}},
{"type": "document", "title": "b.pdf",
"source": {"type": "base64", "data": "PDF_SENTINEL"}},
{"type": "text", "text": "Then compare"},
{"type": "tool_result", "tool_use_id": "t1", "content": [
{"type": "text", "text": "TOOL_TEXT_SENTINEL"},
{"type": "document", "title": "TOOL_TITLE_SENTINEL",
"source": {"type": "text", "data": "NESTED_DOCUMENT_SENTINEL"}}
]}
]);
let original = content.clone();
let projected = user_content_for_display(&content).unwrap();
assert_eq!(projected, "Review\n[attached image]\n[attached document: a.txt]\n[attached document: b.pdf]\nThen compare");
assert!(!projected.contains("SENTINEL"));
assert_eq!(
content, original,
"projection must not mutate canonical data"
);
let tool_only = json!([content[5].clone()]);
assert!(user_content_for_display(&tool_only).is_none());
let mut session = Session::new("m", "low", None);
session.api_messages.push(std::sync::Arc::new(json!({
"role": "user", "content": tool_only
})));
session.api_messages.push(std::sync::Arc::new(json!({
"role": "user", "content": content
})));
session.auto_title();
assert_eq!(
session.title,
projected.chars().take(80).collect::<String>()
);
assert!(!session.title.contains("SENTINEL"));
}
#[test]
fn attachment_only_titles_and_safe_filename_labels() {
let content = json!([
{"type": "document", "title": "/private/path/[report]\u{1b}\n.txt",
"source": {"type": "text", "data": "DO_NOT_DISPLAY"}},
{"type": "document", "filename": "C:\\private\\note.txt"},
{"type": "document", "source": {"data": "NO_FILENAME"}}
]);
assert_eq!(
user_content_for_display(&content).unwrap(),
"[attached document: report.txt]\n[attached document: note.txt]\n[attached document]"
);
for (block, title) in [
(
json!({"type": "image", "source": {"data": "IMAGE_SENTINEL"}}),
"[attached image]",
),
(
json!({"type": "document", "title": "notes.txt",
"source": {"type": "text", "data": "TEXT_SENTINEL"}}),
"[attached document: notes.txt]",
),
] {
let mut session = Session::new("m", "low", None);
session.api_messages.push(std::sync::Arc::new(json!({
"role": "user", "content": [block]
})));
session.auto_title();
assert_eq!(session.title, title);
}
assert!(user_content_for_display(&json!([])).is_none());
assert!(
user_content_for_display(&json!([{"type": "unknown", "text": "hidden"}])).is_none()
);
assert_eq!(
user_content_for_display(&json!("plain text")).as_deref(),
Some("plain text")
);
}
#[test]
fn test_session_info() {
let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
session.api_messages.push(std::sync::Arc::new(json!({
"role": "user",
"content": "test message"
})));
session.api_messages.push(std::sync::Arc::new(json!({
"role": "assistant",
"content": "test response"
})));
session.title = "Test Title".to_string();
session.session_cost = 0.05;
let info = session.info();
assert_eq!(info.id, session.id);
assert_eq!(info.title, "Test Title");
assert_eq!(info.model, "gpt-4");
assert_eq!(info.created_at, session.created_at);
assert_eq!(info.updated_at, session.updated_at);
assert_eq!(info.session_cost, 0.05);
assert_eq!(info.message_count, 2);
}
#[test]
fn test_session_info_struct() {
let now = Utc::now();
let session_info = SessionInfo {
id: "test-id".to_string(),
title: "Test Title".to_string(),
name: None,
model: "gpt-4".to_string(),
created_at: now,
updated_at: now,
session_cost: 1.23,
message_count: 5,
};
assert_eq!(session_info.id, "test-id");
assert_eq!(session_info.title, "Test Title");
assert_eq!(session_info.model, "gpt-4");
assert_eq!(session_info.created_at, now);
assert_eq!(session_info.updated_at, now);
assert_eq!(session_info.session_cost, 1.23);
assert_eq!(session_info.message_count, 5);
}
#[test]
fn test_session_serialization_round_trip() {
let mut session = Session::new(
"gpt-4-turbo",
"detailed",
Some("You are a helpful assistant"),
);
session.title = "Test Session".to_string();
session.api_messages.push(std::sync::Arc::new(
json!({"role": "user", "content": "test"}),
));
session.total_input_tokens = 100;
session.total_output_tokens = 200;
session.session_cost = 0.15;
let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
let deserialized: Session =
serde_json::from_str(&json_str).expect("Failed to deserialize session");
assert_eq!(deserialized.id, session.id);
assert_eq!(deserialized.title, session.title);
assert_eq!(deserialized.model, session.model);
assert_eq!(deserialized.thinking_level, session.thinking_level);
assert_eq!(deserialized.system_prompt, session.system_prompt);
assert_eq!(deserialized.created_at, session.created_at);
assert_eq!(deserialized.updated_at, session.updated_at);
assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
assert_eq!(
deserialized.total_output_tokens,
session.total_output_tokens
);
assert_eq!(deserialized.session_cost, session.session_cost);
assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
}
#[test]
fn codex_ultra_round_trips_and_compaction_preserves_logical_mode() {
let parent = Session::new("openai-codex/gpt-5.6-sol", "ultra", None);
let encoded = serde_json::to_string(&parent).expect("serialize Ultra session");
let persisted: serde_json::Value =
serde_json::from_str(&encoded).expect("inspect persisted session");
assert_eq!(persisted["thinking_level"], "ultra");
let restored: Session = serde_json::from_str(&encoded).expect("restore Ultra session");
assert_eq!(restored.model, "openai-codex/gpt-5.6-sol");
assert_eq!(restored.thinking_level, "ultra");
let compacted =
Session::from_compaction_record(&restored, "summary", test_record(&restored));
assert_eq!(compacted.model, restored.model);
assert_eq!(compacted.thinking_level, "ultra");
assert_eq!(
compacted.parent_session.as_deref(),
Some(restored.id.as_str())
);
}
#[test]
fn codex_max_round_trips_without_becoming_ultra_or_xhigh() {
let session = Session::new("openai-codex/gpt-5.6-luna", "max", None);
let encoded = serde_json::to_string(&session).expect("serialize Max session");
let restored: Session = serde_json::from_str(&encoded).expect("restore Max session");
assert_eq!(restored.thinking_level, "max");
assert_ne!(restored.thinking_level, "ultra");
assert_ne!(restored.thinking_level, "xhigh");
}
#[test]
fn test_session_serialization_preserves_all_fields() {
let mut session = Session::new(
"claude-3-opus",
"comprehensive",
Some("Custom system prompt"),
);
session.title = "Complex Session".to_string();
session.api_messages.push(std::sync::Arc::new(
json!({"role": "user", "content": "First message"}),
));
session.api_messages.push(std::sync::Arc::new(
json!({"role": "assistant", "content": "First response"}),
));
session.api_messages.push(std::sync::Arc::new(
json!({"role": "user", "content": "Second message"}),
));
session.total_input_tokens = 1500;
session.total_output_tokens = 2500;
session.session_cost = 0.75;
let json_str = serde_json::to_string(&session).unwrap();
let restored: Session = serde_json::from_str(&json_str).unwrap();
assert_eq!(restored.id, session.id);
assert_eq!(restored.title, "Complex Session");
assert_eq!(restored.model, "claude-3-opus");
assert_eq!(restored.thinking_level, "comprehensive");
assert_eq!(
restored.system_prompt.as_ref().unwrap(),
"Custom system prompt"
);
assert_eq!(restored.created_at, session.created_at);
assert_eq!(restored.updated_at, session.updated_at);
assert_eq!(restored.total_input_tokens, 1500);
assert_eq!(restored.total_output_tokens, 2500);
assert_eq!(restored.session_cost, 0.75);
assert_eq!(restored.api_messages.len(), 3);
assert_eq!(restored.api_messages[0]["role"], "user");
assert_eq!(restored.api_messages[0]["content"], "First message");
assert_eq!(restored.api_messages[1]["role"], "assistant");
assert_eq!(restored.api_messages[2]["content"], "Second message");
}
#[test]
fn test_session_info_from_session_with_messages() {
let mut session = Session::new("gpt-3.5-turbo", "normal", None);
session.api_messages.push(std::sync::Arc::new(
json!({"role": "user", "content": "message 1"}),
));
session.api_messages.push(std::sync::Arc::new(
json!({"role": "assistant", "content": "response 1"}),
));
session.api_messages.push(std::sync::Arc::new(
json!({"role": "user", "content": "message 2"}),
));
let info = session.info();
assert_eq!(info.message_count, 3);
assert_eq!(info.id, session.id);
assert_eq!(info.model, "gpt-3.5-turbo");
}
#[test]
fn test_session_auto_title_truncation() {
let mut session = Session::new("gpt-4", "brief", None);
let long_content = "a".repeat(200);
session.api_messages.push(std::sync::Arc::new(json!({
"role": "user",
"content": long_content
})));
session.auto_title();
assert_eq!(session.title.len(), 80);
assert_eq!(session.title, "a".repeat(80));
}
#[test]
fn test_session_auto_title_skips_non_user_messages() {
let mut session = Session::new("gpt-4", "brief", None);
session.api_messages.push(std::sync::Arc::new(json!({
"role": "assistant",
"content": "This should be ignored for auto title"
})));
session.auto_title();
assert_eq!(session.title, "");
session.api_messages.push(std::sync::Arc::new(json!({
"role": "system",
"content": "System message should also be ignored"
})));
session.auto_title();
assert_eq!(session.title, "");
}
#[test]
fn test_session_new_generates_unique_ids() {
let session1 = Session::new("gpt-4", "brief", None);
let session2 = Session::new("gpt-4", "brief", None);
assert_ne!(session1.id, session2.id);
assert!(!session1.id.is_empty());
assert!(!session2.id.is_empty());
}
#[test]
fn test_session_new_timestamps() {
let before = Utc::now();
let session = Session::new("gpt-4", "brief", None);
let after = Utc::now();
let created_diff = (session.created_at - before).num_seconds().abs();
let updated_diff = (session.updated_at - before).num_seconds().abs();
assert!(
created_diff <= 2,
"created_at should be within 2 seconds of now"
);
assert!(
updated_diff <= 2,
"updated_at should be within 2 seconds of now"
);
assert_eq!(session.created_at, session.updated_at);
assert!(session.created_at <= after);
assert!(session.updated_at <= after);
}
#[test]
fn test_validate_name() {
assert!(validate_name("work").is_ok());
assert!(validate_name("my-project-2").is_ok());
assert!(validate_name("a").is_ok());
assert!(validate_name(&"a".repeat(40)).is_ok());
assert!(validate_name("").is_err());
assert!(validate_name(&"a".repeat(41)).is_err());
assert!(validate_name("UPPER").is_err());
assert!(validate_name("has space").is_err());
assert!(validate_name("under_score").is_err());
assert!(validate_name("dots.bad").is_err());
let err = validate_name("Bad").unwrap_err();
assert!(err.contains("Bad"));
assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
}
#[test]
fn test_clear_name() {
let mut s = Session::new("m", "brief", None);
s.name = Some("foo".into());
s.clear_name();
assert_eq!(s.name, None);
}
#[test]
fn ultracode_serialization_and_compaction_roundtrip_is_distinct() {
let original = Session::new("anthropic/claude-fable-5", "ultracode", None);
let json = serde_json::to_string(&original).unwrap();
let restored: Session = serde_json::from_str(&json).unwrap();
assert_eq!(restored.thinking_level, "ultracode");
for other in ["ultra", "max", "xhigh"] {
assert_ne!(restored.thinking_level, other);
}
let compacted =
Session::from_compaction_record(&restored, "summary", test_record(&restored));
let resumed: Session =
serde_json::from_str(&serde_json::to_string(&compacted).unwrap()).unwrap();
assert_eq!(resumed.thinking_level, "ultracode");
assert_eq!(
resumed.parent_session.as_deref(),
Some(original.id.as_str())
);
}
#[cfg(unix)]
mod private_modes {
use super::*;
use crate::core::private_fs::test_support::UmaskGuard;
use serial_test::serial;
use std::os::unix::fs::PermissionsExt;
fn mode_of(path: &std::path::Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
#[serial(umask)]
fn save_creates_0600_session_file_and_0700_dir_under_permissive_umask() {
let _umask = UmaskGuard::set(0);
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("sessions");
save_json_in_dir(&dir, "sess-1", b"{}").unwrap();
assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
assert_eq!(
mode_of(&dir.join("sess-1.json")),
0o600,
"session file must be 0600"
);
}
#[test]
fn save_refuses_symlink_target() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("sessions");
std::fs::create_dir_all(&dir).unwrap();
let victim = tmp.path().join("victim.json");
std::fs::write(&victim, "original").unwrap();
std::os::unix::fs::symlink(&victim, dir.join("sess-1.json")).unwrap();
let res = save_json_in_dir(&dir, "sess-1", b"{}");
assert!(res.is_err(), "save onto a symlink must fail");
assert_eq!(
std::fs::read_to_string(&victim).unwrap(),
"original",
"no bytes may be written through the planted symlink"
);
}
}
mod header_message_count {
use super::*;
use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
use std::sync::Arc;
fn session_with(n: usize) -> Session {
let mut s = Session::new("claude-sonnet-4-6", "medium", None);
for i in 0..n {
let role = if i % 2 == 0 { "user" } else { "assistant" };
s.api_messages.push(Arc::new(
serde_json::json!({"role": role, "content": format!("m{i}")}),
));
}
s
}
#[test]
fn json_save_persists_count_in_header() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("sessions");
let s = session_with(7);
save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
let header = read_session_header(&dir, &format!("{}.json", s.id)).unwrap();
assert!(
!header.contains("\"api_messages\""),
"header must stop before the message array"
);
let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
assert_eq!(info.message_count, 7);
}
#[test]
fn journal_append_updates_count() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("sessions");
let mut s = session_with(2);
save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
s.api_messages.push(Arc::new(
serde_json::json!({"role": "user", "content": "more"}),
));
s.updated_at = Utc::now();
save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
assert_eq!(info.message_count, 3);
}
#[test]
fn listing_does_not_overlay_stale_generation_metadata() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().canonicalize().unwrap().join("sessions");
let mut s = session_with(2);
save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
s.api_messages
.push(Arc::new(serde_json::json!({"role":"user","content":"old"})));
s.session_cost = 999.0;
s.updated_at += chrono::Duration::hours(1);
save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
let journal_path = dir.join(format!("{}.journal", s.id));
let stale = std::fs::read(&journal_path).unwrap();
s.api_messages.truncate(1);
s.session_cost = 1.0;
s.updated_at -= chrono::Duration::hours(2);
save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
std::fs::write(journal_path, stale).unwrap();
let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
assert_eq!(info.message_count, 1);
assert_eq!(info.session_cost, 1.0);
assert_eq!(info.updated_at, s.updated_at);
}
#[test]
fn legacy_header_defaults_to_zero() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("sessions");
let s = session_with(3);
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"message_count\":0,"));
let legacy = json.replace("\"message_count\":0,", "");
save_json_in_dir(&dir, &s.id, legacy.as_bytes()).unwrap();
let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
assert_eq!(info.message_count, 0);
}
}
use serial_test::serial;
fn save_test_session(dir: &std::path::Path, session: &Session) {
crate::core::session_journal::save_session_in_dir(
dir,
session,
crate::core::session_journal::SessionPersistence::default(),
)
.unwrap();
}
struct BaseDirGuard(Option<String>);
impl BaseDirGuard {
fn set(path: &std::path::Path) -> Self {
let old = std::env::var("SYNAPS_BASE_DIR").ok();
std::env::set_var("SYNAPS_BASE_DIR", path);
Self(old)
}
}
impl Drop for BaseDirGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("SYNAPS_BASE_DIR", value),
None => std::env::remove_var("SYNAPS_BASE_DIR"),
}
}
}
#[test]
#[serial]
fn follow_chain_no_compaction_is_identity() {
let dir = tempfile::tempdir().unwrap();
let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
std::fs::create_dir_all(dir.path().join(".synaps-cli/sessions")).unwrap();
let s = Session::new("m", "brief", None);
save_test_session(&dir.path().join(".synaps-cli/sessions"), &s);
let resolved = follow_compaction_chain(s.clone()).unwrap();
assert_eq!(resolved.session.id, s.id);
assert!(resolved.compaction_notice.is_none());
}
#[test]
#[serial]
fn follow_chain_of_three() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join(".synaps-cli/sessions");
let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
std::fs::create_dir_all(&sessions_dir).unwrap();
let mut a = Session::new("m", "brief", None);
a.id = "aaa".into();
let mut b = Session::new("m", "brief", None);
b.id = "bbb".into();
b.parent_session = Some("aaa".into());
let mut c = Session::new("m", "brief", None);
c.id = "ccc".into();
c.parent_session = Some("bbb".into());
a.compacted_into = Some("bbb".into());
b.compacted_into = Some("ccc".into());
save_test_session(&sessions_dir, &a);
save_test_session(&sessions_dir, &b);
save_test_session(&sessions_dir, &c);
let resolved = follow_compaction_chain(a).unwrap();
assert_eq!(resolved.session.id, "ccc");
let notice = resolved.compaction_notice.unwrap();
assert!(notice.contains("aaa"), "notice mentions original: {notice}");
assert!(notice.contains("ccc"), "notice mentions final: {notice}");
}
#[test]
#[serial]
fn follow_chain_cycle_guard() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join(".synaps-cli/sessions");
let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
std::fs::create_dir_all(&sessions_dir).unwrap();
let mut a = Session::new("m", "brief", None);
a.id = "aaa".into();
let mut b = Session::new("m", "brief", None);
b.id = "bbb".into();
a.compacted_into = Some("bbb".into());
b.compacted_into = Some("aaa".into());
save_test_session(&sessions_dir, &a);
save_test_session(&sessions_dir, &b);
let err = follow_compaction_chain(a).unwrap_err();
assert!(
err.to_string().contains("cycle"),
"error mentions cycle: {}",
err
);
}
#[test]
#[serial]
fn follow_chain_missing_successor_error() {
let dir = tempfile::tempdir().unwrap();
let sessions_dir = dir.path().join(".synaps-cli/sessions");
let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
std::fs::create_dir_all(&sessions_dir).unwrap();
let mut a = Session::new("m", "brief", None);
a.id = "aaa".into();
a.compacted_into = Some("nonexistent".into());
save_test_session(&sessions_dir, &a);
let err = follow_compaction_chain(a).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("compacted into nonexistent") && msg.contains("failed to load"),
"error names the missing successor: {msg}"
);
}
}