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