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 self.check_name(name).map_err(|e| match e {
399 NameError::Io(e) => e,
400 other => std::io::Error::other(other.to_string()),
401 })?;
402 self.name = Some(name.to_string());
403 Ok(())
404 }
405
406 pub fn check_name(&self, name: &str) -> Result<(), NameError> {
409 validate_name(name).map_err(NameError::Invalid)?;
410 if self.name.as_deref() == Some(name) {
411 return Ok(());
412 }
413 for s in &list_sessions().map_err(NameError::Io)? {
414 if s.name.as_deref() == Some(name) && s.id != self.id {
415 return Err(NameError::Taken {
416 name: name.to_string(),
417 by: s.id.clone(),
418 });
419 }
420 }
421 if crate::core::chain::load_chain(name).is_ok() {
422 return Err(NameError::ChainConflict {
423 name: name.to_string(),
424 });
425 }
426 Ok(())
427 }
428
429 pub fn clear_name(&mut self) {
430 self.name = None;
431 }
432}
433
434#[cfg(test)]
438pub(crate) fn save_json_in_dir(
439 dir: &std::path::Path,
440 id: &str,
441 json: &[u8],
442) -> std::io::Result<()> {
443 crate::core::session_journal::write_json_snapshot(dir, id, json)
446}
447
448pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
450 let dir = sessions_dir();
451 if !dir.exists() {
452 return Err(std::io::Error::new(
453 std::io::ErrorKind::NotFound,
454 "no sessions directory",
455 ));
456 }
457
458 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
460
461 if entries
463 .iter()
464 .any(|e| e.name == format!("{partial_id}.json"))
465 {
466 return Session::load(partial_id);
467 }
468
469 let mut matches: Vec<String> = Vec::new();
471 for entry in &entries {
472 if entry.name.ends_with(".json") {
473 let id = entry.name.trim_end_matches(".json");
474 if id.contains(partial_id) {
475 matches.push(id.to_string());
476 }
477 }
478 }
479
480 match matches.len() {
481 0 => Err(std::io::Error::new(
482 std::io::ErrorKind::NotFound,
483 format!("no session matching '{}'", partial_id),
484 )),
485 1 => Session::load(&matches[0]),
486 _ => Err(std::io::Error::other(format!(
487 "ambiguous: {} sessions match '{}'",
488 matches.len(),
489 partial_id
490 ))),
491 }
492}
493
494pub fn latest_session() -> std::io::Result<Session> {
496 let dir = sessions_dir();
503 if !dir.exists() {
504 return Err(std::io::Error::new(
505 std::io::ErrorKind::NotFound,
506 "no sessions found",
507 ));
508 }
509 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
512 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
513 let mut newest: Option<(std::time::SystemTime, String)> = None;
514 for entry in &entries {
515 let id = if let Some(id) = entry.name.strip_suffix(".json") {
519 id
520 } else if let Some(id) = entry.name.strip_suffix(".journal") {
521 if !names.contains(format!("{id}.json").as_str()) {
522 continue; }
524 id
525 } else {
526 continue;
527 };
528 if let Some(mtime) = entry.mtime {
529 if newest.as_ref().is_none_or(|(t, _)| mtime > *t) {
530 newest = Some((mtime, id.to_string()));
531 }
532 }
533 }
534 let id = newest
535 .map(|(_, id)| id)
536 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))?;
537 Session::load(&id)
538}
539
540pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
547 let dir = sessions_dir();
548 if !dir.exists() {
549 return Ok(Vec::new());
550 }
551 let mut sessions: Vec<SessionInfo> = Vec::new();
552 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
553 if entry.name.ends_with(".json") {
554 if let Some(info) = parse_session_header(&dir, &entry.name) {
555 sessions.push(info);
556 }
557 }
558 }
559 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
560 Ok(sessions)
561}
562
563pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
569 let dir = sessions_dir();
570 if !dir.exists() {
571 return Ok(Vec::new());
572 }
573 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
575 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
576 let mut by_snapshot: std::collections::HashMap<String, std::time::SystemTime> =
577 std::collections::HashMap::new();
578 for entry in &entries {
579 let id = if let Some(id) = entry.name.strip_suffix(".json") {
581 id
582 } else if let Some(id) = entry.name.strip_suffix(".journal") {
583 if !names.contains(format!("{id}.json").as_str()) {
584 continue;
585 }
586 id
587 } else {
588 continue;
589 };
590 if let Some(mtime) = entry.mtime {
591 let slot = by_snapshot
592 .entry(id.to_string())
593 .or_insert(std::time::SystemTime::UNIX_EPOCH);
594 if mtime > *slot {
595 *slot = mtime;
596 }
597 }
598 }
599 let mut files: Vec<(std::time::SystemTime, String)> =
600 by_snapshot.into_iter().map(|(id, t)| (t, id)).collect();
601 files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
602 files.truncate(limit);
603 let mut sessions: Vec<SessionInfo> = Vec::new();
604 for (_, id) in files {
605 if let Some(info) = parse_session_header(&dir, &format!("{id}.json")) {
606 sessions.push(info);
607 }
608 }
609 Ok(sessions)
610}
611
612fn parse_session_header(dir: &std::path::Path, file_name: &str) -> Option<SessionInfo> {
617 #[derive(Deserialize)]
618 struct SessionMetadata {
619 #[serde(default)]
620 _journal_generation: Option<String>,
621 id: String,
622 #[serde(default)]
623 title: String,
624 #[serde(default)]
625 name: Option<String>,
626 model: String,
627 created_at: DateTime<Utc>,
628 updated_at: DateTime<Utc>,
629 #[serde(default)]
630 session_cost: f64,
631 #[serde(default)]
632 message_count: usize,
633 }
634 let header = read_session_header(dir, file_name)?;
635 let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
636 let mut info = SessionInfo {
637 id: meta.id,
638 title: meta.title,
639 name: meta.name,
640 model: meta.model,
641 created_at: meta.created_at,
642 updated_at: meta.updated_at,
643 session_cost: meta.session_cost,
644 message_count: meta.message_count,
645 };
646 if let Some(tail) = crate::core::session_journal::journal_meta_tail_for_generation(
650 dir,
651 &info.id,
652 meta._journal_generation.as_deref(),
653 ) {
654 if tail.updated_at > info.updated_at {
655 info.updated_at = tail.updated_at;
656 info.session_cost = tail.session_cost;
657 if let Some(count) = tail.message_count {
658 info.message_count = count;
659 }
660 }
661 }
662 Some(info)
663}
664
665fn read_session_header(dir: &std::path::Path, file_name: &str) -> Option<String> {
673 let file = crate::core::session_journal::confined_open(dir, file_name).ok()??;
674 read_session_header_from_file(file)
675}
676
677pub(crate) fn read_session_header_from_file(mut file: std::fs::File) -> Option<String> {
679 use std::io::Read;
680 const KEY: &[u8] = b"\"api_messages\"";
681 const MAX_HEADER: usize = 256 * 1024; let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
687 let mut chunk = [0u8; 16 * 1024];
688 let mut cut: Option<usize> = None;
689 while buf.len() <= MAX_HEADER {
690 let n = file.read(&mut chunk).ok()?;
691 if n == 0 {
692 break;
693 }
694 buf.extend_from_slice(&chunk[..n]);
695 if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
696 cut = Some(pos);
697 break;
698 }
699 }
700
701 let end = cut.unwrap_or(buf.len());
702 let trimmed = String::from_utf8_lossy(&buf[..end]);
703 let mut s = trimmed.trim_end().to_string();
704 if s.ends_with(',') {
705 s.pop();
706 }
707 if !s.ends_with('}') {
708 s.push('}');
709 }
710 Some(s)
711}
712
713fn sessions_dir() -> PathBuf {
714 crate::config::get_active_config_dir().join("sessions")
715}
716
717pub fn delete_session_file(id: &str) -> std::io::Result<()> {
721 crate::core::session_journal::delete_session_files_in_dir(&sessions_dir(), id)
722}
723
724#[derive(Debug)]
727pub enum NameError {
728 Invalid(String),
730 Taken { name: String, by: String },
732 ChainConflict { name: String },
734 Io(std::io::Error),
736}
737
738impl NameError {
739 pub fn code(&self) -> &'static str {
741 match self {
742 Self::Invalid(_) => "invalid",
743 Self::Taken { .. } => "taken",
744 Self::ChainConflict { .. } => "chain_conflict",
745 Self::Io(_) => "io",
746 }
747 }
748}
749
750impl std::fmt::Display for NameError {
751 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
752 match self {
753 Self::Invalid(m) => f.write_str(m),
754 Self::Taken { name, by } => write!(f, "name '{name}' already used by session {by}"),
755 Self::ChainConflict { name } => {
756 write!(f, "name '{name}' conflicts with an existing chain name")
757 }
758 Self::Io(e) => write!(f, "cannot read the sessions directory: {e}"),
759 }
760 }
761}
762
763impl std::error::Error for NameError {}
764
765pub fn validate_name(name: &str) -> Result<(), String> {
767 if name.is_empty() {
768 return Err("name cannot be empty".into());
769 }
770 if name.len() > 40 {
771 return Err(format!("invalid name '{}': must be 40 chars or less", name));
772 }
773 if !name
774 .chars()
775 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
776 {
777 return Err(format!(
778 "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
779 name
780 ));
781 }
782 Ok(())
783}
784
785pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
789 let dir = sessions_dir();
790 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
791 if !entry.name.ends_with(".json") {
792 continue;
793 }
794 if let Some(info) = parse_session_header(&dir, &entry.name) {
795 if info.name.as_deref() == Some(name) {
796 return Session::load(&info.id);
797 }
798 }
799 }
800 Err(std::io::Error::new(
801 std::io::ErrorKind::NotFound,
802 format!("no session named '{}'", name),
803 ))
804}
805
806#[derive(Debug)]
808pub struct ResolvedSession {
809 pub session: Session,
810 pub compaction_notice: Option<String>,
814}
815
816const MAX_COMPACTION_HOPS: usize = 32;
818
819pub fn follow_compaction_chain(session: Session) -> std::io::Result<ResolvedSession> {
823 if session.compacted_into.is_none() {
824 return Ok(ResolvedSession {
825 session,
826 compaction_notice: None,
827 });
828 }
829
830 let original_id = session.id.clone();
831 let mut current = session;
832 let mut visited = std::collections::HashSet::new();
833 visited.insert(current.id.clone());
834
835 while let Some(ref successor_id) = current.compacted_into {
836 let succ_id = successor_id.clone();
837 if !visited.insert(succ_id.clone()) {
838 return Err(std::io::Error::new(
839 std::io::ErrorKind::InvalidData,
840 format!(
841 "compaction cycle detected: session {} loops back to {}",
842 current.id, succ_id
843 ),
844 ));
845 }
846 if visited.len() > MAX_COMPACTION_HOPS {
847 return Err(std::io::Error::new(
848 std::io::ErrorKind::InvalidData,
849 format!(
850 "compaction chain too long (>{} hops) starting from {}",
851 MAX_COMPACTION_HOPS, original_id
852 ),
853 ));
854 }
855 current = Session::load(&succ_id).map_err(|e| {
856 std::io::Error::new(
857 e.kind(),
858 format!(
859 "session {} was compacted into {}, but the successor failed to load: {}",
860 current.id, succ_id, e
861 ),
862 )
863 })?;
864 }
865
866 let notice = format!(
867 "{} was compacted into {} \u{2014} continuing there",
868 original_id, current.id
869 );
870 tracing::info!("{}", notice);
871 Ok(ResolvedSession {
872 session: current,
873 compaction_notice: Some(notice),
874 })
875}
876
877pub fn read_compacted_into(id: &str) -> std::io::Result<Option<String>> {
880 read_compacted_into_in_dir(&sessions_dir(), id)
881}
882
883pub fn read_compacted_into_in_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Option<String>> {
885 let file_name = format!("{}.json", id);
886 let header = read_session_header(dir, &file_name).ok_or_else(|| {
887 std::io::Error::new(
888 std::io::ErrorKind::NotFound,
889 format!("no session file for '{}'", id),
890 )
891 })?;
892 #[derive(serde::Deserialize)]
893 struct Hdr {
894 #[serde(default)]
895 compacted_into: Option<String>,
896 }
897 let hdr: Hdr = serde_json::from_str(&header).map_err(|e| {
898 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
899 })?;
900 Ok(hdr.compacted_into)
901}
902
903pub fn resolve_session(query: &str) -> std::io::Result<Session> {
906 if let Ok(ptr) = crate::core::chain::load_chain(query) {
907 match Session::load(&ptr.head) {
908 Ok(s) => {
909 tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
910 return Ok(s);
911 }
912 Err(e) => {
913 return Err(std::io::Error::new(
914 e.kind(),
915 format!(
916 "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
917 query, ptr.head, e, query
918 ),
919 ));
920 }
921 }
922 }
923 if let Ok(s) = find_session_by_name(query) {
924 tracing::info!("resolved '{}' via session name → {}", query, s.id);
925 return Ok(s);
926 }
927 find_session(query)
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use serde_json::json;
934
935 fn test_record(parent: &Session) -> crate::compaction::CompactionRecord {
937 crate::compaction::CompactionRecord {
938 schema_version: crate::compaction::COMPACTION_SUMMARY_SCHEMA_VERSION,
939 source_session: parent.id.clone(),
940 source_message_count: parent.api_messages.len(),
941 source_range_digest: crate::compaction::message_range_digest(&parent.api_messages),
942 summary_provider: "anthropic".into(),
943 summary_model: "claude-sonnet-4-6".into(),
944 created_at: Utc::now(),
945 prompt_stack_digest: crate::compaction::prompt_stack_digest(&["test"]),
946 included_classes: crate::compaction::ContentClass::ALL.to_vec(),
947 excluded_classes: Vec::new(),
948 redaction_policy: crate::compaction::RedactionPolicy::TruncationOnly,
949 prior_system_prompt: parent.system_prompt.clone(),
950 }
951 }
952
953 #[cfg(unix)]
954 #[tokio::test]
955 async fn durable_dispatch_checkpoints_json_and_journal_without_global_config() {
956 use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
957 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
958 let tmp = tempfile::TempDir::new().unwrap();
959 let dir = tmp
960 .path()
961 .canonicalize()
962 .unwrap()
963 .join("profile/nested/sessions");
964 let mut session = Session::new("m", "medium", None);
965 session.api_messages.push(std::sync::Arc::new(
966 json!({"role":"user","content":"checkpoint"}),
967 ));
968 session.save_durable_to(dir.clone(), mode).await.unwrap();
969 let loaded = Session::load_from_dir(&dir, &session.id).unwrap();
970 assert_eq!(loaded.api_messages, session.api_messages);
971 session.api_messages.push(std::sync::Arc::new(
974 json!({"role":"assistant","content":"old tail"}),
975 ));
976 save_session_in_dir(&dir, &session, SessionPersistence::Journal).unwrap();
977 session.api_messages.truncate(1);
978 session.save_durable_to(dir.clone(), mode).await.unwrap();
979 assert_eq!(
980 Session::load_from_dir(&dir, &session.id)
981 .unwrap()
982 .api_messages,
983 session.api_messages
984 );
985 let snapshot: Session = serde_json::from_slice(
986 &std::fs::read(dir.join(format!("{}.json", session.id))).unwrap(),
987 )
988 .unwrap();
989 assert_eq!(snapshot.api_messages, session.api_messages);
990 assert_eq!(
991 dir.join(format!("{}.journal", session.id)).exists(),
992 mode == SessionPersistence::Journal
993 );
994 }
995 }
996
997 #[cfg(unix)]
998 #[tokio::test]
999 async fn durable_dispatch_returns_io_errors_and_refuses_symlinks() {
1000 use crate::core::session_journal::SessionPersistence;
1001 let tmp = tempfile::TempDir::new().unwrap();
1002 let root = tmp.path().canonicalize().unwrap();
1003 let victim = root.join("victim");
1004 std::fs::write(&victim, b"unchanged").unwrap();
1005 let dir = root.join("sessions");
1006 std::fs::create_dir(&dir).unwrap();
1007 let session = Session::new("m", "medium", None);
1008 std::os::unix::fs::symlink(&victim, dir.join(format!("{}.json", session.id))).unwrap();
1009 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1010 assert!(session.save_durable_to(dir.clone(), mode).await.is_err());
1011 }
1012 assert_eq!(std::fs::read(&victim).unwrap(), b"unchanged");
1013 }
1014
1015 #[cfg(unix)]
1016 #[tokio::test]
1017 async fn durable_dispatch_reports_post_commit_cleanup_error() {
1018 use crate::core::session_journal::SessionPersistence;
1019 let tmp = tempfile::TempDir::new().unwrap();
1020 let dir = tmp.path().canonicalize().unwrap();
1021 let mut session = Session::new("m", "medium", None);
1022 session
1023 .save_durable_to(dir.clone(), SessionPersistence::Journal)
1024 .await
1025 .unwrap();
1026 session.title = "published candidate".into();
1027 std::fs::create_dir(dir.join(format!("{}.journal.tmp", session.id))).unwrap();
1028 assert!(session
1029 .save_durable_to(dir.clone(), SessionPersistence::Journal)
1030 .await
1031 .is_err());
1032 assert_eq!(
1034 Session::load_from_dir(&dir, &session.id).unwrap().title,
1035 session.title
1036 );
1037 }
1038
1039 #[test]
1040 fn test_session_new() {
1041 let session = Session::new("gpt-4", "brief", Some("test prompt"));
1042
1043 assert_eq!(session.model, "gpt-4");
1045 assert_eq!(session.thinking_level, "brief");
1046 assert_eq!(session.system_prompt, Some("test prompt".to_string()));
1047
1048 assert!(!session.id.is_empty());
1050
1051 assert_eq!(session.title, "");
1053
1054 assert_eq!(session.total_input_tokens, 0);
1056 assert_eq!(session.total_output_tokens, 0);
1057
1058 assert_eq!(session.session_cost, 0.0);
1060
1061 assert!(session.api_messages.is_empty());
1063
1064 let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
1066 assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
1067 assert_eq!(session_no_prompt.thinking_level, "normal");
1068 assert_eq!(session_no_prompt.system_prompt, None);
1069 }
1070
1071 #[test]
1072 fn test_session_auto_title() {
1073 let mut session = Session::new("gpt-4", "brief", None);
1074
1075 session.api_messages.push(std::sync::Arc::new(json!({
1077 "role": "user",
1078 "content": "hello world"
1079 })));
1080
1081 session.auto_title();
1083
1084 assert_eq!(session.title, "hello world");
1086
1087 session.title = "existing title".to_string();
1089 session.auto_title();
1090 assert_eq!(session.title, "existing title");
1091
1092 let mut empty_session = Session::new("gpt-4", "brief", None);
1094 empty_session.auto_title();
1095 assert_eq!(empty_session.title, "");
1096
1097 let mut session_no_user = Session::new("gpt-4", "brief", None);
1099 session_no_user
1100 .api_messages
1101 .push(std::sync::Arc::new(json!({
1102 "role": "assistant",
1103 "content": "response"
1104 })));
1105 session_no_user.auto_title();
1106 assert_eq!(session_no_user.title, "");
1107
1108 let mut session_long = Session::new("gpt-4", "brief", None);
1110 let long_content = "a".repeat(100);
1111 session_long.api_messages.push(std::sync::Arc::new(json!({
1112 "role": "user",
1113 "content": long_content
1114 })));
1115 session_long.auto_title();
1116 assert_eq!(session_long.title.len(), 80);
1117 assert_eq!(session_long.title, "a".repeat(80));
1118 }
1119
1120 #[test]
1121 fn attachment_projection_and_auto_title_never_read_sources_or_tool_results() {
1122 let content = json!([
1123 {"type": "text", "text": "Review"},
1124 {"type": "image", "source": {"type": "base64", "data": "IMAGE_SENTINEL"}},
1125 {"type": "document", "title": "a.txt",
1126 "source": {"type": "text", "data": "DOCUMENT_TEXT_SENTINEL"}},
1127 {"type": "document", "title": "b.pdf",
1128 "source": {"type": "base64", "data": "PDF_SENTINEL"}},
1129 {"type": "text", "text": "Then compare"},
1130 {"type": "tool_result", "tool_use_id": "t1", "content": [
1131 {"type": "text", "text": "TOOL_TEXT_SENTINEL"},
1132 {"type": "document", "title": "TOOL_TITLE_SENTINEL",
1133 "source": {"type": "text", "data": "NESTED_DOCUMENT_SENTINEL"}}
1134 ]}
1135 ]);
1136 let original = content.clone();
1137 let projected = user_content_for_display(&content).unwrap();
1138 assert_eq!(projected, "Review\n[attached image]\n[attached document: a.txt]\n[attached document: b.pdf]\nThen compare");
1139 assert!(!projected.contains("SENTINEL"));
1140 assert_eq!(
1141 content, original,
1142 "projection must not mutate canonical data"
1143 );
1144
1145 let tool_only = json!([content[5].clone()]);
1146 assert!(user_content_for_display(&tool_only).is_none());
1147 let mut session = Session::new("m", "low", None);
1148 session.api_messages.push(std::sync::Arc::new(json!({
1149 "role": "user", "content": tool_only
1150 })));
1151 session.api_messages.push(std::sync::Arc::new(json!({
1152 "role": "user", "content": content
1153 })));
1154 session.auto_title();
1155 assert_eq!(
1156 session.title,
1157 projected.chars().take(80).collect::<String>()
1158 );
1159 assert!(!session.title.contains("SENTINEL"));
1160 }
1161
1162 #[test]
1163 fn attachment_only_titles_and_safe_filename_labels() {
1164 let content = json!([
1165 {"type": "document", "title": "/private/path/[report]\u{1b}\n.txt",
1166 "source": {"type": "text", "data": "DO_NOT_DISPLAY"}},
1167 {"type": "document", "filename": "C:\\private\\note.txt"},
1168 {"type": "document", "source": {"data": "NO_FILENAME"}}
1169 ]);
1170 assert_eq!(
1171 user_content_for_display(&content).unwrap(),
1172 "[attached document: report.txt]\n[attached document: note.txt]\n[attached document]"
1173 );
1174 for (block, title) in [
1175 (
1176 json!({"type": "image", "source": {"data": "IMAGE_SENTINEL"}}),
1177 "[attached image]",
1178 ),
1179 (
1180 json!({"type": "document", "title": "notes.txt",
1181 "source": {"type": "text", "data": "TEXT_SENTINEL"}}),
1182 "[attached document: notes.txt]",
1183 ),
1184 ] {
1185 let mut session = Session::new("m", "low", None);
1186 session.api_messages.push(std::sync::Arc::new(json!({
1187 "role": "user", "content": [block]
1188 })));
1189 session.auto_title();
1190 assert_eq!(session.title, title);
1191 }
1192 assert!(user_content_for_display(&json!([])).is_none());
1193 assert!(
1194 user_content_for_display(&json!([{"type": "unknown", "text": "hidden"}])).is_none()
1195 );
1196 assert_eq!(
1197 user_content_for_display(&json!("plain text")).as_deref(),
1198 Some("plain text")
1199 );
1200 }
1201
1202 #[test]
1203 fn test_session_info() {
1204 let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
1205
1206 session.api_messages.push(std::sync::Arc::new(json!({
1208 "role": "user",
1209 "content": "test message"
1210 })));
1211 session.api_messages.push(std::sync::Arc::new(json!({
1212 "role": "assistant",
1213 "content": "test response"
1214 })));
1215
1216 session.title = "Test Title".to_string();
1217 session.session_cost = 0.05;
1218
1219 let info = session.info();
1220
1221 assert_eq!(info.id, session.id);
1222 assert_eq!(info.title, "Test Title");
1223 assert_eq!(info.model, "gpt-4");
1224 assert_eq!(info.created_at, session.created_at);
1225 assert_eq!(info.updated_at, session.updated_at);
1226 assert_eq!(info.session_cost, 0.05);
1227 assert_eq!(info.message_count, 2);
1228 }
1229
1230 #[test]
1231 fn test_session_info_struct() {
1232 let now = Utc::now();
1233
1234 let session_info = SessionInfo {
1235 id: "test-id".to_string(),
1236 title: "Test Title".to_string(),
1237 name: None,
1238 model: "gpt-4".to_string(),
1239 created_at: now,
1240 updated_at: now,
1241 session_cost: 1.23,
1242 message_count: 5,
1243 };
1244
1245 assert_eq!(session_info.id, "test-id");
1247 assert_eq!(session_info.title, "Test Title");
1248 assert_eq!(session_info.model, "gpt-4");
1249 assert_eq!(session_info.created_at, now);
1250 assert_eq!(session_info.updated_at, now);
1251 assert_eq!(session_info.session_cost, 1.23);
1252 assert_eq!(session_info.message_count, 5);
1253 }
1254
1255 #[test]
1256 fn test_session_serialization_round_trip() {
1257 let mut session = Session::new(
1258 "gpt-4-turbo",
1259 "detailed",
1260 Some("You are a helpful assistant"),
1261 );
1262 session.title = "Test Session".to_string();
1263 session.api_messages.push(std::sync::Arc::new(
1264 json!({"role": "user", "content": "test"}),
1265 ));
1266 session.total_input_tokens = 100;
1267 session.total_output_tokens = 200;
1268 session.session_cost = 0.15;
1269
1270 let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
1272
1273 let deserialized: Session =
1275 serde_json::from_str(&json_str).expect("Failed to deserialize session");
1276
1277 assert_eq!(deserialized.id, session.id);
1279 assert_eq!(deserialized.title, session.title);
1280 assert_eq!(deserialized.model, session.model);
1281 assert_eq!(deserialized.thinking_level, session.thinking_level);
1282 assert_eq!(deserialized.system_prompt, session.system_prompt);
1283 assert_eq!(deserialized.created_at, session.created_at);
1284 assert_eq!(deserialized.updated_at, session.updated_at);
1285 assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
1286 assert_eq!(
1287 deserialized.total_output_tokens,
1288 session.total_output_tokens
1289 );
1290 assert_eq!(deserialized.session_cost, session.session_cost);
1291 assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
1292 assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
1293 }
1294
1295 #[test]
1296 fn codex_ultra_round_trips_and_compaction_preserves_logical_mode() {
1297 let parent = Session::new("openai-codex/gpt-5.6-sol", "ultra", None);
1298 let encoded = serde_json::to_string(&parent).expect("serialize Ultra session");
1299 let persisted: serde_json::Value =
1300 serde_json::from_str(&encoded).expect("inspect persisted session");
1301 assert_eq!(persisted["thinking_level"], "ultra");
1302 let restored: Session = serde_json::from_str(&encoded).expect("restore Ultra session");
1303 assert_eq!(restored.model, "openai-codex/gpt-5.6-sol");
1304 assert_eq!(restored.thinking_level, "ultra");
1305
1306 let compacted =
1307 Session::from_compaction_record(&restored, "summary", test_record(&restored));
1308 assert_eq!(compacted.model, restored.model);
1309 assert_eq!(compacted.thinking_level, "ultra");
1310 assert_eq!(
1311 compacted.parent_session.as_deref(),
1312 Some(restored.id.as_str())
1313 );
1314 }
1315
1316 #[test]
1317 fn codex_max_round_trips_without_becoming_ultra_or_xhigh() {
1318 let session = Session::new("openai-codex/gpt-5.6-luna", "max", None);
1319 let encoded = serde_json::to_string(&session).expect("serialize Max session");
1320 let restored: Session = serde_json::from_str(&encoded).expect("restore Max session");
1321 assert_eq!(restored.thinking_level, "max");
1322 assert_ne!(restored.thinking_level, "ultra");
1323 assert_ne!(restored.thinking_level, "xhigh");
1324 }
1325
1326 #[test]
1327 fn test_session_serialization_preserves_all_fields() {
1328 let mut session = Session::new(
1329 "claude-3-opus",
1330 "comprehensive",
1331 Some("Custom system prompt"),
1332 );
1333 session.title = "Complex Session".to_string();
1334
1335 session.api_messages.push(std::sync::Arc::new(
1337 json!({"role": "user", "content": "First message"}),
1338 ));
1339 session.api_messages.push(std::sync::Arc::new(
1340 json!({"role": "assistant", "content": "First response"}),
1341 ));
1342 session.api_messages.push(std::sync::Arc::new(
1343 json!({"role": "user", "content": "Second message"}),
1344 ));
1345
1346 session.total_input_tokens = 1500;
1348 session.total_output_tokens = 2500;
1349 session.session_cost = 0.75;
1350
1351 let json_str = serde_json::to_string(&session).unwrap();
1353 let restored: Session = serde_json::from_str(&json_str).unwrap();
1354
1355 assert_eq!(restored.id, session.id);
1357 assert_eq!(restored.title, "Complex Session");
1358 assert_eq!(restored.model, "claude-3-opus");
1359 assert_eq!(restored.thinking_level, "comprehensive");
1360 assert_eq!(
1361 restored.system_prompt.as_ref().unwrap(),
1362 "Custom system prompt"
1363 );
1364 assert_eq!(restored.created_at, session.created_at);
1365 assert_eq!(restored.updated_at, session.updated_at);
1366 assert_eq!(restored.total_input_tokens, 1500);
1367 assert_eq!(restored.total_output_tokens, 2500);
1368 assert_eq!(restored.session_cost, 0.75);
1369 assert_eq!(restored.api_messages.len(), 3);
1370 assert_eq!(restored.api_messages[0]["role"], "user");
1371 assert_eq!(restored.api_messages[0]["content"], "First message");
1372 assert_eq!(restored.api_messages[1]["role"], "assistant");
1373 assert_eq!(restored.api_messages[2]["content"], "Second message");
1374 }
1375
1376 #[test]
1377 fn test_session_info_from_session_with_messages() {
1378 let mut session = Session::new("gpt-3.5-turbo", "normal", None);
1379
1380 session.api_messages.push(std::sync::Arc::new(
1382 json!({"role": "user", "content": "message 1"}),
1383 ));
1384 session.api_messages.push(std::sync::Arc::new(
1385 json!({"role": "assistant", "content": "response 1"}),
1386 ));
1387 session.api_messages.push(std::sync::Arc::new(
1388 json!({"role": "user", "content": "message 2"}),
1389 ));
1390
1391 let info = session.info();
1392
1393 assert_eq!(info.message_count, 3);
1395 assert_eq!(info.id, session.id);
1396 assert_eq!(info.model, "gpt-3.5-turbo");
1397 }
1398
1399 #[test]
1400 fn test_session_auto_title_truncation() {
1401 let mut session = Session::new("gpt-4", "brief", None);
1402
1403 let long_content = "a".repeat(200);
1405 session.api_messages.push(std::sync::Arc::new(json!({
1406 "role": "user",
1407 "content": long_content
1408 })));
1409
1410 session.auto_title();
1411
1412 assert_eq!(session.title.len(), 80);
1414 assert_eq!(session.title, "a".repeat(80));
1415 }
1416
1417 #[test]
1418 fn test_session_auto_title_skips_non_user_messages() {
1419 let mut session = Session::new("gpt-4", "brief", None);
1420
1421 session.api_messages.push(std::sync::Arc::new(json!({
1423 "role": "assistant",
1424 "content": "This should be ignored for auto title"
1425 })));
1426
1427 session.auto_title();
1428
1429 assert_eq!(session.title, "");
1431
1432 session.api_messages.push(std::sync::Arc::new(json!({
1434 "role": "system",
1435 "content": "System message should also be ignored"
1436 })));
1437
1438 session.auto_title();
1439 assert_eq!(session.title, "");
1440 }
1441
1442 #[test]
1443 fn test_session_new_generates_unique_ids() {
1444 let session1 = Session::new("gpt-4", "brief", None);
1445 let session2 = Session::new("gpt-4", "brief", None);
1446
1447 assert_ne!(session1.id, session2.id);
1449 assert!(!session1.id.is_empty());
1450 assert!(!session2.id.is_empty());
1451 }
1452
1453 #[test]
1454 fn test_session_new_timestamps() {
1455 let before = Utc::now();
1456 let session = Session::new("gpt-4", "brief", None);
1457 let after = Utc::now();
1458
1459 let created_diff = (session.created_at - before).num_seconds().abs();
1461 let updated_diff = (session.updated_at - before).num_seconds().abs();
1462
1463 assert!(
1464 created_diff <= 2,
1465 "created_at should be within 2 seconds of now"
1466 );
1467 assert!(
1468 updated_diff <= 2,
1469 "updated_at should be within 2 seconds of now"
1470 );
1471
1472 assert_eq!(session.created_at, session.updated_at);
1474
1475 assert!(session.created_at <= after);
1477 assert!(session.updated_at <= after);
1478 }
1479
1480 #[test]
1481 fn test_validate_name() {
1482 assert!(validate_name("work").is_ok());
1483 assert!(validate_name("my-project-2").is_ok());
1484 assert!(validate_name("a").is_ok());
1485 assert!(validate_name(&"a".repeat(40)).is_ok());
1486
1487 assert!(validate_name("").is_err());
1488 assert!(validate_name(&"a".repeat(41)).is_err());
1489 assert!(validate_name("UPPER").is_err());
1490 assert!(validate_name("has space").is_err());
1491 assert!(validate_name("under_score").is_err());
1492 assert!(validate_name("dots.bad").is_err());
1493
1494 let err = validate_name("Bad").unwrap_err();
1495 assert!(err.contains("Bad"));
1496 assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
1497 }
1498
1499 #[test]
1500 fn test_clear_name() {
1501 let mut s = Session::new("m", "brief", None);
1502 s.name = Some("foo".into());
1503 s.clear_name();
1504 assert_eq!(s.name, None);
1505 }
1506
1507 #[test]
1508 fn ultracode_serialization_and_compaction_roundtrip_is_distinct() {
1509 let original = Session::new("anthropic/claude-fable-5", "ultracode", None);
1510 let json = serde_json::to_string(&original).unwrap();
1511 let restored: Session = serde_json::from_str(&json).unwrap();
1512 assert_eq!(restored.thinking_level, "ultracode");
1513 for other in ["ultra", "max", "xhigh"] {
1514 assert_ne!(restored.thinking_level, other);
1515 }
1516 let compacted =
1517 Session::from_compaction_record(&restored, "summary", test_record(&restored));
1518 let resumed: Session =
1519 serde_json::from_str(&serde_json::to_string(&compacted).unwrap()).unwrap();
1520 assert_eq!(resumed.thinking_level, "ultracode");
1521 assert_eq!(
1522 resumed.parent_session.as_deref(),
1523 Some(original.id.as_str())
1524 );
1525 }
1526
1527 #[cfg(unix)]
1532 mod private_modes {
1533 use super::*;
1534 use crate::core::private_fs::test_support::UmaskGuard;
1535 use serial_test::serial;
1536 use std::os::unix::fs::PermissionsExt;
1537
1538 fn mode_of(path: &std::path::Path) -> u32 {
1539 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1540 }
1541
1542 #[test]
1543 #[serial(umask)]
1544 fn save_creates_0600_session_file_and_0700_dir_under_permissive_umask() {
1545 let _umask = UmaskGuard::set(0);
1546 let tmp = tempfile::TempDir::new().unwrap();
1547 let dir = tmp.path().join("sessions");
1548 save_json_in_dir(&dir, "sess-1", b"{}").unwrap();
1549 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1550 assert_eq!(
1551 mode_of(&dir.join("sess-1.json")),
1552 0o600,
1553 "session file must be 0600"
1554 );
1555 }
1556
1557 #[test]
1558 fn save_refuses_symlink_target() {
1559 let tmp = tempfile::TempDir::new().unwrap();
1560 let dir = tmp.path().join("sessions");
1561 std::fs::create_dir_all(&dir).unwrap();
1562 let victim = tmp.path().join("victim.json");
1563 std::fs::write(&victim, "original").unwrap();
1564 std::os::unix::fs::symlink(&victim, dir.join("sess-1.json")).unwrap();
1565 let res = save_json_in_dir(&dir, "sess-1", b"{}");
1566 assert!(res.is_err(), "save onto a symlink must fail");
1567 assert_eq!(
1568 std::fs::read_to_string(&victim).unwrap(),
1569 "original",
1570 "no bytes may be written through the planted symlink"
1571 );
1572 }
1573 }
1574
1575 mod header_message_count {
1576 use super::*;
1577 use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
1578 use std::sync::Arc;
1579
1580 fn session_with(n: usize) -> Session {
1581 let mut s = Session::new("claude-sonnet-4-6", "medium", None);
1582 for i in 0..n {
1583 let role = if i % 2 == 0 { "user" } else { "assistant" };
1584 s.api_messages.push(Arc::new(
1585 serde_json::json!({"role": role, "content": format!("m{i}")}),
1586 ));
1587 }
1588 s
1589 }
1590
1591 #[test]
1594 fn json_save_persists_count_in_header() {
1595 let tmp = tempfile::TempDir::new().unwrap();
1596 let dir = tmp.path().join("sessions");
1597 let s = session_with(7);
1598 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1599 let header = read_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1600 assert!(
1601 !header.contains("\"api_messages\""),
1602 "header must stop before the message array"
1603 );
1604 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1605 assert_eq!(info.message_count, 7);
1606 }
1607
1608 #[test]
1610 fn journal_append_updates_count() {
1611 let tmp = tempfile::TempDir::new().unwrap();
1612 let dir = tmp.path().join("sessions");
1613 let mut s = session_with(2);
1614 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1615 s.api_messages.push(Arc::new(
1616 serde_json::json!({"role": "user", "content": "more"}),
1617 ));
1618 s.updated_at = Utc::now();
1619 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1620 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1621 assert_eq!(info.message_count, 3);
1622 }
1623
1624 #[test]
1625 fn listing_does_not_overlay_stale_generation_metadata() {
1626 let tmp = tempfile::TempDir::new().unwrap();
1627 let dir = tmp.path().canonicalize().unwrap().join("sessions");
1628 let mut s = session_with(2);
1629 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1630 s.api_messages
1631 .push(Arc::new(serde_json::json!({"role":"user","content":"old"})));
1632 s.session_cost = 999.0;
1633 s.updated_at += chrono::Duration::hours(1);
1634 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1635 let journal_path = dir.join(format!("{}.journal", s.id));
1636 let stale = std::fs::read(&journal_path).unwrap();
1637 s.api_messages.truncate(1);
1638 s.session_cost = 1.0;
1639 s.updated_at -= chrono::Duration::hours(2);
1640 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1641 std::fs::write(journal_path, stale).unwrap();
1642 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1643 assert_eq!(info.message_count, 1);
1644 assert_eq!(info.session_cost, 1.0);
1645 assert_eq!(info.updated_at, s.updated_at);
1646 }
1647
1648 #[test]
1650 fn legacy_header_defaults_to_zero() {
1651 let tmp = tempfile::TempDir::new().unwrap();
1652 let dir = tmp.path().join("sessions");
1653 let s = session_with(3);
1654 let json = serde_json::to_string(&s).unwrap();
1657 assert!(json.contains("\"message_count\":0,"));
1658 let legacy = json.replace("\"message_count\":0,", "");
1659 save_json_in_dir(&dir, &s.id, legacy.as_bytes()).unwrap();
1660 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1661 assert_eq!(info.message_count, 0);
1662 }
1663 }
1664
1665 use serial_test::serial;
1668
1669 fn save_test_session(dir: &std::path::Path, session: &Session) {
1670 crate::core::session_journal::save_session_in_dir(
1671 dir,
1672 session,
1673 crate::core::session_journal::SessionPersistence::default(),
1674 )
1675 .unwrap();
1676 }
1677
1678 struct BaseDirGuard(Option<String>);
1684
1685 impl BaseDirGuard {
1686 fn set(path: &std::path::Path) -> Self {
1687 let old = std::env::var("SYNAPS_BASE_DIR").ok();
1688 std::env::set_var("SYNAPS_BASE_DIR", path);
1689 Self(old)
1690 }
1691 }
1692
1693 impl Drop for BaseDirGuard {
1694 fn drop(&mut self) {
1695 match self.0.take() {
1696 Some(value) => std::env::set_var("SYNAPS_BASE_DIR", value),
1697 None => std::env::remove_var("SYNAPS_BASE_DIR"),
1698 }
1699 }
1700 }
1701
1702 #[test]
1703 #[serial]
1704 fn follow_chain_no_compaction_is_identity() {
1705 let dir = tempfile::tempdir().unwrap();
1706 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1707 std::fs::create_dir_all(dir.path().join(".synaps-cli/sessions")).unwrap();
1708 let s = Session::new("m", "brief", None);
1709 save_test_session(&dir.path().join(".synaps-cli/sessions"), &s);
1710 let resolved = follow_compaction_chain(s.clone()).unwrap();
1711 assert_eq!(resolved.session.id, s.id);
1712 assert!(resolved.compaction_notice.is_none());
1713 }
1714
1715 #[test]
1716 #[serial]
1717 fn follow_chain_of_three() {
1718 let dir = tempfile::tempdir().unwrap();
1719 let sessions_dir = dir.path().join(".synaps-cli/sessions");
1720 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1721 std::fs::create_dir_all(&sessions_dir).unwrap();
1722
1723 let mut a = Session::new("m", "brief", None);
1724 a.id = "aaa".into();
1725 let mut b = Session::new("m", "brief", None);
1726 b.id = "bbb".into();
1727 b.parent_session = Some("aaa".into());
1728 let mut c = Session::new("m", "brief", None);
1729 c.id = "ccc".into();
1730 c.parent_session = Some("bbb".into());
1731
1732 a.compacted_into = Some("bbb".into());
1733 b.compacted_into = Some("ccc".into());
1734 save_test_session(&sessions_dir, &a);
1737 save_test_session(&sessions_dir, &b);
1738 save_test_session(&sessions_dir, &c);
1739
1740 let resolved = follow_compaction_chain(a).unwrap();
1741 assert_eq!(resolved.session.id, "ccc");
1742 let notice = resolved.compaction_notice.unwrap();
1743 assert!(notice.contains("aaa"), "notice mentions original: {notice}");
1744 assert!(notice.contains("ccc"), "notice mentions final: {notice}");
1745 }
1746
1747 #[test]
1748 #[serial]
1749 fn follow_chain_cycle_guard() {
1750 let dir = tempfile::tempdir().unwrap();
1751 let sessions_dir = dir.path().join(".synaps-cli/sessions");
1752 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1753 std::fs::create_dir_all(&sessions_dir).unwrap();
1754
1755 let mut a = Session::new("m", "brief", None);
1756 a.id = "aaa".into();
1757 let mut b = Session::new("m", "brief", None);
1758 b.id = "bbb".into();
1759
1760 a.compacted_into = Some("bbb".into());
1761 b.compacted_into = Some("aaa".into()); save_test_session(&sessions_dir, &a);
1764 save_test_session(&sessions_dir, &b);
1765
1766 let err = follow_compaction_chain(a).unwrap_err();
1767 assert!(
1768 err.to_string().contains("cycle"),
1769 "error mentions cycle: {}",
1770 err
1771 );
1772 }
1773
1774 #[test]
1775 #[serial]
1776 fn follow_chain_missing_successor_error() {
1777 let dir = tempfile::tempdir().unwrap();
1778 let sessions_dir = dir.path().join(".synaps-cli/sessions");
1779 let _base_dir = BaseDirGuard::set(&dir.path().join(".synaps-cli"));
1780 std::fs::create_dir_all(&sessions_dir).unwrap();
1781
1782 let mut a = Session::new("m", "brief", None);
1783 a.id = "aaa".into();
1784 a.compacted_into = Some("nonexistent".into());
1785
1786 save_test_session(&sessions_dir, &a);
1787
1788 let err = follow_compaction_chain(a).unwrap_err();
1789 let msg = err.to_string();
1790 assert!(
1791 msg.contains("compacted into nonexistent") && msg.contains("failed to load"),
1792 "error names the missing successor: {msg}"
1793 );
1794 }
1795
1796}