1use crate::agent::session_storage::{InMemorySessionStorage, JsonlSessionStorage, SessionStorage};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use yoagent::types::AgentMessage;
9
10pub const CURRENT_SESSION_VERSION: u32 = 3;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct SessionHeader {
20 #[serde(rename = "type")]
21 pub type_: String, #[serde(default)]
23 pub version: Option<u32>,
24 pub id: String,
25 pub timestamp: String,
26 pub cwd: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub parent_session: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type")]
38pub enum SessionEntry {
39 #[serde(rename = "message")]
40 Message(MessageEntry),
41 #[serde(rename = "thinking_level_change")]
42 ThinkingLevelChange(ThinkingLevelChangeEntry),
43 #[serde(rename = "model_change")]
44 ModelChange(ModelChangeEntry),
45 #[serde(rename = "active_tools_change")]
46 ActiveToolsChange(ActiveToolsChangeEntry),
47 #[serde(rename = "compaction")]
48 Compaction(CompactionEntry),
49 #[serde(rename = "branch_summary")]
50 BranchSummary(BranchSummaryEntry),
51 #[serde(rename = "session_info")]
52 SessionInfo(SessionInfoEntry),
53 #[serde(rename = "label")]
54 Label(LabelEntry),
55 #[serde(rename = "custom")]
56 Custom(CustomEntry),
57 #[serde(rename = "custom_message")]
58 CustomMessage(CustomMessageEntry),
59 #[serde(rename = "leaf")]
60 Leaf(LeafEntry),
61}
62
63impl SessionEntry {
64 pub fn id(&self) -> &str {
65 match self {
66 SessionEntry::Message(e) => &e.id,
67 SessionEntry::ThinkingLevelChange(e) => &e.id,
68 SessionEntry::ModelChange(e) => &e.id,
69 SessionEntry::ActiveToolsChange(e) => &e.id,
70 SessionEntry::Compaction(e) => &e.id,
71 SessionEntry::BranchSummary(e) => &e.id,
72 SessionEntry::SessionInfo(e) => &e.id,
73 SessionEntry::Label(e) => &e.id,
74 SessionEntry::Custom(e) => &e.id,
75 SessionEntry::CustomMessage(e) => &e.id,
76 SessionEntry::Leaf(e) => &e.id,
77 }
78 }
79
80 pub fn parent_id(&self) -> Option<&str> {
81 match self {
82 SessionEntry::Message(e) => e.parent_id.as_deref(),
83 SessionEntry::ThinkingLevelChange(e) => e.parent_id.as_deref(),
84 SessionEntry::ModelChange(e) => e.parent_id.as_deref(),
85 SessionEntry::ActiveToolsChange(e) => e.parent_id.as_deref(),
86 SessionEntry::Compaction(e) => e.parent_id.as_deref(),
87 SessionEntry::BranchSummary(e) => e.parent_id.as_deref(),
88 SessionEntry::SessionInfo(e) => e.parent_id.as_deref(),
89 SessionEntry::Label(e) => e.parent_id.as_deref(),
90 SessionEntry::Custom(e) => e.parent_id.as_deref(),
91 SessionEntry::CustomMessage(e) => e.parent_id.as_deref(),
92 SessionEntry::Leaf(e) => e.parent_id.as_deref(),
93 }
94 }
95
96 pub fn timestamp(&self) -> &str {
97 match self {
98 SessionEntry::Message(e) => &e.timestamp,
99 SessionEntry::ThinkingLevelChange(e) => &e.timestamp,
100 SessionEntry::ModelChange(e) => &e.timestamp,
101 SessionEntry::ActiveToolsChange(e) => &e.timestamp,
102 SessionEntry::Compaction(e) => &e.timestamp,
103 SessionEntry::BranchSummary(e) => &e.timestamp,
104 SessionEntry::SessionInfo(e) => &e.timestamp,
105 SessionEntry::Label(e) => &e.timestamp,
106 SessionEntry::Custom(e) => &e.timestamp,
107 SessionEntry::CustomMessage(e) => &e.timestamp,
108 SessionEntry::Leaf(e) => &e.timestamp,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct MessageEntry {
117 pub id: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub parent_id: Option<String>,
120 pub timestamp: String,
121 pub message: AgentMessage,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct ThinkingLevelChangeEntry {
127 pub id: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub parent_id: Option<String>,
130 pub timestamp: String,
131 pub thinking_level: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct ModelChangeEntry {
137 pub id: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub parent_id: Option<String>,
140 pub timestamp: String,
141 pub provider: String,
142 pub model_id: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct ActiveToolsChangeEntry {
148 pub id: String,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub parent_id: Option<String>,
151 pub timestamp: String,
152 pub active_tool_names: Vec<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct CompactionEntry {
158 pub id: String,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub parent_id: Option<String>,
161 pub timestamp: String,
162 pub summary: String,
163 pub first_kept_entry_id: String,
164 pub tokens_before: u64,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub details: Option<serde_json::Value>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub from_hook: Option<bool>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct BranchSummaryEntry {
174 pub id: String,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub parent_id: Option<String>,
177 pub timestamp: String,
178 pub from_id: String,
179 pub summary: String,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub details: Option<serde_json::Value>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub from_hook: Option<bool>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct SessionInfoEntry {
189 pub id: String,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub parent_id: Option<String>,
192 pub timestamp: String,
193 pub name: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct LabelEntry {
199 pub id: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub parent_id: Option<String>,
202 pub timestamp: String,
203 pub target_id: String,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub label: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct CustomEntry {
211 pub id: String,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub parent_id: Option<String>,
214 pub timestamp: String,
215 pub custom_type: String,
216 pub data: serde_json::Value,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct CustomMessageEntry {
222 pub id: String,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub parent_id: Option<String>,
225 pub timestamp: String,
226 pub custom_type: String,
227 pub content: serde_json::Value,
228 #[serde(default)]
229 pub display: bool,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub details: Option<serde_json::Value>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct LeafEntry {
237 pub id: String,
238 #[serde(skip_serializing_if = "Option::is_none")]
239 pub parent_id: Option<String>,
240 pub timestamp: String,
241 #[serde(skip_serializing_if = "Option::is_none")]
242 pub target_id: Option<String>,
243}
244
245#[derive(Debug, Clone)]
249pub struct SessionInfo {
250 pub path: PathBuf,
251 pub id: String,
252 pub cwd: String,
253 pub name: Option<String>,
254 pub parent_session_path: Option<String>,
255 pub created: DateTime<Utc>,
256 pub modified: DateTime<Utc>,
257 pub message_count: usize,
258 pub first_message: String,
259 pub all_messages_text: String,
261}
262
263#[derive(Debug, Clone)]
267pub struct SessionTreeNode {
268 pub entry: SessionEntry,
269 pub children: Vec<SessionTreeNode>,
270 pub label: Option<String>,
271 pub label_timestamp: Option<String>,
272}
273
274#[derive(Debug, Clone, Default)]
278pub struct NewSessionOptions {
279 pub id: Option<String>,
280 pub parent_session: Option<String>,
281}
282
283#[derive(Debug, Clone)]
288pub struct SessionContext {
289 pub messages: Vec<AgentMessage>,
290 pub thinking_level: String,
291 pub model: Option<(String, String)>,
292 pub active_tool_names: Option<Vec<String>>,
293}
294
295pub fn parse_session_entry_line(line: &str) -> Option<SessionEntry> {
299 let line = line.trim();
300 if line.is_empty() {
301 return None;
302 }
303 serde_json::from_str(line).ok()
304}
305
306pub fn parse_session_header_line(line: &str) -> Option<SessionHeader> {
308 let line = line.trim();
309 if line.is_empty() {
310 return None;
311 }
312 let header: SessionHeader = serde_json::from_str(line).ok()?;
313 if header.type_ != "session" {
314 return None;
315 }
316 Some(header)
317}
318
319pub fn read_session_header(path: &Path) -> Option<SessionHeader> {
321 let content = fs::read_to_string(path).ok()?;
322 let first_line = content.lines().next()?;
323 parse_session_header_line(first_line)
324}
325
326const SESSION_READ_BUFFER_SIZE: usize = 1024 * 1024; pub fn load_session_from_file(path: &Path) -> (Option<SessionHeader>, Vec<SessionEntry>) {
332 let file = match std::fs::File::open(path) {
333 Ok(f) => f,
334 Err(_) => return (None, vec![]),
335 };
336
337 use std::io::Read;
338 let mut reader = std::io::BufReader::with_capacity(SESSION_READ_BUFFER_SIZE, file);
339 let mut content = String::new();
340 if reader.read_to_string(&mut content).is_err() {
341 return (None, vec![]);
342 }
343
344 let mut header: Option<SessionHeader> = None;
345 let mut entries: Vec<SessionEntry> = Vec::new();
346
347 for (i, line_str) in content.lines().enumerate() {
348 let line = line_str.trim();
349 if line.is_empty() {
350 continue;
351 }
352
353 if i == 0 {
354 header = parse_session_header_line(line);
356 if header.is_none() {
357 return (None, vec![]);
359 }
360 continue;
361 }
362
363 if let Some(entry) = parse_session_entry_line(line) {
364 entries.push(entry);
365 }
366 }
368
369 (header, entries)
370}
371
372pub fn load_entries_from_file(path: &Path) -> Vec<SessionEntry> {
374 load_session_from_file(path).1
375}
376
377pub fn write_entries_to_file(
380 path: &Path,
381 header: &SessionHeader,
382 entries: &[SessionEntry],
383) -> std::io::Result<()> {
384 if let Some(parent) = path.parent() {
385 fs::create_dir_all(parent)?;
386 }
387 let mut content = serde_json::to_string(header).map_err(std::io::Error::from)?;
388 content.push('\n');
389 for entry in entries {
390 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
391 content.push_str(&line);
392 content.push('\n');
393 }
394 fs::write(path, &content)
395}
396
397pub fn append_entry_to_file(path: &Path, entry: &SessionEntry) -> std::io::Result<()> {
399 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
400 let content = format!("{}\n", line);
401 std::fs::OpenOptions::new()
402 .create(true)
403 .append(true)
404 .open(path)?
405 .write_all(content.as_bytes())
406}
407
408pub fn encode_cwd_for_dir(cwd: &Path) -> String {
413 let s = cwd.to_string_lossy();
414 let cleaned = s
415 .trim_start_matches('/')
416 .trim_start_matches('\\')
417 .replace(['/', '\\', ':'], "-");
418 format!("--{}--", cleaned)
419}
420
421pub fn get_default_session_dir(cwd: &Path) -> PathBuf {
423 let rab_dir = directories::BaseDirs::new()
424 .expect("Could not determine home directory")
425 .home_dir()
426 .join(".rab");
427 rab_dir.join("sessions").join(encode_cwd_for_dir(cwd))
428}
429
430pub fn generate_entry_id(by_id: &HashMap<String, SessionEntry>) -> String {
432 for _ in 0..100 {
433 let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
434 if !by_id.contains_key(&id) {
435 return id;
436 }
437 }
438 uuid::Uuid::new_v4().to_string()
440}
441
442use crate::agent::session_storage::SessionMetadata;
445
446pub struct Session {
452 storage: Box<dyn SessionStorage>,
453}
454
455impl Session {
456 pub fn new(storage: Box<dyn SessionStorage>) -> Self {
458 Self { storage }
459 }
460
461 pub fn get_storage(&self) -> &dyn SessionStorage {
463 self.storage.as_ref()
464 }
465
466 pub fn get_storage_mut(&mut self) -> &mut dyn SessionStorage {
468 self.storage.as_mut()
469 }
470
471 pub fn into_storage(self) -> Box<dyn SessionStorage> {
473 self.storage
474 }
475
476 pub fn metadata(&self) -> SessionMetadata {
479 self.storage.metadata()
480 }
481
482 pub fn get_leaf_id(&self) -> Option<String> {
483 self.storage.get_leaf_id()
484 }
485
486 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
487 self.storage.get_entry(id)
488 }
489
490 pub fn get_entries(&self) -> Vec<SessionEntry> {
491 self.storage.get_entries()
492 }
493
494 pub fn find_entries(&self, type_name: &str) -> Vec<SessionEntry> {
495 self.storage.find_entries(type_name)
496 }
497
498 pub fn get_label(&self, id: &str) -> Option<String> {
499 self.storage.get_label(id)
500 }
501
502 pub fn get_branch(&self, from_id: Option<&str>) -> Result<Vec<SessionEntry>, String> {
505 self.storage.get_path_to_root(from_id)
506 }
507
508 pub fn build_context(&self) -> SessionContext {
511 let path = self.get_branch(None).unwrap_or_default();
512 build_session_context(&path)
513 }
514
515 pub fn build_session_context(&self) -> SessionContext {
517 self.build_context()
518 }
519
520 pub fn session_id(&self) -> String {
522 self.metadata().id
523 }
524
525 pub fn session_file(&self) -> Option<PathBuf> {
527 self.metadata().path
528 }
529
530 pub fn session_name(&self) -> Option<String> {
532 self.get_session_name()
533 }
534
535 pub fn get_session_name(&self) -> Option<String> {
537 let entries = self.find_entries("session_info");
538 let last = entries.last()?;
539 if let SessionEntry::SessionInfo(e) = last {
540 let name = e.name.trim();
541 if name.is_empty() {
542 None
543 } else {
544 Some(name.to_string())
545 }
546 } else {
547 None
548 }
549 }
550
551 pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
555 let entry = SessionEntry::Message(MessageEntry {
556 id: self.storage.create_entry_id(),
557 parent_id: self.storage.get_leaf_id(),
558 timestamp: chrono::Utc::now().to_rfc3339(),
559 message: message.clone(),
560 });
561 let id = entry.id().to_string();
562 self.storage.append_entry(entry).unwrap_or_else(|e| {
563 eprintln!("Warning: failed to append message: {}", e);
564 });
565 id
566 }
567
568 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
570 let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
571 id: self.storage.create_entry_id(),
572 parent_id: self.storage.get_leaf_id(),
573 timestamp: chrono::Utc::now().to_rfc3339(),
574 thinking_level: thinking_level.to_string(),
575 });
576 let id = entry.id().to_string();
577 self.storage.append_entry(entry).unwrap_or_else(|e| {
578 eprintln!("Warning: failed to append thinking level change: {}", e);
579 });
580 id
581 }
582
583 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
585 let entry = SessionEntry::ModelChange(ModelChangeEntry {
586 id: self.storage.create_entry_id(),
587 parent_id: self.storage.get_leaf_id(),
588 timestamp: chrono::Utc::now().to_rfc3339(),
589 provider: provider.to_string(),
590 model_id: model_id.to_string(),
591 });
592 let id = entry.id().to_string();
593 self.storage.append_entry(entry).unwrap_or_else(|e| {
594 eprintln!("Warning: failed to append model change: {}", e);
595 });
596 id
597 }
598
599 pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
601 let entry = SessionEntry::ActiveToolsChange(ActiveToolsChangeEntry {
602 id: self.storage.create_entry_id(),
603 parent_id: self.storage.get_leaf_id(),
604 timestamp: chrono::Utc::now().to_rfc3339(),
605 active_tool_names: active_tool_names.to_vec(),
606 });
607 let id = entry.id().to_string();
608 self.storage.append_entry(entry).unwrap_or_else(|e| {
609 eprintln!("Warning: failed to append active tools change: {}", e);
610 });
611 id
612 }
613
614 pub fn append_compaction(
616 &mut self,
617 summary: &str,
618 first_kept_entry_id: &str,
619 tokens_before: u64,
620 details: Option<serde_json::Value>,
621 from_hook: Option<bool>,
622 ) -> String {
623 let entry = SessionEntry::Compaction(CompactionEntry {
624 id: self.storage.create_entry_id(),
625 parent_id: self.storage.get_leaf_id(),
626 timestamp: chrono::Utc::now().to_rfc3339(),
627 summary: summary.to_string(),
628 first_kept_entry_id: first_kept_entry_id.to_string(),
629 tokens_before,
630 details,
631 from_hook,
632 });
633 let id = entry.id().to_string();
634 self.storage.append_entry(entry).unwrap_or_else(|e| {
635 eprintln!("Warning: failed to append compaction: {}", e);
636 });
637 id
638 }
639
640 pub fn append_session_info(&mut self, name: &str) -> String {
642 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
643 id: self.storage.create_entry_id(),
644 parent_id: self.storage.get_leaf_id(),
645 timestamp: chrono::Utc::now().to_rfc3339(),
646 name: name.trim().to_string(),
647 });
648 let id = entry.id().to_string();
649 self.storage.append_entry(entry).unwrap_or_else(|e| {
650 eprintln!("Warning: failed to append session info: {}", e);
651 });
652 id
653 }
654
655 pub fn append_branch_summary(
657 &mut self,
658 from_id: &str,
659 summary: &str,
660 details: Option<serde_json::Value>,
661 from_hook: Option<bool>,
662 ) -> String {
663 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
664 id: self.storage.create_entry_id(),
665 parent_id: self.storage.get_leaf_id(),
666 timestamp: chrono::Utc::now().to_rfc3339(),
667 from_id: from_id.to_string(),
668 summary: summary.to_string(),
669 details,
670 from_hook,
671 });
672 let id = entry.id().to_string();
673 self.storage.append_entry(entry).unwrap_or_else(|e| {
674 eprintln!("Warning: failed to append branch summary: {}", e);
675 });
676 id
677 }
678
679 pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
681 let entry = SessionEntry::Label(LabelEntry {
682 id: self.storage.create_entry_id(),
683 parent_id: self.storage.get_leaf_id(),
684 timestamp: chrono::Utc::now().to_rfc3339(),
685 target_id: target_id.to_string(),
686 label: label.map(|s| s.to_string()),
687 });
688 let id = entry.id().to_string();
689 self.storage.append_entry(entry).unwrap_or_else(|e| {
690 eprintln!("Warning: failed to append label change: {}", e);
691 });
692 id
693 }
694
695 pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
697 let entry = SessionEntry::Custom(CustomEntry {
698 id: self.storage.create_entry_id(),
699 parent_id: self.storage.get_leaf_id(),
700 timestamp: chrono::Utc::now().to_rfc3339(),
701 custom_type: custom_type.to_string(),
702 data,
703 });
704 let id = entry.id().to_string();
705 self.storage.append_entry(entry).unwrap_or_else(|e| {
706 eprintln!("Warning: failed to append custom entry: {}", e);
707 });
708 id
709 }
710
711 pub fn append_custom_message_entry(
713 &mut self,
714 custom_type: &str,
715 content: serde_json::Value,
716 display: bool,
717 details: Option<serde_json::Value>,
718 ) -> String {
719 let entry = SessionEntry::CustomMessage(CustomMessageEntry {
720 id: self.storage.create_entry_id(),
721 parent_id: self.storage.get_leaf_id(),
722 timestamp: chrono::Utc::now().to_rfc3339(),
723 custom_type: custom_type.to_string(),
724 content,
725 display,
726 details,
727 });
728 let id = entry.id().to_string();
729 self.storage.append_entry(entry).unwrap_or_else(|e| {
730 eprintln!("Warning: failed to append custom message: {}", e);
731 });
732 id
733 }
734
735 pub fn move_to(
741 &mut self,
742 entry_id: Option<&str>,
743 summary: Option<(String, Option<serde_json::Value>, Option<bool>)>,
744 ) -> Result<Option<String>, String> {
745 if let Some(ref id) = entry_id
747 && self.get_entry(id).is_none()
748 {
749 return Err(format!("Entry {} not found", id));
750 }
751 self.storage.set_leaf_id(entry_id)?;
753
754 if let Some((summary_text, details, from_hook)) = summary {
756 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
757 id: self.storage.create_entry_id(),
758 parent_id: entry_id.map(|s| s.to_string()),
759 timestamp: chrono::Utc::now().to_rfc3339(),
760 from_id: entry_id.unwrap_or("root").to_string(),
761 summary: summary_text,
762 details,
763 from_hook,
764 });
765 let id = entry.id().to_string();
766 self.storage.append_entry(entry).unwrap_or_else(|e| {
767 eprintln!("Warning: failed to append branch summary: {}", e);
768 });
769 Ok(Some(id))
770 } else {
771 Ok(None)
772 }
773 }
774
775 pub fn set_leaf_id(&mut self, leaf_id: Option<&str>) -> Result<(), String> {
778 self.storage.set_leaf_id(leaf_id)
779 }
780
781 pub fn reset_leaf(&mut self) -> Result<(), String> {
783 self.storage.set_leaf_id(None)
784 }
785}
786
787pub fn build_session_context(path: &[SessionEntry]) -> SessionContext {
792 let mut thinking_level = "off".to_string();
793 let mut model: Option<(String, String)> = None;
794 let mut active_tool_names: Option<Vec<String>> = None;
795 let mut compaction_entry: Option<&CompactionEntry> = None;
796
797 for entry in path {
798 match entry {
799 SessionEntry::ThinkingLevelChange(e) => {
800 thinking_level = e.thinking_level.clone();
801 }
802 SessionEntry::ModelChange(e) => {
803 model = Some((e.provider.clone(), e.model_id.clone()));
804 }
805 SessionEntry::ActiveToolsChange(e) => {
806 active_tool_names = Some(e.active_tool_names.clone());
807 }
808 SessionEntry::Compaction(e) => {
809 compaction_entry = Some(e);
810 }
811 _ => {}
812 }
813 }
814
815 if model.is_none() {
817 for entry in path {
818 if let SessionEntry::Message(e) = entry
819 && let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
820 model: ref m,
821 provider: ref p,
822 ..
823 }) = e.message
824 && !m.is_empty()
825 && !p.is_empty()
826 {
827 model = Some((p.clone(), m.clone()));
828 break;
829 }
830 }
831 }
832
833 let messages = if let Some(compaction) = compaction_entry {
834 let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
835
836 let comp_text = format!(
838 "The conversation history before this point was compacted into the following summary:\n\n<summary>\n{}\n</summary>",
839 compaction.summary
840 );
841 msgs.push(yoagent::types::AgentMessage::Llm(
842 yoagent::types::Message::User {
843 content: vec![yoagent::types::Content::Text { text: comp_text }],
844 timestamp: chrono::Utc::now().timestamp_millis() as u64,
845 },
846 ));
847
848 let compaction_idx = path
850 .iter()
851 .position(|e| matches!(e, SessionEntry::Compaction(ce) if ce.id == compaction.id));
852
853 if let Some(cidx) = compaction_idx {
854 let mut found_first_kept = false;
856 for entry in path.iter().take(cidx) {
857 if entry.id() == compaction.first_kept_entry_id {
858 found_first_kept = true;
859 }
860 if found_first_kept {
861 append_entry_to_message_list(entry, &mut msgs);
862 }
863 }
864
865 for entry in path.iter().skip(cidx + 1) {
867 append_entry_to_message_list(entry, &mut msgs);
868 }
869 } else {
870 for entry in path {
872 append_entry_to_message_list(entry, &mut msgs);
873 }
874 }
875
876 msgs
877 } else {
878 let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
880 for entry in path {
881 append_entry_to_message_list(entry, &mut msgs);
882 }
883 msgs
884 };
885
886 SessionContext {
887 messages,
888 thinking_level,
889 model,
890 active_tool_names,
891 }
892}
893
894fn append_entry_to_message_list(
899 entry: &SessionEntry,
900 msgs: &mut Vec<yoagent::types::AgentMessage>,
901) {
902 match entry {
903 SessionEntry::Message(e) => {
904 if crate::agent::types::message_error(&e.message).is_some() {
906 return;
907 }
908 msgs.push(e.message.clone());
909 }
910 SessionEntry::CustomMessage(e) => {
911 msgs.push(yoagent::types::AgentMessage::Extension(
912 yoagent::types::ExtensionMessage::new(
913 &e.custom_type,
914 serde_json::json!({ "text": e.content.get("text").and_then(|v| v.as_str()).unwrap_or(""), "display": e.display }),
915 ),
916 ));
917 }
918 SessionEntry::BranchSummary(e) if !e.summary.is_empty() => {
919 let bs_text = format!(
921 "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n{}\n</summary>",
922 e.summary
923 );
924 msgs.push(yoagent::types::AgentMessage::Llm(
925 yoagent::types::Message::User {
926 content: vec![yoagent::types::Content::Text { text: bs_text }],
927 timestamp: chrono::Utc::now().timestamp_millis() as u64,
928 },
929 ));
930 }
931 _ => {}
932 }
933}
934
935pub struct SessionManager {
943 session: Session,
945 session_dir: PathBuf,
947 cwd: PathBuf,
949 persist: bool,
951 flushed: bool,
953}
954
955impl SessionManager {
956 pub fn with_session(
960 session: Session,
961 session_dir: PathBuf,
962 cwd: PathBuf,
963 persist: bool,
964 ) -> Self {
965 Self {
966 session,
967 session_dir,
968 cwd,
969 persist,
970 flushed: false,
971 }
972 }
973
974 fn create_persisted(
977 cwd: &Path,
978 session_dir: &Path,
979 options: Option<&NewSessionOptions>,
980 ) -> Self {
981 let id = options
982 .and_then(|o| o.id.as_deref())
983 .map(|s| s.to_string())
984 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
985 let created_at = chrono::Utc::now().to_rfc3339();
986
987 let meta = crate::agent::session_storage::SessionMetadata {
989 id: id.clone(),
990 created_at: created_at.clone(),
991 cwd: cwd.to_string_lossy().to_string(),
992 path: None, parent_session_path: options.and_then(|o| o.parent_session.clone()),
994 };
995 let storage = InMemorySessionStorage::new(meta);
996 let session = Session::new(Box::new(storage));
997 Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), true)
998 }
999
1000 fn open_session(path: &Path, session_dir: &Path, cwd_override: Option<&Path>) -> Self {
1002 let cwd = cwd_override
1003 .map(|p| p.to_path_buf())
1004 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
1005
1006 let storage: Box<dyn SessionStorage> = match JsonlSessionStorage::open(path.to_path_buf()) {
1007 Ok(s) => Box::new(s),
1008 Err(e) => {
1009 eprintln!("Warning: failed to open session: {}, creating new", e);
1010 let id = uuid::Uuid::new_v4().to_string();
1012 match JsonlSessionStorage::create(
1013 path.to_path_buf(),
1014 &cwd.to_string_lossy(),
1015 &id,
1016 None,
1017 ) {
1018 Ok(s) => Box::new(s),
1019 Err(e2) => {
1020 eprintln!("Warning: failed to create session file: {}", e2);
1021 Box::new(InMemorySessionStorage::new(
1022 crate::agent::session_storage::SessionMetadata {
1023 id,
1024 created_at: chrono::Utc::now().to_rfc3339(),
1025 cwd: cwd.to_string_lossy().to_string(),
1026 path: Some(path.to_path_buf()),
1027 parent_session_path: None,
1028 },
1029 ))
1030 }
1031 }
1032 }
1033 };
1034 let cwd = cwd_override
1035 .map(|p| p.to_path_buf())
1036 .unwrap_or_else(|| PathBuf::from(storage.metadata().cwd));
1037 let session = Session::new(storage);
1038 Self::with_session(session, session_dir.to_path_buf(), cwd, true)
1039 }
1040
1041 fn create_in_memory(cwd: &Path, session_dir: &Path) -> Self {
1043 let meta = crate::agent::session_storage::SessionMetadata {
1044 id: uuid::Uuid::new_v4().to_string(),
1045 created_at: chrono::Utc::now().to_rfc3339(),
1046 cwd: cwd.to_string_lossy().to_string(),
1047 path: None,
1048 parent_session_path: None,
1049 };
1050 let storage = InMemorySessionStorage::new(meta);
1051 let session = Session::new(Box::new(storage));
1052 Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), false)
1053 }
1054
1055 pub fn new_session(&mut self, options: Option<&NewSessionOptions>) {
1058 let id = options
1059 .and_then(|o| o.id.as_deref())
1060 .map(|s| s.to_string())
1061 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1062 let created_at = chrono::Utc::now().to_rfc3339();
1063
1064 let meta = crate::agent::session_storage::SessionMetadata {
1067 id,
1068 created_at,
1069 cwd: self.cwd.to_string_lossy().to_string(),
1070 path: None,
1071 parent_session_path: options.and_then(|o| o.parent_session.clone()),
1072 };
1073 let storage = InMemorySessionStorage::new(meta);
1074 self.session = Session::new(Box::new(storage));
1075 self.flushed = false;
1076 }
1077
1078 pub fn ensure_flushed(&mut self) {
1082 if self.flushed || !self.persist {
1083 return;
1084 }
1085
1086 let id = self.session.metadata().id;
1087 let created_at = self.session.metadata().created_at.clone();
1088 let cwd_str = self.cwd.to_string_lossy().to_string();
1089 let parent_session = self.session.metadata().parent_session_path.clone();
1090 let file_ts = created_at.replace([':', '.'], "-");
1091 let file_path = self.session_dir.join(format!("{}_{}.jsonl", file_ts, id));
1092
1093 let existing_entries = self.session.get_entries();
1095
1096 match JsonlSessionStorage::create(file_path.clone(), &cwd_str, &id, parent_session) {
1098 Ok(mut file_storage) => {
1099 for entry in &existing_entries {
1101 if let Err(e) = file_storage.append_entry(entry.clone()) {
1102 eprintln!("Warning: failed to write entry to session file: {}", e);
1103 }
1104 }
1105 self.session = Session::new(Box::new(file_storage));
1106 self.flushed = true;
1107 }
1108 Err(e) => {
1109 eprintln!("Warning: failed to create session file: {}", e);
1110 self.flushed = true;
1112 }
1113 }
1114 }
1115
1116 pub fn is_persisted(&self) -> bool {
1119 self.persist
1120 }
1121
1122 pub fn cwd(&self) -> &Path {
1123 &self.cwd
1124 }
1125
1126 pub fn session_dir(&self) -> &Path {
1127 &self.session_dir
1128 }
1129
1130 pub fn uses_default_session_dir(&self) -> bool {
1132 self.session_dir == get_default_session_dir(&self.cwd)
1133 }
1134
1135 pub fn session_id(&self) -> String {
1136 self.session.metadata().id
1137 }
1138
1139 pub fn session_file(&self) -> Option<PathBuf> {
1140 self.session.metadata().path
1141 }
1142
1143 pub fn leaf_id(&self) -> Option<String> {
1144 self.session.get_leaf_id()
1145 }
1146
1147 pub fn session_name(&self) -> Option<String> {
1149 self.session.get_session_name()
1150 }
1151
1152 pub fn session(&self) -> &Session {
1154 &self.session
1155 }
1156
1157 pub fn session_mut(&mut self) -> &mut Session {
1159 &mut self.session
1160 }
1161
1162 pub fn into_session(self) -> Session {
1164 self.session
1165 }
1166
1167 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1171 self.leaf_id().and_then(|id| self.entry(&id))
1172 }
1173
1174 pub fn get_tree(&self) -> Vec<SessionTreeNode> {
1176 let entries = self.session.get_entries();
1177 let mut node_map: HashMap<String, SessionTreeNode> = HashMap::new();
1178
1179 for entry in &entries {
1180 let label = self.session.get_label(entry.id());
1181 node_map.insert(
1182 entry.id().to_string(),
1183 SessionTreeNode {
1184 entry: entry.clone(),
1185 children: Vec::new(),
1186 label,
1187 label_timestamp: None,
1188 },
1189 );
1190 }
1191
1192 let child_edges: Vec<(Option<String>, String)> = entries
1193 .iter()
1194 .map(|e| (e.parent_id().map(|s| s.to_string()), e.id().to_string()))
1195 .collect();
1196
1197 let mut child_additions: Vec<(String, SessionTreeNode)> = Vec::new();
1198 let mut roots: Vec<String> = Vec::new();
1199 for (parent_id, child_id) in &child_edges {
1200 if let Some(pid) = parent_id {
1201 if !node_map.contains_key(pid) {
1202 roots.push(child_id.clone());
1203 } else if let Some(child) = node_map.get(child_id) {
1204 child_additions.push((pid.clone(), child.clone()));
1205 }
1206 } else {
1207 roots.push(child_id.clone());
1208 }
1209 }
1210 for (pid, child) in child_additions {
1211 if let Some(parent) = node_map.get_mut(&pid) {
1212 parent.children.push(child);
1213 }
1214 }
1215
1216 fn sort_tree(node: &mut SessionTreeNode) {
1217 node.children
1218 .sort_by_key(|c| c.entry.timestamp().to_string());
1219 for child in &mut node.children {
1220 sort_tree(child);
1221 }
1222 }
1223
1224 let mut result: Vec<SessionTreeNode> =
1225 roots.iter().filter_map(|id| node_map.remove(id)).collect();
1226 for root in &mut result {
1227 sort_tree(root);
1228 }
1229
1230 result
1231 }
1232
1233 pub fn get_entries(&self) -> Vec<SessionEntry> {
1235 self.session.get_entries()
1236 }
1237
1238 pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
1241 if !self.flushed && self.persist && crate::agent::types::message_is_assistant(message) {
1243 self.ensure_flushed();
1244 }
1245 self.session.append_message(message)
1246 }
1247
1248 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1249 self.session.append_thinking_level_change(thinking_level)
1250 }
1251
1252 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1253 self.session.append_model_change(provider, model_id)
1254 }
1255
1256 pub fn append_session_info(&mut self, name: &str) -> String {
1257 self.session.append_session_info(name)
1258 }
1259
1260 pub fn append_compaction(
1261 &mut self,
1262 summary: &str,
1263 first_kept_entry_id: &str,
1264 tokens_before: u64,
1265 details: Option<serde_json::Value>,
1266 from_hook: Option<bool>,
1267 ) -> String {
1268 self.session.append_compaction(
1269 summary,
1270 first_kept_entry_id,
1271 tokens_before,
1272 details,
1273 from_hook,
1274 )
1275 }
1276
1277 pub fn append_branch_summary(
1278 &mut self,
1279 from_id: &str,
1280 summary: &str,
1281 details: Option<serde_json::Value>,
1282 from_hook: Option<bool>,
1283 ) -> String {
1284 self.session
1285 .append_branch_summary(from_id, summary, details, from_hook)
1286 }
1287
1288 pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
1289 self.session.append_label_change(target_id, label)
1290 }
1291
1292 pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
1293 self.session.append_custom_entry(custom_type, data)
1294 }
1295
1296 pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
1297 self.session.append_active_tools_change(active_tool_names)
1298 }
1299
1300 pub fn append_custom_message_entry(
1301 &mut self,
1302 custom_type: &str,
1303 content: serde_json::Value,
1304 display: bool,
1305 details: Option<serde_json::Value>,
1306 ) -> String {
1307 self.session
1308 .append_custom_message_entry(custom_type, content, display, details)
1309 }
1310
1311 pub fn find_entries_by_type(&self, type_name: &str) -> Vec<SessionEntry> {
1315 self.session.find_entries(type_name)
1316 }
1317
1318 pub fn entries(&self) -> Vec<SessionEntry> {
1320 self.session.get_entries()
1321 }
1322
1323 pub fn entry(&self, id: &str) -> Option<SessionEntry> {
1325 self.session.get_entry(id)
1326 }
1327
1328 pub fn children(&self, parent_id: &str) -> Vec<SessionEntry> {
1330 self.session
1331 .get_entries()
1332 .into_iter()
1333 .filter(|e| e.parent_id() == Some(parent_id))
1334 .collect()
1335 }
1336
1337 pub fn branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1339 self.session.get_branch(from_id).unwrap_or_default()
1340 }
1341
1342 pub fn build_session_context(&self) -> SessionContext {
1345 self.session.build_context()
1346 }
1347
1348 pub fn label(&self, id: &str) -> Option<String> {
1350 self.session.get_label(id)
1351 }
1352
1353 pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1358 self.session.set_leaf_id(Some(branch_from_id))
1359 }
1360
1361 pub fn reset_leaf(&mut self) {
1363 let _ = self.session.reset_leaf();
1364 }
1365
1366 pub fn branch_with_summary(
1369 &mut self,
1370 branch_from_id: Option<&str>,
1371 summary: &str,
1372 details: Option<serde_json::Value>,
1373 from_hook: Option<bool>,
1374 ) -> Result<String, String> {
1375 let summary_tuple = Some((summary.to_string(), details, from_hook));
1376 self.session
1377 .move_to(branch_from_id, summary_tuple)
1378 .map(|opt| opt.unwrap_or_default())
1379 }
1380
1381 pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
1385 let dir = session_dir
1386 .map(|p| p.to_path_buf())
1387 .unwrap_or_else(|| get_default_session_dir(cwd));
1388 Self::create_persisted(cwd, &dir, None)
1389 }
1390
1391 pub fn create_with_options(
1393 cwd: &Path,
1394 session_dir: Option<&Path>,
1395 options: Option<&NewSessionOptions>,
1396 ) -> Self {
1397 let dir = session_dir
1398 .map(|p| p.to_path_buf())
1399 .unwrap_or_else(|| get_default_session_dir(cwd));
1400 Self::create_persisted(cwd, &dir, options)
1401 }
1402
1403 pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
1405 let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1406 path.parent()
1407 .map(|p| p.to_path_buf())
1408 .unwrap_or_else(|| get_default_session_dir(&PathBuf::from("/")))
1409 });
1410 Self::open_session(path, &dir, cwd_override)
1411 }
1412
1413 pub fn in_memory(cwd: &Path) -> Self {
1415 let dir = get_default_session_dir(cwd);
1416 Self::create_in_memory(cwd, &dir)
1417 }
1418
1419 pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
1421 let dir = session_dir
1422 .map(|p| p.to_path_buf())
1423 .unwrap_or_else(|| get_default_session_dir(cwd));
1424 let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
1425 let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
1426 if let Some(path) = most_recent {
1427 Self::open_session(&path, &dir, Some(cwd))
1428 } else {
1429 Self::create_persisted(cwd, &dir, None)
1430 }
1431 }
1432
1433 pub fn fork_from(
1436 source_path: &Path,
1437 target_cwd: &Path,
1438 session_dir: Option<&Path>,
1439 options: Option<&NewSessionOptions>,
1440 ) -> std::io::Result<Self> {
1441 let resolved_source = source_path;
1442 let resolved_target = target_cwd.to_path_buf();
1443 let dir = session_dir
1444 .map(|p| p.to_path_buf())
1445 .unwrap_or_else(|| get_default_session_dir(&resolved_target));
1446
1447 let source_entries = load_entries_from_file(resolved_source);
1448 if source_entries.is_empty() {
1449 return Err(std::io::Error::new(
1450 std::io::ErrorKind::InvalidData,
1451 "Cannot fork: source session is empty or invalid",
1452 ));
1453 }
1454
1455 let _source_header = read_session_header(resolved_source).ok_or_else(|| {
1456 std::io::Error::new(
1457 std::io::ErrorKind::InvalidData,
1458 "Cannot fork: source session has no header",
1459 )
1460 })?;
1461
1462 let id = options
1464 .and_then(|o| o.id.clone())
1465 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1466 let timestamp = chrono::Utc::now().to_rfc3339();
1467 let file_ts = timestamp.replace([':', '.'], "-");
1468 let file_name = format!("{}_{}.jsonl", file_ts, id);
1469 let target_path = dir.join(&file_name);
1470
1471 let mut storage = JsonlSessionStorage::create(
1473 target_path.clone(),
1474 &resolved_target.to_string_lossy(),
1475 &id,
1476 Some(resolved_source.to_string_lossy().to_string()),
1477 )
1478 .map_err(std::io::Error::other)?;
1479
1480 for entry in &source_entries {
1482 storage
1483 .append_entry(entry.clone())
1484 .map_err(std::io::Error::other)?;
1485 }
1486
1487 let session = Session::new(Box::new(storage));
1488 Ok(Self::with_session(session, dir, resolved_target, true))
1489 }
1490
1491 pub fn create_branched_session(&mut self, leaf_id: &str) -> Option<PathBuf> {
1495 let path = self.branch(Some(leaf_id));
1496 if path.is_empty() {
1497 return None;
1498 }
1499
1500 let mut path_clean: Vec<SessionEntry> = Vec::new();
1502 let mut path_parent_id: Option<String> = None;
1503 for entry in &path {
1504 if matches!(entry, SessionEntry::Label(_) | SessionEntry::Leaf(_)) {
1505 continue;
1506 }
1507 let mut e = entry.clone();
1508 match &mut e {
1509 SessionEntry::Message(m) => m.parent_id = path_parent_id.clone(),
1510 SessionEntry::ThinkingLevelChange(m) => m.parent_id = path_parent_id.clone(),
1511 SessionEntry::ModelChange(m) => m.parent_id = path_parent_id.clone(),
1512 SessionEntry::ActiveToolsChange(m) => m.parent_id = path_parent_id.clone(),
1513 SessionEntry::Compaction(m) => m.parent_id = path_parent_id.clone(),
1514 SessionEntry::BranchSummary(m) => m.parent_id = path_parent_id.clone(),
1515 SessionEntry::SessionInfo(m) => m.parent_id = path_parent_id.clone(),
1516 SessionEntry::Custom(m) => m.parent_id = path_parent_id.clone(),
1517 SessionEntry::CustomMessage(m) => m.parent_id = path_parent_id.clone(),
1518 _ => {}
1519 }
1520 path_parent_id = Some(e.id().to_string());
1521 path_clean.push(e);
1522 }
1523
1524 let path_entry_ids: std::collections::HashSet<String> =
1526 path_clean.iter().map(|e| e.id().to_string()).collect();
1527 let mut labels_to_write: Vec<(String, String)> = Vec::new();
1528 for id in &path_entry_ids {
1529 if let Some(label) = self.session.get_label(id) {
1530 labels_to_write.push((id.clone(), label));
1531 }
1532 }
1533
1534 let new_session_id = uuid::Uuid::new_v4().to_string();
1535 let timestamp = chrono::Utc::now().to_rfc3339();
1536 let file_ts = timestamp.replace([':', '.'], "-");
1537 let new_session_file = self
1538 .session_dir
1539 .join(format!("{}_{}.jsonl", file_ts, new_session_id));
1540
1541 let cwd_str = self.cwd.to_string_lossy().to_string();
1542
1543 if self.persist {
1545 let header = SessionHeader {
1546 type_: "session".to_string(),
1547 version: Some(CURRENT_SESSION_VERSION),
1548 id: new_session_id,
1549 timestamp,
1550 cwd: cwd_str,
1551 parent_session: self
1552 .session
1553 .metadata()
1554 .path
1555 .map(|p| p.to_string_lossy().to_string()),
1556 };
1557
1558 if let Some(parent) = new_session_file.parent() {
1559 let _ = std::fs::create_dir_all(parent);
1560 }
1561 let mut content = serde_json::to_string(&header).unwrap_or_default();
1562 content.push('\n');
1563 for entry in &path_clean {
1564 let line = serde_json::to_string(entry).unwrap_or_default();
1565 content.push_str(&line);
1566 content.push('\n');
1567 }
1568 for (target_id, label) in &labels_to_write {
1569 let label_entry = SessionEntry::Label(LabelEntry {
1570 id: uuid::Uuid::new_v4().to_string()[..8].to_string(),
1571 parent_id: path_parent_id.clone(),
1572 timestamp: chrono::Utc::now().to_rfc3339(),
1573 target_id: target_id.clone(),
1574 label: Some(label.clone()),
1575 });
1576 let line = serde_json::to_string(&label_entry).unwrap_or_default();
1577 content.push_str(&line);
1578 content.push('\n');
1579 }
1580 let _ = std::fs::write(&new_session_file, &content);
1581 }
1582
1583 Some(new_session_file)
1584 }
1585
1586 pub fn list_all(session_dir: Option<&Path>) -> Vec<SessionInfo> {
1588 let dir = if let Some(d) = session_dir {
1589 d.to_path_buf()
1590 } else {
1591 directories::BaseDirs::new()
1592 .expect("Could not determine home directory")
1593 .home_dir()
1594 .join(".rab")
1595 .join("sessions")
1596 };
1597
1598 let mut all_sessions: Vec<SessionInfo> = Vec::new();
1599
1600 if let Ok(read_dir) = std::fs::read_dir(&dir) {
1601 for entry in read_dir.flatten() {
1602 let path = entry.path();
1603 if path.is_dir() {
1604 let sessions = list_sessions(&path);
1605 all_sessions.extend(sessions);
1606 }
1607 }
1608 }
1609
1610 let root_sessions = list_sessions(&dir);
1612 all_sessions.extend(root_sessions);
1613
1614 all_sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1615 all_sessions
1616 }
1617}
1618
1619pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
1621 let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
1622 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1623
1624 let entries = std::fs::read_dir(session_dir).ok()?;
1625 for entry in entries.flatten() {
1626 let path = entry.path();
1627 if path.extension().is_some_and(|ext| ext == "jsonl") {
1628 let header = read_session_header(&path);
1629 if let Some(ref h) = header {
1630 if let Some(ref rcwd) = resolved_cwd
1631 && h.cwd != rcwd.to_string_lossy().as_ref()
1632 {
1633 continue;
1634 }
1635 } else {
1636 continue;
1637 }
1638 if let Ok(meta) = path.metadata()
1639 && let Ok(mtime) = meta.modified()
1640 {
1641 files.push((path, mtime));
1642 }
1643 }
1644 }
1645
1646 files.sort_by_key(|b| std::cmp::Reverse(b.1));
1647 files.into_iter().next().map(|(path, _)| path)
1648}
1649
1650pub fn list_sessions(session_dir: &Path) -> Vec<SessionInfo> {
1655 let mut sessions: Vec<SessionInfo> = Vec::new();
1656 let dir = match std::fs::read_dir(session_dir) {
1657 Ok(d) => d,
1658 Err(_) => return sessions,
1659 };
1660 for entry in dir.flatten() {
1661 let path = entry.path();
1662 if path.extension().is_some_and(|ext| ext == "jsonl")
1663 && let Some(info) = load_session_info(&path)
1664 {
1665 sessions.push(info);
1666 }
1667 }
1668 sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1669 sessions
1670}
1671
1672pub fn load_session_info(path: &Path) -> Option<SessionInfo> {
1674 let header = read_session_header(path)?;
1675 let created = DateTime::parse_from_rfc3339(&header.timestamp)
1676 .ok()?
1677 .with_timezone(&Utc);
1678 let modified = path.metadata().ok()?.modified().ok()?;
1679 let modified_dt: DateTime<Utc> = modified.into();
1680 let entries = load_entries_from_file(path);
1681 let name = entries.iter().rev().find_map(|e| {
1682 if let SessionEntry::SessionInfo(si) = e {
1683 let n = si.name.trim();
1684 if n.is_empty() {
1685 None
1686 } else {
1687 Some(n.to_string())
1688 }
1689 } else {
1690 None
1691 }
1692 });
1693 let message_count = entries
1694 .iter()
1695 .filter(|e| matches!(e, SessionEntry::Message(_)))
1696 .count();
1697 let first_message = entries
1698 .iter()
1699 .find_map(|e| {
1700 if let SessionEntry::Message(m) = e {
1701 Some(crate::agent::types::message_text(&m.message))
1702 } else {
1703 None
1704 }
1705 })
1706 .unwrap_or_default();
1707 let all_messages_text = entries
1708 .iter()
1709 .filter_map(|e| {
1710 if let SessionEntry::Message(m) = e {
1711 Some(crate::agent::types::message_text(&m.message))
1712 } else {
1713 None
1714 }
1715 })
1716 .collect::<Vec<_>>()
1717 .join("\n");
1718
1719 Some(SessionInfo {
1720 path: path.to_path_buf(),
1721 id: header.id,
1722 cwd: header.cwd,
1723 name,
1724 parent_session_path: header.parent_session,
1725 created,
1726 modified: modified_dt,
1727 message_count,
1728 first_message,
1729 all_messages_text,
1730 })
1731}
1732
1733pub fn delete_session(path: &Path) -> std::io::Result<()> {
1735 if path.exists() {
1736 std::fs::remove_file(path)?;
1737 }
1738 Ok(())
1739}
1740
1741pub fn fork_session(
1747 source_path: &Path,
1748 target_dir: &Path,
1749 entry_id: Option<&str>,
1750 position: Option<&str>,
1751) -> std::io::Result<String> {
1752 let header = read_session_header(source_path).ok_or_else(|| {
1753 std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing session header")
1754 })?;
1755 let entries = load_entries_from_file(source_path);
1756
1757 let by_id: HashMap<String, &SessionEntry> =
1759 entries.iter().map(|e| (e.id().to_string(), e)).collect();
1760
1761 let forked_entries: Vec<SessionEntry> = if let Some(target_id) = entry_id {
1762 let target = by_id.get(target_id).ok_or_else(|| {
1764 std::io::Error::new(std::io::ErrorKind::InvalidInput, "Entry not found")
1765 })?;
1766
1767 let effective_leaf_id = match position.unwrap_or("before") {
1769 "at" => Some(target.id().to_string()),
1770 _ => {
1771 if !matches!(target, SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message))
1772 {
1773 return Err(std::io::Error::new(
1774 std::io::ErrorKind::InvalidInput,
1775 "Entry is not a user message",
1776 ));
1777 }
1778 target.parent_id().map(|s| s.to_string())
1779 }
1780 };
1781
1782 let mut path: Vec<&SessionEntry> = Vec::new();
1784 let mut current = effective_leaf_id.as_ref().and_then(|id| by_id.get(id));
1785 while let Some(entry) = current {
1786 path.push(entry);
1787 current = entry.parent_id().and_then(|pid| by_id.get(pid));
1788 }
1789 path.reverse();
1790 path.into_iter().cloned().collect()
1791 } else {
1792 entries.clone()
1793 };
1794
1795 let session_id = uuid::Uuid::new_v4().to_string();
1797 let timestamp = chrono::Utc::now().to_rfc3339();
1798 let file_ts = timestamp.replace([':', '.'], "-");
1799 let file_name = format!("{}_{}.jsonl", file_ts, session_id);
1800 let target_path = target_dir.join(&file_name);
1801
1802 std::fs::create_dir_all(target_dir)?;
1803
1804 let new_header = SessionHeader {
1805 type_: "session".to_string(),
1806 version: Some(CURRENT_SESSION_VERSION),
1807 id: session_id.clone(),
1808 timestamp,
1809 cwd: header.cwd.clone(),
1810 parent_session: Some(source_path.to_string_lossy().to_string()),
1811 };
1812 write_entries_to_file(&target_path, &new_header, &forked_entries)?;
1813
1814 Ok(session_id)
1815}
1816
1817#[cfg(test)]
1820mod tests {
1821 use super::*;
1822 use crate::agent::types::user_message;
1823 use tempfile::TempDir;
1824
1825 fn make_user_msg(content: &str) -> AgentMessage {
1826 user_message(content)
1827 }
1828
1829 fn make_asst_msg(content: &str) -> AgentMessage {
1830 crate::agent::types::assistant_message(content)
1831 }
1832
1833 #[test]
1836 fn test_build_context_tracks_metadata() {
1837 let tmp = TempDir::new().unwrap();
1838 let sessions_dir = tmp.path().join("sessions");
1839 let cwd = tmp.path().join("project");
1840 std::fs::create_dir_all(&cwd).unwrap();
1841
1842 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1843 sm.append_thinking_level_change("high");
1844 sm.append_model_change("opencode_go", "deepseek-v4-pro");
1845 sm.append_active_tools_change(&["read".to_string(), "write".to_string()]);
1846 sm.append_message(&make_user_msg("hello"));
1847 sm.append_message(&make_asst_msg("hi"));
1848
1849 let context = sm.build_session_context();
1850 assert_eq!(context.thinking_level, "high");
1851 assert_eq!(
1852 context.model,
1853 Some(("opencode_go".to_string(), "deepseek-v4-pro".to_string()))
1854 );
1855 assert_eq!(
1856 context.active_tool_names,
1857 Some(vec!["read".to_string(), "write".to_string()])
1858 );
1859 assert_eq!(context.messages.len(), 2);
1860 }
1861
1862 #[test]
1863 fn test_build_context_defaults_when_no_metadata() {
1864 let cwd = Path::new("/tmp/test");
1865 let sm = SessionManager::in_memory(cwd);
1866 let context = sm.build_session_context();
1867 assert_eq!(context.thinking_level, "off");
1868 assert!(context.model.is_none());
1869 assert!(context.active_tool_names.is_none());
1870 assert!(context.messages.is_empty());
1871 }
1872
1873 #[test]
1876 fn test_find_entries_by_type() {
1877 let cwd = Path::new("/tmp/test");
1878 let mut sm = SessionManager::in_memory(cwd);
1879 sm.append_message(&make_user_msg("hello"));
1880 sm.append_thinking_level_change("high");
1881 sm.append_model_change("p", "m");
1882 sm.append_session_info("test session");
1883
1884 let messages = sm.find_entries_by_type("message");
1885 assert_eq!(messages.len(), 1);
1886
1887 let thinking = sm.find_entries_by_type("thinking_level_change");
1888 assert_eq!(thinking.len(), 1);
1889
1890 let models = sm.find_entries_by_type("model_change");
1891 assert_eq!(models.len(), 1);
1892
1893 let infos = sm.find_entries_by_type("session_info");
1894 assert_eq!(infos.len(), 1);
1895 }
1896
1897 #[test]
1900 fn test_list_sessions_empty_dir() {
1901 let tmp = TempDir::new().unwrap();
1902 let sessions = list_sessions(tmp.path());
1903 assert!(sessions.is_empty());
1904 }
1905
1906 #[test]
1907 fn test_list_sessions() {
1908 let tmp = TempDir::new().unwrap();
1909 let sessions_dir = tmp.path().join("sessions");
1910 let cwd = tmp.path().join("project");
1911 std::fs::create_dir_all(&cwd).unwrap();
1912
1913 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1914 sm.append_message(&make_user_msg("first"));
1915 sm.append_message(&make_asst_msg("response"));
1916 let path = sm.session_file().unwrap().to_path_buf();
1917 drop(sm);
1918
1919 let sessions = list_sessions(&sessions_dir);
1920 assert_eq!(sessions.len(), 1);
1921 assert_eq!(sessions[0].path, path);
1922 assert_eq!(sessions[0].message_count, 2);
1923 }
1924
1925 #[test]
1926 fn test_fork_session_all_entries() {
1927 let tmp = TempDir::new().unwrap();
1928 let sessions_dir = tmp.path().join("sessions");
1929 let cwd = tmp.path().join("project");
1930 std::fs::create_dir_all(&cwd).unwrap();
1931
1932 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1933 sm.append_message(&make_user_msg("hello"));
1934 sm.append_message(&make_asst_msg("world"));
1935 let source_path = sm.session_file().unwrap().to_path_buf();
1936 drop(sm);
1937
1938 let target_dir = tmp.path().join("forked");
1939 let new_id = fork_session(&source_path, &target_dir, None, None).unwrap();
1940 assert!(!new_id.is_empty());
1941
1942 let sessions = list_sessions(&target_dir);
1943 assert_eq!(sessions.len(), 1);
1944 assert_eq!(sessions[0].message_count, 2);
1945 }
1946
1947 #[test]
1948 fn test_delete_session() {
1949 let tmp = TempDir::new().unwrap();
1950 let path = tmp.path().join("test.jsonl");
1951 std::fs::write(&path, "{\"type\":\"session\",\"id\":\"test\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":\"/\"}\n").unwrap();
1952 assert!(path.exists());
1953 delete_session(&path).unwrap();
1954 assert!(!path.exists());
1955 delete_session(&path).unwrap();
1957 }
1958
1959 #[test]
1960 fn test_parse_session_entry_line() {
1961 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1962 id: "abc12345".to_string(),
1963 parent_id: None,
1964 timestamp: "2026-06-19T12:00:00Z".to_string(),
1965 name: "Test session".to_string(),
1966 });
1967 let json = serde_json::to_string(&entry).unwrap();
1968 let parsed = parse_session_entry_line(&json);
1969 assert!(parsed.is_some());
1970 }
1971
1972 #[test]
1973 fn test_parse_session_entry_line_empty() {
1974 assert!(parse_session_entry_line("").is_none());
1975 assert!(parse_session_entry_line(" ").is_none());
1976 }
1977
1978 #[test]
1979 fn test_parse_session_entry_line_malformed() {
1980 assert!(parse_session_entry_line("not valid json").is_none());
1981 }
1982
1983 #[test]
1984 fn test_parse_session_header_line() {
1985 let header = SessionHeader {
1986 type_: "session".to_string(),
1987 version: Some(3),
1988 id: "session123".to_string(),
1989 timestamp: "2026-06-19T12:00:00Z".to_string(),
1990 cwd: "/home/user/project".to_string(),
1991 parent_session: None,
1992 };
1993 let json = serde_json::to_string(&header).unwrap();
1994 let parsed = parse_session_header_line(&json);
1995 assert!(parsed.is_some());
1996 assert_eq!(parsed.unwrap().id, "session123");
1997 }
1998
1999 #[test]
2000 fn test_parse_session_header_line_wrong_type() {
2001 let json =
2003 r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
2004 let result = parse_session_header_line(json);
2005 assert!(result.is_none());
2006 }
2007
2008 #[test]
2009 fn test_write_and_read_entries() {
2010 let tmp = TempDir::new().unwrap();
2011 let file_path = tmp.path().join("test.jsonl");
2012
2013 let header = SessionHeader {
2014 type_: "session".to_string(),
2015 version: Some(3),
2016 id: "session1".to_string(),
2017 timestamp: "2026-06-19T12:00:00Z".to_string(),
2018 cwd: "/home/user/project".to_string(),
2019 parent_session: None,
2020 };
2021
2022 let entries: Vec<SessionEntry> = vec![
2023 SessionEntry::Message(MessageEntry {
2024 id: "msg1".to_string(),
2025 parent_id: None,
2026 timestamp: "2026-06-19T12:00:01Z".to_string(),
2027 message: make_user_msg("hello"),
2028 }),
2029 SessionEntry::Message(MessageEntry {
2030 id: "msg2".to_string(),
2031 parent_id: Some("msg1".to_string()),
2032 timestamp: "2026-06-19T12:00:02Z".to_string(),
2033 message: AgentMessage::Llm(yoagent::types::Message::Assistant {
2034 content: vec![yoagent::types::Content::Text {
2035 text: "hi there".to_string(),
2036 }],
2037 stop_reason: yoagent::types::StopReason::Stop,
2038 model: String::new(),
2039 provider: String::new(),
2040 usage: yoagent::types::Usage {
2041 input: 10,
2042 output: 5,
2043 ..Default::default()
2044 },
2045 timestamp: 0,
2046 error_message: None,
2047 }),
2048 }),
2049 ];
2050
2051 write_entries_to_file(&file_path, &header, &entries).unwrap();
2052
2053 let read_header = read_session_header(&file_path).unwrap();
2055 assert_eq!(read_header.id, "session1");
2056
2057 let read_entries = load_entries_from_file(&file_path);
2059 assert_eq!(read_entries.len(), 2);
2060
2061 match &read_entries[0] {
2062 SessionEntry::Message(e) => {
2063 assert_eq!(e.id, "msg1");
2064 assert!(crate::agent::types::message_is_user(&e.message));
2065 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2066 }
2067 _ => panic!("Expected Message"),
2068 }
2069
2070 match &read_entries[1] {
2071 SessionEntry::Message(e) => {
2072 assert_eq!(e.id, "msg2");
2073 assert!(crate::agent::types::message_is_assistant(&e.message));
2074 assert_eq!(crate::agent::types::message_text(&e.message), "hi there");
2075 assert!(crate::agent::types::message_usage(&e.message).is_some());
2076 }
2077 _ => panic!("Expected Message"),
2078 }
2079 }
2080
2081 #[test]
2082 fn test_append_entry_to_file() {
2083 let tmp = TempDir::new().unwrap();
2084 let file_path = tmp.path().join("append_test.jsonl");
2085
2086 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2087 id: "abc12345".to_string(),
2088 parent_id: None,
2089 timestamp: "2026-06-19T12:00:00Z".to_string(),
2090 name: "Test".to_string(),
2091 });
2092
2093 append_entry_to_file(&file_path, &entry).unwrap();
2094
2095 let content = fs::read_to_string(&file_path).unwrap();
2096 assert!(content.contains("Test"));
2097 assert!(content.contains("abc12345"));
2098 }
2099
2100 #[test]
2101 fn test_load_entries_missing_file() {
2102 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2103 assert!(entries.is_empty());
2104 }
2105
2106 #[test]
2107 fn test_read_session_header_missing_file() {
2108 let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
2109 assert!(header.is_none());
2110 }
2111
2112 #[test]
2115 fn test_encode_cwd() {
2116 assert_eq!(
2117 encode_cwd_for_dir(Path::new("/home/user/project")),
2118 "--home-user-project--"
2119 );
2120 }
2121
2122 #[test]
2123 fn test_encode_cwd_windows_style() {
2124 assert_eq!(
2125 encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
2126 "--C--Users-user-project--"
2127 );
2128 }
2129
2130 #[test]
2131 fn test_encode_cwd_no_leading_slash() {
2132 assert_eq!(
2133 encode_cwd_for_dir(Path::new("home/user/project")),
2134 "--home-user-project--"
2135 );
2136 }
2137
2138 #[test]
2139 fn test_encode_cwd_special_chars() {
2140 assert_eq!(
2141 encode_cwd_for_dir(Path::new("/home/user/my:project")),
2142 "--home-user-my-project--"
2143 );
2144 }
2145
2146 #[test]
2149 fn test_entry_id_accessor() {
2150 let entry = SessionEntry::Message(MessageEntry {
2151 id: "myid".to_string(),
2152 parent_id: None,
2153 timestamp: "2026-06-19T12:00:00Z".to_string(),
2154 message: make_user_msg("hello"),
2155 });
2156 assert_eq!(entry.id(), "myid");
2157 }
2158
2159 #[test]
2160 fn test_entry_parent_id_accessor() {
2161 let entry = SessionEntry::Message(MessageEntry {
2162 id: "myid".to_string(),
2163 parent_id: Some("parent".to_string()),
2164 timestamp: "2026-06-19T12:00:00Z".to_string(),
2165 message: make_user_msg("hello"),
2166 });
2167 assert_eq!(entry.parent_id(), Some("parent"));
2168 }
2169
2170 #[test]
2171 fn test_entry_timestamp_accessor() {
2172 let entry = SessionEntry::Message(MessageEntry {
2173 id: "myid".to_string(),
2174 parent_id: None,
2175 timestamp: "2026-06-19T12:00:00Z".to_string(),
2176 message: make_user_msg("hello"),
2177 });
2178 assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
2179 }
2180
2181 #[test]
2184 fn test_generate_entry_id_length() {
2185 let map = HashMap::new();
2186 let id = generate_entry_id(&map);
2187 assert_eq!(id.len(), 8);
2188 }
2189
2190 #[test]
2191 fn test_generate_entry_id_hex() {
2192 let map = HashMap::new();
2193 let id = generate_entry_id(&map);
2194 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
2195 }
2196
2197 #[test]
2198 fn test_generate_entry_id_collision_fallback() {
2199 let map = HashMap::new();
2204 let id1 = generate_entry_id(&map);
2205 assert!(!id1.is_empty());
2206 }
2207
2208 #[test]
2211 fn test_deserialize_pi_format_message() {
2212 let json = r#"{"type":"message","id":"abc12345","parentId":null,"timestamp":"2026-06-19T12:00:00Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":1718800000000}}"#;
2215 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2216 match entry {
2217 SessionEntry::Message(e) => {
2218 assert_eq!(e.id, "abc12345");
2219 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2220 }
2221 _ => panic!("Expected Message"),
2222 }
2223 }
2224
2225 #[test]
2226 fn test_deserialize_pi_format_thinking_level() {
2227 let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
2228 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2229 match entry {
2230 SessionEntry::ThinkingLevelChange(e) => {
2231 assert_eq!(e.thinking_level, "high");
2232 }
2233 _ => panic!("Expected ThinkingLevelChange"),
2234 }
2235 }
2236
2237 #[test]
2238 fn test_deserialize_pi_format_model_change() {
2239 let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
2240 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2241 match entry {
2242 SessionEntry::ModelChange(e) => {
2243 assert_eq!(e.provider, "opencode_go");
2244 assert_eq!(e.model_id, "deepseek-v4-pro");
2245 }
2246 _ => panic!("Expected ModelChange"),
2247 }
2248 }
2249
2250 #[test]
2251 fn test_deserialize_pi_format_compaction() {
2252 let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
2253 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2254 match entry {
2255 SessionEntry::Compaction(e) => {
2256 assert_eq!(e.summary, "Earlier conversation summarized");
2257 assert_eq!(e.first_kept_entry_id, "entry123");
2258 assert_eq!(e.tokens_before, 5000);
2259 }
2260 _ => panic!("Expected Compaction"),
2261 }
2262 }
2263
2264 #[test]
2265 fn test_deserialize_pi_format_session_info() {
2266 let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
2267 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2268 match entry {
2269 SessionEntry::SessionInfo(e) => {
2270 assert_eq!(e.name, "My session");
2271 }
2272 _ => panic!("Expected SessionInfo"),
2273 }
2274 }
2275
2276 #[test]
2279 fn test_session_create_in_memory() {
2280 let cwd = Path::new("/tmp/test-project");
2281 let sm = SessionManager::in_memory(cwd);
2282 assert!(!sm.is_persisted());
2283 assert!(!sm.session_id().is_empty());
2284 assert_eq!(sm.cwd(), cwd);
2285 assert!(sm.leaf_id().is_none());
2286 assert!(sm.entries().is_empty());
2287 }
2288
2289 #[test]
2290 fn test_session_create_persisted() {
2291 let tmp = TempDir::new().unwrap();
2292 let sessions_dir = tmp.path().join("sessions");
2293 let cwd = tmp.path().join("project");
2294 std::fs::create_dir_all(&cwd).unwrap();
2295
2296 let sm = SessionManager::create(&cwd, Some(&sessions_dir));
2297 assert!(sm.is_persisted());
2298 assert!(!sm.session_id().is_empty());
2299 assert!(
2301 sm.session_file().is_none(),
2302 "session file should not be created until first assistant message (lazy write)"
2303 );
2304 assert!(!sm.flushed);
2305 }
2306
2307 #[test]
2308 fn test_session_append_and_build_context() {
2309 let tmp = TempDir::new().unwrap();
2310 let sessions_dir = tmp.path().join("sessions");
2311 let cwd = tmp.path().join("project");
2312 std::fs::create_dir_all(&cwd).unwrap();
2313
2314 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2315
2316 let user_msg = make_user_msg("hello");
2317 let user_id = sm.append_message(&user_msg);
2318 assert_eq!(sm.leaf_id().as_deref(), Some(user_id.as_str()));
2319
2320 assert_eq!(sm.entries().len(), 1);
2322
2323 let assistant_msg = make_asst_msg("hi there");
2324 sm.append_message(&assistant_msg);
2325 assert_eq!(sm.entries().len(), 2);
2326
2327 assert!(
2329 sm.session_file().unwrap().exists(),
2330 "session file should exist after first assistant message"
2331 );
2332
2333 let context = sm.build_session_context();
2334 assert_eq!(context.messages.len(), 2);
2335 assert_eq!(
2336 crate::agent::types::message_text(&context.messages[0]),
2337 "hello"
2338 );
2339 assert_eq!(
2340 crate::agent::types::message_text(&context.messages[1]),
2341 "hi there"
2342 );
2343 }
2344
2345 #[test]
2346 fn test_session_open_existing() {
2347 let tmp = TempDir::new().unwrap();
2348 let sessions_dir = tmp.path().join("sessions");
2349 let cwd = tmp.path().join("project");
2350 std::fs::create_dir_all(&cwd).unwrap();
2351
2352 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2354 sm.append_message(&make_user_msg("first"));
2355 sm.append_message(&make_asst_msg("response"));
2356
2357 let file_path = sm.session_file().unwrap().to_path_buf();
2358 let session_id = sm.session_id().to_string();
2359 drop(sm);
2360
2361 let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
2363 assert_eq!(sm2.session_id(), session_id);
2364 let context = sm2.build_session_context();
2365 assert_eq!(context.messages.len(), 2);
2366 assert_eq!(
2367 crate::agent::types::message_text(&context.messages[0]),
2368 "first"
2369 );
2370 assert_eq!(
2371 crate::agent::types::message_text(&context.messages[1]),
2372 "response"
2373 );
2374 }
2375
2376 #[test]
2377 fn test_session_continue_recent() {
2378 let tmp = TempDir::new().unwrap();
2379 let sessions_dir = tmp.path().join("sessions");
2380 let cwd = tmp.path().join("project");
2381 std::fs::create_dir_all(&cwd).unwrap();
2382
2383 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2385 sm1.append_message(&make_user_msg("old session"));
2386 sm1.append_message(&make_asst_msg("old response"));
2387 let _old_id = sm1.session_id().to_string();
2388 drop(sm1);
2389
2390 std::thread::sleep(std::time::Duration::from_millis(10));
2392
2393 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2395 sm2.append_message(&make_user_msg("new session"));
2396 sm2.append_message(&make_asst_msg("new response"));
2397 let new_id = sm2.session_id().to_string();
2398 drop(sm2);
2399
2400 let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2402 assert_eq!(sm3.session_id(), new_id);
2403 let context = sm3.build_session_context();
2404 assert_eq!(
2405 crate::agent::types::message_text(&context.messages[0]),
2406 "new session"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_session_continue_recent_none_exist() {
2412 let tmp = TempDir::new().unwrap();
2413 let sessions_dir = tmp.path().join("sessions");
2414 let cwd = tmp.path().join("project");
2415 std::fs::create_dir_all(&sessions_dir).unwrap();
2416 std::fs::create_dir_all(&cwd).unwrap();
2417
2418 let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2420 assert!(!sm.session_id().is_empty());
2421 assert!(sm.entries().is_empty());
2422 }
2423
2424 #[test]
2425 fn test_session_name() {
2426 let tmp = TempDir::new().unwrap();
2427 let sessions_dir = tmp.path().join("sessions");
2428 let cwd = tmp.path().join("project");
2429 std::fs::create_dir_all(&cwd).unwrap();
2430
2431 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2432 assert!(sm.session_name().is_none());
2433
2434 sm.append_session_info("My Task");
2435 sm.append_message(&make_user_msg("hello"));
2436 sm.append_message(&make_asst_msg("hi"));
2437 assert_eq!(sm.session_name().as_deref(), Some("My Task"));
2438
2439 sm.append_session_info("");
2441 assert!(sm.session_name().is_none());
2442 }
2443
2444 #[test]
2445 fn test_session_thinking_level_change() {
2446 let tmp = TempDir::new().unwrap();
2447 let sessions_dir = tmp.path().join("sessions");
2448 let cwd = tmp.path().join("project");
2449 std::fs::create_dir_all(&cwd).unwrap();
2450
2451 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2452 sm.append_thinking_level_change("high");
2453
2454 assert_eq!(sm.entries().len(), 1);
2455 match &sm.entries()[0] {
2456 SessionEntry::ThinkingLevelChange(e) => {
2457 assert_eq!(e.thinking_level, "high");
2458 }
2459 _ => panic!("Expected ThinkingLevelChange"),
2460 }
2461 }
2462
2463 #[test]
2464 fn test_session_model_change() {
2465 let tmp = TempDir::new().unwrap();
2466 let sessions_dir = tmp.path().join("sessions");
2467 let cwd = tmp.path().join("project");
2468 std::fs::create_dir_all(&cwd).unwrap();
2469
2470 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2471 sm.append_model_change("opencode_go", "deepseek-v4-pro");
2472
2473 assert_eq!(sm.entries().len(), 1);
2474 match &sm.entries()[0] {
2475 SessionEntry::ModelChange(e) => {
2476 assert_eq!(e.provider, "opencode_go");
2477 assert_eq!(e.model_id, "deepseek-v4-pro");
2478 }
2479 _ => panic!("Expected ModelChange"),
2480 }
2481 }
2482
2483 #[test]
2484 fn test_session_compaction() {
2485 let tmp = TempDir::new().unwrap();
2486 let sessions_dir = tmp.path().join("sessions");
2487 let cwd = tmp.path().join("project");
2488 std::fs::create_dir_all(&cwd).unwrap();
2489
2490 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2491 sm.append_compaction("Earlier work summarized", "entry_kept", 5000, None, None);
2492
2493 match &sm.entries()[0] {
2494 SessionEntry::Compaction(e) => {
2495 assert_eq!(e.summary, "Earlier work summarized");
2496 assert_eq!(e.first_kept_entry_id, "entry_kept");
2497 assert_eq!(e.tokens_before, 5000);
2498 }
2499 _ => panic!("Expected Compaction"),
2500 }
2501 }
2502
2503 #[test]
2504 fn test_session_label() {
2505 let tmp = TempDir::new().unwrap();
2506 let sessions_dir = tmp.path().join("sessions");
2507 let cwd = tmp.path().join("project");
2508 std::fs::create_dir_all(&cwd).unwrap();
2509
2510 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2511 let msg_id = sm.append_message(&make_user_msg("important message"));
2512 sm.append_message(&make_asst_msg("ok"));
2513
2514 sm.append_label_change(&msg_id, Some("important"));
2516 assert_eq!(sm.label(&msg_id).as_deref(), Some("important"));
2517
2518 sm.append_label_change(&msg_id, None);
2520 assert_eq!(sm.label(&msg_id), None);
2521 }
2522
2523 #[test]
2524 fn test_session_branch_navigation() {
2525 let tmp = TempDir::new().unwrap();
2526 let sessions_dir = tmp.path().join("sessions");
2527 let cwd = tmp.path().join("project");
2528 std::fs::create_dir_all(&cwd).unwrap();
2529
2530 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2531 let m1 = sm.append_message(&make_user_msg("one"));
2532 sm.append_message(&make_asst_msg("response one"));
2533 let _m2 = sm.append_message(&make_user_msg("two"));
2534 sm.append_message(&make_asst_msg("response two"));
2535
2536 assert_eq!(sm.entries().len(), 4);
2538
2539 sm.set_branch(&m1).unwrap();
2541 assert_eq!(sm.entries().len(), 5); assert_eq!(sm.leaf_id().as_deref(), Some(m1.as_str()));
2543
2544 sm.append_message(&make_asst_msg("alternate response"));
2546 assert_eq!(sm.entries().len(), 6);
2548
2549 let context = sm.build_session_context();
2551 assert_eq!(context.messages.len(), 2); assert_eq!(context.thinking_level, "off");
2554 assert!(context.model.is_none());
2555 assert!(context.active_tool_names.is_none());
2556 }
2557
2558 #[test]
2559 fn test_session_reset_leaf() {
2560 let tmp = TempDir::new().unwrap();
2561 let sessions_dir = tmp.path().join("sessions");
2562 let cwd = tmp.path().join("project");
2563 std::fs::create_dir_all(&cwd).unwrap();
2564
2565 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2566 sm.append_message(&make_user_msg("one"));
2567 sm.append_message(&make_asst_msg("response"));
2568 assert_eq!(sm.entries().len(), 2);
2569
2570 sm.reset_leaf();
2572 assert_eq!(sm.entries().len(), 3);
2574 assert!(sm.leaf_id().is_none());
2575
2576 sm.append_message(&make_user_msg("fresh start"));
2578 assert_eq!(sm.entries().len(), 4);
2579 match &sm.entries()[3] {
2581 SessionEntry::Message(m) => {
2582 assert!(m.parent_id.is_none());
2583 }
2584 _ => panic!("Expected Message"),
2585 }
2586 }
2587
2588 #[test]
2589 fn test_session_branch_summary() {
2590 let tmp = TempDir::new().unwrap();
2591 let sessions_dir = tmp.path().join("sessions");
2592 let cwd = tmp.path().join("project");
2593 std::fs::create_dir_all(&cwd).unwrap();
2594
2595 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2596 sm.append_message(&make_user_msg("one"));
2597 sm.append_message(&make_asst_msg("response"));
2598
2599 sm.append_branch_summary("root", "Abandoned path summary", None, None);
2600
2601 match &sm.entries()[2] {
2602 SessionEntry::BranchSummary(e) => {
2603 assert_eq!(e.summary, "Abandoned path summary");
2604 assert_eq!(e.from_id, "root");
2605 }
2606 _ => panic!("Expected BranchSummary"),
2607 }
2608 }
2609
2610 #[test]
2611 fn test_session_children() {
2612 let tmp = TempDir::new().unwrap();
2613 let sessions_dir = tmp.path().join("sessions");
2614 let cwd = tmp.path().join("project");
2615 std::fs::create_dir_all(&cwd).unwrap();
2616
2617 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2618 let m1 = sm.append_message(&make_user_msg("one"));
2619 sm.append_message(&make_asst_msg("response"));
2620
2621 let children = sm.children(&m1);
2623 assert_eq!(children.len(), 1);
2624 }
2625
2626 #[test]
2627 fn test_session_custom_entry() {
2628 let tmp = TempDir::new().unwrap();
2629 let sessions_dir = tmp.path().join("sessions");
2630 let cwd = tmp.path().join("project");
2631 std::fs::create_dir_all(&cwd).unwrap();
2632
2633 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2634 sm.append_message(&make_user_msg("one"));
2635 sm.append_message(&make_asst_msg("ok"));
2636 sm.append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
2637
2638 match &sm.entries()[2] {
2639 SessionEntry::Custom(e) => {
2640 assert_eq!(e.custom_type, "my_ext");
2641 assert_eq!(e.data["key"], "value");
2642 }
2643 _ => panic!("Expected Custom"),
2644 }
2645 }
2646
2647 #[test]
2648 fn test_find_most_recent_session() {
2649 let tmp = TempDir::new().unwrap();
2650 let sessions_dir = tmp.path().join("sessions");
2651 let cwd = tmp.path().join("project");
2652 std::fs::create_dir_all(&sessions_dir).unwrap();
2653 std::fs::create_dir_all(&cwd).unwrap();
2654
2655 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2657 sm1.append_message(&make_user_msg("old"));
2658 sm1.append_message(&make_asst_msg("old"));
2659 let _path1 = sm1.session_file().unwrap().to_path_buf();
2660 drop(sm1);
2661
2662 std::thread::sleep(std::time::Duration::from_millis(10));
2663
2664 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2666 sm2.append_message(&make_user_msg("new"));
2667 sm2.append_message(&make_asst_msg("new"));
2668 let path2 = sm2.session_file().unwrap().to_path_buf();
2669 drop(sm2);
2670
2671 let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
2672 assert_eq!(most_recent, path2);
2673 }
2674
2675 #[test]
2678 fn test_corrupt_empty_file_is_recovered() {
2679 let tmp = TempDir::new().unwrap();
2680 let sessions_dir = tmp.path().join("sessions");
2681 let cwd = tmp.path().join("project");
2682 std::fs::create_dir_all(&sessions_dir).unwrap();
2683 std::fs::create_dir_all(&cwd).unwrap();
2684
2685 let file_path = sessions_dir.join("empty.jsonl");
2687 std::fs::write(&file_path, "").unwrap();
2688
2689 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2691 assert!(!sm.session_id().is_empty());
2692 assert!(sm.entries().is_empty());
2693 assert_eq!(sm.session_file().unwrap(), file_path);
2694 }
2695
2696 #[test]
2697 fn test_corrupt_garbage_file_is_recovered() {
2698 let tmp = TempDir::new().unwrap();
2699 let sessions_dir = tmp.path().join("sessions");
2700 let cwd = tmp.path().join("project");
2701 std::fs::create_dir_all(&sessions_dir).unwrap();
2702 std::fs::create_dir_all(&cwd).unwrap();
2703
2704 let file_path = sessions_dir.join("garbage.jsonl");
2706 std::fs::write(
2707 &file_path,
2708 "this is not json\nneither is this\n{half-json\n",
2709 )
2710 .unwrap();
2711
2712 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2714 assert!(!sm.session_id().is_empty());
2715 assert!(sm.entries().is_empty());
2716 }
2717
2718 #[test]
2719 fn test_corrupt_header_only_file_is_kept() {
2720 let tmp = TempDir::new().unwrap();
2721 let sessions_dir = tmp.path().join("sessions");
2722 let cwd = tmp.path().join("project");
2723 std::fs::create_dir_all(&sessions_dir).unwrap();
2724 std::fs::create_dir_all(&cwd).unwrap();
2725
2726 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2728 sm.append_message(&make_user_msg("test"));
2729 sm.append_message(&make_asst_msg("ok"));
2730 let original_id = sm.session_id().to_string();
2731 let file_path = sm.session_file().unwrap().to_path_buf();
2732 drop(sm);
2733
2734 let content = std::fs::read_to_string(&file_path).unwrap();
2736 let header_line = content.lines().next().unwrap();
2737 std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
2738
2739 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2741 assert_eq!(sm.session_id(), original_id);
2742 assert!(sm.entries().is_empty());
2743 }
2744
2745 #[test]
2746 fn test_corrupt_malformed_lines_are_skipped() {
2747 let tmp = TempDir::new().unwrap();
2748 let sessions_dir = tmp.path().join("sessions");
2749 let cwd = tmp.path().join("project");
2750 std::fs::create_dir_all(&sessions_dir).unwrap();
2751 std::fs::create_dir_all(&cwd).unwrap();
2752
2753 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2755 sm.append_message(&make_user_msg("valid message"));
2756 sm.append_message(&make_asst_msg("valid response"));
2757 let file_path = sm.session_file().unwrap().to_path_buf();
2758 drop(sm);
2759
2760 let mut content = std::fs::read_to_string(&file_path).unwrap();
2762 content.push_str("this is garbage\n");
2763 content.push_str("{incomplete json\n");
2764 content.push('\n'); std::fs::write(&file_path, &content).unwrap();
2766
2767 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2769 let ctx = sm.build_session_context();
2770 assert_eq!(ctx.messages.len(), 2);
2771 assert_eq!(
2772 crate::agent::types::message_text(&ctx.messages[0]),
2773 "valid message"
2774 );
2775 assert_eq!(
2776 crate::agent::types::message_text(&ctx.messages[1]),
2777 "valid response"
2778 );
2779 }
2780
2781 #[test]
2782 fn test_corrupt_missing_header_uses_new_id() {
2783 let tmp = TempDir::new().unwrap();
2784 let sessions_dir = tmp.path().join("sessions");
2785 let cwd = tmp.path().join("project");
2786 std::fs::create_dir_all(&sessions_dir).unwrap();
2787 std::fs::create_dir_all(&cwd).unwrap();
2788
2789 let entry = SessionEntry::Message(MessageEntry {
2791 id: "msg1".to_string(),
2792 parent_id: None,
2793 timestamp: "2026-01-01T00:00:00Z".to_string(),
2794 message: make_user_msg("orphan message"),
2795 });
2796 let json = serde_json::to_string(&entry).unwrap();
2797 let file_path = sessions_dir.join("no_header.jsonl");
2798 std::fs::write(&file_path, format!("{}\n", json)).unwrap();
2799
2800 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2803 assert!(!sm.session_id().is_empty());
2804 assert_eq!(sm.entries().len(), 0);
2805 }
2806
2807 #[test]
2808 fn test_corrupt_file_then_append_works() {
2809 let tmp = TempDir::new().unwrap();
2810 let sessions_dir = tmp.path().join("sessions");
2811 let cwd = tmp.path().join("project");
2812 std::fs::create_dir_all(&sessions_dir).unwrap();
2813 std::fs::create_dir_all(&cwd).unwrap();
2814
2815 let file_path = sessions_dir.join("recovered.jsonl");
2817 std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
2818
2819 let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2821 assert!(sm.entries().is_empty());
2822
2823 sm.append_message(&make_user_msg("fresh start"));
2825 sm.append_message(&make_asst_msg("fresh response"));
2826
2827 let ctx = sm.build_session_context();
2828 assert_eq!(ctx.messages.len(), 2);
2829 assert_eq!(
2830 crate::agent::types::message_text(&ctx.messages[0]),
2831 "fresh start"
2832 );
2833
2834 let content = std::fs::read_to_string(&file_path).unwrap();
2836 assert!(content.contains("fresh start"));
2837 assert!(!content.contains("garbage"));
2838 }
2839
2840 #[test]
2841 fn test_corrupt_all_lines_malformed_is_empty() {
2842 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2843 assert!(entries.is_empty());
2844 }
2845
2846 #[test]
2847 fn test_corrupt_malformed_line_returns_none() {
2848 let result = parse_session_entry_line("not valid json");
2849 assert!(result.is_none());
2850 }
2851
2852 #[test]
2853 fn test_corrupt_blank_lines_are_skipped() {
2854 let result = parse_session_entry_line("");
2855 assert!(result.is_none());
2856 let result = parse_session_entry_line(" ");
2857 assert!(result.is_none());
2858 }
2859
2860 #[test]
2861 fn test_corrupt_header_line_malformed_returns_none() {
2862 let result = read_session_header(Path::new("/nonexistent"));
2863 assert!(result.is_none());
2864 }
2865}