1pub mod lifecycle;
20pub mod orphan;
21pub mod reader;
22pub mod serialize;
23pub mod writer;
24
25use serde::{Deserialize, Serialize};
26use std::path::{Path, PathBuf};
27
28use crate::llm::ToolSpec;
29use crate::message::Message;
30
31pub use lifecycle::{truncate_transcript_to_turn, SessionLock, SessionLockBusy, TruncateStats};
33pub use orphan::OrphanToolCall;
34pub use reader::SessionReader;
35pub use serialize::{entry_to_message, LoadedEntry, TranscriptEntry};
36pub use writer::{SessionPersistenceSink, SessionWriter};
37
38const SESSION_SCHEMA_VERSION: u32 = 1;
41
42pub(crate) const SUPPORTED_SESSION_SCHEMA_VERSION: u32 = 1;
47
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum SessionStatus {
87 #[serde(alias = "success")]
91 Completed,
92 #[serde(alias = "incomplete")]
97 Crashed,
98 #[serde(alias = "cancelled")]
103 Interrupted,
104 Paused,
107 #[serde(other)]
121 #[default]
122 Active,
123}
124
125impl std::fmt::Display for SessionStatus {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 match self {
133 Self::Completed => write!(f, "completed"),
134 Self::Crashed => write!(f, "crashed"),
135 Self::Interrupted => write!(f, "interrupted"),
136 Self::Paused => write!(f, "paused"),
137 Self::Active => write!(f, "active"),
138 }
139 }
140}
141
142impl SessionStatus {
143 pub fn for_finish(reason: &crate::agent::FinishReason) -> Self {
151 use crate::agent::FinishReason;
152 match reason {
153 FinishReason::NoMoreToolCalls => Self::Completed,
154 FinishReason::BudgetExceeded
155 | FinishReason::ProviderStop(_)
156 | FinishReason::Stuck { .. }
157 | FinishReason::TranscriptLimit { .. }
158 | FinishReason::PermissionDenialLimit
159 | FinishReason::WallClockExceeded { .. } => Self::Crashed,
160 FinishReason::Cancelled => Self::Interrupted,
161 }
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct SessionFile {
172 pub schema_version: u32,
173 pub goal: String,
175 pub model: String,
177 pub provider: String,
179 pub tool_registry_hash: String,
182 pub steps_consumed: usize,
184 pub transcript: Vec<Message>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub preset: Option<String>,
191}
192
193impl SessionFile {
194 pub fn new(
196 goal: String,
197 model: String,
198 provider: String,
199 tool_specs: &[ToolSpec],
200 steps_consumed: usize,
201 transcript: Vec<Message>,
202 ) -> Self {
203 let tool_registry_hash = hash_tool_specs(tool_specs);
204 Self {
205 schema_version: SESSION_SCHEMA_VERSION,
206 goal,
207 model,
208 provider,
209 tool_registry_hash,
210 steps_consumed,
211 transcript,
212 preset: None,
213 }
214 }
215
216 pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
218 let json = serde_json::to_string_pretty(self)
219 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
220 if let Some(parent) = path.parent() {
221 std::fs::create_dir_all(parent)?;
222 }
223 crate::atomic::atomic_write(path, json.as_bytes())
224 }
225
226 pub fn read_from(path: &Path) -> std::io::Result<Self> {
228 let bytes = std::fs::read(path)?;
229 serde_json::from_slice(&bytes)
230 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
231 }
232
233 pub fn validate_tool_registry(&self, current_specs: &[ToolSpec]) -> Result<(), String> {
236 let current_hash = hash_tool_specs(current_specs);
237 if self.tool_registry_hash == current_hash {
238 Ok(())
239 } else {
240 Err(format!(
241 "tool registry hash mismatch: session has '{}', current is '{}'. \
242 Tools have changed since the session was saved; cannot resume.",
243 self.tool_registry_hash, current_hash
244 ))
245 }
246 }
247
248 pub fn messages(&self) -> &[Message] {
250 &self.transcript
251 }
252
253 pub fn into_transcript(self) -> Vec<Message> {
255 self.transcript
256 }
257}
258
259pub fn hash_tool_specs(specs: &[ToolSpec]) -> String {
274 use std::collections::BTreeMap;
275
276 let mut map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
277 for spec in specs {
278 let value = serde_json::json!({
279 "description": spec.description,
280 "parameters": spec.parameters,
281 });
282 map.insert(spec.name.clone(), value);
283 }
284 let canonical = serde_json::to_string(&map).unwrap_or_else(|e| {
289 tracing::error!(error = %e, "hash_tool_specs: unreachable serialization failure — drift detection may be compromised");
290 String::new()
291 });
292 let hash = blake3::hash(canonical.as_bytes());
293 hash.to_hex().to_string()
294}
295
296pub fn default_session_path(workspace: &Path, goal: &str) -> PathBuf {
303 let ts = filesystem_safe_timestamp();
304 let prefix: String = goal
306 .chars()
307 .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
308 .take(40)
309 .collect();
310 let prefix = if prefix.is_empty() {
311 "unnamed".to_string()
312 } else {
313 prefix
314 };
315 let dir = crate::paths::user_sessions_dir(workspace)
316 .unwrap_or_else(|_| workspace.join(".recursive").join("sessions"));
317 dir.join(format!("{}-{}.json", ts, prefix))
318}
319
320pub fn list_sessions(workspace: &Path) -> std::io::Result<Vec<PathBuf>> {
322 let dir = match crate::paths::user_sessions_dir(workspace) {
323 Ok(d) => d,
324 Err(_) => workspace.join(".recursive").join("sessions"),
325 };
326 if !dir.is_dir() {
327 return Ok(Vec::new());
328 }
329 let mut sessions = Vec::new();
330 for entry in std::fs::read_dir(&dir)? {
331 let entry = entry?;
332 let path = entry.path();
333 if path.extension().and_then(|e| e.to_str()) == Some("json") {
334 sessions.push(path);
335 }
336 }
337 sessions.sort();
338 Ok(sessions)
339}
340
341pub(crate) fn filesystem_safe_timestamp() -> String {
348 chrono_lite_now().replace(':', "-")
349}
350
351pub(crate) fn chrono_lite_now() -> String {
354 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
355}
356
357pub(crate) fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
358 let z = z + 719_468;
359 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
360 let doe = (z - era * 146_097) as u64;
361 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
362 let y = yoe as i64 + era * 400;
363 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
364 let mp = (5 * doy + 2) / 153;
365 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
366 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
367 let y = if m <= 2 { y + 1 } else { y };
368 (y, m, d)
369}
370
371pub(crate) fn workspace_slug(workspace: &Path) -> String {
381 let abs = if workspace.is_absolute() {
382 workspace.to_path_buf()
383 } else {
384 std::env::current_dir().unwrap_or_default().join(workspace)
385 };
386
387 let s: String = abs
392 .to_string_lossy()
393 .chars()
394 .map(|c| {
395 if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
396 c
397 } else {
398 '-'
399 }
400 })
401 .collect();
402 let s = s.trim_start_matches('-').to_string();
404 if s.len() > 80 {
406 crate::truncate_str(&s, 80).to_string()
407 } else {
408 s
409 }
410}
411
412#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
421pub struct UsageMeta {
422 pub input_tokens: u32,
423 pub output_tokens: u32,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub cache_creation_tokens: Option<u32>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub cache_read_tokens: Option<u32>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub reasoning_tokens: Option<u32>,
433}
434
435impl UsageMeta {
436 pub fn accumulate(&mut self, other: &UsageMeta) {
438 self.input_tokens += other.input_tokens;
439 self.output_tokens += other.output_tokens;
440 self.cache_creation_tokens = Some(
441 self.cache_creation_tokens.unwrap_or(0) + other.cache_creation_tokens.unwrap_or(0),
442 );
443 self.cache_read_tokens =
444 Some(self.cache_read_tokens.unwrap_or(0) + other.cache_read_tokens.unwrap_or(0));
445 self.reasoning_tokens =
446 Some(self.reasoning_tokens.unwrap_or(0) + other.reasoning_tokens.unwrap_or(0));
447 }
448
449 pub fn from_token_usage(tu: &crate::llm::TokenUsage) -> Self {
451 UsageMeta {
452 input_tokens: tu.prompt_tokens,
453 output_tokens: tu.completion_tokens,
454 cache_creation_tokens: if tu.cache_miss_tokens > 0 {
455 Some(tu.cache_miss_tokens)
456 } else {
457 None
458 },
459 cache_read_tokens: if tu.cache_hit_tokens > 0 {
460 Some(tu.cache_hit_tokens)
461 } else {
462 None
463 },
464 reasoning_tokens: Some(tu.reasoning_tokens),
468 }
469 }
470
471 pub fn is_zero(&self) -> bool {
473 self.input_tokens == 0 && self.output_tokens == 0
474 }
475}
476
477#[derive(Debug, Clone, Default, Serialize, Deserialize)]
483pub struct SessionCost {
484 pub total_input_tokens: u64,
485 pub total_output_tokens: u64,
486 #[serde(default)]
487 pub total_cache_creation_tokens: u64,
488 #[serde(default)]
489 pub total_cache_read_tokens: u64,
490 #[serde(default)]
494 pub total_reasoning_tokens: u64,
495}
496
497impl SessionCost {
498 pub fn accumulate(&mut self, usage: &UsageMeta) {
499 self.total_input_tokens += usage.input_tokens as u64;
500 self.total_output_tokens += usage.output_tokens as u64;
501 self.total_cache_creation_tokens += usage.cache_creation_tokens.unwrap_or(0) as u64;
502 self.total_cache_read_tokens += usage.cache_read_tokens.unwrap_or(0) as u64;
503 self.total_reasoning_tokens += usage.reasoning_tokens.unwrap_or(0) as u64;
504 }
505}
506
507fn default_schema_version() -> u32 {
516 1
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct SessionMeta {
522 #[serde(default = "default_schema_version")]
532 pub schema_version: u32,
533 pub session_id: String,
534 pub goal: String,
535 pub model: String,
536 pub provider: String,
537 pub created_at: String,
538 pub updated_at: String,
539 pub message_count: u64,
540 #[serde(default)]
545 pub status: SessionStatus,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub tool_registry_hash: Option<String>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub first_prompt: Option<String>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub last_prompt: Option<String>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub cost: Option<SessionCost>,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub preset: Option<String>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub name: Option<String>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ExportedTranscript {
581 pub version: u32,
582 pub session_id: String,
583 pub model: String,
584 pub goal: String,
585 pub created_at: String,
586 pub status: SessionStatus,
590 pub messages: Vec<TranscriptEntry>,
596 #[serde(default)]
600 pub compact_boundaries: Vec<ExportedCompactBoundary>,
601 pub message_count: u64,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct ExportedCompactBoundary {
608 pub message_index: usize,
611 pub turn: Option<u32>,
612 pub removed: usize,
614 pub timestamp: String,
615}
616
617impl ExportedTranscript {
618 pub fn from_session_dir(session_dir: &Path) -> std::io::Result<Self> {
620 let meta = SessionReader::load_meta(session_dir)?;
621 let loaded = SessionReader::load_full_history(session_dir)?;
622 let mut messages: Vec<TranscriptEntry> = Vec::new();
623 let mut compact_boundaries: Vec<ExportedCompactBoundary> = Vec::new();
624 for entry in loaded {
625 match entry {
626 LoadedEntry::Message(m) => messages.push(*m),
627 LoadedEntry::CompactBoundary { turn, removed } => {
628 let timestamp = messages
631 .last()
632 .map(|m| m.timestamp.clone())
633 .unwrap_or_default();
634 compact_boundaries.push(ExportedCompactBoundary {
635 message_index: messages.len(),
636 turn,
637 removed,
638 timestamp,
639 });
640 }
641 }
642 }
643 let message_count = messages.len() as u64;
644 Ok(Self {
645 version: 1,
646 session_id: meta.session_id,
647 model: meta.model,
648 goal: meta.goal,
649 created_at: meta.created_at,
650 status: meta.status,
651 messages,
652 compact_boundaries,
653 message_count,
654 })
655 }
656}
657
658#[cfg(test)]
663mod tests {
664 use super::*;
665 use crate::message::{Message, Role};
666
667 #[test]
668 fn session_round_trip() {
669 let goal = "fix the bug".to_string();
670 let model = "gpt-4o-mini".to_string();
671 let provider = "openai".to_string();
672 let tool_specs = vec![
673 ToolSpec {
674 name: "Read".into(),
675 description: "Read a file".into(),
676 parameters: serde_json::json!({"type":"object"}),
677 },
678 ToolSpec {
679 name: "Write".into(),
680 description: "Write a file".into(),
681 parameters: serde_json::json!({"type":"object"}),
682 },
683 ];
684 let transcript = vec![
685 Message::system("You are a helpful assistant.".to_string()),
686 Message::user("fix the bug".to_string()),
687 Message::assistant("Let me look at the code.".to_string()),
688 ];
689
690 let session = SessionFile::new(
691 goal.clone(),
692 model.clone(),
693 provider.clone(),
694 &tool_specs,
695 2,
696 transcript.clone(),
697 );
698
699 let tmp = tempfile::NamedTempFile::new().unwrap();
700 session.write_to(tmp.path()).unwrap();
701
702 let restored = SessionFile::read_from(tmp.path()).unwrap();
703 assert_eq!(restored.schema_version, SESSION_SCHEMA_VERSION);
704 assert_eq!(restored.goal, goal);
705 assert_eq!(restored.model, model);
706 assert_eq!(restored.provider, provider);
707 assert_eq!(restored.steps_consumed, 2);
708 assert_eq!(restored.transcript.len(), 3);
709 assert_eq!(
710 restored.transcript[0].content,
711 "You are a helpful assistant."
712 );
713 assert_eq!(restored.transcript[1].content, "fix the bug");
714 assert_eq!(restored.transcript[2].content, "Let me look at the code.");
715 }
716
717 #[test]
718 fn resume_validates_tool_registry_hash() {
719 let tool_specs = vec![ToolSpec {
720 name: "Read".into(),
721 description: "Read a file".into(),
722 parameters: serde_json::json!({"type":"object"}),
723 }];
724 let session = SessionFile::new(
725 "test".into(),
726 "model".into(),
727 "provider".into(),
728 &tool_specs,
729 0,
730 vec![],
731 );
732
733 assert!(session.validate_tool_registry(&tool_specs).is_ok());
735
736 let different_specs = vec![ToolSpec {
738 name: "Write".into(),
739 description: "Write a file".into(),
740 parameters: serde_json::json!({"type":"object"}),
741 }];
742 assert!(session.validate_tool_registry(&different_specs).is_err());
743 }
744
745 #[test]
746 #[cfg_attr(target_os = "windows", ignore)]
747 fn session_list_finds_files_in_workspace() {
748 let tmp = crate::test_util::IsolatedWorkspace::new();
749 let ws = tmp.path();
750
751 let sessions = list_sessions(ws).unwrap();
753 assert!(sessions.is_empty());
754
755 let session = SessionFile::new(
757 "test".into(),
758 "model".into(),
759 "provider".into(),
760 &[],
761 0,
762 vec![],
763 );
764 let path = default_session_path(ws, "test");
765 session.write_to(&path).unwrap();
766
767 let sessions = list_sessions(ws).unwrap();
768 assert_eq!(sessions.len(), 1);
769 assert_eq!(
770 sessions[0].extension().and_then(|e| e.to_str()),
771 Some("json")
772 );
773 }
774
775 #[test]
776 fn resume_continues_from_seeded_transcript() {
777 let transcript = vec![
778 Message::system("sys".to_string()),
779 Message::user("original goal".to_string()),
780 Message::assistant("partial work".to_string()),
781 ];
782 let session = SessionFile::new(
783 "original goal".into(),
784 "model".into(),
785 "provider".into(),
786 &[],
787 1,
788 transcript.clone(),
789 );
790
791 assert_eq!(session.messages().len(), 3);
793 assert_eq!(session.messages()[0].content, "sys");
794 assert_eq!(session.messages()[1].content, "original goal");
795 assert_eq!(session.messages()[2].content, "partial work");
796
797 let restored = session.into_transcript();
799 assert_eq!(restored.len(), 3);
800 }
801
802 #[test]
803 fn round_trip_with_tool_calls() {
804 use crate::llm::ToolCall;
805
806 let tool_calls = vec![
807 ToolCall {
808 id: "call_001".to_string(),
809 name: "Read".to_string(),
810 arguments: serde_json::json!({"path": "/tmp/foo.rs"}),
811 },
812 ToolCall {
813 id: "call_002".to_string(),
814 name: "Write".to_string(),
815 arguments: serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"}),
816 },
817 ];
818
819 let transcript = vec![
820 Message::system("You are an agent.".to_string()),
821 Message::user("refactor the code".to_string()),
822 Message::assistant_with_tool_calls(
823 "I'll read the file first.".to_string(),
824 tool_calls.clone(),
825 ),
826 Message::tool_result("call_001", "fn main() { println!(\"hello\"); }"),
827 ];
828
829 let session = SessionFile::new(
830 "refactor".into(),
831 "gpt-4o".into(),
832 "openai".into(),
833 &[],
834 3,
835 transcript,
836 );
837
838 let tmp = tempfile::NamedTempFile::new().unwrap();
839 session.write_to(tmp.path()).unwrap();
840
841 let restored = SessionFile::read_from(tmp.path()).unwrap();
842 assert_eq!(restored.transcript.len(), 4);
843
844 let assistant_msg = &restored.transcript[2];
846 assert_eq!(assistant_msg.role, Role::Assistant);
847 assert_eq!(assistant_msg.content, "I'll read the file first.");
848 assert_eq!(assistant_msg.tool_calls.len(), 2);
849 assert_eq!(assistant_msg.tool_calls[0].id, "call_001");
850 assert_eq!(assistant_msg.tool_calls[0].name, "Read");
851 assert_eq!(
852 assistant_msg.tool_calls[0].arguments,
853 serde_json::json!({"path": "/tmp/foo.rs"})
854 );
855 assert_eq!(assistant_msg.tool_calls[1].id, "call_002");
856 assert_eq!(assistant_msg.tool_calls[1].name, "Write");
857 assert_eq!(
858 assistant_msg.tool_calls[1].arguments,
859 serde_json::json!({"path": "/tmp/bar.rs", "content": "fn main() {}"})
860 );
861
862 let tool_msg = &restored.transcript[3];
864 assert_eq!(tool_msg.role, Role::Tool);
865 assert_eq!(tool_msg.tool_call_id, Some("call_001".to_string()));
866 assert_eq!(tool_msg.content, "fn main() { println!(\"hello\"); }");
867 }
868
869 #[test]
870 fn read_from_nonexistent_file() {
871 let tmp = crate::test_util::IsolatedWorkspace::new();
872 let bogus_path = tmp.path().join("does_not_exist.json");
873
874 let result = SessionFile::read_from(&bogus_path);
875 assert!(result.is_err());
876 let err = result.unwrap_err();
877 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
878 }
879
880 #[test]
881 fn read_from_corrupt_json() {
882 let tmp = tempfile::NamedTempFile::new().unwrap();
883 std::fs::write(tmp.path(), "this is not valid json {{{garbage").unwrap();
884
885 let result = SessionFile::read_from(tmp.path());
886 assert!(result.is_err());
887 let err = result.unwrap_err();
888 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
889 }
890
891 #[test]
892 fn validate_tool_registry_mismatch() {
893 let original_specs = vec![
894 ToolSpec {
895 name: "Read".into(),
896 description: "Read a file".into(),
897 parameters: serde_json::json!({"type":"object"}),
898 },
899 ToolSpec {
900 name: "Write".into(),
901 description: "Write a file".into(),
902 parameters: serde_json::json!({"type":"object"}),
903 },
904 ];
905
906 let session = SessionFile::new(
907 "test".into(),
908 "model".into(),
909 "provider".into(),
910 &original_specs,
911 0,
912 vec![],
913 );
914
915 let different_specs = vec![ToolSpec {
917 name: "execute_command".into(),
918 description: "Run a shell command".into(),
919 parameters: serde_json::json!({"type":"object","properties":{"cmd":{"type":"string"}}}),
920 }];
921
922 let result = session.validate_tool_registry(&different_specs);
923 assert!(result.is_err());
924 let err_msg = result.unwrap_err();
925 assert!(
926 err_msg.contains("mismatch"),
927 "Expected error to contain 'mismatch', got: {err_msg}"
928 );
929 }
930
931 #[test]
932 fn default_session_path_sanitizes_special_chars() {
933 let tmp = crate::test_util::IsolatedWorkspace::new();
934 let ws = tmp.path();
935
936 let goal = "fix bug/issue #42 — with spëcial chars™ 日本語";
938 let path = default_session_path(ws, goal);
939
940 let filename = path.file_stem().unwrap().to_str().unwrap();
942
943 let goal_suffix = filename
948 .find("Z-")
949 .map(|i| &filename[i + 2..])
950 .expect("filename should contain Z- separator between timestamp and goal");
951
952 for ch in goal_suffix.chars() {
954 assert!(
955 ch.is_alphanumeric() || ch == '_' || ch == '-',
956 "Unexpected character '{}' (U+{:04X}) in goal suffix: {}",
957 ch,
958 ch as u32,
959 goal_suffix
960 );
961 }
962
963 assert!(!goal_suffix.contains(' '));
965 assert!(!goal_suffix.contains('/'));
966 assert!(!goal_suffix.contains('#'));
967 assert!(!goal_suffix.contains('™'));
968 assert!(!goal_suffix.contains('—'));
969
970 let parent = path.parent().expect("session path has parent");
973 assert_eq!(
974 parent.file_name().and_then(|n| n.to_str()),
975 Some("sessions"),
976 "expected sessions dir as parent, got {}",
977 path.display()
978 );
979 assert_eq!(path.extension().and_then(|e| e.to_str()), Some("json"));
981 }
982
983 #[test]
997 fn default_session_path_preserves_underscore_and_alphanumeric() {
998 let tmp = crate::test_util::IsolatedWorkspace::new();
999 let ws = tmp.path();
1000 let path = default_session_path(ws, "my_feature");
1001 let filename = path.file_stem().unwrap().to_str().unwrap();
1002 let goal_suffix = filename
1003 .find("Z-")
1004 .map(|i| &filename[i + 2..])
1005 .expect("filename should contain Z- separator");
1006 assert_eq!(
1007 goal_suffix, "my_feature",
1008 "goal prefix must preserve both alphanumeric chars and underscores; got: {goal_suffix}"
1009 );
1010 }
1011
1012 #[test]
1013 fn default_session_path_empty_goal_uses_unnamed() {
1014 let tmp = crate::test_util::IsolatedWorkspace::new();
1020 let ws = tmp.path();
1021
1022 let goal = "!@#$%^&*()";
1024 let path = default_session_path(ws, goal);
1025 let filename = path.file_stem().unwrap().to_str().unwrap();
1026
1027 let goal_suffix = filename
1029 .find("Z-")
1030 .map(|i| &filename[i + 2..])
1031 .expect("filename should contain Z- separator");
1032 assert_eq!(
1033 goal_suffix, "unnamed",
1034 "empty-goal filter must produce 'unnamed'"
1035 );
1036 }
1037
1038 #[test]
1039 #[cfg_attr(target_os = "windows", ignore)]
1040 fn list_sessions_ignores_non_json_files() {
1041 use std::fs;
1045
1046 let tmp = crate::test_util::IsolatedWorkspace::new();
1047 let ws = tmp.path();
1048
1049 let session = SessionFile::new("test".into(), "m".into(), "p".into(), &[], 0, vec![]);
1051 let json_path = default_session_path(ws, "test");
1052 session.write_to(&json_path).unwrap();
1053
1054 let sessions_dir = json_path.parent().unwrap();
1056 fs::write(sessions_dir.join("README.txt"), b"not a session").unwrap();
1057 fs::write(sessions_dir.join("tmp.bak"), b"backup").unwrap();
1058
1059 let sessions = list_sessions(ws).unwrap();
1060
1061 assert_eq!(
1063 sessions.len(),
1064 1,
1065 "non-json files must be excluded; found: {sessions:?}"
1066 );
1067 assert_eq!(
1068 sessions[0].extension().and_then(|e| e.to_str()),
1069 Some("json"),
1070 );
1071 }
1072
1073 #[test]
1074 fn filesystem_safe_timestamp_has_no_colons() {
1075 let ts = filesystem_safe_timestamp();
1076 assert!(!ts.contains(':'));
1077 assert!(ts.ends_with('Z'));
1078 }
1079
1080 #[test]
1081 fn workspace_slug_matches_expected() {
1082 let p = Path::new("/home/user/projects/my-app");
1084 let slug = workspace_slug(p);
1085 assert!(!slug.starts_with('-'));
1086 assert!(slug.contains("home-user-projects-my-app"));
1087 assert!(slug.len() <= 80);
1088
1089 let p = Path::new(".");
1091 let slug = workspace_slug(p);
1092 assert!(!slug.is_empty());
1093 assert!(slug.len() <= 80);
1094 }
1095
1096 #[test]
1103 fn workspace_slug_preserves_underscore_and_dot() {
1104 let p = Path::new("/home/user_name/project.ver");
1105 let slug = workspace_slug(p);
1106 assert!(
1107 slug.contains('_'),
1108 "workspace_slug must preserve underscore; got: {slug}"
1109 );
1110 assert!(
1111 slug.contains('.'),
1112 "workspace_slug must preserve dot; got: {slug}"
1113 );
1114 }
1115
1116 #[test]
1117 fn workspace_slug_truncates_to_80_chars() {
1118 let long_component = "a".repeat(100);
1123 let p = std::path::PathBuf::from(format!("/home/user/{long_component}"));
1124 let slug = workspace_slug(&p);
1125 assert!(
1126 slug.len() <= 80,
1127 "slug must be capped at 80 chars; got length {}",
1128 slug.len()
1129 );
1130 assert!(!slug.starts_with('-'), "slug must not start with dash");
1132 }
1133
1134 #[test]
1137 fn epoch_day_to_ymd_unix_epoch() {
1138 assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
1140 }
1141
1142 #[test]
1143 fn epoch_day_to_ymd_known_dates() {
1144 assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
1146 assert_eq!(epoch_day_to_ymd(11016), (2000, 2, 29));
1148 assert_eq!(epoch_day_to_ymd(47482), (2100, 1, 1));
1151 }
1152
1153 #[test]
1162 fn epoch_day_to_ymd_century_correction_at_doe_36524() {
1163 assert_eq!(epoch_day_to_ymd(47541), (2100, 3, 1));
1165 }
1166
1167 #[test]
1174 fn epoch_day_to_ymd_negative_epoch_day_pre_ce() {
1175 assert_eq!(epoch_day_to_ymd(-719528), (0, 1, 1));
1177 }
1178
1179 #[test]
1180 fn chrono_lite_now_format() {
1181 let ts = chrono_lite_now();
1182 assert_eq!(ts.len(), 20, "unexpected length: {ts}");
1184 assert_eq!(&ts[4..5], "-", "missing first dash: {ts}");
1185 assert_eq!(&ts[7..8], "-", "missing second dash: {ts}");
1186 assert_eq!(&ts[10..11], "T", "missing T separator: {ts}");
1187 assert_eq!(&ts[13..14], ":", "missing first colon: {ts}");
1188 assert_eq!(&ts[16..17], ":", "missing second colon: {ts}");
1189 assert_eq!(&ts[19..20], "Z", "missing Z suffix: {ts}");
1190 let year: u32 = ts[0..4].parse().expect("year");
1192 let month: u32 = ts[5..7].parse().expect("month");
1193 let day: u32 = ts[8..10].parse().expect("day");
1194 let hour: u32 = ts[11..13].parse().expect("hour");
1195 let min: u32 = ts[14..16].parse().expect("minute");
1196 let sec: u32 = ts[17..19].parse().expect("second");
1197 assert!(year >= 2024, "year looks wrong: {year}");
1198 assert!((1..=12).contains(&month), "month out of range: {month}");
1199 assert!((1..=31).contains(&day), "day out of range: {day}");
1200 assert!(hour < 24, "hour out of range: {hour}");
1201 assert!(min < 60, "minute out of range: {min}");
1202 assert!(sec < 60, "second out of range: {sec}");
1203 }
1204
1205 #[test]
1208 fn session_status_serializes_lowercase() {
1209 for (variant, expected) in [
1213 (SessionStatus::Active, "active"),
1214 (SessionStatus::Completed, "completed"),
1215 (SessionStatus::Crashed, "crashed"),
1216 (SessionStatus::Interrupted, "interrupted"),
1217 (SessionStatus::Paused, "paused"),
1218 ] {
1219 let json = serde_json::to_string(&variant).unwrap();
1220 assert_eq!(json, format!("\"{expected}\""), "mismatch for {variant:?}");
1221 }
1222 }
1223
1224 #[test]
1225 fn session_status_display_matches_serde() {
1226 for variant in [
1231 SessionStatus::Active,
1232 SessionStatus::Completed,
1233 SessionStatus::Crashed,
1234 SessionStatus::Interrupted,
1235 SessionStatus::Paused,
1236 ] {
1237 let from_display = format!("{variant}");
1238 let from_serde = serde_json::to_string(&variant)
1239 .unwrap()
1240 .trim_matches('"')
1241 .to_string();
1242 assert_eq!(
1243 from_display, from_serde,
1244 "Display and serde disagree for {variant:?}"
1245 );
1246 }
1247 }
1248
1249 #[test]
1250 fn session_status_legacy_aliases_round_trip() {
1251 let cases = [
1258 ("success", SessionStatus::Completed),
1259 ("incomplete", SessionStatus::Crashed),
1260 ("cancelled", SessionStatus::Interrupted),
1261 ("completed", SessionStatus::Completed),
1262 ("crashed", SessionStatus::Crashed),
1263 ("interrupted", SessionStatus::Interrupted),
1264 ("paused", SessionStatus::Paused),
1265 ];
1266 for (legacy_str, expected) in cases {
1267 let parsed: SessionStatus = serde_json::from_str(&format!("\"{legacy_str}\""))
1268 .unwrap_or_else(|e| panic!("legacy alias {legacy_str:?} did not deserialize: {e}"));
1269 assert_eq!(parsed, expected, "wrong mapping for {legacy_str:?}");
1270 }
1271 }
1272
1273 #[test]
1274 fn session_meta_unknown_status_deserializes_to_active() {
1275 let json = serde_json::json!({
1282 "session_id": "future",
1283 "goal": "future session",
1284 "model": "gpt-4o-mini",
1285 "provider": "openai",
1286 "created_at": "2099-01-01T00:00:00Z",
1287 "updated_at": "2099-01-01T00:00:00Z",
1288 "message_count": 0,
1289 "status": "stalled"
1290 });
1291 let restored: SessionMeta = serde_json::from_value(json)
1292 .expect("unknown status string must not break SessionMeta deserialization");
1293 assert_eq!(
1294 restored.status,
1295 SessionStatus::Active,
1296 "unknown status should map to Active via #[serde(other)]"
1297 );
1298 }
1299
1300 #[test]
1301 fn session_meta_missing_status_field_defaults_to_active() {
1302 let json = serde_json::json!({
1307 "session_id": "missing-status",
1308 "goal": "no status key",
1309 "model": "gpt-4o-mini",
1310 "provider": "openai",
1311 "created_at": "2099-01-01T00:00:00Z",
1312 "updated_at": "2099-01-01T00:00:00Z",
1313 "message_count": 0
1314 });
1316 let restored: SessionMeta = serde_json::from_value(json)
1317 .expect("missing status field must not break SessionMeta deserialization");
1318 assert_eq!(
1319 restored.status,
1320 SessionStatus::Active,
1321 "missing status key should default to Active via #[serde(default)]"
1322 );
1323 }
1324
1325 #[test]
1326 fn session_meta_status_active_round_trip() {
1327 let meta = SessionMeta {
1330 status: SessionStatus::Active,
1331 ..meta_for_test(SUPPORTED_SESSION_SCHEMA_VERSION)
1332 };
1333 let json = serde_json::to_string(&meta).expect("serialize SessionMeta");
1334 let restored: SessionMeta = serde_json::from_str(&json).expect("deserialize SessionMeta");
1335 assert_eq!(restored.status, SessionStatus::Active);
1336 }
1337
1338 #[test]
1339 fn for_finish_maps_exhaustively() {
1340 use crate::agent::FinishReason;
1346 assert_eq!(
1347 SessionStatus::for_finish(&FinishReason::NoMoreToolCalls),
1348 SessionStatus::Completed
1349 );
1350 assert_eq!(
1351 SessionStatus::for_finish(&FinishReason::BudgetExceeded),
1352 SessionStatus::Crashed
1353 );
1354 assert_eq!(
1355 SessionStatus::for_finish(&FinishReason::ProviderStop("rate_limited".into())),
1356 SessionStatus::Crashed
1357 );
1358 assert_eq!(
1359 SessionStatus::for_finish(&FinishReason::Stuck {
1360 repeated_call: "Read".into(),
1361 repeats: 5
1362 }),
1363 SessionStatus::Crashed
1364 );
1365 assert_eq!(
1366 SessionStatus::for_finish(&FinishReason::TranscriptLimit {
1367 chars: 100_000,
1368 limit: 80_000
1369 }),
1370 SessionStatus::Crashed
1371 );
1372 assert_eq!(
1373 SessionStatus::for_finish(&FinishReason::Cancelled),
1374 SessionStatus::Interrupted
1375 );
1376 assert_eq!(
1377 SessionStatus::for_finish(&FinishReason::PermissionDenialLimit),
1378 SessionStatus::Crashed
1379 );
1380 }
1381
1382 fn meta_for_test(schema_version: u32) -> SessionMeta {
1392 SessionMeta {
1393 schema_version,
1394 session_id: "20260611T120000Z-test".into(),
1395 goal: "schema version test".into(),
1396 model: "gpt-4o-mini".into(),
1397 provider: "openai".into(),
1398 created_at: "2026-06-11T12:00:00Z".into(),
1399 updated_at: "2026-06-11T12:00:00Z".into(),
1400 message_count: 0,
1401 status: SessionStatus::Active,
1402 tool_registry_hash: None,
1403 first_prompt: None,
1404 last_prompt: None,
1405 cost: None,
1406 preset: None,
1407 name: None,
1408 }
1409 }
1410
1411 #[test]
1412 fn test_session_meta_round_trip() {
1413 let original = meta_for_test(SUPPORTED_SESSION_SCHEMA_VERSION);
1416 let json = serde_json::to_string(&original).expect("serialize SessionMeta");
1417 let restored: SessionMeta = serde_json::from_str(&json).expect("deserialize SessionMeta");
1418 assert_eq!(restored.schema_version, SUPPORTED_SESSION_SCHEMA_VERSION);
1419 assert_eq!(restored.schema_version, 1);
1420 assert_eq!(restored.session_id, original.session_id);
1421 assert_eq!(restored.goal, original.goal);
1422 }
1423
1424 #[test]
1425 fn test_session_meta_default_schema_version() {
1426 let legacy_json = serde_json::json!({
1430 "session_id": "20260610T120000Z-legacy",
1431 "goal": "legacy session",
1432 "model": "gpt-4o-mini",
1433 "provider": "openai",
1434 "created_at": "2026-06-10T12:00:00Z",
1435 "updated_at": "2026-06-10T12:00:00Z",
1436 "message_count": 0,
1437 "status": "completed"
1438 });
1439 assert!(
1442 legacy_json.get("schema_version").is_none(),
1443 "test fixture must omit schema_version to exercise the default"
1444 );
1445 let restored: SessionMeta =
1446 serde_json::from_value(legacy_json).expect("deserialize legacy SessionMeta");
1447 assert_eq!(restored.schema_version, default_schema_version());
1448 assert_eq!(restored.schema_version, 1);
1449 }
1450
1451 #[test]
1452 fn test_load_rejects_future_schema_version() {
1453 let tmp = tempfile::tempdir().expect("tempdir");
1459 let session_dir = tmp.path();
1460 std::fs::create_dir_all(session_dir).expect("mkdir session dir");
1461 let future = serde_json::json!({
1462 "schema_version": 999,
1463 "session_id": "2099-01-01T00:00:00Z-future",
1464 "goal": "future session",
1465 "model": "gpt-4o-mini",
1466 "provider": "openai",
1467 "created_at": "2099-01-01T00:00:00Z",
1468 "updated_at": "2099-01-01T00:00:00Z",
1469 "message_count": 0,
1470 "status": "active"
1471 });
1472 let meta_path = session_dir.join(".meta.json");
1473 std::fs::write(
1474 &meta_path,
1475 serde_json::to_string_pretty(&future).expect("serialize future meta"),
1476 )
1477 .expect("write future .meta.json");
1478
1479 let err = SessionReader::load_meta(session_dir)
1480 .expect_err("load_meta must reject future schema_version");
1481 let msg = err.to_string();
1482 assert!(
1486 msg.contains("schema_version=999"),
1487 "error must include found version; got: {msg}"
1488 );
1489 assert!(
1490 msg.contains("supported up to 1"),
1491 "error must include supported version; got: {msg}"
1492 );
1493 }
1494
1495 #[test]
1497 fn session_cost_tracks_reasoning_tokens() {
1498 let mut cost = SessionCost::default();
1499 let usage_a = UsageMeta {
1500 reasoning_tokens: Some(1000),
1501 ..Default::default()
1502 };
1503 let usage_b = UsageMeta {
1504 reasoning_tokens: Some(500),
1505 ..Default::default()
1506 };
1507 cost.accumulate(&usage_a);
1508 cost.accumulate(&usage_b);
1509 assert_eq!(cost.total_reasoning_tokens, 1500);
1510 }
1511
1512 #[test]
1517 fn usage_meta_accumulate_adds_tokens() {
1518 let mut a = UsageMeta {
1519 input_tokens: 10,
1520 output_tokens: 20,
1521 cache_creation_tokens: Some(5),
1522 cache_read_tokens: Some(3),
1523 reasoning_tokens: Some(7),
1524 };
1525 let b = UsageMeta {
1526 input_tokens: 1,
1527 output_tokens: 2,
1528 cache_creation_tokens: Some(3),
1529 cache_read_tokens: Some(4),
1530 reasoning_tokens: Some(5),
1531 };
1532 a.accumulate(&b);
1533 assert_eq!(a.input_tokens, 11, "input_tokens must add");
1534 assert_eq!(a.output_tokens, 22, "output_tokens must add");
1535 assert_eq!(a.cache_creation_tokens, Some(8), "cache_creation must add");
1536 assert_eq!(a.cache_read_tokens, Some(7), "cache_read must add");
1537 assert_eq!(a.reasoning_tokens, Some(12), "reasoning must add");
1538 }
1539
1540 #[test]
1541 fn usage_meta_accumulate_with_none_treats_as_zero() {
1542 let mut a = UsageMeta {
1543 input_tokens: 5,
1544 output_tokens: 10,
1545 cache_creation_tokens: None,
1546 cache_read_tokens: None,
1547 reasoning_tokens: None,
1548 };
1549 let b = UsageMeta {
1550 input_tokens: 3,
1551 output_tokens: 4,
1552 cache_creation_tokens: Some(2),
1553 cache_read_tokens: Some(1),
1554 reasoning_tokens: Some(6),
1555 };
1556 a.accumulate(&b);
1557 assert_eq!(a.input_tokens, 8);
1558 assert_eq!(a.output_tokens, 14);
1559 assert_eq!(a.cache_creation_tokens, Some(2), "None + Some(2) = Some(2)");
1560 assert_eq!(a.cache_read_tokens, Some(1), "None + Some(1) = Some(1)");
1561 assert_eq!(a.reasoning_tokens, Some(6), "None + Some(6) = Some(6)");
1562 }
1563
1564 #[test]
1565 fn usage_meta_from_token_usage_positive_values() {
1566 use crate::llm::TokenUsage;
1567 let tu = TokenUsage {
1568 prompt_tokens: 100,
1569 completion_tokens: 50,
1570 total_tokens: 150,
1571 cache_miss_tokens: 30,
1572 cache_hit_tokens: 20,
1573 reasoning_tokens: 10,
1574 };
1575 let meta = UsageMeta::from_token_usage(&tu);
1576 assert_eq!(meta.input_tokens, 100);
1577 assert_eq!(meta.output_tokens, 50);
1578 assert_eq!(meta.cache_creation_tokens, Some(30), "positive miss → Some");
1579 assert_eq!(meta.cache_read_tokens, Some(20), "positive hit → Some");
1580 assert_eq!(meta.reasoning_tokens, Some(10));
1581 }
1582
1583 #[test]
1584 fn usage_meta_from_token_usage_zero_cache_is_none() {
1585 use crate::llm::TokenUsage;
1586 let tu = TokenUsage {
1587 prompt_tokens: 5,
1588 completion_tokens: 5,
1589 total_tokens: 10,
1590 cache_miss_tokens: 0,
1591 cache_hit_tokens: 0,
1592 reasoning_tokens: 0,
1593 };
1594 let meta = UsageMeta::from_token_usage(&tu);
1595 assert_eq!(
1596 meta.cache_creation_tokens, None,
1597 "zero cache_miss must produce None"
1598 );
1599 assert_eq!(
1600 meta.cache_read_tokens, None,
1601 "zero cache_hit must produce None"
1602 );
1603 }
1604
1605 #[test]
1606 fn usage_meta_is_zero_both_zero() {
1607 let meta = UsageMeta {
1608 input_tokens: 0,
1609 output_tokens: 0,
1610 ..Default::default()
1611 };
1612 assert!(meta.is_zero(), "both zero → is_zero returns true");
1613 }
1614
1615 #[test]
1616 fn usage_meta_is_zero_input_nonzero() {
1617 let meta = UsageMeta {
1618 input_tokens: 1,
1619 output_tokens: 0,
1620 ..Default::default()
1621 };
1622 assert!(!meta.is_zero(), "nonzero input → is_zero returns false");
1623 }
1624
1625 #[test]
1626 fn usage_meta_is_zero_output_nonzero() {
1627 let meta = UsageMeta {
1628 input_tokens: 0,
1629 output_tokens: 1,
1630 ..Default::default()
1631 };
1632 assert!(!meta.is_zero(), "nonzero output → is_zero returns false");
1633 }
1634
1635 #[test]
1640 fn session_cost_accumulate_all_fields() {
1641 let mut cost = SessionCost::default();
1642 let usage = UsageMeta {
1643 input_tokens: 100,
1644 output_tokens: 50,
1645 cache_creation_tokens: Some(20),
1646 cache_read_tokens: Some(10),
1647 reasoning_tokens: Some(5),
1648 };
1649 cost.accumulate(&usage);
1650 assert_eq!(cost.total_input_tokens, 100, "input must accumulate");
1651 assert_eq!(cost.total_output_tokens, 50, "output must accumulate");
1652 assert_eq!(
1653 cost.total_cache_creation_tokens, 20,
1654 "cache creation must accumulate"
1655 );
1656 assert_eq!(
1657 cost.total_cache_read_tokens, 10,
1658 "cache read must accumulate"
1659 );
1660 assert_eq!(cost.total_reasoning_tokens, 5, "reasoning must accumulate");
1661 }
1662
1663 #[test]
1664 fn session_cost_accumulate_twice_sums_correctly() {
1665 let mut cost = SessionCost::default();
1666 let u1 = UsageMeta {
1667 input_tokens: 100,
1668 output_tokens: 50,
1669 cache_creation_tokens: None,
1670 cache_read_tokens: None,
1671 reasoning_tokens: None,
1672 };
1673 let u2 = UsageMeta {
1674 input_tokens: 200,
1675 output_tokens: 75,
1676 cache_creation_tokens: None,
1677 cache_read_tokens: None,
1678 reasoning_tokens: None,
1679 };
1680 cost.accumulate(&u1);
1681 cost.accumulate(&u2);
1682 assert_eq!(cost.total_input_tokens, 300, "300 = 100 + 200");
1683 assert_eq!(cost.total_output_tokens, 125, "125 = 50 + 75");
1684 }
1685
1686 #[test]
1691 fn hash_tool_specs_is_deterministic() {
1692 let specs = vec![
1693 ToolSpec {
1694 name: "Read".into(),
1695 description: "read a file".into(),
1696 parameters: serde_json::json!({"type": "object"}),
1697 },
1698 ToolSpec {
1699 name: "Write".into(),
1700 description: "write a file".into(),
1701 parameters: serde_json::json!({"type": "object"}),
1702 },
1703 ];
1704 let h1 = hash_tool_specs(&specs);
1705 let h2 = hash_tool_specs(&specs);
1706 assert_eq!(h1, h2, "same inputs must produce same hash");
1707 assert!(!h1.is_empty(), "hash must be non-empty");
1708 }
1709
1710 #[test]
1711 fn hash_tool_specs_differs_for_different_specs() {
1712 let specs_a = vec![ToolSpec {
1713 name: "Read".into(),
1714 description: "read".into(),
1715 parameters: serde_json::json!({}),
1716 }];
1717 let specs_b = vec![ToolSpec {
1718 name: "Write".into(),
1719 description: "write".into(),
1720 parameters: serde_json::json!({}),
1721 }];
1722 assert_ne!(
1723 hash_tool_specs(&specs_a),
1724 hash_tool_specs(&specs_b),
1725 "different specs must produce different hashes"
1726 );
1727 }
1728
1729 #[test]
1730 fn default_schema_version_is_one() {
1731 assert_eq!(
1732 default_schema_version(),
1733 1,
1734 "default schema version must be 1"
1735 );
1736 }
1737
1738 #[test]
1742 fn export_includes_full_history_and_compact_boundaries() {
1743 let tmp = crate::test_util::IsolatedWorkspace::new();
1744 let ws = tmp.path();
1745 let mut w = SessionWriter::create(ws, "export test", "model", "openai").unwrap();
1746 let session_dir = w.session_dir().to_path_buf();
1747
1748 w.append(&Message::user("folded turn".to_string()), None, None)
1749 .unwrap();
1750 w.append(&Message::assistant("folded reply".to_string()), None, None)
1751 .unwrap();
1752 w.write_compact_boundary(1, 2, None).unwrap();
1754 w.append(&Message::system("summary".to_string()), None, None)
1755 .unwrap();
1756 w.append(&Message::user("post turn".to_string()), None, None)
1757 .unwrap();
1758 w.finish(SessionStatus::Completed).unwrap();
1759
1760 let exported = ExportedTranscript::from_session_dir(&session_dir).unwrap();
1761 assert_eq!(exported.messages.len(), 4);
1763 assert_eq!(exported.message_count, 4);
1764 assert_eq!(exported.compact_boundaries.len(), 1);
1767 let b = &exported.compact_boundaries[0];
1768 assert_eq!(b.removed, 2);
1769 assert_eq!(b.turn, Some(1));
1770 assert_eq!(b.message_index, 2, "boundary precedes the summary message");
1771 }
1772}