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
16use super::fs_util::atomic_write;
21
22pub type EntryId = Uuid;
24
25pub const CURRENT_SESSION_VERSION: i32 = 3;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SessionMeta {
35 pub id: Uuid,
37 pub parent_id: Option<Uuid>,
39 pub root_id: Option<Uuid>,
41 pub branch_point: Option<Uuid>,
43 pub created_at: i64,
45 pub updated_at: i64,
47 pub name: Option<String>,
49}
50
51impl SessionMeta {
52 pub fn new(id: Uuid) -> Self {
54 let now = Utc::now().timestamp_millis();
55 Self {
56 id,
57 parent_id: None,
58 root_id: None,
59 branch_point: None,
60 created_at: now,
61 updated_at: now,
62 name: None,
63 }
64 }
65
66 pub fn branched_from(parent_id: Uuid, root_id: Option<Uuid>, branch_point: Uuid) -> Self {
68 let now = Utc::now().timestamp_millis();
69 Self {
70 id: Uuid::new_v4(),
71 parent_id: Some(parent_id),
72 root_id: root_id.or(Some(parent_id)),
73 branch_point: Some(branch_point),
74 created_at: now,
75 updated_at: now,
76 name: None,
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct BranchInfo {
84 pub session_id: Uuid,
86 pub parent_session_id: Option<Uuid>,
88 pub root_session_id: Option<Uuid>,
90 pub branch_point_entry_id: Option<Uuid>,
92 pub parent_session_name: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SessionHeader {
103 #[serde(rename = "type")]
105 pub entry_type: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub version: Option<i32>,
109 pub id: String,
111 pub timestamp: String,
113 pub cwd: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub parent_session: Option<String>,
118}
119
120impl SessionHeader {
121 pub fn new(id: String, cwd: String, parent_session: Option<String>) -> Self {
123 Self {
124 entry_type: "session".to_string(),
125 version: Some(CURRENT_SESSION_VERSION),
126 id,
127 timestamp: Utc::now().to_rfc3339(),
128 cwd,
129 parent_session,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(untagged)]
141pub enum ContentValue {
142 String(String),
144 Blocks(Vec<ContentBlock>),
146}
147
148impl ContentValue {
149 pub fn as_str(&self) -> &str {
151 match self {
152 ContentValue::String(s) => s,
153 ContentValue::Blocks(blocks) => {
154 for block in blocks {
156 if let ContentBlock::Text { text } = block {
157 return text;
158 }
159 }
160 ""
161 }
162 }
163 }
164}
165
166impl From<String> for ContentValue {
167 fn from(s: String) -> Self {
168 ContentValue::String(s)
169 }
170}
171
172impl From<&str> for ContentValue {
173 fn from(s: &str) -> Self {
174 ContentValue::String(s.to_string())
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(tag = "type")]
181pub enum ContentBlock {
182 #[serde(rename = "text")]
184 Text {
185 text: String,
187 },
188 #[serde(rename = "image")]
190 Image {
191 data: String,
193 media_type: Option<String>,
195 },
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "role")]
205pub enum AgentMessage {
206 #[serde(rename = "user")]
208 User {
209 content: ContentValue,
211 },
212 #[serde(rename = "assistant")]
214 Assistant {
215 content: Vec<AssistantContentBlock>,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 provider: Option<String>,
220 #[serde(skip_serializing_if = "Option::is_none")]
222 model_id: Option<String>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 usage: Option<Usage>,
226 #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
228 stop_reason: Option<String>,
229 },
230 #[serde(rename = "toolResult")]
232 ToolResult {
233 content: ContentValue,
235 #[serde(rename = "toolCallId")]
237 tool_call_id: String,
238 },
239 #[serde(rename = "system")]
241 System {
242 content: ContentValue,
244 },
245 #[serde(rename = "bashExecution")]
247 BashExecution {
248 command: String,
250 output: String,
252 #[serde(rename = "exitCode")]
254 exit_code: Option<i32>,
255 cancelled: bool,
257 truncated: bool,
259 #[serde(rename = "fullOutputPath", skip_serializing_if = "Option::is_none")]
261 full_output_path: Option<String>,
262 #[serde(rename = "excludeFromContext", skip_serializing_if = "Option::is_none")]
264 exclude_from_context: Option<bool>,
265 timestamp: i64,
267 },
268 #[serde(rename = "custom")]
270 Custom {
271 #[serde(rename = "customType")]
273 custom_type: String,
274 content: ContentValue,
276 display: bool,
278 #[serde(skip_serializing_if = "Option::is_none")]
280 details: Option<serde_json::Value>,
281 timestamp: i64,
283 },
284 #[serde(rename = "branchSummary")]
286 BranchSummary {
287 summary: String,
289 #[serde(rename = "fromId")]
291 from_id: String,
292 timestamp: i64,
294 },
295 #[serde(rename = "compactionSummary")]
297 CompactionSummary {
298 summary: String,
300 #[serde(rename = "tokensBefore")]
302 tokens_before: i64,
303 timestamp: i64,
305 },
306}
307
308impl AgentMessage {
309 pub fn content(&self) -> String {
311 match self {
312 AgentMessage::User { content } => content.as_str().to_string(),
313 AgentMessage::Assistant { content, .. } => {
314 let estimated_len = content
315 .iter()
316 .map(|b| match b {
317 AssistantContentBlock::Text { text: t } => t.len(),
318 _ => 0,
319 })
320 .sum::<usize>();
321 let mut text = String::with_capacity(estimated_len.max(256));
322 for block in content {
323 if let AssistantContentBlock::Text { text: t } = block {
324 text.push_str(t)
325 }
326 }
327 text
328 }
329 AgentMessage::ToolResult { content, .. } => content.as_str().to_string(),
330 AgentMessage::System { content } => content.as_str().to_string(),
331 AgentMessage::BashExecution { output, .. } => output.clone(),
332 AgentMessage::Custom { content, .. } => content.as_str().to_string(),
333 AgentMessage::BranchSummary { summary, .. } => summary.clone(),
334 AgentMessage::CompactionSummary { summary, .. } => summary.clone(),
335 }
336 }
337
338 pub fn is_user(&self) -> bool {
340 matches!(self, AgentMessage::User { .. })
341 }
342
343 pub fn is_assistant(&self) -> bool {
345 matches!(self, AgentMessage::Assistant { .. })
346 }
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(tag = "type")]
352pub enum AssistantContentBlock {
353 #[serde(rename = "text")]
355 Text {
356 text: String,
358 },
359 #[serde(rename = "thinking")]
361 Thinking {
362 thinking: String,
364 },
365 #[serde(rename = "toolCall")]
367 ToolCall {
368 id: String,
370 name: String,
372 arguments: serde_json::Value,
374 },
375 #[serde(rename = "toolPlan")]
377 ToolPlan {
378 content: String,
380 #[serde(rename = "toolCallId")]
382 tool_call_id: String,
383 },
384 #[serde(rename = "image")]
386 ImageResult {
387 data: String,
389 media_type: String,
391 },
392 #[serde(rename = "refusal")]
394 Refusal {
395 content: String,
397 },
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct Usage {
403 #[serde(rename = "inputTokens", skip_serializing_if = "Option::is_none")]
405 pub input: Option<i64>,
406 #[serde(rename = "outputTokens", skip_serializing_if = "Option::is_none")]
408 pub output: Option<i64>,
409 #[serde(rename = "cacheReadTokens", skip_serializing_if = "Option::is_none")]
411 pub cache_read: Option<i64>,
412 #[serde(rename = "cacheWriteTokens", skip_serializing_if = "Option::is_none")]
414 pub cache_write: Option<i64>,
415 #[serde(rename = "totalTokens", skip_serializing_if = "Option::is_none")]
417 pub total_tokens: Option<i64>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct SessionEntryBase {
427 #[serde(rename = "type")]
429 pub entry_type: String,
430 pub id: String,
432 #[serde(rename = "parentId")]
434 pub parent_id: Option<String>,
435 pub timestamp: String,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct SessionMessageEntry {
442 #[serde(flatten)]
444 pub base: SessionEntryBase,
445 pub message: AgentMessage,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct ThinkingLevelChangeEntry {
452 #[serde(flatten)]
454 pub base: SessionEntryBase,
455 #[serde(rename = "thinkingLevel")]
457 pub thinking_level: String,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct ModelChangeEntry {
463 #[serde(flatten)]
465 pub base: SessionEntryBase,
466 pub provider: String,
468 #[serde(rename = "modelId")]
470 pub model_id: String,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct CompactionEntry {
476 #[serde(flatten)]
478 pub base: SessionEntryBase,
479 pub summary: String,
481 #[serde(rename = "firstKeptEntryId")]
483 pub first_kept_entry_id: String,
484 #[serde(rename = "tokensBefore")]
486 pub tokens_before: i64,
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub details: Option<serde_json::Value>,
490 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
492 pub from_hook: Option<bool>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct BranchSummaryEntry {
498 #[serde(flatten)]
500 pub base: SessionEntryBase,
501 #[serde(rename = "fromId")]
503 pub from_id: String,
504 pub summary: String,
506 #[serde(skip_serializing_if = "Option::is_none")]
508 pub details: Option<serde_json::Value>,
509 #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
511 pub from_hook: Option<bool>,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct CustomEntry {
517 #[serde(flatten)]
519 pub base: SessionEntryBase,
520 #[serde(rename = "customType")]
522 pub custom_type: String,
523 #[serde(skip_serializing_if = "Option::is_none")]
525 pub data: Option<serde_json::Value>,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct LabelEntry {
531 #[serde(flatten)]
533 pub base: SessionEntryBase,
534 #[serde(rename = "targetId")]
536 pub target_id: String,
537 pub label: Option<String>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct SessionInfoEntry {
544 #[serde(flatten)]
546 pub base: SessionEntryBase,
547 pub name: Option<String>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct CustomMessageEntry {
554 #[serde(flatten)]
556 pub base: SessionEntryBase,
557 #[serde(rename = "customType")]
559 pub custom_type: String,
560 pub content: ContentValue,
562 #[serde(skip_serializing_if = "Option::is_none")]
564 pub details: Option<serde_json::Value>,
565 pub display: bool,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
571#[serde(untagged)]
572pub enum SessionEntryEnum {
573 Message(SessionMessageEntry),
575 ThinkingLevelChange(ThinkingLevelChangeEntry),
577 ModelChange(ModelChangeEntry),
579 Compaction(CompactionEntry),
581 BranchSummary(BranchSummaryEntry),
583 Custom(CustomEntry),
585 Label(LabelEntry),
587 SessionInfo(SessionInfoEntry),
589 CustomMessage(CustomMessageEntry),
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct SessionEntry {
597 pub id: String,
599 pub parent_id: Option<String>,
601 pub timestamp: i64,
603 pub message: AgentMessage,
605}
606
607impl SessionEntry {
608 pub fn new(message: AgentMessage) -> Self {
610 Self {
611 id: Uuid::new_v4().to_string(),
612 parent_id: None,
613 timestamp: Utc::now().timestamp_millis(),
614 message,
615 }
616 }
617
618 pub fn simple_message(role: &str, content: &str) -> Self {
620 use crate::store::session::ContentValue;
621 let message = match role {
622 "user" => AgentMessage::User {
623 content: ContentValue::String(content.to_string()),
624 },
625 "assistant" => AgentMessage::Assistant {
626 content: vec![AssistantContentBlock::Text {
627 text: content.to_string(),
628 }],
629 provider: None,
630 model_id: None,
631 usage: None,
632 stop_reason: None,
633 },
634 "system" => AgentMessage::System {
635 content: ContentValue::String(content.to_string()),
636 },
637 _ => AgentMessage::System {
638 content: ContentValue::String(content.to_string()),
639 },
640 };
641 Self::new(message)
642 }
643
644 pub fn branched(message: AgentMessage, parent_id: &str) -> Self {
646 Self {
647 id: Uuid::new_v4().to_string(),
648 parent_id: Some(parent_id.to_string()),
649 timestamp: Utc::now().timestamp_millis(),
650 message,
651 }
652 }
653
654 pub fn content(&self) -> String {
656 self.message.content()
657 }
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662#[serde(untagged)]
663pub enum FileEntry {
664 Header(SessionHeader),
666 Entry(SessionEntryEnum),
668}
669
670#[derive(Debug, Clone)]
676pub struct SessionContext {
677 pub messages: Vec<AgentMessage>,
679 pub thinking_level: String,
681 pub model: Option<ModelInfo>,
683}
684
685#[derive(Debug, Clone)]
687pub struct ModelInfo {
688 pub provider: String,
690 pub model_id: String,
692}
693
694#[derive(Debug, Clone)]
700pub struct SessionInfo {
701 pub path: String,
703 pub id: String,
705 pub cwd: String,
707 pub name: Option<String>,
709 pub parent_session_path: Option<String>,
711 pub created: DateTime<Utc>,
713 pub modified: DateTime<Utc>,
715 pub message_count: i64,
717 pub first_message: String,
719 pub all_messages_text: String,
721}
722
723#[derive(Debug, Clone)]
729pub struct SessionTreeNode {
730 pub entry: SessionEntry,
732 pub children: Vec<SessionTreeNode>,
734 pub label: Option<String>,
736 pub label_timestamp: Option<String>,
738}
739
740fn generate_id(by_id: &HashSet<String>) -> String {
745 for _ in 0..100 {
746 let id = Uuid::new_v4().to_string()[..8].to_string();
747 if !by_id.contains(&id) {
748 return id;
749 }
750 }
751 Uuid::new_v4().to_string()
753}
754
755fn migrate_v1_to_v2(entries: &mut [FileEntry]) {
761 let mut ids = HashSet::new();
762 let mut prev_id: Option<String> = None;
763
764 for entry in entries.iter_mut() {
765 match entry {
766 FileEntry::Header(header) => {
767 header.version = Some(2);
768 }
769 FileEntry::Entry(entry) => {
770 let id = match entry {
771 SessionEntryEnum::Message(e) => {
772 e.base.id = generate_id(&ids);
773 e.base.parent_id = prev_id.clone();
774 e.base.entry_type = "message".to_string();
775 prev_id = Some(e.base.id.clone());
776 e.base.id.clone()
777 }
778 SessionEntryEnum::ThinkingLevelChange(e) => {
779 e.base.id = generate_id(&ids);
780 e.base.parent_id = prev_id.clone();
781 e.base.entry_type = "thinking_level_change".to_string();
782 prev_id = Some(e.base.id.clone());
783 e.base.id.clone()
784 }
785 SessionEntryEnum::ModelChange(e) => {
786 e.base.id = generate_id(&ids);
787 e.base.parent_id = prev_id.clone();
788 e.base.entry_type = "model_change".to_string();
789 prev_id = Some(e.base.id.clone());
790 e.base.id.clone()
791 }
792 SessionEntryEnum::Compaction(e) => {
793 e.base.id = generate_id(&ids);
794 e.base.parent_id = prev_id.clone();
795 e.base.entry_type = "compaction".to_string();
796 prev_id = Some(e.base.id.clone());
797 e.base.id.clone()
798 }
799 SessionEntryEnum::BranchSummary(e) => {
800 e.base.id = generate_id(&ids);
801 e.base.parent_id = prev_id.clone();
802 e.base.entry_type = "branch_summary".to_string();
803 prev_id = Some(e.base.id.clone());
804 e.base.id.clone()
805 }
806 SessionEntryEnum::Custom(e) => {
807 e.base.id = generate_id(&ids);
808 e.base.parent_id = prev_id.clone();
809 e.base.entry_type = "custom".to_string();
810 prev_id = Some(e.base.id.clone());
811 e.base.id.clone()
812 }
813 SessionEntryEnum::Label(e) => {
814 e.base.id = generate_id(&ids);
815 e.base.parent_id = prev_id.clone();
816 e.base.entry_type = "label".to_string();
817 prev_id = Some(e.base.id.clone());
818 e.base.id.clone()
819 }
820 SessionEntryEnum::SessionInfo(e) => {
821 e.base.id = generate_id(&ids);
822 e.base.parent_id = prev_id.clone();
823 e.base.entry_type = "session_info".to_string();
824 prev_id = Some(e.base.id.clone());
825 e.base.id.clone()
826 }
827 SessionEntryEnum::CustomMessage(e) => {
828 e.base.id = generate_id(&ids);
829 e.base.parent_id = prev_id.clone();
830 e.base.entry_type = "custom_message".to_string();
831 prev_id = Some(e.base.id.clone());
832 e.base.id.clone()
833 }
834 };
835 ids.insert(id);
836 }
837 }
838 }
839}
840
841fn migrate_v2_to_v3(entries: &mut [FileEntry]) {
843 for entry in entries.iter_mut() {
844 match entry {
845 FileEntry::Header(header) => {
846 header.version = Some(3);
847 }
848 FileEntry::Entry(_) => {
849 }
851 }
852 }
853}
854
855fn migrate_to_current_version(entries: &mut [FileEntry]) -> bool {
857 let header = entries.iter().find_map(|e| match e {
858 FileEntry::Header(h) => Some(h),
859 _ => None,
860 });
861 let version = header.and_then(|h| h.version).unwrap_or(1);
862
863 if version >= CURRENT_SESSION_VERSION {
864 return false;
865 }
866
867 if version < 2 {
868 migrate_v1_to_v2(entries);
869 }
870 if version < 3 {
871 migrate_v2_to_v3(entries);
872 }
873
874 true
875}
876
877pub struct SessionManager {
887 session_id: String,
888 session_file: Option<String>,
889 session_dir: String,
890 cwd: String,
891 persist: bool,
892 flushed: bool,
893 persisted_count: RwLock<usize>,
896 file_entries: RwLock<Vec<FileEntry>>,
897 by_id: RwLock<HashMap<String, SessionEntry>>,
898 labels_by_id: RwLock<HashMap<String, String>>,
899 label_timestamps_by_id: RwLock<HashMap<String, String>>,
900 leaf_id: RwLock<Option<String>>,
901}
902
903impl Clone for SessionManager {
905 fn clone(&self) -> Self {
906 Self {
907 session_id: self.session_id.clone(),
908 session_file: self.session_file.clone(),
909 session_dir: self.session_dir.clone(),
910 cwd: self.cwd.clone(),
911 persist: self.persist,
912 flushed: self.flushed,
913 persisted_count: RwLock::new(*self.persisted_count.read()),
914 file_entries: RwLock::new(self.file_entries.read().clone()),
915 by_id: RwLock::new(self.by_id.read().clone()),
916 labels_by_id: RwLock::new(self.labels_by_id.read().clone()),
917 label_timestamps_by_id: RwLock::new(self.label_timestamps_by_id.read().clone()),
918 leaf_id: RwLock::new(self.leaf_id.read().clone()),
919 }
920 }
921}
922
923impl SessionManager {
924 pub fn create(cwd: &str, session_dir: Option<&str>) -> Self {
926 let dir = session_dir
927 .map(|s| s.to_string())
928 .unwrap_or_else(|| get_default_session_dir(cwd));
929
930 let mut manager = Self::new_internal(cwd, &dir, None, true);
931 manager.persist = true;
932 manager
933 }
934
935 pub fn open(path: &str, session_dir: Option<&str>, cwd_override: Option<&str>) -> Self {
937 let entries = load_entries_from_file(path);
938 let header = entries.iter().find_map(|e| match e {
939 FileEntry::Header(h) => Some(h),
940 _ => None,
941 });
942 let cwd = cwd_override
943 .map(|s| s.to_string())
944 .or_else(|| header.as_ref().map(|h| h.cwd.clone()))
945 .unwrap_or_else(|| {
946 std::env::current_dir()
947 .unwrap_or_else(|_| PathBuf::from("."))
948 .to_string_lossy()
949 .to_string()
950 });
951 let dir = session_dir.map(|s| s.to_string()).unwrap_or_else(|| {
952 Path::new(path)
953 .parent()
954 .map(|p| p.to_string_lossy().to_string())
955 .unwrap_or_else(|| ".".to_string())
956 });
957
958 let mut manager = Self::new_internal(&cwd, &dir, Some(path), true);
959 manager.persist = true;
960 manager
961 }
962
963 pub fn continue_recent(cwd: &str, session_dir: Option<&str>) -> Self {
965 let dir = session_dir
966 .map(|s| s.to_string())
967 .unwrap_or_else(|| get_default_session_dir(cwd));
968
969 if let Some(most_recent) = find_most_recent_session(&dir) {
970 return Self::open(&most_recent, None, None);
971 }
972 Self::create(cwd, None)
973 }
974
975 pub fn in_memory(cwd: &str) -> Self {
977 let cwd = cwd.to_string();
978 Self::new_internal(&cwd, "", None, false)
979 }
980
981 fn new_internal(
982 cwd: &str,
983 session_dir: &str,
984 session_file: Option<&str>,
985 persist: bool,
986 ) -> Self {
987 let cwd = cwd.to_string();
988 let session_dir = session_dir.to_string();
989
990 if persist && !session_dir.is_empty() && !Path::new(&session_dir).exists() {
991 let _ = fs::create_dir_all(&session_dir);
992 }
993
994 let mut manager = Self {
995 session_id: Uuid::new_v4().to_string(),
996 session_file: session_file.map(|s| s.to_string()),
997 session_dir,
998 cwd,
999 persist,
1000 flushed: false,
1001 persisted_count: RwLock::new(0),
1002 file_entries: RwLock::new(Vec::new()),
1003 by_id: RwLock::new(HashMap::new()),
1004 labels_by_id: RwLock::new(HashMap::new()),
1005 label_timestamps_by_id: RwLock::new(HashMap::new()),
1006 leaf_id: RwLock::new(None),
1007 };
1008
1009 if let Some(file) = session_file {
1010 manager.set_session_file(file);
1011 } else {
1012 manager.new_session(None);
1013 }
1014
1015 manager
1016 }
1017
1018 pub fn set_session_file(&mut self, session_file: &str) {
1020 let path = Path::new(session_file)
1021 .canonicalize()
1022 .unwrap_or_else(|_| PathBuf::from(session_file));
1023 let path_str = path.to_string_lossy().to_string();
1024 self.session_file = Some(path_str.clone());
1025
1026 if path.exists() {
1027 let mut entries = load_entries_from_file(&path_str);
1028
1029 if entries.is_empty() {
1031 let explicit_path = self.session_file.take();
1032 self.new_session(None);
1033 self.session_file = explicit_path;
1034 self._rewrite_file();
1035 self.flushed = true;
1036 return;
1037 }
1038
1039 let header = entries.iter().find_map(|e| match e {
1040 FileEntry::Header(h) => Some(h),
1041 _ => None,
1042 });
1043 self.session_id = header
1044 .map(|h| h.id.clone())
1045 .unwrap_or_else(|| Uuid::new_v4().to_string());
1046
1047 if migrate_to_current_version(&mut entries) {
1048 self._rewrite_file();
1049 }
1050
1051 *self.file_entries.write() = entries;
1052 self._build_index();
1053 self.flushed = true;
1054 } else {
1055 let explicit_path = self.session_file.take();
1056 self.new_session(None);
1057 self.session_file = explicit_path;
1058 }
1059 }
1060
1061 pub fn new_session(&mut self, options: Option<NewSessionOptions>) {
1063 self.session_id = options
1064 .as_ref()
1065 .and_then(|o| o.id.clone())
1066 .unwrap_or_else(|| Uuid::new_v4().to_string());
1067 let timestamp = Utc::now().to_rfc3339();
1068 let header = SessionHeader::new(
1069 self.session_id.clone(),
1070 self.cwd.clone(),
1071 options.and_then(|o| o.parent_session),
1072 );
1073
1074 self.file_entries = RwLock::new(vec![FileEntry::Header(header)]);
1075 self.by_id.write().clear();
1076 self.labels_by_id.write().clear();
1077 self.label_timestamps_by_id.write().clear();
1078 *self.leaf_id.write() = None;
1079 *self.persisted_count.write() = 0;
1080 self.flushed = false;
1081
1082 if self.persist {
1083 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1084 let short_id = &self.session_id[..8];
1085 self.session_file = Some(format!(
1086 "{}/{}_{}.jsonl",
1087 self.session_dir, file_timestamp, short_id
1088 ));
1089 }
1090 }
1091
1092 fn _build_index(&mut self) {
1093 let mut by_id = self.by_id.write();
1094 let mut labels = self.labels_by_id.write();
1095 let mut label_timestamps = self.label_timestamps_by_id.write();
1096 let mut leaf_id = self.leaf_id.write();
1097
1098 by_id.clear();
1099 labels.clear();
1100 label_timestamps.clear();
1101 *leaf_id = None;
1102
1103 for entry in self.file_entries.read().iter() {
1104 if let FileEntry::Entry(e) = entry {
1105 if let Some(session_entry) = convert_to_session_entry(e) {
1107 by_id.insert(session_entry.id.clone(), session_entry.clone());
1108 *leaf_id = Some(session_entry.id.clone());
1109 }
1110
1111 if let SessionEntryEnum::Label(l) = e {
1113 if let Some(ref label) = l.label {
1114 labels.insert(l.target_id.clone(), label.clone());
1115 label_timestamps.insert(l.target_id.clone(), l.base.timestamp.clone());
1116 } else {
1117 labels.remove(&l.target_id);
1118 label_timestamps.remove(&l.target_id);
1119 }
1120 }
1121 }
1122 }
1123 }
1124
1125 fn _rewrite_file(&self) {
1126 if !self.persist || self.session_file.is_none() {
1127 return;
1128 }
1129
1130 let file = match self.session_file.as_ref() {
1131 Some(f) => f,
1132 None => return,
1133 };
1134
1135 let content: String = self
1136 .file_entries
1137 .read()
1138 .iter()
1139 .map(|e| serde_json::to_string(e).unwrap_or_default())
1140 .collect::<Vec<_>>()
1141 .join("\n")
1142 + "\n";
1143
1144 if let Err(e) = atomic_write(Path::new(file), &content) {
1145 tracing::warn!("Failed to rewrite session file {}: {}", file, e);
1146 }
1147 }
1148
1149 pub fn is_persisted(&self) -> bool {
1151 self.persist
1152 }
1153
1154 pub fn validate_session_id(id: &str) -> bool {
1159 Uuid::parse_str(id).is_ok()
1160 }
1161
1162 pub fn is_readonly(&self) -> bool {
1170 if !self.persist {
1171 return false;
1173 }
1174 if let Some(ref file) = self.session_file {
1175 let path = Path::new(file);
1176 if path.exists()
1177 && let Ok(metadata) = fs::metadata(path)
1178 {
1179 #[cfg(unix)]
1180 {
1181 use std::os::unix::fs::PermissionsExt;
1182 let perm = metadata.permissions().mode();
1183 return perm & 0o200 == 0;
1185 }
1186 #[cfg(not(unix))]
1187 {
1188 let _ = metadata;
1189 return false;
1190 }
1191 }
1192 }
1193 false
1194 }
1195
1196 pub fn can_append(&self) -> bool {
1200 !self.is_readonly() && self.persist
1201 }
1202
1203 pub fn persisted_count(&self) -> usize {
1205 *self.persisted_count.read()
1206 }
1207
1208 pub fn set_persisted_count(&self, count: usize) {
1210 *self.persisted_count.write() = count;
1211 }
1212
1213 pub fn get_cwd(&self) -> String {
1215 self.cwd.clone()
1216 }
1217
1218 pub fn get_session_dir(&self) -> String {
1220 self.session_dir.clone()
1221 }
1222
1223 pub fn get_session_id(&self) -> String {
1225 self.session_id.clone()
1226 }
1227
1228 pub fn get_session_file(&self) -> Option<String> {
1230 self.session_file.clone()
1231 }
1232
1233 pub fn cleanup_if_empty(&self) {
1237 if !self.persist {
1238 return;
1239 }
1240 let Some(file) = &self.session_file else {
1241 return;
1242 };
1243
1244 let has_user = self.file_entries.read().iter().any(|e| {
1245 matches!(
1246 e,
1247 FileEntry::Entry(SessionEntryEnum::Message(m)) if m.message.is_user()
1248 )
1249 });
1250
1251 if !has_user {
1252 let path = Path::new(file);
1253 if path.exists() {
1254 if let Err(e) = fs::remove_file(path) {
1255 tracing::warn!("Failed to remove empty session file {}: {}", file, e);
1256 } else {
1257 tracing::debug!("Removed empty session file: {}", file);
1258 }
1259 }
1260 }
1261 }
1262
1263 fn _persist(&mut self, entry: &SessionEntry) {
1264 if !self.persist {
1265 return;
1266 }
1267 let Some(file) = &self.session_file else {
1268 return;
1269 };
1270
1271 let has_assistant = self.file_entries.read().iter().any(|e| {
1276 matches!(
1277 e,
1278 FileEntry::Entry(SessionEntryEnum::Message(m))
1279 if m.message.is_assistant()
1280 )
1281 });
1282
1283 if !has_assistant {
1284 self.flushed = false;
1288 return;
1289 }
1290
1291 let mut handle = match fs::OpenOptions::new().create(true).append(true).open(file) {
1292 Ok(h) => h,
1293 Err(e) => {
1294 tracing::warn!("Failed to open session file for append {}: {}", file, e);
1295 return;
1296 }
1297 };
1298
1299 if !self.flushed {
1300 for e in self.file_entries.read().iter() {
1301 if let Ok(line) = serde_json::to_string(e) {
1302 let _ = writeln!(&mut handle, "{}", line);
1303 } else {
1304 tracing::warn!("Failed to serialize session entry, skipping");
1305 }
1306 }
1307 self.flushed = true;
1308 } else {
1309 let file_entry = convert_from_session_entry(entry);
1311 if let Ok(line) = serde_json::to_string(&file_entry) {
1312 let _ = writeln!(&mut handle, "{}", line);
1313 } else {
1314 tracing::warn!("Failed to serialize incremental session entry, skipping");
1315 }
1316 }
1317 }
1318
1319 fn _append_entry(&mut self, entry: SessionEntry) {
1323 let file_entry = convert_from_session_entry(&entry);
1324 self.file_entries.write().push(FileEntry::Entry(file_entry));
1325 self.by_id.write().insert(entry.id.clone(), entry.clone());
1326 *self.leaf_id.write() = Some(entry.id.clone());
1327 self._persist(&entry);
1328 }
1329
1330 pub fn append_message(&mut self, message: AgentMessage) -> String {
1332 let leaf = self.leaf_id.read().clone();
1333 let id = Uuid::new_v4().to_string();
1334 let entry = SessionEntry {
1335 id: id.clone(),
1336 parent_id: leaf,
1337 timestamp: Utc::now().timestamp_millis(),
1338 message,
1339 };
1340 self._append_entry(entry);
1341 id
1342 }
1343
1344 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1346 let leaf = self.leaf_id.read().clone();
1347 let id = Uuid::new_v4().to_string();
1348 let entry = SessionEntry {
1349 id: id.clone(),
1350 parent_id: leaf,
1351 timestamp: Utc::now().timestamp_millis(),
1352 message: AgentMessage::Custom {
1353 custom_type: "thinking_level_change".to_string(),
1354 content: ContentValue::String(thinking_level.to_string()),
1355 display: false,
1356 details: None,
1357 timestamp: Utc::now().timestamp_millis(),
1358 },
1359 };
1360 self._append_entry(entry);
1361 id
1362 }
1363
1364 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1366 let leaf = self.leaf_id.read().clone();
1367 let id = Uuid::new_v4().to_string();
1368 let entry = SessionEntry {
1369 id: id.clone(),
1370 parent_id: leaf,
1371 timestamp: Utc::now().timestamp_millis(),
1372 message: AgentMessage::Custom {
1373 custom_type: "model_change".to_string(),
1374 content: ContentValue::String(format!("{}:{}", provider, model_id)),
1375 display: false,
1376 details: None,
1377 timestamp: Utc::now().timestamp_millis(),
1378 },
1379 };
1380 self._append_entry(entry);
1381 id
1382 }
1383
1384 pub fn append_compaction(
1386 &mut self,
1387 summary: &str,
1388 _first_kept_entry_id: &str,
1389 tokens_before: i64,
1390 _details: Option<serde_json::Value>,
1391 _from_hook: Option<bool>,
1392 ) -> String {
1393 let leaf = self.leaf_id.read().clone();
1394 let id = Uuid::new_v4().to_string();
1395 let entry = SessionEntry {
1396 id: id.clone(),
1397 parent_id: leaf,
1398 timestamp: Utc::now().timestamp_millis(),
1399 message: AgentMessage::CompactionSummary {
1400 summary: summary.to_string(),
1401 tokens_before,
1402 timestamp: Utc::now().timestamp_millis(),
1403 },
1404 };
1405 self._append_entry(entry);
1406 id
1407 }
1408
1409 pub fn append_custom_entry(
1411 &mut self,
1412 custom_type: &str,
1413 data: Option<serde_json::Value>,
1414 ) -> String {
1415 let leaf = self.leaf_id.read().clone();
1416 let id = Uuid::new_v4().to_string();
1417 let entry = SessionEntry {
1418 id: id.clone(),
1419 parent_id: leaf,
1420 timestamp: Utc::now().timestamp_millis(),
1421 message: AgentMessage::Custom {
1422 custom_type: custom_type.to_string(),
1423 content: data
1424 .as_ref()
1425 .map(|d| ContentValue::String(d.to_string()))
1426 .unwrap_or(ContentValue::String(String::new())),
1427 display: false,
1428 details: data.clone(),
1429 timestamp: Utc::now().timestamp_millis(),
1430 },
1431 };
1432 self._append_entry(entry);
1433 id
1434 }
1435
1436 pub fn append_session_info(&mut self, name: &str) -> String {
1438 let leaf = self.leaf_id.read().clone();
1439 let id = Uuid::new_v4().to_string();
1440 let entry = SessionEntry {
1441 id: id.clone(),
1442 parent_id: leaf,
1443 timestamp: Utc::now().timestamp_millis(),
1444 message: AgentMessage::Custom {
1445 custom_type: "session_info".to_string(),
1446 content: ContentValue::String(name.trim().to_string()),
1447 display: false,
1448 details: None,
1449 timestamp: Utc::now().timestamp_millis(),
1450 },
1451 };
1452 self._append_entry(entry);
1453 id
1454 }
1455
1456 pub fn get_session_name(&self) -> Option<String> {
1458 let entries = self.get_entries();
1459 for entry in entries.iter().rev() {
1460 if let AgentMessage::Custom {
1461 custom_type,
1462 content,
1463 ..
1464 } = &entry.message
1465 && custom_type == "session_info"
1466 {
1467 return Some(content.as_str().trim().to_string()).filter(|s| !s.is_empty());
1468 }
1469 }
1470 None
1471 }
1472
1473 pub fn append_custom_message_entry(
1475 &mut self,
1476 custom_type: &str,
1477 content: ContentValue,
1478 display: bool,
1479 details: Option<serde_json::Value>,
1480 ) -> String {
1481 let leaf = self.leaf_id.read().clone();
1482 let id = Uuid::new_v4().to_string();
1483 let entry = SessionEntry {
1484 id: id.clone(),
1485 parent_id: leaf,
1486 timestamp: Utc::now().timestamp_millis(),
1487 message: AgentMessage::Custom {
1488 custom_type: custom_type.to_string(),
1489 content,
1490 display,
1491 details,
1492 timestamp: Utc::now().timestamp_millis(),
1493 },
1494 };
1495 self._append_entry(entry);
1496 id
1497 }
1498
1499 pub fn get_leaf_id(&self) -> Option<String> {
1505 self.leaf_id.read().clone()
1506 }
1507
1508 pub fn set_leaf_from_entry(&self, entry_id: &str) -> Result<(), String> {
1515 if !self.by_id.read().contains_key(entry_id) {
1516 return Err(format!("Entry {} not found", entry_id));
1517 }
1518 *self.leaf_id.write() = Some(entry_id.to_string());
1519 Ok(())
1520 }
1521
1522 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1524 self.leaf_id
1525 .read()
1526 .as_ref()
1527 .and_then(|id| self.by_id.read().get(id).cloned())
1528 }
1529
1530 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
1532 self.by_id.read().get(id).cloned()
1533 }
1534
1535 pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1537 self.by_id
1538 .read()
1539 .values()
1540 .filter(|e| e.parent_id.as_deref() == Some(parent_id))
1541 .cloned()
1542 .collect()
1543 }
1544
1545 pub fn get_parent(&self, id: &str) -> Option<SessionEntry> {
1547 self.by_id
1548 .read()
1549 .get(id)
1550 .and_then(|e| e.parent_id.as_deref())
1551 .and_then(|pid| self.by_id.read().get(pid).cloned())
1552 }
1553
1554 pub fn get_label(&self, id: &str) -> Option<String> {
1556 self.labels_by_id.read().get(id).cloned()
1557 }
1558
1559 pub fn append_label_change(
1561 &mut self,
1562 target_id: &str,
1563 label: Option<&str>,
1564 ) -> Result<String, String> {
1565 if !self.by_id.read().contains_key(target_id) {
1566 return Err(format!("Entry {} not found", target_id));
1567 }
1568
1569 let leaf = self.leaf_id.read().clone();
1570 let id = Uuid::new_v4().to_string();
1571 let entry = SessionEntry {
1572 id: id.clone(),
1573 parent_id: leaf,
1574 timestamp: Utc::now().timestamp_millis(),
1575 message: AgentMessage::Custom {
1576 custom_type: "label".to_string(),
1577 content: ContentValue::String(label.unwrap_or("").to_string()),
1578 display: false,
1579 details: Some(serde_json::json!({ "targetId": target_id })),
1580 timestamp: Utc::now().timestamp_millis(),
1581 },
1582 };
1583
1584 self._append_entry(entry);
1585
1586 if let Some(l) = label {
1587 self.labels_by_id
1588 .write()
1589 .insert(target_id.to_string(), l.to_string());
1590 self.label_timestamps_by_id
1591 .write()
1592 .insert(target_id.to_string(), Utc::now().to_rfc3339());
1593 } else {
1594 self.labels_by_id.write().remove(target_id);
1595 self.label_timestamps_by_id.write().remove(target_id);
1596 }
1597
1598 Ok(id)
1599 }
1600
1601 pub fn get_branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1603 let mut path = Vec::new();
1604 let leaf_fallback = self.leaf_id.read().clone();
1605 let start_id = from_id.or(leaf_fallback.as_deref());
1606 let Some(start_id) = start_id else {
1607 return path;
1608 };
1609
1610 let by_id = self.by_id.read();
1612 let mut current = by_id.get(start_id).cloned();
1613 while let Some(entry) = current {
1614 path.insert(0, entry.clone());
1615 current = entry
1616 .parent_id
1617 .as_ref()
1618 .and_then(|pid| by_id.get(pid).cloned());
1619 }
1620 path
1621 }
1622
1623 pub fn get_path_to_root(&self, from_id: &str) -> Vec<SessionEntry> {
1625 self.get_branch(Some(from_id))
1626 }
1627
1628 pub fn get_ancestry(&self, from_id: &str) -> Vec<SessionEntry> {
1630 self.get_branch(Some(from_id))
1631 }
1632
1633 pub fn get_depth(&self, id: &str) -> i64 {
1635 let mut depth = 0;
1636 let mut current = self.by_id.read().get(id).cloned();
1637 while let Some(entry) = current {
1638 depth += 1;
1639 current = entry
1640 .parent_id
1641 .as_ref()
1642 .and_then(|pid| self.by_id.read().get(pid).cloned());
1643 }
1644 depth - 1 }
1646
1647 pub fn build_session_context(&self) -> SessionContext {
1649 let entries = self.get_entries();
1650 let leaf_id = self.leaf_id.read().clone();
1651 build_session_context_internal(&entries, leaf_id, None)
1652 }
1653
1654 pub fn get_header(&self) -> Option<SessionHeader> {
1656 self.file_entries.read().iter().find_map(|e| match e {
1657 FileEntry::Header(h) => Some(h.clone()),
1658 _ => None,
1659 })
1660 }
1661
1662 pub fn get_entries(&self) -> Vec<SessionEntry> {
1664 self.by_id.read().values().cloned().collect()
1665 }
1666
1667 pub fn get_tree(&self, _id: Uuid) -> anyhow::Result<Vec<SessionTreeNode>> {
1670 let entries = self.get_entries();
1671 let labels: HashMap<String, String> = self.labels_by_id.read().clone();
1672 let label_timestamps: HashMap<String, String> = self.label_timestamps_by_id.read().clone();
1673
1674 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
1675 let mut root_ids: Vec<String> = Vec::new();
1676
1677 for entry in &entries {
1679 adj.insert(entry.id.clone(), Vec::new());
1680 }
1681
1682 for entry in &entries {
1684 let is_root = match entry.parent_id.as_deref() {
1685 Some(pid) if pid != entry.id => !adj.contains_key(pid),
1686 _ => true,
1687 };
1688 if is_root {
1689 root_ids.push(entry.id.clone());
1690 } else if let Some(ref pid) = entry.parent_id {
1691 if let Some(children) = adj.get_mut(pid.as_str()) {
1692 children.push(entry.id.clone());
1693 } else {
1694 root_ids.push(entry.id.clone());
1695 }
1696 }
1697 }
1698
1699 let entries_map: HashMap<String, SessionEntry> =
1701 entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1702
1703 fn build(
1705 id: &str,
1706 adj: &HashMap<String, Vec<String>>,
1707 entries_map: &HashMap<String, SessionEntry>,
1708 labels: &HashMap<String, String>,
1709 label_timestamps: &HashMap<String, String>,
1710 ) -> anyhow::Result<SessionTreeNode> {
1711 let entry = entries_map
1712 .get(id)
1713 .ok_or_else(|| anyhow::anyhow!("Corrupted session: entry {} not found", id))?
1714 .clone();
1715 let child_ids = adj.get(id).cloned().unwrap_or_default();
1716 let children: Vec<SessionTreeNode> = child_ids
1717 .iter()
1718 .map(|cid| build(cid, adj, entries_map, labels, label_timestamps))
1719 .collect::<Result<Vec<_>, _>>()?;
1720 Ok(SessionTreeNode {
1721 entry,
1722 children,
1723 label: labels.get(id).cloned(),
1724 label_timestamp: label_timestamps.get(id).cloned(),
1725 })
1726 }
1727
1728 let mut roots = root_ids
1729 .into_iter()
1730 .map(|rid| build(&rid, &adj, &entries_map, &labels, &label_timestamps))
1731 .collect::<anyhow::Result<Vec<_>>>()?;
1732
1733 sort_tree_by_timestamp(&mut roots);
1734 Ok(roots)
1735 }
1736
1737 pub fn branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1743 if !self.by_id.read().contains_key(branch_from_id) {
1744 return Err(format!("Entry {} not found", branch_from_id));
1745 }
1746 *self.leaf_id.write() = Some(branch_from_id.to_string());
1747 Ok(())
1748 }
1749
1750 pub fn reset_leaf(&mut self) {
1752 *self.leaf_id.write() = None;
1753 }
1754
1755 pub fn branch_with_summary(
1757 &mut self,
1758 branch_from_id: Option<&str>,
1759 summary: &str,
1760 _details: Option<serde_json::Value>,
1761 _from_hook: Option<bool>,
1762 ) -> String {
1763 if let Some(id) = branch_from_id
1764 && !self.by_id.read().contains_key(id)
1765 {
1766 return String::new();
1767 }
1768
1769 *self.leaf_id.write() = branch_from_id.map(|s| s.to_string());
1770
1771 let id = Uuid::new_v4().to_string();
1772 let entry = SessionEntry {
1773 id: id.clone(),
1774 parent_id: branch_from_id.map(|s| s.to_string()),
1775 timestamp: Utc::now().timestamp_millis(),
1776 message: AgentMessage::BranchSummary {
1777 summary: summary.to_string(),
1778 from_id: branch_from_id.unwrap_or("root").to_string(),
1779 timestamp: Utc::now().timestamp_millis(),
1780 },
1781 };
1782
1783 self._append_entry(entry);
1784 id
1785 }
1786
1787 pub fn add_label(&mut self, target_id: &str, label: &str) -> Result<String, String> {
1789 self.append_label_change(target_id, Some(label))
1790 }
1791
1792 pub fn remove_label(&mut self, target_id: &str) -> Result<String, String> {
1794 self.append_label_change(target_id, None)
1795 }
1796
1797 pub fn get_latest_compaction_entry(&self) -> Option<SessionEntry> {
1803 let entries = self.get_entries();
1804 for entry in entries.iter().rev() {
1805 if let AgentMessage::CompactionSummary { .. } = &entry.message {
1806 return Some(entry.clone());
1807 }
1808 }
1809 None
1810 }
1811
1812 pub fn get_compaction_entries(&self) -> Vec<SessionEntry> {
1814 self.get_entries()
1815 .iter()
1816 .filter(|e| matches!(&e.message, AgentMessage::CompactionSummary { .. }))
1817 .cloned()
1818 .collect()
1819 }
1820
1821 pub fn get_session_stats(&self) -> SessionStats {
1827 let entries = self.get_entries();
1828 let mut message_count = 0i64;
1829 let mut user_message_count = 0i64;
1830 let mut assistant_message_count = 0i64;
1831 let mut total_chars = 0i64;
1832 let mut total_tokens_estimate = 0i64;
1833
1834 for entry in &entries {
1835 if let AgentMessage::User { .. } = &entry.message {
1836 user_message_count += 1;
1837 }
1838 if let AgentMessage::Assistant { .. } = &entry.message {
1839 assistant_message_count += 1;
1840 }
1841 if entry.message.is_user() || entry.message.is_assistant() {
1842 message_count += 1;
1843 let content = entry.content();
1845 let chars = content.len() as i64;
1846 total_chars += chars;
1847 total_tokens_estimate += (chars as f64 / 4.0).ceil() as i64;
1848 }
1849 }
1850
1851 SessionStats {
1852 message_count,
1853 user_message_count,
1854 assistant_message_count,
1855 total_chars,
1856 estimated_tokens: total_tokens_estimate,
1857 }
1858 }
1859
1860 pub async fn list(cwd: &str, session_dir: Option<&str>) -> Result<Vec<SessionInfo>> {
1866 let dir = session_dir
1867 .map(|s| s.to_string())
1868 .unwrap_or_else(|| get_default_session_dir(cwd));
1869 list_sessions_from_dir(&dir).await
1870 }
1871
1872 pub async fn list_all() -> Result<Vec<SessionInfo>> {
1874 let sessions_dir = get_sessions_dir();
1875
1876 if !Path::new(&sessions_dir).exists() {
1877 return Ok(Vec::new());
1878 }
1879
1880 let mut all_sessions = Vec::new();
1881 let entries = fs::read_dir(&sessions_dir)?;
1882
1883 for entry in entries {
1884 let entry = entry?;
1885 let path = entry.path();
1886 if path.is_dir()
1887 && let Ok(sessions) = list_sessions_from_dir(&path.to_string_lossy()).await
1888 {
1889 all_sessions.extend(sessions);
1890 }
1891 }
1892
1893 all_sessions.sort_by_key(|b| std::cmp::Reverse(b.modified));
1894 Ok(all_sessions)
1895 }
1896
1897 pub fn fork_from(
1899 source_path: &str,
1900 target_cwd: &str,
1901 session_dir: Option<&str>,
1902 ) -> Result<Self, String> {
1903 let source_entries = load_entries_from_file(source_path);
1904 if source_entries.is_empty() {
1905 return Err(format!(
1906 "Cannot fork: source session file is empty or invalid: {}",
1907 source_path
1908 ));
1909 }
1910
1911 let source_header = source_entries.iter().find_map(|e| match e {
1912 FileEntry::Header(h) => Some(h),
1913 _ => None,
1914 });
1915 if source_header.is_none() {
1916 return Err(format!(
1917 "Cannot fork: source session has no header: {}",
1918 source_path
1919 ));
1920 }
1921
1922 let dir = session_dir
1923 .map(|s| s.to_string())
1924 .unwrap_or_else(|| get_default_session_dir(target_cwd));
1925
1926 if !Path::new(&dir).exists() {
1927 let _ = fs::create_dir_all(&dir);
1928 }
1929
1930 let new_session_id = Uuid::new_v4().to_string();
1931 let timestamp = Utc::now().to_rfc3339();
1932 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1933 let short_id = &new_session_id[..8];
1934 let new_session_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
1935
1936 let new_header = SessionHeader {
1938 entry_type: "session".to_string(),
1939 version: Some(CURRENT_SESSION_VERSION),
1940 id: new_session_id.clone(),
1941 timestamp: timestamp.clone(),
1942 cwd: target_cwd.to_string(),
1943 parent_session: Some(source_path.to_string()),
1944 };
1945
1946 let mut handle = fs::OpenOptions::new()
1947 .create(true)
1948 .truncate(true)
1949 .write(true)
1950 .open(&new_session_file)
1951 .map_err(|e| e.to_string())?;
1952 writeln!(
1953 &mut handle,
1954 "{}",
1955 serde_json::to_string(&new_header).expect("session header serializable")
1956 )
1957 .map_err(|e| e.to_string())?;
1958
1959 for file_entry in &source_entries {
1961 if let FileEntry::Entry(_) = file_entry {
1962 writeln!(
1963 &mut handle,
1964 "{}",
1965 serde_json::to_string(file_entry).expect("session entry serializable")
1966 )
1967 .map_err(|e| e.to_string())?;
1968 }
1969 }
1970
1971 Ok(Self::open(&new_session_file, Some(&dir), Some(target_cwd)))
1972 }
1973
1974 pub fn delete_session(path: &str) -> Result<()> {
1976 fs::remove_file(path).context("Failed to delete session file")?;
1977 Ok(())
1978 }
1979
1980 pub fn rename_session(&mut self, name: &str) -> String {
1982 self.append_session_info(name)
1983 }
1984
1985 pub async fn new() -> Result<Self> {
1991 Self::new_async().await
1992 }
1993
1994 pub async fn new_async() -> Result<Self> {
1996 let home = dirs::home_dir().context("Cannot find home directory")?;
1997 let base_dir = home.join(".oxi");
1998 let sessions_dir = base_dir.join("sessions");
1999 tokio::fs::create_dir_all(&sessions_dir).await?;
2000 let cwd = std::env::current_dir()
2001 .unwrap_or_else(|_| PathBuf::from("."))
2002 .to_string_lossy()
2003 .to_string();
2004 Ok(Self::in_memory(&cwd))
2005 }
2006
2007 pub fn session_path(&self, id: &Uuid) -> PathBuf {
2009 if let Some(file) = &self.session_file {
2010 PathBuf::from(file)
2011 } else {
2012 PathBuf::from(format!("{}/{}.jsonl", self.session_dir, id))
2013 }
2014 }
2015
2016 pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
2018 let mut metas = Vec::new();
2020 let session_dir = Path::new(&self.session_dir);
2021 if !session_dir.exists() {
2022 return Ok(metas);
2023 }
2024 let entries = fs::read_dir(session_dir)?;
2025 for entry in entries {
2026 let entry = entry?;
2027 let path = entry.path();
2028 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2029 let file_name = path
2030 .file_stem()
2031 .unwrap_or_else(|| std::ffi::OsStr::new(""))
2032 .to_string_lossy()
2033 .to_string();
2034 if let Some(uuid_part) = file_name.split('_').next_back()
2036 && let Ok(uuid) = Uuid::parse_str(uuid_part)
2037 {
2038 let mtime = entry.metadata().ok().and_then(|m| m.modified().ok());
2039 let now_ts = Utc::now().timestamp_millis();
2040 metas.push(SessionMeta {
2041 id: uuid,
2042 parent_id: None,
2043 root_id: None,
2044 branch_point: None,
2045 created_at: now_ts,
2046 updated_at: mtime
2047 .map(|t| {
2048 let dt: DateTime<Utc> = DateTime::from(t);
2049 dt.timestamp_millis()
2050 })
2051 .unwrap_or(now_ts),
2052 name: None,
2053 });
2054 }
2055 }
2056 }
2057 metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
2058 Ok(metas)
2059 }
2060
2061 pub async fn save(&self, _id: Uuid, _entries: &[SessionEntry]) -> Result<()> {
2063 self._rewrite_file();
2064 Ok(())
2065 }
2066
2067 pub async fn load(&self, _id: Uuid) -> Result<Vec<SessionEntry>> {
2069 Ok(self.get_entries())
2070 }
2071
2072 pub async fn delete(&self, id: Uuid) -> Result<()> {
2074 let path = self.session_path(&id);
2075 if path.exists() {
2076 fs::remove_file(path).context("Failed to delete session file")?;
2077 }
2078 Ok(())
2079 }
2080
2081 pub async fn branch_from(
2083 &self,
2084 parent_id: Uuid,
2085 entry_id: Uuid,
2086 ) -> Result<(Uuid, Vec<SessionEntry>)> {
2087 let _entry_id_str = entry_id.to_string();
2088 let _parent_id_str = parent_id.to_string();
2089
2090 let _entries = self.get_entries();
2092 let path = self.get_branch(Some(&entry_id.to_string()));
2093
2094 let new_id = Uuid::new_v4();
2095 let new_entries: Vec<SessionEntry> = path
2096 .into_iter()
2097 .map(|e| {
2098 let mut new_entry = e.clone();
2099 new_entry.id = Uuid::new_v4().to_string();
2100 new_entry
2101 })
2102 .collect();
2103
2104 Ok((new_id, new_entries))
2107 }
2108
2109 pub async fn get_branch_info(&self, _id: Uuid) -> Result<Option<BranchInfo>> {
2111 Ok(None)
2113 }
2114
2115 pub async fn get_tree_async(&self, _id: Uuid) -> Result<Vec<SessionTreeNode>> {
2117 self.get_tree(Uuid::nil())
2118 }
2119
2120 pub async fn save_meta(&self, _meta: &SessionMeta) -> Result<()> {
2122 Ok(())
2123 }
2124
2125 pub async fn load_meta(&self, _id: Uuid) -> Result<Option<SessionMeta>> {
2127 Ok(None)
2128 }
2129
2130 pub async fn create_session(&mut self) -> Result<SessionMeta> {
2132 let id = Uuid::new_v4();
2133 let meta = SessionMeta::new(id);
2134 Ok(meta)
2135 }
2136
2137 pub fn branch_from_entry(&self, entry_id: &str) -> Result<String, String> {
2139 let path = self
2140 .get_session_file()
2141 .ok_or_else(|| "No session file path".to_string())?;
2142 let source_entries = load_entries_from_file(&path);
2143 if source_entries.is_empty() {
2144 return Err("Cannot fork: source session is empty".to_string());
2145 }
2146 let _header = source_entries
2148 .iter()
2149 .find_map(|e| match e {
2150 FileEntry::Header(h) => Some(h),
2151 _ => None,
2152 })
2153 .ok_or_else(|| "Missing session header".to_string())?;
2154 let new_id = Uuid::new_v4().to_string();
2155 let timestamp = chrono::Utc::now().to_rfc3339();
2156 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
2157 let short_id = &new_id[..8];
2158 let dir = std::path::Path::new(&path)
2159 .parent()
2160 .map(|p| p.to_string_lossy().into_owned())
2161 .unwrap_or_else(|| ".".to_string());
2162 let new_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
2163 let mut found = false;
2164 let mut new_entries = vec![FileEntry::Header(SessionHeader {
2165 entry_type: "session".to_string(),
2166 version: Some(CURRENT_SESSION_VERSION),
2167 id: new_id.clone(),
2168 timestamp,
2169 cwd: self.get_cwd(),
2170 parent_session: Some(path),
2171 })];
2172 for file_entry in &source_entries {
2173 if let FileEntry::Entry(entry) = file_entry {
2174 let eid = match entry {
2175 SessionEntryEnum::Message(m) => m.base.id.clone(),
2176 SessionEntryEnum::ThinkingLevelChange(m) => m.base.id.clone(),
2177 SessionEntryEnum::ModelChange(m) => m.base.id.clone(),
2178 SessionEntryEnum::Compaction(m) => m.base.id.clone(),
2179 SessionEntryEnum::BranchSummary(m) => m.base.id.clone(),
2180 SessionEntryEnum::Custom(m) => m.base.id.clone(),
2181 SessionEntryEnum::Label(m) => m.base.id.clone(),
2182 SessionEntryEnum::SessionInfo(m) => m.base.id.clone(),
2183 SessionEntryEnum::CustomMessage(m) => m.base.id.clone(),
2184 };
2185 if eid == entry_id {
2186 found = true;
2187 let mut entry = entry.clone();
2190 clear_entry_parent_id(&mut entry);
2191 new_entries.push(FileEntry::Entry(entry));
2192 } else if found {
2193 new_entries.push(FileEntry::Entry(entry.clone()));
2194 }
2195 }
2196 }
2197 if !found {
2198 return Err(format!("Entry not found: {}", entry_id));
2199 }
2200 let mut handle = std::fs::OpenOptions::new()
2201 .create(true)
2202 .truncate(true)
2203 .write(true)
2204 .open(&new_file)
2205 .map_err(|e| e.to_string())?;
2206 for entry in &new_entries {
2207 let line = serde_json::to_string(entry).map_err(|e| e.to_string())?;
2208 writeln!(&mut handle, "{}", line).map_err(|e| e.to_string())?;
2209 }
2210 Ok(new_file)
2211 }
2212}
2213
2214fn clear_entry_parent_id(entry: &mut SessionEntryEnum) {
2221 match entry {
2222 SessionEntryEnum::Message(m) => m.base.parent_id = None,
2223 SessionEntryEnum::ThinkingLevelChange(m) => m.base.parent_id = None,
2224 SessionEntryEnum::ModelChange(m) => m.base.parent_id = None,
2225 SessionEntryEnum::Compaction(m) => m.base.parent_id = None,
2226 SessionEntryEnum::BranchSummary(m) => m.base.parent_id = None,
2227 SessionEntryEnum::Custom(m) => m.base.parent_id = None,
2228 SessionEntryEnum::Label(m) => m.base.parent_id = None,
2229 SessionEntryEnum::SessionInfo(m) => m.base.parent_id = None,
2230 SessionEntryEnum::CustomMessage(m) => m.base.parent_id = None,
2231 }
2232}
2233
2234fn convert_to_session_entry(entry: &SessionEntryEnum) -> Option<SessionEntry> {
2236 match entry {
2237 SessionEntryEnum::Message(m) => Some(SessionEntry {
2238 id: m.base.id.clone(),
2239 parent_id: m.base.parent_id.clone(),
2240 timestamp: DateTime::parse_from_rfc3339(&m.base.timestamp)
2241 .map(|dt| dt.timestamp_millis())
2242 .unwrap_or(0),
2243 message: m.message.clone(),
2244 }),
2245 _ => None, }
2247}
2248
2249fn convert_from_session_entry(entry: &SessionEntry) -> SessionEntryEnum {
2251 let timestamp = DateTime::from_timestamp_millis(entry.timestamp)
2252 .map(|dt| dt.to_rfc3339())
2253 .unwrap_or_else(|| Utc::now().to_rfc3339());
2254
2255 SessionEntryEnum::Message(SessionMessageEntry {
2256 base: SessionEntryBase {
2257 entry_type: "message".to_string(),
2258 id: entry.id.clone(),
2259 parent_id: entry.parent_id.clone(),
2260 timestamp,
2261 },
2262 message: entry.message.clone(),
2263 })
2264}
2265
2266#[derive(Debug, Clone)]
2272pub struct SessionStats {
2273 pub message_count: i64,
2275 pub user_message_count: i64,
2277 pub assistant_message_count: i64,
2279 pub total_chars: i64,
2281 pub estimated_tokens: i64,
2283}
2284
2285#[derive(Debug, Clone)]
2291pub struct NewSessionOptions {
2292 pub id: Option<String>,
2294 pub parent_session: Option<String>,
2296}
2297
2298pub fn get_default_session_dir(cwd: &str) -> String {
2304 let agent_dir = get_agent_dir();
2305 let safe_path = format!("--{}--", cwd.replace(['/', '\\', ':'], "-"));
2306 let session_dir = format!("{}/sessions/{}", agent_dir, safe_path);
2307
2308 if !Path::new(&session_dir).exists() {
2309 let _ = fs::create_dir_all(&session_dir);
2310 }
2311
2312 session_dir
2313}
2314
2315fn get_agent_dir() -> String {
2316 dirs::home_dir()
2317 .map(|h| h.join(".oxi").to_string_lossy().to_string())
2318 .unwrap_or_else(|| ".oxi".to_string())
2319}
2320
2321fn get_sessions_dir() -> String {
2322 format!("{}/sessions", get_agent_dir())
2323}
2324
2325fn load_entries_from_file(file_path: &str) -> Vec<FileEntry> {
2327 if !Path::new(file_path).exists() {
2328 return Vec::new();
2329 }
2330
2331 let file = match File::open(file_path) {
2332 Ok(f) => f,
2333 Err(_) => return Vec::new(),
2334 };
2335
2336 let reader = BufReader::new(file);
2337 let mut entries = Vec::new();
2338
2339 for line in reader.lines() {
2340 let line = match line {
2341 Ok(l) => l,
2342 Err(_) => continue,
2343 };
2344 if line.trim().is_empty() {
2345 continue;
2346 }
2347 match serde_json::from_str::<FileEntry>(&line) {
2348 Ok(entry) => entries.push(entry),
2349 Err(_) => continue,
2350 }
2351 }
2352
2353 if entries.is_empty() {
2355 return entries;
2356 }
2357 let header = match &entries[0] {
2358 FileEntry::Header(h) => h,
2359 _ => return Vec::new(),
2360 };
2361 if header.entry_type != "session" || header.id.is_empty() {
2362 return Vec::new();
2363 }
2364
2365 entries
2366}
2367
2368fn is_valid_session_file(file_path: &str) -> bool {
2370 if let Ok(mut file) = File::open(file_path) {
2371 use std::io::Read;
2372 let mut buffer = vec![0u8; 512];
2373 if let Ok(bytes_read) = file.read(&mut buffer)
2374 && let Ok(content) = String::from_utf8(buffer[..bytes_read].to_vec())
2375 && let Some(first_line) = content.split('\n').next()
2376 && let Ok(header) = serde_json::from_str::<SessionHeader>(first_line)
2377 {
2378 return header.entry_type == "session" && !header.id.is_empty();
2379 }
2380 }
2381 false
2382}
2383
2384pub fn find_recent_session_path(cwd: &str) -> Option<String> {
2386 let dir = get_default_session_dir(cwd);
2387 find_most_recent_session(&dir)
2388}
2389
2390fn find_most_recent_session(session_dir: &str) -> Option<String> {
2391 if !Path::new(session_dir).exists() {
2392 return None;
2393 }
2394
2395 let mut files: Vec<(String, std::time::SystemTime)> = Vec::new();
2396
2397 if let Ok(entries) = fs::read_dir(session_dir) {
2398 for entry in entries.flatten() {
2399 let path = entry.path();
2400 if path.extension().map(|e| e == "jsonl").unwrap_or(false)
2401 && let Some(path_str) = path.to_str()
2402 && is_valid_session_file(path_str)
2403 && let Ok(metadata) = entry.metadata()
2404 && let Ok(mtime) = metadata.modified()
2405 {
2406 files.push((path_str.to_string(), mtime));
2407 }
2408 }
2409 }
2410
2411 files.sort_by_key(|b| std::cmp::Reverse(b.1));
2412 files.into_iter().next().map(|(p, _)| p)
2413}
2414
2415pub fn resolve_session_path(input: &str, cwd: &str) -> Result<String, String> {
2417 let path = input.trim();
2418 if path.is_empty() {
2419 return Err("Empty path".to_string());
2420 }
2421 let resolved = if let Some(rest) = path.strip_prefix('~') {
2422 if rest.is_empty() {
2423 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2424 home.to_string_lossy().into_owned()
2425 } else if let Some(rest) = rest.strip_prefix('/') {
2426 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2427 format!("{}/{}", home.to_string_lossy(), rest)
2428 } else {
2429 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2430 format!("{}/{}", home.to_string_lossy(), rest)
2431 }
2432 } else if path.starts_with('/') || path.contains(':') {
2433 path.to_string()
2434 } else {
2435 if let Some(stripped) = path.strip_prefix("./") {
2436 format!("{}/{}", cwd.trim_end_matches('/'), stripped)
2437 } else {
2438 format!("{}/{}", cwd.trim_end_matches('/'), path)
2439 }
2440 };
2441 let p = std::path::Path::new(&resolved);
2442 p.canonicalize()
2443 .map(|c| c.to_string_lossy().into_owned())
2444 .or(Ok(resolved))
2445}
2446
2447fn build_session_context_internal(
2449 entries: &[SessionEntry],
2450 leaf_id: Option<String>,
2451 _by_id: Option<&RwLock<HashMap<String, SessionEntry>>>,
2452) -> SessionContext {
2453 let leaf: Option<&SessionEntry> = leaf_id
2455 .as_ref()
2456 .and_then(|id| entries.iter().find(|e| e.id == *id));
2457
2458 let leaf = leaf.or_else(|| entries.last());
2459
2460 let Some(leaf) = leaf else {
2461 return SessionContext {
2462 messages: Vec::new(),
2463 thinking_level: "off".to_string(),
2464 model: None,
2465 };
2466 };
2467
2468 let mut path: Vec<&SessionEntry> = Vec::new();
2470 let mut current: Option<&SessionEntry> = Some(leaf);
2471 while let Some(entry) = current {
2472 path.insert(0, entry);
2473 current = entry
2474 .parent_id
2475 .as_ref()
2476 .and_then(|pid| entries.iter().find(|e| e.id == *pid));
2477 }
2478
2479 let mut thinking_level = "off".to_string();
2481 let mut model: Option<ModelInfo> = None;
2482
2483 for entry in &path {
2484 if let AgentMessage::Assistant {
2485 provider, model_id, ..
2486 } = &entry.message
2487 {
2488 model = Some(ModelInfo {
2489 provider: provider.clone().unwrap_or_default(),
2490 model_id: model_id.clone().unwrap_or_default(),
2491 });
2492 }
2493 if let AgentMessage::Custom {
2494 custom_type,
2495 content,
2496 ..
2497 } = &entry.message
2498 && custom_type == "thinking_level_change"
2499 {
2500 thinking_level = content.as_str().to_string();
2501 }
2502 }
2503
2504 let messages: Vec<AgentMessage> = path
2506 .iter()
2507 .filter(|e| {
2508 e.message.is_user()
2509 || e.message.is_assistant()
2510 || matches!(&e.message, AgentMessage::BranchSummary { .. })
2511 || matches!(&e.message, AgentMessage::CompactionSummary { .. })
2512 })
2513 .map(|e| e.message.clone())
2514 .collect();
2515
2516 SessionContext {
2517 messages,
2518 thinking_level,
2519 model,
2520 }
2521}
2522
2523fn sort_tree_by_timestamp(nodes: &mut Vec<SessionTreeNode>) {
2525 nodes.sort_by_key(|a| a.entry.timestamp);
2526
2527 for node in nodes {
2528 sort_tree_by_timestamp(&mut node.children);
2529 }
2530}
2531
2532async fn list_sessions_from_dir(dir: &str) -> Result<Vec<SessionInfo>> {
2534 if !Path::new(dir).exists() {
2535 return Ok(Vec::new());
2536 }
2537
2538 let mut sessions = Vec::new();
2539
2540 let entries = fs::read_dir(dir)?;
2541 let files: Vec<String> = entries
2542 .filter_map(|e| e.ok())
2543 .filter(|e| {
2544 e.path()
2545 .extension()
2546 .map(|ext| ext == "jsonl")
2547 .unwrap_or(false)
2548 })
2549 .filter_map(|e| e.path().to_str().map(|s| s.to_string()))
2550 .collect();
2551
2552 for file in files {
2553 if let Some(info) = build_session_info(&file).await {
2554 sessions.push(info);
2555 }
2556 }
2557
2558 Ok(sessions)
2559}
2560
2561async fn build_session_info(file_path: &str) -> Option<SessionInfo> {
2563 let content = fs::read_to_string(file_path).ok()?;
2564 let entries = parse_session_entries(&content)?;
2565
2566 if entries.is_empty() {
2567 return None;
2568 }
2569
2570 let header = match &entries[0] {
2571 FileEntry::Header(h) => h,
2572 _ => return None,
2573 };
2574
2575 let stats = fs::metadata(file_path).ok()?;
2576 let mut message_count = 0i64;
2577 let mut first_message = String::new();
2578 let mut all_messages = Vec::new();
2579 let mut name: Option<String> = None;
2580
2581 for entry in &entries {
2582 if let FileEntry::Entry(e) = entry {
2583 if let SessionEntryEnum::SessionInfo(si) = e {
2585 name = si
2586 .name
2587 .clone()
2588 .map(|n| n.trim().to_string())
2589 .filter(|n| !n.is_empty());
2590 }
2591 if let SessionEntryEnum::Message(m) = e {
2593 if m.message.is_user() {
2594 message_count += 1;
2595 let text = m.message.content();
2596 if !text.is_empty() {
2597 all_messages.push(text.clone());
2598 if first_message.is_empty() {
2599 first_message = text;
2600 }
2601 }
2602 } else if m.message.is_assistant() {
2603 if first_message.is_empty() {
2605 let text = m.message.content();
2606 if !text.is_empty() {
2607 first_message = text;
2608 }
2609 }
2610 }
2611 }
2612 }
2613 }
2614
2615 if first_message.is_empty() {
2619 return None;
2620 }
2621
2622 let cwd = header.cwd.clone();
2623 let parent_session_path = header.parent_session.clone();
2624 let created = chrono::DateTime::parse_from_rfc3339(&header.timestamp)
2625 .map(|dt| dt.with_timezone(&Utc))
2626 .unwrap_or_else(|_| Utc::now());
2627 let modified = get_session_modified_date(&entries, &header.timestamp, &stats);
2628
2629 Some(SessionInfo {
2630 path: file_path.to_string(),
2631 id: header.id.clone(),
2632 cwd,
2633 name,
2634 parent_session_path,
2635 created,
2636 modified,
2637 message_count,
2638 first_message: if first_message.is_empty() {
2639 "(no messages)".to_string()
2640 } else {
2641 first_message
2642 },
2643 all_messages_text: all_messages.join(" "),
2644 })
2645}
2646
2647fn parse_session_entries(content: &str) -> Option<Vec<FileEntry>> {
2649 let mut entries = Vec::new();
2650
2651 for line in content.trim().lines() {
2652 if line.trim().is_empty() {
2653 continue;
2654 }
2655 if let Ok(entry) = serde_json::from_str::<FileEntry>(line) {
2656 entries.push(entry);
2657 }
2658 }
2659
2660 Some(entries)
2661}
2662
2663fn get_session_modified_date(
2665 entries: &[FileEntry],
2666 header_timestamp: &str,
2667 stats: &std::fs::Metadata,
2668) -> DateTime<Utc> {
2669 let last_activity_time = get_last_activity_time(entries);
2670 if let Some(t) = last_activity_time
2671 && t > 0
2672 {
2673 return DateTime::from_timestamp_millis(t).unwrap_or_else(Utc::now);
2674 }
2675
2676 let header_time = chrono::DateTime::parse_from_rfc3339(header_timestamp)
2677 .map(|dt| dt.timestamp_millis())
2678 .unwrap_or(-1);
2679
2680 if header_time > 0 {
2681 return DateTime::from_timestamp_millis(header_time).unwrap_or_else(Utc::now);
2682 }
2683
2684 if let Ok(mtime) = stats.modified() {
2685 return DateTime::from(mtime);
2686 }
2687
2688 Utc::now()
2689}
2690
2691fn get_last_activity_time(entries: &[FileEntry]) -> Option<i64> {
2693 let mut last_activity: Option<i64> = None;
2694
2695 for entry in entries {
2696 let entry = match entry {
2697 FileEntry::Entry(e) => e,
2698 _ => continue,
2699 };
2700
2701 if let SessionEntryEnum::Message(m) = entry
2702 && (m.message.is_user() || m.message.is_assistant())
2703 {
2704 last_activity = Some(std::cmp::max(
2705 last_activity.unwrap_or(0),
2706 m.base.timestamp.parse().unwrap_or(0),
2707 ));
2708 }
2709 }
2710
2711 last_activity
2712}
2713
2714#[cfg(test)]
2719mod tests {
2720 use super::*;
2721
2722 #[test]
2723 fn test_session_creation() {
2724 let manager = SessionManager::in_memory("/tmp");
2725 assert!(!manager.get_session_id().is_empty());
2726 assert_eq!(manager.get_entries().len(), 0);
2727 }
2728
2729 #[test]
2730 fn test_append_message() {
2731 let mut manager = SessionManager::in_memory("/tmp");
2732 let id = manager.append_message(AgentMessage::User {
2733 content: ContentValue::String("Hello".to_string()),
2734 });
2735 assert!(!id.is_empty());
2736 assert_eq!(manager.get_entries().len(), 1);
2737 assert_eq!(manager.get_leaf_id(), Some(id));
2738 }
2739
2740 #[test]
2741 fn test_tree_traversal() {
2742 let mut manager = SessionManager::in_memory("/tmp");
2743 let id1 = manager.append_message(AgentMessage::User {
2744 content: ContentValue::String("Hello".to_string()),
2745 });
2746 let id2 = manager.append_message(AgentMessage::Assistant {
2747 content: vec![],
2748 provider: None,
2749 model_id: None,
2750 usage: None,
2751 stop_reason: None,
2752 });
2753
2754 let branch = manager.get_branch(None);
2756 assert_eq!(branch.len(), 2);
2757
2758 let branch = manager.get_branch(Some(&id1));
2760 assert_eq!(branch.len(), 1);
2761
2762 let children = manager.get_children(&id1);
2764 assert_eq!(children.len(), 1);
2765
2766 let parent = manager.get_parent(&id2);
2768 assert!(parent.is_some());
2769 assert_eq!(parent.unwrap().id, id1);
2770 }
2771
2772 #[test]
2773 fn test_branching() {
2774 let mut manager = SessionManager::in_memory("/tmp");
2775 let id1 = manager.append_message(AgentMessage::User {
2776 content: ContentValue::String("Hello".to_string()),
2777 });
2778 let _id2 = manager.append_message(AgentMessage::Assistant {
2779 content: vec![],
2780 provider: None,
2781 model_id: None,
2782 usage: None,
2783 stop_reason: None,
2784 });
2785 let _id3 = manager.append_message(AgentMessage::User {
2786 content: ContentValue::String("How are you?".to_string()),
2787 });
2788
2789 manager.branch(&id1).unwrap();
2791 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2792
2793 let id4 = manager.append_message(AgentMessage::Assistant {
2795 content: vec![],
2796 provider: None,
2797 model_id: None,
2798 usage: None,
2799 stop_reason: None,
2800 });
2801
2802 assert_eq!(manager.get_entries().len(), 4);
2804
2805 assert_eq!(manager.get_leaf_id(), Some(id4));
2807
2808 let tree = manager.get_tree(Uuid::nil()).unwrap();
2810 assert_eq!(tree.len(), 1); assert_eq!(tree[0].children.len(), 2); }
2813
2814 #[test]
2815 fn test_session_context() {
2816 let mut manager = SessionManager::in_memory("/tmp");
2817 manager.append_message(AgentMessage::User {
2818 content: ContentValue::String("Hello".to_string()),
2819 });
2820 manager.append_message(AgentMessage::Assistant {
2821 content: vec![AssistantContentBlock::Text {
2822 text: "Hi there!".to_string(),
2823 }],
2824 provider: Some("test".to_string()),
2825 model_id: Some("model".to_string()),
2826 usage: None,
2827 stop_reason: None,
2828 });
2829
2830 let context = manager.build_session_context();
2831 assert_eq!(context.messages.len(), 2);
2832 assert!(context.model.is_some());
2833 }
2834
2835 #[test]
2836 fn test_compaction_entry() {
2837 let mut manager = SessionManager::in_memory("/tmp");
2838 let id1 = manager.append_message(AgentMessage::User {
2839 content: ContentValue::String("First message".to_string()),
2840 });
2841 let _id2 = manager.append_message(AgentMessage::Assistant {
2842 content: vec![],
2843 provider: None,
2844 model_id: None,
2845 usage: None,
2846 stop_reason: None,
2847 });
2848
2849 let id3 = manager.append_compaction("Summarized conversation", &id1, 1000, None, None);
2850 assert!(!id3.is_empty());
2851
2852 let latest = manager.get_latest_compaction_entry();
2853 assert!(latest.is_some());
2854 }
2855
2856 #[test]
2857 fn test_labels() {
2858 let mut manager = SessionManager::in_memory("/tmp");
2859 let id1 = manager.append_message(AgentMessage::User {
2860 content: ContentValue::String("Hello".to_string()),
2861 });
2862
2863 manager.add_label(&id1, "important").unwrap();
2864 assert_eq!(manager.get_label(&id1), Some("important".to_string()));
2865
2866 manager.remove_label(&id1).unwrap();
2867 assert_eq!(manager.get_label(&id1), None);
2868 }
2869
2870 fn user_msg(text: &str) -> AgentMessage {
2876 AgentMessage::User {
2877 content: ContentValue::String(text.to_string()),
2878 }
2879 }
2880
2881 fn assistant_msg(text: &str) -> AgentMessage {
2883 AgentMessage::Assistant {
2884 content: vec![AssistantContentBlock::Text {
2885 text: text.to_string(),
2886 }],
2887 provider: Some("anthropic".to_string()),
2888 model_id: Some("claude-test".to_string()),
2889 usage: None,
2890 stop_reason: None,
2891 }
2892 }
2893
2894 fn bare_assistant_msg() -> AgentMessage {
2896 AgentMessage::Assistant {
2897 content: vec![],
2898 provider: None,
2899 model_id: None,
2900 usage: None,
2901 stop_reason: None,
2902 }
2903 }
2904
2905 #[test]
2910 fn test_append_thinking_level_change_integrates() {
2911 let mut manager = SessionManager::in_memory("/tmp");
2912 let msg_id = manager.append_message(user_msg("hello"));
2913 let thinking_id = manager.append_thinking_level_change("high");
2914 let msg2_id = manager.append_message(assistant_msg("response"));
2915
2916 let entries = manager.get_entries();
2917 assert_eq!(entries.len(), 3);
2918
2919 let thinking_entry = entries.iter().find(|e| e.id == thinking_id).unwrap();
2921 assert_eq!(thinking_entry.parent_id, Some(msg_id));
2922
2923 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2924 assert_eq!(msg2.parent_id, Some(thinking_id));
2925 }
2926
2927 #[test]
2928 fn test_append_model_change_integrates() {
2929 let mut manager = SessionManager::in_memory("/tmp");
2930 let msg_id = manager.append_message(user_msg("hello"));
2931 let model_id = manager.append_model_change("openai", "gpt-4");
2932 let msg2_id = manager.append_message(assistant_msg("response"));
2933
2934 let entries = manager.get_entries();
2935 let model_entry = entries.iter().find(|e| e.id == model_id).unwrap();
2936 assert_eq!(model_entry.parent_id, Some(msg_id));
2937
2938 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2939 assert_eq!(msg2.parent_id, Some(model_id));
2940 }
2941
2942 #[test]
2943 fn test_append_compaction_integrates_into_tree() {
2944 let mut manager = SessionManager::in_memory("/tmp");
2945 let id1 = manager.append_message(user_msg("1"));
2946 let id2 = manager.append_message(assistant_msg("2"));
2947 let compaction_id = manager.append_compaction("summary", &id1, 1000, None, None);
2948 let id3 = manager.append_message(user_msg("3"));
2949
2950 let entries = manager.get_entries();
2951 let compaction = entries.iter().find(|e| e.id == compaction_id).unwrap();
2952 assert_eq!(compaction.parent_id, Some(id2));
2953
2954 let msg3 = entries.iter().find(|e| e.id == id3).unwrap();
2955 assert_eq!(msg3.parent_id, Some(compaction_id));
2956
2957 if let AgentMessage::CompactionSummary {
2959 summary,
2960 tokens_before,
2961 ..
2962 } = &compaction.message
2963 {
2964 assert_eq!(summary, "summary");
2965 assert_eq!(*tokens_before, 1000);
2966 } else {
2967 panic!("Expected CompactionSummary");
2968 }
2969 }
2970
2971 #[test]
2972 fn test_leaf_pointer_advances() {
2973 let mut manager = SessionManager::in_memory("/tmp");
2974 assert!(manager.get_leaf_id().is_none());
2975
2976 let id1 = manager.append_message(user_msg("1"));
2977 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2978
2979 let id2 = manager.append_message(assistant_msg("2"));
2980 assert_eq!(manager.get_leaf_id(), Some(id2.clone()));
2981
2982 let id3 = manager.append_thinking_level_change("high");
2983 assert_eq!(manager.get_leaf_id(), Some(id3));
2984 }
2985
2986 #[test]
2987 fn test_get_entry() {
2988 let mut manager = SessionManager::in_memory("/tmp");
2989 assert!(manager.get_entry("nonexistent").is_none());
2990
2991 let id1 = manager.append_message(user_msg("first"));
2992 let id2 = manager.append_message(assistant_msg("second"));
2993
2994 let entry1 = manager.get_entry(&id1);
2995 assert!(entry1.is_some());
2996 assert!(entry1.unwrap().message.is_user());
2997
2998 let entry2 = manager.get_entry(&id2);
2999 assert!(entry2.is_some());
3000 assert!(entry2.unwrap().message.is_assistant());
3001 }
3002
3003 #[test]
3004 fn test_get_leaf_entry() {
3005 let manager = SessionManager::in_memory("/tmp");
3006 assert!(manager.get_leaf_entry().is_none());
3007
3008 let mut manager = SessionManager::in_memory("/tmp");
3009 manager.append_message(user_msg("1"));
3010 let id2 = manager.append_message(assistant_msg("2"));
3011
3012 let leaf = manager.get_leaf_entry();
3013 assert!(leaf.is_some());
3014 assert_eq!(leaf.unwrap().id, id2);
3015 }
3016
3017 #[test]
3022 fn test_get_branch_full_path_root_to_leaf() {
3023 let mut manager = SessionManager::in_memory("/tmp");
3024 let id1 = manager.append_message(user_msg("1"));
3025 let id2 = manager.append_message(assistant_msg("2"));
3026 let id3 = manager.append_thinking_level_change("high");
3027 let id4 = manager.append_message(user_msg("3"));
3028
3029 let branch = manager.get_branch(None);
3030 assert_eq!(branch.len(), 4);
3031 assert_eq!(branch[0].id, id1);
3032 assert_eq!(branch[1].id, id2);
3033 assert_eq!(branch[2].id, id3);
3034 assert_eq!(branch[3].id, id4);
3035 }
3036
3037 #[test]
3038 fn test_get_branch_from_specific_entry() {
3039 let mut manager = SessionManager::in_memory("/tmp");
3040 let id1 = manager.append_message(user_msg("1"));
3041 let id2 = manager.append_message(assistant_msg("2"));
3042 manager.append_message(user_msg("3"));
3043 manager.append_message(assistant_msg("4"));
3044
3045 let branch = manager.get_branch(Some(&id2));
3046 assert_eq!(branch.len(), 2);
3047 assert_eq!(branch[0].id, id1);
3048 assert_eq!(branch[1].id, id2);
3049 }
3050
3051 #[test]
3056 fn test_multiple_branches_at_same_point() {
3057 let mut manager = SessionManager::in_memory("/tmp");
3058 manager.append_message(user_msg("root"));
3059 let id2 = manager.append_message(bare_assistant_msg());
3060
3061 manager.branch(&id2).unwrap();
3063 let id_a = manager.append_message(user_msg("branch-A"));
3064
3065 manager.branch(&id2).unwrap();
3067 let id_b = manager.append_message(user_msg("branch-B"));
3068
3069 manager.branch(&id2).unwrap();
3071 let id_c = manager.append_message(user_msg("branch-C"));
3072
3073 let tree = manager.get_tree(Uuid::nil()).unwrap();
3074 let node2 = &tree[0].children[0];
3075 assert_eq!(node2.entry.id, id2);
3076 assert_eq!(node2.children.len(), 3);
3077
3078 let mut branch_ids: Vec<String> =
3079 node2.children.iter().map(|c| c.entry.id.clone()).collect();
3080 branch_ids.sort();
3081 let mut expected = vec![id_a, id_b, id_c];
3082 expected.sort();
3083 assert_eq!(branch_ids, expected);
3084 }
3085
3086 #[test]
3091 fn test_deep_branching() {
3092 let mut manager = SessionManager::in_memory("/tmp");
3093
3094 manager.append_message(user_msg("1"));
3096 let id2 = manager.append_message(bare_assistant_msg());
3097 let id3 = manager.append_message(user_msg("3"));
3098 manager.append_message(bare_assistant_msg());
3099
3100 manager.branch(&id2).unwrap();
3102 let id5 = manager.append_message(user_msg("5"));
3103 manager.append_message(bare_assistant_msg());
3104
3105 manager.branch(&id5).unwrap();
3107 manager.append_message(user_msg("7"));
3108
3109 let tree = manager.get_tree(Uuid::nil()).unwrap();
3110
3111 let node2 = &tree[0].children[0];
3113 assert_eq!(node2.children.len(), 2);
3114
3115 let node5 = node2.children.iter().find(|c| c.entry.id == id5).unwrap();
3116 assert_eq!(node5.children.len(), 2); let node3 = node2.children.iter().find(|c| c.entry.id == id3).unwrap();
3119 assert_eq!(node3.children.len(), 1); }
3121
3122 #[test]
3127 fn test_branch_with_summary_inserts_and_advances() {
3128 let mut manager = SessionManager::in_memory("/tmp");
3129 let id1 = manager.append_message(user_msg("1"));
3130 manager.append_message(bare_assistant_msg());
3131 manager.append_message(user_msg("3"));
3132
3133 let summary_id =
3134 manager.branch_with_summary(Some(&id1), "Summary of abandoned work", None, None);
3135 assert!(!summary_id.is_empty());
3136 assert_eq!(manager.get_leaf_id(), Some(summary_id.clone()));
3137
3138 let entries = manager.get_entries();
3140 let summary_entry = entries.iter().find(|e| e.id == summary_id).unwrap();
3141 assert_eq!(summary_entry.parent_id, Some(id1));
3142
3143 if let AgentMessage::BranchSummary { summary, .. } = &summary_entry.message {
3144 assert_eq!(summary, "Summary of abandoned work");
3145 } else {
3146 panic!("Expected BranchSummary");
3147 }
3148 }
3149
3150 #[test]
3155 fn test_build_session_context_returns_branch_messages() {
3156 let mut manager = SessionManager::in_memory("/tmp");
3157
3158 manager.append_message(user_msg("msg1"));
3160 let id2 = manager.append_message(bare_assistant_msg());
3161 manager.append_message(user_msg("msg3"));
3162
3163 manager.branch(&id2).unwrap();
3165 manager.append_message(assistant_msg("msg4-branch"));
3166
3167 let ctx = manager.build_session_context();
3168 assert_eq!(ctx.messages.len(), 3);
3170 assert!(ctx.messages[0].is_user());
3171 assert!(ctx.messages[1].is_assistant());
3172 assert!(ctx.messages[2].is_assistant());
3173 }
3174
3175 #[test]
3176 fn test_build_session_context_follows_branch_path() {
3177 let mut manager = SessionManager::in_memory("/tmp");
3180 manager.append_message(user_msg("start"));
3181 let id2 = manager.append_message(bare_assistant_msg());
3182 manager.append_message(user_msg("branch A"));
3183
3184 manager.branch(&id2).unwrap();
3186 manager.append_message(user_msg("branch B"));
3187
3188 let ctx = manager.build_session_context();
3189 assert_eq!(ctx.messages.len(), 3);
3190 let last = ctx.messages.last().unwrap();
3192 assert_eq!(last.content(), "branch B");
3193 }
3194
3195 #[test]
3196 fn test_build_session_context_includes_branch_summary() {
3197 let mut manager = SessionManager::in_memory("/tmp");
3198 manager.append_message(user_msg("start"));
3199 let id2 = manager.append_message(bare_assistant_msg());
3200 manager.append_message(user_msg("abandoned path"));
3201
3202 manager.branch_with_summary(Some(&id2), "Summary of abandoned work", None, None);
3204 manager.append_message(user_msg("new direction"));
3205
3206 let ctx = manager.build_session_context();
3207 assert!(ctx.messages.len() >= 3);
3209
3210 let has_summary = ctx.messages.iter().any(|m| {
3212 if let AgentMessage::BranchSummary { summary, .. } = m {
3213 summary == "Summary of abandoned work"
3214 } else {
3215 false
3216 }
3217 });
3218 assert!(has_summary, "Branch summary should be in context messages");
3219 }
3220
3221 #[test]
3222 fn test_build_session_context_with_compaction() {
3223 let mut manager = SessionManager::in_memory("/tmp");
3224
3225 let id1 = manager.append_message(user_msg("first"));
3227 manager.append_message(assistant_msg("response1"));
3228 manager.append_message(user_msg("second"));
3229 manager.append_message(assistant_msg("response2"));
3230
3231 manager.append_compaction("Summary of first two turns", &id1, 1000, None, None);
3233
3234 manager.append_message(user_msg("third"));
3236 manager.append_message(assistant_msg("response3"));
3237
3238 let ctx = manager.build_session_context();
3239 assert!(ctx.messages.len() >= 4); let compaction_entries = manager.get_compaction_entries();
3245 assert_eq!(compaction_entries.len(), 1);
3246 }
3247
3248 #[test]
3249 fn test_build_session_context_tracks_thinking_level() {
3250 let mut manager = SessionManager::in_memory("/tmp");
3251 manager.append_message(user_msg("hello"));
3252 manager.append_thinking_level_change("high");
3253 manager.append_message(assistant_msg("thinking hard"));
3254
3255 let ctx = manager.build_session_context();
3256 assert_eq!(ctx.thinking_level, "high");
3257 }
3258
3259 #[test]
3264 fn test_labels_in_tree_nodes() {
3265 let mut manager = SessionManager::in_memory("/tmp");
3266 let id1 = manager.append_message(user_msg("hello"));
3267 let id2 = manager.append_message(assistant_msg("hi"));
3268
3269 manager.add_label(&id1, "start").unwrap();
3270 manager.add_label(&id2, "response").unwrap();
3271
3272 let tree = manager.get_tree(Uuid::nil()).unwrap();
3273 let node1 = &tree[0];
3274 assert_eq!(node1.label, Some("start".to_string()));
3275
3276 let node2 = &node1.children[0];
3277 assert_eq!(node2.label, Some("response".to_string()));
3278 }
3279
3280 #[test]
3281 fn test_last_label_wins() {
3282 let mut manager = SessionManager::in_memory("/tmp");
3283 let id1 = manager.append_message(user_msg("hello"));
3284
3285 manager.add_label(&id1, "first").unwrap();
3286 manager.add_label(&id1, "second").unwrap();
3287 manager.add_label(&id1, "third").unwrap();
3288
3289 assert_eq!(manager.get_label(&id1), Some("third".to_string()));
3290 }
3291
3292 #[test]
3297 fn test_branch_throws_for_nonexistent() {
3298 let mut manager = SessionManager::in_memory("/tmp");
3299 manager.append_message(user_msg("hello"));
3300
3301 let result = manager.branch("nonexistent");
3302 assert!(result.is_err());
3303 }
3304
3305 #[test]
3310 fn test_labels_not_in_session_context() {
3311 let mut manager = SessionManager::in_memory("/tmp");
3312 let msg_id = manager.append_message(user_msg("hello"));
3313 manager.add_label(&msg_id, "checkpoint").unwrap();
3314
3315 let ctx = manager.build_session_context();
3316 assert_eq!(ctx.messages.len(), 1);
3318 assert!(ctx.messages[0].is_user());
3319 }
3320
3321 #[test]
3326 fn test_custom_entry_integrates_into_tree() {
3327 let mut manager = SessionManager::in_memory("/tmp");
3328 let msg_id = manager.append_message(user_msg("hello"));
3329 let custom_id =
3330 manager.append_custom_entry("my_data", Some(serde_json::json!({"foo": "bar"})));
3331 let msg2_id = manager.append_message(assistant_msg("response"));
3332
3333 let entries = manager.get_entries();
3334 let custom = entries.iter().find(|e| e.id == custom_id).unwrap();
3335 assert_eq!(custom.parent_id, Some(msg_id));
3336
3337 if let AgentMessage::Custom { custom_type, .. } = &custom.message {
3338 assert_eq!(custom_type, "my_data");
3339 } else {
3340 panic!("Expected Custom message");
3341 }
3342
3343 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
3344 assert_eq!(msg2.parent_id, Some(custom_id));
3345
3346 let ctx = manager.build_session_context();
3348 assert_eq!(ctx.messages.len(), 2);
3350 }
3351
3352 #[test]
3357 fn test_get_branch_empty_session() {
3358 let manager = SessionManager::in_memory("/tmp");
3359 let branch = manager.get_branch(None);
3360 assert!(branch.is_empty());
3361 }
3362
3363 #[test]
3364 fn test_get_tree_empty_session() {
3365 let manager = SessionManager::in_memory("/tmp");
3366 let tree = manager.get_tree(Uuid::nil()).unwrap();
3367 assert!(tree.is_empty());
3368 }
3369
3370 #[test]
3375 fn test_complex_tree_with_branches_and_compaction() {
3376 let mut manager = SessionManager::in_memory("/tmp");
3377
3378 manager.append_message(user_msg("start"));
3380 manager.append_message(assistant_msg("r1"));
3381 let id3 = manager.append_message(user_msg("q2"));
3382 manager.append_message(assistant_msg("r2"));
3383 manager.append_compaction("Compacted history", &id3, 1000, None, None);
3384 manager.append_message(user_msg("q3"));
3385 manager.append_message(assistant_msg("r3"));
3386
3387 manager.branch(&id3).unwrap();
3389 manager.append_message(user_msg("wrong path"));
3390 manager.append_message(assistant_msg("wrong response"));
3391
3392 manager.branch_with_summary(Some(&id3), "Tried wrong approach", None, None);
3394 manager.append_message(user_msg("better approach"));
3395
3396 let tree = manager.get_tree(Uuid::nil()).unwrap();
3397 assert_eq!(tree.len(), 1);
3399
3400 let root = &tree[0];
3402 assert!(root.entry.message.is_user());
3403 }
3404
3405 #[test]
3410 fn test_multiple_compactions_returns_latest() {
3411 let mut manager = SessionManager::in_memory("/tmp");
3412 let id1 = manager.append_message(user_msg("a"));
3413 manager.append_message(bare_assistant_msg());
3414 manager.append_compaction("First summary", &id1, 1000, None, None);
3415 manager.append_message(user_msg("c"));
3416 manager.append_message(bare_assistant_msg());
3417 manager.append_compaction("Second summary", &id1, 2000, None, None);
3418
3419 let compactions = manager.get_compaction_entries();
3421 assert_eq!(compactions.len(), 2);
3422
3423 let latest = manager.get_latest_compaction_entry();
3425 assert!(latest.is_some());
3426 }
3427
3428 #[test]
3433 fn test_get_all_compaction_entries() {
3434 let mut manager = SessionManager::in_memory("/tmp");
3435 let id1 = manager.append_message(user_msg("a"));
3436 manager.append_message(bare_assistant_msg());
3437 manager.append_compaction("First", &id1, 1000, None, None);
3438 manager.append_message(user_msg("b"));
3439 manager.append_message(bare_assistant_msg());
3440 manager.append_compaction("Second", &id1, 2000, None, None);
3441
3442 let compactions = manager.get_compaction_entries();
3443 assert_eq!(compactions.len(), 2);
3444 }
3445}