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 pub api_messages: Vec<SharedMessage>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub abort_context: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub parent_session: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub compacted_into: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub prompt_provenance: Option<crate::prompt::PromptProvenance>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub compaction: Option<crate::compaction::CompactionRecord>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SessionInfo {
41 pub id: String,
42 pub title: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub name: Option<String>,
45 pub model: String,
46 pub created_at: DateTime<Utc>,
47 pub updated_at: DateTime<Utc>,
48 pub session_cost: f64,
49 pub message_count: usize,
50}
51
52impl Session {
53 pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
54 let now = Utc::now();
55 let id = format!(
56 "{}-{}",
57 now.format("%Y%m%d-%H%M%S"),
58 &uuid::Uuid::new_v4().to_string()[..4]
59 );
60 Session {
61 id,
62 title: String::new(),
63 name: None,
64 model: model.to_string(),
65 thinking_level: thinking_level.to_string(),
66 system_prompt: system_prompt.map(|s| s.to_string()),
67 created_at: now,
68 updated_at: now,
69 total_input_tokens: 0,
70 total_output_tokens: 0,
71 session_cost: 0.0,
72 api_messages: Vec::new(),
73 abort_context: None,
74 parent_session: None,
75 compacted_into: None,
76 prompt_provenance: None,
77 compaction: None,
78 }
79 }
80
81 pub fn from_compaction_record(
87 parent: &Session,
88 summary_text: &str,
89 record: crate::compaction::CompactionRecord,
90 ) -> Self {
91 let now = Utc::now();
92 let id = format!(
93 "{}-{}",
94 now.format("%Y%m%d-%H%M%S"),
95 &uuid::Uuid::new_v4().to_string()[..4]
96 );
97 let name = parent.name.clone();
101 Session {
102 id,
103 title: format!(
104 "↳ {}",
105 if parent.title.is_empty() {
106 &parent.id
107 } else {
108 &parent.title
109 }
110 ),
111 name,
112 model: parent.model.clone(),
113 thinking_level: parent.thinking_level.clone(),
114 system_prompt: parent.system_prompt.clone(),
115 created_at: now,
116 updated_at: now,
117 total_input_tokens: 0,
118 total_output_tokens: 0,
119 session_cost: 0.0,
120 api_messages: crate::compaction::compaction_context_messages(summary_text),
121 abort_context: None,
122 parent_session: Some(parent.id.clone()),
123 compacted_into: None,
124 prompt_provenance: None,
125 compaction: Some(record),
126 }
127 }
128
129 pub fn auto_title(&mut self) {
131 if !self.title.is_empty() {
132 return;
133 }
134 for msg in &self.api_messages {
135 if msg["role"].as_str() == Some("user") {
136 if let Some(content) = msg["content"].as_str() {
137 self.title = content.chars().take(80).collect();
138 return;
139 }
140 }
141 }
142 }
143
144 pub async fn save(&self) -> std::io::Result<()> {
148 let dir = crate::config::resolve_write_path("sessions");
149 let mode = crate::config::load_config().session_persistence;
150 let session = self.clone(); tokio::task::spawn_blocking(move || {
152 crate::core::session_journal::save_session_in_dir(&dir, &session, mode).map(|_| ())
153 })
154 .await
155 .map_err(std::io::Error::other)?
156 }
157
158 pub fn load(id: &str) -> std::io::Result<Self> {
159 Self::load_from_dir(&sessions_dir(), id)
160 }
161
162 pub fn load_from_dir(dir: &std::path::Path, id: &str) -> std::io::Result<Self> {
166 crate::core::session_journal::load_session_in_dir(dir, id)
167 }
168
169 pub fn info(&self) -> SessionInfo {
170 SessionInfo {
171 id: self.id.clone(),
172 title: self.title.clone(),
173 name: self.name.clone(),
174 model: self.model.clone(),
175 created_at: self.created_at,
176 updated_at: self.updated_at,
177 session_cost: self.session_cost,
178 message_count: self.api_messages.len(),
179 }
180 }
181
182 pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
186 validate_name(name).map_err(std::io::Error::other)?;
187 if self.name.as_deref() == Some(name) {
188 return Ok(());
189 }
190 let sessions = list_sessions()?;
191 for s in &sessions {
192 if s.name.as_deref() == Some(name) && s.id != self.id {
193 return Err(std::io::Error::other(format!(
194 "name '{}' already used by session {}",
195 name, s.id
196 )));
197 }
198 }
199 if crate::core::chain::load_chain(name).is_ok() {
200 return Err(std::io::Error::other(format!(
201 "name '{}' conflicts with an existing chain name",
202 name
203 )));
204 }
205 self.name = Some(name.to_string());
206 Ok(())
207 }
208
209 pub fn clear_name(&mut self) {
210 self.name = None;
211 }
212}
213
214#[cfg(test)]
218pub(crate) fn save_json_in_dir(
219 dir: &std::path::Path,
220 id: &str,
221 json: &[u8],
222) -> std::io::Result<()> {
223 crate::core::session_journal::write_json_snapshot(dir, id, json)
226}
227
228pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
230 let dir = sessions_dir();
231 if !dir.exists() {
232 return Err(std::io::Error::new(
233 std::io::ErrorKind::NotFound,
234 "no sessions directory",
235 ));
236 }
237
238 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
240
241 if entries
243 .iter()
244 .any(|e| e.name == format!("{partial_id}.json"))
245 {
246 return Session::load(partial_id);
247 }
248
249 let mut matches: Vec<String> = Vec::new();
251 for entry in &entries {
252 if entry.name.ends_with(".json") {
253 let id = entry.name.trim_end_matches(".json");
254 if id.contains(partial_id) {
255 matches.push(id.to_string());
256 }
257 }
258 }
259
260 match matches.len() {
261 0 => Err(std::io::Error::new(
262 std::io::ErrorKind::NotFound,
263 format!("no session matching '{}'", partial_id),
264 )),
265 1 => Session::load(&matches[0]),
266 _ => Err(std::io::Error::other(format!(
267 "ambiguous: {} sessions match '{}'",
268 matches.len(),
269 partial_id
270 ))),
271 }
272}
273
274pub fn latest_session() -> std::io::Result<Session> {
276 let dir = sessions_dir();
283 if !dir.exists() {
284 return Err(std::io::Error::new(
285 std::io::ErrorKind::NotFound,
286 "no sessions found",
287 ));
288 }
289 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
292 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
293 let mut newest: Option<(std::time::SystemTime, String)> = None;
294 for entry in &entries {
295 let id = if let Some(id) = entry.name.strip_suffix(".json") {
299 id
300 } else if let Some(id) = entry.name.strip_suffix(".journal") {
301 if !names.contains(format!("{id}.json").as_str()) {
302 continue; }
304 id
305 } else {
306 continue;
307 };
308 if let Some(mtime) = entry.mtime {
309 if newest.as_ref().map_or(true, |(t, _)| mtime > *t) {
310 newest = Some((mtime, id.to_string()));
311 }
312 }
313 }
314 let id = newest
315 .map(|(_, id)| id)
316 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found"))?;
317 Session::load(&id)
318}
319
320pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
327 let dir = sessions_dir();
328 if !dir.exists() {
329 return Ok(Vec::new());
330 }
331 let mut sessions: Vec<SessionInfo> = Vec::new();
332 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
333 if entry.name.ends_with(".json") {
334 if let Some(info) = parse_session_header(&dir, &entry.name) {
335 sessions.push(info);
336 }
337 }
338 }
339 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
340 Ok(sessions)
341}
342
343pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
349 let dir = sessions_dir();
350 if !dir.exists() {
351 return Ok(Vec::new());
352 }
353 let entries = crate::core::session_journal::session_dir_entries(&dir)?;
355 let names: std::collections::HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect();
356 let mut by_snapshot: std::collections::HashMap<String, std::time::SystemTime> =
357 std::collections::HashMap::new();
358 for entry in &entries {
359 let id = if let Some(id) = entry.name.strip_suffix(".json") {
361 id
362 } else if let Some(id) = entry.name.strip_suffix(".journal") {
363 if !names.contains(format!("{id}.json").as_str()) {
364 continue;
365 }
366 id
367 } else {
368 continue;
369 };
370 if let Some(mtime) = entry.mtime {
371 let slot = by_snapshot
372 .entry(id.to_string())
373 .or_insert(std::time::SystemTime::UNIX_EPOCH);
374 if mtime > *slot {
375 *slot = mtime;
376 }
377 }
378 }
379 let mut files: Vec<(std::time::SystemTime, String)> =
380 by_snapshot.into_iter().map(|(id, t)| (t, id)).collect();
381 files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
382 files.truncate(limit);
383 let mut sessions: Vec<SessionInfo> = Vec::new();
384 for (_, id) in files {
385 if let Some(info) = parse_session_header(&dir, &format!("{id}.json")) {
386 sessions.push(info);
387 }
388 }
389 Ok(sessions)
390}
391
392fn parse_session_header(dir: &std::path::Path, file_name: &str) -> Option<SessionInfo> {
396 #[derive(Deserialize)]
397 struct SessionMetadata {
398 id: String,
399 #[serde(default)]
400 title: String,
401 #[serde(default)]
402 name: Option<String>,
403 model: String,
404 created_at: DateTime<Utc>,
405 updated_at: DateTime<Utc>,
406 #[serde(default)]
407 session_cost: f64,
408 }
409 let header = read_session_header(dir, file_name)?;
410 let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
411 let mut info = SessionInfo {
412 id: meta.id,
413 title: meta.title,
414 name: meta.name,
415 model: meta.model,
416 created_at: meta.created_at,
417 updated_at: meta.updated_at,
418 session_cost: meta.session_cost,
419 message_count: 0,
420 };
421 if let Some(tail) = crate::core::session_journal::journal_meta_tail(dir, &info.id) {
425 if tail.updated_at > info.updated_at {
426 info.updated_at = tail.updated_at;
427 info.session_cost = tail.session_cost;
428 }
429 }
430 Some(info)
431}
432
433fn read_session_header(dir: &std::path::Path, file_name: &str) -> Option<String> {
441 use std::io::Read;
442 const KEY: &[u8] = b"\"api_messages\"";
443 const MAX_HEADER: usize = 256 * 1024; let mut file = crate::core::session_journal::confined_open(dir, file_name).ok()??;
449 let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
450 let mut chunk = [0u8; 16 * 1024];
451 let mut cut: Option<usize> = None;
452 while buf.len() <= MAX_HEADER {
453 let n = file.read(&mut chunk).ok()?;
454 if n == 0 {
455 break;
456 }
457 buf.extend_from_slice(&chunk[..n]);
458 if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
459 cut = Some(pos);
460 break;
461 }
462 }
463
464 let end = cut.unwrap_or(buf.len());
465 let trimmed = String::from_utf8_lossy(&buf[..end]);
466 let mut s = trimmed.trim_end().to_string();
467 if s.ends_with(',') {
468 s.pop();
469 }
470 if !s.ends_with('}') {
471 s.push('}');
472 }
473 Some(s)
474}
475
476fn sessions_dir() -> PathBuf {
477 crate::config::get_active_config_dir().join("sessions")
478}
479
480pub fn delete_session_file(id: &str) -> std::io::Result<()> {
484 crate::core::session_journal::delete_session_files_in_dir(&sessions_dir(), id)
485}
486
487pub fn validate_name(name: &str) -> Result<(), String> {
489 if name.is_empty() {
490 return Err("name cannot be empty".into());
491 }
492 if name.len() > 40 {
493 return Err(format!("invalid name '{}': must be 40 chars or less", name));
494 }
495 if !name
496 .chars()
497 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
498 {
499 return Err(format!(
500 "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
501 name
502 ));
503 }
504 Ok(())
505}
506
507pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
511 let dir = sessions_dir();
512 for entry in crate::core::session_journal::session_dir_entries(&dir)? {
513 if !entry.name.ends_with(".json") {
514 continue;
515 }
516 if let Some(info) = parse_session_header(&dir, &entry.name) {
517 if info.name.as_deref() == Some(name) {
518 return Session::load(&info.id);
519 }
520 }
521 }
522 Err(std::io::Error::new(
523 std::io::ErrorKind::NotFound,
524 format!("no session named '{}'", name),
525 ))
526}
527
528pub fn resolve_session(query: &str) -> std::io::Result<Session> {
531 if let Ok(ptr) = crate::core::chain::load_chain(query) {
532 match Session::load(&ptr.head) {
533 Ok(s) => {
534 tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
535 return Ok(s);
536 }
537 Err(e) => {
538 return Err(std::io::Error::new(
539 e.kind(),
540 format!(
541 "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
542 query, ptr.head, e, query
543 ),
544 ));
545 }
546 }
547 }
548 if let Ok(s) = find_session_by_name(query) {
549 tracing::info!("resolved '{}' via session name → {}", query, s.id);
550 return Ok(s);
551 }
552 find_session(query)
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use serde_json::json;
559
560 fn test_record(parent: &Session) -> crate::compaction::CompactionRecord {
562 crate::compaction::CompactionRecord {
563 schema_version: crate::compaction::COMPACTION_SUMMARY_SCHEMA_VERSION,
564 source_session: parent.id.clone(),
565 source_message_count: parent.api_messages.len(),
566 source_range_digest: crate::compaction::message_range_digest(&parent.api_messages),
567 summary_provider: "anthropic".into(),
568 summary_model: "claude-sonnet-4-6".into(),
569 created_at: Utc::now(),
570 prompt_stack_digest: crate::compaction::prompt_stack_digest(&["test"]),
571 included_classes: crate::compaction::ContentClass::ALL.to_vec(),
572 excluded_classes: Vec::new(),
573 redaction_policy: crate::compaction::RedactionPolicy::TruncationOnly,
574 prior_system_prompt: parent.system_prompt.clone(),
575 }
576 }
577
578 #[test]
579 fn test_session_new() {
580 let session = Session::new("gpt-4", "brief", Some("test prompt"));
581
582 assert_eq!(session.model, "gpt-4");
584 assert_eq!(session.thinking_level, "brief");
585 assert_eq!(session.system_prompt, Some("test prompt".to_string()));
586
587 assert!(!session.id.is_empty());
589
590 assert_eq!(session.title, "");
592
593 assert_eq!(session.total_input_tokens, 0);
595 assert_eq!(session.total_output_tokens, 0);
596
597 assert_eq!(session.session_cost, 0.0);
599
600 assert!(session.api_messages.is_empty());
602
603 let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
605 assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
606 assert_eq!(session_no_prompt.thinking_level, "normal");
607 assert_eq!(session_no_prompt.system_prompt, None);
608 }
609
610 #[test]
611 fn test_session_auto_title() {
612 let mut session = Session::new("gpt-4", "brief", None);
613
614 session.api_messages.push(std::sync::Arc::new(json!({
616 "role": "user",
617 "content": "hello world"
618 })));
619
620 session.auto_title();
622
623 assert_eq!(session.title, "hello world");
625
626 session.title = "existing title".to_string();
628 session.auto_title();
629 assert_eq!(session.title, "existing title");
630
631 let mut empty_session = Session::new("gpt-4", "brief", None);
633 empty_session.auto_title();
634 assert_eq!(empty_session.title, "");
635
636 let mut session_no_user = Session::new("gpt-4", "brief", None);
638 session_no_user
639 .api_messages
640 .push(std::sync::Arc::new(json!({
641 "role": "assistant",
642 "content": "response"
643 })));
644 session_no_user.auto_title();
645 assert_eq!(session_no_user.title, "");
646
647 let mut session_long = Session::new("gpt-4", "brief", None);
649 let long_content = "a".repeat(100);
650 session_long.api_messages.push(std::sync::Arc::new(json!({
651 "role": "user",
652 "content": long_content
653 })));
654 session_long.auto_title();
655 assert_eq!(session_long.title.len(), 80);
656 assert_eq!(session_long.title, "a".repeat(80));
657 }
658
659 #[test]
660 fn test_session_info() {
661 let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
662
663 session.api_messages.push(std::sync::Arc::new(json!({
665 "role": "user",
666 "content": "test message"
667 })));
668 session.api_messages.push(std::sync::Arc::new(json!({
669 "role": "assistant",
670 "content": "test response"
671 })));
672
673 session.title = "Test Title".to_string();
674 session.session_cost = 0.05;
675
676 let info = session.info();
677
678 assert_eq!(info.id, session.id);
679 assert_eq!(info.title, "Test Title");
680 assert_eq!(info.model, "gpt-4");
681 assert_eq!(info.created_at, session.created_at);
682 assert_eq!(info.updated_at, session.updated_at);
683 assert_eq!(info.session_cost, 0.05);
684 assert_eq!(info.message_count, 2);
685 }
686
687 #[test]
688 fn test_session_info_struct() {
689 let now = Utc::now();
690
691 let session_info = SessionInfo {
692 id: "test-id".to_string(),
693 title: "Test Title".to_string(),
694 name: None,
695 model: "gpt-4".to_string(),
696 created_at: now,
697 updated_at: now,
698 session_cost: 1.23,
699 message_count: 5,
700 };
701
702 assert_eq!(session_info.id, "test-id");
704 assert_eq!(session_info.title, "Test Title");
705 assert_eq!(session_info.model, "gpt-4");
706 assert_eq!(session_info.created_at, now);
707 assert_eq!(session_info.updated_at, now);
708 assert_eq!(session_info.session_cost, 1.23);
709 assert_eq!(session_info.message_count, 5);
710 }
711
712 #[test]
713 fn test_session_serialization_round_trip() {
714 let mut session = Session::new(
715 "gpt-4-turbo",
716 "detailed",
717 Some("You are a helpful assistant"),
718 );
719 session.title = "Test Session".to_string();
720 session.api_messages.push(std::sync::Arc::new(
721 json!({"role": "user", "content": "test"}),
722 ));
723 session.total_input_tokens = 100;
724 session.total_output_tokens = 200;
725 session.session_cost = 0.15;
726
727 let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
729
730 let deserialized: Session =
732 serde_json::from_str(&json_str).expect("Failed to deserialize session");
733
734 assert_eq!(deserialized.id, session.id);
736 assert_eq!(deserialized.title, session.title);
737 assert_eq!(deserialized.model, session.model);
738 assert_eq!(deserialized.thinking_level, session.thinking_level);
739 assert_eq!(deserialized.system_prompt, session.system_prompt);
740 assert_eq!(deserialized.created_at, session.created_at);
741 assert_eq!(deserialized.updated_at, session.updated_at);
742 assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
743 assert_eq!(
744 deserialized.total_output_tokens,
745 session.total_output_tokens
746 );
747 assert_eq!(deserialized.session_cost, session.session_cost);
748 assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
749 assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
750 }
751
752 #[test]
753 fn codex_ultra_round_trips_and_compaction_preserves_logical_mode() {
754 let parent = Session::new("openai-codex/gpt-5.6-sol", "ultra", None);
755 let encoded = serde_json::to_string(&parent).expect("serialize Ultra session");
756 let persisted: serde_json::Value =
757 serde_json::from_str(&encoded).expect("inspect persisted session");
758 assert_eq!(persisted["thinking_level"], "ultra");
759 let restored: Session = serde_json::from_str(&encoded).expect("restore Ultra session");
760 assert_eq!(restored.model, "openai-codex/gpt-5.6-sol");
761 assert_eq!(restored.thinking_level, "ultra");
762
763 let compacted =
764 Session::from_compaction_record(&restored, "summary", test_record(&restored));
765 assert_eq!(compacted.model, restored.model);
766 assert_eq!(compacted.thinking_level, "ultra");
767 assert_eq!(
768 compacted.parent_session.as_deref(),
769 Some(restored.id.as_str())
770 );
771 }
772
773 #[test]
774 fn codex_max_round_trips_without_becoming_ultra_or_xhigh() {
775 let session = Session::new("openai-codex/gpt-5.6-luna", "max", None);
776 let encoded = serde_json::to_string(&session).expect("serialize Max session");
777 let restored: Session = serde_json::from_str(&encoded).expect("restore Max session");
778 assert_eq!(restored.thinking_level, "max");
779 assert_ne!(restored.thinking_level, "ultra");
780 assert_ne!(restored.thinking_level, "xhigh");
781 }
782
783 #[test]
784 fn test_session_serialization_preserves_all_fields() {
785 let mut session = Session::new(
786 "claude-3-opus",
787 "comprehensive",
788 Some("Custom system prompt"),
789 );
790 session.title = "Complex Session".to_string();
791
792 session.api_messages.push(std::sync::Arc::new(
794 json!({"role": "user", "content": "First message"}),
795 ));
796 session.api_messages.push(std::sync::Arc::new(
797 json!({"role": "assistant", "content": "First response"}),
798 ));
799 session.api_messages.push(std::sync::Arc::new(
800 json!({"role": "user", "content": "Second message"}),
801 ));
802
803 session.total_input_tokens = 1500;
805 session.total_output_tokens = 2500;
806 session.session_cost = 0.75;
807
808 let json_str = serde_json::to_string(&session).unwrap();
810 let restored: Session = serde_json::from_str(&json_str).unwrap();
811
812 assert_eq!(restored.id, session.id);
814 assert_eq!(restored.title, "Complex Session");
815 assert_eq!(restored.model, "claude-3-opus");
816 assert_eq!(restored.thinking_level, "comprehensive");
817 assert_eq!(
818 restored.system_prompt.as_ref().unwrap(),
819 "Custom system prompt"
820 );
821 assert_eq!(restored.created_at, session.created_at);
822 assert_eq!(restored.updated_at, session.updated_at);
823 assert_eq!(restored.total_input_tokens, 1500);
824 assert_eq!(restored.total_output_tokens, 2500);
825 assert_eq!(restored.session_cost, 0.75);
826 assert_eq!(restored.api_messages.len(), 3);
827 assert_eq!(restored.api_messages[0]["role"], "user");
828 assert_eq!(restored.api_messages[0]["content"], "First message");
829 assert_eq!(restored.api_messages[1]["role"], "assistant");
830 assert_eq!(restored.api_messages[2]["content"], "Second message");
831 }
832
833 #[test]
834 fn test_session_info_from_session_with_messages() {
835 let mut session = Session::new("gpt-3.5-turbo", "normal", None);
836
837 session.api_messages.push(std::sync::Arc::new(
839 json!({"role": "user", "content": "message 1"}),
840 ));
841 session.api_messages.push(std::sync::Arc::new(
842 json!({"role": "assistant", "content": "response 1"}),
843 ));
844 session.api_messages.push(std::sync::Arc::new(
845 json!({"role": "user", "content": "message 2"}),
846 ));
847
848 let info = session.info();
849
850 assert_eq!(info.message_count, 3);
852 assert_eq!(info.id, session.id);
853 assert_eq!(info.model, "gpt-3.5-turbo");
854 }
855
856 #[test]
857 fn test_session_auto_title_truncation() {
858 let mut session = Session::new("gpt-4", "brief", None);
859
860 let long_content = "a".repeat(200);
862 session.api_messages.push(std::sync::Arc::new(json!({
863 "role": "user",
864 "content": long_content
865 })));
866
867 session.auto_title();
868
869 assert_eq!(session.title.len(), 80);
871 assert_eq!(session.title, "a".repeat(80));
872 }
873
874 #[test]
875 fn test_session_auto_title_skips_non_user_messages() {
876 let mut session = Session::new("gpt-4", "brief", None);
877
878 session.api_messages.push(std::sync::Arc::new(json!({
880 "role": "assistant",
881 "content": "This should be ignored for auto title"
882 })));
883
884 session.auto_title();
885
886 assert_eq!(session.title, "");
888
889 session.api_messages.push(std::sync::Arc::new(json!({
891 "role": "system",
892 "content": "System message should also be ignored"
893 })));
894
895 session.auto_title();
896 assert_eq!(session.title, "");
897 }
898
899 #[test]
900 fn test_session_new_generates_unique_ids() {
901 let session1 = Session::new("gpt-4", "brief", None);
902 let session2 = Session::new("gpt-4", "brief", None);
903
904 assert_ne!(session1.id, session2.id);
906 assert!(!session1.id.is_empty());
907 assert!(!session2.id.is_empty());
908 }
909
910 #[test]
911 fn test_session_new_timestamps() {
912 let before = Utc::now();
913 let session = Session::new("gpt-4", "brief", None);
914 let after = Utc::now();
915
916 let created_diff = (session.created_at - before).num_seconds().abs();
918 let updated_diff = (session.updated_at - before).num_seconds().abs();
919
920 assert!(
921 created_diff <= 2,
922 "created_at should be within 2 seconds of now"
923 );
924 assert!(
925 updated_diff <= 2,
926 "updated_at should be within 2 seconds of now"
927 );
928
929 assert_eq!(session.created_at, session.updated_at);
931
932 assert!(session.created_at <= after);
934 assert!(session.updated_at <= after);
935 }
936
937 #[test]
938 fn test_validate_name() {
939 assert!(validate_name("work").is_ok());
940 assert!(validate_name("my-project-2").is_ok());
941 assert!(validate_name("a").is_ok());
942 assert!(validate_name(&"a".repeat(40)).is_ok());
943
944 assert!(validate_name("").is_err());
945 assert!(validate_name(&"a".repeat(41)).is_err());
946 assert!(validate_name("UPPER").is_err());
947 assert!(validate_name("has space").is_err());
948 assert!(validate_name("under_score").is_err());
949 assert!(validate_name("dots.bad").is_err());
950
951 let err = validate_name("Bad").unwrap_err();
952 assert!(err.contains("Bad"));
953 assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
954 }
955
956 #[test]
957 fn test_clear_name() {
958 let mut s = Session::new("m", "brief", None);
959 s.name = Some("foo".into());
960 s.clear_name();
961 assert_eq!(s.name, None);
962 }
963
964 #[test]
965 fn ultracode_serialization_and_compaction_roundtrip_is_distinct() {
966 let original = Session::new("anthropic/claude-fable-5", "ultracode", None);
967 let json = serde_json::to_string(&original).unwrap();
968 let restored: Session = serde_json::from_str(&json).unwrap();
969 assert_eq!(restored.thinking_level, "ultracode");
970 for other in ["ultra", "max", "xhigh"] {
971 assert_ne!(restored.thinking_level, other);
972 }
973 let compacted =
974 Session::from_compaction_record(&restored, "summary", test_record(&restored));
975 let resumed: Session =
976 serde_json::from_str(&serde_json::to_string(&compacted).unwrap()).unwrap();
977 assert_eq!(resumed.thinking_level, "ultracode");
978 assert_eq!(
979 resumed.parent_session.as_deref(),
980 Some(original.id.as_str())
981 );
982 }
983
984 #[cfg(unix)]
989 mod private_modes {
990 use super::*;
991 use crate::core::private_fs::test_support::UmaskGuard;
992 use serial_test::serial;
993 use std::os::unix::fs::PermissionsExt;
994
995 fn mode_of(path: &std::path::Path) -> u32 {
996 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
997 }
998
999 #[test]
1000 #[serial(umask)]
1001 fn save_creates_0600_session_file_and_0700_dir_under_permissive_umask() {
1002 let _umask = UmaskGuard::set(0);
1003 let tmp = tempfile::TempDir::new().unwrap();
1004 let dir = tmp.path().join("sessions");
1005 save_json_in_dir(&dir, "sess-1", b"{}").unwrap();
1006 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1007 assert_eq!(
1008 mode_of(&dir.join("sess-1.json")),
1009 0o600,
1010 "session file must be 0600"
1011 );
1012 }
1013
1014 #[test]
1015 fn save_refuses_symlink_target() {
1016 let tmp = tempfile::TempDir::new().unwrap();
1017 let dir = tmp.path().join("sessions");
1018 std::fs::create_dir_all(&dir).unwrap();
1019 let victim = tmp.path().join("victim.json");
1020 std::fs::write(&victim, "original").unwrap();
1021 std::os::unix::fs::symlink(&victim, dir.join("sess-1.json")).unwrap();
1022 let res = save_json_in_dir(&dir, "sess-1", b"{}");
1023 assert!(res.is_err(), "save onto a symlink must fail");
1024 assert_eq!(
1025 std::fs::read_to_string(&victim).unwrap(),
1026 "original",
1027 "no bytes may be written through the planted symlink"
1028 );
1029 }
1030 }
1031}