use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use uuid::Uuid;
use crate::event::{AgentEvent, EventSink};
use crate::llm::ToolSpec;
use crate::message::Message;
const SESSION_SCHEMA_VERSION: u32 = 1;
#[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)?;
}
std::fs::write(path, json)
}
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_default();
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)
}
fn filesystem_safe_timestamp() -> String {
chrono_lite_now().replace(':', "-")
}
fn chrono_lite_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let day = secs / 86_400;
let sec_of_day = secs % 86_400;
let (h, m, s) = (sec_of_day / 3600, (sec_of_day / 60) % 60, sec_of_day % 60);
let (y, mo, d) = epoch_day_to_ymd(day as i64);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
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)
}
#[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: None,
}
}
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,
}
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;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
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,
pub status: String,
#[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: String,
pub messages: Vec<TranscriptEntry>,
pub message_count: u64,
}
impl ExportedTranscript {
pub fn from_session_dir(session_dir: &Path) -> std::io::Result<Self> {
let meta = SessionReader::load_meta(session_dir)?;
let entries = SessionReader::load_transcript(session_dir)?;
Ok(Self {
version: 1,
session_id: meta.session_id,
model: meta.model,
goal: meta.goal,
created_at: meta.created_at,
status: meta.status,
messages: entries.clone(),
message_count: entries.len() as u64,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptEntry {
#[serde(default)]
pub uuid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_uuid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_tool_assistant_uuid: Option<String>,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<crate::llm::ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<UsageMeta>,
pub timestamp: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit: Option<crate::tools::AuditMeta>,
}
#[derive(Debug, Clone)]
pub struct OrphanToolCall {
pub assistant_msg_id: String,
pub tool_call_id: String,
pub tool_name: String,
pub args_hash: String,
pub side_effect_at_call: crate::tools::ToolSideEffect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CompactBoundaryEntry {
#[serde(rename = "type")]
entry_type: String,
subtype: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
turn: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
summary_uuid: Option<String>,
timestamp: String,
}
pub fn entry_to_message(entry: TranscriptEntry) -> Message {
use crate::message::Role;
let role = match entry.role.as_str() {
"system" => Role::System,
"user" => Role::User,
"assistant" => Role::Assistant,
"tool" => Role::Tool,
_ => Role::User,
};
Message {
role,
content: entry.content,
tool_calls: entry.tool_calls,
tool_call_id: entry.tool_call_id,
reasoning_content: entry.reasoning_content,
}
}
pub struct SessionWriter {
session_id: String,
session_dir: PathBuf,
writer: BufWriter<std::fs::File>,
message_count: u64,
last_uuid: Option<String>,
cumulative_usage: UsageMeta,
first_prompt: Option<String>,
last_prompt: Option<String>,
_lock: Option<SessionLock>,
name: Option<String>,
}
impl SessionWriter {
pub fn create(
workspace: &Path,
goal: &str,
model: &str,
provider: &str,
) -> std::io::Result<Self> {
Self::create_with_tools(workspace, goal, model, provider, &[], None)
}
pub fn create_with_tools(
workspace: &Path,
goal: &str,
model: &str,
provider: &str,
tool_specs: &[ToolSpec],
preset: Option<&str>,
) -> std::io::Result<Self> {
let slug = workspace_slug(workspace);
let session_id = format!("{}-{}", filesystem_safe_timestamp(), slug);
let sessions_root = crate::paths::user_sessions_dir(workspace)
.map_err(|e| std::io::Error::other(format!("user_sessions_dir: {e}")))?;
let session_dir = sessions_root.join(&slug).join(&session_id);
std::fs::create_dir_all(&session_dir)?;
let lock = SessionLock::acquire(&session_dir)?;
let jsonl_path = session_dir.join("transcript.jsonl");
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&jsonl_path)?;
let now = chrono_lite_now();
let tool_registry_hash = if tool_specs.is_empty() {
None
} else {
Some(hash_tool_specs(tool_specs))
};
let meta = SessionMeta {
session_id: session_id.clone(),
goal: goal.to_string(),
model: model.to_string(),
provider: provider.to_string(),
created_at: now.clone(),
updated_at: now.clone(),
message_count: 0,
status: "active".to_string(),
tool_registry_hash,
first_prompt: None,
last_prompt: None,
cost: None,
preset: preset.map(|s| s.to_string()),
name: None,
};
let meta_path = session_dir.join(".meta.json");
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&meta_path, meta_json)?;
Ok(Self {
session_id,
session_dir,
writer: BufWriter::new(file),
message_count: 0,
last_uuid: None,
cumulative_usage: UsageMeta::default(),
first_prompt: None,
last_prompt: None,
_lock: Some(lock),
name: None,
})
}
pub fn open_existing(session_dir: &Path) -> std::io::Result<Self> {
let meta = SessionReader::load_meta(session_dir)?;
let lock = SessionLock::acquire(session_dir)?;
let jsonl_path = session_dir.join("transcript.jsonl");
let last_uuid = read_last_message_uuid(&jsonl_path);
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&jsonl_path)?;
Ok(Self {
session_id: meta.session_id,
session_dir: session_dir.to_path_buf(),
writer: BufWriter::new(file),
message_count: meta.message_count,
last_uuid,
cumulative_usage: UsageMeta::default(),
first_prompt: meta.first_prompt,
last_prompt: meta.last_prompt,
_lock: Some(lock),
name: meta.name,
})
}
pub fn append(
&mut self,
msg: &Message,
parent_uuid_override: Option<&str>,
usage: Option<&UsageMeta>,
) -> std::io::Result<String> {
self.append_with_audit(msg, None, parent_uuid_override, usage)
}
pub fn append_with_audit(
&mut self,
msg: &Message,
audit: Option<crate::tools::AuditMeta>,
parent_uuid_override: Option<&str>,
usage: Option<&UsageMeta>,
) -> std::io::Result<String> {
self.message_count += 1;
let msg_id = format!("msg_{:03}", self.message_count);
let parent_id = if self.message_count > 1 {
Some(format!("msg_{:03}", self.message_count - 1))
} else {
None
};
let new_uuid = Uuid::new_v4().to_string();
let parent_uuid = parent_uuid_override
.map(|s| s.to_string())
.or_else(|| self.last_uuid.clone());
let source_tool_assistant_uuid = if matches!(msg.role, crate::message::Role::Tool) {
parent_uuid.clone()
} else {
None
};
let role_str = match msg.role {
crate::message::Role::System => "system",
crate::message::Role::User => "user",
crate::message::Role::Assistant => "assistant",
crate::message::Role::Tool => "tool",
};
if matches!(msg.role, crate::message::Role::User) {
let prompt: String = msg.content.chars().take(200).collect();
if self.first_prompt.is_none() {
self.first_prompt = Some(prompt.clone());
}
self.last_prompt = Some(prompt);
}
if let Some(u) = usage {
self.cumulative_usage.accumulate(u);
}
let entry = TranscriptEntry {
uuid: new_uuid.clone(),
parent_uuid,
source_tool_assistant_uuid,
id: msg_id,
parent_id,
role: role_str.to_string(),
content: msg.content.clone(),
tool_calls: msg.tool_calls.clone(),
tool_call_id: msg.tool_call_id.clone(),
reasoning_content: msg.reasoning_content.clone(),
usage: usage.cloned(),
timestamp: chrono_lite_now(),
audit,
};
let line = serde_json::to_string(&entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
self.writer.write_all(line.as_bytes())?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
self.last_uuid = Some(new_uuid.clone());
if matches!(
msg.role,
crate::message::Role::User | crate::message::Role::Assistant
) {
let _ = self.bump_updated_at();
}
Ok(new_uuid)
}
pub fn write_compact_boundary(
&mut self,
turn: u32,
compacted_count: usize,
summary_uuid: Option<&str>,
) -> std::io::Result<()> {
let entry = CompactBoundaryEntry {
entry_type: "system".to_string(),
subtype: "compact_boundary".to_string(),
turn: Some(turn),
compacted_count: Some(compacted_count),
summary_uuid: summary_uuid.map(|s| s.to_string()),
timestamp: chrono_lite_now(),
};
let line = serde_json::to_string(&entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
self.writer.write_all(line.as_bytes())?;
self.writer.write_all(b"\n")?;
self.writer.flush()
}
fn bump_updated_at(&self) -> std::io::Result<()> {
let meta_path = self.session_dir.join(".meta.json");
let bytes = std::fs::read(&meta_path)?;
let mut meta: SessionMeta = serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
meta.updated_at = chrono_lite_now();
meta.message_count = self.message_count;
if self.first_prompt.is_some() {
meta.first_prompt = self.first_prompt.clone();
}
if self.last_prompt.is_some() {
meta.last_prompt = self.last_prompt.clone();
}
let json = serde_json::to_string_pretty(&meta)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&meta_path, json)
}
pub fn finish(&mut self, status: &str) -> std::io::Result<()> {
self.writer.flush()?;
let meta_path = self.session_dir.join(".meta.json");
let bytes = std::fs::read(&meta_path)?;
let mut meta: SessionMeta = serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
meta.updated_at = chrono_lite_now();
meta.message_count = self.message_count;
meta.status = status.to_string();
if self.first_prompt.is_some() {
meta.first_prompt = self.first_prompt.clone();
}
if self.last_prompt.is_some() {
meta.last_prompt = self.last_prompt.clone();
}
if !self.cumulative_usage.is_zero() {
let mut cost = meta.cost.take().unwrap_or_default();
cost.accumulate(&self.cumulative_usage);
meta.cost = Some(cost);
}
if self.name.is_some() {
meta.name = self.name.clone();
}
let meta_json = serde_json::to_string_pretty(&meta)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&meta_path, meta_json)
}
pub fn set_name(&mut self, name: impl Into<String>) {
self.name = Some(name.into());
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn session_dir(&self) -> &Path {
&self.session_dir
}
pub fn message_count(&self) -> u64 {
self.message_count
}
pub fn last_uuid(&self) -> Option<&str> {
self.last_uuid.as_deref()
}
}
fn read_last_message_uuid(jsonl_path: &Path) -> Option<String> {
let file = std::fs::File::open(jsonl_path).ok()?;
let reader = std::io::BufReader::new(file);
let mut last = None;
for line in reader.lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<TranscriptEntry>(&line) {
if !entry.uuid.is_empty() {
last = Some(entry.uuid);
}
}
}
last
}
pub fn truncate_transcript_to_turn(
session_dir: &Path,
cutoff_turn: usize,
) -> std::io::Result<TruncateStats> {
let jsonl_path = session_dir.join("transcript.jsonl");
if !jsonl_path.exists() {
return Ok(TruncateStats {
kept: 0,
dropped: 0,
});
}
let file = std::fs::File::open(&jsonl_path)?;
let reader = std::io::BufReader::new(file);
let tmp_path = jsonl_path.with_extension("jsonl.rewind-tmp");
let tmp = std::fs::File::create(&tmp_path)?;
let mut writer = BufWriter::new(tmp);
let mut user_seen = 0usize;
let mut kept = 0u64;
let mut dropped = 0u64;
let mut stop = false;
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if stop {
dropped += 1;
continue;
}
let role = serde_json::from_str::<serde_json::Value>(&line)
.ok()
.and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string));
let is_turn_boundary = matches!(role.as_deref(), Some("user"));
if is_turn_boundary {
if user_seen >= cutoff_turn {
stop = true;
dropped += 1;
continue;
}
user_seen += 1;
}
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
kept += 1;
}
writer.flush()?;
drop(writer);
std::fs::rename(&tmp_path, &jsonl_path)?;
let meta_path = session_dir.join(".meta.json");
if meta_path.exists() {
if let Ok(bytes) = std::fs::read(&meta_path) {
if let Ok(mut meta) = serde_json::from_slice::<SessionMeta>(&bytes) {
meta.message_count = kept;
meta.updated_at = chrono_lite_now();
if let Ok(json) = serde_json::to_string_pretty(&meta) {
let _ = std::fs::write(&meta_path, json);
}
}
}
}
Ok(TruncateStats { kept, dropped })
}
#[derive(Debug, Clone, Copy)]
pub struct TruncateStats {
pub kept: u64,
pub dropped: u64,
}
pub use crate::session_lock::{SessionLock, SessionLockBusy};
pub struct SessionReader;
impl SessionReader {
pub fn load_transcript(session_dir: &Path) -> std::io::Result<Vec<TranscriptEntry>> {
let jsonl_path = session_dir.join("transcript.jsonl");
let file = std::fs::File::open(&jsonl_path)?;
let reader = std::io::BufReader::new(file);
let mut all_entries: Vec<TranscriptEntry> = Vec::new();
let mut boundary_after: usize = 0;
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(sys) = serde_json::from_str::<CompactBoundaryEntry>(&line) {
if sys.entry_type == "system" && sys.subtype == "compact_boundary" {
boundary_after = all_entries.len();
continue;
}
}
match serde_json::from_str::<TranscriptEntry>(&line) {
Ok(entry) => all_entries.push(entry),
Err(e) => {
eprintln!(
"warning: skipping corrupt line in {}: {}",
jsonl_path.display(),
e
);
continue;
}
}
}
Ok(all_entries.split_off(boundary_after))
}
pub fn load_transcript_indexed(
session_dir: &Path,
) -> std::io::Result<(
Vec<TranscriptEntry>,
std::collections::HashMap<String, TranscriptEntry>,
)> {
let entries = Self::load_transcript(session_dir)?;
let mut index = std::collections::HashMap::with_capacity(entries.len());
for entry in &entries {
if !entry.uuid.is_empty() {
index.insert(entry.uuid.clone(), entry.clone());
}
}
Ok((entries, index))
}
pub fn load_messages(session_dir: &Path) -> std::io::Result<Vec<Message>> {
let entries = Self::load_transcript(session_dir)?;
Ok(entries.into_iter().map(entry_to_message).collect())
}
pub fn scan_orphan_tool_calls(
session_dir: &Path,
registry: &crate::tools::ToolRegistry,
) -> std::io::Result<Vec<OrphanToolCall>> {
let entries = Self::load_transcript(session_dir)?;
if entries.is_empty() {
return Ok(Vec::new());
}
let last_assistant = entries
.iter()
.enumerate()
.rev()
.find(|(_, e)| e.role == "assistant" && !e.tool_calls.is_empty());
let Some((asst_idx, asst_entry)) = last_assistant else {
return Ok(Vec::new());
};
let answered: std::collections::HashSet<String> = entries[asst_idx + 1..]
.iter()
.filter(|e| e.role == "tool")
.filter_map(|e| e.tool_call_id.clone())
.collect();
let mut orphans = Vec::new();
for tc in &asst_entry.tool_calls {
if !answered.contains(&tc.id) {
let side_effect = registry
.get(&tc.name)
.map(|t| t.side_effect_class())
.unwrap_or(crate::tools::ToolSideEffect::External);
let args_hash = {
let canonical = tc.arguments.to_string();
let hash = blake3::hash(canonical.as_bytes());
hash.to_hex().to_string()
};
orphans.push(OrphanToolCall {
assistant_msg_id: asst_entry.id.clone(),
tool_call_id: tc.id.clone(),
tool_name: tc.name.clone(),
args_hash,
side_effect_at_call: side_effect,
});
}
}
Ok(orphans)
}
pub fn load_meta(session_dir: &Path) -> std::io::Result<SessionMeta> {
let meta_path = session_dir.join(".meta.json");
let bytes = std::fs::read(&meta_path)?;
serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
let base = match crate::paths::user_sessions_dir(workspace) {
Ok(d) => d,
Err(_) => workspace.join(".recursive").join("sessions"),
};
if !base.is_dir() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(&base)? {
let entry = entry?;
let slug_dir = entry.path();
if !slug_dir.is_dir() {
continue;
}
for session_entry in std::fs::read_dir(&slug_dir)? {
let session_entry = session_entry?;
let session_dir = session_entry.path();
if session_dir.is_dir() && session_dir.join(".meta.json").is_file() {
sessions.push(session_dir);
}
}
}
sessions.sort();
Ok(sessions)
}
pub fn list_sessions_sorted_by_updated_at(
workspace: &Path,
) -> std::io::Result<Vec<(PathBuf, SessionMeta)>> {
let dirs = Self::list_sessions(workspace)?;
let mut entries: Vec<(PathBuf, SessionMeta, std::time::SystemTime)> = Vec::new();
for dir in dirs {
let meta = match Self::load_meta(&dir) {
Ok(m) => m,
Err(_) => continue,
};
let mtime = std::fs::metadata(dir.join("transcript.jsonl"))
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
entries.push((dir, meta, mtime));
}
entries.sort_by(|a, b| {
b.1.updated_at
.cmp(&a.1.updated_at)
.then_with(|| b.2.cmp(&a.2))
.then_with(|| a.1.session_id.cmp(&b.1.session_id))
});
Ok(entries.into_iter().map(|(p, m, _)| (p, m)).collect())
}
pub fn list_all_sessions(base: &Path) -> std::io::Result<Vec<PathBuf>> {
let sessions_dir = base.join(".recursive").join("sessions");
if !sessions_dir.is_dir() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let slug_dir = entry.path();
if !slug_dir.is_dir() {
continue;
}
for session_entry in std::fs::read_dir(&slug_dir)? {
let session_entry = session_entry?;
let session_dir = session_entry.path();
if session_dir.is_dir() && session_dir.join(".meta.json").is_file() {
sessions.push(session_dir);
}
}
}
sessions.sort();
Ok(sessions)
}
}
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| match c {
'/' | '\\' | ':' => '-',
c if c.is_control() => '-',
c => c,
})
.collect();
let s = s.trim_start_matches('-').to_string();
if s.len() > 80 {
crate::truncate_str(&s, 80).to_string()
} else {
s
}
}
pub struct SessionPersistenceSink {
writer: Arc<std::sync::Mutex<SessionWriter>>,
}
impl SessionPersistenceSink {
pub fn new(writer: Arc<std::sync::Mutex<SessionWriter>>) -> Self {
Self { writer }
}
}
#[async_trait::async_trait]
impl EventSink for SessionPersistenceSink {
async fn emit(&self, event: AgentEvent) {
match event {
AgentEvent::MessageAppended {
message,
parent_uuid,
usage,
} => {
let result = {
match self.writer.lock() {
Ok(mut w) => w.append_with_audit(
&message,
None,
parent_uuid.as_deref(),
usage.as_ref(),
),
Err(poisoned) => {
let mut w = poisoned.into_inner();
w.append_with_audit(
&message,
None,
parent_uuid.as_deref(),
usage.as_ref(),
)
}
}
};
if let Err(e) = result {
tracing::error!("session persistence: failed to append message: {e}");
}
}
AgentEvent::MessageAppendedWithAudit { message, audit } => {
let result = {
match self.writer.lock() {
Ok(mut w) => w.append_with_audit(&message, Some(audit), None, None),
Err(poisoned) => {
let mut w = poisoned.into_inner();
w.append_with_audit(&message, None, None, None)
}
}
};
if let Err(e) = result {
tracing::error!("session persistence: failed to append audited message: {e}");
}
}
AgentEvent::CompactionBoundary {
turn,
compacted_count,
summary_uuid,
} => {
let result = match self.writer.lock() {
Ok(mut w) => {
w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
}
Err(poisoned) => {
let mut w = poisoned.into_inner();
w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
}
};
if let Err(e) = result {
tracing::error!("session persistence: failed to write compact_boundary: {e}");
}
}
_ => {}
}
}
}
#[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_file".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type":"object"}),
},
ToolSpec {
name: "write_file".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_file".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_file".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_file".to_string(),
arguments: serde_json::json!({"path": "/tmp/foo.rs"}),
},
ToolCall {
id: "call_002".to_string(),
name: "write_file".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_file");
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_file");
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_file".into(),
description: "Read a file".into(),
parameters: serde_json::json!({"type":"object"}),
},
ToolSpec {
name: "write_file".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 session_writer_creates_meta_and_jsonl() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
let session_dir = writer.session_dir().to_path_buf();
assert!(session_dir.join("transcript.jsonl").is_file());
assert!(session_dir.join(".meta.json").is_file());
let meta = SessionReader::load_meta(&session_dir).unwrap();
assert_eq!(meta.goal, "test goal");
assert_eq!(meta.model, "gpt-4o");
assert_eq!(meta.provider, "openai");
assert_eq!(meta.message_count, 0);
assert_eq!(meta.status, "active");
writer.finish("completed").unwrap();
let meta = SessionReader::load_meta(&session_dir).unwrap();
assert_eq!(meta.message_count, 0);
assert_eq!(meta.status, "completed");
}
#[test]
fn session_writer_appends_lines() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "test", "gpt-4o", "openai").unwrap();
let id1 = writer.append(&Message::user("hello"), None, None).unwrap();
assert_eq!(id1.len(), 36, "uuid should be 36 chars");
let id2 = writer
.append(&Message::assistant("hi there"), None, None)
.unwrap();
assert_eq!(id2.len(), 36);
assert_ne!(id1, id2, "each message gets a unique uuid");
let session_dir = writer.session_dir().to_path_buf();
writer.finish("completed").unwrap();
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "msg_001");
assert_eq!(entries[0].parent_id, None);
assert_eq!(entries[0].parent_uuid, None, "root has no parent_uuid");
assert!(!entries[0].uuid.is_empty(), "uuid must be present");
assert_eq!(entries[0].role, "user");
assert_eq!(entries[0].content, "hello");
assert_eq!(entries[1].id, "msg_002");
assert_eq!(entries[1].parent_id, Some("msg_001".to_string()));
assert_eq!(
entries[1].parent_uuid,
Some(entries[0].uuid.clone()),
"parent_uuid points to first entry"
);
assert_eq!(entries[1].role, "assistant");
assert_eq!(entries[1].content, "hi there");
}
#[test]
fn session_reader_round_trips() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "round trip", "gpt-4o", "openai").unwrap();
writer
.append(&Message::system("You are a bot."), None, None)
.unwrap();
writer
.append(&Message::user("do something"), None, None)
.unwrap();
writer
.append(&Message::assistant("I will do it."), None, None)
.unwrap();
let session_dir = writer.session_dir().to_path_buf();
writer.finish("completed").unwrap();
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].role, "system");
assert_eq!(entries[1].role, "user");
assert_eq!(entries[2].role, "assistant");
}
#[test]
fn session_writer_finish_updates_meta() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "meta test", "gpt-4o", "openai").unwrap();
writer.append(&Message::user("msg1"), None, None).unwrap();
writer
.append(&Message::assistant("msg2"), None, None)
.unwrap();
let session_dir = writer.session_dir().to_path_buf();
writer.finish("completed").unwrap();
let meta = SessionReader::load_meta(&session_dir).unwrap();
assert_eq!(meta.message_count, 2);
assert_eq!(meta.status, "completed");
}
#[test]
fn session_meta_preserves_preset_field() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let (_session_dir, preset_after) = {
let mut writer = SessionWriter::create_with_tools(
ws,
"preset run",
"deepseek-chat",
"openai",
&[],
Some("deepseek"),
)
.unwrap();
let dir = writer.session_dir().to_path_buf();
writer.finish("completed").unwrap();
let meta = SessionReader::load_meta(&dir).unwrap();
(dir, meta.preset)
};
assert_eq!(preset_after.as_deref(), Some("deepseek"));
let writer2 =
SessionWriter::create_with_tools(ws, "no preset run", "gpt-4o", "openai", &[], None)
.unwrap();
let session_dir2 = writer2.session_dir().to_path_buf();
drop(writer2);
let meta2 = SessionReader::load_meta(&session_dir2).unwrap();
assert!(meta2.preset.is_none());
let raw = std::fs::read_to_string(session_dir2.join(".meta.json")).unwrap();
assert!(
!raw.contains("\"preset\""),
"preset key should be absent when None, got: {raw}"
);
}
#[test]
fn list_sessions_finds_sessions() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let sessions = SessionReader::list_sessions(ws).unwrap();
assert!(sessions.is_empty());
let writer = SessionWriter::create(ws, "session1", "gpt-4o", "openai").unwrap();
let dir1 = writer.session_dir().to_path_buf();
drop(writer);
let sessions = SessionReader::list_sessions(ws).unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0], dir1);
}
#[test]
fn crash_partial_line_skipped() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "crash test", "gpt-4o", "openai").unwrap();
writer
.append(&Message::user("good line"), None, None)
.unwrap();
let session_dir = writer.session_dir().to_path_buf();
writer.finish("crashed").unwrap();
use std::io::Write;
let jsonl_path = session_dir.join("transcript.jsonl");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&jsonl_path)
.unwrap();
writeln!(f, "this is not json").unwrap();
drop(f);
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].content, "good line");
}
#[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 truncate_transcript_to_turn_drops_at_user_boundary() {
let dir = crate::test_util::IsolatedWorkspace::new();
let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
w.append(&Message::system("sys".to_string()), None, None)
.unwrap();
w.append(&Message::user("u0".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a0".to_string()), None, None)
.unwrap();
w.append(&Message::user("u1".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a1".to_string()), None, None)
.unwrap();
w.append(&Message::user("u2".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a2".to_string()), None, None)
.unwrap();
w.finish("done").unwrap();
let session_dir = w.session_dir().to_path_buf();
let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
assert_eq!(stats.kept, 3);
assert_eq!(stats.dropped, 4);
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].role, "system");
assert_eq!(entries[1].role, "user");
assert_eq!(entries[1].content, "u0");
assert_eq!(entries[2].role, "assistant");
assert_eq!(entries[2].content, "a0");
let meta = SessionReader::load_meta(&session_dir).unwrap();
assert_eq!(meta.message_count, 3);
}
#[test]
fn truncate_transcript_to_zero_drops_all_turns_keeps_system() {
let dir = crate::test_util::IsolatedWorkspace::new();
let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
w.append(&Message::system("sys".to_string()), None, None)
.unwrap();
w.append(&Message::user("u0".to_string()), None, None)
.unwrap();
w.append(&Message::assistant("a0".to_string()), None, None)
.unwrap();
w.finish("done").unwrap();
let session_dir = w.session_dir().to_path_buf();
let stats = truncate_transcript_to_turn(&session_dir, 0).unwrap();
assert_eq!(stats.kept, 1, "system message should remain");
assert_eq!(stats.dropped, 2);
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].role, "system");
}
#[test]
fn truncate_transcript_missing_file_is_noop() {
let dir = crate::test_util::IsolatedWorkspace::new();
let stats = truncate_transcript_to_turn(dir.path(), 5).unwrap();
assert_eq!(stats.kept, 0);
assert_eq!(stats.dropped, 0);
}
#[test]
fn load_messages_drops_persistence_fields() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "g151 test", "model", "openai").unwrap();
let session_dir = writer.session_dir().to_path_buf();
writer
.append(&Message::user("hello".to_string()), None, None)
.unwrap();
writer
.append(&Message::assistant("hi back".to_string()), None, None)
.unwrap();
writer.finish("success").unwrap();
drop(writer);
let msgs = SessionReader::load_messages(&session_dir).unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, Role::User);
assert_eq!(msgs[0].content, "hello");
assert_eq!(msgs[1].role, Role::Assistant);
assert_eq!(msgs[1].content, "hi back");
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "msg_001");
assert!(!entries[0].timestamp.is_empty());
}
#[test]
fn meta_round_trip_with_tool_registry_hash() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let specs = vec![ToolSpec {
name: "read_file".into(),
description: "Read".into(),
parameters: serde_json::json!({"type":"object"}),
}];
let writer =
SessionWriter::create_with_tools(ws, "with hash", "model", "openai", &specs, None)
.unwrap();
let session_dir = writer.session_dir().to_path_buf();
drop(writer);
let meta = SessionReader::load_meta(&session_dir).unwrap();
let hash = meta
.tool_registry_hash
.as_ref()
.expect("expected hash to be Some(_)");
assert_eq!(*hash, hash_tool_specs(&specs));
}
#[test]
fn meta_round_trip_old_format_no_hash() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp
.path()
.join(".recursive")
.join("sessions")
.join("legacy");
std::fs::create_dir_all(&session_dir).unwrap();
let raw = r#"{
"session_id": "legacy-id",
"goal": "old goal",
"model": "model",
"provider": "openai",
"created_at": "2020-01-01T00:00:00Z",
"updated_at": "2020-01-01T00:00:00Z",
"message_count": 0,
"status": "active"
}"#;
std::fs::write(session_dir.join(".meta.json"), raw).unwrap();
std::fs::write(session_dir.join("transcript.jsonl"), "").unwrap();
let meta = SessionReader::load_meta(&session_dir).unwrap();
assert_eq!(meta.session_id, "legacy-id");
assert!(meta.tool_registry_hash.is_none());
}
#[test]
fn append_bumps_updated_at() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "bump", "model", "openai").unwrap();
let session_dir = writer.session_dir().to_path_buf();
let meta_before = SessionReader::load_meta(&session_dir).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
writer
.append(&Message::user("ping".to_string()), None, None)
.unwrap();
let meta_after = SessionReader::load_meta(&session_dir).unwrap();
assert_ne!(
meta_before.updated_at, meta_after.updated_at,
"expected updated_at to advance after append; before={} after={}",
meta_before.updated_at, meta_after.updated_at
);
}
#[test]
fn open_existing_continues_msg_numbering() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let ws = tmp.path();
let mut writer = SessionWriter::create(ws, "resume-num", "model", "openai").unwrap();
let session_dir = writer.session_dir().to_path_buf();
writer
.append(&Message::user("u1".to_string()), None, None)
.unwrap();
writer
.append(&Message::assistant("a1".to_string()), None, None)
.unwrap();
writer
.append(&Message::user("u2".to_string()), None, None)
.unwrap();
drop(writer);
let mut writer2 = SessionWriter::open_existing(&session_dir).unwrap();
let id = writer2
.append(&Message::assistant("a2".to_string()), None, None)
.unwrap();
assert!(!id.is_empty(), "expected non-empty UUID from append");
drop(writer2);
let entries = SessionReader::load_transcript(&session_dir).unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].id, "msg_001");
assert_eq!(entries[1].id, "msg_002");
assert_eq!(entries[2].id, "msg_003");
assert_eq!(entries[3].id, "msg_004");
assert_eq!(entries[3].parent_id.as_deref(), Some("msg_003"));
}
#[test]
fn lock_alive_pid_blocks_acquire() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-A");
std::fs::create_dir_all(&session_dir).unwrap();
let lock = SessionLock::acquire(&session_dir).unwrap();
let err = SessionLock::acquire(&session_dir).expect_err("second acquire should fail");
assert!(
err.to_string()
.contains(&format!("pid {}", std::process::id())),
"expected error to mention our pid {}, got: {}",
std::process::id(),
err
);
drop(lock);
}
fn make_isolated_writer() -> (
crate::test_util::IsolatedWorkspace,
Arc<std::sync::Mutex<SessionWriter>>,
) {
let ws = crate::test_util::IsolatedWorkspace::new();
let writer = SessionWriter::create(ws.path(), "test goal", "gpt-4o", "openai").unwrap();
(ws, Arc::new(std::sync::Mutex::new(writer)))
}
#[tokio::test]
async fn message_appended_round_trips_through_sink() {
use crate::event::{AgentEvent, EventSink};
use crate::llm::ToolCall as LlmToolCall;
let (_ws, sw) = make_isolated_writer();
let sink = SessionPersistenceSink::new(sw.clone());
let tc = LlmToolCall {
id: "call_1".into(),
name: "my_tool".into(),
arguments: serde_json::json!({"x": 1}),
};
let msg = Message {
role: Role::Assistant,
content: "response text".into(),
tool_calls: vec![tc],
tool_call_id: None,
reasoning_content: Some("I thought about it".into()),
};
sink.emit(AgentEvent::MessageAppended {
message: msg.clone(),
parent_uuid: None,
usage: None,
})
.await;
sink.emit(AgentEvent::PlanConfirmed).await;
let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
drop(sink);
drop(sw);
let transcript = SessionReader::load_messages(&session_dir).unwrap();
assert_eq!(transcript.len(), 1, "exactly one message written");
let loaded = &transcript[0];
assert_eq!(loaded.content, "response text");
assert_eq!(
loaded.reasoning_content.as_deref(),
Some("I thought about it")
);
assert_eq!(loaded.tool_calls.len(), 1);
assert_eq!(loaded.tool_calls[0].name, "my_tool");
}
#[tokio::test]
async fn sink_recovers_from_poisoned_mutex() {
use crate::event::{AgentEvent, EventSink};
let (_ws, sw) = make_isolated_writer();
let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
let sw2 = sw.clone();
let _ = std::panic::catch_unwind(move || {
let _guard = sw2.lock().unwrap();
panic!("intentional poison");
});
assert!(sw.is_poisoned(), "mutex must be poisoned after the panic");
let sink = SessionPersistenceSink::new(sw);
sink.emit(AgentEvent::MessageAppended {
message: Message::user("after poison"),
parent_uuid: None,
usage: None,
})
.await;
let transcript = SessionReader::load_messages(&session_dir).unwrap();
assert_eq!(transcript.len(), 1);
assert_eq!(transcript[0].content, "after poison");
}
#[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 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}");
}
}