1use crate::core::stream_types::SharedMessage;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Session {
8 pub id: String,
9 pub title: String,
10 #[serde(default, skip_serializing_if = "Option::is_none")]
11 pub name: Option<String>,
12 pub model: String,
13 pub thinking_level: String,
14 pub system_prompt: Option<String>,
15 pub created_at: DateTime<Utc>,
16 pub updated_at: DateTime<Utc>,
17 pub total_input_tokens: u64,
18 pub total_output_tokens: u64,
19 pub session_cost: f64,
20 #[serde(default)]
26 pub message_count: usize,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub parent_session: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub compacted_into: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub env: Option<Vec<(String, String)>>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub env_stripped: Vec<String>,
45 pub api_messages: Vec<SharedMessage>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub abort_context: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub prompt_provenance: Option<crate::prompt::PromptProvenance>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub compaction: Option<crate::compaction::CompactionRecord>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct SessionInfo {
65 pub id: String,
66 pub title: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub name: Option<String>,
69 pub model: String,
70 pub created_at: DateTime<Utc>,
71 pub updated_at: DateTime<Utc>,
72 pub session_cost: f64,
73 pub message_count: usize,
74}
75
76pub fn user_content_for_display(content: &serde_json::Value) -> Option<String> {
81 if let Some(text) = content.as_str() {
82 return Some(text.to_string());
83 }
84 let parts: Vec<String> = content
85 .as_array()?
86 .iter()
87 .filter_map(|block| match block["type"].as_str() {
88 Some("text") => block["text"].as_str().map(str::to_string),
89 Some("image") => Some("[attached image]".to_string()),
90 Some("document") => {
91 let name = block["filename"]
95 .as_str()
96 .or_else(|| block["title"].as_str())
97 .unwrap_or("")
98 .rsplit(['/', '\\'])
99 .next()
100 .unwrap_or("");
101 let name: String = name
102 .chars()
103 .filter(|c| {
104 !c.is_control()
105 && !matches!(c, '[' | ']' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
106 })
107 .take(120)
108 .collect();
109 let name = name.trim();
110 Some(if name.is_empty() {
111 "[attached document]".to_string()
112 } else {
113 format!("[attached document: {name}]")
114 })
115 }
116 _ => None,
117 })
118 .filter(|part| !part.is_empty())
119 .collect();
120 (!parts.is_empty()).then(|| parts.join("\n"))
121}
122
123impl Session {
124 pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
125 let now = Utc::now();
126 let id = format!(
127 "{}-{}",
128 now.format("%Y%m%d-%H%M%S"),
129 &uuid::Uuid::new_v4().to_string()[..4]
130 );
131 Session {
132 id,
133 title: String::new(),
134 name: None,
135 model: model.to_string(),
136 thinking_level: thinking_level.to_string(),
137 system_prompt: system_prompt.map(|s| s.to_string()),
138 created_at: now,
139 updated_at: now,
140 total_input_tokens: 0,
141 total_output_tokens: 0,
142 session_cost: 0.0,
143 message_count: 0,
144 api_messages: Vec::new(),
145 abort_context: None,
146 parent_session: None,
147 compacted_into: None,
148 env: None,
149 env_stripped: Vec::new(),
150 prompt_provenance: None,
151 compaction: None,
152 }
153 }
154
155 pub fn from_compaction_record(
161 parent: &Session,
162 summary_text: &str,
163 record: crate::compaction::CompactionRecord,
164 ) -> Self {
165 let now = Utc::now();
166 let id = format!(
167 "{}-{}",
168 now.format("%Y%m%d-%H%M%S"),
169 &uuid::Uuid::new_v4().to_string()[..4]
170 );
171 let name = parent.name.clone();
175 Session {
176 id,
177 title: format!(
178 "↳ {}",
179 if parent.title.is_empty() {
180 &parent.id
181 } else {
182 &parent.title
183 }
184 ),
185 name,
186 model: parent.model.clone(),
187 thinking_level: parent.thinking_level.clone(),
188 system_prompt: parent.system_prompt.clone(),
189 created_at: now,
190 updated_at: now,
191 total_input_tokens: 0,
192 total_output_tokens: 0,
193 session_cost: 0.0,
194 message_count: 0,
195 api_messages: crate::compaction::compaction_context_messages(summary_text),
196 abort_context: None,
197 parent_session: Some(parent.id.clone()),
198 compacted_into: None,
199 env: parent.env.clone(),
200 env_stripped: parent.env_stripped.clone(),
201 prompt_provenance: None,
202 compaction: Some(record),
203 }
204 }
205
206 pub fn auto_title(&mut self) {
208 if !self.title.is_empty() {
209 return;
210 }
211 for msg in &self.api_messages {
212 if msg["role"].as_str() == Some("user") {
213 if let Some(content) = user_content_for_display(&msg["content"]) {
214 self.title = content.chars().take(80).collect();
215 return;
216 }
217 }
218 }
219 }
220
221 pub async fn save(&self) -> std::io::Result<()> {
225 let dir = sessions_dir();
226 let mode = crate::config::load_config().session_persistence;
227 let session = self.clone(); let guard = crate::core::session_save_order::acquire(&dir, &session.id).await;
229 tokio::task::spawn_blocking(move || {
230 let _guard = guard;
231 crate::core::session_journal::save_session_in_dir(&dir, &session, mode).map(|_| ())
232 })
233 .await
234 .map_err(std::io::Error::other)?
235 }
236
237 pub async fn save_durable(&self) -> std::io::Result<()> {
254 let dir = sessions_dir();
255 let mode = crate::config::load_config().session_persistence;
256 self.save_durable_to(dir, mode).await
257 }
258
259 async fn save_durable_to(
262 &self,
263 dir: PathBuf,
264 mode: crate::core::session_journal::SessionPersistence,
265 ) -> std::io::Result<()> {
266 let session = self.clone();
267 let guard = crate::core::session_save_order::acquire(&dir, &session.id).await;
268 tokio::task::spawn_blocking(move || {
269 let _guard = guard;
270 crate::core::session_journal::save_session_durable_in_dir(&dir, &session, mode)
271 .map(|_| ())
272 })
273 .await
274 .map_err(std::io::Error::other)?
275 }
276
277 pub async fn recover_durable(id: &str) -> std::io::Result<Self> {
281 let dir = sessions_dir();
282 let mode = crate::config::load_config().session_persistence;
283 let id = id.to_owned();
284 let guard = crate::core::session_save_order::acquire(&dir, &id).await;
285 tokio::task::spawn_blocking(move || {
286 let _guard = guard;
287 let session = Self::load_from_dir(&dir, &id)?;
288 crate::core::session_journal::save_session_durable_in_dir(&dir, &session, mode)?;
289 Ok(session)
290 })
291 .await
292 .map_err(std::io::Error::other)?
293 }
294
295 pub fn load(id: &str) -> std::io::Result<Self> {
296 Self::load_from_dir(&sessions_dir(), id)
297 }
298
299 pub fn load_from_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Self> {
303 crate::core::session_journal::load_session_in_dir(dir, id)
304 }
305
306 pub fn info(&self) -> SessionInfo {
307 SessionInfo {
308 id: self.id.clone(),
309 title: self.title.clone(),
310 name: self.name.clone(),
311 model: self.model.clone(),
312 created_at: self.created_at,
313 updated_at: self.updated_at,
314 session_cost: self.session_cost,
315 message_count: self.api_messages.len(),
316 }
317 }
318
319 pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
323 validate_name(name).map_err(std::io::Error::other)?;
324 if self.name.as_deref() == Some(name) {
325 return Ok(());
326 }
327 let sessions = list_sessions()?;
328 for s in &sessions {
329 if s.name.as_deref() == Some(name) && s.id != self.id {
330 return Err(std::io::Error::other(format!(
331 "name '{}' already used by session {}",
332 name, s.id
333 )));
334 }
335 }
336 if crate::core::chain::load_chain(name).is_ok() {
337 return Err(std::io::Error::other(format!(
338 "name '{}' conflicts with an existing chain name",
339 name
340 )));
341 }
342 self.name = Some(name.to_string());
343 Ok(())
344 }
345
346 pub fn clear_name(&mut self) {
347 self.name = None;
348 }
349}
350
351#[cfg(test)]
355pub(crate) fn save_json_in_dir(
356 dir: &std::path::Path,
357 id: &str,
358 json: &[u8],
359) -> std::io::Result<()> {
360 crate::core::session_journal::write_json_snapshot(dir, id, json)
363}
364
365pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
367 let dir = sessions_dir();
368 if !dir.exists() {
369 return Err(std::io::Error::new(
370 std::io::ErrorKind::NotFound,
371 "no sessions directory",
372 ));
373 }
374
375 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
377
378 if entries
380 .iter()
381 .any(|e| e.name == format!("{partial_id}.json"))
382 {
383 return Session::load(partial_id);
384 }
385
386 let mut matches: Vec<String> = Vec::new();
388 for entry in &entries {
389 if entry.name.ends_with(".json") {
390 let id = entry.name.trim_end_matches(".json");
391 if id.contains(partial_id) {
392 matches.push(id.to_string());
393 }
394 }
395 }
396
397 match matches.len() {
398 0 => Err(std::io::Error::new(
399 std::io::ErrorKind::NotFound,
400 format!("no session matching '{}'", partial_id),
401 )),
402 1 => Session::load(&matches[0]),
403 _ => Err(std::io::Error::other(format!(
404 "ambiguous: {} sessions match '{}'",
405 matches.len(),
406 partial_id
407 ))),
408 }
409}
410
411pub fn latest_session() -> std::io::Result<Session> {
413 let dir = sessions_dir();
420 if !dir.exists() {
421 return Err(std::io::Error::new(
422 std::io::ErrorKind::NotFound,
423 "no sessions found",
424 ));
425 }
426 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
429 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
430 let mut newest: Option<(std::time::SystemTime, String)> = None;
431 for entry in &entries {
432 let id = if let Some(id) = entry.name.strip_suffix(".json") {
436 id
437 } else if let Some(id) = entry.name.strip_suffix(".journal") {
438 if !names.contains(format!("{id}.json").as_str()) {
439 continue; }
441 id
442 } else {
443 continue;
444 };
445 if let Some(mtime) = entry.mtime {
446 if newest.as_ref().map_or(true, |(t, _)| mtime > *t) {
447 newest = Some((mtime, id.to_string()));
448 }
449 }
450 }
451 let id = newest
452 .map(|(_, id)| id)
453 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))?;
454 Session::load(&id)
455}
456
457pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
464 let dir = sessions_dir();
465 if !dir.exists() {
466 return Ok(Vec::new());
467 }
468 let mut sessions: Vec<SessionInfo> = Vec::new();
469 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
470 if entry.name.ends_with(".json") {
471 if let Some(info) = parse_session_header(&dir, &entry.name) {
472 sessions.push(info);
473 }
474 }
475 }
476 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
477 Ok(sessions)
478}
479
480pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
486 let dir = sessions_dir();
487 if !dir.exists() {
488 return Ok(Vec::new());
489 }
490 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
492 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
493 let mut by_snapshot: std::collections::HashMap<String, std::time::SystemTime> =
494 std::collections::HashMap::new();
495 for entry in &entries {
496 let id = if let Some(id) = entry.name.strip_suffix(".json") {
498 id
499 } else if let Some(id) = entry.name.strip_suffix(".journal") {
500 if !names.contains(format!("{id}.json").as_str()) {
501 continue;
502 }
503 id
504 } else {
505 continue;
506 };
507 if let Some(mtime) = entry.mtime {
508 let slot = by_snapshot
509 .entry(id.to_string())
510 .or_insert(std::time::SystemTime::UNIX_EPOCH);
511 if mtime > *slot {
512 *slot = mtime;
513 }
514 }
515 }
516 let mut files: Vec<(std::time::SystemTime, String)> =
517 by_snapshot.into_iter().map(|(id, t)| (t, id)).collect();
518 files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
519 files.truncate(limit);
520 let mut sessions: Vec<SessionInfo> = Vec::new();
521 for (_, id) in files {
522 if let Some(info) = parse_session_header(&dir, &format!("{id}.json")) {
523 sessions.push(info);
524 }
525 }
526 Ok(sessions)
527}
528
529fn parse_session_header(dir: &std::path::Path, file_name: &str) -> Option<SessionInfo> {
534 #[derive(Deserialize)]
535 struct SessionMetadata {
536 #[serde(default)]
537 _journal_generation: Option<String>,
538 id: String,
539 #[serde(default)]
540 title: String,
541 #[serde(default)]
542 name: Option<String>,
543 model: String,
544 created_at: DateTime<Utc>,
545 updated_at: DateTime<Utc>,
546 #[serde(default)]
547 session_cost: f64,
548 #[serde(default)]
549 message_count: usize,
550 }
551 let header = read_session_header(dir, file_name)?;
552 let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
553 let mut info = SessionInfo {
554 id: meta.id,
555 title: meta.title,
556 name: meta.name,
557 model: meta.model,
558 created_at: meta.created_at,
559 updated_at: meta.updated_at,
560 session_cost: meta.session_cost,
561 message_count: meta.message_count,
562 };
563 if let Some(tail) = crate::core::session_journal::journal_meta_tail_for_generation(
567 dir,
568 &info.id,
569 meta._journal_generation.as_deref(),
570 ) {
571 if tail.updated_at > info.updated_at {
572 info.updated_at = tail.updated_at;
573 info.session_cost = tail.session_cost;
574 if let Some(count) = tail.message_count {
575 info.message_count = count;
576 }
577 }
578 }
579 Some(info)
580}
581
582fn read_session_header(dir: &std::path::Path, file_name: &str) -> Option<String> {
590 let file = crate::core::session_journal::confined_open(dir, file_name).ok()??;
591 read_session_header_from_file(file)
592}
593
594pub(crate) fn read_session_header_from_file(mut file: std::fs::File) -> Option<String> {
596 use std::io::Read;
597 const KEY: &[u8] = b"\"api_messages\"";
598 const MAX_HEADER: usize = 256 * 1024; let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
604 let mut chunk = [0u8; 16 * 1024];
605 let mut cut: Option<usize> = None;
606 while buf.len() <= MAX_HEADER {
607 let n = file.read(&mut chunk).ok()?;
608 if n == 0 {
609 break;
610 }
611 buf.extend_from_slice(&chunk[..n]);
612 if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
613 cut = Some(pos);
614 break;
615 }
616 }
617
618 let end = cut.unwrap_or(buf.len());
619 let trimmed = String::from_utf8_lossy(&buf[..end]);
620 let mut s = trimmed.trim_end().to_string();
621 if s.ends_with(',') {
622 s.pop();
623 }
624 if !s.ends_with('}') {
625 s.push('}');
626 }
627 Some(s)
628}
629
630fn sessions_dir() -> PathBuf {
631 crate::config::get_active_config_dir().join("sessions")
632}
633
634pub fn delete_session_file(id: &str) -> std::io::Result<()> {
638 crate::core::session_journal::delete_session_files_in_dir(&sessions_dir(), id)
639}
640
641pub fn validate_name(name: &str) -> Result<(), String> {
643 if name.is_empty() {
644 return Err("name cannot be empty".into());
645 }
646 if name.len() > 40 {
647 return Err(format!("invalid name '{}': must be 40 chars or less", name));
648 }
649 if !name
650 .chars()
651 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
652 {
653 return Err(format!(
654 "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
655 name
656 ));
657 }
658 Ok(())
659}
660
661pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
665 let dir = sessions_dir();
666 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
667 if !entry.name.ends_with(".json") {
668 continue;
669 }
670 if let Some(info) = parse_session_header(&dir, &entry.name) {
671 if info.name.as_deref() == Some(name) {
672 return Session::load(&info.id);
673 }
674 }
675 }
676 Err(std::io::Error::new(
677 std::io::ErrorKind::NotFound,
678 format!("no session named '{}'", name),
679 ))
680}
681
682#[derive(Debug)]
684pub struct ResolvedSession {
685 pub session: Session,
686 pub compaction_notice: Option<String>,
690}
691
692const MAX_COMPACTION_HOPS: usize = 32;
694
695pub fn follow_compaction_chain(session: Session) -> std::io::Result<ResolvedSession> {
699 if session.compacted_into.is_none() {
700 return Ok(ResolvedSession {
701 session,
702 compaction_notice: None,
703 });
704 }
705
706 let original_id = session.id.clone();
707 let mut current = session;
708 let mut visited = std::collections::HashSet::new();
709 visited.insert(current.id.clone());
710
711 while let Some(ref successor_id) = current.compacted_into {
712 let succ_id = successor_id.clone();
713 if !visited.insert(succ_id.clone()) {
714 return Err(std::io::Error::new(
715 std::io::ErrorKind::InvalidData,
716 format!(
717 "compaction cycle detected: session {} loops back to {}",
718 current.id, succ_id
719 ),
720 ));
721 }
722 if visited.len() > MAX_COMPACTION_HOPS {
723 return Err(std::io::Error::new(
724 std::io::ErrorKind::InvalidData,
725 format!(
726 "compaction chain too long (>{} hops) starting from {}",
727 MAX_COMPACTION_HOPS, original_id
728 ),
729 ));
730 }
731 current = Session::load(&succ_id).map_err(|e| {
732 std::io::Error::new(
733 e.kind(),
734 format!(
735 "session {} was compacted into {}, but the successor failed to load: {}",
736 current.id, succ_id, e
737 ),
738 )
739 })?;
740 }
741
742 let notice = format!(
743 "{} was compacted into {} \u{2014} continuing there",
744 original_id, current.id
745 );
746 tracing::info!("{}", notice);
747 Ok(ResolvedSession {
748 session: current,
749 compaction_notice: Some(notice),
750 })
751}
752
753pub fn read_compacted_into(id: &str) -> std::io::Result<Option<String>> {
756 read_compacted_into_in_dir(&sessions_dir(), id)
757}
758
759pub fn read_compacted_into_in_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Option<String>> {
761 let file_name = format!("{}.json", id);
762 let header = read_session_header(dir, &file_name).ok_or_else(|| {
763 std::io::Error::new(
764 std::io::ErrorKind::NotFound,
765 format!("no session file for '{}'", id),
766 )
767 })?;
768 #[derive(serde::Deserialize)]
769 struct Hdr {
770 #[serde(default)]
771 compacted_into: Option<String>,
772 }
773 let hdr: Hdr = serde_json::from_str(&header).map_err(|e| {
774 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
775 })?;
776 Ok(hdr.compacted_into)
777}
778
779pub fn resolve_session(query: &str) -> std::io::Result<Session> {
782 if let Ok(ptr) = crate::core::chain::load_chain(query) {
783 match Session::load(&ptr.head) {
784 Ok(s) => {
785 tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
786 return Ok(s);
787 }
788 Err(e) => {
789 return Err(std::io::Error::new(
790 e.kind(),
791 format!(
792 "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
793 query, ptr.head, e, query
794 ),
795 ));
796 }
797 }
798 }
799 if let Ok(s) = find_session_by_name(query) {
800 tracing::info!("resolved '{}' via session name → {}", query, s.id);
801 return Ok(s);
802 }
803 find_session(query)
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 use serde_json::json;
810
811 fn test_record(parent: &Session) -> crate::compaction::CompactionRecord {
813 crate::compaction::CompactionRecord {
814 schema_version: crate::compaction::COMPACTION_SUMMARY_SCHEMA_VERSION,
815 source_session: parent.id.clone(),
816 source_message_count: parent.api_messages.len(),
817 source_range_digest: crate::compaction::message_range_digest(&parent.api_messages),
818 summary_provider: "anthropic".into(),
819 summary_model: "claude-sonnet-4-6".into(),
820 created_at: Utc::now(),
821 prompt_stack_digest: crate::compaction::prompt_stack_digest(&["test"]),
822 included_classes: crate::compaction::ContentClass::ALL.to_vec(),
823 excluded_classes: Vec::new(),
824 redaction_policy: crate::compaction::RedactionPolicy::TruncationOnly,
825 prior_system_prompt: parent.system_prompt.clone(),
826 }
827 }
828
829 #[cfg(unix)]
830 #[tokio::test]
831 async fn durable_dispatch_checkpoints_json_and_journal_without_global_config() {
832 use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
833 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
834 let tmp = tempfile::TempDir::new().unwrap();
835 let dir = tmp
836 .path()
837 .canonicalize()
838 .unwrap()
839 .join("profile/nested/sessions");
840 let mut session = Session::new("m", "medium", None);
841 session.api_messages.push(std::sync::Arc::new(
842 json!({"role":"user","content":"checkpoint"}),
843 ));
844 session.save_durable_to(dir.clone(), mode).await.unwrap();
845 let loaded = Session::load_from_dir(&dir, &session.id).unwrap();
846 assert_eq!(loaded.api_messages, session.api_messages);
847 session.api_messages.push(std::sync::Arc::new(
850 json!({"role":"assistant","content":"old tail"}),
851 ));
852 save_session_in_dir(&dir, &session, SessionPersistence::Journal).unwrap();
853 session.api_messages.truncate(1);
854 session.save_durable_to(dir.clone(), mode).await.unwrap();
855 assert_eq!(
856 Session::load_from_dir(&dir, &session.id)
857 .unwrap()
858 .api_messages,
859 session.api_messages
860 );
861 let snapshot: Session = serde_json::from_slice(
862 &std::fs::read(dir.join(format!("{}.json", session.id))).unwrap(),
863 )
864 .unwrap();
865 assert_eq!(snapshot.api_messages, session.api_messages);
866 assert_eq!(
867 dir.join(format!("{}.journal", session.id)).exists(),
868 mode == SessionPersistence::Journal
869 );
870 }
871 }
872
873 #[cfg(unix)]
874 #[tokio::test]
875 async fn durable_dispatch_returns_io_errors_and_refuses_symlinks() {
876 use crate::core::session_journal::SessionPersistence;
877 let tmp = tempfile::TempDir::new().unwrap();
878 let root = tmp.path().canonicalize().unwrap();
879 let victim = root.join("victim");
880 std::fs::write(&victim, b"unchanged").unwrap();
881 let dir = root.join("sessions");
882 std::fs::create_dir(&dir).unwrap();
883 let session = Session::new("m", "medium", None);
884 std::os::unix::fs::symlink(&victim, dir.join(format!("{}.json", session.id))).unwrap();
885 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
886 assert!(session.save_durable_to(dir.clone(), mode).await.is_err());
887 }
888 assert_eq!(std::fs::read(&victim).unwrap(), b"unchanged");
889 }
890
891 #[cfg(unix)]
892 #[tokio::test]
893 async fn durable_dispatch_reports_post_commit_cleanup_error() {
894 use crate::core::session_journal::SessionPersistence;
895 let tmp = tempfile::TempDir::new().unwrap();
896 let dir = tmp.path().canonicalize().unwrap();
897 let mut session = Session::new("m", "medium", None);
898 session
899 .save_durable_to(dir.clone(), SessionPersistence::Journal)
900 .await
901 .unwrap();
902 session.title = "published candidate".into();
903 std::fs::create_dir(dir.join(format!("{}.journal.tmp", session.id))).unwrap();
904 assert!(session
905 .save_durable_to(dir.clone(), SessionPersistence::Journal)
906 .await
907 .is_err());
908 assert_eq!(
910 Session::load_from_dir(&dir, &session.id).unwrap().title,
911 session.title
912 );
913 }
914
915 #[test]
916 fn test_session_new() {
917 let session = Session::new("gpt-4", "brief", Some("test prompt"));
918
919 assert_eq!(session.model, "gpt-4");
921 assert_eq!(session.thinking_level, "brief");
922 assert_eq!(session.system_prompt, Some("test prompt".to_string()));
923
924 assert!(!session.id.is_empty());
926
927 assert_eq!(session.title, "");
929
930 assert_eq!(session.total_input_tokens, 0);
932 assert_eq!(session.total_output_tokens, 0);
933
934 assert_eq!(session.session_cost, 0.0);
936
937 assert!(session.api_messages.is_empty());
939
940 let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
942 assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
943 assert_eq!(session_no_prompt.thinking_level, "normal");
944 assert_eq!(session_no_prompt.system_prompt, None);
945 }
946
947 #[test]
948 fn test_session_auto_title() {
949 let mut session = Session::new("gpt-4", "brief", None);
950
951 session.api_messages.push(std::sync::Arc::new(json!({
953 "role": "user",
954 "content": "hello world"
955 })));
956
957 session.auto_title();
959
960 assert_eq!(session.title, "hello world");
962
963 session.title = "existing title".to_string();
965 session.auto_title();
966 assert_eq!(session.title, "existing title");
967
968 let mut empty_session = Session::new("gpt-4", "brief", None);
970 empty_session.auto_title();
971 assert_eq!(empty_session.title, "");
972
973 let mut session_no_user = Session::new("gpt-4", "brief", None);
975 session_no_user
976 .api_messages
977 .push(std::sync::Arc::new(json!({
978 "role": "assistant",
979 "content": "response"
980 })));
981 session_no_user.auto_title();
982 assert_eq!(session_no_user.title, "");
983
984 let mut session_long = Session::new("gpt-4", "brief", None);
986 let long_content = "a".repeat(100);
987 session_long.api_messages.push(std::sync::Arc::new(json!({
988 "role": "user",
989 "content": long_content
990 })));
991 session_long.auto_title();
992 assert_eq!(session_long.title.len(), 80);
993 assert_eq!(session_long.title, "a".repeat(80));
994 }
995
996 #[test]
997 fn attachment_projection_and_auto_title_never_read_sources_or_tool_results() {
998 let content = json!([
999 {"type": "text", "text": "Review"},
1000 {"type": "image", "source": {"type": "base64", "data": "IMAGE_SENTINEL"}},
1001 {"type": "document", "title": "a.txt",
1002 "source": {"type": "text", "data": "DOCUMENT_TEXT_SENTINEL"}},
1003 {"type": "document", "title": "b.pdf",
1004 "source": {"type": "base64", "data": "PDF_SENTINEL"}},
1005 {"type": "text", "text": "Then compare"},
1006 {"type": "tool_result", "tool_use_id": "t1", "content": [
1007 {"type": "text", "text": "TOOL_TEXT_SENTINEL"},
1008 {"type": "document", "title": "TOOL_TITLE_SENTINEL",
1009 "source": {"type": "text", "data": "NESTED_DOCUMENT_SENTINEL"}}
1010 ]}
1011 ]);
1012 let original = content.clone();
1013 let projected = user_content_for_display(&content).unwrap();
1014 assert_eq!(projected, "Review\n[attached image]\n[attached document: a.txt]\n[attached document: b.pdf]\nThen compare");
1015 assert!(!projected.contains("SENTINEL"));
1016 assert_eq!(
1017 content, original,
1018 "projection must not mutate canonical data"
1019 );
1020
1021 let tool_only = json!([content[5].clone()]);
1022 assert!(user_content_for_display(&tool_only).is_none());
1023 let mut session = Session::new("m", "low", None);
1024 session.api_messages.push(std::sync::Arc::new(json!({
1025 "role": "user", "content": tool_only
1026 })));
1027 session.api_messages.push(std::sync::Arc::new(json!({
1028 "role": "user", "content": content
1029 })));
1030 session.auto_title();
1031 assert_eq!(
1032 session.title,
1033 projected.chars().take(80).collect::<String>()
1034 );
1035 assert!(!session.title.contains("SENTINEL"));
1036 }
1037
1038 #[test]
1039 fn attachment_only_titles_and_safe_filename_labels() {
1040 let content = json!([
1041 {"type": "document", "title": "/private/path/[report]\u{1b}\n.txt",
1042 "source": {"type": "text", "data": "DO_NOT_DISPLAY"}},
1043 {"type": "document", "filename": "C:\\private\\note.txt"},
1044 {"type": "document", "source": {"data": "NO_FILENAME"}}
1045 ]);
1046 assert_eq!(
1047 user_content_for_display(&content).unwrap(),
1048 "[attached document: report.txt]\n[attached document: note.txt]\n[attached document]"
1049 );
1050 for (block, title) in [
1051 (
1052 json!({"type": "image", "source": {"data": "IMAGE_SENTINEL"}}),
1053 "[attached image]",
1054 ),
1055 (
1056 json!({"type": "document", "title": "notes.txt",
1057 "source": {"type": "text", "data": "TEXT_SENTINEL"}}),
1058 "[attached document: notes.txt]",
1059 ),
1060 ] {
1061 let mut session = Session::new("m", "low", None);
1062 session.api_messages.push(std::sync::Arc::new(json!({
1063 "role": "user", "content": [block]
1064 })));
1065 session.auto_title();
1066 assert_eq!(session.title, title);
1067 }
1068 assert!(user_content_for_display(&json!([])).is_none());
1069 assert!(
1070 user_content_for_display(&json!([{"type": "unknown", "text": "hidden"}])).is_none()
1071 );
1072 assert_eq!(
1073 user_content_for_display(&json!("plain text")).as_deref(),
1074 Some("plain text")
1075 );
1076 }
1077
1078 #[test]
1079 fn test_session_info() {
1080 let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
1081
1082 session.api_messages.push(std::sync::Arc::new(json!({
1084 "role": "user",
1085 "content": "test message"
1086 })));
1087 session.api_messages.push(std::sync::Arc::new(json!({
1088 "role": "assistant",
1089 "content": "test response"
1090 })));
1091
1092 session.title = "Test Title".to_string();
1093 session.session_cost = 0.05;
1094
1095 let info = session.info();
1096
1097 assert_eq!(info.id, session.id);
1098 assert_eq!(info.title, "Test Title");
1099 assert_eq!(info.model, "gpt-4");
1100 assert_eq!(info.created_at, session.created_at);
1101 assert_eq!(info.updated_at, session.updated_at);
1102 assert_eq!(info.session_cost, 0.05);
1103 assert_eq!(info.message_count, 2);
1104 }
1105
1106 #[test]
1107 fn test_session_info_struct() {
1108 let now = Utc::now();
1109
1110 let session_info = SessionInfo {
1111 id: "test-id".to_string(),
1112 title: "Test Title".to_string(),
1113 name: None,
1114 model: "gpt-4".to_string(),
1115 created_at: now,
1116 updated_at: now,
1117 session_cost: 1.23,
1118 message_count: 5,
1119 };
1120
1121 assert_eq!(session_info.id, "test-id");
1123 assert_eq!(session_info.title, "Test Title");
1124 assert_eq!(session_info.model, "gpt-4");
1125 assert_eq!(session_info.created_at, now);
1126 assert_eq!(session_info.updated_at, now);
1127 assert_eq!(session_info.session_cost, 1.23);
1128 assert_eq!(session_info.message_count, 5);
1129 }
1130
1131 #[test]
1132 fn test_session_serialization_round_trip() {
1133 let mut session = Session::new(
1134 "gpt-4-turbo",
1135 "detailed",
1136 Some("You are a helpful assistant"),
1137 );
1138 session.title = "Test Session".to_string();
1139 session.api_messages.push(std::sync::Arc::new(
1140 json!({"role": "user", "content": "test"}),
1141 ));
1142 session.total_input_tokens = 100;
1143 session.total_output_tokens = 200;
1144 session.session_cost = 0.15;
1145
1146 let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
1148
1149 let deserialized: Session =
1151 serde_json::from_str(&json_str).expect("Failed to deserialize session");
1152
1153 assert_eq!(deserialized.id, session.id);
1155 assert_eq!(deserialized.title, session.title);
1156 assert_eq!(deserialized.model, session.model);
1157 assert_eq!(deserialized.thinking_level, session.thinking_level);
1158 assert_eq!(deserialized.system_prompt, session.system_prompt);
1159 assert_eq!(deserialized.created_at, session.created_at);
1160 assert_eq!(deserialized.updated_at, session.updated_at);
1161 assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
1162 assert_eq!(
1163 deserialized.total_output_tokens,
1164 session.total_output_tokens
1165 );
1166 assert_eq!(deserialized.session_cost, session.session_cost);
1167 assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
1168 assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
1169 }
1170
1171 #[test]
1172 fn codex_ultra_round_trips_and_compaction_preserves_logical_mode() {
1173 let parent = Session::new("openai-codex/gpt-5.6-sol", "ultra", None);
1174 let encoded = serde_json::to_string(&parent).expect("serialize Ultra session");
1175 let persisted: serde_json::Value =
1176 serde_json::from_str(&encoded).expect("inspect persisted session");
1177 assert_eq!(persisted["thinking_level"], "ultra");
1178 let restored: Session = serde_json::from_str(&encoded).expect("restore Ultra session");
1179 assert_eq!(restored.model, "openai-codex/gpt-5.6-sol");
1180 assert_eq!(restored.thinking_level, "ultra");
1181
1182 let compacted =
1183 Session::from_compaction_record(&restored, "summary", test_record(&restored));
1184 assert_eq!(compacted.model, restored.model);
1185 assert_eq!(compacted.thinking_level, "ultra");
1186 assert_eq!(
1187 compacted.parent_session.as_deref(),
1188 Some(restored.id.as_str())
1189 );
1190 }
1191
1192 #[test]
1193 fn codex_max_round_trips_without_becoming_ultra_or_xhigh() {
1194 let session = Session::new("openai-codex/gpt-5.6-luna", "max", None);
1195 let encoded = serde_json::to_string(&session).expect("serialize Max session");
1196 let restored: Session = serde_json::from_str(&encoded).expect("restore Max session");
1197 assert_eq!(restored.thinking_level, "max");
1198 assert_ne!(restored.thinking_level, "ultra");
1199 assert_ne!(restored.thinking_level, "xhigh");
1200 }
1201
1202 #[test]
1203 fn test_session_serialization_preserves_all_fields() {
1204 let mut session = Session::new(
1205 "claude-3-opus",
1206 "comprehensive",
1207 Some("Custom system prompt"),
1208 );
1209 session.title = "Complex Session".to_string();
1210
1211 session.api_messages.push(std::sync::Arc::new(
1213 json!({"role": "user", "content": "First message"}),
1214 ));
1215 session.api_messages.push(std::sync::Arc::new(
1216 json!({"role": "assistant", "content": "First response"}),
1217 ));
1218 session.api_messages.push(std::sync::Arc::new(
1219 json!({"role": "user", "content": "Second message"}),
1220 ));
1221
1222 session.total_input_tokens = 1500;
1224 session.total_output_tokens = 2500;
1225 session.session_cost = 0.75;
1226
1227 let json_str = serde_json::to_string(&session).unwrap();
1229 let restored: Session = serde_json::from_str(&json_str).unwrap();
1230
1231 assert_eq!(restored.id, session.id);
1233 assert_eq!(restored.title, "Complex Session");
1234 assert_eq!(restored.model, "claude-3-opus");
1235 assert_eq!(restored.thinking_level, "comprehensive");
1236 assert_eq!(
1237 restored.system_prompt.as_ref().unwrap(),
1238 "Custom system prompt"
1239 );
1240 assert_eq!(restored.created_at, session.created_at);
1241 assert_eq!(restored.updated_at, session.updated_at);
1242 assert_eq!(restored.total_input_tokens, 1500);
1243 assert_eq!(restored.total_output_tokens, 2500);
1244 assert_eq!(restored.session_cost, 0.75);
1245 assert_eq!(restored.api_messages.len(), 3);
1246 assert_eq!(restored.api_messages[0]["role"], "user");
1247 assert_eq!(restored.api_messages[0]["content"], "First message");
1248 assert_eq!(restored.api_messages[1]["role"], "assistant");
1249 assert_eq!(restored.api_messages[2]["content"], "Second message");
1250 }
1251
1252 #[test]
1253 fn test_session_info_from_session_with_messages() {
1254 let mut session = Session::new("gpt-3.5-turbo", "normal", None);
1255
1256 session.api_messages.push(std::sync::Arc::new(
1258 json!({"role": "user", "content": "message 1"}),
1259 ));
1260 session.api_messages.push(std::sync::Arc::new(
1261 json!({"role": "assistant", "content": "response 1"}),
1262 ));
1263 session.api_messages.push(std::sync::Arc::new(
1264 json!({"role": "user", "content": "message 2"}),
1265 ));
1266
1267 let info = session.info();
1268
1269 assert_eq!(info.message_count, 3);
1271 assert_eq!(info.id, session.id);
1272 assert_eq!(info.model, "gpt-3.5-turbo");
1273 }
1274
1275 #[test]
1276 fn test_session_auto_title_truncation() {
1277 let mut session = Session::new("gpt-4", "brief", None);
1278
1279 let long_content = "a".repeat(200);
1281 session.api_messages.push(std::sync::Arc::new(json!({
1282 "role": "user",
1283 "content": long_content
1284 })));
1285
1286 session.auto_title();
1287
1288 assert_eq!(session.title.len(), 80);
1290 assert_eq!(session.title, "a".repeat(80));
1291 }
1292
1293 #[test]
1294 fn test_session_auto_title_skips_non_user_messages() {
1295 let mut session = Session::new("gpt-4", "brief", None);
1296
1297 session.api_messages.push(std::sync::Arc::new(json!({
1299 "role": "assistant",
1300 "content": "This should be ignored for auto title"
1301 })));
1302
1303 session.auto_title();
1304
1305 assert_eq!(session.title, "");
1307
1308 session.api_messages.push(std::sync::Arc::new(json!({
1310 "role": "system",
1311 "content": "System message should also be ignored"
1312 })));
1313
1314 session.auto_title();
1315 assert_eq!(session.title, "");
1316 }
1317
1318 #[test]
1319 fn test_session_new_generates_unique_ids() {
1320 let session1 = Session::new("gpt-4", "brief", None);
1321 let session2 = Session::new("gpt-4", "brief", None);
1322
1323 assert_ne!(session1.id, session2.id);
1325 assert!(!session1.id.is_empty());
1326 assert!(!session2.id.is_empty());
1327 }
1328
1329 #[test]
1330 fn test_session_new_timestamps() {
1331 let before = Utc::now();
1332 let session = Session::new("gpt-4", "brief", None);
1333 let after = Utc::now();
1334
1335 let created_diff = (session.created_at - before).num_seconds().abs();
1337 let updated_diff = (session.updated_at - before).num_seconds().abs();
1338
1339 assert!(
1340 created_diff <= 2,
1341 "created_at should be within 2 seconds of now"
1342 );
1343 assert!(
1344 updated_diff <= 2,
1345 "updated_at should be within 2 seconds of now"
1346 );
1347
1348 assert_eq!(session.created_at, session.updated_at);
1350
1351 assert!(session.created_at <= after);
1353 assert!(session.updated_at <= after);
1354 }
1355
1356 #[test]
1357 fn test_validate_name() {
1358 assert!(validate_name("work").is_ok());
1359 assert!(validate_name("my-project-2").is_ok());
1360 assert!(validate_name("a").is_ok());
1361 assert!(validate_name(&"a".repeat(40)).is_ok());
1362
1363 assert!(validate_name("").is_err());
1364 assert!(validate_name(&"a".repeat(41)).is_err());
1365 assert!(validate_name("UPPER").is_err());
1366 assert!(validate_name("has space").is_err());
1367 assert!(validate_name("under_score").is_err());
1368 assert!(validate_name("dots.bad").is_err());
1369
1370 let err = validate_name("Bad").unwrap_err();
1371 assert!(err.contains("Bad"));
1372 assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
1373 }
1374
1375 #[test]
1376 fn test_clear_name() {
1377 let mut s = Session::new("m", "brief", None);
1378 s.name = Some("foo".into());
1379 s.clear_name();
1380 assert_eq!(s.name, None);
1381 }
1382
1383 #[test]
1384 fn ultracode_serialization_and_compaction_roundtrip_is_distinct() {
1385 let original = Session::new("anthropic/claude-fable-5", "ultracode", None);
1386 let json = serde_json::to_string(&original).unwrap();
1387 let restored: Session = serde_json::from_str(&json).unwrap();
1388 assert_eq!(restored.thinking_level, "ultracode");
1389 for other in ["ultra", "max", "xhigh"] {
1390 assert_ne!(restored.thinking_level, other);
1391 }
1392 let compacted =
1393 Session::from_compaction_record(&restored, "summary", test_record(&restored));
1394 let resumed: Session =
1395 serde_json::from_str(&serde_json::to_string(&compacted).unwrap()).unwrap();
1396 assert_eq!(resumed.thinking_level, "ultracode");
1397 assert_eq!(
1398 resumed.parent_session.as_deref(),
1399 Some(original.id.as_str())
1400 );
1401 }
1402
1403 #[cfg(unix)]
1408 mod private_modes {
1409 use super::*;
1410 use crate::core::private_fs::test_support::UmaskGuard;
1411 use serial_test::serial;
1412 use std::os::unix::fs::PermissionsExt;
1413
1414 fn mode_of(path: &std::path::Path) -> u32 {
1415 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1416 }
1417
1418 #[test]
1419 #[serial(umask)]
1420 fn save_creates_0600_session_file_and_0700_dir_under_permissive_umask() {
1421 let _umask = UmaskGuard::set(0);
1422 let tmp = tempfile::TempDir::new().unwrap();
1423 let dir = tmp.path().join("sessions");
1424 save_json_in_dir(&dir, "sess-1", b"{}").unwrap();
1425 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1426 assert_eq!(
1427 mode_of(&dir.join("sess-1.json")),
1428 0o600,
1429 "session file must be 0600"
1430 );
1431 }
1432
1433 #[test]
1434 fn save_refuses_symlink_target() {
1435 let tmp = tempfile::TempDir::new().unwrap();
1436 let dir = tmp.path().join("sessions");
1437 std::fs::create_dir_all(&dir).unwrap();
1438 let victim = tmp.path().join("victim.json");
1439 std::fs::write(&victim, "original").unwrap();
1440 std::os::unix::fs::symlink(&victim, dir.join("sess-1.json")).unwrap();
1441 let res = save_json_in_dir(&dir, "sess-1", b"{}");
1442 assert!(res.is_err(), "save onto a symlink must fail");
1443 assert_eq!(
1444 std::fs::read_to_string(&victim).unwrap(),
1445 "original",
1446 "no bytes may be written through the planted symlink"
1447 );
1448 }
1449 }
1450
1451 mod header_message_count {
1452 use super::*;
1453 use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
1454 use std::sync::Arc;
1455
1456 fn session_with(n: usize) -> Session {
1457 let mut s = Session::new("claude-sonnet-4-6", "medium", None);
1458 for i in 0..n {
1459 let role = if i % 2 == 0 { "user" } else { "assistant" };
1460 s.api_messages.push(Arc::new(
1461 serde_json::json!({"role": role, "content": format!("m{i}")}),
1462 ));
1463 }
1464 s
1465 }
1466
1467 #[test]
1470 fn json_save_persists_count_in_header() {
1471 let tmp = tempfile::TempDir::new().unwrap();
1472 let dir = tmp.path().join("sessions");
1473 let s = session_with(7);
1474 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1475 let header = read_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1476 assert!(
1477 !header.contains("\"api_messages\""),
1478 "header must stop before the message array"
1479 );
1480 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1481 assert_eq!(info.message_count, 7);
1482 }
1483
1484 #[test]
1486 fn journal_append_updates_count() {
1487 let tmp = tempfile::TempDir::new().unwrap();
1488 let dir = tmp.path().join("sessions");
1489 let mut s = session_with(2);
1490 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1491 s.api_messages.push(Arc::new(
1492 serde_json::json!({"role": "user", "content": "more"}),
1493 ));
1494 s.updated_at = Utc::now();
1495 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1496 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1497 assert_eq!(info.message_count, 3);
1498 }
1499
1500 #[test]
1501 fn listing_does_not_overlay_stale_generation_metadata() {
1502 let tmp = tempfile::TempDir::new().unwrap();
1503 let dir = tmp.path().canonicalize().unwrap().join("sessions");
1504 let mut s = session_with(2);
1505 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1506 s.api_messages
1507 .push(Arc::new(serde_json::json!({"role":"user","content":"old"})));
1508 s.session_cost = 999.0;
1509 s.updated_at += chrono::Duration::hours(1);
1510 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1511 let journal_path = dir.join(format!("{}.journal", s.id));
1512 let stale = std::fs::read(&journal_path).unwrap();
1513 s.api_messages.truncate(1);
1514 s.session_cost = 1.0;
1515 s.updated_at -= chrono::Duration::hours(2);
1516 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1517 std::fs::write(journal_path, stale).unwrap();
1518 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1519 assert_eq!(info.message_count, 1);
1520 assert_eq!(info.session_cost, 1.0);
1521 assert_eq!(info.updated_at, s.updated_at);
1522 }
1523
1524 #[test]
1526 fn legacy_header_defaults_to_zero() {
1527 let tmp = tempfile::TempDir::new().unwrap();
1528 let dir = tmp.path().join("sessions");
1529 let s = session_with(3);
1530 let json = serde_json::to_string(&s).unwrap();
1533 assert!(json.contains("\"message_count\":0,"));
1534 let legacy = json.replace("\"message_count\":0,", "");
1535 save_json_in_dir(&dir, &s.id, legacy.as_bytes()).unwrap();
1536 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1537 assert_eq!(info.message_count, 0);
1538 }
1539 }
1540
1541 use serial_test::serial;
1544
1545 fn save_test_session(dir: &std::path::Path, session: &Session) {
1546 crate::core::session_journal::save_session_in_dir(
1547 dir,
1548 session,
1549 crate::core::session_journal::SessionPersistence::default(),
1550 )
1551 .unwrap();
1552 }
1553
1554 struct BaseDirGuard(Option<String>);
1560
1561 impl BaseDirGuard {
1562 fn set(path: &std::path::Path) -> Self {
1563 let old = std::env::var("SYNAPS_BASE_DIR").ok();
1564 std::env::set_var("SYNAPS_BASE_DIR", path);
1565 Self(old)
1566 }
1567 }
1568
1569 impl Drop for BaseDirGuard {
1570 fn drop(&mut self) {
1571 match self.0.take() {
1572 Some(value) => std::env::set_var("SYNAPS_BASE_DIR", value),
1573 None => std::env::remove_var("SYNAPS_BASE_DIR"),
1574 }
1575 }
1576 }
1577
1578 #[test]
1579 #[serial]
1580 fn follow_chain_no_compaction_is_identity() {
1581 let dir = tempfile::tempdir().unwrap();
1582 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1583 std::fs::create_dir_all(dir.path().join(".synaps-cli/sessions")).unwrap();
1584 let s = Session::new("m", "brief", None);
1585 save_test_session(&dir.path().join(".synaps-cli/sessions"), &s);
1586 let resolved = follow_compaction_chain(s.clone()).unwrap();
1587 assert_eq!(resolved.session.id, s.id);
1588 assert!(resolved.compaction_notice.is_none());
1589 }
1590
1591 #[test]
1592 #[serial]
1593 fn follow_chain_of_three() {
1594 let dir = tempfile::tempdir().unwrap();
1595 let sessions_dir = dir.path().join(".synaps-cli/sessions");
1596 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1597 std::fs::create_dir_all(&sessions_dir).unwrap();
1598
1599 let mut a = Session::new("m", "brief", None);
1600 a.id = "aaa".into();
1601 let mut b = Session::new("m", "brief", None);
1602 b.id = "bbb".into();
1603 b.parent_session = Some("aaa".into());
1604 let mut c = Session::new("m", "brief", None);
1605 c.id = "ccc".into();
1606 c.parent_session = Some("bbb".into());
1607
1608 a.compacted_into = Some("bbb".into());
1609 b.compacted_into = Some("ccc".into());
1610 save_test_session(&sessions_dir, &a);
1613 save_test_session(&sessions_dir, &b);
1614 save_test_session(&sessions_dir, &c);
1615
1616 let resolved = follow_compaction_chain(a).unwrap();
1617 assert_eq!(resolved.session.id, "ccc");
1618 let notice = resolved.compaction_notice.unwrap();
1619 assert!(notice.contains("aaa"), "notice mentions original: {notice}");
1620 assert!(notice.contains("ccc"), "notice mentions final: {notice}");
1621 }
1622
1623 #[test]
1624 #[serial]
1625 fn follow_chain_cycle_guard() {
1626 let dir = tempfile::tempdir().unwrap();
1627 let sessions_dir = dir.path().join(".synaps-cli/sessions");
1628 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1629 std::fs::create_dir_all(&sessions_dir).unwrap();
1630
1631 let mut a = Session::new("m", "brief", None);
1632 a.id = "aaa".into();
1633 let mut b = Session::new("m", "brief", None);
1634 b.id = "bbb".into();
1635
1636 a.compacted_into = Some("bbb".into());
1637 b.compacted_into = Some("aaa".into()); save_test_session(&sessions_dir, &a);
1640 save_test_session(&sessions_dir, &b);
1641
1642 let err = follow_compaction_chain(a).unwrap_err();
1643 assert!(
1644 err.to_string().contains("cycle"),
1645 "error mentions cycle: {}",
1646 err
1647 );
1648 }
1649
1650 #[test]
1651 #[serial]
1652 fn follow_chain_missing_successor_error() {
1653 let dir = tempfile::tempdir().unwrap();
1654 let sessions_dir = dir.path().join(".synaps-cli/sessions");
1655 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1656 std::fs::create_dir_all(&sessions_dir).unwrap();
1657
1658 let mut a = Session::new("m", "brief", None);
1659 a.id = "aaa".into();
1660 a.compacted_into = Some("nonexistent".into());
1661
1662 save_test_session(&sessions_dir, &a);
1663
1664 let err = follow_compaction_chain(a).unwrap_err();
1665 let msg = err.to_string();
1666 assert!(
1667 msg.contains("compacted into nonexistent") && msg.contains("failed to load"),
1668 "error names the missing successor: {msg}"
1669 );
1670 }
1671
1672}