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 content: ContentValue,
218 },
219 #[serde(rename = "assistant")]
221 Assistant {
222 content: Vec<AssistantContentBlock>,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 provider: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 model_id: Option<String>,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 usage: Option<Usage>,
233 #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
235 stop_reason: Option<String>,
236 },
237 #[serde(rename = "toolResult")]
239 ToolResult {
240 content: ContentValue,
242 #[serde(rename = "toolCallId")]
244 tool_call_id: String,
245 },
246 #[serde(rename = "system")]
248 System {
249 content: ContentValue,
251 },
252 #[serde(rename = "bashExecution")]
254 BashExecution {
255 command: String,
257 output: String,
259 #[serde(rename = "exitCode")]
261 exit_code: Option<i32>,
262 cancelled: bool,
264 truncated: bool,
266 #[serde(rename = "fullOutputPath", skip_serializing_if = "Option::is_none")]
268 full_output_path: Option<String>,
269 #[serde(rename = "excludeFromContext", skip_serializing_if = "Option::is_none")]
271 exclude_from_context: Option<bool>,
272 timestamp: i64,
274 },
275 #[serde(rename = "custom")]
277 Custom {
278 #[serde(rename = "customType")]
280 custom_type: String,
281 content: ContentValue,
283 display: bool,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 details: Option<serde_json::Value>,
288 timestamp: i64,
290 },
291 #[serde(rename = "branchSummary")]
293 BranchSummary {
294 summary: String,
296 #[serde(rename = "fromId")]
298 from_id: String,
299 timestamp: i64,
301 },
302 #[serde(rename = "compactionSummary")]
304 CompactionSummary {
305 summary: String,
307 #[serde(rename = "tokensBefore")]
309 tokens_before: i64,
310 timestamp: i64,
312 },
313}
314
315impl AgentMessage {
316 pub fn content(&self) -> String {
318 match self {
319 AgentMessage::User { content } => content.as_str().to_string(),
320 AgentMessage::Assistant { content, .. } => {
321 let estimated_len = content
322 .iter()
323 .map(|b| match b {
324 AssistantContentBlock::Text { text: t } => t.len(),
325 _ => 0,
326 })
327 .sum::<usize>();
328 let mut text = String::with_capacity(estimated_len.max(256));
329 for block in content {
330 if let AssistantContentBlock::Text { text: t } = block {
331 text.push_str(t)
332 }
333 }
334 text
335 }
336 AgentMessage::ToolResult { content, .. } => content.as_str().to_string(),
337 AgentMessage::System { content } => content.as_str().to_string(),
338 AgentMessage::BashExecution { output, .. } => output.clone(),
339 AgentMessage::Custom { content, .. } => content.as_str().to_string(),
340 AgentMessage::BranchSummary { summary, .. } => summary.clone(),
341 AgentMessage::CompactionSummary { summary, .. } => summary.clone(),
342 }
343 }
344
345 pub fn is_user(&self) -> bool {
347 matches!(self, AgentMessage::User { .. })
348 }
349
350 pub fn is_assistant(&self) -> bool {
352 matches!(self, AgentMessage::Assistant { .. })
353 }
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(tag = "type")]
359pub enum AssistantContentBlock {
360 #[serde(rename = "text")]
362 Text {
363 text: String,
365 },
366 #[serde(rename = "thinking")]
368 Thinking {
369 thinking: String,
371 },
372 #[serde(rename = "toolCall")]
374 ToolCall {
375 id: String,
377 name: String,
379 arguments: serde_json::Value,
381 },
382 #[serde(rename = "toolPlan")]
384 ToolPlan {
385 content: String,
387 #[serde(rename = "toolCallId")]
389 tool_call_id: String,
390 },
391 #[serde(rename = "image")]
393 ImageResult {
394 data: String,
396 media_type: String,
398 },
399 #[serde(rename = "refusal")]
401 Refusal {
402 content: String,
404 },
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct Usage {
410 #[serde(rename = "inputTokens", skip_serializing_if = "Option::is_none")]
412 pub input: Option<i64>,
413 #[serde(rename = "outputTokens", skip_serializing_if = "Option::is_none")]
415 pub output: Option<i64>,
416 #[serde(rename = "cacheReadTokens", skip_serializing_if = "Option::is_none")]
418 pub cache_read: Option<i64>,
419 #[serde(rename = "cacheWriteTokens", skip_serializing_if = "Option::is_none")]
421 pub cache_write: Option<i64>,
422 #[serde(rename = "totalTokens", skip_serializing_if = "Option::is_none")]
424 pub total_tokens: Option<i64>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct SessionEntryBase {
434 #[serde(rename = "type")]
436 pub entry_type: String,
437 pub id: String,
439 #[serde(rename = "parentId")]
441 pub parent_id: Option<String>,
442 pub timestamp: String,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct SessionMessageEntry {
449 #[serde(flatten)]
451 pub base: SessionEntryBase,
452 pub message: AgentMessage,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct ThinkingLevelChangeEntry {
459 #[serde(flatten)]
461 pub base: SessionEntryBase,
462 #[serde(rename = "thinkingLevel")]
464 pub thinking_level: String,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct ModelChangeEntry {
470 #[serde(flatten)]
472 pub base: SessionEntryBase,
473 pub provider: String,
475 #[serde(rename = "modelId")]
477 pub model_id: String,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct CompactionEntry {
483 #[serde(flatten)]
485 pub base: SessionEntryBase,
486 pub summary: String,
488 #[serde(rename = "firstKeptEntryId")]
490 pub first_kept_entry_id: String,
491 #[serde(rename = "tokensBefore")]
493 pub tokens_before: i64,
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub details: Option<serde_json::Value>,
497 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
499 pub from_hook: Option<bool>,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct BranchSummaryEntry {
505 #[serde(flatten)]
507 pub base: SessionEntryBase,
508 #[serde(rename = "fromId")]
510 pub from_id: String,
511 pub summary: String,
513 #[serde(skip_serializing_if = "Option::is_none")]
515 pub details: Option<serde_json::Value>,
516 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
518 pub from_hook: Option<bool>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct CustomEntry {
524 #[serde(flatten)]
526 pub base: SessionEntryBase,
527 #[serde(rename = "customType")]
529 pub custom_type: String,
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub data: Option<serde_json::Value>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct LabelEntry {
538 #[serde(flatten)]
540 pub base: SessionEntryBase,
541 #[serde(rename = "targetId")]
543 pub target_id: String,
544 pub label: Option<String>,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
550pub struct SessionInfoEntry {
551 #[serde(flatten)]
553 pub base: SessionEntryBase,
554 pub name: Option<String>,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct CustomMessageEntry {
561 #[serde(flatten)]
563 pub base: SessionEntryBase,
564 #[serde(rename = "customType")]
566 pub custom_type: String,
567 pub content: ContentValue,
569 #[serde(skip_serializing_if = "Option::is_none")]
571 pub details: Option<serde_json::Value>,
572 pub display: bool,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(untagged)]
579pub enum SessionEntryEnum {
580 Message(SessionMessageEntry),
582 ThinkingLevelChange(ThinkingLevelChangeEntry),
584 ModelChange(ModelChangeEntry),
586 Compaction(CompactionEntry),
588 BranchSummary(BranchSummaryEntry),
590 Custom(CustomEntry),
592 Label(LabelEntry),
594 SessionInfo(SessionInfoEntry),
596 CustomMessage(CustomMessageEntry),
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct SessionEntry {
604 pub id: String,
606 pub parent_id: Option<String>,
608 pub timestamp: i64,
610 pub message: AgentMessage,
612}
613
614impl SessionEntry {
615 pub fn new(message: AgentMessage) -> Self {
617 Self {
618 id: Uuid::new_v4().to_string(),
619 parent_id: None,
620 timestamp: Utc::now().timestamp_millis(),
621 message,
622 }
623 }
624
625 pub fn simple_message(role: &str, content: &str) -> Self {
627 use crate::store::session::ContentValue;
628 let message = match role {
629 "user" => AgentMessage::User {
630 content: ContentValue::String(content.to_string()),
631 },
632 "assistant" => AgentMessage::Assistant {
633 content: vec![AssistantContentBlock::Text {
634 text: content.to_string(),
635 }],
636 provider: None,
637 model_id: None,
638 usage: None,
639 stop_reason: None,
640 },
641 "system" => AgentMessage::System {
642 content: ContentValue::String(content.to_string()),
643 },
644 _ => AgentMessage::System {
645 content: ContentValue::String(content.to_string()),
646 },
647 };
648 Self::new(message)
649 }
650
651 pub fn branched(message: AgentMessage, parent_id: &str) -> Self {
653 Self {
654 id: Uuid::new_v4().to_string(),
655 parent_id: Some(parent_id.to_string()),
656 timestamp: Utc::now().timestamp_millis(),
657 message,
658 }
659 }
660
661 pub fn content(&self) -> String {
663 self.message.content()
664 }
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize)]
669#[serde(untagged)]
670pub enum FileEntry {
671 Header(SessionHeader),
673 Entry(SessionEntryEnum),
675}
676
677#[derive(Debug, Clone)]
683pub struct SessionContext {
684 pub messages: Vec<AgentMessage>,
686 pub thinking_level: String,
688 pub model: Option<ModelInfo>,
690}
691
692#[derive(Debug, Clone)]
694pub struct ModelInfo {
695 pub provider: String,
697 pub model_id: String,
699}
700
701#[derive(Debug, Clone)]
707pub struct SessionInfo {
708 pub path: String,
710 pub id: String,
712 pub cwd: String,
714 pub name: Option<String>,
716 pub parent_session_path: Option<String>,
718 pub created: DateTime<Utc>,
720 pub modified: DateTime<Utc>,
722 pub message_count: i64,
724 pub first_message: String,
726 pub all_messages_text: String,
728}
729
730#[derive(Debug, Clone)]
736pub struct SessionTreeNode {
737 pub entry: SessionEntry,
739 pub children: Vec<SessionTreeNode>,
741 pub label: Option<String>,
743 pub label_timestamp: Option<String>,
745}
746
747fn generate_id(by_id: &HashSet<String>) -> String {
752 for _ in 0..100 {
753 let id = Uuid::new_v4().to_string()[..8].to_string();
754 if !by_id.contains(&id) {
755 return id;
756 }
757 }
758 Uuid::new_v4().to_string()
760}
761
762fn migrate_v1_to_v2(entries: &mut [FileEntry]) {
768 let mut ids = HashSet::new();
769 let mut prev_id: Option<String> = None;
770
771 for entry in entries.iter_mut() {
772 match entry {
773 FileEntry::Header(header) => {
774 header.version = Some(2);
775 }
776 FileEntry::Entry(entry) => {
777 let id = match entry {
778 SessionEntryEnum::Message(e) => {
779 e.base.id = generate_id(&ids);
780 e.base.parent_id = prev_id.clone();
781 e.base.entry_type = "message".to_string();
782 prev_id = Some(e.base.id.clone());
783 e.base.id.clone()
784 }
785 SessionEntryEnum::ThinkingLevelChange(e) => {
786 e.base.id = generate_id(&ids);
787 e.base.parent_id = prev_id.clone();
788 e.base.entry_type = "thinking_level_change".to_string();
789 prev_id = Some(e.base.id.clone());
790 e.base.id.clone()
791 }
792 SessionEntryEnum::ModelChange(e) => {
793 e.base.id = generate_id(&ids);
794 e.base.parent_id = prev_id.clone();
795 e.base.entry_type = "model_change".to_string();
796 prev_id = Some(e.base.id.clone());
797 e.base.id.clone()
798 }
799 SessionEntryEnum::Compaction(e) => {
800 e.base.id = generate_id(&ids);
801 e.base.parent_id = prev_id.clone();
802 e.base.entry_type = "compaction".to_string();
803 prev_id = Some(e.base.id.clone());
804 e.base.id.clone()
805 }
806 SessionEntryEnum::BranchSummary(e) => {
807 e.base.id = generate_id(&ids);
808 e.base.parent_id = prev_id.clone();
809 e.base.entry_type = "branch_summary".to_string();
810 prev_id = Some(e.base.id.clone());
811 e.base.id.clone()
812 }
813 SessionEntryEnum::Custom(e) => {
814 e.base.id = generate_id(&ids);
815 e.base.parent_id = prev_id.clone();
816 e.base.entry_type = "custom".to_string();
817 prev_id = Some(e.base.id.clone());
818 e.base.id.clone()
819 }
820 SessionEntryEnum::Label(e) => {
821 e.base.id = generate_id(&ids);
822 e.base.parent_id = prev_id.clone();
823 e.base.entry_type = "label".to_string();
824 prev_id = Some(e.base.id.clone());
825 e.base.id.clone()
826 }
827 SessionEntryEnum::SessionInfo(e) => {
828 e.base.id = generate_id(&ids);
829 e.base.parent_id = prev_id.clone();
830 e.base.entry_type = "session_info".to_string();
831 prev_id = Some(e.base.id.clone());
832 e.base.id.clone()
833 }
834 SessionEntryEnum::CustomMessage(e) => {
835 e.base.id = generate_id(&ids);
836 e.base.parent_id = prev_id.clone();
837 e.base.entry_type = "custom_message".to_string();
838 prev_id = Some(e.base.id.clone());
839 e.base.id.clone()
840 }
841 };
842 ids.insert(id);
843 }
844 }
845 }
846}
847
848fn migrate_v2_to_v3(entries: &mut [FileEntry]) {
850 for entry in entries.iter_mut() {
851 match entry {
852 FileEntry::Header(header) => {
853 header.version = Some(3);
854 }
855 FileEntry::Entry(_) => {
856 }
858 }
859 }
860}
861
862fn migrate_to_current_version(entries: &mut [FileEntry]) -> bool {
864 let header = entries.iter().find_map(|e| match e {
865 FileEntry::Header(h) => Some(h),
866 _ => None,
867 });
868 let version = header.and_then(|h| h.version).unwrap_or(1);
869
870 if version >= CURRENT_SESSION_VERSION {
871 return false;
872 }
873
874 if version < 2 {
875 migrate_v1_to_v2(entries);
876 }
877 if version < 3 {
878 migrate_v2_to_v3(entries);
879 }
880
881 true
882}
883
884pub struct SessionManager {
894 session_id: String,
895 session_file: Option<String>,
896 session_dir: String,
897 cwd: String,
898 persist: bool,
899 flushed: bool,
900 persisted_count: RwLock<usize>,
903 file_entries: RwLock<Vec<FileEntry>>,
904 by_id: RwLock<HashMap<String, SessionEntry>>,
905 labels_by_id: RwLock<HashMap<String, String>>,
906 label_timestamps_by_id: RwLock<HashMap<String, String>>,
907 leaf_id: RwLock<Option<String>>,
908}
909
910impl Clone for SessionManager {
912 fn clone(&self) -> Self {
913 Self {
914 session_id: self.session_id.clone(),
915 session_file: self.session_file.clone(),
916 session_dir: self.session_dir.clone(),
917 cwd: self.cwd.clone(),
918 persist: self.persist,
919 flushed: self.flushed,
920 persisted_count: RwLock::new(*self.persisted_count.read()),
921 file_entries: RwLock::new(self.file_entries.read().clone()),
922 by_id: RwLock::new(self.by_id.read().clone()),
923 labels_by_id: RwLock::new(self.labels_by_id.read().clone()),
924 label_timestamps_by_id: RwLock::new(self.label_timestamps_by_id.read().clone()),
925 leaf_id: RwLock::new(self.leaf_id.read().clone()),
926 }
927 }
928}
929
930impl SessionManager {
931 pub fn create(cwd: &str, session_dir: Option<&str>) -> Self {
933 let dir = session_dir
934 .map(|s| s.to_string())
935 .unwrap_or_else(|| get_default_session_dir(cwd));
936
937 let mut manager = Self::new_internal(cwd, &dir, None, true);
938 manager.persist = true;
939 manager
940 }
941
942 pub fn open(path: &str, session_dir: Option<&str>, cwd_override: Option<&str>) -> Self {
944 let entries = load_entries_from_file(path);
945 let header = entries.iter().find_map(|e| match e {
946 FileEntry::Header(h) => Some(h),
947 _ => None,
948 });
949 let cwd = cwd_override
950 .map(|s| s.to_string())
951 .or_else(|| header.as_ref().map(|h| h.cwd.clone()))
952 .unwrap_or_else(|| {
953 std::env::current_dir()
954 .unwrap_or_else(|_| PathBuf::from("."))
955 .to_string_lossy()
956 .to_string()
957 });
958 let dir = session_dir.map(|s| s.to_string()).unwrap_or_else(|| {
959 Path::new(path)
960 .parent()
961 .map(|p| p.to_string_lossy().to_string())
962 .unwrap_or_else(|| ".".to_string())
963 });
964
965 let mut manager = Self::new_internal(&cwd, &dir, Some(path), true);
966 manager.persist = true;
967 manager
968 }
969
970 pub fn continue_recent(cwd: &str, session_dir: Option<&str>) -> Self {
972 let dir = session_dir
973 .map(|s| s.to_string())
974 .unwrap_or_else(|| get_default_session_dir(cwd));
975
976 if let Some(most_recent) = find_most_recent_session(&dir) {
977 return Self::open(&most_recent, None, None);
978 }
979 Self::create(cwd, None)
980 }
981
982 pub fn in_memory(cwd: &str) -> Self {
984 let cwd = cwd.to_string();
985 Self::new_internal(&cwd, "", None, false)
986 }
987
988 fn new_internal(
989 cwd: &str,
990 session_dir: &str,
991 session_file: Option<&str>,
992 persist: bool,
993 ) -> Self {
994 let cwd = cwd.to_string();
995 let session_dir = session_dir.to_string();
996
997 if persist && !session_dir.is_empty() && !Path::new(&session_dir).exists() {
998 let _ = fs::create_dir_all(&session_dir);
999 }
1000
1001 let mut manager = Self {
1002 session_id: Uuid::new_v4().to_string(),
1003 session_file: session_file.map(|s| s.to_string()),
1004 session_dir,
1005 cwd,
1006 persist,
1007 flushed: false,
1008 persisted_count: RwLock::new(0),
1009 file_entries: RwLock::new(Vec::new()),
1010 by_id: RwLock::new(HashMap::new()),
1011 labels_by_id: RwLock::new(HashMap::new()),
1012 label_timestamps_by_id: RwLock::new(HashMap::new()),
1013 leaf_id: RwLock::new(None),
1014 };
1015
1016 if let Some(file) = session_file {
1017 manager.set_session_file(file);
1018 } else {
1019 manager.new_session(None);
1020 }
1021
1022 manager
1023 }
1024
1025 pub fn set_session_file(&mut self, session_file: &str) {
1027 let path = Path::new(session_file)
1028 .canonicalize()
1029 .unwrap_or_else(|_| PathBuf::from(session_file));
1030 let path_str = path.to_string_lossy().to_string();
1031 self.session_file = Some(path_str.clone());
1032
1033 if path.exists() {
1034 let mut entries = load_entries_from_file(&path_str);
1035
1036 if entries.is_empty() {
1038 let explicit_path = self.session_file.take();
1039 self.new_session(None);
1040 self.session_file = explicit_path;
1041 self._rewrite_file();
1042 self.flushed = true;
1043 return;
1044 }
1045
1046 let header = entries.iter().find_map(|e| match e {
1047 FileEntry::Header(h) => Some(h),
1048 _ => None,
1049 });
1050 self.session_id = header
1051 .map(|h| h.id.clone())
1052 .unwrap_or_else(|| Uuid::new_v4().to_string());
1053
1054 if migrate_to_current_version(&mut entries) {
1055 self._rewrite_file();
1056 }
1057
1058 *self.file_entries.write() = entries;
1059 self._build_index();
1060 self.flushed = true;
1061 } else {
1062 let explicit_path = self.session_file.take();
1063 self.new_session(None);
1064 self.session_file = explicit_path;
1065 }
1066 }
1067
1068 pub fn new_session(&mut self, options: Option<NewSessionOptions>) {
1070 self.session_id = options
1071 .as_ref()
1072 .and_then(|o| o.id.clone())
1073 .unwrap_or_else(|| Uuid::new_v4().to_string());
1074 let timestamp = Utc::now().to_rfc3339();
1075 let header = SessionHeader::new(
1076 self.session_id.clone(),
1077 self.cwd.clone(),
1078 options.and_then(|o| o.parent_session),
1079 );
1080
1081 self.file_entries = RwLock::new(vec![FileEntry::Header(header)]);
1082 self.by_id.write().clear();
1083 self.labels_by_id.write().clear();
1084 self.label_timestamps_by_id.write().clear();
1085 *self.leaf_id.write() = None;
1086 *self.persisted_count.write() = 0;
1087 self.flushed = false;
1088
1089 if self.persist {
1090 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1091 let short_id = &self.session_id[..8];
1092 self.session_file = Some(format!(
1093 "{}/{}_{}.jsonl",
1094 self.session_dir, file_timestamp, short_id
1095 ));
1096 }
1097 }
1098
1099 fn _build_index(&mut self) {
1100 let mut by_id = self.by_id.write();
1101 let mut labels = self.labels_by_id.write();
1102 let mut label_timestamps = self.label_timestamps_by_id.write();
1103 let mut leaf_id = self.leaf_id.write();
1104
1105 by_id.clear();
1106 labels.clear();
1107 label_timestamps.clear();
1108 *leaf_id = None;
1109
1110 for entry in self.file_entries.read().iter() {
1111 if let FileEntry::Entry(e) = entry {
1112 if let Some(session_entry) = convert_to_session_entry(e) {
1114 by_id.insert(session_entry.id.clone(), session_entry.clone());
1115 *leaf_id = Some(session_entry.id.clone());
1116 }
1117
1118 if let SessionEntryEnum::Label(l) = e {
1120 if let Some(ref label) = l.label {
1121 labels.insert(l.target_id.clone(), label.clone());
1122 label_timestamps.insert(l.target_id.clone(), l.base.timestamp.clone());
1123 } else {
1124 labels.remove(&l.target_id);
1125 label_timestamps.remove(&l.target_id);
1126 }
1127 }
1128 }
1129 }
1130 }
1131
1132 fn _rewrite_file(&self) {
1133 if !self.persist || self.session_file.is_none() {
1134 return;
1135 }
1136
1137 let file = match self.session_file.as_ref() {
1138 Some(f) => f,
1139 None => return,
1140 };
1141
1142 let content: String = self
1143 .file_entries
1144 .read()
1145 .iter()
1146 .map(|e| serde_json::to_string(e).unwrap_or_default())
1147 .collect::<Vec<_>>()
1148 .join("\n")
1149 + "\n";
1150
1151 if let Err(e) = atomic_write(Path::new(file), &content) {
1152 tracing::warn!("Failed to rewrite session file {}: {}", file, e);
1153 }
1154 }
1155
1156 pub fn is_persisted(&self) -> bool {
1158 self.persist
1159 }
1160
1161 pub fn validate_session_id(id: &str) -> bool {
1166 Uuid::parse_str(id).is_ok()
1167 }
1168
1169 pub fn is_readonly(&self) -> bool {
1177 if !self.persist {
1178 return false;
1180 }
1181 if let Some(ref file) = self.session_file {
1182 let path = Path::new(file);
1183 if path.exists()
1184 && let Ok(metadata) = fs::metadata(path)
1185 {
1186 #[cfg(unix)]
1187 {
1188 use std::os::unix::fs::PermissionsExt;
1189 let perm = metadata.permissions().mode();
1190 return perm & 0o200 == 0;
1192 }
1193 #[cfg(not(unix))]
1194 {
1195 let _ = metadata;
1196 return false;
1197 }
1198 }
1199 }
1200 false
1201 }
1202
1203 pub fn can_append(&self) -> bool {
1207 !self.is_readonly() && self.persist
1208 }
1209
1210 pub fn persisted_count(&self) -> usize {
1212 *self.persisted_count.read()
1213 }
1214
1215 pub fn set_persisted_count(&self, count: usize) {
1217 *self.persisted_count.write() = count;
1218 }
1219
1220 pub fn get_cwd(&self) -> String {
1222 self.cwd.clone()
1223 }
1224
1225 pub fn get_session_dir(&self) -> String {
1227 self.session_dir.clone()
1228 }
1229
1230 pub fn get_session_id(&self) -> String {
1232 self.session_id.clone()
1233 }
1234
1235 pub fn get_session_file(&self) -> Option<String> {
1237 self.session_file.clone()
1238 }
1239
1240 pub fn cleanup_if_empty(&self) {
1244 if !self.persist {
1245 return;
1246 }
1247 let Some(file) = &self.session_file else {
1248 return;
1249 };
1250
1251 let has_user = self.file_entries.read().iter().any(|e| {
1252 matches!(
1253 e,
1254 FileEntry::Entry(SessionEntryEnum::Message(m)) if m.message.is_user()
1255 )
1256 });
1257
1258 if !has_user {
1259 let path = Path::new(file);
1260 if path.exists() {
1261 if let Err(e) = fs::remove_file(path) {
1262 tracing::warn!("Failed to remove empty session file {}: {}", file, e);
1263 } else {
1264 tracing::debug!("Removed empty session file: {}", file);
1265 }
1266 }
1267 }
1268 }
1269
1270 fn _persist(&mut self, entry: &SessionEntry) {
1271 if !self.persist {
1272 return;
1273 }
1274 let Some(file) = &self.session_file else {
1275 return;
1276 };
1277
1278 let has_assistant = self.file_entries.read().iter().any(|e| {
1283 matches!(
1284 e,
1285 FileEntry::Entry(SessionEntryEnum::Message(m))
1286 if m.message.is_assistant()
1287 )
1288 });
1289
1290 if !has_assistant {
1291 self.flushed = false;
1295 return;
1296 }
1297
1298 let mut handle = match fs::OpenOptions::new().create(true).append(true).open(file) {
1299 Ok(h) => h,
1300 Err(e) => {
1301 tracing::warn!("Failed to open session file for append {}: {}", file, e);
1302 return;
1303 }
1304 };
1305
1306 if !self.flushed {
1307 for e in self.file_entries.read().iter() {
1308 if let Ok(line) = serde_json::to_string(e) {
1309 let _ = writeln!(&mut handle, "{}", line);
1310 } else {
1311 tracing::warn!("Failed to serialize session entry, skipping");
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 } else {
1321 tracing::warn!("Failed to serialize incremental session entry, skipping");
1322 }
1323 }
1324 }
1325
1326 fn _append_entry(&mut self, entry: SessionEntry) {
1330 let file_entry = convert_from_session_entry(&entry);
1331 self.file_entries.write().push(FileEntry::Entry(file_entry));
1332 self.by_id.write().insert(entry.id.clone(), entry.clone());
1333 *self.leaf_id.write() = Some(entry.id.clone());
1334 self._persist(&entry);
1335 }
1336
1337 pub fn append_message(&mut self, message: AgentMessage) -> String {
1339 let leaf = self.leaf_id.read().clone();
1340 let id = Uuid::new_v4().to_string();
1341 let entry = SessionEntry {
1342 id: id.clone(),
1343 parent_id: leaf,
1344 timestamp: Utc::now().timestamp_millis(),
1345 message,
1346 };
1347 self._append_entry(entry);
1348 id
1349 }
1350
1351 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1353 let leaf = self.leaf_id.read().clone();
1354 let id = Uuid::new_v4().to_string();
1355 let entry = SessionEntry {
1356 id: id.clone(),
1357 parent_id: leaf,
1358 timestamp: Utc::now().timestamp_millis(),
1359 message: AgentMessage::Custom {
1360 custom_type: "thinking_level_change".to_string(),
1361 content: ContentValue::String(thinking_level.to_string()),
1362 display: false,
1363 details: None,
1364 timestamp: Utc::now().timestamp_millis(),
1365 },
1366 };
1367 self._append_entry(entry);
1368 id
1369 }
1370
1371 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1373 let leaf = self.leaf_id.read().clone();
1374 let id = Uuid::new_v4().to_string();
1375 let entry = SessionEntry {
1376 id: id.clone(),
1377 parent_id: leaf,
1378 timestamp: Utc::now().timestamp_millis(),
1379 message: AgentMessage::Custom {
1380 custom_type: "model_change".to_string(),
1381 content: ContentValue::String(format!("{}:{}", provider, model_id)),
1382 display: false,
1383 details: None,
1384 timestamp: Utc::now().timestamp_millis(),
1385 },
1386 };
1387 self._append_entry(entry);
1388 id
1389 }
1390
1391 pub fn append_compaction(
1393 &mut self,
1394 summary: &str,
1395 _first_kept_entry_id: &str,
1396 tokens_before: i64,
1397 _details: Option<serde_json::Value>,
1398 _from_hook: Option<bool>,
1399 ) -> String {
1400 let leaf = self.leaf_id.read().clone();
1401 let id = Uuid::new_v4().to_string();
1402 let entry = SessionEntry {
1403 id: id.clone(),
1404 parent_id: leaf,
1405 timestamp: Utc::now().timestamp_millis(),
1406 message: AgentMessage::CompactionSummary {
1407 summary: summary.to_string(),
1408 tokens_before,
1409 timestamp: Utc::now().timestamp_millis(),
1410 },
1411 };
1412 self._append_entry(entry);
1413 id
1414 }
1415
1416 pub fn append_custom_entry(
1418 &mut self,
1419 custom_type: &str,
1420 data: Option<serde_json::Value>,
1421 ) -> String {
1422 let leaf = self.leaf_id.read().clone();
1423 let id = Uuid::new_v4().to_string();
1424 let entry = SessionEntry {
1425 id: id.clone(),
1426 parent_id: leaf,
1427 timestamp: Utc::now().timestamp_millis(),
1428 message: AgentMessage::Custom {
1429 custom_type: custom_type.to_string(),
1430 content: data
1431 .as_ref()
1432 .map(|d| ContentValue::String(d.to_string()))
1433 .unwrap_or(ContentValue::String(String::new())),
1434 display: false,
1435 details: data.clone(),
1436 timestamp: Utc::now().timestamp_millis(),
1437 },
1438 };
1439 self._append_entry(entry);
1440 id
1441 }
1442
1443 pub fn append_session_info(&mut self, name: &str) -> String {
1445 let leaf = self.leaf_id.read().clone();
1446 let id = Uuid::new_v4().to_string();
1447 let entry = SessionEntry {
1448 id: id.clone(),
1449 parent_id: leaf,
1450 timestamp: Utc::now().timestamp_millis(),
1451 message: AgentMessage::Custom {
1452 custom_type: "session_info".to_string(),
1453 content: ContentValue::String(name.trim().to_string()),
1454 display: false,
1455 details: None,
1456 timestamp: Utc::now().timestamp_millis(),
1457 },
1458 };
1459 self._append_entry(entry);
1460 id
1461 }
1462
1463 pub fn get_session_name(&self) -> Option<String> {
1465 let entries = self.get_entries();
1466 for entry in entries.iter().rev() {
1467 if let AgentMessage::Custom {
1468 custom_type,
1469 content,
1470 ..
1471 } = &entry.message
1472 && custom_type == "session_info"
1473 {
1474 return Some(content.as_str().trim().to_string()).filter(|s| !s.is_empty());
1475 }
1476 }
1477 None
1478 }
1479
1480 pub fn append_custom_message_entry(
1482 &mut self,
1483 custom_type: &str,
1484 content: ContentValue,
1485 display: bool,
1486 details: Option<serde_json::Value>,
1487 ) -> String {
1488 let leaf = self.leaf_id.read().clone();
1489 let id = Uuid::new_v4().to_string();
1490 let entry = SessionEntry {
1491 id: id.clone(),
1492 parent_id: leaf,
1493 timestamp: Utc::now().timestamp_millis(),
1494 message: AgentMessage::Custom {
1495 custom_type: custom_type.to_string(),
1496 content,
1497 display,
1498 details,
1499 timestamp: Utc::now().timestamp_millis(),
1500 },
1501 };
1502 self._append_entry(entry);
1503 id
1504 }
1505
1506 pub fn get_leaf_id(&self) -> Option<String> {
1512 self.leaf_id.read().clone()
1513 }
1514
1515 pub fn set_leaf_from_entry(&self, entry_id: &str) -> Result<(), String> {
1522 if !self.by_id.read().contains_key(entry_id) {
1523 return Err(format!("Entry {} not found", entry_id));
1524 }
1525 *self.leaf_id.write() = Some(entry_id.to_string());
1526 Ok(())
1527 }
1528
1529 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1531 self.leaf_id
1532 .read()
1533 .as_ref()
1534 .and_then(|id| self.by_id.read().get(id).cloned())
1535 }
1536
1537 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
1539 self.by_id.read().get(id).cloned()
1540 }
1541
1542 pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1544 self.by_id
1545 .read()
1546 .values()
1547 .filter(|e| e.parent_id.as_deref() == Some(parent_id))
1548 .cloned()
1549 .collect()
1550 }
1551
1552 pub fn get_parent(&self, id: &str) -> Option<SessionEntry> {
1554 self.by_id
1555 .read()
1556 .get(id)
1557 .and_then(|e| e.parent_id.as_deref())
1558 .and_then(|pid| self.by_id.read().get(pid).cloned())
1559 }
1560
1561 pub fn get_label(&self, id: &str) -> Option<String> {
1563 self.labels_by_id.read().get(id).cloned()
1564 }
1565
1566 pub fn append_label_change(
1568 &mut self,
1569 target_id: &str,
1570 label: Option<&str>,
1571 ) -> Result<String, String> {
1572 if !self.by_id.read().contains_key(target_id) {
1573 return Err(format!("Entry {} not found", target_id));
1574 }
1575
1576 let leaf = self.leaf_id.read().clone();
1577 let id = Uuid::new_v4().to_string();
1578 let entry = SessionEntry {
1579 id: id.clone(),
1580 parent_id: leaf,
1581 timestamp: Utc::now().timestamp_millis(),
1582 message: AgentMessage::Custom {
1583 custom_type: "label".to_string(),
1584 content: ContentValue::String(label.unwrap_or("").to_string()),
1585 display: false,
1586 details: Some(serde_json::json!({ "targetId": target_id })),
1587 timestamp: Utc::now().timestamp_millis(),
1588 },
1589 };
1590
1591 self._append_entry(entry);
1592
1593 if let Some(l) = label {
1594 self.labels_by_id
1595 .write()
1596 .insert(target_id.to_string(), l.to_string());
1597 self.label_timestamps_by_id
1598 .write()
1599 .insert(target_id.to_string(), Utc::now().to_rfc3339());
1600 } else {
1601 self.labels_by_id.write().remove(target_id);
1602 self.label_timestamps_by_id.write().remove(target_id);
1603 }
1604
1605 Ok(id)
1606 }
1607
1608 pub fn get_branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1610 let mut path = Vec::new();
1611 let leaf_fallback = self.leaf_id.read().clone();
1612 let start_id = from_id.or(leaf_fallback.as_deref());
1613 let Some(start_id) = start_id else {
1614 return path;
1615 };
1616
1617 let by_id = self.by_id.read();
1619 let mut current = by_id.get(start_id).cloned();
1620 while let Some(entry) = current {
1621 path.insert(0, entry.clone());
1622 current = entry
1623 .parent_id
1624 .as_ref()
1625 .and_then(|pid| by_id.get(pid).cloned());
1626 }
1627 path
1628 }
1629
1630 pub fn get_path_to_root(&self, from_id: &str) -> Vec<SessionEntry> {
1632 self.get_branch(Some(from_id))
1633 }
1634
1635 pub fn get_ancestry(&self, from_id: &str) -> Vec<SessionEntry> {
1637 self.get_branch(Some(from_id))
1638 }
1639
1640 pub fn get_depth(&self, id: &str) -> i64 {
1642 let mut depth = 0;
1643 let mut current = self.by_id.read().get(id).cloned();
1644 while let Some(entry) = current {
1645 depth += 1;
1646 current = entry
1647 .parent_id
1648 .as_ref()
1649 .and_then(|pid| self.by_id.read().get(pid).cloned());
1650 }
1651 depth - 1 }
1653
1654 pub fn build_session_context(&self) -> SessionContext {
1656 let entries = self.get_entries();
1657 let leaf_id = self.leaf_id.read().clone();
1658 build_session_context_internal(&entries, leaf_id, None)
1659 }
1660
1661 pub fn get_header(&self) -> Option<SessionHeader> {
1663 self.file_entries.read().iter().find_map(|e| match e {
1664 FileEntry::Header(h) => Some(h.clone()),
1665 _ => None,
1666 })
1667 }
1668
1669 pub fn get_entries(&self) -> Vec<SessionEntry> {
1671 self.by_id.read().values().cloned().collect()
1672 }
1673
1674 pub fn get_tree(&self, _id: Uuid) -> anyhow::Result<Vec<SessionTreeNode>> {
1677 let entries = self.get_entries();
1678 let labels: HashMap<String, String> = self.labels_by_id.read().clone();
1679 let label_timestamps: HashMap<String, String> = self.label_timestamps_by_id.read().clone();
1680
1681 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
1682 let mut root_ids: Vec<String> = Vec::new();
1683
1684 for entry in &entries {
1686 adj.insert(entry.id.clone(), Vec::new());
1687 }
1688
1689 for entry in &entries {
1691 let is_root = match entry.parent_id.as_deref() {
1692 Some(pid) if pid != entry.id => !adj.contains_key(pid),
1693 _ => true,
1694 };
1695 if is_root {
1696 root_ids.push(entry.id.clone());
1697 } else if let Some(ref pid) = entry.parent_id {
1698 if let Some(children) = adj.get_mut(pid.as_str()) {
1699 children.push(entry.id.clone());
1700 } else {
1701 root_ids.push(entry.id.clone());
1702 }
1703 }
1704 }
1705
1706 let entries_map: HashMap<String, SessionEntry> =
1708 entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1709
1710 fn build(
1712 id: &str,
1713 adj: &HashMap<String, Vec<String>>,
1714 entries_map: &HashMap<String, SessionEntry>,
1715 labels: &HashMap<String, String>,
1716 label_timestamps: &HashMap<String, String>,
1717 ) -> anyhow::Result<SessionTreeNode> {
1718 let entry = entries_map
1719 .get(id)
1720 .ok_or_else(|| anyhow::anyhow!("Corrupted session: entry {} not found", id))?
1721 .clone();
1722 let child_ids = adj.get(id).cloned().unwrap_or_default();
1723 let children: Vec<SessionTreeNode> = child_ids
1724 .iter()
1725 .map(|cid| build(cid, adj, entries_map, labels, label_timestamps))
1726 .collect::<Result<Vec<_>, _>>()?;
1727 Ok(SessionTreeNode {
1728 entry,
1729 children,
1730 label: labels.get(id).cloned(),
1731 label_timestamp: label_timestamps.get(id).cloned(),
1732 })
1733 }
1734
1735 let mut roots = root_ids
1736 .into_iter()
1737 .map(|rid| build(&rid, &adj, &entries_map, &labels, &label_timestamps))
1738 .collect::<anyhow::Result<Vec<_>>>()?;
1739
1740 sort_tree_by_timestamp(&mut roots);
1741 Ok(roots)
1742 }
1743
1744 pub fn branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1750 if !self.by_id.read().contains_key(branch_from_id) {
1751 return Err(format!("Entry {} not found", branch_from_id));
1752 }
1753 *self.leaf_id.write() = Some(branch_from_id.to_string());
1754 Ok(())
1755 }
1756
1757 pub fn reset_leaf(&mut self) {
1759 *self.leaf_id.write() = None;
1760 }
1761
1762 pub fn branch_with_summary(
1764 &mut self,
1765 branch_from_id: Option<&str>,
1766 summary: &str,
1767 _details: Option<serde_json::Value>,
1768 _from_hook: Option<bool>,
1769 ) -> String {
1770 if let Some(id) = branch_from_id
1771 && !self.by_id.read().contains_key(id)
1772 {
1773 return String::new();
1774 }
1775
1776 *self.leaf_id.write() = branch_from_id.map(|s| s.to_string());
1777
1778 let id = Uuid::new_v4().to_string();
1779 let entry = SessionEntry {
1780 id: id.clone(),
1781 parent_id: branch_from_id.map(|s| s.to_string()),
1782 timestamp: Utc::now().timestamp_millis(),
1783 message: AgentMessage::BranchSummary {
1784 summary: summary.to_string(),
1785 from_id: branch_from_id.unwrap_or("root").to_string(),
1786 timestamp: Utc::now().timestamp_millis(),
1787 },
1788 };
1789
1790 self._append_entry(entry);
1791 id
1792 }
1793
1794 pub fn add_label(&mut self, target_id: &str, label: &str) -> Result<String, String> {
1796 self.append_label_change(target_id, Some(label))
1797 }
1798
1799 pub fn remove_label(&mut self, target_id: &str) -> Result<String, String> {
1801 self.append_label_change(target_id, None)
1802 }
1803
1804 pub fn get_latest_compaction_entry(&self) -> Option<SessionEntry> {
1810 let entries = self.get_entries();
1811 for entry in entries.iter().rev() {
1812 if let AgentMessage::CompactionSummary { .. } = &entry.message {
1813 return Some(entry.clone());
1814 }
1815 }
1816 None
1817 }
1818
1819 pub fn get_compaction_entries(&self) -> Vec<SessionEntry> {
1821 self.get_entries()
1822 .iter()
1823 .filter(|e| matches!(&e.message, AgentMessage::CompactionSummary { .. }))
1824 .cloned()
1825 .collect()
1826 }
1827
1828 pub fn get_session_stats(&self) -> SessionStats {
1834 let entries = self.get_entries();
1835 let mut message_count = 0i64;
1836 let mut user_message_count = 0i64;
1837 let mut assistant_message_count = 0i64;
1838 let mut total_chars = 0i64;
1839 let mut total_tokens_estimate = 0i64;
1840
1841 for entry in &entries {
1842 if let AgentMessage::User { .. } = &entry.message {
1843 user_message_count += 1;
1844 }
1845 if let AgentMessage::Assistant { .. } = &entry.message {
1846 assistant_message_count += 1;
1847 }
1848 if entry.message.is_user() || entry.message.is_assistant() {
1849 message_count += 1;
1850 let content = entry.content();
1852 let chars = content.len() as i64;
1853 total_chars += chars;
1854 total_tokens_estimate += (chars as f64 / 4.0).ceil() as i64;
1855 }
1856 }
1857
1858 SessionStats {
1859 message_count,
1860 user_message_count,
1861 assistant_message_count,
1862 total_chars,
1863 estimated_tokens: total_tokens_estimate,
1864 }
1865 }
1866
1867 pub async fn list(cwd: &str, session_dir: Option<&str>) -> Result<Vec<SessionInfo>> {
1873 let dir = session_dir
1874 .map(|s| s.to_string())
1875 .unwrap_or_else(|| get_default_session_dir(cwd));
1876 list_sessions_from_dir(&dir).await
1877 }
1878
1879 pub async fn list_all() -> Result<Vec<SessionInfo>> {
1881 let sessions_dir = get_sessions_dir();
1882
1883 if !Path::new(&sessions_dir).exists() {
1884 return Ok(Vec::new());
1885 }
1886
1887 let mut all_sessions = Vec::new();
1888 let entries = fs::read_dir(&sessions_dir)?;
1889
1890 for entry in entries {
1891 let entry = entry?;
1892 let path = entry.path();
1893 if path.is_dir()
1894 && let Ok(sessions) = list_sessions_from_dir(&path.to_string_lossy()).await
1895 {
1896 all_sessions.extend(sessions);
1897 }
1898 }
1899
1900 all_sessions.sort_by_key(|b| std::cmp::Reverse(b.modified));
1901 Ok(all_sessions)
1902 }
1903
1904 pub fn fork_from(
1906 source_path: &str,
1907 target_cwd: &str,
1908 session_dir: Option<&str>,
1909 ) -> Result<Self, String> {
1910 let source_entries = load_entries_from_file(source_path);
1911 if source_entries.is_empty() {
1912 return Err(format!(
1913 "Cannot fork: source session file is empty or invalid: {}",
1914 source_path
1915 ));
1916 }
1917
1918 let source_header = source_entries.iter().find_map(|e| match e {
1919 FileEntry::Header(h) => Some(h),
1920 _ => None,
1921 });
1922 if source_header.is_none() {
1923 return Err(format!(
1924 "Cannot fork: source session has no header: {}",
1925 source_path
1926 ));
1927 }
1928
1929 let dir = session_dir
1930 .map(|s| s.to_string())
1931 .unwrap_or_else(|| get_default_session_dir(target_cwd));
1932
1933 if !Path::new(&dir).exists() {
1934 let _ = fs::create_dir_all(&dir);
1935 }
1936
1937 let new_session_id = Uuid::new_v4().to_string();
1938 let timestamp = Utc::now().to_rfc3339();
1939 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1940 let short_id = &new_session_id[..8];
1941 let new_session_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
1942
1943 let new_header = SessionHeader {
1945 entry_type: "session".to_string(),
1946 version: Some(CURRENT_SESSION_VERSION),
1947 id: new_session_id.clone(),
1948 timestamp: timestamp.clone(),
1949 cwd: target_cwd.to_string(),
1950 parent_session: Some(source_path.to_string()),
1951 };
1952
1953 let mut handle = fs::OpenOptions::new()
1954 .create(true)
1955 .truncate(true)
1956 .write(true)
1957 .open(&new_session_file)
1958 .map_err(|e| e.to_string())?;
1959 writeln!(
1960 &mut handle,
1961 "{}",
1962 serde_json::to_string(&new_header).expect("session header serializable")
1963 )
1964 .map_err(|e| e.to_string())?;
1965
1966 for file_entry in &source_entries {
1968 if let FileEntry::Entry(_) = file_entry {
1969 writeln!(
1970 &mut handle,
1971 "{}",
1972 serde_json::to_string(file_entry).expect("session entry serializable")
1973 )
1974 .map_err(|e| e.to_string())?;
1975 }
1976 }
1977
1978 Ok(Self::open(&new_session_file, Some(&dir), Some(target_cwd)))
1979 }
1980
1981 pub fn delete_session(path: &str) -> Result<()> {
1983 fs::remove_file(path).context("Failed to delete session file")?;
1984 Ok(())
1985 }
1986
1987 pub fn rename_session(&mut self, name: &str) -> String {
1989 self.append_session_info(name)
1990 }
1991
1992 pub async fn new() -> Result<Self> {
1998 Self::new_async().await
1999 }
2000
2001 pub async fn new_async() -> Result<Self> {
2003 let home = dirs::home_dir().context("Cannot find home directory")?;
2004 let base_dir = home.join(".oxi");
2005 let sessions_dir = base_dir.join("sessions");
2006 tokio::fs::create_dir_all(&sessions_dir).await?;
2007 let cwd = std::env::current_dir()
2008 .unwrap_or_else(|_| PathBuf::from("."))
2009 .to_string_lossy()
2010 .to_string();
2011 Ok(Self::in_memory(&cwd))
2012 }
2013
2014 pub fn session_path(&self, id: &Uuid) -> PathBuf {
2016 if let Some(file) = &self.session_file {
2017 PathBuf::from(file)
2018 } else {
2019 PathBuf::from(format!("{}/{}.jsonl", self.session_dir, id))
2020 }
2021 }
2022
2023 pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
2025 let mut metas = Vec::new();
2027 let session_dir = Path::new(&self.session_dir);
2028 if !session_dir.exists() {
2029 return Ok(metas);
2030 }
2031 let entries = fs::read_dir(session_dir)?;
2032 for entry in entries {
2033 let entry = entry?;
2034 let path = entry.path();
2035 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2036 let file_name = path
2037 .file_stem()
2038 .unwrap_or_else(|| std::ffi::OsStr::new(""))
2039 .to_string_lossy()
2040 .to_string();
2041 if let Some(uuid_part) = file_name.split('_').next_back()
2043 && let Ok(uuid) = Uuid::parse_str(uuid_part)
2044 {
2045 let mtime = entry.metadata().ok().and_then(|m| m.modified().ok());
2046 let now_ts = Utc::now().timestamp_millis();
2047 metas.push(SessionMeta {
2048 id: uuid,
2049 parent_id: None,
2050 root_id: None,
2051 branch_point: None,
2052 created_at: now_ts,
2053 updated_at: mtime
2054 .map(|t| {
2055 let dt: DateTime<Utc> = DateTime::from(t);
2056 dt.timestamp_millis()
2057 })
2058 .unwrap_or(now_ts),
2059 name: None,
2060 });
2061 }
2062 }
2063 }
2064 metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
2065 Ok(metas)
2066 }
2067
2068 pub async fn save(&self, _id: Uuid, _entries: &[SessionEntry]) -> Result<()> {
2070 self._rewrite_file();
2071 Ok(())
2072 }
2073
2074 pub async fn load(&self, _id: Uuid) -> Result<Vec<SessionEntry>> {
2076 Ok(self.get_entries())
2077 }
2078
2079 pub async fn delete(&self, id: Uuid) -> Result<()> {
2081 let path = self.session_path(&id);
2082 if path.exists() {
2083 fs::remove_file(path).context("Failed to delete session file")?;
2084 }
2085 Ok(())
2086 }
2087
2088 pub async fn branch_from(
2090 &self,
2091 parent_id: Uuid,
2092 entry_id: Uuid,
2093 ) -> Result<(Uuid, Vec<SessionEntry>)> {
2094 let _entry_id_str = entry_id.to_string();
2095 let _parent_id_str = parent_id.to_string();
2096
2097 let _entries = self.get_entries();
2099 let path = self.get_branch(Some(&entry_id.to_string()));
2100
2101 let new_id = Uuid::new_v4();
2102 let new_entries: Vec<SessionEntry> = path
2103 .into_iter()
2104 .map(|e| {
2105 let mut new_entry = e.clone();
2106 new_entry.id = Uuid::new_v4().to_string();
2107 new_entry
2108 })
2109 .collect();
2110
2111 Ok((new_id, new_entries))
2114 }
2115
2116 pub async fn get_branch_info(&self, _id: Uuid) -> Result<Option<BranchInfo>> {
2118 Ok(None)
2120 }
2121
2122 pub async fn get_tree_async(&self, _id: Uuid) -> Result<Vec<SessionTreeNode>> {
2124 self.get_tree(Uuid::nil())
2125 }
2126
2127 pub async fn save_meta(&self, _meta: &SessionMeta) -> Result<()> {
2129 Ok(())
2130 }
2131
2132 pub async fn load_meta(&self, _id: Uuid) -> Result<Option<SessionMeta>> {
2134 Ok(None)
2135 }
2136
2137 pub async fn create_session(&mut self) -> Result<SessionMeta> {
2139 let id = Uuid::new_v4();
2140 let meta = SessionMeta::new(id);
2141 Ok(meta)
2142 }
2143
2144 pub fn branch_from_entry(&self, entry_id: &str) -> Result<String, String> {
2146 let path = self
2147 .get_session_file()
2148 .ok_or_else(|| "No session file path".to_string())?;
2149 let source_entries = load_entries_from_file(&path);
2150 if source_entries.is_empty() {
2151 return Err("Cannot fork: source session is empty".to_string());
2152 }
2153 let _header = source_entries
2155 .iter()
2156 .find_map(|e| match e {
2157 FileEntry::Header(h) => Some(h),
2158 _ => None,
2159 })
2160 .ok_or_else(|| "Missing session header".to_string())?;
2161 let new_id = Uuid::new_v4().to_string();
2162 let timestamp = chrono::Utc::now().to_rfc3339();
2163 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
2164 let short_id = &new_id[..8];
2165 let dir = std::path::Path::new(&path)
2166 .parent()
2167 .map(|p| p.to_string_lossy().into_owned())
2168 .unwrap_or_else(|| ".".to_string());
2169 let new_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
2170 let mut found = false;
2171 let mut new_entries = vec![FileEntry::Header(SessionHeader {
2172 entry_type: "session".to_string(),
2173 version: Some(CURRENT_SESSION_VERSION),
2174 id: new_id.clone(),
2175 timestamp,
2176 cwd: self.get_cwd(),
2177 parent_session: Some(path),
2178 })];
2179 for file_entry in &source_entries {
2180 if let FileEntry::Entry(entry) = file_entry {
2181 let eid = match entry {
2182 SessionEntryEnum::Message(m) => m.base.id.clone(),
2183 SessionEntryEnum::ThinkingLevelChange(m) => m.base.id.clone(),
2184 SessionEntryEnum::ModelChange(m) => m.base.id.clone(),
2185 SessionEntryEnum::Compaction(m) => m.base.id.clone(),
2186 SessionEntryEnum::BranchSummary(m) => m.base.id.clone(),
2187 SessionEntryEnum::Custom(m) => m.base.id.clone(),
2188 SessionEntryEnum::Label(m) => m.base.id.clone(),
2189 SessionEntryEnum::SessionInfo(m) => m.base.id.clone(),
2190 SessionEntryEnum::CustomMessage(m) => m.base.id.clone(),
2191 };
2192 if eid == entry_id {
2193 found = true;
2194 let mut entry = entry.clone();
2197 clear_entry_parent_id(&mut entry);
2198 new_entries.push(FileEntry::Entry(entry));
2199 } else if found {
2200 new_entries.push(FileEntry::Entry(entry.clone()));
2201 }
2202 }
2203 }
2204 if !found {
2205 return Err(format!("Entry not found: {}", entry_id));
2206 }
2207 let mut handle = std::fs::OpenOptions::new()
2208 .create(true)
2209 .truncate(true)
2210 .write(true)
2211 .open(&new_file)
2212 .map_err(|e| e.to_string())?;
2213 for entry in &new_entries {
2214 let line = serde_json::to_string(entry).map_err(|e| e.to_string())?;
2215 writeln!(&mut handle, "{}", line).map_err(|e| e.to_string())?;
2216 }
2217 Ok(new_file)
2218 }
2219}
2220
2221fn clear_entry_parent_id(entry: &mut SessionEntryEnum) {
2228 match entry {
2229 SessionEntryEnum::Message(m) => m.base.parent_id = None,
2230 SessionEntryEnum::ThinkingLevelChange(m) => m.base.parent_id = None,
2231 SessionEntryEnum::ModelChange(m) => m.base.parent_id = None,
2232 SessionEntryEnum::Compaction(m) => m.base.parent_id = None,
2233 SessionEntryEnum::BranchSummary(m) => m.base.parent_id = None,
2234 SessionEntryEnum::Custom(m) => m.base.parent_id = None,
2235 SessionEntryEnum::Label(m) => m.base.parent_id = None,
2236 SessionEntryEnum::SessionInfo(m) => m.base.parent_id = None,
2237 SessionEntryEnum::CustomMessage(m) => m.base.parent_id = None,
2238 }
2239}
2240
2241fn convert_to_session_entry(entry: &SessionEntryEnum) -> Option<SessionEntry> {
2243 match entry {
2244 SessionEntryEnum::Message(m) => Some(SessionEntry {
2245 id: m.base.id.clone(),
2246 parent_id: m.base.parent_id.clone(),
2247 timestamp: DateTime::parse_from_rfc3339(&m.base.timestamp)
2248 .map(|dt| dt.timestamp_millis())
2249 .unwrap_or(0),
2250 message: m.message.clone(),
2251 }),
2252 _ => None, }
2254}
2255
2256fn convert_from_session_entry(entry: &SessionEntry) -> SessionEntryEnum {
2258 let timestamp = DateTime::from_timestamp_millis(entry.timestamp)
2259 .map(|dt| dt.to_rfc3339())
2260 .unwrap_or_else(|| Utc::now().to_rfc3339());
2261
2262 SessionEntryEnum::Message(SessionMessageEntry {
2263 base: SessionEntryBase {
2264 entry_type: "message".to_string(),
2265 id: entry.id.clone(),
2266 parent_id: entry.parent_id.clone(),
2267 timestamp,
2268 },
2269 message: entry.message.clone(),
2270 })
2271}
2272
2273#[derive(Debug, Clone)]
2279pub struct SessionStats {
2280 pub message_count: i64,
2282 pub user_message_count: i64,
2284 pub assistant_message_count: i64,
2286 pub total_chars: i64,
2288 pub estimated_tokens: i64,
2290}
2291
2292#[derive(Debug, Clone)]
2298pub struct NewSessionOptions {
2299 pub id: Option<String>,
2301 pub parent_session: Option<String>,
2303}
2304
2305pub fn get_default_session_dir(cwd: &str) -> String {
2311 let agent_dir = get_agent_dir();
2312 let safe_path = format!("--{}--", cwd.replace(['/', '\\', ':'], "-"));
2313 let session_dir = format!("{}/sessions/{}", agent_dir, safe_path);
2314
2315 if !Path::new(&session_dir).exists() {
2316 let _ = fs::create_dir_all(&session_dir);
2317 }
2318
2319 session_dir
2320}
2321
2322fn get_agent_dir() -> String {
2323 dirs::home_dir()
2324 .map(|h| h.join(".oxi").to_string_lossy().to_string())
2325 .unwrap_or_else(|| ".oxi".to_string())
2326}
2327
2328fn get_sessions_dir() -> String {
2329 format!("{}/sessions", get_agent_dir())
2330}
2331
2332fn load_entries_from_file(file_path: &str) -> Vec<FileEntry> {
2334 if !Path::new(file_path).exists() {
2335 return Vec::new();
2336 }
2337
2338 let file = match File::open(file_path) {
2339 Ok(f) => f,
2340 Err(_) => return Vec::new(),
2341 };
2342
2343 let reader = BufReader::new(file);
2344 let mut entries = Vec::new();
2345
2346 for line in reader.lines() {
2347 let line = match line {
2348 Ok(l) => l,
2349 Err(_) => continue,
2350 };
2351 if line.trim().is_empty() {
2352 continue;
2353 }
2354 match serde_json::from_str::<FileEntry>(&line) {
2355 Ok(entry) => entries.push(entry),
2356 Err(_) => continue,
2357 }
2358 }
2359
2360 if entries.is_empty() {
2362 return entries;
2363 }
2364 let header = match &entries[0] {
2365 FileEntry::Header(h) => h,
2366 _ => return Vec::new(),
2367 };
2368 if header.entry_type != "session" || header.id.is_empty() {
2369 return Vec::new();
2370 }
2371
2372 entries
2373}
2374
2375fn is_valid_session_file(file_path: &str) -> bool {
2377 if let Ok(mut file) = File::open(file_path) {
2378 use std::io::Read;
2379 let mut buffer = vec![0u8; 512];
2380 if let Ok(bytes_read) = file.read(&mut buffer)
2381 && let Ok(content) = String::from_utf8(buffer[..bytes_read].to_vec())
2382 && let Some(first_line) = content.split('\n').next()
2383 && let Ok(header) = serde_json::from_str::<SessionHeader>(first_line)
2384 {
2385 return header.entry_type == "session" && !header.id.is_empty();
2386 }
2387 }
2388 false
2389}
2390
2391pub fn find_recent_session_path(cwd: &str) -> Option<String> {
2393 let dir = get_default_session_dir(cwd);
2394 find_most_recent_session(&dir)
2395}
2396
2397fn find_most_recent_session(session_dir: &str) -> Option<String> {
2398 if !Path::new(session_dir).exists() {
2399 return None;
2400 }
2401
2402 let mut files: Vec<(String, std::time::SystemTime)> = Vec::new();
2403
2404 if let Ok(entries) = fs::read_dir(session_dir) {
2405 for entry in entries.flatten() {
2406 let path = entry.path();
2407 if path.extension().map(|e| e == "jsonl").unwrap_or(false)
2408 && let Some(path_str) = path.to_str()
2409 && is_valid_session_file(path_str)
2410 && let Ok(metadata) = entry.metadata()
2411 && let Ok(mtime) = metadata.modified()
2412 {
2413 files.push((path_str.to_string(), mtime));
2414 }
2415 }
2416 }
2417
2418 files.sort_by_key(|b| std::cmp::Reverse(b.1));
2419 files.into_iter().next().map(|(p, _)| p)
2420}
2421
2422pub fn resolve_session_path(input: &str, cwd: &str) -> Result<String, String> {
2424 let path = input.trim();
2425 if path.is_empty() {
2426 return Err("Empty path".to_string());
2427 }
2428 let resolved = if let Some(rest) = path.strip_prefix('~') {
2429 if rest.is_empty() {
2430 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2431 home.to_string_lossy().into_owned()
2432 } else if let Some(rest) = rest.strip_prefix('/') {
2433 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2434 format!("{}/{}", home.to_string_lossy(), rest)
2435 } else {
2436 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2437 format!("{}/{}", home.to_string_lossy(), rest)
2438 }
2439 } else if path.starts_with('/') || path.contains(':') {
2440 path.to_string()
2441 } else {
2442 if let Some(stripped) = path.strip_prefix("./") {
2443 format!("{}/{}", cwd.trim_end_matches('/'), stripped)
2444 } else {
2445 format!("{}/{}", cwd.trim_end_matches('/'), path)
2446 }
2447 };
2448 let p = std::path::Path::new(&resolved);
2449 p.canonicalize()
2450 .map(|c| c.to_string_lossy().into_owned())
2451 .or(Ok(resolved))
2452}
2453
2454fn build_session_context_internal(
2456 entries: &[SessionEntry],
2457 leaf_id: Option<String>,
2458 _by_id: Option<&RwLock<HashMap<String, SessionEntry>>>,
2459) -> SessionContext {
2460 let leaf: Option<&SessionEntry> = leaf_id
2462 .as_ref()
2463 .and_then(|id| entries.iter().find(|e| e.id == *id));
2464
2465 let leaf = leaf.or_else(|| entries.last());
2466
2467 let Some(leaf) = leaf else {
2468 return SessionContext {
2469 messages: Vec::new(),
2470 thinking_level: "off".to_string(),
2471 model: None,
2472 };
2473 };
2474
2475 let mut path: Vec<&SessionEntry> = Vec::new();
2477 let mut current: Option<&SessionEntry> = Some(leaf);
2478 while let Some(entry) = current {
2479 path.insert(0, entry);
2480 current = entry
2481 .parent_id
2482 .as_ref()
2483 .and_then(|pid| entries.iter().find(|e| e.id == *pid));
2484 }
2485
2486 let mut thinking_level = "off".to_string();
2488 let mut model: Option<ModelInfo> = None;
2489
2490 for entry in &path {
2491 if let AgentMessage::Assistant {
2492 provider, model_id, ..
2493 } = &entry.message
2494 {
2495 model = Some(ModelInfo {
2496 provider: provider.clone().unwrap_or_default(),
2497 model_id: model_id.clone().unwrap_or_default(),
2498 });
2499 }
2500 if let AgentMessage::Custom {
2501 custom_type,
2502 content,
2503 ..
2504 } = &entry.message
2505 && custom_type == "thinking_level_change"
2506 {
2507 thinking_level = content.as_str().to_string();
2508 }
2509 }
2510
2511 let messages: Vec<AgentMessage> = path
2513 .iter()
2514 .filter(|e| {
2515 e.message.is_user()
2516 || e.message.is_assistant()
2517 || matches!(&e.message, AgentMessage::BranchSummary { .. })
2518 || matches!(&e.message, AgentMessage::CompactionSummary { .. })
2519 })
2520 .map(|e| e.message.clone())
2521 .collect();
2522
2523 SessionContext {
2524 messages,
2525 thinking_level,
2526 model,
2527 }
2528}
2529
2530fn sort_tree_by_timestamp(nodes: &mut Vec<SessionTreeNode>) {
2532 nodes.sort_by_key(|a| a.entry.timestamp);
2533
2534 for node in nodes {
2535 sort_tree_by_timestamp(&mut node.children);
2536 }
2537}
2538
2539async fn list_sessions_from_dir(dir: &str) -> Result<Vec<SessionInfo>> {
2541 if !Path::new(dir).exists() {
2542 return Ok(Vec::new());
2543 }
2544
2545 let mut sessions = Vec::new();
2546
2547 let entries = fs::read_dir(dir)?;
2548 let files: Vec<String> = entries
2549 .filter_map(|e| e.ok())
2550 .filter(|e| {
2551 e.path()
2552 .extension()
2553 .map(|ext| ext == "jsonl")
2554 .unwrap_or(false)
2555 })
2556 .filter_map(|e| e.path().to_str().map(|s| s.to_string()))
2557 .collect();
2558
2559 for file in files {
2560 if let Some(info) = build_session_info(&file).await {
2561 sessions.push(info);
2562 }
2563 }
2564
2565 Ok(sessions)
2566}
2567
2568async fn build_session_info(file_path: &str) -> Option<SessionInfo> {
2570 let content = fs::read_to_string(file_path).ok()?;
2571 let entries = parse_session_entries(&content)?;
2572
2573 if entries.is_empty() {
2574 return None;
2575 }
2576
2577 let header = match &entries[0] {
2578 FileEntry::Header(h) => h,
2579 _ => return None,
2580 };
2581
2582 let stats = fs::metadata(file_path).ok()?;
2583 let mut message_count = 0i64;
2584 let mut first_message = String::new();
2585 let mut all_messages = Vec::new();
2586 let mut name: Option<String> = None;
2587
2588 for entry in &entries {
2589 if let FileEntry::Entry(e) = entry {
2590 if let SessionEntryEnum::SessionInfo(si) = e {
2592 name = si
2593 .name
2594 .clone()
2595 .map(|n| n.trim().to_string())
2596 .filter(|n| !n.is_empty());
2597 }
2598 if let SessionEntryEnum::Message(m) = e {
2600 if m.message.is_user() {
2601 message_count += 1;
2602 let text = m.message.content();
2603 if !text.is_empty() {
2604 all_messages.push(text.clone());
2605 if first_message.is_empty() {
2606 first_message = text;
2607 }
2608 }
2609 } else if m.message.is_assistant() {
2610 if first_message.is_empty() {
2612 let text = m.message.content();
2613 if !text.is_empty() {
2614 first_message = text;
2615 }
2616 }
2617 }
2618 }
2619 }
2620 }
2621
2622 if first_message.is_empty() {
2626 return None;
2627 }
2628
2629 let cwd = header.cwd.clone();
2630 let parent_session_path = header.parent_session.clone();
2631 let created = chrono::DateTime::parse_from_rfc3339(&header.timestamp)
2632 .map(|dt| dt.with_timezone(&Utc))
2633 .unwrap_or_else(|_| Utc::now());
2634 let modified = get_session_modified_date(&entries, &header.timestamp, &stats);
2635
2636 Some(SessionInfo {
2637 path: file_path.to_string(),
2638 id: header.id.clone(),
2639 cwd,
2640 name,
2641 parent_session_path,
2642 created,
2643 modified,
2644 message_count,
2645 first_message: if first_message.is_empty() {
2646 "(no messages)".to_string()
2647 } else {
2648 first_message
2649 },
2650 all_messages_text: all_messages.join(" "),
2651 })
2652}
2653
2654fn parse_session_entries(content: &str) -> Option<Vec<FileEntry>> {
2656 let mut entries = Vec::new();
2657
2658 for line in content.trim().lines() {
2659 if line.trim().is_empty() {
2660 continue;
2661 }
2662 if let Ok(entry) = serde_json::from_str::<FileEntry>(line) {
2663 entries.push(entry);
2664 }
2665 }
2666
2667 Some(entries)
2668}
2669
2670fn get_session_modified_date(
2672 entries: &[FileEntry],
2673 header_timestamp: &str,
2674 stats: &std::fs::Metadata,
2675) -> DateTime<Utc> {
2676 let last_activity_time = get_last_activity_time(entries);
2677 if let Some(t) = last_activity_time
2678 && t > 0
2679 {
2680 return DateTime::from_timestamp_millis(t).unwrap_or_else(Utc::now);
2681 }
2682
2683 let header_time = chrono::DateTime::parse_from_rfc3339(header_timestamp)
2684 .map(|dt| dt.timestamp_millis())
2685 .unwrap_or(-1);
2686
2687 if header_time > 0 {
2688 return DateTime::from_timestamp_millis(header_time).unwrap_or_else(Utc::now);
2689 }
2690
2691 if let Ok(mtime) = stats.modified() {
2692 return DateTime::from(mtime);
2693 }
2694
2695 Utc::now()
2696}
2697
2698fn get_last_activity_time(entries: &[FileEntry]) -> Option<i64> {
2700 let mut last_activity: Option<i64> = None;
2701
2702 for entry in entries {
2703 let entry = match entry {
2704 FileEntry::Entry(e) => e,
2705 _ => continue,
2706 };
2707
2708 if let SessionEntryEnum::Message(m) = entry
2709 && (m.message.is_user() || m.message.is_assistant())
2710 {
2711 last_activity = Some(std::cmp::max(
2712 last_activity.unwrap_or(0),
2713 m.base.timestamp.parse().unwrap_or(0),
2714 ));
2715 }
2716 }
2717
2718 last_activity
2719}
2720
2721#[cfg(test)]
2726mod tests {
2727 use super::*;
2728
2729 #[test]
2730 fn test_session_creation() {
2731 let manager = SessionManager::in_memory("/tmp");
2732 assert!(!manager.get_session_id().is_empty());
2733 assert_eq!(manager.get_entries().len(), 0);
2734 }
2735
2736 #[test]
2737 fn test_append_message() {
2738 let mut manager = SessionManager::in_memory("/tmp");
2739 let id = manager.append_message(AgentMessage::User {
2740 content: ContentValue::String("Hello".to_string()),
2741 });
2742 assert!(!id.is_empty());
2743 assert_eq!(manager.get_entries().len(), 1);
2744 assert_eq!(manager.get_leaf_id(), Some(id));
2745 }
2746
2747 #[test]
2748 fn test_tree_traversal() {
2749 let mut manager = SessionManager::in_memory("/tmp");
2750 let id1 = manager.append_message(AgentMessage::User {
2751 content: ContentValue::String("Hello".to_string()),
2752 });
2753 let id2 = manager.append_message(AgentMessage::Assistant {
2754 content: vec![],
2755 provider: None,
2756 model_id: None,
2757 usage: None,
2758 stop_reason: None,
2759 });
2760
2761 let branch = manager.get_branch(None);
2763 assert_eq!(branch.len(), 2);
2764
2765 let branch = manager.get_branch(Some(&id1));
2767 assert_eq!(branch.len(), 1);
2768
2769 let children = manager.get_children(&id1);
2771 assert_eq!(children.len(), 1);
2772
2773 let parent = manager.get_parent(&id2);
2775 assert!(parent.is_some());
2776 assert_eq!(parent.unwrap().id, id1);
2777 }
2778
2779 #[test]
2780 fn test_branching() {
2781 let mut manager = SessionManager::in_memory("/tmp");
2782 let id1 = manager.append_message(AgentMessage::User {
2783 content: ContentValue::String("Hello".to_string()),
2784 });
2785 let _id2 = manager.append_message(AgentMessage::Assistant {
2786 content: vec![],
2787 provider: None,
2788 model_id: None,
2789 usage: None,
2790 stop_reason: None,
2791 });
2792 let _id3 = manager.append_message(AgentMessage::User {
2793 content: ContentValue::String("How are you?".to_string()),
2794 });
2795
2796 manager.branch(&id1).unwrap();
2798 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2799
2800 let id4 = manager.append_message(AgentMessage::Assistant {
2802 content: vec![],
2803 provider: None,
2804 model_id: None,
2805 usage: None,
2806 stop_reason: None,
2807 });
2808
2809 assert_eq!(manager.get_entries().len(), 4);
2811
2812 assert_eq!(manager.get_leaf_id(), Some(id4));
2814
2815 let tree = manager.get_tree(Uuid::nil()).unwrap();
2817 assert_eq!(tree.len(), 1); assert_eq!(tree[0].children.len(), 2); }
2820
2821 #[test]
2822 fn test_session_context() {
2823 let mut manager = SessionManager::in_memory("/tmp");
2824 manager.append_message(AgentMessage::User {
2825 content: ContentValue::String("Hello".to_string()),
2826 });
2827 manager.append_message(AgentMessage::Assistant {
2828 content: vec![AssistantContentBlock::Text {
2829 text: "Hi there!".to_string(),
2830 }],
2831 provider: Some("test".to_string()),
2832 model_id: Some("model".to_string()),
2833 usage: None,
2834 stop_reason: None,
2835 });
2836
2837 let context = manager.build_session_context();
2838 assert_eq!(context.messages.len(), 2);
2839 assert!(context.model.is_some());
2840 }
2841
2842 #[test]
2843 fn test_compaction_entry() {
2844 let mut manager = SessionManager::in_memory("/tmp");
2845 let id1 = manager.append_message(AgentMessage::User {
2846 content: ContentValue::String("First message".to_string()),
2847 });
2848 let _id2 = manager.append_message(AgentMessage::Assistant {
2849 content: vec![],
2850 provider: None,
2851 model_id: None,
2852 usage: None,
2853 stop_reason: None,
2854 });
2855
2856 let id3 = manager.append_compaction("Summarized conversation", &id1, 1000, None, None);
2857 assert!(!id3.is_empty());
2858
2859 let latest = manager.get_latest_compaction_entry();
2860 assert!(latest.is_some());
2861 }
2862
2863 #[test]
2864 fn test_labels() {
2865 let mut manager = SessionManager::in_memory("/tmp");
2866 let id1 = manager.append_message(AgentMessage::User {
2867 content: ContentValue::String("Hello".to_string()),
2868 });
2869
2870 manager.add_label(&id1, "important").unwrap();
2871 assert_eq!(manager.get_label(&id1), Some("important".to_string()));
2872
2873 manager.remove_label(&id1).unwrap();
2874 assert_eq!(manager.get_label(&id1), None);
2875 }
2876
2877 fn user_msg(text: &str) -> AgentMessage {
2883 AgentMessage::User {
2884 content: ContentValue::String(text.to_string()),
2885 }
2886 }
2887
2888 fn assistant_msg(text: &str) -> AgentMessage {
2890 AgentMessage::Assistant {
2891 content: vec![AssistantContentBlock::Text {
2892 text: text.to_string(),
2893 }],
2894 provider: Some("anthropic".to_string()),
2895 model_id: Some("claude-test".to_string()),
2896 usage: None,
2897 stop_reason: None,
2898 }
2899 }
2900
2901 fn bare_assistant_msg() -> AgentMessage {
2903 AgentMessage::Assistant {
2904 content: vec![],
2905 provider: None,
2906 model_id: None,
2907 usage: None,
2908 stop_reason: None,
2909 }
2910 }
2911
2912 #[test]
2917 fn test_append_thinking_level_change_integrates() {
2918 let mut manager = SessionManager::in_memory("/tmp");
2919 let msg_id = manager.append_message(user_msg("hello"));
2920 let thinking_id = manager.append_thinking_level_change("high");
2921 let msg2_id = manager.append_message(assistant_msg("response"));
2922
2923 let entries = manager.get_entries();
2924 assert_eq!(entries.len(), 3);
2925
2926 let thinking_entry = entries.iter().find(|e| e.id == thinking_id).unwrap();
2928 assert_eq!(thinking_entry.parent_id, Some(msg_id));
2929
2930 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2931 assert_eq!(msg2.parent_id, Some(thinking_id));
2932 }
2933
2934 #[test]
2935 fn test_append_model_change_integrates() {
2936 let mut manager = SessionManager::in_memory("/tmp");
2937 let msg_id = manager.append_message(user_msg("hello"));
2938 let model_id = manager.append_model_change("openai", "gpt-4");
2939 let msg2_id = manager.append_message(assistant_msg("response"));
2940
2941 let entries = manager.get_entries();
2942 let model_entry = entries.iter().find(|e| e.id == model_id).unwrap();
2943 assert_eq!(model_entry.parent_id, Some(msg_id));
2944
2945 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2946 assert_eq!(msg2.parent_id, Some(model_id));
2947 }
2948
2949 #[test]
2950 fn test_append_compaction_integrates_into_tree() {
2951 let mut manager = SessionManager::in_memory("/tmp");
2952 let id1 = manager.append_message(user_msg("1"));
2953 let id2 = manager.append_message(assistant_msg("2"));
2954 let compaction_id = manager.append_compaction("summary", &id1, 1000, None, None);
2955 let id3 = manager.append_message(user_msg("3"));
2956
2957 let entries = manager.get_entries();
2958 let compaction = entries.iter().find(|e| e.id == compaction_id).unwrap();
2959 assert_eq!(compaction.parent_id, Some(id2));
2960
2961 let msg3 = entries.iter().find(|e| e.id == id3).unwrap();
2962 assert_eq!(msg3.parent_id, Some(compaction_id));
2963
2964 if let AgentMessage::CompactionSummary {
2966 summary,
2967 tokens_before,
2968 ..
2969 } = &compaction.message
2970 {
2971 assert_eq!(summary, "summary");
2972 assert_eq!(*tokens_before, 1000);
2973 } else {
2974 panic!("Expected CompactionSummary");
2975 }
2976 }
2977
2978 #[test]
2979 fn test_leaf_pointer_advances() {
2980 let mut manager = SessionManager::in_memory("/tmp");
2981 assert!(manager.get_leaf_id().is_none());
2982
2983 let id1 = manager.append_message(user_msg("1"));
2984 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2985
2986 let id2 = manager.append_message(assistant_msg("2"));
2987 assert_eq!(manager.get_leaf_id(), Some(id2.clone()));
2988
2989 let id3 = manager.append_thinking_level_change("high");
2990 assert_eq!(manager.get_leaf_id(), Some(id3));
2991 }
2992
2993 #[test]
2994 fn test_get_entry() {
2995 let mut manager = SessionManager::in_memory("/tmp");
2996 assert!(manager.get_entry("nonexistent").is_none());
2997
2998 let id1 = manager.append_message(user_msg("first"));
2999 let id2 = manager.append_message(assistant_msg("second"));
3000
3001 let entry1 = manager.get_entry(&id1);
3002 assert!(entry1.is_some());
3003 assert!(entry1.unwrap().message.is_user());
3004
3005 let entry2 = manager.get_entry(&id2);
3006 assert!(entry2.is_some());
3007 assert!(entry2.unwrap().message.is_assistant());
3008 }
3009
3010 #[test]
3011 fn test_get_leaf_entry() {
3012 let manager = SessionManager::in_memory("/tmp");
3013 assert!(manager.get_leaf_entry().is_none());
3014
3015 let mut manager = SessionManager::in_memory("/tmp");
3016 manager.append_message(user_msg("1"));
3017 let id2 = manager.append_message(assistant_msg("2"));
3018
3019 let leaf = manager.get_leaf_entry();
3020 assert!(leaf.is_some());
3021 assert_eq!(leaf.unwrap().id, id2);
3022 }
3023
3024 #[test]
3029 fn test_get_branch_full_path_root_to_leaf() {
3030 let mut manager = SessionManager::in_memory("/tmp");
3031 let id1 = manager.append_message(user_msg("1"));
3032 let id2 = manager.append_message(assistant_msg("2"));
3033 let id3 = manager.append_thinking_level_change("high");
3034 let id4 = manager.append_message(user_msg("3"));
3035
3036 let branch = manager.get_branch(None);
3037 assert_eq!(branch.len(), 4);
3038 assert_eq!(branch[0].id, id1);
3039 assert_eq!(branch[1].id, id2);
3040 assert_eq!(branch[2].id, id3);
3041 assert_eq!(branch[3].id, id4);
3042 }
3043
3044 #[test]
3045 fn test_get_branch_from_specific_entry() {
3046 let mut manager = SessionManager::in_memory("/tmp");
3047 let id1 = manager.append_message(user_msg("1"));
3048 let id2 = manager.append_message(assistant_msg("2"));
3049 manager.append_message(user_msg("3"));
3050 manager.append_message(assistant_msg("4"));
3051
3052 let branch = manager.get_branch(Some(&id2));
3053 assert_eq!(branch.len(), 2);
3054 assert_eq!(branch[0].id, id1);
3055 assert_eq!(branch[1].id, id2);
3056 }
3057
3058 #[test]
3063 fn test_multiple_branches_at_same_point() {
3064 let mut manager = SessionManager::in_memory("/tmp");
3065 manager.append_message(user_msg("root"));
3066 let id2 = manager.append_message(bare_assistant_msg());
3067
3068 manager.branch(&id2).unwrap();
3070 let id_a = manager.append_message(user_msg("branch-A"));
3071
3072 manager.branch(&id2).unwrap();
3074 let id_b = manager.append_message(user_msg("branch-B"));
3075
3076 manager.branch(&id2).unwrap();
3078 let id_c = manager.append_message(user_msg("branch-C"));
3079
3080 let tree = manager.get_tree(Uuid::nil()).unwrap();
3081 let node2 = &tree[0].children[0];
3082 assert_eq!(node2.entry.id, id2);
3083 assert_eq!(node2.children.len(), 3);
3084
3085 let mut branch_ids: Vec<String> =
3086 node2.children.iter().map(|c| c.entry.id.clone()).collect();
3087 branch_ids.sort();
3088 let mut expected = vec![id_a, id_b, id_c];
3089 expected.sort();
3090 assert_eq!(branch_ids, expected);
3091 }
3092
3093 #[test]
3098 fn test_deep_branching() {
3099 let mut manager = SessionManager::in_memory("/tmp");
3100
3101 manager.append_message(user_msg("1"));
3103 let id2 = manager.append_message(bare_assistant_msg());
3104 let id3 = manager.append_message(user_msg("3"));
3105 manager.append_message(bare_assistant_msg());
3106
3107 manager.branch(&id2).unwrap();
3109 let id5 = manager.append_message(user_msg("5"));
3110 manager.append_message(bare_assistant_msg());
3111
3112 manager.branch(&id5).unwrap();
3114 manager.append_message(user_msg("7"));
3115
3116 let tree = manager.get_tree(Uuid::nil()).unwrap();
3117
3118 let node2 = &tree[0].children[0];
3120 assert_eq!(node2.children.len(), 2);
3121
3122 let node5 = node2.children.iter().find(|c| c.entry.id == id5).unwrap();
3123 assert_eq!(node5.children.len(), 2); let node3 = node2.children.iter().find(|c| c.entry.id == id3).unwrap();
3126 assert_eq!(node3.children.len(), 1); }
3128
3129 #[test]
3134 fn test_branch_with_summary_inserts_and_advances() {
3135 let mut manager = SessionManager::in_memory("/tmp");
3136 let id1 = manager.append_message(user_msg("1"));
3137 manager.append_message(bare_assistant_msg());
3138 manager.append_message(user_msg("3"));
3139
3140 let summary_id =
3141 manager.branch_with_summary(Some(&id1), "Summary of abandoned work", None, None);
3142 assert!(!summary_id.is_empty());
3143 assert_eq!(manager.get_leaf_id(), Some(summary_id.clone()));
3144
3145 let entries = manager.get_entries();
3147 let summary_entry = entries.iter().find(|e| e.id == summary_id).unwrap();
3148 assert_eq!(summary_entry.parent_id, Some(id1));
3149
3150 if let AgentMessage::BranchSummary { summary, .. } = &summary_entry.message {
3151 assert_eq!(summary, "Summary of abandoned work");
3152 } else {
3153 panic!("Expected BranchSummary");
3154 }
3155 }
3156
3157 #[test]
3162 fn test_build_session_context_returns_branch_messages() {
3163 let mut manager = SessionManager::in_memory("/tmp");
3164
3165 manager.append_message(user_msg("msg1"));
3167 let id2 = manager.append_message(bare_assistant_msg());
3168 manager.append_message(user_msg("msg3"));
3169
3170 manager.branch(&id2).unwrap();
3172 manager.append_message(assistant_msg("msg4-branch"));
3173
3174 let ctx = manager.build_session_context();
3175 assert_eq!(ctx.messages.len(), 3);
3177 assert!(ctx.messages[0].is_user());
3178 assert!(ctx.messages[1].is_assistant());
3179 assert!(ctx.messages[2].is_assistant());
3180 }
3181
3182 #[test]
3183 fn test_build_session_context_follows_branch_path() {
3184 let mut manager = SessionManager::in_memory("/tmp");
3187 manager.append_message(user_msg("start"));
3188 let id2 = manager.append_message(bare_assistant_msg());
3189 manager.append_message(user_msg("branch A"));
3190
3191 manager.branch(&id2).unwrap();
3193 manager.append_message(user_msg("branch B"));
3194
3195 let ctx = manager.build_session_context();
3196 assert_eq!(ctx.messages.len(), 3);
3197 let last = ctx.messages.last().unwrap();
3199 assert_eq!(last.content(), "branch B");
3200 }
3201
3202 #[test]
3203 fn test_build_session_context_includes_branch_summary() {
3204 let mut manager = SessionManager::in_memory("/tmp");
3205 manager.append_message(user_msg("start"));
3206 let id2 = manager.append_message(bare_assistant_msg());
3207 manager.append_message(user_msg("abandoned path"));
3208
3209 manager.branch_with_summary(Some(&id2), "Summary of abandoned work", None, None);
3211 manager.append_message(user_msg("new direction"));
3212
3213 let ctx = manager.build_session_context();
3214 assert!(ctx.messages.len() >= 3);
3216
3217 let has_summary = ctx.messages.iter().any(|m| {
3219 if let AgentMessage::BranchSummary { summary, .. } = m {
3220 summary == "Summary of abandoned work"
3221 } else {
3222 false
3223 }
3224 });
3225 assert!(has_summary, "Branch summary should be in context messages");
3226 }
3227
3228 #[test]
3229 fn test_build_session_context_with_compaction() {
3230 let mut manager = SessionManager::in_memory("/tmp");
3231
3232 let id1 = manager.append_message(user_msg("first"));
3234 manager.append_message(assistant_msg("response1"));
3235 manager.append_message(user_msg("second"));
3236 manager.append_message(assistant_msg("response2"));
3237
3238 manager.append_compaction("Summary of first two turns", &id1, 1000, None, None);
3240
3241 manager.append_message(user_msg("third"));
3243 manager.append_message(assistant_msg("response3"));
3244
3245 let ctx = manager.build_session_context();
3246 assert!(ctx.messages.len() >= 4); let compaction_entries = manager.get_compaction_entries();
3252 assert_eq!(compaction_entries.len(), 1);
3253 }
3254
3255 #[test]
3256 fn test_build_session_context_tracks_thinking_level() {
3257 let mut manager = SessionManager::in_memory("/tmp");
3258 manager.append_message(user_msg("hello"));
3259 manager.append_thinking_level_change("high");
3260 manager.append_message(assistant_msg("thinking hard"));
3261
3262 let ctx = manager.build_session_context();
3263 assert_eq!(ctx.thinking_level, "high");
3264 }
3265
3266 #[test]
3271 fn test_labels_in_tree_nodes() {
3272 let mut manager = SessionManager::in_memory("/tmp");
3273 let id1 = manager.append_message(user_msg("hello"));
3274 let id2 = manager.append_message(assistant_msg("hi"));
3275
3276 manager.add_label(&id1, "start").unwrap();
3277 manager.add_label(&id2, "response").unwrap();
3278
3279 let tree = manager.get_tree(Uuid::nil()).unwrap();
3280 let node1 = &tree[0];
3281 assert_eq!(node1.label, Some("start".to_string()));
3282
3283 let node2 = &node1.children[0];
3284 assert_eq!(node2.label, Some("response".to_string()));
3285 }
3286
3287 #[test]
3288 fn test_last_label_wins() {
3289 let mut manager = SessionManager::in_memory("/tmp");
3290 let id1 = manager.append_message(user_msg("hello"));
3291
3292 manager.add_label(&id1, "first").unwrap();
3293 manager.add_label(&id1, "second").unwrap();
3294 manager.add_label(&id1, "third").unwrap();
3295
3296 assert_eq!(manager.get_label(&id1), Some("third".to_string()));
3297 }
3298
3299 #[test]
3304 fn test_branch_throws_for_nonexistent() {
3305 let mut manager = SessionManager::in_memory("/tmp");
3306 manager.append_message(user_msg("hello"));
3307
3308 let result = manager.branch("nonexistent");
3309 assert!(result.is_err());
3310 }
3311
3312 #[test]
3317 fn test_labels_not_in_session_context() {
3318 let mut manager = SessionManager::in_memory("/tmp");
3319 let msg_id = manager.append_message(user_msg("hello"));
3320 manager.add_label(&msg_id, "checkpoint").unwrap();
3321
3322 let ctx = manager.build_session_context();
3323 assert_eq!(ctx.messages.len(), 1);
3325 assert!(ctx.messages[0].is_user());
3326 }
3327
3328 #[test]
3333 fn test_custom_entry_integrates_into_tree() {
3334 let mut manager = SessionManager::in_memory("/tmp");
3335 let msg_id = manager.append_message(user_msg("hello"));
3336 let custom_id =
3337 manager.append_custom_entry("my_data", Some(serde_json::json!({"foo": "bar"})));
3338 let msg2_id = manager.append_message(assistant_msg("response"));
3339
3340 let entries = manager.get_entries();
3341 let custom = entries.iter().find(|e| e.id == custom_id).unwrap();
3342 assert_eq!(custom.parent_id, Some(msg_id));
3343
3344 if let AgentMessage::Custom { custom_type, .. } = &custom.message {
3345 assert_eq!(custom_type, "my_data");
3346 } else {
3347 panic!("Expected Custom message");
3348 }
3349
3350 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
3351 assert_eq!(msg2.parent_id, Some(custom_id));
3352
3353 let ctx = manager.build_session_context();
3355 assert_eq!(ctx.messages.len(), 2);
3357 }
3358
3359 #[test]
3364 fn test_get_branch_empty_session() {
3365 let manager = SessionManager::in_memory("/tmp");
3366 let branch = manager.get_branch(None);
3367 assert!(branch.is_empty());
3368 }
3369
3370 #[test]
3371 fn test_get_tree_empty_session() {
3372 let manager = SessionManager::in_memory("/tmp");
3373 let tree = manager.get_tree(Uuid::nil()).unwrap();
3374 assert!(tree.is_empty());
3375 }
3376
3377 #[test]
3382 fn test_complex_tree_with_branches_and_compaction() {
3383 let mut manager = SessionManager::in_memory("/tmp");
3384
3385 manager.append_message(user_msg("start"));
3387 manager.append_message(assistant_msg("r1"));
3388 let id3 = manager.append_message(user_msg("q2"));
3389 manager.append_message(assistant_msg("r2"));
3390 manager.append_compaction("Compacted history", &id3, 1000, None, None);
3391 manager.append_message(user_msg("q3"));
3392 manager.append_message(assistant_msg("r3"));
3393
3394 manager.branch(&id3).unwrap();
3396 manager.append_message(user_msg("wrong path"));
3397 manager.append_message(assistant_msg("wrong response"));
3398
3399 manager.branch_with_summary(Some(&id3), "Tried wrong approach", None, None);
3401 manager.append_message(user_msg("better approach"));
3402
3403 let tree = manager.get_tree(Uuid::nil()).unwrap();
3404 assert_eq!(tree.len(), 1);
3406
3407 let root = &tree[0];
3409 assert!(root.entry.message.is_user());
3410 }
3411
3412 #[test]
3417 fn test_multiple_compactions_returns_latest() {
3418 let mut manager = SessionManager::in_memory("/tmp");
3419 let id1 = manager.append_message(user_msg("a"));
3420 manager.append_message(bare_assistant_msg());
3421 manager.append_compaction("First summary", &id1, 1000, None, None);
3422 manager.append_message(user_msg("c"));
3423 manager.append_message(bare_assistant_msg());
3424 manager.append_compaction("Second summary", &id1, 2000, None, None);
3425
3426 let compactions = manager.get_compaction_entries();
3428 assert_eq!(compactions.len(), 2);
3429
3430 let latest = manager.get_latest_compaction_entry();
3432 assert!(latest.is_some());
3433 }
3434
3435 #[test]
3440 fn test_get_all_compaction_entries() {
3441 let mut manager = SessionManager::in_memory("/tmp");
3442 let id1 = manager.append_message(user_msg("a"));
3443 manager.append_message(bare_assistant_msg());
3444 manager.append_compaction("First", &id1, 1000, None, None);
3445 manager.append_message(user_msg("b"));
3446 manager.append_message(bare_assistant_msg());
3447 manager.append_compaction("Second", &id1, 2000, None, None);
3448
3449 let compactions = manager.get_compaction_entries();
3450 assert_eq!(compactions.len(), 2);
3451 }
3452}