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