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 file_handle = match fs::OpenOptions::new().create(true).append(true).open(file) {
1303 Ok(h) => h,
1304 Err(e) => {
1305 tracing::warn!("Failed to open session file for append {}: {}", file, e);
1306 return;
1307 }
1308 };
1309 let mut handle = std::io::BufWriter::new(file_handle);
1310
1311 if !self.flushed {
1312 for e in self.file_entries.read().iter() {
1313 if let Ok(line) = serde_json::to_string(e) {
1314 let _ = writeln!(&mut handle, "{}", line);
1315 } else {
1316 tracing::warn!("Failed to serialize session entry, skipping");
1317 }
1318 }
1319 self.flushed = true;
1320 } else {
1321 let file_entry = convert_from_session_entry(entry);
1323 if let Ok(line) = serde_json::to_string(&file_entry) {
1324 let _ = writeln!(&mut handle, "{}", line);
1325 } else {
1326 tracing::warn!("Failed to serialize incremental session entry, skipping");
1327 }
1328 }
1329 }
1330
1331 fn _append_entry(&mut self, entry: SessionEntry) {
1335 let file_entry = convert_from_session_entry(&entry);
1336 self.file_entries.write().push(FileEntry::Entry(file_entry));
1337 self.by_id.write().insert(entry.id.clone(), entry.clone());
1338 *self.leaf_id.write() = Some(entry.id.clone());
1339 self._persist(&entry);
1340 }
1341
1342 pub fn append_message(&mut self, message: AgentMessage) -> String {
1344 let leaf = self.leaf_id.read().clone();
1345 let id = Uuid::new_v4().to_string();
1346 let entry = SessionEntry {
1347 id: id.clone(),
1348 parent_id: leaf,
1349 timestamp: Utc::now().timestamp_millis(),
1350 message,
1351 };
1352 self._append_entry(entry);
1353 id
1354 }
1355
1356 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1358 let leaf = self.leaf_id.read().clone();
1359 let id = Uuid::new_v4().to_string();
1360 let entry = SessionEntry {
1361 id: id.clone(),
1362 parent_id: leaf,
1363 timestamp: Utc::now().timestamp_millis(),
1364 message: AgentMessage::Custom {
1365 custom_type: "thinking_level_change".to_string(),
1366 content: ContentValue::String(thinking_level.to_string()),
1367 display: false,
1368 details: None,
1369 timestamp: Utc::now().timestamp_millis(),
1370 },
1371 };
1372 self._append_entry(entry);
1373 id
1374 }
1375
1376 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1378 let leaf = self.leaf_id.read().clone();
1379 let id = Uuid::new_v4().to_string();
1380 let entry = SessionEntry {
1381 id: id.clone(),
1382 parent_id: leaf,
1383 timestamp: Utc::now().timestamp_millis(),
1384 message: AgentMessage::Custom {
1385 custom_type: "model_change".to_string(),
1386 content: ContentValue::String(format!("{}:{}", provider, model_id)),
1387 display: false,
1388 details: None,
1389 timestamp: Utc::now().timestamp_millis(),
1390 },
1391 };
1392 self._append_entry(entry);
1393 id
1394 }
1395
1396 pub fn append_compaction(
1398 &mut self,
1399 summary: &str,
1400 _first_kept_entry_id: &str,
1401 tokens_before: i64,
1402 _details: Option<serde_json::Value>,
1403 _from_hook: Option<bool>,
1404 ) -> String {
1405 let leaf = self.leaf_id.read().clone();
1406 let id = Uuid::new_v4().to_string();
1407 let entry = SessionEntry {
1408 id: id.clone(),
1409 parent_id: leaf,
1410 timestamp: Utc::now().timestamp_millis(),
1411 message: AgentMessage::CompactionSummary {
1412 summary: summary.to_string(),
1413 tokens_before,
1414 timestamp: Utc::now().timestamp_millis(),
1415 },
1416 };
1417 self._append_entry(entry);
1418 id
1419 }
1420
1421 pub fn append_custom_entry(
1423 &mut self,
1424 custom_type: &str,
1425 data: Option<serde_json::Value>,
1426 ) -> String {
1427 let leaf = self.leaf_id.read().clone();
1428 let id = Uuid::new_v4().to_string();
1429 let entry = SessionEntry {
1430 id: id.clone(),
1431 parent_id: leaf,
1432 timestamp: Utc::now().timestamp_millis(),
1433 message: AgentMessage::Custom {
1434 custom_type: custom_type.to_string(),
1435 content: data
1436 .as_ref()
1437 .map(|d| ContentValue::String(d.to_string()))
1438 .unwrap_or(ContentValue::String(String::new())),
1439 display: false,
1440 details: data.clone(),
1441 timestamp: Utc::now().timestamp_millis(),
1442 },
1443 };
1444 self._append_entry(entry);
1445 id
1446 }
1447
1448 pub fn append_session_info(&mut self, name: &str) -> String {
1450 let leaf = self.leaf_id.read().clone();
1451 let id = Uuid::new_v4().to_string();
1452 let entry = SessionEntry {
1453 id: id.clone(),
1454 parent_id: leaf,
1455 timestamp: Utc::now().timestamp_millis(),
1456 message: AgentMessage::Custom {
1457 custom_type: "session_info".to_string(),
1458 content: ContentValue::String(name.trim().to_string()),
1459 display: false,
1460 details: None,
1461 timestamp: Utc::now().timestamp_millis(),
1462 },
1463 };
1464 self._append_entry(entry);
1465 id
1466 }
1467
1468 pub fn get_session_name(&self) -> Option<String> {
1470 let entries = self.get_entries();
1471 for entry in entries.iter().rev() {
1472 if let AgentMessage::Custom {
1473 custom_type,
1474 content,
1475 ..
1476 } = &entry.message
1477 && custom_type == "session_info"
1478 {
1479 return Some(content.as_str().trim().to_string()).filter(|s| !s.is_empty());
1480 }
1481 }
1482 None
1483 }
1484
1485 pub fn append_custom_message_entry(
1487 &mut self,
1488 custom_type: &str,
1489 content: ContentValue,
1490 display: bool,
1491 details: Option<serde_json::Value>,
1492 ) -> String {
1493 let leaf = self.leaf_id.read().clone();
1494 let id = Uuid::new_v4().to_string();
1495 let entry = SessionEntry {
1496 id: id.clone(),
1497 parent_id: leaf,
1498 timestamp: Utc::now().timestamp_millis(),
1499 message: AgentMessage::Custom {
1500 custom_type: custom_type.to_string(),
1501 content,
1502 display,
1503 details,
1504 timestamp: Utc::now().timestamp_millis(),
1505 },
1506 };
1507 self._append_entry(entry);
1508 id
1509 }
1510
1511 pub fn get_leaf_id(&self) -> Option<String> {
1517 self.leaf_id.read().clone()
1518 }
1519
1520 pub fn set_leaf_from_entry(&self, entry_id: &str) -> Result<(), String> {
1527 if !self.by_id.read().contains_key(entry_id) {
1528 return Err(format!("Entry {} not found", entry_id));
1529 }
1530 *self.leaf_id.write() = Some(entry_id.to_string());
1531 Ok(())
1532 }
1533
1534 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1536 self.leaf_id
1537 .read()
1538 .as_ref()
1539 .and_then(|id| self.by_id.read().get(id).cloned())
1540 }
1541
1542 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
1544 self.by_id.read().get(id).cloned()
1545 }
1546
1547 pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1549 self.by_id
1550 .read()
1551 .values()
1552 .filter(|e| e.parent_id.as_deref() == Some(parent_id))
1553 .cloned()
1554 .collect()
1555 }
1556
1557 pub fn get_parent(&self, id: &str) -> Option<SessionEntry> {
1559 self.by_id
1560 .read()
1561 .get(id)
1562 .and_then(|e| e.parent_id.as_deref())
1563 .and_then(|pid| self.by_id.read().get(pid).cloned())
1564 }
1565
1566 pub fn get_label(&self, id: &str) -> Option<String> {
1568 self.labels_by_id.read().get(id).cloned()
1569 }
1570
1571 pub fn append_label_change(
1573 &mut self,
1574 target_id: &str,
1575 label: Option<&str>,
1576 ) -> Result<String, String> {
1577 if !self.by_id.read().contains_key(target_id) {
1578 return Err(format!("Entry {} not found", target_id));
1579 }
1580
1581 let leaf = self.leaf_id.read().clone();
1582 let id = Uuid::new_v4().to_string();
1583 let entry = SessionEntry {
1584 id: id.clone(),
1585 parent_id: leaf,
1586 timestamp: Utc::now().timestamp_millis(),
1587 message: AgentMessage::Custom {
1588 custom_type: "label".to_string(),
1589 content: ContentValue::String(label.unwrap_or("").to_string()),
1590 display: false,
1591 details: Some(serde_json::json!({ "targetId": target_id })),
1592 timestamp: Utc::now().timestamp_millis(),
1593 },
1594 };
1595
1596 self._append_entry(entry);
1597
1598 if let Some(l) = label {
1599 self.labels_by_id
1600 .write()
1601 .insert(target_id.to_string(), l.to_string());
1602 self.label_timestamps_by_id
1603 .write()
1604 .insert(target_id.to_string(), Utc::now().to_rfc3339());
1605 } else {
1606 self.labels_by_id.write().remove(target_id);
1607 self.label_timestamps_by_id.write().remove(target_id);
1608 }
1609
1610 Ok(id)
1611 }
1612
1613 pub fn get_branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1615 let mut path = Vec::new();
1616 let leaf_fallback = self.leaf_id.read().clone();
1617 let start_id = from_id.or(leaf_fallback.as_deref());
1618 let Some(start_id) = start_id else {
1619 return path;
1620 };
1621
1622 let by_id = self.by_id.read();
1624 let mut current = by_id.get(start_id).cloned();
1625 while let Some(entry) = current {
1626 path.insert(0, entry.clone());
1627 current = entry
1628 .parent_id
1629 .as_ref()
1630 .and_then(|pid| by_id.get(pid).cloned());
1631 }
1632 path
1633 }
1634
1635 pub fn get_path_to_root(&self, from_id: &str) -> Vec<SessionEntry> {
1637 self.get_branch(Some(from_id))
1638 }
1639
1640 pub fn get_ancestry(&self, from_id: &str) -> Vec<SessionEntry> {
1642 self.get_branch(Some(from_id))
1643 }
1644
1645 pub fn get_depth(&self, id: &str) -> i64 {
1647 let mut depth = 0;
1648 let mut current = self.by_id.read().get(id).cloned();
1649 while let Some(entry) = current {
1650 depth += 1;
1651 current = entry
1652 .parent_id
1653 .as_ref()
1654 .and_then(|pid| self.by_id.read().get(pid).cloned());
1655 }
1656 depth - 1 }
1658
1659 pub fn build_session_context(&self) -> SessionContext {
1661 let entries = self.get_entries();
1662 let leaf_id = self.leaf_id.read().clone();
1663 build_session_context_internal(&entries, leaf_id, None)
1664 }
1665
1666 pub fn get_header(&self) -> Option<SessionHeader> {
1668 self.file_entries.read().iter().find_map(|e| match e {
1669 FileEntry::Header(h) => Some(h.clone()),
1670 _ => None,
1671 })
1672 }
1673
1674 pub fn get_entries(&self) -> Vec<SessionEntry> {
1676 self.by_id.read().values().cloned().collect()
1677 }
1678
1679 pub fn get_tree(&self, _id: Uuid) -> anyhow::Result<Vec<SessionTreeNode>> {
1682 let entries = self.get_entries();
1683 let labels: HashMap<String, String> = self.labels_by_id.read().clone();
1684 let label_timestamps: HashMap<String, String> = self.label_timestamps_by_id.read().clone();
1685
1686 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
1687 let mut root_ids: Vec<String> = Vec::new();
1688
1689 for entry in &entries {
1691 adj.insert(entry.id.clone(), Vec::new());
1692 }
1693
1694 for entry in &entries {
1696 let is_root = match entry.parent_id.as_deref() {
1697 Some(pid) if pid != entry.id => !adj.contains_key(pid),
1698 _ => true,
1699 };
1700 if is_root {
1701 root_ids.push(entry.id.clone());
1702 } else if let Some(ref pid) = entry.parent_id {
1703 if let Some(children) = adj.get_mut(pid.as_str()) {
1704 children.push(entry.id.clone());
1705 } else {
1706 root_ids.push(entry.id.clone());
1707 }
1708 }
1709 }
1710
1711 let entries_map: HashMap<String, SessionEntry> =
1713 entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1714
1715 fn build(
1717 id: &str,
1718 adj: &HashMap<String, Vec<String>>,
1719 entries_map: &HashMap<String, SessionEntry>,
1720 labels: &HashMap<String, String>,
1721 label_timestamps: &HashMap<String, String>,
1722 ) -> anyhow::Result<SessionTreeNode> {
1723 let entry = entries_map
1724 .get(id)
1725 .ok_or_else(|| anyhow::anyhow!("Corrupted session: entry {} not found", id))?
1726 .clone();
1727 let child_ids = adj.get(id).cloned().unwrap_or_default();
1728 let children: Vec<SessionTreeNode> = child_ids
1729 .iter()
1730 .map(|cid| build(cid, adj, entries_map, labels, label_timestamps))
1731 .collect::<Result<Vec<_>, _>>()?;
1732 Ok(SessionTreeNode {
1733 entry,
1734 children,
1735 label: labels.get(id).cloned(),
1736 label_timestamp: label_timestamps.get(id).cloned(),
1737 })
1738 }
1739
1740 let mut roots = root_ids
1741 .into_iter()
1742 .map(|rid| build(&rid, &adj, &entries_map, &labels, &label_timestamps))
1743 .collect::<anyhow::Result<Vec<_>>>()?;
1744
1745 sort_tree_by_timestamp(&mut roots);
1746 Ok(roots)
1747 }
1748
1749 pub fn branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1755 if !self.by_id.read().contains_key(branch_from_id) {
1756 return Err(format!("Entry {} not found", branch_from_id));
1757 }
1758 *self.leaf_id.write() = Some(branch_from_id.to_string());
1759 Ok(())
1760 }
1761
1762 pub fn reset_leaf(&mut self) {
1764 *self.leaf_id.write() = None;
1765 }
1766
1767 pub fn branch_with_summary(
1769 &mut self,
1770 branch_from_id: Option<&str>,
1771 summary: &str,
1772 _details: Option<serde_json::Value>,
1773 _from_hook: Option<bool>,
1774 ) -> String {
1775 if let Some(id) = branch_from_id
1776 && !self.by_id.read().contains_key(id)
1777 {
1778 return String::new();
1779 }
1780
1781 *self.leaf_id.write() = branch_from_id.map(|s| s.to_string());
1782
1783 let id = Uuid::new_v4().to_string();
1784 let entry = SessionEntry {
1785 id: id.clone(),
1786 parent_id: branch_from_id.map(|s| s.to_string()),
1787 timestamp: Utc::now().timestamp_millis(),
1788 message: AgentMessage::BranchSummary {
1789 summary: summary.to_string(),
1790 from_id: branch_from_id.unwrap_or("root").to_string(),
1791 timestamp: Utc::now().timestamp_millis(),
1792 },
1793 };
1794
1795 self._append_entry(entry);
1796 id
1797 }
1798
1799 pub fn add_label(&mut self, target_id: &str, label: &str) -> Result<String, String> {
1801 self.append_label_change(target_id, Some(label))
1802 }
1803
1804 pub fn remove_label(&mut self, target_id: &str) -> Result<String, String> {
1806 self.append_label_change(target_id, None)
1807 }
1808
1809 pub fn get_latest_compaction_entry(&self) -> Option<SessionEntry> {
1815 let entries = self.get_entries();
1816 for entry in entries.iter().rev() {
1817 if let AgentMessage::CompactionSummary { .. } = &entry.message {
1818 return Some(entry.clone());
1819 }
1820 }
1821 None
1822 }
1823
1824 pub fn get_compaction_entries(&self) -> Vec<SessionEntry> {
1826 self.get_entries()
1827 .iter()
1828 .filter(|e| matches!(&e.message, AgentMessage::CompactionSummary { .. }))
1829 .cloned()
1830 .collect()
1831 }
1832
1833 pub fn get_session_stats(&self) -> SessionStats {
1839 let entries = self.get_entries();
1840 let mut message_count = 0i64;
1841 let mut user_message_count = 0i64;
1842 let mut assistant_message_count = 0i64;
1843 let mut total_chars = 0i64;
1844 let mut total_tokens_estimate = 0i64;
1845
1846 for entry in &entries {
1847 if let AgentMessage::User { .. } = &entry.message {
1848 user_message_count += 1;
1849 }
1850 if let AgentMessage::Assistant { .. } = &entry.message {
1851 assistant_message_count += 1;
1852 }
1853 if entry.message.is_user() || entry.message.is_assistant() {
1854 message_count += 1;
1855 let content = entry.content();
1857 let chars = content.len() as i64;
1858 total_chars += chars;
1859 total_tokens_estimate += (chars as f64 / 4.0).ceil() as i64;
1860 }
1861 }
1862
1863 SessionStats {
1864 message_count,
1865 user_message_count,
1866 assistant_message_count,
1867 total_chars,
1868 estimated_tokens: total_tokens_estimate,
1869 }
1870 }
1871
1872 pub async fn list(cwd: &str, session_dir: Option<&str>) -> Result<Vec<SessionInfo>> {
1878 let dir = session_dir
1879 .map(|s| s.to_string())
1880 .unwrap_or_else(|| get_default_session_dir(cwd));
1881 list_sessions_from_dir(&dir).await
1882 }
1883
1884 pub async fn list_all() -> Result<Vec<SessionInfo>> {
1886 let sessions_dir = get_sessions_dir();
1887
1888 if !Path::new(&sessions_dir).exists() {
1889 return Ok(Vec::new());
1890 }
1891
1892 let mut all_sessions = Vec::new();
1893 let entries = fs::read_dir(&sessions_dir)?;
1894
1895 for entry in entries {
1896 let entry = entry?;
1897 let path = entry.path();
1898 if path.is_dir()
1899 && let Ok(sessions) = list_sessions_from_dir(&path.to_string_lossy()).await
1900 {
1901 all_sessions.extend(sessions);
1902 }
1903 }
1904
1905 all_sessions.sort_by_key(|b| std::cmp::Reverse(b.modified));
1906 Ok(all_sessions)
1907 }
1908
1909 pub fn fork_from(
1911 source_path: &str,
1912 target_cwd: &str,
1913 session_dir: Option<&str>,
1914 ) -> Result<Self, String> {
1915 let source_entries = load_entries_from_file(source_path);
1916 if source_entries.is_empty() {
1917 return Err(format!(
1918 "Cannot fork: source session file is empty or invalid: {}",
1919 source_path
1920 ));
1921 }
1922
1923 let source_header = source_entries.iter().find_map(|e| match e {
1924 FileEntry::Header(h) => Some(h),
1925 _ => None,
1926 });
1927 if source_header.is_none() {
1928 return Err(format!(
1929 "Cannot fork: source session has no header: {}",
1930 source_path
1931 ));
1932 }
1933
1934 let dir = session_dir
1935 .map(|s| s.to_string())
1936 .unwrap_or_else(|| get_default_session_dir(target_cwd));
1937
1938 if !Path::new(&dir).exists() {
1939 let _ = fs::create_dir_all(&dir);
1940 }
1941
1942 let new_session_id = Uuid::new_v4().to_string();
1943 let timestamp = Utc::now().to_rfc3339();
1944 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1945 let short_id = &new_session_id[..8];
1946 let new_session_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
1947
1948 let new_header = SessionHeader {
1950 entry_type: "session".to_string(),
1951 version: Some(CURRENT_SESSION_VERSION),
1952 id: new_session_id.clone(),
1953 timestamp: timestamp.clone(),
1954 cwd: target_cwd.to_string(),
1955 parent_session: Some(source_path.to_string()),
1956 };
1957
1958 let mut handle = fs::OpenOptions::new()
1959 .create(true)
1960 .truncate(true)
1961 .write(true)
1962 .open(&new_session_file)
1963 .map_err(|e| e.to_string())?;
1964 writeln!(
1965 &mut handle,
1966 "{}",
1967 serde_json::to_string(&new_header).expect("session header serializable")
1968 )
1969 .map_err(|e| e.to_string())?;
1970
1971 for file_entry in &source_entries {
1973 if let FileEntry::Entry(_) = file_entry {
1974 writeln!(
1975 &mut handle,
1976 "{}",
1977 serde_json::to_string(file_entry).expect("session entry serializable")
1978 )
1979 .map_err(|e| e.to_string())?;
1980 }
1981 }
1982
1983 Ok(Self::open(&new_session_file, Some(&dir), Some(target_cwd)))
1984 }
1985
1986 pub fn delete_session(path: &str) -> Result<()> {
1988 fs::remove_file(path).context("Failed to delete session file")?;
1989 Ok(())
1990 }
1991
1992 pub fn rename_session(&mut self, name: &str) -> String {
1994 self.append_session_info(name)
1995 }
1996
1997 pub async fn new() -> Result<Self> {
2003 Self::new_async().await
2004 }
2005
2006 pub async fn new_async() -> Result<Self> {
2008 let home = dirs::home_dir().context("Cannot find home directory")?;
2009 let base_dir = home.join(".oxi");
2010 let sessions_dir = base_dir.join("sessions");
2011 tokio::fs::create_dir_all(&sessions_dir).await?;
2012 let cwd = std::env::current_dir()
2013 .unwrap_or_else(|_| PathBuf::from("."))
2014 .to_string_lossy()
2015 .to_string();
2016 Ok(Self::in_memory(&cwd))
2017 }
2018
2019 pub fn session_path(&self, id: &Uuid) -> PathBuf {
2021 if let Some(file) = &self.session_file {
2022 PathBuf::from(file)
2023 } else {
2024 PathBuf::from(format!("{}/{}.jsonl", self.session_dir, id))
2025 }
2026 }
2027
2028 pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
2030 let mut metas = Vec::new();
2032 let session_dir = Path::new(&self.session_dir);
2033 if !session_dir.exists() {
2034 return Ok(metas);
2035 }
2036 let entries = fs::read_dir(session_dir)?;
2037 for entry in entries {
2038 let entry = entry?;
2039 let path = entry.path();
2040 if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2041 let file_name = path
2042 .file_stem()
2043 .unwrap_or_else(|| std::ffi::OsStr::new(""))
2044 .to_string_lossy()
2045 .to_string();
2046 if let Some(uuid_part) = file_name.split('_').next_back()
2048 && let Ok(uuid) = Uuid::parse_str(uuid_part)
2049 {
2050 let mtime = entry.metadata().ok().and_then(|m| m.modified().ok());
2051 let now_ts = Utc::now().timestamp_millis();
2052 metas.push(SessionMeta {
2053 id: uuid,
2054 parent_id: None,
2055 root_id: None,
2056 branch_point: None,
2057 created_at: now_ts,
2058 updated_at: mtime
2059 .map(|t| {
2060 let dt: DateTime<Utc> = DateTime::from(t);
2061 dt.timestamp_millis()
2062 })
2063 .unwrap_or(now_ts),
2064 name: None,
2065 });
2066 }
2067 }
2068 }
2069 metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
2070 Ok(metas)
2071 }
2072
2073 pub async fn save(&self, _id: Uuid, _entries: &[SessionEntry]) -> Result<()> {
2075 self._rewrite_file();
2076 Ok(())
2077 }
2078
2079 pub async fn load(&self, _id: Uuid) -> Result<Vec<SessionEntry>> {
2081 Ok(self.get_entries())
2082 }
2083
2084 pub async fn delete(&self, id: Uuid) -> Result<()> {
2086 let path = self.session_path(&id);
2087 if path.exists() {
2088 fs::remove_file(path).context("Failed to delete session file")?;
2089 }
2090 Ok(())
2091 }
2092
2093 pub async fn branch_from(
2095 &self,
2096 parent_id: Uuid,
2097 entry_id: Uuid,
2098 ) -> Result<(Uuid, Vec<SessionEntry>)> {
2099 let _entry_id_str = entry_id.to_string();
2100 let _parent_id_str = parent_id.to_string();
2101
2102 let _entries = self.get_entries();
2104 let path = self.get_branch(Some(&entry_id.to_string()));
2105
2106 let new_id = Uuid::new_v4();
2107 let new_entries: Vec<SessionEntry> = path
2108 .into_iter()
2109 .map(|e| {
2110 let mut new_entry = e.clone();
2111 new_entry.id = Uuid::new_v4().to_string();
2112 new_entry
2113 })
2114 .collect();
2115
2116 Ok((new_id, new_entries))
2119 }
2120
2121 pub async fn get_branch_info(&self, _id: Uuid) -> Result<Option<BranchInfo>> {
2123 Ok(None)
2125 }
2126
2127 pub async fn get_tree_async(&self, _id: Uuid) -> Result<Vec<SessionTreeNode>> {
2129 self.get_tree(Uuid::nil())
2130 }
2131
2132 pub async fn save_meta(&self, _meta: &SessionMeta) -> Result<()> {
2134 Ok(())
2135 }
2136
2137 pub async fn load_meta(&self, _id: Uuid) -> Result<Option<SessionMeta>> {
2139 Ok(None)
2140 }
2141
2142 pub async fn create_session(&mut self) -> Result<SessionMeta> {
2144 let id = Uuid::new_v4();
2145 let meta = SessionMeta::new(id);
2146 Ok(meta)
2147 }
2148
2149 pub fn branch_from_entry(&self, entry_id: &str) -> Result<String, String> {
2151 let path = self
2152 .get_session_file()
2153 .ok_or_else(|| "No session file path".to_string())?;
2154 let source_entries = load_entries_from_file(&path);
2155 if source_entries.is_empty() {
2156 return Err("Cannot fork: source session is empty".to_string());
2157 }
2158 let _header = source_entries
2160 .iter()
2161 .find_map(|e| match e {
2162 FileEntry::Header(h) => Some(h),
2163 _ => None,
2164 })
2165 .ok_or_else(|| "Missing session header".to_string())?;
2166 let new_id = Uuid::new_v4().to_string();
2167 let timestamp = chrono::Utc::now().to_rfc3339();
2168 let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
2169 let short_id = &new_id[..8];
2170 let dir = std::path::Path::new(&path)
2171 .parent()
2172 .map(|p| p.to_string_lossy().into_owned())
2173 .unwrap_or_else(|| ".".to_string());
2174 let new_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
2175 let mut found = false;
2176 let mut new_entries = vec![FileEntry::Header(SessionHeader {
2177 entry_type: "session".to_string(),
2178 version: Some(CURRENT_SESSION_VERSION),
2179 id: new_id.clone(),
2180 timestamp,
2181 cwd: self.get_cwd(),
2182 parent_session: Some(path),
2183 })];
2184 for file_entry in &source_entries {
2185 if let FileEntry::Entry(entry) = file_entry {
2186 let eid = match entry {
2187 SessionEntryEnum::Message(m) => m.base.id.clone(),
2188 SessionEntryEnum::ThinkingLevelChange(m) => m.base.id.clone(),
2189 SessionEntryEnum::ModelChange(m) => m.base.id.clone(),
2190 SessionEntryEnum::Compaction(m) => m.base.id.clone(),
2191 SessionEntryEnum::BranchSummary(m) => m.base.id.clone(),
2192 SessionEntryEnum::Custom(m) => m.base.id.clone(),
2193 SessionEntryEnum::Label(m) => m.base.id.clone(),
2194 SessionEntryEnum::SessionInfo(m) => m.base.id.clone(),
2195 SessionEntryEnum::CustomMessage(m) => m.base.id.clone(),
2196 };
2197 if eid == entry_id {
2198 found = true;
2199 let mut entry = entry.clone();
2202 clear_entry_parent_id(&mut entry);
2203 new_entries.push(FileEntry::Entry(entry));
2204 } else if found {
2205 new_entries.push(FileEntry::Entry(entry.clone()));
2206 }
2207 }
2208 }
2209 if !found {
2210 return Err(format!("Entry not found: {}", entry_id));
2211 }
2212 let mut handle = std::fs::OpenOptions::new()
2213 .create(true)
2214 .truncate(true)
2215 .write(true)
2216 .open(&new_file)
2217 .map_err(|e| e.to_string())?;
2218 for entry in &new_entries {
2219 let line = serde_json::to_string(entry).map_err(|e| e.to_string())?;
2220 writeln!(&mut handle, "{}", line).map_err(|e| e.to_string())?;
2221 }
2222 Ok(new_file)
2223 }
2224}
2225
2226fn clear_entry_parent_id(entry: &mut SessionEntryEnum) {
2233 match entry {
2234 SessionEntryEnum::Message(m) => m.base.parent_id = None,
2235 SessionEntryEnum::ThinkingLevelChange(m) => m.base.parent_id = None,
2236 SessionEntryEnum::ModelChange(m) => m.base.parent_id = None,
2237 SessionEntryEnum::Compaction(m) => m.base.parent_id = None,
2238 SessionEntryEnum::BranchSummary(m) => m.base.parent_id = None,
2239 SessionEntryEnum::Custom(m) => m.base.parent_id = None,
2240 SessionEntryEnum::Label(m) => m.base.parent_id = None,
2241 SessionEntryEnum::SessionInfo(m) => m.base.parent_id = None,
2242 SessionEntryEnum::CustomMessage(m) => m.base.parent_id = None,
2243 }
2244}
2245
2246fn convert_to_session_entry(entry: &SessionEntryEnum) -> Option<SessionEntry> {
2248 match entry {
2249 SessionEntryEnum::Message(m) => Some(SessionEntry {
2250 id: m.base.id.clone(),
2251 parent_id: m.base.parent_id.clone(),
2252 timestamp: DateTime::parse_from_rfc3339(&m.base.timestamp)
2253 .map(|dt| dt.timestamp_millis())
2254 .unwrap_or(0),
2255 message: m.message.clone(),
2256 }),
2257 _ => None, }
2259}
2260
2261fn convert_from_session_entry(entry: &SessionEntry) -> SessionEntryEnum {
2263 let timestamp = DateTime::from_timestamp_millis(entry.timestamp)
2264 .map(|dt| dt.to_rfc3339())
2265 .unwrap_or_else(|| Utc::now().to_rfc3339());
2266
2267 SessionEntryEnum::Message(SessionMessageEntry {
2268 base: SessionEntryBase {
2269 entry_type: "message".to_string(),
2270 id: entry.id.clone(),
2271 parent_id: entry.parent_id.clone(),
2272 timestamp,
2273 },
2274 message: entry.message.clone(),
2275 })
2276}
2277
2278#[derive(Debug, Clone)]
2284pub struct SessionStats {
2285 pub message_count: i64,
2287 pub user_message_count: i64,
2289 pub assistant_message_count: i64,
2291 pub total_chars: i64,
2293 pub estimated_tokens: i64,
2295}
2296
2297#[derive(Debug, Clone)]
2303pub struct NewSessionOptions {
2304 pub id: Option<String>,
2306 pub parent_session: Option<String>,
2308}
2309
2310pub fn get_default_session_dir(cwd: &str) -> String {
2316 let agent_dir = get_agent_dir();
2317 let safe_path = format!("--{}--", cwd.replace(['/', '\\', ':'], "-"));
2318 let session_dir = format!("{}/sessions/{}", agent_dir, safe_path);
2319
2320 if !Path::new(&session_dir).exists() {
2321 let _ = fs::create_dir_all(&session_dir);
2322 }
2323
2324 session_dir
2325}
2326
2327fn get_agent_dir() -> String {
2328 dirs::home_dir()
2329 .map(|h| h.join(".oxi").to_string_lossy().to_string())
2330 .unwrap_or_else(|| ".oxi".to_string())
2331}
2332
2333fn get_sessions_dir() -> String {
2334 format!("{}/sessions", get_agent_dir())
2335}
2336
2337fn load_entries_from_file(file_path: &str) -> Vec<FileEntry> {
2339 if !Path::new(file_path).exists() {
2340 return Vec::new();
2341 }
2342
2343 let file = match File::open(file_path) {
2344 Ok(f) => f,
2345 Err(_) => return Vec::new(),
2346 };
2347
2348 let reader = BufReader::new(file);
2349 let mut entries = Vec::new();
2350
2351 for line in reader.lines() {
2352 let line = match line {
2353 Ok(l) => l,
2354 Err(_) => continue,
2355 };
2356 if line.trim().is_empty() {
2357 continue;
2358 }
2359 match serde_json::from_str::<FileEntry>(&line) {
2360 Ok(entry) => entries.push(entry),
2361 Err(_) => continue,
2362 }
2363 }
2364
2365 if entries.is_empty() {
2367 return entries;
2368 }
2369 let header = match &entries[0] {
2370 FileEntry::Header(h) => h,
2371 _ => return Vec::new(),
2372 };
2373 if header.entry_type != "session" || header.id.is_empty() {
2374 return Vec::new();
2375 }
2376
2377 entries
2378}
2379
2380fn is_valid_session_file(file_path: &str) -> bool {
2382 if let Ok(mut file) = File::open(file_path) {
2383 use std::io::Read;
2384 let mut buffer = vec![0u8; 512];
2385 if let Ok(bytes_read) = file.read(&mut buffer)
2386 && let Ok(content) = String::from_utf8(buffer[..bytes_read].to_vec())
2387 && let Some(first_line) = content.split('\n').next()
2388 && let Ok(header) = serde_json::from_str::<SessionHeader>(first_line)
2389 {
2390 return header.entry_type == "session" && !header.id.is_empty();
2391 }
2392 }
2393 false
2394}
2395
2396pub fn find_recent_session_path(cwd: &str) -> Option<String> {
2398 let dir = get_default_session_dir(cwd);
2399 find_most_recent_session(&dir)
2400}
2401
2402fn find_most_recent_session(session_dir: &str) -> Option<String> {
2403 if !Path::new(session_dir).exists() {
2404 return None;
2405 }
2406
2407 let mut files: Vec<(String, std::time::SystemTime)> = Vec::new();
2408
2409 if let Ok(entries) = fs::read_dir(session_dir) {
2410 for entry in entries.flatten() {
2411 let path = entry.path();
2412 if path.extension().map(|e| e == "jsonl").unwrap_or(false)
2413 && let Some(path_str) = path.to_str()
2414 && is_valid_session_file(path_str)
2415 && let Ok(metadata) = entry.metadata()
2416 && let Ok(mtime) = metadata.modified()
2417 {
2418 files.push((path_str.to_string(), mtime));
2419 }
2420 }
2421 }
2422
2423 files.sort_by_key(|b| std::cmp::Reverse(b.1));
2424 files.into_iter().next().map(|(p, _)| p)
2425}
2426
2427pub fn resolve_session_path(input: &str, cwd: &str) -> Result<String, String> {
2429 let path = input.trim();
2430 if path.is_empty() {
2431 return Err("Empty path".to_string());
2432 }
2433 let resolved = if let Some(rest) = path.strip_prefix('~') {
2434 if rest.is_empty() {
2435 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2436 home.to_string_lossy().into_owned()
2437 } else if let Some(rest) = rest.strip_prefix('/') {
2438 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2439 format!("{}/{}", home.to_string_lossy(), rest)
2440 } else {
2441 let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2442 format!("{}/{}", home.to_string_lossy(), rest)
2443 }
2444 } else if path.starts_with('/') || path.contains(':') {
2445 path.to_string()
2446 } else {
2447 if let Some(stripped) = path.strip_prefix("./") {
2448 format!("{}/{}", cwd.trim_end_matches('/'), stripped)
2449 } else {
2450 format!("{}/{}", cwd.trim_end_matches('/'), path)
2451 }
2452 };
2453 let p = std::path::Path::new(&resolved);
2454 p.canonicalize()
2455 .map(|c| c.to_string_lossy().into_owned())
2456 .or(Ok(resolved))
2457}
2458
2459fn build_session_context_internal(
2461 entries: &[SessionEntry],
2462 leaf_id: Option<String>,
2463 _by_id: Option<&RwLock<HashMap<String, SessionEntry>>>,
2464) -> SessionContext {
2465 let leaf: Option<&SessionEntry> = leaf_id
2467 .as_ref()
2468 .and_then(|id| entries.iter().find(|e| e.id == *id));
2469
2470 let leaf = leaf.or_else(|| entries.last());
2471
2472 let Some(leaf) = leaf else {
2473 return SessionContext {
2474 messages: Vec::new(),
2475 thinking_level: "off".to_string(),
2476 model: None,
2477 };
2478 };
2479
2480 let mut path: Vec<&SessionEntry> = Vec::new();
2482 let mut current: Option<&SessionEntry> = Some(leaf);
2483 while let Some(entry) = current {
2484 path.insert(0, entry);
2485 current = entry
2486 .parent_id
2487 .as_ref()
2488 .and_then(|pid| entries.iter().find(|e| e.id == *pid));
2489 }
2490
2491 let mut thinking_level = "off".to_string();
2493 let mut model: Option<ModelInfo> = None;
2494
2495 for entry in &path {
2496 if let AgentMessage::Assistant {
2497 provider, model_id, ..
2498 } = &entry.message
2499 {
2500 model = Some(ModelInfo {
2501 provider: provider.clone().unwrap_or_default(),
2502 model_id: model_id.clone().unwrap_or_default(),
2503 });
2504 }
2505 if let AgentMessage::Custom {
2506 custom_type,
2507 content,
2508 ..
2509 } = &entry.message
2510 && custom_type == "thinking_level_change"
2511 {
2512 thinking_level = content.as_str().to_string();
2513 }
2514 }
2515
2516 let messages: Vec<AgentMessage> = path
2518 .iter()
2519 .filter(|e| {
2520 e.message.is_user()
2521 || e.message.is_assistant()
2522 || matches!(&e.message, AgentMessage::BranchSummary { .. })
2523 || matches!(&e.message, AgentMessage::CompactionSummary { .. })
2524 })
2525 .map(|e| e.message.clone())
2526 .collect();
2527
2528 SessionContext {
2529 messages,
2530 thinking_level,
2531 model,
2532 }
2533}
2534
2535fn sort_tree_by_timestamp(nodes: &mut Vec<SessionTreeNode>) {
2537 nodes.sort_by_key(|a| a.entry.timestamp);
2538
2539 for node in nodes {
2540 sort_tree_by_timestamp(&mut node.children);
2541 }
2542}
2543
2544async fn list_sessions_from_dir(dir: &str) -> Result<Vec<SessionInfo>> {
2546 if !Path::new(dir).exists() {
2547 return Ok(Vec::new());
2548 }
2549
2550 let mut sessions = Vec::new();
2551
2552 let entries = fs::read_dir(dir)?;
2553 let files: Vec<String> = entries
2554 .filter_map(|e| e.ok())
2555 .filter(|e| {
2556 e.path()
2557 .extension()
2558 .map(|ext| ext == "jsonl")
2559 .unwrap_or(false)
2560 })
2561 .filter_map(|e| e.path().to_str().map(|s| s.to_string()))
2562 .collect();
2563
2564 for file in files {
2565 if let Some(info) = build_session_info(&file).await {
2566 sessions.push(info);
2567 }
2568 }
2569
2570 Ok(sessions)
2571}
2572
2573async fn build_session_info(file_path: &str) -> Option<SessionInfo> {
2575 let content = fs::read_to_string(file_path).ok()?;
2576 let entries = parse_session_entries(&content)?;
2577
2578 if entries.is_empty() {
2579 return None;
2580 }
2581
2582 let header = match &entries[0] {
2583 FileEntry::Header(h) => h,
2584 _ => return None,
2585 };
2586
2587 let stats = fs::metadata(file_path).ok()?;
2588 let mut message_count = 0i64;
2589 let mut first_message = String::new();
2590 let mut all_messages = Vec::new();
2591 let mut name: Option<String> = None;
2592
2593 for entry in &entries {
2594 if let FileEntry::Entry(e) = entry {
2595 if let SessionEntryEnum::SessionInfo(si) = e {
2597 name = si
2598 .name
2599 .clone()
2600 .map(|n| n.trim().to_string())
2601 .filter(|n| !n.is_empty());
2602 }
2603 if let SessionEntryEnum::Message(m) = e {
2605 if m.message.is_user() {
2606 message_count += 1;
2607 let text = m.message.content();
2608 if !text.is_empty() {
2609 all_messages.push(text.clone());
2610 if first_message.is_empty() {
2611 first_message = text;
2612 }
2613 }
2614 } else if m.message.is_assistant() {
2615 if first_message.is_empty() {
2617 let text = m.message.content();
2618 if !text.is_empty() {
2619 first_message = text;
2620 }
2621 }
2622 }
2623 }
2624 }
2625 }
2626
2627 if first_message.is_empty() {
2631 return None;
2632 }
2633
2634 let cwd = header.cwd.clone();
2635 let parent_session_path = header.parent_session.clone();
2636 let created = chrono::DateTime::parse_from_rfc3339(&header.timestamp)
2637 .map(|dt| dt.with_timezone(&Utc))
2638 .unwrap_or_else(|_| Utc::now());
2639 let modified = get_session_modified_date(&entries, &header.timestamp, &stats);
2640
2641 Some(SessionInfo {
2642 path: file_path.to_string(),
2643 id: header.id.clone(),
2644 cwd,
2645 name,
2646 parent_session_path,
2647 created,
2648 modified,
2649 message_count,
2650 first_message: if first_message.is_empty() {
2651 "(no messages)".to_string()
2652 } else {
2653 first_message
2654 },
2655 all_messages_text: all_messages.join(" "),
2656 })
2657}
2658
2659fn parse_session_entries(content: &str) -> Option<Vec<FileEntry>> {
2661 let mut entries = Vec::new();
2662
2663 for line in content.trim().lines() {
2664 if line.trim().is_empty() {
2665 continue;
2666 }
2667 if let Ok(entry) = serde_json::from_str::<FileEntry>(line) {
2668 entries.push(entry);
2669 }
2670 }
2671
2672 Some(entries)
2673}
2674
2675fn get_session_modified_date(
2677 entries: &[FileEntry],
2678 header_timestamp: &str,
2679 stats: &std::fs::Metadata,
2680) -> DateTime<Utc> {
2681 let last_activity_time = get_last_activity_time(entries);
2682 if let Some(t) = last_activity_time
2683 && t > 0
2684 {
2685 return DateTime::from_timestamp_millis(t).unwrap_or_else(Utc::now);
2686 }
2687
2688 let header_time = chrono::DateTime::parse_from_rfc3339(header_timestamp)
2689 .map(|dt| dt.timestamp_millis())
2690 .unwrap_or(-1);
2691
2692 if header_time > 0 {
2693 return DateTime::from_timestamp_millis(header_time).unwrap_or_else(Utc::now);
2694 }
2695
2696 if let Ok(mtime) = stats.modified() {
2697 return DateTime::from(mtime);
2698 }
2699
2700 Utc::now()
2701}
2702
2703fn get_last_activity_time(entries: &[FileEntry]) -> Option<i64> {
2705 let mut last_activity: Option<i64> = None;
2706
2707 for entry in entries {
2708 let entry = match entry {
2709 FileEntry::Entry(e) => e,
2710 _ => continue,
2711 };
2712
2713 if let SessionEntryEnum::Message(m) = entry
2714 && (m.message.is_user() || m.message.is_assistant())
2715 {
2716 last_activity = Some(std::cmp::max(
2717 last_activity.unwrap_or(0),
2718 m.base.timestamp.parse().unwrap_or(0),
2719 ));
2720 }
2721 }
2722
2723 last_activity
2724}
2725
2726#[cfg(test)]
2731mod tests {
2732 use super::*;
2733
2734 #[test]
2735 fn test_session_creation() {
2736 let manager = SessionManager::in_memory("/tmp");
2737 assert!(!manager.get_session_id().is_empty());
2738 assert_eq!(manager.get_entries().len(), 0);
2739 }
2740
2741 #[test]
2742 fn test_append_message() {
2743 let mut manager = SessionManager::in_memory("/tmp");
2744 let id = manager.append_message(AgentMessage::User {
2745 content: ContentValue::String("Hello".to_string()),
2746 });
2747 assert!(!id.is_empty());
2748 assert_eq!(manager.get_entries().len(), 1);
2749 assert_eq!(manager.get_leaf_id(), Some(id));
2750 }
2751
2752 #[test]
2753 fn test_tree_traversal() {
2754 let mut manager = SessionManager::in_memory("/tmp");
2755 let id1 = manager.append_message(AgentMessage::User {
2756 content: ContentValue::String("Hello".to_string()),
2757 });
2758 let id2 = manager.append_message(AgentMessage::Assistant {
2759 content: vec![],
2760 provider: None,
2761 model_id: None,
2762 usage: None,
2763 stop_reason: None,
2764 });
2765
2766 let branch = manager.get_branch(None);
2768 assert_eq!(branch.len(), 2);
2769
2770 let branch = manager.get_branch(Some(&id1));
2772 assert_eq!(branch.len(), 1);
2773
2774 let children = manager.get_children(&id1);
2776 assert_eq!(children.len(), 1);
2777
2778 let parent = manager.get_parent(&id2);
2780 assert!(parent.is_some());
2781 assert_eq!(parent.unwrap().id, id1);
2782 }
2783
2784 #[test]
2785 fn test_branching() {
2786 let mut manager = SessionManager::in_memory("/tmp");
2787 let id1 = manager.append_message(AgentMessage::User {
2788 content: ContentValue::String("Hello".to_string()),
2789 });
2790 let _id2 = manager.append_message(AgentMessage::Assistant {
2791 content: vec![],
2792 provider: None,
2793 model_id: None,
2794 usage: None,
2795 stop_reason: None,
2796 });
2797 let _id3 = manager.append_message(AgentMessage::User {
2798 content: ContentValue::String("How are you?".to_string()),
2799 });
2800
2801 manager.branch(&id1).unwrap();
2803 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2804
2805 let id4 = manager.append_message(AgentMessage::Assistant {
2807 content: vec![],
2808 provider: None,
2809 model_id: None,
2810 usage: None,
2811 stop_reason: None,
2812 });
2813
2814 assert_eq!(manager.get_entries().len(), 4);
2816
2817 assert_eq!(manager.get_leaf_id(), Some(id4));
2819
2820 let tree = manager.get_tree(Uuid::nil()).unwrap();
2822 assert_eq!(tree.len(), 1); assert_eq!(tree[0].children.len(), 2); }
2825
2826 #[test]
2827 fn test_session_context() {
2828 let mut manager = SessionManager::in_memory("/tmp");
2829 manager.append_message(AgentMessage::User {
2830 content: ContentValue::String("Hello".to_string()),
2831 });
2832 manager.append_message(AgentMessage::Assistant {
2833 content: vec![AssistantContentBlock::Text {
2834 text: "Hi there!".to_string(),
2835 }],
2836 provider: Some("test".to_string()),
2837 model_id: Some("model".to_string()),
2838 usage: None,
2839 stop_reason: None,
2840 });
2841
2842 let context = manager.build_session_context();
2843 assert_eq!(context.messages.len(), 2);
2844 assert!(context.model.is_some());
2845 }
2846
2847 #[test]
2848 fn test_compaction_entry() {
2849 let mut manager = SessionManager::in_memory("/tmp");
2850 let id1 = manager.append_message(AgentMessage::User {
2851 content: ContentValue::String("First message".to_string()),
2852 });
2853 let _id2 = manager.append_message(AgentMessage::Assistant {
2854 content: vec![],
2855 provider: None,
2856 model_id: None,
2857 usage: None,
2858 stop_reason: None,
2859 });
2860
2861 let id3 = manager.append_compaction("Summarized conversation", &id1, 1000, None, None);
2862 assert!(!id3.is_empty());
2863
2864 let latest = manager.get_latest_compaction_entry();
2865 assert!(latest.is_some());
2866 }
2867
2868 #[test]
2869 fn test_labels() {
2870 let mut manager = SessionManager::in_memory("/tmp");
2871 let id1 = manager.append_message(AgentMessage::User {
2872 content: ContentValue::String("Hello".to_string()),
2873 });
2874
2875 manager.add_label(&id1, "important").unwrap();
2876 assert_eq!(manager.get_label(&id1), Some("important".to_string()));
2877
2878 manager.remove_label(&id1).unwrap();
2879 assert_eq!(manager.get_label(&id1), None);
2880 }
2881
2882 fn user_msg(text: &str) -> AgentMessage {
2888 AgentMessage::User {
2889 content: ContentValue::String(text.to_string()),
2890 }
2891 }
2892
2893 fn assistant_msg(text: &str) -> AgentMessage {
2895 AgentMessage::Assistant {
2896 content: vec![AssistantContentBlock::Text {
2897 text: text.to_string(),
2898 }],
2899 provider: Some("anthropic".to_string()),
2900 model_id: Some("claude-test".to_string()),
2901 usage: None,
2902 stop_reason: None,
2903 }
2904 }
2905
2906 fn bare_assistant_msg() -> AgentMessage {
2908 AgentMessage::Assistant {
2909 content: vec![],
2910 provider: None,
2911 model_id: None,
2912 usage: None,
2913 stop_reason: None,
2914 }
2915 }
2916
2917 #[test]
2922 fn test_append_thinking_level_change_integrates() {
2923 let mut manager = SessionManager::in_memory("/tmp");
2924 let msg_id = manager.append_message(user_msg("hello"));
2925 let thinking_id = manager.append_thinking_level_change("high");
2926 let msg2_id = manager.append_message(assistant_msg("response"));
2927
2928 let entries = manager.get_entries();
2929 assert_eq!(entries.len(), 3);
2930
2931 let thinking_entry = entries.iter().find(|e| e.id == thinking_id).unwrap();
2933 assert_eq!(thinking_entry.parent_id, Some(msg_id));
2934
2935 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2936 assert_eq!(msg2.parent_id, Some(thinking_id));
2937 }
2938
2939 #[test]
2940 fn test_append_model_change_integrates() {
2941 let mut manager = SessionManager::in_memory("/tmp");
2942 let msg_id = manager.append_message(user_msg("hello"));
2943 let model_id = manager.append_model_change("openai", "gpt-4");
2944 let msg2_id = manager.append_message(assistant_msg("response"));
2945
2946 let entries = manager.get_entries();
2947 let model_entry = entries.iter().find(|e| e.id == model_id).unwrap();
2948 assert_eq!(model_entry.parent_id, Some(msg_id));
2949
2950 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2951 assert_eq!(msg2.parent_id, Some(model_id));
2952 }
2953
2954 #[test]
2955 fn test_append_compaction_integrates_into_tree() {
2956 let mut manager = SessionManager::in_memory("/tmp");
2957 let id1 = manager.append_message(user_msg("1"));
2958 let id2 = manager.append_message(assistant_msg("2"));
2959 let compaction_id = manager.append_compaction("summary", &id1, 1000, None, None);
2960 let id3 = manager.append_message(user_msg("3"));
2961
2962 let entries = manager.get_entries();
2963 let compaction = entries.iter().find(|e| e.id == compaction_id).unwrap();
2964 assert_eq!(compaction.parent_id, Some(id2));
2965
2966 let msg3 = entries.iter().find(|e| e.id == id3).unwrap();
2967 assert_eq!(msg3.parent_id, Some(compaction_id));
2968
2969 if let AgentMessage::CompactionSummary {
2971 summary,
2972 tokens_before,
2973 ..
2974 } = &compaction.message
2975 {
2976 assert_eq!(summary, "summary");
2977 assert_eq!(*tokens_before, 1000);
2978 } else {
2979 panic!("Expected CompactionSummary");
2980 }
2981 }
2982
2983 #[test]
2984 fn test_leaf_pointer_advances() {
2985 let mut manager = SessionManager::in_memory("/tmp");
2986 assert!(manager.get_leaf_id().is_none());
2987
2988 let id1 = manager.append_message(user_msg("1"));
2989 assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2990
2991 let id2 = manager.append_message(assistant_msg("2"));
2992 assert_eq!(manager.get_leaf_id(), Some(id2.clone()));
2993
2994 let id3 = manager.append_thinking_level_change("high");
2995 assert_eq!(manager.get_leaf_id(), Some(id3));
2996 }
2997
2998 #[test]
2999 fn test_get_entry() {
3000 let mut manager = SessionManager::in_memory("/tmp");
3001 assert!(manager.get_entry("nonexistent").is_none());
3002
3003 let id1 = manager.append_message(user_msg("first"));
3004 let id2 = manager.append_message(assistant_msg("second"));
3005
3006 let entry1 = manager.get_entry(&id1);
3007 assert!(entry1.is_some());
3008 assert!(entry1.unwrap().message.is_user());
3009
3010 let entry2 = manager.get_entry(&id2);
3011 assert!(entry2.is_some());
3012 assert!(entry2.unwrap().message.is_assistant());
3013 }
3014
3015 #[test]
3016 fn test_get_leaf_entry() {
3017 let manager = SessionManager::in_memory("/tmp");
3018 assert!(manager.get_leaf_entry().is_none());
3019
3020 let mut manager = SessionManager::in_memory("/tmp");
3021 manager.append_message(user_msg("1"));
3022 let id2 = manager.append_message(assistant_msg("2"));
3023
3024 let leaf = manager.get_leaf_entry();
3025 assert!(leaf.is_some());
3026 assert_eq!(leaf.unwrap().id, id2);
3027 }
3028
3029 #[test]
3034 fn test_get_branch_full_path_root_to_leaf() {
3035 let mut manager = SessionManager::in_memory("/tmp");
3036 let id1 = manager.append_message(user_msg("1"));
3037 let id2 = manager.append_message(assistant_msg("2"));
3038 let id3 = manager.append_thinking_level_change("high");
3039 let id4 = manager.append_message(user_msg("3"));
3040
3041 let branch = manager.get_branch(None);
3042 assert_eq!(branch.len(), 4);
3043 assert_eq!(branch[0].id, id1);
3044 assert_eq!(branch[1].id, id2);
3045 assert_eq!(branch[2].id, id3);
3046 assert_eq!(branch[3].id, id4);
3047 }
3048
3049 #[test]
3050 fn test_get_branch_from_specific_entry() {
3051 let mut manager = SessionManager::in_memory("/tmp");
3052 let id1 = manager.append_message(user_msg("1"));
3053 let id2 = manager.append_message(assistant_msg("2"));
3054 manager.append_message(user_msg("3"));
3055 manager.append_message(assistant_msg("4"));
3056
3057 let branch = manager.get_branch(Some(&id2));
3058 assert_eq!(branch.len(), 2);
3059 assert_eq!(branch[0].id, id1);
3060 assert_eq!(branch[1].id, id2);
3061 }
3062
3063 #[test]
3068 fn test_multiple_branches_at_same_point() {
3069 let mut manager = SessionManager::in_memory("/tmp");
3070 manager.append_message(user_msg("root"));
3071 let id2 = manager.append_message(bare_assistant_msg());
3072
3073 manager.branch(&id2).unwrap();
3075 let id_a = manager.append_message(user_msg("branch-A"));
3076
3077 manager.branch(&id2).unwrap();
3079 let id_b = manager.append_message(user_msg("branch-B"));
3080
3081 manager.branch(&id2).unwrap();
3083 let id_c = manager.append_message(user_msg("branch-C"));
3084
3085 let tree = manager.get_tree(Uuid::nil()).unwrap();
3086 let node2 = &tree[0].children[0];
3087 assert_eq!(node2.entry.id, id2);
3088 assert_eq!(node2.children.len(), 3);
3089
3090 let mut branch_ids: Vec<String> =
3091 node2.children.iter().map(|c| c.entry.id.clone()).collect();
3092 branch_ids.sort();
3093 let mut expected = vec![id_a, id_b, id_c];
3094 expected.sort();
3095 assert_eq!(branch_ids, expected);
3096 }
3097
3098 #[test]
3103 fn test_deep_branching() {
3104 let mut manager = SessionManager::in_memory("/tmp");
3105
3106 manager.append_message(user_msg("1"));
3108 let id2 = manager.append_message(bare_assistant_msg());
3109 let id3 = manager.append_message(user_msg("3"));
3110 manager.append_message(bare_assistant_msg());
3111
3112 manager.branch(&id2).unwrap();
3114 let id5 = manager.append_message(user_msg("5"));
3115 manager.append_message(bare_assistant_msg());
3116
3117 manager.branch(&id5).unwrap();
3119 manager.append_message(user_msg("7"));
3120
3121 let tree = manager.get_tree(Uuid::nil()).unwrap();
3122
3123 let node2 = &tree[0].children[0];
3125 assert_eq!(node2.children.len(), 2);
3126
3127 let node5 = node2.children.iter().find(|c| c.entry.id == id5).unwrap();
3128 assert_eq!(node5.children.len(), 2); let node3 = node2.children.iter().find(|c| c.entry.id == id3).unwrap();
3131 assert_eq!(node3.children.len(), 1); }
3133
3134 #[test]
3139 fn test_branch_with_summary_inserts_and_advances() {
3140 let mut manager = SessionManager::in_memory("/tmp");
3141 let id1 = manager.append_message(user_msg("1"));
3142 manager.append_message(bare_assistant_msg());
3143 manager.append_message(user_msg("3"));
3144
3145 let summary_id =
3146 manager.branch_with_summary(Some(&id1), "Summary of abandoned work", None, None);
3147 assert!(!summary_id.is_empty());
3148 assert_eq!(manager.get_leaf_id(), Some(summary_id.clone()));
3149
3150 let entries = manager.get_entries();
3152 let summary_entry = entries.iter().find(|e| e.id == summary_id).unwrap();
3153 assert_eq!(summary_entry.parent_id, Some(id1));
3154
3155 if let AgentMessage::BranchSummary { summary, .. } = &summary_entry.message {
3156 assert_eq!(summary, "Summary of abandoned work");
3157 } else {
3158 panic!("Expected BranchSummary");
3159 }
3160 }
3161
3162 #[test]
3167 fn test_build_session_context_returns_branch_messages() {
3168 let mut manager = SessionManager::in_memory("/tmp");
3169
3170 manager.append_message(user_msg("msg1"));
3172 let id2 = manager.append_message(bare_assistant_msg());
3173 manager.append_message(user_msg("msg3"));
3174
3175 manager.branch(&id2).unwrap();
3177 manager.append_message(assistant_msg("msg4-branch"));
3178
3179 let ctx = manager.build_session_context();
3180 assert_eq!(ctx.messages.len(), 3);
3182 assert!(ctx.messages[0].is_user());
3183 assert!(ctx.messages[1].is_assistant());
3184 assert!(ctx.messages[2].is_assistant());
3185 }
3186
3187 #[test]
3188 fn test_build_session_context_follows_branch_path() {
3189 let mut manager = SessionManager::in_memory("/tmp");
3192 manager.append_message(user_msg("start"));
3193 let id2 = manager.append_message(bare_assistant_msg());
3194 manager.append_message(user_msg("branch A"));
3195
3196 manager.branch(&id2).unwrap();
3198 manager.append_message(user_msg("branch B"));
3199
3200 let ctx = manager.build_session_context();
3201 assert_eq!(ctx.messages.len(), 3);
3202 let last = ctx.messages.last().unwrap();
3204 assert_eq!(last.content(), "branch B");
3205 }
3206
3207 #[test]
3208 fn test_build_session_context_includes_branch_summary() {
3209 let mut manager = SessionManager::in_memory("/tmp");
3210 manager.append_message(user_msg("start"));
3211 let id2 = manager.append_message(bare_assistant_msg());
3212 manager.append_message(user_msg("abandoned path"));
3213
3214 manager.branch_with_summary(Some(&id2), "Summary of abandoned work", None, None);
3216 manager.append_message(user_msg("new direction"));
3217
3218 let ctx = manager.build_session_context();
3219 assert!(ctx.messages.len() >= 3);
3221
3222 let has_summary = ctx.messages.iter().any(|m| {
3224 if let AgentMessage::BranchSummary { summary, .. } = m {
3225 summary == "Summary of abandoned work"
3226 } else {
3227 false
3228 }
3229 });
3230 assert!(has_summary, "Branch summary should be in context messages");
3231 }
3232
3233 #[test]
3234 fn test_build_session_context_with_compaction() {
3235 let mut manager = SessionManager::in_memory("/tmp");
3236
3237 let id1 = manager.append_message(user_msg("first"));
3239 manager.append_message(assistant_msg("response1"));
3240 manager.append_message(user_msg("second"));
3241 manager.append_message(assistant_msg("response2"));
3242
3243 manager.append_compaction("Summary of first two turns", &id1, 1000, None, None);
3245
3246 manager.append_message(user_msg("third"));
3248 manager.append_message(assistant_msg("response3"));
3249
3250 let ctx = manager.build_session_context();
3251 assert!(ctx.messages.len() >= 4); let compaction_entries = manager.get_compaction_entries();
3257 assert_eq!(compaction_entries.len(), 1);
3258 }
3259
3260 #[test]
3261 fn test_build_session_context_tracks_thinking_level() {
3262 let mut manager = SessionManager::in_memory("/tmp");
3263 manager.append_message(user_msg("hello"));
3264 manager.append_thinking_level_change("high");
3265 manager.append_message(assistant_msg("thinking hard"));
3266
3267 let ctx = manager.build_session_context();
3268 assert_eq!(ctx.thinking_level, "high");
3269 }
3270
3271 #[test]
3276 fn test_labels_in_tree_nodes() {
3277 let mut manager = SessionManager::in_memory("/tmp");
3278 let id1 = manager.append_message(user_msg("hello"));
3279 let id2 = manager.append_message(assistant_msg("hi"));
3280
3281 manager.add_label(&id1, "start").unwrap();
3282 manager.add_label(&id2, "response").unwrap();
3283
3284 let tree = manager.get_tree(Uuid::nil()).unwrap();
3285 let node1 = &tree[0];
3286 assert_eq!(node1.label, Some("start".to_string()));
3287
3288 let node2 = &node1.children[0];
3289 assert_eq!(node2.label, Some("response".to_string()));
3290 }
3291
3292 #[test]
3293 fn test_last_label_wins() {
3294 let mut manager = SessionManager::in_memory("/tmp");
3295 let id1 = manager.append_message(user_msg("hello"));
3296
3297 manager.add_label(&id1, "first").unwrap();
3298 manager.add_label(&id1, "second").unwrap();
3299 manager.add_label(&id1, "third").unwrap();
3300
3301 assert_eq!(manager.get_label(&id1), Some("third".to_string()));
3302 }
3303
3304 #[test]
3309 fn test_branch_throws_for_nonexistent() {
3310 let mut manager = SessionManager::in_memory("/tmp");
3311 manager.append_message(user_msg("hello"));
3312
3313 let result = manager.branch("nonexistent");
3314 assert!(result.is_err());
3315 }
3316
3317 #[test]
3322 fn test_labels_not_in_session_context() {
3323 let mut manager = SessionManager::in_memory("/tmp");
3324 let msg_id = manager.append_message(user_msg("hello"));
3325 manager.add_label(&msg_id, "checkpoint").unwrap();
3326
3327 let ctx = manager.build_session_context();
3328 assert_eq!(ctx.messages.len(), 1);
3330 assert!(ctx.messages[0].is_user());
3331 }
3332
3333 #[test]
3338 fn test_custom_entry_integrates_into_tree() {
3339 let mut manager = SessionManager::in_memory("/tmp");
3340 let msg_id = manager.append_message(user_msg("hello"));
3341 let custom_id =
3342 manager.append_custom_entry("my_data", Some(serde_json::json!({"foo": "bar"})));
3343 let msg2_id = manager.append_message(assistant_msg("response"));
3344
3345 let entries = manager.get_entries();
3346 let custom = entries.iter().find(|e| e.id == custom_id).unwrap();
3347 assert_eq!(custom.parent_id, Some(msg_id));
3348
3349 if let AgentMessage::Custom { custom_type, .. } = &custom.message {
3350 assert_eq!(custom_type, "my_data");
3351 } else {
3352 panic!("Expected Custom message");
3353 }
3354
3355 let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
3356 assert_eq!(msg2.parent_id, Some(custom_id));
3357
3358 let ctx = manager.build_session_context();
3360 assert_eq!(ctx.messages.len(), 2);
3362 }
3363
3364 #[test]
3369 fn test_get_branch_empty_session() {
3370 let manager = SessionManager::in_memory("/tmp");
3371 let branch = manager.get_branch(None);
3372 assert!(branch.is_empty());
3373 }
3374
3375 #[test]
3376 fn test_get_tree_empty_session() {
3377 let manager = SessionManager::in_memory("/tmp");
3378 let tree = manager.get_tree(Uuid::nil()).unwrap();
3379 assert!(tree.is_empty());
3380 }
3381
3382 #[test]
3387 fn test_complex_tree_with_branches_and_compaction() {
3388 let mut manager = SessionManager::in_memory("/tmp");
3389
3390 manager.append_message(user_msg("start"));
3392 manager.append_message(assistant_msg("r1"));
3393 let id3 = manager.append_message(user_msg("q2"));
3394 manager.append_message(assistant_msg("r2"));
3395 manager.append_compaction("Compacted history", &id3, 1000, None, None);
3396 manager.append_message(user_msg("q3"));
3397 manager.append_message(assistant_msg("r3"));
3398
3399 manager.branch(&id3).unwrap();
3401 manager.append_message(user_msg("wrong path"));
3402 manager.append_message(assistant_msg("wrong response"));
3403
3404 manager.branch_with_summary(Some(&id3), "Tried wrong approach", None, None);
3406 manager.append_message(user_msg("better approach"));
3407
3408 let tree = manager.get_tree(Uuid::nil()).unwrap();
3409 assert_eq!(tree.len(), 1);
3411
3412 let root = &tree[0];
3414 assert!(root.entry.message.is_user());
3415 }
3416
3417 #[test]
3422 fn test_multiple_compactions_returns_latest() {
3423 let mut manager = SessionManager::in_memory("/tmp");
3424 let id1 = manager.append_message(user_msg("a"));
3425 manager.append_message(bare_assistant_msg());
3426 manager.append_compaction("First summary", &id1, 1000, None, None);
3427 manager.append_message(user_msg("c"));
3428 manager.append_message(bare_assistant_msg());
3429 manager.append_compaction("Second summary", &id1, 2000, None, None);
3430
3431 let compactions = manager.get_compaction_entries();
3433 assert_eq!(compactions.len(), 2);
3434
3435 let latest = manager.get_latest_compaction_entry();
3437 assert!(latest.is_some());
3438 }
3439
3440 #[test]
3445 fn test_get_all_compaction_entries() {
3446 let mut manager = SessionManager::in_memory("/tmp");
3447 let id1 = manager.append_message(user_msg("a"));
3448 manager.append_message(bare_assistant_msg());
3449 manager.append_compaction("First", &id1, 1000, None, None);
3450 manager.append_message(user_msg("b"));
3451 manager.append_message(bare_assistant_msg());
3452 manager.append_compaction("Second", &id1, 2000, None, None);
3453
3454 let compactions = manager.get_compaction_entries();
3455 assert_eq!(compactions.len(), 2);
3456 }
3457}