1use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::fs::{self, File};
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use uuid::Uuid;
15
16fn atomic_write(path: &Path, content: &str) -> Result<(), std::io::Error> {
23 let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
24 std::fs::write(&tmp_path, content)?;
25 std::fs::rename(&tmp_path, path)?;
26 Ok(())
27}
28
29pub type EntryId = Uuid;
31
32pub const CURRENT_SESSION_VERSION: i32 = 3;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SessionMeta {
42 pub id: Uuid,
44 pub parent_id: Option<Uuid>,
46 pub root_id: Option<Uuid>,
48 pub branch_point: Option<Uuid>,
50 pub created_at: i64,
52 pub updated_at: i64,
54 pub name: Option<String>,
56}
57
58impl SessionMeta {
59 pub fn new(id: Uuid) -> Self {
61 let now = Utc::now().timestamp_millis();
62 Self {
63 id,
64 parent_id: None,
65 root_id: None,
66 branch_point: None,
67 created_at: now,
68 updated_at: now,
69 name: None,
70 }
71 }
72
73 pub fn branched_from(parent_id: Uuid, root_id: Option<Uuid>, branch_point: Uuid) -> Self {
75 let now = Utc::now().timestamp_millis();
76 Self {
77 id: Uuid::new_v4(),
78 parent_id: Some(parent_id),
79 root_id: root_id.or(Some(parent_id)),
80 branch_point: Some(branch_point),
81 created_at: now,
82 updated_at: now,
83 name: None,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct BranchInfo {
91 pub session_id: Uuid,
93 pub parent_session_id: Option<Uuid>,
95 pub root_session_id: Option<Uuid>,
97 pub branch_point_entry_id: Option<Uuid>,
99 pub parent_session_name: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SessionHeader {
110 #[serde(rename = "type")]
112 pub entry_type: String,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub version: Option<i32>,
116 pub id: String,
118 pub timestamp: String,
120 pub cwd: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub parent_session: Option<String>,
125}
126
127impl SessionHeader {
128 pub fn new(id: String, cwd: String, parent_session: Option<String>) -> Self {
130 Self {
131 entry_type: "session".to_string(),
132 version: Some(CURRENT_SESSION_VERSION),
133 id,
134 timestamp: Utc::now().to_rfc3339(),
135 cwd,
136 parent_session,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(untagged)]
148pub enum ContentValue {
149 String(String),
151 Blocks(Vec<ContentBlock>),
153}
154
155impl ContentValue {
156 pub fn as_str(&self) -> &str {
158 match self {
159 ContentValue::String(s) => s,
160 ContentValue::Blocks(blocks) => {
161 for block in blocks {
163 if let ContentBlock::Text { text } = block {
164 return text;
165 }
166 }
167 ""
168 }
169 }
170 }
171}
172
173impl From<String> for ContentValue {
174 fn from(s: String) -> Self {
175 ContentValue::String(s)
176 }
177}
178
179impl From<&str> for ContentValue {
180 fn from(s: &str) -> Self {
181 ContentValue::String(s.to_string())
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(tag = "type")]
188pub enum ContentBlock {
189 #[serde(rename = "text")]
191 Text {
192 text: String,
194 },
195 #[serde(rename = "image")]
197 Image {
198 data: String,
200 media_type: Option<String>,
202 },
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(tag = "role")]
212pub enum AgentMessage {
213 #[serde(rename = "user")]
215 User {
216 #[serde(flatten)]
218 content: ContentValue,
219 },
220 #[serde(rename = "assistant")]
222 Assistant {
223 content: Vec<AssistantContentBlock>,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 provider: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 model_id: Option<String>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 usage: Option<Usage>,
234 #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
236 stop_reason: Option<String>,
237 },
238 #[serde(rename = "toolResult")]
240 ToolResult {
241 content: ContentValue,
243 #[serde(rename = "toolCallId")]
245 tool_call_id: String,
246 },
247 #[serde(rename = "system")]
249 System {
250 #[serde(flatten)]
252 content: ContentValue,
253 },
254 #[serde(rename = "bashExecution")]
256 BashExecution {
257 command: String,
259 output: String,
261 #[serde(rename = "exitCode")]
263 exit_code: Option<i32>,
264 cancelled: bool,
266 truncated: bool,
268 #[serde(rename = "fullOutputPath", skip_serializing_if = "Option::is_none")]
270 full_output_path: Option<String>,
271 #[serde(rename = "excludeFromContext", skip_serializing_if = "Option::is_none")]
273 exclude_from_context: Option<bool>,
274 timestamp: i64,
276 },
277 #[serde(rename = "custom")]
279 Custom {
280 #[serde(rename = "customType")]
282 custom_type: String,
283 content: ContentValue,
285 display: bool,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 details: Option<serde_json::Value>,
290 timestamp: i64,
292 },
293 #[serde(rename = "branchSummary")]
295 BranchSummary {
296 summary: String,
298 #[serde(rename = "fromId")]
300 from_id: String,
301 timestamp: i64,
303 },
304 #[serde(rename = "compactionSummary")]
306 CompactionSummary {
307 summary: String,
309 #[serde(rename = "tokensBefore")]
311 tokens_before: i64,
312 timestamp: i64,
314 },
315}
316
317impl AgentMessage {
318 pub fn content(&self) -> String {
320 match self {
321 AgentMessage::User { content } => content.as_str().to_string(),
322 AgentMessage::Assistant { content, .. } => {
323 let estimated_len = content
324 .iter()
325 .map(|b| match b {
326 AssistantContentBlock::Text { text: t } => t.len(),
327 _ => 0,
328 })
329 .sum::<usize>();
330 let mut text = String::with_capacity(estimated_len.max(256));
331 for block in content {
332 if let AssistantContentBlock::Text { text: t } = block {
333 text.push_str(t)
334 }
335 }
336 text
337 }
338 AgentMessage::ToolResult { content, .. } => content.as_str().to_string(),
339 AgentMessage::System { content } => content.as_str().to_string(),
340 AgentMessage::BashExecution { output, .. } => output.clone(),
341 AgentMessage::Custom { content, .. } => content.as_str().to_string(),
342 AgentMessage::BranchSummary { summary, .. } => summary.clone(),
343 AgentMessage::CompactionSummary { summary, .. } => summary.clone(),
344 }
345 }
346
347 pub fn is_user(&self) -> bool {
349 matches!(self, AgentMessage::User { .. })
350 }
351
352 pub fn is_assistant(&self) -> bool {
354 matches!(self, AgentMessage::Assistant { .. })
355 }
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
360#[serde(tag = "type")]
361pub enum AssistantContentBlock {
362 #[serde(rename = "text")]
364 Text {
365 text: String,
367 },
368 #[serde(rename = "thinking")]
370 Thinking {
371 thinking: String,
373 },
374 #[serde(rename = "toolCall")]
376 ToolCall {
377 id: String,
379 name: String,
381 arguments: serde_json::Value,
383 },
384 #[serde(rename = "toolPlan")]
386 ToolPlan {
387 content: String,
389 #[serde(rename = "toolCallId")]
391 tool_call_id: String,
392 },
393 #[serde(rename = "image")]
395 ImageResult {
396 data: String,
398 media_type: String,
400 },
401 #[serde(rename = "refusal")]
403 Refusal {
404 content: String,
406 },
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct Usage {
412 #[serde(rename = "inputTokens", skip_serializing_if = "Option::is_none")]
414 pub input: Option<i64>,
415 #[serde(rename = "outputTokens", skip_serializing_if = "Option::is_none")]
417 pub output: Option<i64>,
418 #[serde(rename = "cacheReadTokens", skip_serializing_if = "Option::is_none")]
420 pub cache_read: Option<i64>,
421 #[serde(rename = "cacheWriteTokens", skip_serializing_if = "Option::is_none")]
423 pub cache_write: Option<i64>,
424 #[serde(rename = "totalTokens", skip_serializing_if = "Option::is_none")]
426 pub total_tokens: Option<i64>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct SessionEntryBase {
436 #[serde(rename = "type")]
438 pub entry_type: String,
439 pub id: String,
441 #[serde(rename = "parentId")]
443 pub parent_id: Option<String>,
444 pub timestamp: String,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct SessionMessageEntry {
451 #[serde(flatten)]
453 pub base: SessionEntryBase,
454 pub message: AgentMessage,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct ThinkingLevelChangeEntry {
461 #[serde(flatten)]
463 pub base: SessionEntryBase,
464 #[serde(rename = "thinkingLevel")]
466 pub thinking_level: String,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct ModelChangeEntry {
472 #[serde(flatten)]
474 pub base: SessionEntryBase,
475 pub provider: String,
477 #[serde(rename = "modelId")]
479 pub model_id: String,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct CompactionEntry {
485 #[serde(flatten)]
487 pub base: SessionEntryBase,
488 pub summary: String,
490 #[serde(rename = "firstKeptEntryId")]
492 pub first_kept_entry_id: String,
493 #[serde(rename = "tokensBefore")]
495 pub tokens_before: i64,
496 #[serde(skip_serializing_if = "Option::is_none")]
498 pub details: Option<serde_json::Value>,
499 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
501 pub from_hook: Option<bool>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct BranchSummaryEntry {
507 #[serde(flatten)]
509 pub base: SessionEntryBase,
510 #[serde(rename = "fromId")]
512 pub from_id: String,
513 pub summary: String,
515 #[serde(skip_serializing_if = "Option::is_none")]
517 pub details: Option<serde_json::Value>,
518 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
520 pub from_hook: Option<bool>,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct CustomEntry {
526 #[serde(flatten)]
528 pub base: SessionEntryBase,
529 #[serde(rename = "customType")]
531 pub custom_type: String,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub data: Option<serde_json::Value>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct LabelEntry {
540 #[serde(flatten)]
542 pub base: SessionEntryBase,
543 #[serde(rename = "targetId")]
545 pub target_id: String,
546 pub label: Option<String>,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct SessionInfoEntry {
553 #[serde(flatten)]
555 pub base: SessionEntryBase,
556 pub name: Option<String>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct CustomMessageEntry {
563 #[serde(flatten)]
565 pub base: SessionEntryBase,
566 #[serde(rename = "customType")]
568 pub custom_type: String,
569 pub content: ContentValue,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 pub details: Option<serde_json::Value>,
574 pub display: bool,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
580#[serde(untagged)]
581pub enum SessionEntryEnum {
582 Message(SessionMessageEntry),
584 ThinkingLevelChange(ThinkingLevelChangeEntry),
586 ModelChange(ModelChangeEntry),
588 Compaction(CompactionEntry),
590 BranchSummary(BranchSummaryEntry),
592 Custom(CustomEntry),
594 Label(LabelEntry),
596 SessionInfo(SessionInfoEntry),
598 CustomMessage(CustomMessageEntry),
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct SessionEntry {
606 pub id: String,
608 pub parent_id: Option<String>,
610 pub timestamp: i64,
612 pub message: AgentMessage,
614}
615
616impl SessionEntry {
617 pub fn new(message: AgentMessage) -> Self {
619 Self {
620 id: Uuid::new_v4().to_string(),
621 parent_id: None,
622 timestamp: Utc::now().timestamp_millis(),
623 message,
624 }
625 }
626
627 pub fn simple_message(role: &str, content: &str) -> Self {
629 use crate::session::ContentValue;
630 let message = match role {
631 "user" => AgentMessage::User {
632 content: ContentValue::String(content.to_string()),
633 },
634 "assistant" => AgentMessage::Assistant {
635 content: vec![AssistantContentBlock::Text {
636 text: content.to_string(),
637 }],
638 provider: None,
639 model_id: None,
640 usage: None,
641 stop_reason: None,
642 },
643 "system" => AgentMessage::System {
644 content: ContentValue::String(content.to_string()),
645 },
646 _ => AgentMessage::System {
647 content: ContentValue::String(content.to_string()),
648 },
649 };
650 Self::new(message)
651 }
652
653 pub fn branched(message: AgentMessage, parent_id: &str) -> Self {
655 Self {
656 id: Uuid::new_v4().to_string(),
657 parent_id: Some(parent_id.to_string()),
658 timestamp: Utc::now().timestamp_millis(),
659 message,
660 }
661 }
662
663 pub fn content(&self) -> String {
665 self.message.content()
666 }
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(untagged)]
672pub enum FileEntry {
673 Header(SessionHeader),
675 Entry(SessionEntryEnum),
677}
678
679#[derive(Debug, Clone)]
685pub struct SessionContext {
686 pub messages: Vec<AgentMessage>,
688 pub thinking_level: String,
690 pub model: Option<ModelInfo>,
692}
693
694#[derive(Debug, Clone)]
696pub struct ModelInfo {
697 pub provider: String,
699 pub model_id: String,
701}
702
703#[derive(Debug, Clone)]
709pub struct SessionInfo {
710 pub path: String,
712 pub id: String,
714 pub cwd: String,
716 pub name: Option<String>,
718 pub parent_session_path: Option<String>,
720 pub created: DateTime<Utc>,
722 pub modified: DateTime<Utc>,
724 pub message_count: i64,
726 pub first_message: String,
728 pub all_messages_text: String,
730}
731
732#[derive(Debug, Clone)]
738pub struct SessionTreeNode {
739 pub entry: SessionEntry,
741 pub children: Vec<SessionTreeNode>,
743 pub label: Option<String>,
745 pub label_timestamp: Option<String>,
747}
748
749fn generate_id(by_id: &HashSet<String>) -> String {
754 for _ in 0..100 {
755 let id = Uuid::new_v4().to_string()[..8].to_string();
756 if !by_id.contains(&id) {
757 return id;
758 }
759 }
760 Uuid::new_v4().to_string()
762}
763
764fn migrate_v1_to_v2(entries: &mut [FileEntry]) {
770 let mut ids = HashSet::new();
771 let mut prev_id: Option<String> = None;
772
773 for entry in entries.iter_mut() {
774 match entry {
775 FileEntry::Header(header) => {
776 header.version = Some(2);
777 }
778 FileEntry::Entry(entry) => {
779 let id = match entry {
780 SessionEntryEnum::Message(e) => {
781 e.base.id = generate_id(&ids);
782 e.base.parent_id = prev_id.clone();
783 e.base.entry_type = "message".to_string();
784 prev_id = Some(e.base.id.clone());
785 e.base.id.clone()
786 }
787 SessionEntryEnum::ThinkingLevelChange(e) => {
788 e.base.id = generate_id(&ids);
789 e.base.parent_id = prev_id.clone();
790 e.base.entry_type = "thinking_level_change".to_string();
791 prev_id = Some(e.base.id.clone());
792 e.base.id.clone()
793 }
794 SessionEntryEnum::ModelChange(e) => {
795 e.base.id = generate_id(&ids);
796 e.base.parent_id = prev_id.clone();
797 e.base.entry_type = "model_change".to_string();
798 prev_id = Some(e.base.id.clone());
799 e.base.id.clone()
800 }
801 SessionEntryEnum::Compaction(e) => {
802 e.base.id = generate_id(&ids);
803 e.base.parent_id = prev_id.clone();
804 e.base.entry_type = "compaction".to_string();
805 prev_id = Some(e.base.id.clone());
806 e.base.id.clone()
807 }
808 SessionEntryEnum::BranchSummary(e) => {
809 e.base.id = generate_id(&ids);
810 e.base.parent_id = prev_id.clone();
811 e.base.entry_type = "branch_summary".to_string();
812 prev_id = Some(e.base.id.clone());
813 e.base.id.clone()
814 }
815 SessionEntryEnum::Custom(e) => {
816 e.base.id = generate_id(&ids);
817 e.base.parent_id = prev_id.clone();
818 e.base.entry_type = "custom".to_string();
819 prev_id = Some(e.base.id.clone());
820 e.base.id.clone()
821 }
822 SessionEntryEnum::Label(e) => {
823 e.base.id = generate_id(&ids);
824 e.base.parent_id = prev_id.clone();
825 e.base.entry_type = "label".to_string();
826 prev_id = Some(e.base.id.clone());
827 e.base.id.clone()
828 }
829 SessionEntryEnum::SessionInfo(e) => {
830 e.base.id = generate_id(&ids);
831 e.base.parent_id = prev_id.clone();
832 e.base.entry_type = "session_info".to_string();
833 prev_id = Some(e.base.id.clone());
834 e.base.id.clone()
835 }
836 SessionEntryEnum::CustomMessage(e) => {
837 e.base.id = generate_id(&ids);
838 e.base.parent_id = prev_id.clone();
839 e.base.entry_type = "custom_message".to_string();
840 prev_id = Some(e.base.id.clone());
841 e.base.id.clone()
842 }
843 };
844 ids.insert(id);
845 }
846 }
847 }
848}
849
850fn migrate_v2_to_v3(entries: &mut [FileEntry]) {
852 for entry in entries.iter_mut() {
853 match entry {
854 FileEntry::Header(header) => {
855 header.version = Some(3);
856 }
857 FileEntry::Entry(_) => {
858 }
860 }
861 }
862}
863
864fn migrate_to_current_version(entries: &mut [FileEntry]) -> bool {
866 let header = entries.iter().find_map(|e| match e {
867 FileEntry::Header(h) => Some(h),
868 _ => None,
869 });
870 let version = header.and_then(|h| h.version).unwrap_or(1);
871
872 if version >= CURRENT_SESSION_VERSION {
873 return false;
874 }
875
876 if version < 2 {
877 migrate_v1_to_v2(entries);
878 }
879 if version < 3 {
880 migrate_v2_to_v3(entries);
881 }
882
883 true
884}
885
886pub struct SessionManager {
896 session_id: String,
897 session_file: Option<String>,
898 session_dir: String,
899 cwd: String,
900 persist: bool,
901 flushed: bool,
902 persisted_count: RwLock<usize>,
905 file_entries: RwLock<Vec<FileEntry>>,
906 by_id: RwLock<HashMap<String, SessionEntry>>,
907 labels_by_id: RwLock<HashMap<String, String>>,
908 label_timestamps_by_id: RwLock<HashMap<String, String>>,
909 leaf_id: RwLock<Option<String>>,
910}
911
912impl Clone for SessionManager {
914 fn clone(&self) -> Self {
915 Self {
916 session_id: self.session_id.clone(),
917 session_file: self.session_file.clone(),
918 session_dir: self.session_dir.clone(),
919 cwd: self.cwd.clone(),
920 persist: self.persist,
921 flushed: self.flushed,
922 persisted_count: RwLock::new(*self.persisted_count.read()),
923 file_entries: RwLock::new(self.file_entries.read().clone()),
924 by_id: RwLock::new(self.by_id.read().clone()),
925 labels_by_id: RwLock::new(self.labels_by_id.read().clone()),
926 label_timestamps_by_id: RwLock::new(self.label_timestamps_by_id.read().clone()),
927 leaf_id: RwLock::new(self.leaf_id.read().clone()),
928 }
929 }
930}
931
932impl SessionManager {
933 pub fn create(cwd: &str, session_dir: Option<&str>) -> Self {
935 let dir = session_dir
936 .map(|s| s.to_string())
937 .unwrap_or_else(|| get_default_session_dir(cwd));
938
939 let mut manager = Self::new_internal(cwd, &dir, None, true);
940 manager.persist = true;
941 manager
942 }
943
944 pub fn open(path: &str, session_dir: Option<&str>, cwd_override: Option<&str>) -> Self {
946 let entries = load_entries_from_file(path);
947 let header = entries.iter().find_map(|e| match e {
948 FileEntry::Header(h) => Some(h),
949 _ => None,
950 });
951 let cwd = cwd_override
952 .map(|s| s.to_string())
953 .or_else(|| header.as_ref().map(|h| h.cwd.clone()))
954 .unwrap_or_else(|| {
955 std::env::current_dir()
956 .unwrap_or_else(|_| PathBuf::from("."))
957 .to_string_lossy()
958 .to_string()
959 });
960 let dir = session_dir.map(|s| s.to_string()).unwrap_or_else(|| {
961 Path::new(path)
962 .parent()
963 .map(|p| p.to_string_lossy().to_string())
964 .unwrap_or_else(|| ".".to_string())
965 });
966
967 let mut manager = Self::new_internal(&cwd, &dir, Some(path), true);
968 manager.persist = true;
969 manager
970 }
971
972 pub fn continue_recent(cwd: &str, session_dir: Option<&str>) -> Self {
974 let dir = session_dir
975 .map(|s| s.to_string())
976 .unwrap_or_else(|| get_default_session_dir(cwd));
977
978 if let Some(most_recent) = find_most_recent_session(&dir) {
979 return Self::open(&most_recent, None, None);
980 }
981 Self::create(cwd, None)
982 }
983
984 pub fn in_memory(cwd: &str) -> Self {
986 let cwd = cwd.to_string();
987 Self::new_internal(&cwd, "", None, false)
988 }
989
990 fn new_internal(
991 cwd: &str,
992 session_dir: &str,
993 session_file: Option<&str>,
994 persist: bool,
995 ) -> Self {
996 let cwd = cwd.to_string();
997 let session_dir = session_dir.to_string();
998
999 if persist && !session_dir.is_empty() && !Path::new(&session_dir).exists() {
1000 let _ = fs::create_dir_all(&session_dir);
1001 }
1002
1003 let mut manager = Self {
1004 session_id: Uuid::new_v4().to_string(),
1005 session_file: session_file.map(|s| s.to_string()),
1006 session_dir,
1007 cwd,
1008 persist,
1009 flushed: false,
1010 persisted_count: RwLock::new(0),
1011 file_entries: RwLock::new(Vec::new()),
1012 by_id: RwLock::new(HashMap::new()),
1013 labels_by_id: RwLock::new(HashMap::new()),
1014 label_timestamps_by_id: RwLock::new(HashMap::new()),
1015 leaf_id: RwLock::new(None),
1016 };
1017
1018 if let Some(file) = session_file {
1019 manager.set_session_file(file);
1020 } else {
1021 manager.new_session(None);
1022 }
1023
1024 manager
1025 }
1026
1027 pub fn set_session_file(&mut self, session_file: &str) {
1029 let path = Path::new(session_file)
1030 .canonicalize()
1031 .unwrap_or_else(|_| PathBuf::from(session_file));
1032 let path_str = path.to_string_lossy().to_string();
1033 self.session_file = Some(path_str.clone());
1034
1035 if path.exists() {
1036 let mut entries = load_entries_from_file(&path_str);
1037
1038 if entries.is_empty() {
1040 let explicit_path = self.session_file.take();
1041 self.new_session(None);
1042 self.session_file = explicit_path;
1043 self._rewrite_file();
1044 self.flushed = true;
1045 return;
1046 }
1047
1048 let header = entries.iter().find_map(|e| match e {
1049 FileEntry::Header(h) => Some(h),
1050 _ => None,
1051 });
1052 self.session_id = header
1053 .map(|h| h.id.clone())
1054 .unwrap_or_else(|| Uuid::new_v4().to_string());
1055
1056 if migrate_to_current_version(&mut entries) {
1057 self._rewrite_file();
1058 }
1059
1060 *self.file_entries.write() = entries;
1061 self._build_index();
1062 self.flushed = true;
1063 } else {
1064 let explicit_path = self.session_file.take();
1065 self.new_session(None);
1066 self.session_file = explicit_path;
1067 }
1068 }
1069
1070 pub fn new_session(&mut self, options: Option<NewSessionOptions>) {
1072 self.session_id = options
1073 .as_ref()
1074 .and_then(|o| o.id.clone())
1075 .unwrap_or_else(|| Uuid::new_v4().to_string());
1076 let timestamp = Utc::now().to_rfc3339();
1077 let header = SessionHeader::new(
1078 self.session_id.clone(),
1079 self.cwd.clone(),
1080 options.and_then(|o| o.parent_session),
1081 );
1082
1083 self.file_entries = RwLock::new(vec![FileEntry::Header(header)]);
1084 self.by_id.write().clear();
1085 self.labels_by_id.write().clear();
1086 self.label_timestamps_by_id.write().clear();
1087 *self.leaf_id.write() = None;
1088 *self.persisted_count.write() = 0;
1089 self.flushed = false;
1090
1091 if self.persist {
1092 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1093 let short_id = &self.session_id[..8];
1094 self.session_file = Some(format!(
1095 "{}/{}_{}.jsonl",
1096 self.session_dir, file_timestamp, short_id
1097 ));
1098 }
1099 }
1100
1101 fn _build_index(&mut self) {
1102 let mut by_id = self.by_id.write();
1103 let mut labels = self.labels_by_id.write();
1104 let mut label_timestamps = self.label_timestamps_by_id.write();
1105 let mut leaf_id = self.leaf_id.write();
1106
1107 by_id.clear();
1108 labels.clear();
1109 label_timestamps.clear();
1110 *leaf_id = None;
1111
1112 for entry in self.file_entries.read().iter() {
1113 if let FileEntry::Entry(e) = entry {
1114 if let Some(session_entry) = convert_to_session_entry(e) {
1116 by_id.insert(session_entry.id.clone(), session_entry.clone());
1117 *leaf_id = Some(session_entry.id.clone());
1118 }
1119
1120 if let SessionEntryEnum::Label(l) = e {
1122 if let Some(ref label) = l.label {
1123 labels.insert(l.target_id.clone(), label.clone());
1124 label_timestamps.insert(l.target_id.clone(), l.base.timestamp.clone());
1125 } else {
1126 labels.remove(&l.target_id);
1127 label_timestamps.remove(&l.target_id);
1128 }
1129 }
1130 }
1131 }
1132 }
1133
1134 fn _rewrite_file(&self) {
1135 if !self.persist || self.session_file.is_none() {
1136 return;
1137 }
1138
1139 let file = match self.session_file.as_ref() {
1140 Some(f) => f,
1141 None => return,
1142 };
1143
1144 let content: String = self
1145 .file_entries
1146 .read()
1147 .iter()
1148 .map(|e| serde_json::to_string(e).unwrap_or_default())
1149 .collect::<Vec<_>>()
1150 .join("\n")
1151 + "\n";
1152
1153 if let Err(e) = atomic_write(Path::new(file), &content) {
1154 tracing::warn!("Failed to rewrite session file {}: {}", file, e);
1155 }
1156 }
1157
1158 pub fn is_persisted(&self) -> bool {
1160 self.persist
1161 }
1162
1163 pub fn validate_session_id(id: &str) -> bool {
1168 Uuid::parse_str(id).is_ok()
1169 }
1170
1171 pub fn is_readonly(&self) -> bool {
1179 if !self.persist {
1180 return false;
1182 }
1183 if let Some(ref file) = self.session_file {
1184 let path = Path::new(file);
1185 if path.exists() {
1186 if let Ok(metadata) = fs::metadata(path) {
1187 #[cfg(unix)]
1188 {
1189 use std::os::unix::fs::PermissionsExt;
1190 let perm = metadata.permissions().mode();
1191 return perm & 0o200 == 0;
1193 }
1194 #[cfg(not(unix))]
1195 {
1196 let _ = metadata;
1197 return false;
1198 }
1199 }
1200 }
1201 }
1202 false
1203 }
1204
1205 pub fn can_append(&self) -> bool {
1209 !self.is_readonly() && self.persist
1210 }
1211
1212 pub fn persisted_count(&self) -> usize {
1214 *self.persisted_count.read()
1215 }
1216
1217 pub fn set_persisted_count(&self, count: usize) {
1219 *self.persisted_count.write() = count;
1220 }
1221
1222 pub fn get_cwd(&self) -> String {
1224 self.cwd.clone()
1225 }
1226
1227 pub fn get_session_dir(&self) -> String {
1229 self.session_dir.clone()
1230 }
1231
1232 pub fn get_session_id(&self) -> String {
1234 self.session_id.clone()
1235 }
1236
1237 pub fn get_session_file(&self) -> Option<String> {
1239 self.session_file.clone()
1240 }
1241
1242 pub fn cleanup_if_empty(&self) {
1246 if !self.persist {
1247 return;
1248 }
1249 let Some(file) = &self.session_file else {
1250 return;
1251 };
1252
1253 let has_user = self.file_entries.read().iter().any(|e| {
1254 matches!(
1255 e,
1256 FileEntry::Entry(SessionEntryEnum::Message(m)) if m.message.is_user()
1257 )
1258 });
1259
1260 if !has_user {
1261 let path = Path::new(file);
1262 if path.exists() {
1263 if let Err(e) = fs::remove_file(path) {
1264 tracing::warn!("Failed to remove empty session file {}: {}", file, e);
1265 } else {
1266 tracing::debug!("Removed empty session file: {}", file);
1267 }
1268 }
1269 }
1270 }
1271
1272 fn _persist(&mut self, entry: &SessionEntry) {
1273 if !self.persist {
1274 return;
1275 }
1276 let Some(file) = &self.session_file else {
1277 return;
1278 };
1279
1280 let has_assistant = self.file_entries.read().iter().any(|e| {
1285 matches!(
1286 e,
1287 FileEntry::Entry(SessionEntryEnum::Message(m))
1288 if m.message.is_assistant()
1289 )
1290 });
1291
1292 if !has_assistant {
1293 self.flushed = false;
1297 return;
1298 }
1299
1300 let mut handle = match fs::OpenOptions::new().create(true).append(true).open(file) {
1301 Ok(h) => h,
1302 Err(e) => {
1303 tracing::warn!("Failed to open session file for append {}: {}", file, e);
1304 return;
1305 }
1306 };
1307
1308 if !self.flushed {
1309 for e in self.file_entries.read().iter() {
1310 if let Ok(line) = serde_json::to_string(e) {
1311 let _ = writeln!(&mut handle, "{}", line);
1312 }
1313 }
1314 self.flushed = true;
1315 } else {
1316 let file_entry = convert_from_session_entry(entry);
1318 if let Ok(line) = serde_json::to_string(&file_entry) {
1319 let _ = writeln!(&mut handle, "{}", line);
1320 }
1321 }
1322 }
1323
1324 fn _append_entry(&mut self, entry: SessionEntry) {
1328 let file_entry = convert_from_session_entry(&entry);
1329 self.file_entries.write().push(FileEntry::Entry(file_entry));
1330 self.by_id.write().insert(entry.id.clone(), entry.clone());
1331 *self.leaf_id.write() = Some(entry.id.clone());
1332 self._persist(&entry);
1333 }
1334
1335 pub fn append_message(&mut self, message: AgentMessage) -> String {
1337 let leaf = self.leaf_id.read().clone();
1338 let id = Uuid::new_v4().to_string();
1339 let entry = SessionEntry {
1340 id: id.clone(),
1341 parent_id: leaf,
1342 timestamp: Utc::now().timestamp_millis(),
1343 message,
1344 };
1345 self._append_entry(entry);
1346 id
1347 }
1348
1349 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1351 let leaf = self.leaf_id.read().clone();
1352 let id = Uuid::new_v4().to_string();
1353 let entry = SessionEntry {
1354 id: id.clone(),
1355 parent_id: leaf,
1356 timestamp: Utc::now().timestamp_millis(),
1357 message: AgentMessage::Custom {
1358 custom_type: "thinking_level_change".to_string(),
1359 content: ContentValue::String(thinking_level.to_string()),
1360 display: false,
1361 details: None,
1362 timestamp: Utc::now().timestamp_millis(),
1363 },
1364 };
1365 self._append_entry(entry);
1366 id
1367 }
1368
1369 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1371 let leaf = self.leaf_id.read().clone();
1372 let id = Uuid::new_v4().to_string();
1373 let entry = SessionEntry {
1374 id: id.clone(),
1375 parent_id: leaf,
1376 timestamp: Utc::now().timestamp_millis(),
1377 message: AgentMessage::Custom {
1378 custom_type: "model_change".to_string(),
1379 content: ContentValue::String(format!("{}:{}", provider, model_id)),
1380 display: false,
1381 details: None,
1382 timestamp: Utc::now().timestamp_millis(),
1383 },
1384 };
1385 self._append_entry(entry);
1386 id
1387 }
1388
1389 pub fn append_compaction(
1391 &mut self,
1392 summary: &str,
1393 _first_kept_entry_id: &str,
1394 tokens_before: i64,
1395 _details: Option<serde_json::Value>,
1396 _from_hook: Option<bool>,
1397 ) -> String {
1398 let leaf = self.leaf_id.read().clone();
1399 let id = Uuid::new_v4().to_string();
1400 let entry = SessionEntry {
1401 id: id.clone(),
1402 parent_id: leaf,
1403 timestamp: Utc::now().timestamp_millis(),
1404 message: AgentMessage::CompactionSummary {
1405 summary: summary.to_string(),
1406 tokens_before,
1407 timestamp: Utc::now().timestamp_millis(),
1408 },
1409 };
1410 self._append_entry(entry);
1411 id
1412 }
1413
1414 pub fn append_custom_entry(
1416 &mut self,
1417 custom_type: &str,
1418 data: Option<serde_json::Value>,
1419 ) -> String {
1420 let leaf = self.leaf_id.read().clone();
1421 let id = Uuid::new_v4().to_string();
1422 let entry = SessionEntry {
1423 id: id.clone(),
1424 parent_id: leaf,
1425 timestamp: Utc::now().timestamp_millis(),
1426 message: AgentMessage::Custom {
1427 custom_type: custom_type.to_string(),
1428 content: data
1429 .as_ref()
1430 .map(|d| ContentValue::String(d.to_string()))
1431 .unwrap_or(ContentValue::String(String::new())),
1432 display: false,
1433 details: data.clone(),
1434 timestamp: Utc::now().timestamp_millis(),
1435 },
1436 };
1437 self._append_entry(entry);
1438 id
1439 }
1440
1441 pub fn append_session_info(&mut self, name: &str) -> String {
1443 let leaf = self.leaf_id.read().clone();
1444 let id = Uuid::new_v4().to_string();
1445 let entry = SessionEntry {
1446 id: id.clone(),
1447 parent_id: leaf,
1448 timestamp: Utc::now().timestamp_millis(),
1449 message: AgentMessage::Custom {
1450 custom_type: "session_info".to_string(),
1451 content: ContentValue::String(name.trim().to_string()),
1452 display: false,
1453 details: None,
1454 timestamp: Utc::now().timestamp_millis(),
1455 },
1456 };
1457 self._append_entry(entry);
1458 id
1459 }
1460
1461 pub fn get_session_name(&self) -> Option<String> {
1463 let entries = self.get_entries();
1464 for entry in entries.iter().rev() {
1465 if let AgentMessage::Custom {
1466 custom_type,
1467 content,
1468 ..
1469 } = &entry.message
1470 {
1471 if custom_type == "session_info" {
1472 return Some(content.as_str().trim().to_string()).filter(|s| !s.is_empty());
1473 }
1474 }
1475 }
1476 None
1477 }
1478
1479 pub fn append_custom_message_entry(
1481 &mut self,
1482 custom_type: &str,
1483 content: ContentValue,
1484 display: bool,
1485 details: Option<serde_json::Value>,
1486 ) -> String {
1487 let leaf = self.leaf_id.read().clone();
1488 let id = Uuid::new_v4().to_string();
1489 let entry = SessionEntry {
1490 id: id.clone(),
1491 parent_id: leaf,
1492 timestamp: Utc::now().timestamp_millis(),
1493 message: AgentMessage::Custom {
1494 custom_type: custom_type.to_string(),
1495 content,
1496 display,
1497 details,
1498 timestamp: Utc::now().timestamp_millis(),
1499 },
1500 };
1501 self._append_entry(entry);
1502 id
1503 }
1504
1505 pub fn get_leaf_id(&self) -> Option<String> {
1511 self.leaf_id.read().clone()
1512 }
1513
1514 pub fn set_leaf_from_entry(&self, entry_id: &str) -> Result<(), String> {
1521 if !self.by_id.read().contains_key(entry_id) {
1522 return Err(format!("Entry {} not found", entry_id));
1523 }
1524 *self.leaf_id.write() = Some(entry_id.to_string());
1525 Ok(())
1526 }
1527
1528 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1530 self.leaf_id
1531 .read()
1532 .as_ref()
1533 .and_then(|id| self.by_id.read().get(id).cloned())
1534 }
1535
1536 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
1538 self.by_id.read().get(id).cloned()
1539 }
1540
1541 pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1543 self.by_id
1544 .read()
1545 .values()
1546 .filter(|e| e.parent_id.as_deref() == Some(parent_id))
1547 .cloned()
1548 .collect()
1549 }
1550
1551 pub fn get_parent(&self, id: &str) -> Option<SessionEntry> {
1553 self.by_id
1554 .read()
1555 .get(id)
1556 .and_then(|e| e.parent_id.as_deref())
1557 .and_then(|pid| self.by_id.read().get(pid).cloned())
1558 }
1559
1560 pub fn get_label(&self, id: &str) -> Option<String> {
1562 self.labels_by_id.read().get(id).cloned()
1563 }
1564
1565 pub fn append_label_change(
1567 &mut self,
1568 target_id: &str,
1569 label: Option<&str>,
1570 ) -> Result<String, String> {
1571 if !self.by_id.read().contains_key(target_id) {
1572 return Err(format!("Entry {} not found", target_id));
1573 }
1574
1575 let leaf = self.leaf_id.read().clone();
1576 let id = Uuid::new_v4().to_string();
1577 let entry = SessionEntry {
1578 id: id.clone(),
1579 parent_id: leaf,
1580 timestamp: Utc::now().timestamp_millis(),
1581 message: AgentMessage::Custom {
1582 custom_type: "label".to_string(),
1583 content: ContentValue::String(label.unwrap_or("").to_string()),
1584 display: false,
1585 details: Some(serde_json::json!({ "targetId": target_id })),
1586 timestamp: Utc::now().timestamp_millis(),
1587 },
1588 };
1589
1590 self._append_entry(entry);
1591
1592 if let Some(l) = label {
1593 self.labels_by_id
1594 .write()
1595 .insert(target_id.to_string(), l.to_string());
1596 self.label_timestamps_by_id
1597 .write()
1598 .insert(target_id.to_string(), Utc::now().to_rfc3339());
1599 } else {
1600 self.labels_by_id.write().remove(target_id);
1601 self.label_timestamps_by_id.write().remove(target_id);
1602 }
1603
1604 Ok(id)
1605 }
1606
1607 pub fn get_branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1609 let mut path = Vec::new();
1610 let leaf_fallback = self.leaf_id.read().clone();
1611 let start_id = from_id.or(leaf_fallback.as_deref());
1612 let Some(start_id) = start_id else {
1613 return path;
1614 };
1615
1616 let by_id = self.by_id.read();
1618 let mut current = by_id.get(start_id).cloned();
1619 while let Some(entry) = current {
1620 path.insert(0, entry.clone());
1621 current = entry
1622 .parent_id
1623 .as_ref()
1624 .and_then(|pid| by_id.get(pid).cloned());
1625 }
1626 path
1627 }
1628
1629 pub fn get_path_to_root(&self, from_id: &str) -> Vec<SessionEntry> {
1631 self.get_branch(Some(from_id))
1632 }
1633
1634 pub fn get_ancestry(&self, from_id: &str) -> Vec<SessionEntry> {
1636 self.get_branch(Some(from_id))
1637 }
1638
1639 pub fn get_depth(&self, id: &str) -> i64 {
1641 let mut depth = 0;
1642 let mut current = self.by_id.read().get(id).cloned();
1643 while let Some(entry) = current {
1644 depth += 1;
1645 current = entry
1646 .parent_id
1647 .as_ref()
1648 .and_then(|pid| self.by_id.read().get(pid).cloned());
1649 }
1650 depth - 1 }
1652
1653 pub fn build_session_context(&self) -> SessionContext {
1655 let entries = self.get_entries();
1656 let leaf_id = self.leaf_id.read().clone();
1657 build_session_context_internal(&entries, leaf_id, None)
1658 }
1659
1660 pub fn get_header(&self) -> Option<SessionHeader> {
1662 self.file_entries.read().iter().find_map(|e| match e {
1663 FileEntry::Header(h) => Some(h.clone()),
1664 _ => None,
1665 })
1666 }
1667
1668 pub fn get_entries(&self) -> Vec<SessionEntry> {
1670 self.by_id.read().values().cloned().collect()
1671 }
1672
1673 pub fn get_tree(&self, _id: Uuid) -> anyhow::Result<Vec<SessionTreeNode>> {
1676 let entries = self.get_entries();
1677 let labels: HashMap<String, String> = self.labels_by_id.read().clone();
1678 let label_timestamps: HashMap<String, String> = self.label_timestamps_by_id.read().clone();
1679
1680 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
1681 let mut root_ids: Vec<String> = Vec::new();
1682
1683 for entry in &entries {
1685 adj.insert(entry.id.clone(), Vec::new());
1686 }
1687
1688 for entry in &entries {
1690 let is_root = match entry.parent_id.as_deref() {
1691 Some(pid) if pid != entry.id => !adj.contains_key(pid),
1692 _ => true,
1693 };
1694 if is_root {
1695 root_ids.push(entry.id.clone());
1696 } else if let Some(ref pid) = entry.parent_id {
1697 if let Some(children) = adj.get_mut(pid.as_str()) {
1698 children.push(entry.id.clone());
1699 } else {
1700 root_ids.push(entry.id.clone());
1701 }
1702 }
1703 }
1704
1705 let entries_map: HashMap<String, SessionEntry> =
1707 entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1708
1709 fn build(
1711 id: &str,
1712 adj: &HashMap<String, Vec<String>>,
1713 entries_map: &HashMap<String, SessionEntry>,
1714 labels: &HashMap<String, String>,
1715 label_timestamps: &HashMap<String, String>,
1716 ) -> anyhow::Result<SessionTreeNode> {
1717 let entry = entries_map
1718 .get(id)
1719 .ok_or_else(|| anyhow::anyhow!("Corrupted session: entry {} not found", id))?
1720 .clone();
1721 let child_ids = adj.get(id).cloned().unwrap_or_default();
1722 let children: Vec<SessionTreeNode> = child_ids
1723 .iter()
1724 .map(|cid| build(cid, adj, entries_map, labels, label_timestamps))
1725 .collect::<Result<Vec<_>, _>>()?;
1726 Ok(SessionTreeNode {
1727 entry,
1728 children,
1729 label: labels.get(id).cloned(),
1730 label_timestamp: label_timestamps.get(id).cloned(),
1731 })
1732 }
1733
1734 let mut roots = root_ids
1735 .into_iter()
1736 .map(|rid| build(&rid, &adj, &entries_map, &labels, &label_timestamps))
1737 .collect::<anyhow::Result<Vec<_>>>()?;
1738
1739 sort_tree_by_timestamp(&mut roots);
1740 Ok(roots)
1741 }
1742
1743 pub fn branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1749 if !self.by_id.read().contains_key(branch_from_id) {
1750 return Err(format!("Entry {} not found", branch_from_id));
1751 }
1752 *self.leaf_id.write() = Some(branch_from_id.to_string());
1753 Ok(())
1754 }
1755
1756 pub fn reset_leaf(&mut self) {
1758 *self.leaf_id.write() = None;
1759 }
1760
1761 pub fn branch_with_summary(
1763 &mut self,
1764 branch_from_id: Option<&str>,
1765 summary: &str,
1766 _details: Option<serde_json::Value>,
1767 _from_hook: Option<bool>,
1768 ) -> String {
1769 if let Some(id) = branch_from_id {
1770 if !self.by_id.read().contains_key(id) {
1771 return String::new();
1772 }
1773 }
1774
1775 *self.leaf_id.write() = branch_from_id.map(|s| s.to_string());
1776
1777 let id = Uuid::new_v4().to_string();
1778 let entry = SessionEntry {
1779 id: id.clone(),
1780 parent_id: branch_from_id.map(|s| s.to_string()),
1781 timestamp: Utc::now().timestamp_millis(),
1782 message: AgentMessage::BranchSummary {
1783 summary: summary.to_string(),
1784 from_id: branch_from_id.unwrap_or("root").to_string(),
1785 timestamp: Utc::now().timestamp_millis(),
1786 },
1787 };
1788
1789 self._append_entry(entry);
1790 id
1791 }
1792
1793 pub fn add_label(&mut self, target_id: &str, label: &str) -> Result<String, String> {
1795 self.append_label_change(target_id, Some(label))
1796 }
1797
1798 pub fn remove_label(&mut self, target_id: &str) -> Result<String, String> {
1800 self.append_label_change(target_id, None)
1801 }
1802
1803 pub fn get_latest_compaction_entry(&self) -> Option<SessionEntry> {
1809 let entries = self.get_entries();
1810 for entry in entries.iter().rev() {
1811 if let AgentMessage::CompactionSummary { .. } = &entry.message {
1812 return Some(entry.clone());
1813 }
1814 }
1815 None
1816 }
1817
1818 pub fn get_compaction_entries(&self) -> Vec<SessionEntry> {
1820 self.get_entries()
1821 .iter()
1822 .filter(|e| matches!(&e.message, AgentMessage::CompactionSummary { .. }))
1823 .cloned()
1824 .collect()
1825 }
1826
1827 pub fn get_session_stats(&self) -> SessionStats {
1833 let entries = self.get_entries();
1834 let mut message_count = 0i64;
1835 let mut user_message_count = 0i64;
1836 let mut assistant_message_count = 0i64;
1837 let mut total_chars = 0i64;
1838 let mut total_tokens_estimate = 0i64;
1839
1840 for entry in &entries {
1841 if let AgentMessage::User { .. } = &entry.message {
1842 user_message_count += 1;
1843 }
1844 if let AgentMessage::Assistant { .. } = &entry.message {
1845 assistant_message_count += 1;
1846 }
1847 if entry.message.is_user() || entry.message.is_assistant() {
1848 message_count += 1;
1849 let content = entry.content();
1851 let chars = content.len() as i64;
1852 total_chars += chars;
1853 total_tokens_estimate += (chars as f64 / 4.0).ceil() as i64;
1854 }
1855 }
1856
1857 SessionStats {
1858 message_count,
1859 user_message_count,
1860 assistant_message_count,
1861 total_chars,
1862 estimated_tokens: total_tokens_estimate,
1863 }
1864 }
1865
1866 pub async fn list(cwd: &str, session_dir: Option<&str>) -> Result<Vec<SessionInfo>> {
1872 let dir = session_dir
1873 .map(|s| s.to_string())
1874 .unwrap_or_else(|| get_default_session_dir(cwd));
1875 list_sessions_from_dir(&dir).await
1876 }
1877
1878 pub async fn list_all() -> Result<Vec<SessionInfo>> {
1880 let sessions_dir = get_sessions_dir();
1881
1882 if !Path::new(&sessions_dir).exists() {
1883 return Ok(Vec::new());
1884 }
1885
1886 let mut all_sessions = Vec::new();
1887 let entries = fs::read_dir(&sessions_dir)?;
1888
1889 for entry in entries {
1890 let entry = entry?;
1891 let path = entry.path();
1892 if path.is_dir() {
1893 if let Ok(sessions) = list_sessions_from_dir(&path.to_string_lossy()).await {
1894 all_sessions.extend(sessions);
1895 }
1896 }
1897 }
1898
1899 all_sessions.sort_by_key(|b| std::cmp::Reverse(b.modified));
1900 Ok(all_sessions)
1901 }
1902
1903 pub fn fork_from(
1905 source_path: &str,
1906 target_cwd: &str,
1907 session_dir: Option<&str>,
1908 ) -> Result<Self, String> {
1909 let source_entries = load_entries_from_file(source_path);
1910 if source_entries.is_empty() {
1911 return Err(format!(
1912 "Cannot fork: source session file is empty or invalid: {}",
1913 source_path
1914 ));
1915 }
1916
1917 let source_header = source_entries.iter().find_map(|e| match e {
1918 FileEntry::Header(h) => Some(h),
1919 _ => None,
1920 });
1921 if source_header.is_none() {
1922 return Err(format!(
1923 "Cannot fork: source session has no header: {}",
1924 source_path
1925 ));
1926 }
1927
1928 let dir = session_dir
1929 .map(|s| s.to_string())
1930 .unwrap_or_else(|| get_default_session_dir(target_cwd));
1931
1932 if !Path::new(&dir).exists() {
1933 let _ = fs::create_dir_all(&dir);
1934 }
1935
1936 let new_session_id = Uuid::new_v4().to_string();
1937 let timestamp = Utc::now().to_rfc3339();
1938 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1939 let short_id = &new_session_id[..8];
1940 let new_session_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
1941
1942 let new_header = SessionHeader {
1944 entry_type: "session".to_string(),
1945 version: Some(CURRENT_SESSION_VERSION),
1946 id: new_session_id.clone(),
1947 timestamp: timestamp.clone(),
1948 cwd: target_cwd.to_string(),
1949 parent_session: Some(source_path.to_string()),
1950 };
1951
1952 let mut handle = fs::OpenOptions::new()
1953 .create(true)
1954 .truncate(true)
1955 .write(true)
1956 .open(&new_session_file)
1957 .map_err(|e| e.to_string())?;
1958 writeln!(
1959 &mut handle,
1960 "{}",
1961 serde_json::to_string(&new_header).expect("session header serializable")
1962 )
1963 .map_err(|e| e.to_string())?;
1964
1965 for file_entry in &source_entries {
1967 if let FileEntry::Entry(_) = file_entry {
1968 writeln!(
1969 &mut handle,
1970 "{}",
1971 serde_json::to_string(file_entry).expect("session entry serializable")
1972 )
1973 .map_err(|e| e.to_string())?;
1974 }
1975 }
1976
1977 Ok(Self::open(&new_session_file, Some(&dir), Some(target_cwd)))
1978 }
1979
1980 pub fn delete_session(path: &str) -> Result<()> {
1982 fs::remove_file(path).context("Failed to delete session file")?;
1983 Ok(())
1984 }
1985
1986 pub fn rename_session(&mut self, name: &str) -> String {
1988 self.append_session_info(name)
1989 }
1990
1991 pub async fn new() -> Result<Self> {
1997 Self::new_async().await
1998 }
1999
2000 pub async fn new_async() -> Result<Self> {
2002 let home = dirs::home_dir().context("Cannot find home directory")?;
2003 let base_dir = home.join(".oxi");
2004 let sessions_dir = base_dir.join("sessions");
2005 tokio::fs::create_dir_all(&sessions_dir).await?;
2006 let cwd = std::env::current_dir()
2007 .unwrap_or_else(|_| PathBuf::from("."))
2008 .to_string_lossy()
2009 .to_string();
2010 Ok(Self::in_memory(&cwd))
2011 }
2012
2013 pub fn session_path(&self, id: &Uuid) -> PathBuf {
2015 if let Some(file) = &self.session_file {
2016 PathBuf::from(file)
2017 } else {
2018 PathBuf::from(format!("{}/{}.jsonl", self.session_dir, id))
2019 }
2020 }
2021
2022 pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
2024 let mut metas = Vec::new();
2026 let session_dir = Path::new(&self.session_dir);
2027 if !session_dir.exists() {
2028 return Ok(metas);
2029 }
2030 let entries = fs::read_dir(session_dir)?;
2031 for entry in entries {
2032 let entry = entry?;
2033 let path = entry.path();
2034 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2035 let file_name = path
2036 .file_stem()
2037 .unwrap_or_else(|| std::ffi::OsStr::new(""))
2038 .to_string_lossy()
2039 .to_string();
2040 if let Some(uuid_part) = file_name.split('_').next_back() {
2042 if let Ok(uuid) = Uuid::parse_str(uuid_part) {
2043 let mtime = entry.metadata().ok().and_then(|m| m.modified().ok());
2044 let now_ts = Utc::now().timestamp_millis();
2045 metas.push(SessionMeta {
2046 id: uuid,
2047 parent_id: None,
2048 root_id: None,
2049 branch_point: None,
2050 created_at: now_ts,
2051 updated_at: mtime
2052 .map(|t| {
2053 let dt: DateTime<Utc> = DateTime::from(t);
2054 dt.timestamp_millis()
2055 })
2056 .unwrap_or(now_ts),
2057 name: None,
2058 });
2059 }
2060 }
2061 }
2062 }
2063 metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
2064 Ok(metas)
2065 }
2066
2067 pub async fn save(&self, _id: Uuid, _entries: &[SessionEntry]) -> Result<()> {
2069 self._rewrite_file();
2070 Ok(())
2071 }
2072
2073 pub async fn load(&self, _id: Uuid) -> Result<Vec<SessionEntry>> {
2075 Ok(self.get_entries())
2076 }
2077
2078 pub async fn delete(&self, id: Uuid) -> Result<()> {
2080 let path = self.session_path(&id);
2081 if path.exists() {
2082 fs::remove_file(path).context("Failed to delete session file")?;
2083 }
2084 Ok(())
2085 }
2086
2087 pub async fn branch_from(
2089 &self,
2090 parent_id: Uuid,
2091 entry_id: Uuid,
2092 ) -> Result<(Uuid, Vec<SessionEntry>)> {
2093 let _entry_id_str = entry_id.to_string();
2094 let _parent_id_str = parent_id.to_string();
2095
2096 let _entries = self.get_entries();
2098 let path = self.get_branch(Some(&entry_id.to_string()));
2099
2100 let new_id = Uuid::new_v4();
2101 let new_entries: Vec<SessionEntry> = path
2102 .into_iter()
2103 .map(|e| {
2104 let mut new_entry = e.clone();
2105 new_entry.id = Uuid::new_v4().to_string();
2106 new_entry
2107 })
2108 .collect();
2109
2110 Ok((new_id, new_entries))
2113 }
2114
2115 pub async fn get_branch_info(&self, _id: Uuid) -> Result<Option<BranchInfo>> {
2117 Ok(None)
2119 }
2120
2121 pub async fn get_tree_async(&self, _id: Uuid) -> Result<Vec<SessionTreeNode>> {
2123 self.get_tree(Uuid::nil())
2124 }
2125
2126 pub async fn save_meta(&self, _meta: &SessionMeta) -> Result<()> {
2128 Ok(())
2129 }
2130
2131 pub async fn load_meta(&self, _id: Uuid) -> Result<Option<SessionMeta>> {
2133 Ok(None)
2134 }
2135
2136 pub async fn create_session(&mut self) -> Result<SessionMeta> {
2138 let id = Uuid::new_v4();
2139 let meta = SessionMeta::new(id);
2140 Ok(meta)
2141 }
2142
2143 pub fn branch_from_entry(&self, entry_id: &str) -> Result<String, String> {
2145 let path = self
2146 .get_session_file()
2147 .ok_or_else(|| "No session file path".to_string())?;
2148 let source_entries = load_entries_from_file(&path);
2149 if source_entries.is_empty() {
2150 return Err("Cannot fork: source session is empty".to_string());
2151 }
2152 let _header = source_entries
2154 .iter()
2155 .find_map(|e| match e {
2156 FileEntry::Header(h) => Some(h),
2157 _ => None,
2158 })
2159 .ok_or_else(|| "Missing session header".to_string())?;
2160 let new_id = Uuid::new_v4().to_string();
2161 let timestamp = chrono::Utc::now().to_rfc3339();
2162 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
2163 let short_id = &new_id[..8];
2164 let dir = std::path::Path::new(&path)
2165 .parent()
2166 .map(|p| p.to_string_lossy().into_owned())
2167 .unwrap_or_else(|| ".".to_string());
2168 let new_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
2169 let mut found = false;
2170 let mut new_entries = vec![FileEntry::Header(SessionHeader {
2171 entry_type: "session".to_string(),
2172 version: Some(CURRENT_SESSION_VERSION),
2173 id: new_id.clone(),
2174 timestamp,
2175 cwd: self.get_cwd(),
2176 parent_session: Some(path),
2177 })];
2178 for file_entry in &source_entries {
2179 if let FileEntry::Entry(entry) = file_entry {
2180 let eid = match entry {
2181 SessionEntryEnum::Message(m) => m.base.id.clone(),
2182 SessionEntryEnum::ThinkingLevelChange(m) => m.base.id.clone(),
2183 SessionEntryEnum::ModelChange(m) => m.base.id.clone(),
2184 SessionEntryEnum::Compaction(m) => m.base.id.clone(),
2185 SessionEntryEnum::BranchSummary(m) => m.base.id.clone(),
2186 SessionEntryEnum::Custom(m) => m.base.id.clone(),
2187 SessionEntryEnum::Label(m) => m.base.id.clone(),
2188 SessionEntryEnum::SessionInfo(m) => m.base.id.clone(),
2189 SessionEntryEnum::CustomMessage(m) => m.base.id.clone(),
2190 };
2191 if eid == entry_id {
2192 found = true;
2193 let mut entry = entry.clone();
2196 clear_entry_parent_id(&mut entry);
2197 new_entries.push(FileEntry::Entry(entry));
2198 } else if found {
2199 new_entries.push(FileEntry::Entry(entry.clone()));
2200 }
2201 }
2202 }
2203 if !found {
2204 return Err(format!("Entry not found: {}", entry_id));
2205 }
2206 let mut handle = std::fs::OpenOptions::new()
2207 .create(true)
2208 .truncate(true)
2209 .write(true)
2210 .open(&new_file)
2211 .map_err(|e| e.to_string())?;
2212 for entry in &new_entries {
2213 let line = serde_json::to_string(entry).map_err(|e| e.to_string())?;
2214 writeln!(&mut handle, "{}", line).map_err(|e| e.to_string())?;
2215 }
2216 Ok(new_file)
2217 }
2218}
2219
2220fn clear_entry_parent_id(entry: &mut SessionEntryEnum) {
2227 match entry {
2228 SessionEntryEnum::Message(m) => m.base.parent_id = None,
2229 SessionEntryEnum::ThinkingLevelChange(m) => m.base.parent_id = None,
2230 SessionEntryEnum::ModelChange(m) => m.base.parent_id = None,
2231 SessionEntryEnum::Compaction(m) => m.base.parent_id = None,
2232 SessionEntryEnum::BranchSummary(m) => m.base.parent_id = None,
2233 SessionEntryEnum::Custom(m) => m.base.parent_id = None,
2234 SessionEntryEnum::Label(m) => m.base.parent_id = None,
2235 SessionEntryEnum::SessionInfo(m) => m.base.parent_id = None,
2236 SessionEntryEnum::CustomMessage(m) => m.base.parent_id = None,
2237 }
2238}
2239
2240fn convert_to_session_entry(entry: &SessionEntryEnum) -> Option<SessionEntry> {
2242 match entry {
2243 SessionEntryEnum::Message(m) => Some(SessionEntry {
2244 id: m.base.id.clone(),
2245 parent_id: m.base.parent_id.clone(),
2246 timestamp: DateTime::parse_from_rfc3339(&m.base.timestamp)
2247 .map(|dt| dt.timestamp_millis())
2248 .unwrap_or(0),
2249 message: m.message.clone(),
2250 }),
2251 _ => None, }
2253}
2254
2255fn convert_from_session_entry(entry: &SessionEntry) -> SessionEntryEnum {
2257 let timestamp = DateTime::from_timestamp_millis(entry.timestamp)
2258 .map(|dt| dt.to_rfc3339())
2259 .unwrap_or_else(|| Utc::now().to_rfc3339());
2260
2261 SessionEntryEnum::Message(SessionMessageEntry {
2262 base: SessionEntryBase {
2263 entry_type: "message".to_string(),
2264 id: entry.id.clone(),
2265 parent_id: entry.parent_id.clone(),
2266 timestamp,
2267 },
2268 message: entry.message.clone(),
2269 })
2270}
2271
2272#[derive(Debug, Clone)]
2278pub struct SessionStats {
2279 pub message_count: i64,
2281 pub user_message_count: i64,
2283 pub assistant_message_count: i64,
2285 pub total_chars: i64,
2287 pub estimated_tokens: i64,
2289}
2290
2291#[derive(Debug, Clone)]
2297pub struct NewSessionOptions {
2298 pub id: Option<String>,
2300 pub parent_session: Option<String>,
2302}
2303
2304pub fn get_default_session_dir(cwd: &str) -> String {
2310 let agent_dir = get_agent_dir();
2311 let safe_path = format!("--{}--", cwd.replace(['/', '\\', ':'], "-"));
2312 let session_dir = format!("{}/sessions/{}", agent_dir, safe_path);
2313
2314 if !Path::new(&session_dir).exists() {
2315 let _ = fs::create_dir_all(&session_dir);
2316 }
2317
2318 session_dir
2319}
2320
2321fn get_agent_dir() -> String {
2322 dirs::home_dir()
2323 .map(|h| h.join(".oxi").to_string_lossy().to_string())
2324 .unwrap_or_else(|| ".oxi".to_string())
2325}
2326
2327fn get_sessions_dir() -> String {
2328 format!("{}/sessions", get_agent_dir())
2329}
2330
2331fn load_entries_from_file(file_path: &str) -> Vec<FileEntry> {
2333 if !Path::new(file_path).exists() {
2334 return Vec::new();
2335 }
2336
2337 let file = match File::open(file_path) {
2338 Ok(f) => f,
2339 Err(_) => return Vec::new(),
2340 };
2341
2342 let reader = BufReader::new(file);
2343 let mut entries = Vec::new();
2344
2345 for line in reader.lines() {
2346 let line = match line {
2347 Ok(l) => l,
2348 Err(_) => continue,
2349 };
2350 if line.trim().is_empty() {
2351 continue;
2352 }
2353 match serde_json::from_str::<FileEntry>(&line) {
2354 Ok(entry) => entries.push(entry),
2355 Err(_) => continue,
2356 }
2357 }
2358
2359 if entries.is_empty() {
2361 return entries;
2362 }
2363 let header = match &entries[0] {
2364 FileEntry::Header(h) => h,
2365 _ => return Vec::new(),
2366 };
2367 if header.entry_type != "session" || header.id.is_empty() {
2368 return Vec::new();
2369 }
2370
2371 entries
2372}
2373
2374fn is_valid_session_file(file_path: &str) -> bool {
2376 if let Ok(mut file) = File::open(file_path) {
2377 use std::io::Read;
2378 let mut buffer = vec![0u8; 512];
2379 if let Ok(bytes_read) = file.read(&mut buffer) {
2380 if let Ok(content) = String::from_utf8(buffer[..bytes_read].to_vec()) {
2381 if let Some(first_line) = content.split('\n').next() {
2382 if let Ok(header) = serde_json::from_str::<SessionHeader>(first_line) {
2383 return header.entry_type == "session" && !header.id.is_empty();
2384 }
2385 }
2386 }
2387 }
2388 }
2389 false
2390}
2391
2392pub fn find_recent_session_path(cwd: &str) -> Option<String> {
2394 let dir = get_default_session_dir(cwd);
2395 find_most_recent_session(&dir)
2396}
2397
2398fn find_most_recent_session(session_dir: &str) -> Option<String> {
2399 if !Path::new(session_dir).exists() {
2400 return None;
2401 }
2402
2403 let mut files: Vec<(String, std::time::SystemTime)> = Vec::new();
2404
2405 if let Ok(entries) = fs::read_dir(session_dir) {
2406 for entry in entries.flatten() {
2407 let path = entry.path();
2408 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2409 if let Some(path_str) = path.to_str() {
2410 if is_valid_session_file(path_str) {
2411 if let Ok(metadata) = entry.metadata() {
2412 if let Ok(mtime) = metadata.modified() {
2413 files.push((path_str.to_string(), mtime));
2414 }
2415 }
2416 }
2417 }
2418 }
2419 }
2420 }
2421
2422 files.sort_by_key(|b| std::cmp::Reverse(b.1));
2423 files.into_iter().next().map(|(p, _)| p)
2424}
2425
2426pub fn resolve_session_path(input: &str, cwd: &str) -> Result<String, String> {
2428 let path = input.trim();
2429 if path.is_empty() {
2430 return Err("Empty path".to_string());
2431 }
2432 let resolved = if let Some(rest) = path.strip_prefix('~') {
2433 if rest.is_empty() {
2434 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2435 home.to_string_lossy().into_owned()
2436 } else if let Some(rest) = rest.strip_prefix('/') {
2437 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2438 format!("{}/{}", home.to_string_lossy(), rest)
2439 } else {
2440 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2441 format!("{}/{}", home.to_string_lossy(), rest)
2442 }
2443 } else if path.starts_with('/') || path.contains(':') {
2444 path.to_string()
2445 } else {
2446 if let Some(stripped) = path.strip_prefix("./") {
2447 format!("{}/{}", cwd.trim_end_matches('/'), stripped)
2448 } else {
2449 format!("{}/{}", cwd.trim_end_matches('/'), path)
2450 }
2451 };
2452 let p = std::path::Path::new(&resolved);
2453 p.canonicalize()
2454 .map(|c| c.to_string_lossy().into_owned())
2455 .or(Ok(resolved))
2456}
2457
2458fn build_session_context_internal(
2460 entries: &[SessionEntry],
2461 leaf_id: Option<String>,
2462 _by_id: Option<&RwLock<HashMap<String, SessionEntry>>>,
2463) -> SessionContext {
2464 let leaf: Option<&SessionEntry> = leaf_id
2466 .as_ref()
2467 .and_then(|id| entries.iter().find(|e| e.id == *id));
2468
2469 let leaf = leaf.or_else(|| entries.last());
2470
2471 let Some(leaf) = leaf else {
2472 return SessionContext {
2473 messages: Vec::new(),
2474 thinking_level: "off".to_string(),
2475 model: None,
2476 };
2477 };
2478
2479 let mut path: Vec<&SessionEntry> = Vec::new();
2481 let mut current: Option<&SessionEntry> = Some(leaf);
2482 while let Some(entry) = current {
2483 path.insert(0, entry);
2484 current = entry
2485 .parent_id
2486 .as_ref()
2487 .and_then(|pid| entries.iter().find(|e| e.id == *pid));
2488 }
2489
2490 let mut thinking_level = "off".to_string();
2492 let mut model: Option<ModelInfo> = None;
2493
2494 for entry in &path {
2495 if let AgentMessage::Assistant {
2496 provider, model_id, ..
2497 } = &entry.message
2498 {
2499 model = Some(ModelInfo {
2500 provider: provider.clone().unwrap_or_default(),
2501 model_id: model_id.clone().unwrap_or_default(),
2502 });
2503 }
2504 if let AgentMessage::Custom {
2505 custom_type,
2506 content,
2507 ..
2508 } = &entry.message
2509 {
2510 if custom_type == "thinking_level_change" {
2511 thinking_level = content.as_str().to_string();
2512 }
2513 }
2514 }
2515
2516 let messages: Vec<AgentMessage> = path
2518 .iter()
2519 .filter(|e| {
2520 e.message.is_user()
2521 || e.message.is_assistant()
2522 || matches!(&e.message, AgentMessage::BranchSummary { .. })
2523 || matches!(&e.message, AgentMessage::CompactionSummary { .. })
2524 })
2525 .map(|e| e.message.clone())
2526 .collect();
2527
2528 SessionContext {
2529 messages,
2530 thinking_level,
2531 model,
2532 }
2533}
2534
2535fn sort_tree_by_timestamp(nodes: &mut Vec<SessionTreeNode>) {
2537 nodes.sort_by_key(|a| a.entry.timestamp);
2538
2539 for node in nodes {
2540 sort_tree_by_timestamp(&mut node.children);
2541 }
2542}
2543
2544async fn list_sessions_from_dir(dir: &str) -> Result<Vec<SessionInfo>> {
2546 if !Path::new(dir).exists() {
2547 return Ok(Vec::new());
2548 }
2549
2550 let mut sessions = Vec::new();
2551
2552 let entries = fs::read_dir(dir)?;
2553 let files: Vec<String> = entries
2554 .filter_map(|e| e.ok())
2555 .filter(|e| {
2556 e.path()
2557 .extension()
2558 .map(|ext| ext == "jsonl")
2559 .unwrap_or(false)
2560 })
2561 .filter_map(|e| e.path().to_str().map(|s| s.to_string()))
2562 .collect();
2563
2564 for file in files {
2565 if let Some(info) = build_session_info(&file).await {
2566 sessions.push(info);
2567 }
2568 }
2569
2570 Ok(sessions)
2571}
2572
2573async fn build_session_info(file_path: &str) -> Option<SessionInfo> {
2575 let content = fs::read_to_string(file_path).ok()?;
2576 let entries = parse_session_entries(&content)?;
2577
2578 if entries.is_empty() {
2579 return None;
2580 }
2581
2582 let header = match &entries[0] {
2583 FileEntry::Header(h) => h,
2584 _ => return None,
2585 };
2586
2587 let stats = fs::metadata(file_path).ok()?;
2588 let mut message_count = 0i64;
2589 let mut first_message = String::new();
2590 let mut all_messages = Vec::new();
2591 let mut name: Option<String> = None;
2592
2593 for entry in &entries {
2594 if let FileEntry::Entry(e) = entry {
2595 if let SessionEntryEnum::SessionInfo(si) = e {
2597 name = si
2598 .name
2599 .clone()
2600 .map(|n| n.trim().to_string())
2601 .filter(|n| !n.is_empty());
2602 }
2603 if let SessionEntryEnum::Message(m) = e {
2605 if m.message.is_user() {
2606 message_count += 1;
2607 let text = m.message.content();
2608 if !text.is_empty() {
2609 all_messages.push(text.clone());
2610 if first_message.is_empty() {
2611 first_message = text;
2612 }
2613 }
2614 } else if m.message.is_assistant() {
2615 if first_message.is_empty() {
2617 let text = m.message.content();
2618 if !text.is_empty() {
2619 first_message = text;
2620 }
2621 }
2622 }
2623 }
2624 }
2625 }
2626
2627 if first_message.is_empty() {
2631 return None;
2632 }
2633
2634 let cwd = header.cwd.clone();
2635 let parent_session_path = header.parent_session.clone();
2636 let created = chrono::DateTime::parse_from_rfc3339(&header.timestamp)
2637 .map(|dt| dt.with_timezone(&Utc))
2638 .unwrap_or_else(|_| Utc::now());
2639 let modified = get_session_modified_date(&entries, &header.timestamp, &stats);
2640
2641 Some(SessionInfo {
2642 path: file_path.to_string(),
2643 id: header.id.clone(),
2644 cwd,
2645 name,
2646 parent_session_path,
2647 created,
2648 modified,
2649 message_count,
2650 first_message: if first_message.is_empty() {
2651 "(no messages)".to_string()
2652 } else {
2653 first_message
2654 },
2655 all_messages_text: all_messages.join(" "),
2656 })
2657}
2658
2659fn parse_session_entries(content: &str) -> Option<Vec<FileEntry>> {
2661 let mut entries = Vec::new();
2662
2663 for line in content.trim().lines() {
2664 if line.trim().is_empty() {
2665 continue;
2666 }
2667 if let Ok(entry) = serde_json::from_str::<FileEntry>(line) {
2668 entries.push(entry);
2669 }
2670 }
2671
2672 Some(entries)
2673}
2674
2675fn get_session_modified_date(
2677 entries: &[FileEntry],
2678 header_timestamp: &str,
2679 stats: &std::fs::Metadata,
2680) -> DateTime<Utc> {
2681 let last_activity_time = get_last_activity_time(entries);
2682 if let Some(t) = last_activity_time {
2683 if t > 0 {
2684 return DateTime::from_timestamp_millis(t).unwrap_or_else(Utc::now);
2685 }
2686 }
2687
2688 let header_time = chrono::DateTime::parse_from_rfc3339(header_timestamp)
2689 .map(|dt| dt.timestamp_millis())
2690 .unwrap_or(-1);
2691
2692 if header_time > 0 {
2693 return DateTime::from_timestamp_millis(header_time).unwrap_or_else(Utc::now);
2694 }
2695
2696 if let Ok(mtime) = stats.modified() {
2697 return DateTime::from(mtime);
2698 }
2699
2700 Utc::now()
2701}
2702
2703fn get_last_activity_time(entries: &[FileEntry]) -> Option<i64> {
2705 let mut last_activity: Option<i64> = None;
2706
2707 for entry in entries {
2708 let entry = match entry {
2709 FileEntry::Entry(e) => e,
2710 _ => continue,
2711 };
2712
2713 if let SessionEntryEnum::Message(m) = entry {
2714 if m.message.is_user() || m.message.is_assistant() {
2715 last_activity = Some(std::cmp::max(
2716 last_activity.unwrap_or(0),
2717 m.base.timestamp.parse().unwrap_or(0),
2718 ));
2719 }
2720 }
2721 }
2722
2723 last_activity
2724}
2725
2726#[cfg(test)]
2731mod tests {
2732 use super::*;
2733
2734 #[test]
2735 fn test_session_creation() {
2736 let manager = SessionManager::in_memory("/tmp");
2737 assert!(!manager.get_session_id().is_empty());
2738 assert_eq!(manager.get_entries().len(), 0);
2739 }
2740
2741 #[test]
2742 fn test_append_message() {
2743 let mut manager = SessionManager::in_memory("/tmp");
2744 let id = manager.append_message(AgentMessage::User {
2745 content: ContentValue::String("Hello".to_string()),
2746 });
2747 assert!(!id.is_empty());
2748 assert_eq!(manager.get_entries().len(), 1);
2749 assert_eq!(manager.get_leaf_id(), Some(id));
2750 }
2751
2752 #[test]
2753 fn test_tree_traversal() {
2754 let mut manager = SessionManager::in_memory("/tmp");
2755 let id1 = manager.append_message(AgentMessage::User {
2756 content: ContentValue::String("Hello".to_string()),
2757 });
2758 let id2 = manager.append_message(AgentMessage::Assistant {
2759 content: vec![],
2760 provider: None,
2761 model_id: None,
2762 usage: None,
2763 stop_reason: None,
2764 });
2765
2766 let branch = manager.get_branch(None);
2768 assert_eq!(branch.len(), 2);
2769
2770 let branch = manager.get_branch(Some(&id1));
2772 assert_eq!(branch.len(), 1);
2773
2774 let children = manager.get_children(&id1);
2776 assert_eq!(children.len(), 1);
2777
2778 let parent = manager.get_parent(&id2);
2780 assert!(parent.is_some());
2781 assert_eq!(parent.unwrap().id, id1);
2782 }
2783
2784 #[test]
2785 fn test_branching() {
2786 let mut manager = SessionManager::in_memory("/tmp");
2787 let id1 = manager.append_message(AgentMessage::User {
2788 content: ContentValue::String("Hello".to_string()),
2789 });
2790 let _id2 = manager.append_message(AgentMessage::Assistant {
2791 content: vec![],
2792 provider: None,
2793 model_id: None,
2794 usage: None,
2795 stop_reason: None,
2796 });
2797 let _id3 = manager.append_message(AgentMessage::User {
2798 content: ContentValue::String("How are you?".to_string()),
2799 });
2800
2801 manager.branch(&id1).unwrap();
2803 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2804
2805 let id4 = manager.append_message(AgentMessage::Assistant {
2807 content: vec![],
2808 provider: None,
2809 model_id: None,
2810 usage: None,
2811 stop_reason: None,
2812 });
2813
2814 assert_eq!(manager.get_entries().len(), 4);
2816
2817 assert_eq!(manager.get_leaf_id(), Some(id4));
2819
2820 let tree = manager.get_tree(Uuid::nil()).unwrap();
2822 assert_eq!(tree.len(), 1); assert_eq!(tree[0].children.len(), 2); }
2825
2826 #[test]
2827 fn test_session_context() {
2828 let mut manager = SessionManager::in_memory("/tmp");
2829 manager.append_message(AgentMessage::User {
2830 content: ContentValue::String("Hello".to_string()),
2831 });
2832 manager.append_message(AgentMessage::Assistant {
2833 content: vec![AssistantContentBlock::Text {
2834 text: "Hi there!".to_string(),
2835 }],
2836 provider: Some("test".to_string()),
2837 model_id: Some("model".to_string()),
2838 usage: None,
2839 stop_reason: None,
2840 });
2841
2842 let context = manager.build_session_context();
2843 assert_eq!(context.messages.len(), 2);
2844 assert!(context.model.is_some());
2845 }
2846
2847 #[test]
2848 fn test_compaction_entry() {
2849 let mut manager = SessionManager::in_memory("/tmp");
2850 let id1 = manager.append_message(AgentMessage::User {
2851 content: ContentValue::String("First message".to_string()),
2852 });
2853 let _id2 = manager.append_message(AgentMessage::Assistant {
2854 content: vec![],
2855 provider: None,
2856 model_id: None,
2857 usage: None,
2858 stop_reason: None,
2859 });
2860
2861 let id3 = manager.append_compaction("Summarized conversation", &id1, 1000, None, None);
2862 assert!(!id3.is_empty());
2863
2864 let latest = manager.get_latest_compaction_entry();
2865 assert!(latest.is_some());
2866 }
2867
2868 #[test]
2869 fn test_labels() {
2870 let mut manager = SessionManager::in_memory("/tmp");
2871 let id1 = manager.append_message(AgentMessage::User {
2872 content: ContentValue::String("Hello".to_string()),
2873 });
2874
2875 manager.add_label(&id1, "important").unwrap();
2876 assert_eq!(manager.get_label(&id1), Some("important".to_string()));
2877
2878 manager.remove_label(&id1).unwrap();
2879 assert_eq!(manager.get_label(&id1), None);
2880 }
2881
2882 fn user_msg(text: &str) -> AgentMessage {
2888 AgentMessage::User {
2889 content: ContentValue::String(text.to_string()),
2890 }
2891 }
2892
2893 fn assistant_msg(text: &str) -> AgentMessage {
2895 AgentMessage::Assistant {
2896 content: vec![AssistantContentBlock::Text {
2897 text: text.to_string(),
2898 }],
2899 provider: Some("anthropic".to_string()),
2900 model_id: Some("claude-test".to_string()),
2901 usage: None,
2902 stop_reason: None,
2903 }
2904 }
2905
2906 fn bare_assistant_msg() -> AgentMessage {
2908 AgentMessage::Assistant {
2909 content: vec![],
2910 provider: None,
2911 model_id: None,
2912 usage: None,
2913 stop_reason: None,
2914 }
2915 }
2916
2917 #[test]
2922 fn test_append_thinking_level_change_integrates() {
2923 let mut manager = SessionManager::in_memory("/tmp");
2924 let msg_id = manager.append_message(user_msg("hello"));
2925 let thinking_id = manager.append_thinking_level_change("high");
2926 let msg2_id = manager.append_message(assistant_msg("response"));
2927
2928 let entries = manager.get_entries();
2929 assert_eq!(entries.len(), 3);
2930
2931 let thinking_entry = entries.iter().find(|e| e.id == thinking_id).unwrap();
2933 assert_eq!(thinking_entry.parent_id, Some(msg_id));
2934
2935 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2936 assert_eq!(msg2.parent_id, Some(thinking_id));
2937 }
2938
2939 #[test]
2940 fn test_append_model_change_integrates() {
2941 let mut manager = SessionManager::in_memory("/tmp");
2942 let msg_id = manager.append_message(user_msg("hello"));
2943 let model_id = manager.append_model_change("openai", "gpt-4");
2944 let msg2_id = manager.append_message(assistant_msg("response"));
2945
2946 let entries = manager.get_entries();
2947 let model_entry = entries.iter().find(|e| e.id == model_id).unwrap();
2948 assert_eq!(model_entry.parent_id, Some(msg_id));
2949
2950 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2951 assert_eq!(msg2.parent_id, Some(model_id));
2952 }
2953
2954 #[test]
2955 fn test_append_compaction_integrates_into_tree() {
2956 let mut manager = SessionManager::in_memory("/tmp");
2957 let id1 = manager.append_message(user_msg("1"));
2958 let id2 = manager.append_message(assistant_msg("2"));
2959 let compaction_id = manager.append_compaction("summary", &id1, 1000, None, None);
2960 let id3 = manager.append_message(user_msg("3"));
2961
2962 let entries = manager.get_entries();
2963 let compaction = entries.iter().find(|e| e.id == compaction_id).unwrap();
2964 assert_eq!(compaction.parent_id, Some(id2));
2965
2966 let msg3 = entries.iter().find(|e| e.id == id3).unwrap();
2967 assert_eq!(msg3.parent_id, Some(compaction_id));
2968
2969 if let AgentMessage::CompactionSummary {
2971 summary,
2972 tokens_before,
2973 ..
2974 } = &compaction.message
2975 {
2976 assert_eq!(summary, "summary");
2977 assert_eq!(*tokens_before, 1000);
2978 } else {
2979 panic!("Expected CompactionSummary");
2980 }
2981 }
2982
2983 #[test]
2984 fn test_leaf_pointer_advances() {
2985 let mut manager = SessionManager::in_memory("/tmp");
2986 assert!(manager.get_leaf_id().is_none());
2987
2988 let id1 = manager.append_message(user_msg("1"));
2989 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2990
2991 let id2 = manager.append_message(assistant_msg("2"));
2992 assert_eq!(manager.get_leaf_id(), Some(id2.clone()));
2993
2994 let id3 = manager.append_thinking_level_change("high");
2995 assert_eq!(manager.get_leaf_id(), Some(id3));
2996 }
2997
2998 #[test]
2999 fn test_get_entry() {
3000 let mut manager = SessionManager::in_memory("/tmp");
3001 assert!(manager.get_entry("nonexistent").is_none());
3002
3003 let id1 = manager.append_message(user_msg("first"));
3004 let id2 = manager.append_message(assistant_msg("second"));
3005
3006 let entry1 = manager.get_entry(&id1);
3007 assert!(entry1.is_some());
3008 assert!(entry1.unwrap().message.is_user());
3009
3010 let entry2 = manager.get_entry(&id2);
3011 assert!(entry2.is_some());
3012 assert!(entry2.unwrap().message.is_assistant());
3013 }
3014
3015 #[test]
3016 fn test_get_leaf_entry() {
3017 let manager = SessionManager::in_memory("/tmp");
3018 assert!(manager.get_leaf_entry().is_none());
3019
3020 let mut manager = SessionManager::in_memory("/tmp");
3021 manager.append_message(user_msg("1"));
3022 let id2 = manager.append_message(assistant_msg("2"));
3023
3024 let leaf = manager.get_leaf_entry();
3025 assert!(leaf.is_some());
3026 assert_eq!(leaf.unwrap().id, id2);
3027 }
3028
3029 #[test]
3034 fn test_get_branch_full_path_root_to_leaf() {
3035 let mut manager = SessionManager::in_memory("/tmp");
3036 let id1 = manager.append_message(user_msg("1"));
3037 let id2 = manager.append_message(assistant_msg("2"));
3038 let id3 = manager.append_thinking_level_change("high");
3039 let id4 = manager.append_message(user_msg("3"));
3040
3041 let branch = manager.get_branch(None);
3042 assert_eq!(branch.len(), 4);
3043 assert_eq!(branch[0].id, id1);
3044 assert_eq!(branch[1].id, id2);
3045 assert_eq!(branch[2].id, id3);
3046 assert_eq!(branch[3].id, id4);
3047 }
3048
3049 #[test]
3050 fn test_get_branch_from_specific_entry() {
3051 let mut manager = SessionManager::in_memory("/tmp");
3052 let id1 = manager.append_message(user_msg("1"));
3053 let id2 = manager.append_message(assistant_msg("2"));
3054 manager.append_message(user_msg("3"));
3055 manager.append_message(assistant_msg("4"));
3056
3057 let branch = manager.get_branch(Some(&id2));
3058 assert_eq!(branch.len(), 2);
3059 assert_eq!(branch[0].id, id1);
3060 assert_eq!(branch[1].id, id2);
3061 }
3062
3063 #[test]
3068 fn test_multiple_branches_at_same_point() {
3069 let mut manager = SessionManager::in_memory("/tmp");
3070 manager.append_message(user_msg("root"));
3071 let id2 = manager.append_message(bare_assistant_msg());
3072
3073 manager.branch(&id2).unwrap();
3075 let id_a = manager.append_message(user_msg("branch-A"));
3076
3077 manager.branch(&id2).unwrap();
3079 let id_b = manager.append_message(user_msg("branch-B"));
3080
3081 manager.branch(&id2).unwrap();
3083 let id_c = manager.append_message(user_msg("branch-C"));
3084
3085 let tree = manager.get_tree(Uuid::nil()).unwrap();
3086 let node2 = &tree[0].children[0];
3087 assert_eq!(node2.entry.id, id2);
3088 assert_eq!(node2.children.len(), 3);
3089
3090 let mut branch_ids: Vec<String> =
3091 node2.children.iter().map(|c| c.entry.id.clone()).collect();
3092 branch_ids.sort();
3093 let mut expected = vec![id_a, id_b, id_c];
3094 expected.sort();
3095 assert_eq!(branch_ids, expected);
3096 }
3097
3098 #[test]
3103 fn test_deep_branching() {
3104 let mut manager = SessionManager::in_memory("/tmp");
3105
3106 manager.append_message(user_msg("1"));
3108 let id2 = manager.append_message(bare_assistant_msg());
3109 let id3 = manager.append_message(user_msg("3"));
3110 manager.append_message(bare_assistant_msg());
3111
3112 manager.branch(&id2).unwrap();
3114 let id5 = manager.append_message(user_msg("5"));
3115 manager.append_message(bare_assistant_msg());
3116
3117 manager.branch(&id5).unwrap();
3119 manager.append_message(user_msg("7"));
3120
3121 let tree = manager.get_tree(Uuid::nil()).unwrap();
3122
3123 let node2 = &tree[0].children[0];
3125 assert_eq!(node2.children.len(), 2);
3126
3127 let node5 = node2.children.iter().find(|c| c.entry.id == id5).unwrap();
3128 assert_eq!(node5.children.len(), 2); let node3 = node2.children.iter().find(|c| c.entry.id == id3).unwrap();
3131 assert_eq!(node3.children.len(), 1); }
3133
3134 #[test]
3139 fn test_branch_with_summary_inserts_and_advances() {
3140 let mut manager = SessionManager::in_memory("/tmp");
3141 let id1 = manager.append_message(user_msg("1"));
3142 manager.append_message(bare_assistant_msg());
3143 manager.append_message(user_msg("3"));
3144
3145 let summary_id =
3146 manager.branch_with_summary(Some(&id1), "Summary of abandoned work", None, None);
3147 assert!(!summary_id.is_empty());
3148 assert_eq!(manager.get_leaf_id(), Some(summary_id.clone()));
3149
3150 let entries = manager.get_entries();
3152 let summary_entry = entries.iter().find(|e| e.id == summary_id).unwrap();
3153 assert_eq!(summary_entry.parent_id, Some(id1));
3154
3155 if let AgentMessage::BranchSummary { summary, .. } = &summary_entry.message {
3156 assert_eq!(summary, "Summary of abandoned work");
3157 } else {
3158 panic!("Expected BranchSummary");
3159 }
3160 }
3161
3162 #[test]
3167 fn test_build_session_context_returns_branch_messages() {
3168 let mut manager = SessionManager::in_memory("/tmp");
3169
3170 manager.append_message(user_msg("msg1"));
3172 let id2 = manager.append_message(bare_assistant_msg());
3173 manager.append_message(user_msg("msg3"));
3174
3175 manager.branch(&id2).unwrap();
3177 manager.append_message(assistant_msg("msg4-branch"));
3178
3179 let ctx = manager.build_session_context();
3180 assert_eq!(ctx.messages.len(), 3);
3182 assert!(ctx.messages[0].is_user());
3183 assert!(ctx.messages[1].is_assistant());
3184 assert!(ctx.messages[2].is_assistant());
3185 }
3186
3187 #[test]
3188 fn test_build_session_context_follows_branch_path() {
3189 let mut manager = SessionManager::in_memory("/tmp");
3192 manager.append_message(user_msg("start"));
3193 let id2 = manager.append_message(bare_assistant_msg());
3194 manager.append_message(user_msg("branch A"));
3195
3196 manager.branch(&id2).unwrap();
3198 manager.append_message(user_msg("branch B"));
3199
3200 let ctx = manager.build_session_context();
3201 assert_eq!(ctx.messages.len(), 3);
3202 let last = ctx.messages.last().unwrap();
3204 assert_eq!(last.content(), "branch B");
3205 }
3206
3207 #[test]
3208 fn test_build_session_context_includes_branch_summary() {
3209 let mut manager = SessionManager::in_memory("/tmp");
3210 manager.append_message(user_msg("start"));
3211 let id2 = manager.append_message(bare_assistant_msg());
3212 manager.append_message(user_msg("abandoned path"));
3213
3214 manager.branch_with_summary(Some(&id2), "Summary of abandoned work", None, None);
3216 manager.append_message(user_msg("new direction"));
3217
3218 let ctx = manager.build_session_context();
3219 assert!(ctx.messages.len() >= 3);
3221
3222 let has_summary = ctx.messages.iter().any(|m| {
3224 if let AgentMessage::BranchSummary { summary, .. } = m {
3225 summary == "Summary of abandoned work"
3226 } else {
3227 false
3228 }
3229 });
3230 assert!(has_summary, "Branch summary should be in context messages");
3231 }
3232
3233 #[test]
3234 fn test_build_session_context_with_compaction() {
3235 let mut manager = SessionManager::in_memory("/tmp");
3236
3237 let id1 = manager.append_message(user_msg("first"));
3239 manager.append_message(assistant_msg("response1"));
3240 manager.append_message(user_msg("second"));
3241 manager.append_message(assistant_msg("response2"));
3242
3243 manager.append_compaction("Summary of first two turns", &id1, 1000, None, None);
3245
3246 manager.append_message(user_msg("third"));
3248 manager.append_message(assistant_msg("response3"));
3249
3250 let ctx = manager.build_session_context();
3251 assert!(ctx.messages.len() >= 4); let compaction_entries = manager.get_compaction_entries();
3257 assert_eq!(compaction_entries.len(), 1);
3258 }
3259
3260 #[test]
3261 fn test_build_session_context_tracks_thinking_level() {
3262 let mut manager = SessionManager::in_memory("/tmp");
3263 manager.append_message(user_msg("hello"));
3264 manager.append_thinking_level_change("high");
3265 manager.append_message(assistant_msg("thinking hard"));
3266
3267 let ctx = manager.build_session_context();
3268 assert_eq!(ctx.thinking_level, "high");
3269 }
3270
3271 #[test]
3276 fn test_labels_in_tree_nodes() {
3277 let mut manager = SessionManager::in_memory("/tmp");
3278 let id1 = manager.append_message(user_msg("hello"));
3279 let id2 = manager.append_message(assistant_msg("hi"));
3280
3281 manager.add_label(&id1, "start").unwrap();
3282 manager.add_label(&id2, "response").unwrap();
3283
3284 let tree = manager.get_tree(Uuid::nil()).unwrap();
3285 let node1 = &tree[0];
3286 assert_eq!(node1.label, Some("start".to_string()));
3287
3288 let node2 = &node1.children[0];
3289 assert_eq!(node2.label, Some("response".to_string()));
3290 }
3291
3292 #[test]
3293 fn test_last_label_wins() {
3294 let mut manager = SessionManager::in_memory("/tmp");
3295 let id1 = manager.append_message(user_msg("hello"));
3296
3297 manager.add_label(&id1, "first").unwrap();
3298 manager.add_label(&id1, "second").unwrap();
3299 manager.add_label(&id1, "third").unwrap();
3300
3301 assert_eq!(manager.get_label(&id1), Some("third".to_string()));
3302 }
3303
3304 #[test]
3309 fn test_branch_throws_for_nonexistent() {
3310 let mut manager = SessionManager::in_memory("/tmp");
3311 manager.append_message(user_msg("hello"));
3312
3313 let result = manager.branch("nonexistent");
3314 assert!(result.is_err());
3315 }
3316
3317 #[test]
3322 fn test_labels_not_in_session_context() {
3323 let mut manager = SessionManager::in_memory("/tmp");
3324 let msg_id = manager.append_message(user_msg("hello"));
3325 manager.add_label(&msg_id, "checkpoint").unwrap();
3326
3327 let ctx = manager.build_session_context();
3328 assert_eq!(ctx.messages.len(), 1);
3330 assert!(ctx.messages[0].is_user());
3331 }
3332
3333 #[test]
3338 fn test_custom_entry_integrates_into_tree() {
3339 let mut manager = SessionManager::in_memory("/tmp");
3340 let msg_id = manager.append_message(user_msg("hello"));
3341 let custom_id =
3342 manager.append_custom_entry("my_data", Some(serde_json::json!({"foo": "bar"})));
3343 let msg2_id = manager.append_message(assistant_msg("response"));
3344
3345 let entries = manager.get_entries();
3346 let custom = entries.iter().find(|e| e.id == custom_id).unwrap();
3347 assert_eq!(custom.parent_id, Some(msg_id));
3348
3349 if let AgentMessage::Custom { custom_type, .. } = &custom.message {
3350 assert_eq!(custom_type, "my_data");
3351 } else {
3352 panic!("Expected Custom message");
3353 }
3354
3355 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
3356 assert_eq!(msg2.parent_id, Some(custom_id));
3357
3358 let ctx = manager.build_session_context();
3360 assert_eq!(ctx.messages.len(), 2);
3362 }
3363
3364 #[test]
3369 fn test_get_branch_empty_session() {
3370 let manager = SessionManager::in_memory("/tmp");
3371 let branch = manager.get_branch(None);
3372 assert!(branch.is_empty());
3373 }
3374
3375 #[test]
3376 fn test_get_tree_empty_session() {
3377 let manager = SessionManager::in_memory("/tmp");
3378 let tree = manager.get_tree(Uuid::nil()).unwrap();
3379 assert!(tree.is_empty());
3380 }
3381
3382 #[test]
3387 fn test_complex_tree_with_branches_and_compaction() {
3388 let mut manager = SessionManager::in_memory("/tmp");
3389
3390 manager.append_message(user_msg("start"));
3392 manager.append_message(assistant_msg("r1"));
3393 let id3 = manager.append_message(user_msg("q2"));
3394 manager.append_message(assistant_msg("r2"));
3395 manager.append_compaction("Compacted history", &id3, 1000, None, None);
3396 manager.append_message(user_msg("q3"));
3397 manager.append_message(assistant_msg("r3"));
3398
3399 manager.branch(&id3).unwrap();
3401 manager.append_message(user_msg("wrong path"));
3402 manager.append_message(assistant_msg("wrong response"));
3403
3404 manager.branch_with_summary(Some(&id3), "Tried wrong approach", None, None);
3406 manager.append_message(user_msg("better approach"));
3407
3408 let tree = manager.get_tree(Uuid::nil()).unwrap();
3409 assert_eq!(tree.len(), 1);
3411
3412 let root = &tree[0];
3414 assert!(root.entry.message.is_user());
3415 }
3416
3417 #[test]
3422 fn test_multiple_compactions_returns_latest() {
3423 let mut manager = SessionManager::in_memory("/tmp");
3424 let id1 = manager.append_message(user_msg("a"));
3425 manager.append_message(bare_assistant_msg());
3426 manager.append_compaction("First summary", &id1, 1000, None, None);
3427 manager.append_message(user_msg("c"));
3428 manager.append_message(bare_assistant_msg());
3429 manager.append_compaction("Second summary", &id1, 2000, None, None);
3430
3431 let compactions = manager.get_compaction_entries();
3433 assert_eq!(compactions.len(), 2);
3434
3435 let latest = manager.get_latest_compaction_entry();
3437 assert!(latest.is_some());
3438 }
3439
3440 #[test]
3445 fn test_get_all_compaction_entries() {
3446 let mut manager = SessionManager::in_memory("/tmp");
3447 let id1 = manager.append_message(user_msg("a"));
3448 manager.append_message(bare_assistant_msg());
3449 manager.append_compaction("First", &id1, 1000, None, None);
3450 manager.append_message(user_msg("b"));
3451 manager.append_message(bare_assistant_msg());
3452 manager.append_compaction("Second", &id1, 2000, None, None);
3453
3454 let compactions = manager.get_compaction_entries();
3455 assert_eq!(compactions.len(), 2);
3456 }
3457}