1use serde::{Deserialize, Serialize};
13use std::io::{BufRead, BufWriter, Write};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use uuid::Uuid;
17
18use crate::event::{AgentEvent, EventSink};
19use crate::llm::ToolSpec;
20use crate::message::Message;
21
22const SESSION_SCHEMA_VERSION: u32 = 1;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SessionFile {
29 pub schema_version: u32,
30 pub goal: String,
32 pub model: String,
34 pub provider: String,
36 pub tool_registry_hash: String,
39 pub steps_consumed: usize,
41 pub transcript: Vec<Message>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub preset: Option<String>,
48}
49
50impl SessionFile {
51 pub fn new(
53 goal: String,
54 model: String,
55 provider: String,
56 tool_specs: &[ToolSpec],
57 steps_consumed: usize,
58 transcript: Vec<Message>,
59 ) -> Self {
60 let tool_registry_hash = hash_tool_specs(tool_specs);
61 Self {
62 schema_version: SESSION_SCHEMA_VERSION,
63 goal,
64 model,
65 provider,
66 tool_registry_hash,
67 steps_consumed,
68 transcript,
69 preset: None,
70 }
71 }
72
73 pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
75 let json = serde_json::to_string_pretty(self)
76 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
77 if let Some(parent) = path.parent() {
78 std::fs::create_dir_all(parent)?;
79 }
80 std::fs::write(path, json)
81 }
82
83 pub fn read_from(path: &Path) -> std::io::Result<Self> {
85 let bytes = std::fs::read(path)?;
86 serde_json::from_slice(&bytes)
87 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
88 }
89
90 pub fn validate_tool_registry(&self, current_specs: &[ToolSpec]) -> Result<(), String> {
93 let current_hash = hash_tool_specs(current_specs);
94 if self.tool_registry_hash == current_hash {
95 Ok(())
96 } else {
97 Err(format!(
98 "tool registry hash mismatch: session has '{}', current is '{}'. \
99 Tools have changed since the session was saved; cannot resume.",
100 self.tool_registry_hash, current_hash
101 ))
102 }
103 }
104
105 pub fn messages(&self) -> &[Message] {
107 &self.transcript
108 }
109
110 pub fn into_transcript(self) -> Vec<Message> {
112 self.transcript
113 }
114}
115
116pub fn hash_tool_specs(specs: &[ToolSpec]) -> String {
127 use std::collections::BTreeMap;
128
129 let mut map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
130 for spec in specs {
131 let value = serde_json::json!({
132 "description": spec.description,
133 "parameters": spec.parameters,
134 });
135 map.insert(spec.name.clone(), value);
136 }
137 let canonical = serde_json::to_string(&map).unwrap_or_default();
138 let hash = blake3::hash(canonical.as_bytes());
139 hash.to_hex().to_string()
140}
141
142pub fn default_session_path(workspace: &Path, goal: &str) -> PathBuf {
145 let ts = filesystem_safe_timestamp();
146 let prefix: String = goal
148 .chars()
149 .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
150 .take(40)
151 .collect();
152 let prefix = if prefix.is_empty() {
153 "unnamed".to_string()
154 } else {
155 prefix
156 };
157 let dir = crate::paths::user_sessions_dir(workspace)
158 .unwrap_or_else(|_| workspace.join(".recursive").join("sessions"));
159 dir.join(format!("{}-{}.json", ts, prefix))
160}
161
162pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
164 let dir = match crate::paths::user_sessions_dir(workspace) {
165 Ok(d) => d,
166 Err(_) => workspace.join(".recursive").join("sessions"),
167 };
168 if !dir.is_dir() {
169 return Ok(Vec::new());
170 }
171 let mut sessions = Vec::new();
172 for entry in std::fs::read_dir(&dir)? {
173 let entry = entry?;
174 let path = entry.path();
175 if path.extension().and_then(|e| e.to_str()) == Some("json") {
176 sessions.push(path);
177 }
178 }
179 sessions.sort();
180 Ok(sessions)
181}
182
183fn filesystem_safe_timestamp() -> String {
186 chrono_lite_now().replace(':', "-")
187}
188
189fn chrono_lite_now() -> String {
192 use std::time::{SystemTime, UNIX_EPOCH};
193 let secs = SystemTime::now()
194 .duration_since(UNIX_EPOCH)
195 .map(|d| d.as_secs())
196 .unwrap_or(0);
197 let day = secs / 86_400;
198 let sec_of_day = secs % 86_400;
199 let (h, m, s) = (sec_of_day / 3600, (sec_of_day / 60) % 60, sec_of_day % 60);
200 let (y, mo, d) = epoch_day_to_ymd(day as i64);
201 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
202}
203
204fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
205 let z = z + 719_468;
206 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
207 let doe = (z - era * 146_097) as u64;
208 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
209 let y = yoe as i64 + era * 400;
210 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
211 let mp = (5 * doy + 2) / 153;
212 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
213 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
214 let y = if m <= 2 { y + 1 } else { y };
215 (y, m, d)
216}
217
218#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
227pub struct UsageMeta {
228 pub input_tokens: u32,
229 pub output_tokens: u32,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub cache_creation_tokens: Option<u32>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub cache_read_tokens: Option<u32>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub reasoning_tokens: Option<u32>,
239}
240
241impl UsageMeta {
242 pub fn accumulate(&mut self, other: &UsageMeta) {
244 self.input_tokens += other.input_tokens;
245 self.output_tokens += other.output_tokens;
246 self.cache_creation_tokens = Some(
247 self.cache_creation_tokens.unwrap_or(0) + other.cache_creation_tokens.unwrap_or(0),
248 );
249 self.cache_read_tokens =
250 Some(self.cache_read_tokens.unwrap_or(0) + other.cache_read_tokens.unwrap_or(0));
251 self.reasoning_tokens =
252 Some(self.reasoning_tokens.unwrap_or(0) + other.reasoning_tokens.unwrap_or(0));
253 }
254
255 pub fn from_token_usage(tu: &crate::llm::TokenUsage) -> Self {
257 UsageMeta {
258 input_tokens: tu.prompt_tokens,
259 output_tokens: tu.completion_tokens,
260 cache_creation_tokens: if tu.cache_miss_tokens > 0 {
261 Some(tu.cache_miss_tokens)
262 } else {
263 None
264 },
265 cache_read_tokens: if tu.cache_hit_tokens > 0 {
266 Some(tu.cache_hit_tokens)
267 } else {
268 None
269 },
270 reasoning_tokens: None,
271 }
272 }
273
274 pub fn is_zero(&self) -> bool {
276 self.input_tokens == 0 && self.output_tokens == 0
277 }
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct SessionCost {
283 pub total_input_tokens: u64,
284 pub total_output_tokens: u64,
285 #[serde(default)]
286 pub total_cache_creation_tokens: u64,
287 #[serde(default)]
288 pub total_cache_read_tokens: u64,
289}
290
291impl SessionCost {
292 pub fn accumulate(&mut self, usage: &UsageMeta) {
293 self.total_input_tokens += usage.input_tokens as u64;
294 self.total_output_tokens += usage.output_tokens as u64;
295 self.total_cache_creation_tokens += usage.cache_creation_tokens.unwrap_or(0) as u64;
296 self.total_cache_read_tokens += usage.cache_read_tokens.unwrap_or(0) as u64;
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SessionMeta {
303 pub session_id: String,
304 pub goal: String,
305 pub model: String,
306 pub provider: String,
307 pub created_at: String,
308 pub updated_at: String,
309 pub message_count: u64,
310 pub status: String,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub tool_registry_hash: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub first_prompt: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub last_prompt: Option<String>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub cost: Option<SessionCost>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub preset: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub name: Option<String>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ExportedTranscript {
342 pub version: u32,
343 pub session_id: String,
344 pub model: String,
345 pub goal: String,
346 pub created_at: String,
347 pub status: String,
348 pub messages: Vec<TranscriptEntry>,
349 pub message_count: u64,
350}
351
352impl ExportedTranscript {
353 pub fn from_session_dir(session_dir: &Path) -> std::io::Result<Self> {
355 let meta = SessionReader::load_meta(session_dir)?;
356 let entries = SessionReader::load_transcript(session_dir)?;
357 Ok(Self {
358 version: 1,
359 session_id: meta.session_id,
360 model: meta.model,
361 goal: meta.goal,
362 created_at: meta.created_at,
363 status: meta.status,
364 messages: entries.clone(),
365 message_count: entries.len() as u64,
366 })
367 }
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct TranscriptEntry {
373 #[serde(default)]
375 pub uuid: String,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub parent_uuid: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub source_tool_assistant_uuid: Option<String>,
384 pub id: String,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub parent_id: Option<String>,
387 pub role: String,
388 pub content: String,
389 #[serde(default, skip_serializing_if = "Vec::is_empty")]
390 pub tool_calls: Vec<crate::llm::ToolCall>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub tool_call_id: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub reasoning_content: Option<String>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub usage: Option<UsageMeta>,
398 pub timestamp: String,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub audit: Option<crate::tools::AuditMeta>,
404}
405
406#[derive(Debug, Clone)]
409pub struct OrphanToolCall {
410 pub assistant_msg_id: String,
412 pub tool_call_id: String,
415 pub tool_name: String,
417 pub args_hash: String,
419 pub side_effect_at_call: crate::tools::ToolSideEffect,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
428struct CompactBoundaryEntry {
429 #[serde(rename = "type")]
430 entry_type: String,
431 subtype: String,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 turn: Option<u32>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 compacted_count: Option<usize>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 summary_uuid: Option<String>,
438 timestamp: String,
439}
440
441pub fn entry_to_message(entry: TranscriptEntry) -> Message {
456 use crate::message::Role;
457 let role = match entry.role.as_str() {
458 "system" => Role::System,
459 "user" => Role::User,
460 "assistant" => Role::Assistant,
461 "tool" => Role::Tool,
462 _ => Role::User,
463 };
464 Message {
465 role,
466 content: entry.content,
467 tool_calls: entry.tool_calls,
468 tool_call_id: entry.tool_call_id,
469 reasoning_content: entry.reasoning_content,
470 }
471}
472
473pub struct SessionWriter {
479 session_id: String,
480 session_dir: PathBuf,
481 writer: BufWriter<std::fs::File>,
482 message_count: u64,
483 last_uuid: Option<String>,
486 cumulative_usage: UsageMeta,
488 first_prompt: Option<String>,
490 last_prompt: Option<String>,
492 _lock: Option<SessionLock>,
496 name: Option<String>,
498}
499
500impl SessionWriter {
501 pub fn create(
511 workspace: &Path,
512 goal: &str,
513 model: &str,
514 provider: &str,
515 ) -> std::io::Result<Self> {
516 Self::create_with_tools(workspace, goal, model, provider, &[], None)
517 }
518
519 pub fn create_with_tools(
534 workspace: &Path,
535 goal: &str,
536 model: &str,
537 provider: &str,
538 tool_specs: &[ToolSpec],
539 preset: Option<&str>,
540 ) -> std::io::Result<Self> {
541 let slug = workspace_slug(workspace);
542 let session_id = format!("{}-{}", filesystem_safe_timestamp(), slug);
543 let sessions_root = crate::paths::user_sessions_dir(workspace)
546 .map_err(|e| std::io::Error::other(format!("user_sessions_dir: {e}")))?;
547 let session_dir = sessions_root.join(&slug).join(&session_id);
548
549 std::fs::create_dir_all(&session_dir)?;
550
551 let lock = SessionLock::acquire(&session_dir)?;
555
556 let jsonl_path = session_dir.join("transcript.jsonl");
557 let file = std::fs::OpenOptions::new()
558 .create(true)
559 .append(true)
560 .open(&jsonl_path)?;
561
562 let now = chrono_lite_now();
563 let tool_registry_hash = if tool_specs.is_empty() {
564 None
565 } else {
566 Some(hash_tool_specs(tool_specs))
567 };
568 let meta = SessionMeta {
569 session_id: session_id.clone(),
570 goal: goal.to_string(),
571 model: model.to_string(),
572 provider: provider.to_string(),
573 created_at: now.clone(),
574 updated_at: now.clone(),
575 message_count: 0,
576 status: "active".to_string(),
577 tool_registry_hash,
578 first_prompt: None,
579 last_prompt: None,
580 cost: None,
581 preset: preset.map(|s| s.to_string()),
582 name: None,
583 };
584
585 let meta_path = session_dir.join(".meta.json");
587 let meta_json = serde_json::to_string_pretty(&meta)
588 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
589 std::fs::write(&meta_path, meta_json)?;
590
591 Ok(Self {
592 session_id,
593 session_dir,
594 writer: BufWriter::new(file),
595 message_count: 0,
596 last_uuid: None,
597 cumulative_usage: UsageMeta::default(),
598 first_prompt: None,
599 last_prompt: None,
600 _lock: Some(lock),
601 name: None,
602 })
603 }
604
605 pub fn open_existing(session_dir: &Path) -> std::io::Result<Self> {
621 let meta = SessionReader::load_meta(session_dir)?;
622
623 let lock = SessionLock::acquire(session_dir)?;
624
625 let jsonl_path = session_dir.join("transcript.jsonl");
626
627 let last_uuid = read_last_message_uuid(&jsonl_path);
630
631 let file = std::fs::OpenOptions::new()
632 .create(true)
633 .append(true)
634 .open(&jsonl_path)?;
635
636 Ok(Self {
637 session_id: meta.session_id,
638 session_dir: session_dir.to_path_buf(),
639 writer: BufWriter::new(file),
640 message_count: meta.message_count,
641 last_uuid,
642 cumulative_usage: UsageMeta::default(),
643 first_prompt: meta.first_prompt,
644 last_prompt: meta.last_prompt,
645 _lock: Some(lock),
646 name: meta.name,
647 })
648 }
649
650 pub fn append(
667 &mut self,
668 msg: &Message,
669 parent_uuid_override: Option<&str>,
670 usage: Option<&UsageMeta>,
671 ) -> std::io::Result<String> {
672 self.append_with_audit(msg, None, parent_uuid_override, usage)
673 }
674
675 pub fn append_with_audit(
678 &mut self,
679 msg: &Message,
680 audit: Option<crate::tools::AuditMeta>,
681 parent_uuid_override: Option<&str>,
682 usage: Option<&UsageMeta>,
683 ) -> std::io::Result<String> {
684 self.message_count += 1;
685 let msg_id = format!("msg_{:03}", self.message_count);
686
687 let parent_id = if self.message_count > 1 {
688 Some(format!("msg_{:03}", self.message_count - 1))
689 } else {
690 None
691 };
692
693 let new_uuid = Uuid::new_v4().to_string();
695 let parent_uuid = parent_uuid_override
696 .map(|s| s.to_string())
697 .or_else(|| self.last_uuid.clone());
698
699 let source_tool_assistant_uuid = if matches!(msg.role, crate::message::Role::Tool) {
701 parent_uuid.clone()
703 } else {
704 None
705 };
706
707 let role_str = match msg.role {
708 crate::message::Role::System => "system",
709 crate::message::Role::User => "user",
710 crate::message::Role::Assistant => "assistant",
711 crate::message::Role::Tool => "tool",
712 };
713
714 if matches!(msg.role, crate::message::Role::User) {
716 let prompt: String = msg.content.chars().take(200).collect();
717 if self.first_prompt.is_none() {
718 self.first_prompt = Some(prompt.clone());
719 }
720 self.last_prompt = Some(prompt);
721 }
722
723 if let Some(u) = usage {
725 self.cumulative_usage.accumulate(u);
726 }
727
728 let entry = TranscriptEntry {
729 uuid: new_uuid.clone(),
730 parent_uuid,
731 source_tool_assistant_uuid,
732 id: msg_id,
733 parent_id,
734 role: role_str.to_string(),
735 content: msg.content.clone(),
736 tool_calls: msg.tool_calls.clone(),
737 tool_call_id: msg.tool_call_id.clone(),
738 reasoning_content: msg.reasoning_content.clone(),
739 usage: usage.cloned(),
740 timestamp: chrono_lite_now(),
741 audit,
742 };
743
744 let line = serde_json::to_string(&entry)
745 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
746 self.writer.write_all(line.as_bytes())?;
747 self.writer.write_all(b"\n")?;
748 self.writer.flush()?;
749
750 self.last_uuid = Some(new_uuid.clone());
752
753 if matches!(
758 msg.role,
759 crate::message::Role::User | crate::message::Role::Assistant
760 ) {
761 let _ = self.bump_updated_at();
762 }
763
764 Ok(new_uuid)
765 }
766
767 pub fn write_compact_boundary(
774 &mut self,
775 turn: u32,
776 compacted_count: usize,
777 summary_uuid: Option<&str>,
778 ) -> std::io::Result<()> {
779 let entry = CompactBoundaryEntry {
780 entry_type: "system".to_string(),
781 subtype: "compact_boundary".to_string(),
782 turn: Some(turn),
783 compacted_count: Some(compacted_count),
784 summary_uuid: summary_uuid.map(|s| s.to_string()),
785 timestamp: chrono_lite_now(),
786 };
787 let line = serde_json::to_string(&entry)
788 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
789 self.writer.write_all(line.as_bytes())?;
790 self.writer.write_all(b"\n")?;
791 self.writer.flush()
792 }
793
794 fn bump_updated_at(&self) -> std::io::Result<()> {
799 let meta_path = self.session_dir.join(".meta.json");
800 let bytes = std::fs::read(&meta_path)?;
801 let mut meta: SessionMeta = serde_json::from_slice(&bytes)
802 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
803 meta.updated_at = chrono_lite_now();
804 meta.message_count = self.message_count;
805 if self.first_prompt.is_some() {
808 meta.first_prompt = self.first_prompt.clone();
809 }
810 if self.last_prompt.is_some() {
811 meta.last_prompt = self.last_prompt.clone();
812 }
813 let json = serde_json::to_string_pretty(&meta)
814 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
815 std::fs::write(&meta_path, json)
816 }
817
818 pub fn finish(&mut self, status: &str) -> std::io::Result<()> {
821 self.writer.flush()?;
822
823 let meta_path = self.session_dir.join(".meta.json");
826 let bytes = std::fs::read(&meta_path)?;
827 let mut meta: SessionMeta = serde_json::from_slice(&bytes)
828 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
829 meta.updated_at = chrono_lite_now();
830 meta.message_count = self.message_count;
831 meta.status = status.to_string();
832 if self.first_prompt.is_some() {
834 meta.first_prompt = self.first_prompt.clone();
835 }
836 if self.last_prompt.is_some() {
837 meta.last_prompt = self.last_prompt.clone();
838 }
839 if !self.cumulative_usage.is_zero() {
841 let mut cost = meta.cost.take().unwrap_or_default();
842 cost.accumulate(&self.cumulative_usage);
843 meta.cost = Some(cost);
844 }
845 if self.name.is_some() {
847 meta.name = self.name.clone();
848 }
849
850 let meta_json = serde_json::to_string_pretty(&meta)
851 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
852 std::fs::write(&meta_path, meta_json)
853 }
854
855 pub fn set_name(&mut self, name: impl Into<String>) {
858 self.name = Some(name.into());
859 }
860
861 pub fn session_id(&self) -> &str {
863 &self.session_id
864 }
865
866 pub fn session_dir(&self) -> &Path {
868 &self.session_dir
869 }
870
871 pub fn message_count(&self) -> u64 {
873 self.message_count
874 }
875
876 pub fn last_uuid(&self) -> Option<&str> {
878 self.last_uuid.as_deref()
879 }
880}
881
882fn read_last_message_uuid(jsonl_path: &Path) -> Option<String> {
886 let file = std::fs::File::open(jsonl_path).ok()?;
887 let reader = std::io::BufReader::new(file);
888 let mut last = None;
889 for line in reader.lines().map_while(Result::ok) {
890 if line.trim().is_empty() {
891 continue;
892 }
893 if let Ok(entry) = serde_json::from_str::<TranscriptEntry>(&line) {
894 if !entry.uuid.is_empty() {
895 last = Some(entry.uuid);
896 }
897 }
898 }
899 last
900}
901
902pub fn truncate_transcript_to_turn(
913 session_dir: &Path,
914 cutoff_turn: usize,
915) -> std::io::Result<TruncateStats> {
916 let jsonl_path = session_dir.join("transcript.jsonl");
917 if !jsonl_path.exists() {
918 return Ok(TruncateStats {
919 kept: 0,
920 dropped: 0,
921 });
922 }
923
924 let file = std::fs::File::open(&jsonl_path)?;
926 let reader = std::io::BufReader::new(file);
927
928 let tmp_path = jsonl_path.with_extension("jsonl.rewind-tmp");
929 let tmp = std::fs::File::create(&tmp_path)?;
930 let mut writer = BufWriter::new(tmp);
931
932 let mut user_seen = 0usize;
933 let mut kept = 0u64;
934 let mut dropped = 0u64;
935 let mut stop = false;
936
937 for line in reader.lines() {
938 let line = line?;
939 if line.trim().is_empty() {
940 continue;
941 }
942
943 if stop {
944 dropped += 1;
945 continue;
946 }
947
948 let role = serde_json::from_str::<serde_json::Value>(&line)
950 .ok()
951 .and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string));
952
953 let is_turn_boundary = matches!(role.as_deref(), Some("user"));
954 if is_turn_boundary {
955 if user_seen >= cutoff_turn {
956 stop = true;
959 dropped += 1;
960 continue;
961 }
962 user_seen += 1;
963 }
964
965 writer.write_all(line.as_bytes())?;
966 writer.write_all(b"\n")?;
967 kept += 1;
968 }
969 writer.flush()?;
970 drop(writer);
971
972 std::fs::rename(&tmp_path, &jsonl_path)?;
973
974 let meta_path = session_dir.join(".meta.json");
976 if meta_path.exists() {
977 if let Ok(bytes) = std::fs::read(&meta_path) {
978 if let Ok(mut meta) = serde_json::from_slice::<SessionMeta>(&bytes) {
979 meta.message_count = kept;
980 meta.updated_at = chrono_lite_now();
981 if let Ok(json) = serde_json::to_string_pretty(&meta) {
982 let _ = std::fs::write(&meta_path, json);
983 }
984 }
985 }
986 }
987
988 Ok(TruncateStats { kept, dropped })
989}
990
991#[derive(Debug, Clone, Copy)]
993pub struct TruncateStats {
994 pub kept: u64,
995 pub dropped: u64,
996}
997
998pub use crate::session_lock::{SessionLock, SessionLockBusy};
1006
1007pub struct SessionReader;
1009
1010impl SessionReader {
1011 pub fn load_transcript(session_dir: &Path) -> std::io::Result<Vec<TranscriptEntry>> {
1018 let jsonl_path = session_dir.join("transcript.jsonl");
1019 let file = std::fs::File::open(&jsonl_path)?;
1020 let reader = std::io::BufReader::new(file);
1021
1022 let mut all_entries: Vec<TranscriptEntry> = Vec::new();
1023 let mut boundary_after: usize = 0;
1025
1026 for line in reader.lines() {
1027 let line = line?;
1028 if line.trim().is_empty() {
1029 continue;
1030 }
1031 if let Ok(sys) = serde_json::from_str::<CompactBoundaryEntry>(&line) {
1034 if sys.entry_type == "system" && sys.subtype == "compact_boundary" {
1035 boundary_after = all_entries.len();
1037 continue;
1038 }
1039 }
1040 match serde_json::from_str::<TranscriptEntry>(&line) {
1041 Ok(entry) => all_entries.push(entry),
1042 Err(e) => {
1043 eprintln!(
1045 "warning: skipping corrupt line in {}: {}",
1046 jsonl_path.display(),
1047 e
1048 );
1049 continue;
1050 }
1051 }
1052 }
1053 Ok(all_entries.split_off(boundary_after))
1055 }
1056
1057 pub fn load_transcript_indexed(
1060 session_dir: &Path,
1061 ) -> std::io::Result<(
1062 Vec<TranscriptEntry>,
1063 std::collections::HashMap<String, TranscriptEntry>,
1064 )> {
1065 let entries = Self::load_transcript(session_dir)?;
1066 let mut index = std::collections::HashMap::with_capacity(entries.len());
1067 for entry in &entries {
1068 if !entry.uuid.is_empty() {
1069 index.insert(entry.uuid.clone(), entry.clone());
1070 }
1071 }
1072 Ok((entries, index))
1073 }
1074
1075 pub fn load_messages(session_dir: &Path) -> std::io::Result<Vec<Message>> {
1085 let entries = Self::load_transcript(session_dir)?;
1086 Ok(entries.into_iter().map(entry_to_message).collect())
1087 }
1088
1089 pub fn scan_orphan_tool_calls(
1103 session_dir: &Path,
1104 registry: &crate::tools::ToolRegistry,
1105 ) -> std::io::Result<Vec<OrphanToolCall>> {
1106 let entries = Self::load_transcript(session_dir)?;
1107 if entries.is_empty() {
1108 return Ok(Vec::new());
1109 }
1110
1111 let last_assistant = entries
1113 .iter()
1114 .enumerate()
1115 .rev()
1116 .find(|(_, e)| e.role == "assistant" && !e.tool_calls.is_empty());
1117
1118 let Some((asst_idx, asst_entry)) = last_assistant else {
1119 return Ok(Vec::new());
1120 };
1121
1122 let answered: std::collections::HashSet<String> = entries[asst_idx + 1..]
1125 .iter()
1126 .filter(|e| e.role == "tool")
1127 .filter_map(|e| e.tool_call_id.clone())
1128 .collect();
1129
1130 let mut orphans = Vec::new();
1131 for tc in &asst_entry.tool_calls {
1132 if !answered.contains(&tc.id) {
1133 let side_effect = registry
1134 .get(&tc.name)
1135 .map(|t| t.side_effect_class())
1136 .unwrap_or(crate::tools::ToolSideEffect::External);
1137 let args_hash = {
1138 let canonical = tc.arguments.to_string();
1139 let hash = blake3::hash(canonical.as_bytes());
1140 hash.to_hex().to_string()
1141 };
1142 orphans.push(OrphanToolCall {
1143 assistant_msg_id: asst_entry.id.clone(),
1144 tool_call_id: tc.id.clone(),
1145 tool_name: tc.name.clone(),
1146 args_hash,
1147 side_effect_at_call: side_effect,
1148 });
1149 }
1150 }
1151 Ok(orphans)
1152 }
1153
1154 pub fn load_meta(session_dir: &Path) -> std::io::Result<SessionMeta> {
1156 let meta_path = session_dir.join(".meta.json");
1157 let bytes = std::fs::read(&meta_path)?;
1158 serde_json::from_slice(&bytes)
1159 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
1160 }
1161
1162 pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
1167 let base = match crate::paths::user_sessions_dir(workspace) {
1168 Ok(d) => d,
1169 Err(_) => workspace.join(".recursive").join("sessions"),
1170 };
1171 if !base.is_dir() {
1172 return Ok(Vec::new());
1173 }
1174
1175 let mut sessions = Vec::new();
1176 for entry in std::fs::read_dir(&base)? {
1178 let entry = entry?;
1179 let slug_dir = entry.path();
1180 if !slug_dir.is_dir() {
1181 continue;
1182 }
1183 for session_entry in std::fs::read_dir(&slug_dir)? {
1185 let session_entry = session_entry?;
1186 let session_dir = session_entry.path();
1187 if session_dir.is_dir() && session_dir.join(".meta.json").is_file() {
1188 sessions.push(session_dir);
1189 }
1190 }
1191 }
1192 sessions.sort();
1193 Ok(sessions)
1194 }
1195
1196 pub fn list_sessions_sorted_by_updated_at(
1208 workspace: &Path,
1209 ) -> std::io::Result<Vec<(PathBuf, SessionMeta)>> {
1210 let dirs = Self::list_sessions(workspace)?;
1211
1212 let mut entries: Vec<(PathBuf, SessionMeta, std::time::SystemTime)> = Vec::new();
1213 for dir in dirs {
1214 let meta = match Self::load_meta(&dir) {
1215 Ok(m) => m,
1216 Err(_) => continue,
1217 };
1218 let mtime = std::fs::metadata(dir.join("transcript.jsonl"))
1221 .and_then(|m| m.modified())
1222 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1223 entries.push((dir, meta, mtime));
1224 }
1225
1226 entries.sort_by(|a, b| {
1227 b.1.updated_at
1230 .cmp(&a.1.updated_at)
1231 .then_with(|| b.2.cmp(&a.2))
1233 .then_with(|| a.1.session_id.cmp(&b.1.session_id))
1235 });
1236
1237 Ok(entries.into_iter().map(|(p, m, _)| (p, m)).collect())
1238 }
1239
1240 pub fn list_all_sessions(base: &Path) -> std::io::Result<Vec<PathBuf>> {
1242 let sessions_dir = base.join(".recursive").join("sessions");
1243 if !sessions_dir.is_dir() {
1244 return Ok(Vec::new());
1245 }
1246
1247 let mut sessions = Vec::new();
1248 for entry in std::fs::read_dir(&sessions_dir)? {
1249 let entry = entry?;
1250 let slug_dir = entry.path();
1251 if !slug_dir.is_dir() {
1252 continue;
1253 }
1254 for session_entry in std::fs::read_dir(&slug_dir)? {
1255 let session_entry = session_entry?;
1256 let session_dir = session_entry.path();
1257 if session_dir.is_dir() && session_dir.join(".meta.json").is_file() {
1258 sessions.push(session_dir);
1259 }
1260 }
1261 }
1262 sessions.sort();
1263 Ok(sessions)
1264 }
1265}
1266
1267fn workspace_slug(workspace: &Path) -> String {
1273 let abs = if workspace.is_absolute() {
1274 workspace.to_path_buf()
1275 } else {
1276 std::env::current_dir().unwrap_or_default().join(workspace)
1277 };
1278
1279 let s: String = abs
1280 .to_string_lossy()
1281 .chars()
1282 .map(|c| match c {
1283 '/' | '\\' | ':' => '-',
1284 c if c.is_control() => '-',
1285 c => c,
1286 })
1287 .collect();
1288 let s = s.trim_start_matches('-').to_string();
1290 if s.len() > 80 {
1292 crate::truncate_str(&s, 80).to_string()
1293 } else {
1294 s
1295 }
1296}
1297
1298pub struct SessionPersistenceSink {
1321 writer: Arc<std::sync::Mutex<SessionWriter>>,
1322}
1323
1324impl SessionPersistenceSink {
1325 pub fn new(writer: Arc<std::sync::Mutex<SessionWriter>>) -> Self {
1327 Self { writer }
1328 }
1329}
1330
1331#[async_trait::async_trait]
1332impl EventSink for SessionPersistenceSink {
1333 async fn emit(&self, event: AgentEvent) {
1334 match event {
1335 AgentEvent::MessageAppended {
1336 message,
1337 parent_uuid,
1338 usage,
1339 } => {
1340 let result = {
1341 match self.writer.lock() {
1342 Ok(mut w) => w.append_with_audit(
1343 &message,
1344 None,
1345 parent_uuid.as_deref(),
1346 usage.as_ref(),
1347 ),
1348 Err(poisoned) => {
1349 let mut w = poisoned.into_inner();
1350 w.append_with_audit(
1351 &message,
1352 None,
1353 parent_uuid.as_deref(),
1354 usage.as_ref(),
1355 )
1356 }
1357 }
1358 };
1359 if let Err(e) = result {
1360 tracing::error!("session persistence: failed to append message: {e}");
1361 }
1362 }
1363 AgentEvent::MessageAppendedWithAudit { message, audit } => {
1364 let result = {
1366 match self.writer.lock() {
1367 Ok(mut w) => w.append_with_audit(&message, Some(audit), None, None),
1368 Err(poisoned) => {
1369 let mut w = poisoned.into_inner();
1370 w.append_with_audit(&message, None, None, None)
1371 }
1372 }
1373 };
1374 if let Err(e) = result {
1375 tracing::error!("session persistence: failed to append audited message: {e}");
1376 }
1377 }
1378 AgentEvent::CompactionBoundary {
1379 turn,
1380 compacted_count,
1381 summary_uuid,
1382 } => {
1383 let result = match self.writer.lock() {
1386 Ok(mut w) => {
1387 w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
1388 }
1389 Err(poisoned) => {
1390 let mut w = poisoned.into_inner();
1391 w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
1392 }
1393 };
1394 if let Err(e) = result {
1395 tracing::error!("session persistence: failed to write compact_boundary: {e}");
1396 }
1397 }
1398 _ => {}
1399 }
1400 }
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use super::*;
1406 use crate::message::{Message, Role};
1407
1408 #[test]
1409 fn session_round_trip() {
1410 let goal = "fix the bug".to_string();
1411 let model = "gpt-4o-mini".to_string();
1412 let provider = "openai".to_string();
1413 let tool_specs = vec![
1414 ToolSpec {
1415 name: "read_file".into(),
1416 description: "Read a file".into(),
1417 parameters: serde_json::json!({"type":"object"}),
1418 },
1419 ToolSpec {
1420 name: "write_file".into(),
1421 description: "Write a file".into(),
1422 parameters: serde_json::json!({"type":"object"}),
1423 },
1424 ];
1425 let transcript = vec![
1426 Message::system("You are a helpful assistant.".to_string()),
1427 Message::user("fix the bug".to_string()),
1428 Message::assistant("Let me look at the code.".to_string()),
1429 ];
1430
1431 let session = SessionFile::new(
1432 goal.clone(),
1433 model.clone(),
1434 provider.clone(),
1435 &tool_specs,
1436 2,
1437 transcript.clone(),
1438 );
1439
1440 let tmp = tempfile::NamedTempFile::new().unwrap();
1441 session.write_to(tmp.path()).unwrap();
1442
1443 let restored = SessionFile::read_from(tmp.path()).unwrap();
1444 assert_eq!(restored.schema_version, SESSION_SCHEMA_VERSION);
1445 assert_eq!(restored.goal, goal);
1446 assert_eq!(restored.model, model);
1447 assert_eq!(restored.provider, provider);
1448 assert_eq!(restored.steps_consumed, 2);
1449 assert_eq!(restored.transcript.len(), 3);
1450 assert_eq!(
1451 restored.transcript[0].content,
1452 "You are a helpful assistant."
1453 );
1454 assert_eq!(restored.transcript[1].content, "fix the bug");
1455 assert_eq!(restored.transcript[2].content, "Let me look at the code.");
1456 }
1457
1458 #[test]
1459 fn resume_validates_tool_registry_hash() {
1460 let tool_specs = vec![ToolSpec {
1461 name: "read_file".into(),
1462 description: "Read a file".into(),
1463 parameters: serde_json::json!({"type":"object"}),
1464 }];
1465 let session = SessionFile::new(
1466 "test".into(),
1467 "model".into(),
1468 "provider".into(),
1469 &tool_specs,
1470 0,
1471 vec![],
1472 );
1473
1474 assert!(session.validate_tool_registry(&tool_specs).is_ok());
1476
1477 let different_specs = vec![ToolSpec {
1479 name: "write_file".into(),
1480 description: "Write a file".into(),
1481 parameters: serde_json::json!({"type":"object"}),
1482 }];
1483 assert!(session.validate_tool_registry(&different_specs).is_err());
1484 }
1485
1486 #[test]
1487 #[cfg_attr(target_os = "windows", ignore)]
1488 fn session_list_finds_files_in_workspace() {
1489 let tmp = crate::test_util::IsolatedWorkspace::new();
1490 let ws = tmp.path();
1491
1492 let sessions = list_sessions(ws).unwrap();
1494 assert!(sessions.is_empty());
1495
1496 let session = SessionFile::new(
1498 "test".into(),
1499 "model".into(),
1500 "provider".into(),
1501 &[],
1502 0,
1503 vec![],
1504 );
1505 let path = default_session_path(ws, "test");
1506 session.write_to(&path).unwrap();
1507
1508 let sessions = list_sessions(ws).unwrap();
1509 assert_eq!(sessions.len(), 1);
1510 assert_eq!(
1511 sessions[0].extension().and_then(|e| e.to_str()),
1512 Some("json")
1513 );
1514 }
1515
1516 #[test]
1517 fn resume_continues_from_seeded_transcript() {
1518 let transcript = vec![
1519 Message::system("sys".to_string()),
1520 Message::user("original goal".to_string()),
1521 Message::assistant("partial work".to_string()),
1522 ];
1523 let session = SessionFile::new(
1524 "original goal".into(),
1525 "model".into(),
1526 "provider".into(),
1527 &[],
1528 1,
1529 transcript.clone(),
1530 );
1531
1532 assert_eq!(session.messages().len(), 3);
1534 assert_eq!(session.messages()[0].content, "sys");
1535 assert_eq!(session.messages()[1].content, "original goal");
1536 assert_eq!(session.messages()[2].content, "partial work");
1537
1538 let restored = session.into_transcript();
1540 assert_eq!(restored.len(), 3);
1541 }
1542
1543 #[test]
1544 fn round_trip_with_tool_calls() {
1545 use crate::llm::ToolCall;
1546
1547 let tool_calls = vec![
1548 ToolCall {
1549 id: "call_001".to_string(),
1550 name: "read_file".to_string(),
1551 arguments: serde_json::json!({"path": "/tmp/foo.rs"}),
1552 },
1553 ToolCall {
1554 id: "call_002".to_string(),
1555 name: "write_file".to_string(),
1556 arguments: serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"}),
1557 },
1558 ];
1559
1560 let transcript = vec![
1561 Message::system("You are an agent.".to_string()),
1562 Message::user("refactor the code".to_string()),
1563 Message::assistant_with_tool_calls(
1564 "I'll read the file first.".to_string(),
1565 tool_calls.clone(),
1566 ),
1567 Message::tool_result("call_001", "fn main() { println!(\"hello\"); }"),
1568 ];
1569
1570 let session = SessionFile::new(
1571 "refactor".into(),
1572 "gpt-4o".into(),
1573 "openai".into(),
1574 &[],
1575 3,
1576 transcript,
1577 );
1578
1579 let tmp = tempfile::NamedTempFile::new().unwrap();
1580 session.write_to(tmp.path()).unwrap();
1581
1582 let restored = SessionFile::read_from(tmp.path()).unwrap();
1583 assert_eq!(restored.transcript.len(), 4);
1584
1585 let assistant_msg = &restored.transcript[2];
1587 assert_eq!(assistant_msg.role, Role::Assistant);
1588 assert_eq!(assistant_msg.content, "I'll read the file first.");
1589 assert_eq!(assistant_msg.tool_calls.len(), 2);
1590 assert_eq!(assistant_msg.tool_calls[0].id, "call_001");
1591 assert_eq!(assistant_msg.tool_calls[0].name, "read_file");
1592 assert_eq!(
1593 assistant_msg.tool_calls[0].arguments,
1594 serde_json::json!({"path": "/tmp/foo.rs"})
1595 );
1596 assert_eq!(assistant_msg.tool_calls[1].id, "call_002");
1597 assert_eq!(assistant_msg.tool_calls[1].name, "write_file");
1598 assert_eq!(
1599 assistant_msg.tool_calls[1].arguments,
1600 serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"})
1601 );
1602
1603 let tool_msg = &restored.transcript[3];
1605 assert_eq!(tool_msg.role, Role::Tool);
1606 assert_eq!(tool_msg.tool_call_id, Some("call_001".to_string()));
1607 assert_eq!(tool_msg.content, "fn main() { println!(\"hello\"); }");
1608 }
1609
1610 #[test]
1611 fn read_from_nonexistent_file() {
1612 let tmp = crate::test_util::IsolatedWorkspace::new();
1613 let bogus_path = tmp.path().join("does_not_exist.json");
1614
1615 let result = SessionFile::read_from(&bogus_path);
1616 assert!(result.is_err());
1617 let err = result.unwrap_err();
1618 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
1619 }
1620
1621 #[test]
1622 fn read_from_corrupt_json() {
1623 let tmp = tempfile::NamedTempFile::new().unwrap();
1624 std::fs::write(tmp.path(), "this is not valid json {{{garbage").unwrap();
1625
1626 let result = SessionFile::read_from(tmp.path());
1627 assert!(result.is_err());
1628 let err = result.unwrap_err();
1629 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1630 }
1631
1632 #[test]
1633 fn validate_tool_registry_mismatch() {
1634 let original_specs = vec![
1635 ToolSpec {
1636 name: "read_file".into(),
1637 description: "Read a file".into(),
1638 parameters: serde_json::json!({"type":"object"}),
1639 },
1640 ToolSpec {
1641 name: "write_file".into(),
1642 description: "Write a file".into(),
1643 parameters: serde_json::json!({"type":"object"}),
1644 },
1645 ];
1646
1647 let session = SessionFile::new(
1648 "test".into(),
1649 "model".into(),
1650 "provider".into(),
1651 &original_specs,
1652 0,
1653 vec![],
1654 );
1655
1656 let different_specs = vec![ToolSpec {
1658 name: "execute_command".into(),
1659 description: "Run a shell command".into(),
1660 parameters: serde_json::json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
1661 }];
1662
1663 let result = session.validate_tool_registry(&different_specs);
1664 assert!(result.is_err());
1665 let err_msg = result.unwrap_err();
1666 assert!(
1667 err_msg.contains("mismatch"),
1668 "Expected error to contain 'mismatch', got: {err_msg}"
1669 );
1670 }
1671
1672 #[test]
1673 fn default_session_path_sanitizes_special_chars() {
1674 let tmp = crate::test_util::IsolatedWorkspace::new();
1675 let ws = tmp.path();
1676
1677 let goal = "fix bug/issue #42 — with spëcial chars™ 日本語";
1679 let path = default_session_path(ws, goal);
1680
1681 let filename = path.file_stem().unwrap().to_str().unwrap();
1683
1684 let goal_suffix = filename
1689 .find("Z-")
1690 .map(|i| &filename[i + 2..])
1691 .expect("filename should contain Z- separator between timestamp and goal");
1692
1693 for ch in goal_suffix.chars() {
1695 assert!(
1696 ch.is_alphanumeric() || ch == '_' || ch == '-',
1697 "Unexpected character '{}' (U+{:04X}) in goal suffix: {}",
1698 ch,
1699 ch as u32,
1700 goal_suffix
1701 );
1702 }
1703
1704 assert!(!goal_suffix.contains(' '));
1706 assert!(!goal_suffix.contains('/'));
1707 assert!(!goal_suffix.contains('#'));
1708 assert!(!goal_suffix.contains('™'));
1709 assert!(!goal_suffix.contains('—'));
1710
1711 let parent = path.parent().expect("session path has parent");
1714 assert_eq!(
1715 parent.file_name().and_then(|n| n.to_str()),
1716 Some("sessions"),
1717 "expected sessions dir as parent, got {}",
1718 path.display()
1719 );
1720 assert_eq!(path.extension().and_then(|e| e.to_str()), Some("json"));
1722 }
1723
1724 #[test]
1729 fn session_writer_creates_meta_and_jsonl() {
1730 let tmp = crate::test_util::IsolatedWorkspace::new();
1731 let ws = tmp.path();
1732
1733 let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
1734
1735 let session_dir = writer.session_dir().to_path_buf();
1736 assert!(session_dir.join("transcript.jsonl").is_file());
1737 assert!(session_dir.join(".meta.json").is_file());
1738
1739 let meta = SessionReader::load_meta(&session_dir).unwrap();
1741 assert_eq!(meta.goal, "test goal");
1742 assert_eq!(meta.model, "gpt-4o");
1743 assert_eq!(meta.provider, "openai");
1744 assert_eq!(meta.message_count, 0);
1745 assert_eq!(meta.status, "active");
1746
1747 writer.finish("completed").unwrap();
1748
1749 let meta = SessionReader::load_meta(&session_dir).unwrap();
1750 assert_eq!(meta.message_count, 0);
1751 assert_eq!(meta.status, "completed");
1752 }
1753
1754 #[test]
1755 fn session_writer_appends_lines() {
1756 let tmp = crate::test_util::IsolatedWorkspace::new();
1757 let ws = tmp.path();
1758
1759 let mut writer = SessionWriter::create(ws, "test", "gpt-4o", "openai").unwrap();
1760
1761 let id1 = writer.append(&Message::user("hello"), None, None).unwrap();
1762 assert_eq!(id1.len(), 36, "uuid should be 36 chars");
1764
1765 let id2 = writer
1766 .append(&Message::assistant("hi there"), None, None)
1767 .unwrap();
1768 assert_eq!(id2.len(), 36);
1769 assert_ne!(id1, id2, "each message gets a unique uuid");
1770
1771 let session_dir = writer.session_dir().to_path_buf();
1772 writer.finish("completed").unwrap();
1773
1774 let entries = SessionReader::load_transcript(&session_dir).unwrap();
1776 assert_eq!(entries.len(), 2);
1777 assert_eq!(entries[0].id, "msg_001");
1778 assert_eq!(entries[0].parent_id, None);
1779 assert_eq!(entries[0].parent_uuid, None, "root has no parent_uuid");
1780 assert!(!entries[0].uuid.is_empty(), "uuid must be present");
1781 assert_eq!(entries[0].role, "user");
1782 assert_eq!(entries[0].content, "hello");
1783
1784 assert_eq!(entries[1].id, "msg_002");
1785 assert_eq!(entries[1].parent_id, Some("msg_001".to_string()));
1786 assert_eq!(
1787 entries[1].parent_uuid,
1788 Some(entries[0].uuid.clone()),
1789 "parent_uuid points to first entry"
1790 );
1791 assert_eq!(entries[1].role, "assistant");
1792 assert_eq!(entries[1].content, "hi there");
1793 }
1794
1795 #[test]
1796 fn session_reader_round_trips() {
1797 let tmp = crate::test_util::IsolatedWorkspace::new();
1798 let ws = tmp.path();
1799
1800 let mut writer = SessionWriter::create(ws, "round trip", "gpt-4o", "openai").unwrap();
1801
1802 writer
1803 .append(&Message::system("You are a bot."), None, None)
1804 .unwrap();
1805 writer
1806 .append(&Message::user("do something"), None, None)
1807 .unwrap();
1808 writer
1809 .append(&Message::assistant("I will do it."), None, None)
1810 .unwrap();
1811
1812 let session_dir = writer.session_dir().to_path_buf();
1813 writer.finish("completed").unwrap();
1814
1815 let entries = SessionReader::load_transcript(&session_dir).unwrap();
1816 assert_eq!(entries.len(), 3);
1817 assert_eq!(entries[0].role, "system");
1818 assert_eq!(entries[1].role, "user");
1819 assert_eq!(entries[2].role, "assistant");
1820 }
1821
1822 #[test]
1823 fn session_writer_finish_updates_meta() {
1824 let tmp = crate::test_util::IsolatedWorkspace::new();
1825 let ws = tmp.path();
1826
1827 let mut writer = SessionWriter::create(ws, "meta test", "gpt-4o", "openai").unwrap();
1828 writer.append(&Message::user("msg1"), None, None).unwrap();
1829 writer
1830 .append(&Message::assistant("msg2"), None, None)
1831 .unwrap();
1832 let session_dir = writer.session_dir().to_path_buf();
1833 writer.finish("completed").unwrap();
1834
1835 let meta = SessionReader::load_meta(&session_dir).unwrap();
1836 assert_eq!(meta.message_count, 2);
1837 assert_eq!(meta.status, "completed");
1838 }
1839
1840 #[test]
1848 fn session_meta_preserves_preset_field() {
1849 let tmp = crate::test_util::IsolatedWorkspace::new();
1850 let ws = tmp.path();
1851
1852 let (_session_dir, preset_after) = {
1858 let mut writer = SessionWriter::create_with_tools(
1859 ws,
1860 "preset run",
1861 "deepseek-chat",
1862 "openai",
1863 &[],
1864 Some("deepseek"),
1865 )
1866 .unwrap();
1867 let dir = writer.session_dir().to_path_buf();
1868 writer.finish("completed").unwrap();
1869 let meta = SessionReader::load_meta(&dir).unwrap();
1870 (dir, meta.preset)
1871 };
1872 assert_eq!(preset_after.as_deref(), Some("deepseek"));
1873
1874 let writer2 =
1877 SessionWriter::create_with_tools(ws, "no preset run", "gpt-4o", "openai", &[], None)
1878 .unwrap();
1879 let session_dir2 = writer2.session_dir().to_path_buf();
1880 drop(writer2);
1881 let meta2 = SessionReader::load_meta(&session_dir2).unwrap();
1882 assert!(meta2.preset.is_none());
1883 let raw = std::fs::read_to_string(session_dir2.join(".meta.json")).unwrap();
1884 assert!(
1885 !raw.contains("\"preset\""),
1886 "preset key should be absent when None, got: {raw}"
1887 );
1888 }
1889
1890 #[test]
1891 fn list_sessions_finds_sessions() {
1892 let tmp = crate::test_util::IsolatedWorkspace::new();
1893 let ws = tmp.path();
1894
1895 let sessions = SessionReader::list_sessions(ws).unwrap();
1897 assert!(sessions.is_empty());
1898
1899 let writer = SessionWriter::create(ws, "session1", "gpt-4o", "openai").unwrap();
1901 let dir1 = writer.session_dir().to_path_buf();
1902 drop(writer);
1903
1904 let sessions = SessionReader::list_sessions(ws).unwrap();
1905 assert_eq!(sessions.len(), 1);
1906 assert_eq!(sessions[0], dir1);
1907 }
1908
1909 #[test]
1910 fn crash_partial_line_skipped() {
1911 let tmp = crate::test_util::IsolatedWorkspace::new();
1912 let ws = tmp.path();
1913
1914 let mut writer = SessionWriter::create(ws, "crash test", "gpt-4o", "openai").unwrap();
1915 writer
1916 .append(&Message::user("good line"), None, None)
1917 .unwrap();
1918 let session_dir = writer.session_dir().to_path_buf();
1919 writer.finish("crashed").unwrap();
1920
1921 use std::io::Write;
1923 let jsonl_path = session_dir.join("transcript.jsonl");
1924 let mut f = std::fs::OpenOptions::new()
1925 .append(true)
1926 .open(&jsonl_path)
1927 .unwrap();
1928 writeln!(f, "this is not json").unwrap();
1929 drop(f);
1930
1931 let entries = SessionReader::load_transcript(&session_dir).unwrap();
1933 assert_eq!(entries.len(), 1);
1934 assert_eq!(entries[0].content, "good line");
1935 }
1936
1937 #[test]
1938 fn filesystem_safe_timestamp_has_no_colons() {
1939 let ts = filesystem_safe_timestamp();
1940 assert!(!ts.contains(':'));
1941 assert!(ts.ends_with('Z'));
1942 }
1943
1944 #[test]
1945 fn workspace_slug_matches_expected() {
1946 let p = Path::new("/home/user/projects/my-app");
1948 let slug = workspace_slug(p);
1949 assert!(!slug.starts_with('-'));
1950 assert!(slug.contains("home-user-projects-my-app"));
1951 assert!(slug.len() <= 80);
1952
1953 let p = Path::new(".");
1955 let slug = workspace_slug(p);
1956 assert!(!slug.is_empty());
1957 assert!(slug.len() <= 80);
1958 }
1959
1960 #[test]
1961 fn truncate_transcript_to_turn_drops_at_user_boundary() {
1962 let dir = crate::test_util::IsolatedWorkspace::new();
1963 let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
1964 w.append(&Message::system("sys".to_string()), None, None)
1967 .unwrap();
1968 w.append(&Message::user("u0".to_string()), None, None)
1969 .unwrap();
1970 w.append(&Message::assistant("a0".to_string()), None, None)
1971 .unwrap();
1972 w.append(&Message::user("u1".to_string()), None, None)
1973 .unwrap();
1974 w.append(&Message::assistant("a1".to_string()), None, None)
1975 .unwrap();
1976 w.append(&Message::user("u2".to_string()), None, None)
1977 .unwrap();
1978 w.append(&Message::assistant("a2".to_string()), None, None)
1979 .unwrap();
1980 w.finish("done").unwrap();
1981
1982 let session_dir = w.session_dir().to_path_buf();
1983
1984 let stats = truncate_transcript_to_turn(&session_dir, 1).unwrap();
1986 assert_eq!(stats.kept, 3);
1987 assert_eq!(stats.dropped, 4);
1988
1989 let entries = SessionReader::load_transcript(&session_dir).unwrap();
1990 assert_eq!(entries.len(), 3);
1991 assert_eq!(entries[0].role, "system");
1992 assert_eq!(entries[1].role, "user");
1993 assert_eq!(entries[1].content, "u0");
1994 assert_eq!(entries[2].role, "assistant");
1995 assert_eq!(entries[2].content, "a0");
1996
1997 let meta = SessionReader::load_meta(&session_dir).unwrap();
1999 assert_eq!(meta.message_count, 3);
2000 }
2001
2002 #[test]
2003 fn truncate_transcript_to_zero_drops_all_turns_keeps_system() {
2004 let dir = crate::test_util::IsolatedWorkspace::new();
2005 let mut w = SessionWriter::create(dir.path(), "g", "m", "p").unwrap();
2006 w.append(&Message::system("sys".to_string()), None, None)
2007 .unwrap();
2008 w.append(&Message::user("u0".to_string()), None, None)
2009 .unwrap();
2010 w.append(&Message::assistant("a0".to_string()), None, None)
2011 .unwrap();
2012 w.finish("done").unwrap();
2013 let session_dir = w.session_dir().to_path_buf();
2014
2015 let stats = truncate_transcript_to_turn(&session_dir, 0).unwrap();
2016 assert_eq!(stats.kept, 1, "system message should remain");
2017 assert_eq!(stats.dropped, 2);
2018
2019 let entries = SessionReader::load_transcript(&session_dir).unwrap();
2020 assert_eq!(entries.len(), 1);
2021 assert_eq!(entries[0].role, "system");
2022 }
2023
2024 #[test]
2025 fn truncate_transcript_missing_file_is_noop() {
2026 let dir = crate::test_util::IsolatedWorkspace::new();
2027 let stats = truncate_transcript_to_turn(dir.path(), 5).unwrap();
2029 assert_eq!(stats.kept, 0);
2030 assert_eq!(stats.dropped, 0);
2031 }
2032
2033 #[test]
2038 fn load_messages_drops_persistence_fields() {
2039 let tmp = crate::test_util::IsolatedWorkspace::new();
2040 let ws = tmp.path();
2041 let mut writer = SessionWriter::create(ws, "g151 test", "model", "openai").unwrap();
2042 let session_dir = writer.session_dir().to_path_buf();
2043
2044 writer
2045 .append(&Message::user("hello".to_string()), None, None)
2046 .unwrap();
2047 writer
2048 .append(&Message::assistant("hi back".to_string()), None, None)
2049 .unwrap();
2050 writer.finish("success").unwrap();
2051 drop(writer);
2052
2053 let msgs = SessionReader::load_messages(&session_dir).unwrap();
2055 assert_eq!(msgs.len(), 2);
2056 assert_eq!(msgs[0].role, Role::User);
2057 assert_eq!(msgs[0].content, "hello");
2058 assert_eq!(msgs[1].role, Role::Assistant);
2059 assert_eq!(msgs[1].content, "hi back");
2060
2061 let entries = SessionReader::load_transcript(&session_dir).unwrap();
2064 assert_eq!(entries.len(), 2);
2065 assert_eq!(entries[0].id, "msg_001");
2066 assert!(!entries[0].timestamp.is_empty());
2067 }
2068
2069 #[test]
2070 fn meta_round_trip_with_tool_registry_hash() {
2071 let tmp = crate::test_util::IsolatedWorkspace::new();
2072 let ws = tmp.path();
2073
2074 let specs = vec![ToolSpec {
2075 name: "read_file".into(),
2076 description: "Read".into(),
2077 parameters: serde_json::json!({"type":"object"}),
2078 }];
2079 let writer =
2080 SessionWriter::create_with_tools(ws, "with hash", "model", "openai", &specs, None)
2081 .unwrap();
2082 let session_dir = writer.session_dir().to_path_buf();
2083 drop(writer);
2084
2085 let meta = SessionReader::load_meta(&session_dir).unwrap();
2086 let hash = meta
2087 .tool_registry_hash
2088 .as_ref()
2089 .expect("expected hash to be Some(_)");
2090 assert_eq!(*hash, hash_tool_specs(&specs));
2091 }
2092
2093 #[test]
2094 fn meta_round_trip_old_format_no_hash() {
2095 let tmp = crate::test_util::IsolatedWorkspace::new();
2099 let session_dir = tmp
2100 .path()
2101 .join(".recursive")
2102 .join("sessions")
2103 .join("legacy");
2104 std::fs::create_dir_all(&session_dir).unwrap();
2105
2106 let raw = r#"{
2107 "session_id": "legacy-id",
2108 "goal": "old goal",
2109 "model": "model",
2110 "provider": "openai",
2111 "created_at": "2020-01-01T00:00:00Z",
2112 "updated_at": "2020-01-01T00:00:00Z",
2113 "message_count": 0,
2114 "status": "active"
2115}"#;
2116 std::fs::write(session_dir.join(".meta.json"), raw).unwrap();
2117 std::fs::write(session_dir.join("transcript.jsonl"), "").unwrap();
2118
2119 let meta = SessionReader::load_meta(&session_dir).unwrap();
2120 assert_eq!(meta.session_id, "legacy-id");
2121 assert!(meta.tool_registry_hash.is_none());
2122 }
2123
2124 #[test]
2125 fn append_bumps_updated_at() {
2126 let tmp = crate::test_util::IsolatedWorkspace::new();
2127 let ws = tmp.path();
2128 let mut writer = SessionWriter::create(ws, "bump", "model", "openai").unwrap();
2129 let session_dir = writer.session_dir().to_path_buf();
2130
2131 let meta_before = SessionReader::load_meta(&session_dir).unwrap();
2132 std::thread::sleep(std::time::Duration::from_millis(1100));
2136
2137 writer
2138 .append(&Message::user("ping".to_string()), None, None)
2139 .unwrap();
2140
2141 let meta_after = SessionReader::load_meta(&session_dir).unwrap();
2142 assert_ne!(
2143 meta_before.updated_at, meta_after.updated_at,
2144 "expected updated_at to advance after append; before={} after={}",
2145 meta_before.updated_at, meta_after.updated_at
2146 );
2147 }
2148
2149 #[test]
2150 fn open_existing_continues_msg_numbering() {
2151 let tmp = crate::test_util::IsolatedWorkspace::new();
2152 let ws = tmp.path();
2153 let mut writer = SessionWriter::create(ws, "resume-num", "model", "openai").unwrap();
2154 let session_dir = writer.session_dir().to_path_buf();
2155
2156 writer
2157 .append(&Message::user("u1".to_string()), None, None)
2158 .unwrap();
2159 writer
2160 .append(&Message::assistant("a1".to_string()), None, None)
2161 .unwrap();
2162 writer
2163 .append(&Message::user("u2".to_string()), None, None)
2164 .unwrap();
2165 drop(writer);
2168
2169 let mut writer2 = SessionWriter::open_existing(&session_dir).unwrap();
2171 let id = writer2
2172 .append(&Message::assistant("a2".to_string()), None, None)
2173 .unwrap();
2174 assert!(!id.is_empty(), "expected non-empty UUID from append");
2176 drop(writer2);
2177
2178 let entries = SessionReader::load_transcript(&session_dir).unwrap();
2179 assert_eq!(entries.len(), 4);
2180 assert_eq!(entries[0].id, "msg_001");
2181 assert_eq!(entries[1].id, "msg_002");
2182 assert_eq!(entries[2].id, "msg_003");
2183 assert_eq!(entries[3].id, "msg_004");
2184 assert_eq!(entries[3].parent_id.as_deref(), Some("msg_003"));
2185 }
2186
2187 #[test]
2188 fn lock_alive_pid_blocks_acquire() {
2189 let tmp = crate::test_util::IsolatedWorkspace::new();
2190 let session_dir = tmp.path().join("session-A");
2191 std::fs::create_dir_all(&session_dir).unwrap();
2192
2193 let lock = SessionLock::acquire(&session_dir).unwrap();
2195
2196 let err = SessionLock::acquire(&session_dir).expect_err("second acquire should fail");
2199 assert!(
2201 err.to_string()
2202 .contains(&format!("pid {}", std::process::id())),
2203 "expected error to mention our pid {}, got: {}",
2204 std::process::id(),
2205 err
2206 );
2207
2208 drop(lock);
2209 }
2210
2211 fn make_isolated_writer() -> (
2218 crate::test_util::IsolatedWorkspace,
2219 Arc<std::sync::Mutex<SessionWriter>>,
2220 ) {
2221 let ws = crate::test_util::IsolatedWorkspace::new();
2222 let writer = SessionWriter::create(ws.path(), "test goal", "gpt-4o", "openai").unwrap();
2223 (ws, Arc::new(std::sync::Mutex::new(writer)))
2224 }
2225
2226 #[tokio::test]
2229 async fn message_appended_round_trips_through_sink() {
2230 use crate::event::{AgentEvent, EventSink};
2231 use crate::llm::ToolCall as LlmToolCall;
2232
2233 let (_ws, sw) = make_isolated_writer();
2234 let sink = SessionPersistenceSink::new(sw.clone());
2235
2236 let tc = LlmToolCall {
2237 id: "call_1".into(),
2238 name: "my_tool".into(),
2239 arguments: serde_json::json!({"x": 1}),
2240 };
2241 let msg = Message {
2242 role: Role::Assistant,
2243 content: "response text".into(),
2244 tool_calls: vec![tc],
2245 tool_call_id: None,
2246 reasoning_content: Some("I thought about it".into()),
2247 };
2248 sink.emit(AgentEvent::MessageAppended {
2249 message: msg.clone(),
2250 parent_uuid: None,
2251 usage: None,
2252 })
2253 .await;
2254
2255 sink.emit(AgentEvent::PlanConfirmed).await;
2257
2258 let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
2259 drop(sink);
2260 drop(sw);
2261
2262 let transcript = SessionReader::load_messages(&session_dir).unwrap();
2263 assert_eq!(transcript.len(), 1, "exactly one message written");
2264 let loaded = &transcript[0];
2265 assert_eq!(loaded.content, "response text");
2266 assert_eq!(
2267 loaded.reasoning_content.as_deref(),
2268 Some("I thought about it")
2269 );
2270 assert_eq!(loaded.tool_calls.len(), 1);
2271 assert_eq!(loaded.tool_calls[0].name, "my_tool");
2272 }
2273
2274 #[tokio::test]
2277 async fn sink_recovers_from_poisoned_mutex() {
2278 use crate::event::{AgentEvent, EventSink};
2279
2280 let (_ws, sw) = make_isolated_writer();
2281 let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
2282
2283 let sw2 = sw.clone();
2285 let _ = std::panic::catch_unwind(move || {
2286 let _guard = sw2.lock().unwrap();
2287 panic!("intentional poison");
2288 });
2289 assert!(sw.is_poisoned(), "mutex must be poisoned after the panic");
2290
2291 let sink = SessionPersistenceSink::new(sw);
2292 sink.emit(AgentEvent::MessageAppended {
2294 message: Message::user("after poison"),
2295 parent_uuid: None,
2296 usage: None,
2297 })
2298 .await;
2299
2300 let transcript = SessionReader::load_messages(&session_dir).unwrap();
2301 assert_eq!(transcript.len(), 1);
2302 assert_eq!(transcript[0].content, "after poison");
2303 }
2304
2305 #[test]
2308 fn epoch_day_to_ymd_unix_epoch() {
2309 assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
2311 }
2312
2313 #[test]
2314 fn epoch_day_to_ymd_known_dates() {
2315 assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
2317 assert_eq!(epoch_day_to_ymd(11016), (2000, 2, 29));
2319 assert_eq!(epoch_day_to_ymd(47482), (2100, 1, 1));
2322 }
2323
2324 #[test]
2325 fn chrono_lite_now_format() {
2326 let ts = chrono_lite_now();
2327 assert_eq!(ts.len(), 20, "unexpected length: {ts}");
2329 assert_eq!(&ts[4..5], "-", "missing first dash: {ts}");
2330 assert_eq!(&ts[7..8], "-", "missing second dash: {ts}");
2331 assert_eq!(&ts[10..11], "T", "missing T separator: {ts}");
2332 assert_eq!(&ts[13..14], ":", "missing first colon: {ts}");
2333 assert_eq!(&ts[16..17], ":", "missing second colon: {ts}");
2334 assert_eq!(&ts[19..20], "Z", "missing Z suffix: {ts}");
2335 let year: u32 = ts[0..4].parse().expect("year");
2337 let month: u32 = ts[5..7].parse().expect("month");
2338 let day: u32 = ts[8..10].parse().expect("day");
2339 let hour: u32 = ts[11..13].parse().expect("hour");
2340 let min: u32 = ts[14..16].parse().expect("minute");
2341 let sec: u32 = ts[17..19].parse().expect("second");
2342 assert!(year >= 2024, "year looks wrong: {year}");
2343 assert!((1..=12).contains(&month), "month out of range: {month}");
2344 assert!((1..=31).contains(&day), "day out of range: {day}");
2345 assert!(hour < 24, "hour out of range: {hour}");
2346 assert!(min < 60, "minute out of range: {min}");
2347 assert!(sec < 60, "second out of range: {sec}");
2348 }
2349}