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