1use crate::core::stream_types::SharedMessage;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Session {
8 pub id: String,
9 pub title: String,
10 #[serde(default, skip_serializing_if = "Option::is_none")]
11 pub name: Option<String>,
12 pub model: String,
13 pub thinking_level: String,
14 pub system_prompt: Option<String>,
15 pub created_at: DateTime<Utc>,
16 pub updated_at: DateTime<Utc>,
17 pub total_input_tokens: u64,
18 pub total_output_tokens: u64,
19 pub session_cost: f64,
20 #[serde(default)]
26 pub message_count: usize,
27 pub api_messages: Vec<SharedMessage>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub abort_context: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub parent_session: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub compacted_into: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub prompt_provenance: Option<crate::prompt::PromptProvenance>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub compaction: Option<crate::compaction::CompactionRecord>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SessionInfo {
48 pub id: String,
49 pub title: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub name: Option<String>,
52 pub model: String,
53 pub created_at: DateTime<Utc>,
54 pub updated_at: DateTime<Utc>,
55 pub session_cost: f64,
56 pub message_count: usize,
57}
58
59impl Session {
60 pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
61 let now = Utc::now();
62 let id = format!(
63 "{}-{}",
64 now.format("%Y%m%d-%H%M%S"),
65 &uuid::Uuid::new_v4().to_string()[..4]
66 );
67 Session {
68 id,
69 title: String::new(),
70 name: None,
71 model: model.to_string(),
72 thinking_level: thinking_level.to_string(),
73 system_prompt: system_prompt.map(|s| s.to_string()),
74 created_at: now,
75 updated_at: now,
76 total_input_tokens: 0,
77 total_output_tokens: 0,
78 session_cost: 0.0,
79 message_count: 0,
80 api_messages: Vec::new(),
81 abort_context: None,
82 parent_session: None,
83 compacted_into: None,
84 prompt_provenance: None,
85 compaction: None,
86 }
87 }
88
89 pub fn from_compaction_record(
95 parent: &Session,
96 summary_text: &str,
97 record: crate::compaction::CompactionRecord,
98 ) -> Self {
99 let now = Utc::now();
100 let id = format!(
101 "{}-{}",
102 now.format("%Y%m%d-%H%M%S"),
103 &uuid::Uuid::new_v4().to_string()[..4]
104 );
105 let name = parent.name.clone();
109 Session {
110 id,
111 title: format!(
112 "↳ {}",
113 if parent.title.is_empty() {
114 &parent.id
115 } else {
116 &parent.title
117 }
118 ),
119 name,
120 model: parent.model.clone(),
121 thinking_level: parent.thinking_level.clone(),
122 system_prompt: parent.system_prompt.clone(),
123 created_at: now,
124 updated_at: now,
125 total_input_tokens: 0,
126 total_output_tokens: 0,
127 session_cost: 0.0,
128 message_count: 0,
129 api_messages: crate::compaction::compaction_context_messages(summary_text),
130 abort_context: None,
131 parent_session: Some(parent.id.clone()),
132 compacted_into: None,
133 prompt_provenance: None,
134 compaction: Some(record),
135 }
136 }
137
138 pub fn auto_title(&mut self) {
140 if !self.title.is_empty() {
141 return;
142 }
143 for msg in &self.api_messages {
144 if msg["role"].as_str() == Some("user") {
145 if let Some(content) = msg["content"].as_str() {
146 self.title = content.chars().take(80).collect();
147 return;
148 }
149 }
150 }
151 }
152
153 pub async fn save(&self) -> std::io::Result<()> {
157 let dir = crate::config::resolve_write_path("sessions");
158 let mode = crate::config::load_config().session_persistence;
159 let session = self.clone(); tokio::task::spawn_blocking(move || {
161 crate::core::session_journal::save_session_in_dir(&dir, &session, mode).map(|_| ())
162 })
163 .await
164 .map_err(std::io::Error::other)?
165 }
166
167 pub fn load(id: &str) -> std::io::Result<Self> {
168 Self::load_from_dir(&sessions_dir(), id)
169 }
170
171 pub fn load_from_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Self> {
175 crate::core::session_journal::load_session_in_dir(dir, id)
176 }
177
178 pub fn info(&self) -> SessionInfo {
179 SessionInfo {
180 id: self.id.clone(),
181 title: self.title.clone(),
182 name: self.name.clone(),
183 model: self.model.clone(),
184 created_at: self.created_at,
185 updated_at: self.updated_at,
186 session_cost: self.session_cost,
187 message_count: self.api_messages.len(),
188 }
189 }
190
191 pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
195 validate_name(name).map_err(std::io::Error::other)?;
196 if self.name.as_deref() == Some(name) {
197 return Ok(());
198 }
199 let sessions = list_sessions()?;
200 for s in &sessions {
201 if s.name.as_deref() == Some(name) && s.id != self.id {
202 return Err(std::io::Error::other(format!(
203 "name '{}' already used by session {}",
204 name, s.id
205 )));
206 }
207 }
208 if crate::core::chain::load_chain(name).is_ok() {
209 return Err(std::io::Error::other(format!(
210 "name '{}' conflicts with an existing chain name",
211 name
212 )));
213 }
214 self.name = Some(name.to_string());
215 Ok(())
216 }
217
218 pub fn clear_name(&mut self) {
219 self.name = None;
220 }
221}
222
223#[cfg(test)]
227pub(crate) fn save_json_in_dir(
228 dir: &std::path::Path,
229 id: &str,
230 json: &[u8],
231) -> std::io::Result<()> {
232 crate::core::session_journal::write_json_snapshot(dir, id, json)
235}
236
237pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
239 let dir = sessions_dir();
240 if !dir.exists() {
241 return Err(std::io::Error::new(
242 std::io::ErrorKind::NotFound,
243 "no sessions directory",
244 ));
245 }
246
247 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
249
250 if entries
252 .iter()
253 .any(|e| e.name == format!("{partial_id}.json"))
254 {
255 return Session::load(partial_id);
256 }
257
258 let mut matches: Vec<String> = Vec::new();
260 for entry in &entries {
261 if entry.name.ends_with(".json") {
262 let id = entry.name.trim_end_matches(".json");
263 if id.contains(partial_id) {
264 matches.push(id.to_string());
265 }
266 }
267 }
268
269 match matches.len() {
270 0 => Err(std::io::Error::new(
271 std::io::ErrorKind::NotFound,
272 format!("no session matching '{}'", partial_id),
273 )),
274 1 => Session::load(&matches[0]),
275 _ => Err(std::io::Error::other(format!(
276 "ambiguous: {} sessions match '{}'",
277 matches.len(),
278 partial_id
279 ))),
280 }
281}
282
283pub fn latest_session() -> std::io::Result<Session> {
285 let dir = sessions_dir();
292 if !dir.exists() {
293 return Err(std::io::Error::new(
294 std::io::ErrorKind::NotFound,
295 "no sessions found",
296 ));
297 }
298 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
301 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
302 let mut newest: Option<(std::time::SystemTime, String)> = None;
303 for entry in &entries {
304 let id = if let Some(id) = entry.name.strip_suffix(".json") {
308 id
309 } else if let Some(id) = entry.name.strip_suffix(".journal") {
310 if !names.contains(format!("{id}.json").as_str()) {
311 continue; }
313 id
314 } else {
315 continue;
316 };
317 if let Some(mtime) = entry.mtime {
318 if newest.as_ref().map_or(true, |(t, _)| mtime > *t) {
319 newest = Some((mtime, id.to_string()));
320 }
321 }
322 }
323 let id = newest
324 .map(|(_, id)| id)
325 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))?;
326 Session::load(&id)
327}
328
329pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
336 let dir = sessions_dir();
337 if !dir.exists() {
338 return Ok(Vec::new());
339 }
340 let mut sessions: Vec<SessionInfo> = Vec::new();
341 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
342 if entry.name.ends_with(".json") {
343 if let Some(info) = parse_session_header(&dir, &entry.name) {
344 sessions.push(info);
345 }
346 }
347 }
348 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
349 Ok(sessions)
350}
351
352pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
358 let dir = sessions_dir();
359 if !dir.exists() {
360 return Ok(Vec::new());
361 }
362 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
364 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
365 let mut by_snapshot: std::collections::HashMap<String, std::time::SystemTime> =
366 std::collections::HashMap::new();
367 for entry in &entries {
368 let id = if let Some(id) = entry.name.strip_suffix(".json") {
370 id
371 } else if let Some(id) = entry.name.strip_suffix(".journal") {
372 if !names.contains(format!("{id}.json").as_str()) {
373 continue;
374 }
375 id
376 } else {
377 continue;
378 };
379 if let Some(mtime) = entry.mtime {
380 let slot = by_snapshot
381 .entry(id.to_string())
382 .or_insert(std::time::SystemTime::UNIX_EPOCH);
383 if mtime > *slot {
384 *slot = mtime;
385 }
386 }
387 }
388 let mut files: Vec<(std::time::SystemTime, String)> =
389 by_snapshot.into_iter().map(|(id, t)| (t, id)).collect();
390 files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
391 files.truncate(limit);
392 let mut sessions: Vec<SessionInfo> = Vec::new();
393 for (_, id) in files {
394 if let Some(info) = parse_session_header(&dir, &format!("{id}.json")) {
395 sessions.push(info);
396 }
397 }
398 Ok(sessions)
399}
400
401fn parse_session_header(dir: &std::path::Path, file_name: &str) -> Option<SessionInfo> {
406 #[derive(Deserialize)]
407 struct SessionMetadata {
408 id: String,
409 #[serde(default)]
410 title: String,
411 #[serde(default)]
412 name: Option<String>,
413 model: String,
414 created_at: DateTime<Utc>,
415 updated_at: DateTime<Utc>,
416 #[serde(default)]
417 session_cost: f64,
418 #[serde(default)]
419 message_count: usize,
420 }
421 let header = read_session_header(dir, file_name)?;
422 let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
423 let mut info = SessionInfo {
424 id: meta.id,
425 title: meta.title,
426 name: meta.name,
427 model: meta.model,
428 created_at: meta.created_at,
429 updated_at: meta.updated_at,
430 session_cost: meta.session_cost,
431 message_count: meta.message_count,
432 };
433 if let Some(tail) = crate::core::session_journal::journal_meta_tail(dir, &info.id) {
437 if tail.updated_at > info.updated_at {
438 info.updated_at = tail.updated_at;
439 info.session_cost = tail.session_cost;
440 if let Some(count) = tail.message_count {
441 info.message_count = count;
442 }
443 }
444 }
445 Some(info)
446}
447
448fn read_session_header(dir: &std::path::Path, file_name: &str) -> Option<String> {
456 use std::io::Read;
457 const KEY: &[u8] = b"\"api_messages\"";
458 const MAX_HEADER: usize = 256 * 1024; let mut file = crate::core::session_journal::confined_open(dir, file_name).ok()??;
464 let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
465 let mut chunk = [0u8; 16 * 1024];
466 let mut cut: Option<usize> = None;
467 while buf.len() <= MAX_HEADER {
468 let n = file.read(&mut chunk).ok()?;
469 if n == 0 {
470 break;
471 }
472 buf.extend_from_slice(&chunk[..n]);
473 if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
474 cut = Some(pos);
475 break;
476 }
477 }
478
479 let end = cut.unwrap_or(buf.len());
480 let trimmed = String::from_utf8_lossy(&buf[..end]);
481 let mut s = trimmed.trim_end().to_string();
482 if s.ends_with(',') {
483 s.pop();
484 }
485 if !s.ends_with('}') {
486 s.push('}');
487 }
488 Some(s)
489}
490
491fn sessions_dir() -> PathBuf {
492 crate::config::get_active_config_dir().join("sessions")
493}
494
495pub fn delete_session_file(id: &str) -> std::io::Result<()> {
499 crate::core::session_journal::delete_session_files_in_dir(&sessions_dir(), id)
500}
501
502pub fn validate_name(name: &str) -> Result<(), String> {
504 if name.is_empty() {
505 return Err("name cannot be empty".into());
506 }
507 if name.len() > 40 {
508 return Err(format!("invalid name '{}': must be 40 chars or less", name));
509 }
510 if !name
511 .chars()
512 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
513 {
514 return Err(format!(
515 "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
516 name
517 ));
518 }
519 Ok(())
520}
521
522pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
526 let dir = sessions_dir();
527 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
528 if !entry.name.ends_with(".json") {
529 continue;
530 }
531 if let Some(info) = parse_session_header(&dir, &entry.name) {
532 if info.name.as_deref() == Some(name) {
533 return Session::load(&info.id);
534 }
535 }
536 }
537 Err(std::io::Error::new(
538 std::io::ErrorKind::NotFound,
539 format!("no session named '{}'", name),
540 ))
541}
542
543pub fn resolve_session(query: &str) -> std::io::Result<Session> {
546 if let Ok(ptr) = crate::core::chain::load_chain(query) {
547 match Session::load(&ptr.head) {
548 Ok(s) => {
549 tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
550 return Ok(s);
551 }
552 Err(e) => {
553 return Err(std::io::Error::new(
554 e.kind(),
555 format!(
556 "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
557 query, ptr.head, e, query
558 ),
559 ));
560 }
561 }
562 }
563 if let Ok(s) = find_session_by_name(query) {
564 tracing::info!("resolved '{}' via session name → {}", query, s.id);
565 return Ok(s);
566 }
567 find_session(query)
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use serde_json::json;
574
575 fn test_record(parent: &Session) -> crate::compaction::CompactionRecord {
577 crate::compaction::CompactionRecord {
578 schema_version: crate::compaction::COMPACTION_SUMMARY_SCHEMA_VERSION,
579 source_session: parent.id.clone(),
580 source_message_count: parent.api_messages.len(),
581 source_range_digest: crate::compaction::message_range_digest(&parent.api_messages),
582 summary_provider: "anthropic".into(),
583 summary_model: "claude-sonnet-4-6".into(),
584 created_at: Utc::now(),
585 prompt_stack_digest: crate::compaction::prompt_stack_digest(&["test"]),
586 included_classes: crate::compaction::ContentClass::ALL.to_vec(),
587 excluded_classes: Vec::new(),
588 redaction_policy: crate::compaction::RedactionPolicy::TruncationOnly,
589 prior_system_prompt: parent.system_prompt.clone(),
590 }
591 }
592
593 #[test]
594 fn test_session_new() {
595 let session = Session::new("gpt-4", "brief", Some("test prompt"));
596
597 assert_eq!(session.model, "gpt-4");
599 assert_eq!(session.thinking_level, "brief");
600 assert_eq!(session.system_prompt, Some("test prompt".to_string()));
601
602 assert!(!session.id.is_empty());
604
605 assert_eq!(session.title, "");
607
608 assert_eq!(session.total_input_tokens, 0);
610 assert_eq!(session.total_output_tokens, 0);
611
612 assert_eq!(session.session_cost, 0.0);
614
615 assert!(session.api_messages.is_empty());
617
618 let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
620 assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
621 assert_eq!(session_no_prompt.thinking_level, "normal");
622 assert_eq!(session_no_prompt.system_prompt, None);
623 }
624
625 #[test]
626 fn test_session_auto_title() {
627 let mut session = Session::new("gpt-4", "brief", None);
628
629 session.api_messages.push(std::sync::Arc::new(json!({
631 "role": "user",
632 "content": "hello world"
633 })));
634
635 session.auto_title();
637
638 assert_eq!(session.title, "hello world");
640
641 session.title = "existing title".to_string();
643 session.auto_title();
644 assert_eq!(session.title, "existing title");
645
646 let mut empty_session = Session::new("gpt-4", "brief", None);
648 empty_session.auto_title();
649 assert_eq!(empty_session.title, "");
650
651 let mut session_no_user = Session::new("gpt-4", "brief", None);
653 session_no_user
654 .api_messages
655 .push(std::sync::Arc::new(json!({
656 "role": "assistant",
657 "content": "response"
658 })));
659 session_no_user.auto_title();
660 assert_eq!(session_no_user.title, "");
661
662 let mut session_long = Session::new("gpt-4", "brief", None);
664 let long_content = "a".repeat(100);
665 session_long.api_messages.push(std::sync::Arc::new(json!({
666 "role": "user",
667 "content": long_content
668 })));
669 session_long.auto_title();
670 assert_eq!(session_long.title.len(), 80);
671 assert_eq!(session_long.title, "a".repeat(80));
672 }
673
674 #[test]
675 fn test_session_info() {
676 let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
677
678 session.api_messages.push(std::sync::Arc::new(json!({
680 "role": "user",
681 "content": "test message"
682 })));
683 session.api_messages.push(std::sync::Arc::new(json!({
684 "role": "assistant",
685 "content": "test response"
686 })));
687
688 session.title = "Test Title".to_string();
689 session.session_cost = 0.05;
690
691 let info = session.info();
692
693 assert_eq!(info.id, session.id);
694 assert_eq!(info.title, "Test Title");
695 assert_eq!(info.model, "gpt-4");
696 assert_eq!(info.created_at, session.created_at);
697 assert_eq!(info.updated_at, session.updated_at);
698 assert_eq!(info.session_cost, 0.05);
699 assert_eq!(info.message_count, 2);
700 }
701
702 #[test]
703 fn test_session_info_struct() {
704 let now = Utc::now();
705
706 let session_info = SessionInfo {
707 id: "test-id".to_string(),
708 title: "Test Title".to_string(),
709 name: None,
710 model: "gpt-4".to_string(),
711 created_at: now,
712 updated_at: now,
713 session_cost: 1.23,
714 message_count: 5,
715 };
716
717 assert_eq!(session_info.id, "test-id");
719 assert_eq!(session_info.title, "Test Title");
720 assert_eq!(session_info.model, "gpt-4");
721 assert_eq!(session_info.created_at, now);
722 assert_eq!(session_info.updated_at, now);
723 assert_eq!(session_info.session_cost, 1.23);
724 assert_eq!(session_info.message_count, 5);
725 }
726
727 #[test]
728 fn test_session_serialization_round_trip() {
729 let mut session = Session::new(
730 "gpt-4-turbo",
731 "detailed",
732 Some("You are a helpful assistant"),
733 );
734 session.title = "Test Session".to_string();
735 session.api_messages.push(std::sync::Arc::new(
736 json!({"role": "user", "content": "test"}),
737 ));
738 session.total_input_tokens = 100;
739 session.total_output_tokens = 200;
740 session.session_cost = 0.15;
741
742 let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
744
745 let deserialized: Session =
747 serde_json::from_str(&json_str).expect("Failed to deserialize session");
748
749 assert_eq!(deserialized.id, session.id);
751 assert_eq!(deserialized.title, session.title);
752 assert_eq!(deserialized.model, session.model);
753 assert_eq!(deserialized.thinking_level, session.thinking_level);
754 assert_eq!(deserialized.system_prompt, session.system_prompt);
755 assert_eq!(deserialized.created_at, session.created_at);
756 assert_eq!(deserialized.updated_at, session.updated_at);
757 assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
758 assert_eq!(
759 deserialized.total_output_tokens,
760 session.total_output_tokens
761 );
762 assert_eq!(deserialized.session_cost, session.session_cost);
763 assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
764 assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
765 }
766
767 #[test]
768 fn codex_ultra_round_trips_and_compaction_preserves_logical_mode() {
769 let parent = Session::new("openai-codex/gpt-5.6-sol", "ultra", None);
770 let encoded = serde_json::to_string(&parent).expect("serialize Ultra session");
771 let persisted: serde_json::Value =
772 serde_json::from_str(&encoded).expect("inspect persisted session");
773 assert_eq!(persisted["thinking_level"], "ultra");
774 let restored: Session = serde_json::from_str(&encoded).expect("restore Ultra session");
775 assert_eq!(restored.model, "openai-codex/gpt-5.6-sol");
776 assert_eq!(restored.thinking_level, "ultra");
777
778 let compacted =
779 Session::from_compaction_record(&restored, "summary", test_record(&restored));
780 assert_eq!(compacted.model, restored.model);
781 assert_eq!(compacted.thinking_level, "ultra");
782 assert_eq!(
783 compacted.parent_session.as_deref(),
784 Some(restored.id.as_str())
785 );
786 }
787
788 #[test]
789 fn codex_max_round_trips_without_becoming_ultra_or_xhigh() {
790 let session = Session::new("openai-codex/gpt-5.6-luna", "max", None);
791 let encoded = serde_json::to_string(&session).expect("serialize Max session");
792 let restored: Session = serde_json::from_str(&encoded).expect("restore Max session");
793 assert_eq!(restored.thinking_level, "max");
794 assert_ne!(restored.thinking_level, "ultra");
795 assert_ne!(restored.thinking_level, "xhigh");
796 }
797
798 #[test]
799 fn test_session_serialization_preserves_all_fields() {
800 let mut session = Session::new(
801 "claude-3-opus",
802 "comprehensive",
803 Some("Custom system prompt"),
804 );
805 session.title = "Complex Session".to_string();
806
807 session.api_messages.push(std::sync::Arc::new(
809 json!({"role": "user", "content": "First message"}),
810 ));
811 session.api_messages.push(std::sync::Arc::new(
812 json!({"role": "assistant", "content": "First response"}),
813 ));
814 session.api_messages.push(std::sync::Arc::new(
815 json!({"role": "user", "content": "Second message"}),
816 ));
817
818 session.total_input_tokens = 1500;
820 session.total_output_tokens = 2500;
821 session.session_cost = 0.75;
822
823 let json_str = serde_json::to_string(&session).unwrap();
825 let restored: Session = serde_json::from_str(&json_str).unwrap();
826
827 assert_eq!(restored.id, session.id);
829 assert_eq!(restored.title, "Complex Session");
830 assert_eq!(restored.model, "claude-3-opus");
831 assert_eq!(restored.thinking_level, "comprehensive");
832 assert_eq!(
833 restored.system_prompt.as_ref().unwrap(),
834 "Custom system prompt"
835 );
836 assert_eq!(restored.created_at, session.created_at);
837 assert_eq!(restored.updated_at, session.updated_at);
838 assert_eq!(restored.total_input_tokens, 1500);
839 assert_eq!(restored.total_output_tokens, 2500);
840 assert_eq!(restored.session_cost, 0.75);
841 assert_eq!(restored.api_messages.len(), 3);
842 assert_eq!(restored.api_messages[0]["role"], "user");
843 assert_eq!(restored.api_messages[0]["content"], "First message");
844 assert_eq!(restored.api_messages[1]["role"], "assistant");
845 assert_eq!(restored.api_messages[2]["content"], "Second message");
846 }
847
848 #[test]
849 fn test_session_info_from_session_with_messages() {
850 let mut session = Session::new("gpt-3.5-turbo", "normal", None);
851
852 session.api_messages.push(std::sync::Arc::new(
854 json!({"role": "user", "content": "message 1"}),
855 ));
856 session.api_messages.push(std::sync::Arc::new(
857 json!({"role": "assistant", "content": "response 1"}),
858 ));
859 session.api_messages.push(std::sync::Arc::new(
860 json!({"role": "user", "content": "message 2"}),
861 ));
862
863 let info = session.info();
864
865 assert_eq!(info.message_count, 3);
867 assert_eq!(info.id, session.id);
868 assert_eq!(info.model, "gpt-3.5-turbo");
869 }
870
871 #[test]
872 fn test_session_auto_title_truncation() {
873 let mut session = Session::new("gpt-4", "brief", None);
874
875 let long_content = "a".repeat(200);
877 session.api_messages.push(std::sync::Arc::new(json!({
878 "role": "user",
879 "content": long_content
880 })));
881
882 session.auto_title();
883
884 assert_eq!(session.title.len(), 80);
886 assert_eq!(session.title, "a".repeat(80));
887 }
888
889 #[test]
890 fn test_session_auto_title_skips_non_user_messages() {
891 let mut session = Session::new("gpt-4", "brief", None);
892
893 session.api_messages.push(std::sync::Arc::new(json!({
895 "role": "assistant",
896 "content": "This should be ignored for auto title"
897 })));
898
899 session.auto_title();
900
901 assert_eq!(session.title, "");
903
904 session.api_messages.push(std::sync::Arc::new(json!({
906 "role": "system",
907 "content": "System message should also be ignored"
908 })));
909
910 session.auto_title();
911 assert_eq!(session.title, "");
912 }
913
914 #[test]
915 fn test_session_new_generates_unique_ids() {
916 let session1 = Session::new("gpt-4", "brief", None);
917 let session2 = Session::new("gpt-4", "brief", None);
918
919 assert_ne!(session1.id, session2.id);
921 assert!(!session1.id.is_empty());
922 assert!(!session2.id.is_empty());
923 }
924
925 #[test]
926 fn test_session_new_timestamps() {
927 let before = Utc::now();
928 let session = Session::new("gpt-4", "brief", None);
929 let after = Utc::now();
930
931 let created_diff = (session.created_at - before).num_seconds().abs();
933 let updated_diff = (session.updated_at - before).num_seconds().abs();
934
935 assert!(
936 created_diff <= 2,
937 "created_at should be within 2 seconds of now"
938 );
939 assert!(
940 updated_diff <= 2,
941 "updated_at should be within 2 seconds of now"
942 );
943
944 assert_eq!(session.created_at, session.updated_at);
946
947 assert!(session.created_at <= after);
949 assert!(session.updated_at <= after);
950 }
951
952 #[test]
953 fn test_validate_name() {
954 assert!(validate_name("work").is_ok());
955 assert!(validate_name("my-project-2").is_ok());
956 assert!(validate_name("a").is_ok());
957 assert!(validate_name(&"a".repeat(40)).is_ok());
958
959 assert!(validate_name("").is_err());
960 assert!(validate_name(&"a".repeat(41)).is_err());
961 assert!(validate_name("UPPER").is_err());
962 assert!(validate_name("has space").is_err());
963 assert!(validate_name("under_score").is_err());
964 assert!(validate_name("dots.bad").is_err());
965
966 let err = validate_name("Bad").unwrap_err();
967 assert!(err.contains("Bad"));
968 assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
969 }
970
971 #[test]
972 fn test_clear_name() {
973 let mut s = Session::new("m", "brief", None);
974 s.name = Some("foo".into());
975 s.clear_name();
976 assert_eq!(s.name, None);
977 }
978
979 #[test]
980 fn ultracode_serialization_and_compaction_roundtrip_is_distinct() {
981 let original = Session::new("anthropic/claude-fable-5", "ultracode", None);
982 let json = serde_json::to_string(&original).unwrap();
983 let restored: Session = serde_json::from_str(&json).unwrap();
984 assert_eq!(restored.thinking_level, "ultracode");
985 for other in ["ultra", "max", "xhigh"] {
986 assert_ne!(restored.thinking_level, other);
987 }
988 let compacted =
989 Session::from_compaction_record(&restored, "summary", test_record(&restored));
990 let resumed: Session =
991 serde_json::from_str(&serde_json::to_string(&compacted).unwrap()).unwrap();
992 assert_eq!(resumed.thinking_level, "ultracode");
993 assert_eq!(
994 resumed.parent_session.as_deref(),
995 Some(original.id.as_str())
996 );
997 }
998
999 #[cfg(unix)]
1004 mod private_modes {
1005 use super::*;
1006 use crate::core::private_fs::test_support::UmaskGuard;
1007 use serial_test::serial;
1008 use std::os::unix::fs::PermissionsExt;
1009
1010 fn mode_of(path: &std::path::Path) -> u32 {
1011 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1012 }
1013
1014 #[test]
1015 #[serial(umask)]
1016 fn save_creates_0600_session_file_and_0700_dir_under_permissive_umask() {
1017 let _umask = UmaskGuard::set(0);
1018 let tmp = tempfile::TempDir::new().unwrap();
1019 let dir = tmp.path().join("sessions");
1020 save_json_in_dir(&dir, "sess-1", b"{}").unwrap();
1021 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1022 assert_eq!(
1023 mode_of(&dir.join("sess-1.json")),
1024 0o600,
1025 "session file must be 0600"
1026 );
1027 }
1028
1029 #[test]
1030 fn save_refuses_symlink_target() {
1031 let tmp = tempfile::TempDir::new().unwrap();
1032 let dir = tmp.path().join("sessions");
1033 std::fs::create_dir_all(&dir).unwrap();
1034 let victim = tmp.path().join("victim.json");
1035 std::fs::write(&victim, "original").unwrap();
1036 std::os::unix::fs::symlink(&victim, dir.join("sess-1.json")).unwrap();
1037 let res = save_json_in_dir(&dir, "sess-1", b"{}");
1038 assert!(res.is_err(), "save onto a symlink must fail");
1039 assert_eq!(
1040 std::fs::read_to_string(&victim).unwrap(),
1041 "original",
1042 "no bytes may be written through the planted symlink"
1043 );
1044 }
1045 }
1046
1047 mod header_message_count {
1048 use super::*;
1049 use crate::core::session_journal::{save_session_in_dir, SessionPersistence};
1050 use std::sync::Arc;
1051
1052 fn session_with(n: usize) -> Session {
1053 let mut s = Session::new("claude-sonnet-4-6", "medium", None);
1054 for i in 0..n {
1055 let role = if i % 2 == 0 { "user" } else { "assistant" };
1056 s.api_messages
1057 .push(Arc::new(serde_json::json!({"role": role, "content": format!("m{i}")})));
1058 }
1059 s
1060 }
1061
1062 #[test]
1065 fn json_save_persists_count_in_header() {
1066 let tmp = tempfile::TempDir::new().unwrap();
1067 let dir = tmp.path().join("sessions");
1068 let s = session_with(7);
1069 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1070 let header = read_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1071 assert!(
1072 !header.contains("\"api_messages\""),
1073 "header must stop before the message array"
1074 );
1075 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1076 assert_eq!(info.message_count, 7);
1077 }
1078
1079 #[test]
1081 fn journal_append_updates_count() {
1082 let tmp = tempfile::TempDir::new().unwrap();
1083 let dir = tmp.path().join("sessions");
1084 let mut s = session_with(2);
1085 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1086 s.api_messages
1087 .push(Arc::new(serde_json::json!({"role": "user", "content": "more"})));
1088 s.updated_at = Utc::now();
1089 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1090 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1091 assert_eq!(info.message_count, 3);
1092 }
1093
1094 #[test]
1096 fn legacy_header_defaults_to_zero() {
1097 let tmp = tempfile::TempDir::new().unwrap();
1098 let dir = tmp.path().join("sessions");
1099 let s = session_with(3);
1100 let json = serde_json::to_string(&s).unwrap();
1103 assert!(json.contains("\"message_count\":0,"));
1104 let legacy = json.replace("\"message_count\":0,", "");
1105 save_json_in_dir(&dir, &s.id, legacy.as_bytes()).unwrap();
1106 let info = parse_session_header(&dir, &format!("{}.json", s.id)).unwrap();
1107 assert_eq!(info.message_count, 0);
1108 }
1109 }
1110}