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 {
1244 self.ensure_flushed();
1245 }
1246 self.session.append_message(message)
1247 }
1248
1249 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1250 self.session.append_thinking_level_change(thinking_level)
1251 }
1252
1253 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1254 self.session.append_model_change(provider, model_id)
1255 }
1256
1257 pub fn append_session_info(&mut self, name: &str) -> String {
1258 self.session.append_session_info(name)
1259 }
1260
1261 pub fn append_compaction(
1262 &mut self,
1263 summary: &str,
1264 first_kept_entry_id: &str,
1265 tokens_before: u64,
1266 details: Option<serde_json::Value>,
1267 from_hook: Option<bool>,
1268 ) -> String {
1269 self.session.append_compaction(
1270 summary,
1271 first_kept_entry_id,
1272 tokens_before,
1273 details,
1274 from_hook,
1275 )
1276 }
1277
1278 pub fn append_branch_summary(
1279 &mut self,
1280 from_id: &str,
1281 summary: &str,
1282 details: Option<serde_json::Value>,
1283 from_hook: Option<bool>,
1284 ) -> String {
1285 self.session
1286 .append_branch_summary(from_id, summary, details, from_hook)
1287 }
1288
1289 pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
1290 self.session.append_label_change(target_id, label)
1291 }
1292
1293 pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
1294 self.session.append_custom_entry(custom_type, data)
1295 }
1296
1297 pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
1298 self.session.append_active_tools_change(active_tool_names)
1299 }
1300
1301 pub fn append_custom_message_entry(
1302 &mut self,
1303 custom_type: &str,
1304 content: serde_json::Value,
1305 display: bool,
1306 details: Option<serde_json::Value>,
1307 ) -> String {
1308 self.session
1309 .append_custom_message_entry(custom_type, content, display, details)
1310 }
1311
1312 pub fn find_entries_by_type(&self, type_name: &str) -> Vec<SessionEntry> {
1316 self.session.find_entries(type_name)
1317 }
1318
1319 pub fn entries(&self) -> Vec<SessionEntry> {
1321 self.session.get_entries()
1322 }
1323
1324 pub fn entry(&self, id: &str) -> Option<SessionEntry> {
1326 self.session.get_entry(id)
1327 }
1328
1329 pub fn children(&self, parent_id: &str) -> Vec<SessionEntry> {
1331 self.session
1332 .get_entries()
1333 .into_iter()
1334 .filter(|e| e.parent_id() == Some(parent_id))
1335 .collect()
1336 }
1337
1338 pub fn branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1340 self.session.get_branch(from_id).unwrap_or_default()
1341 }
1342
1343 pub fn build_session_context(&self) -> SessionContext {
1346 self.session.build_context()
1347 }
1348
1349 pub fn label(&self, id: &str) -> Option<String> {
1351 self.session.get_label(id)
1352 }
1353
1354 pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1359 self.session.set_leaf_id(Some(branch_from_id))
1360 }
1361
1362 pub fn reset_leaf(&mut self) {
1364 let _ = self.session.reset_leaf();
1365 }
1366
1367 pub fn branch_with_summary(
1370 &mut self,
1371 branch_from_id: Option<&str>,
1372 summary: &str,
1373 details: Option<serde_json::Value>,
1374 from_hook: Option<bool>,
1375 ) -> Result<String, String> {
1376 let summary_tuple = Some((summary.to_string(), details, from_hook));
1377 self.session
1378 .move_to(branch_from_id, summary_tuple)
1379 .map(|opt| opt.unwrap_or_default())
1380 }
1381
1382 pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
1386 let dir = session_dir
1387 .map(|p| p.to_path_buf())
1388 .unwrap_or_else(|| get_default_session_dir(cwd));
1389 Self::create_persisted(cwd, &dir, None)
1390 }
1391
1392 pub fn create_with_options(
1394 cwd: &Path,
1395 session_dir: Option<&Path>,
1396 options: Option<&NewSessionOptions>,
1397 ) -> Self {
1398 let dir = session_dir
1399 .map(|p| p.to_path_buf())
1400 .unwrap_or_else(|| get_default_session_dir(cwd));
1401 Self::create_persisted(cwd, &dir, options)
1402 }
1403
1404 pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
1406 let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1407 path.parent()
1408 .map(|p| p.to_path_buf())
1409 .unwrap_or_else(|| get_default_session_dir(&PathBuf::from("/")))
1410 });
1411 Self::open_session(path, &dir, cwd_override)
1412 }
1413
1414 pub fn in_memory(cwd: &Path) -> Self {
1416 let dir = get_default_session_dir(cwd);
1417 Self::create_in_memory(cwd, &dir)
1418 }
1419
1420 pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
1422 let dir = session_dir
1423 .map(|p| p.to_path_buf())
1424 .unwrap_or_else(|| get_default_session_dir(cwd));
1425 let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
1426 let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
1427 if let Some(path) = most_recent {
1428 Self::open_session(&path, &dir, Some(cwd))
1429 } else {
1430 Self::create_persisted(cwd, &dir, None)
1431 }
1432 }
1433
1434 pub fn fork_from(
1437 source_path: &Path,
1438 target_cwd: &Path,
1439 session_dir: Option<&Path>,
1440 options: Option<&NewSessionOptions>,
1441 ) -> std::io::Result<Self> {
1442 let resolved_source = source_path;
1443 let resolved_target = target_cwd.to_path_buf();
1444 let dir = session_dir
1445 .map(|p| p.to_path_buf())
1446 .unwrap_or_else(|| get_default_session_dir(&resolved_target));
1447
1448 let source_entries = load_entries_from_file(resolved_source);
1449 if source_entries.is_empty() {
1450 return Err(std::io::Error::new(
1451 std::io::ErrorKind::InvalidData,
1452 "Cannot fork: source session is empty or invalid",
1453 ));
1454 }
1455
1456 let _source_header = read_session_header(resolved_source).ok_or_else(|| {
1457 std::io::Error::new(
1458 std::io::ErrorKind::InvalidData,
1459 "Cannot fork: source session has no header",
1460 )
1461 })?;
1462
1463 let id = options
1465 .and_then(|o| o.id.clone())
1466 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1467 let timestamp = chrono::Utc::now().to_rfc3339();
1468 let file_ts = timestamp.replace([':', '.'], "-");
1469 let file_name = format!("{}_{}.jsonl", file_ts, id);
1470 let target_path = dir.join(&file_name);
1471
1472 let mut storage = JsonlSessionStorage::create(
1474 target_path.clone(),
1475 &resolved_target.to_string_lossy(),
1476 &id,
1477 Some(resolved_source.to_string_lossy().to_string()),
1478 )
1479 .map_err(std::io::Error::other)?;
1480
1481 for entry in &source_entries {
1483 storage
1484 .append_entry(entry.clone())
1485 .map_err(std::io::Error::other)?;
1486 }
1487
1488 let session = Session::new(Box::new(storage));
1489 Ok(Self::with_session(session, dir, resolved_target, true))
1490 }
1491
1492 pub fn create_branched_session(&mut self, leaf_id: &str) -> Option<PathBuf> {
1496 let path = self.branch(Some(leaf_id));
1497 if path.is_empty() {
1498 return None;
1499 }
1500
1501 let mut path_clean: Vec<SessionEntry> = Vec::new();
1503 let mut path_parent_id: Option<String> = None;
1504 for entry in &path {
1505 if matches!(entry, SessionEntry::Label(_) | SessionEntry::Leaf(_)) {
1506 continue;
1507 }
1508 let mut e = entry.clone();
1509 match &mut e {
1510 SessionEntry::Message(m) => m.parent_id = path_parent_id.clone(),
1511 SessionEntry::ThinkingLevelChange(m) => m.parent_id = path_parent_id.clone(),
1512 SessionEntry::ModelChange(m) => m.parent_id = path_parent_id.clone(),
1513 SessionEntry::ActiveToolsChange(m) => m.parent_id = path_parent_id.clone(),
1514 SessionEntry::Compaction(m) => m.parent_id = path_parent_id.clone(),
1515 SessionEntry::BranchSummary(m) => m.parent_id = path_parent_id.clone(),
1516 SessionEntry::SessionInfo(m) => m.parent_id = path_parent_id.clone(),
1517 SessionEntry::Custom(m) => m.parent_id = path_parent_id.clone(),
1518 SessionEntry::CustomMessage(m) => m.parent_id = path_parent_id.clone(),
1519 _ => {}
1520 }
1521 path_parent_id = Some(e.id().to_string());
1522 path_clean.push(e);
1523 }
1524
1525 let path_entry_ids: std::collections::HashSet<String> =
1527 path_clean.iter().map(|e| e.id().to_string()).collect();
1528 let mut labels_to_write: Vec<(String, String)> = Vec::new();
1529 for id in &path_entry_ids {
1530 if let Some(label) = self.session.get_label(id) {
1531 labels_to_write.push((id.clone(), label));
1532 }
1533 }
1534
1535 let new_session_id = uuid::Uuid::new_v4().to_string();
1536 let timestamp = chrono::Utc::now().to_rfc3339();
1537 let file_ts = timestamp.replace([':', '.'], "-");
1538 let new_session_file = self
1539 .session_dir
1540 .join(format!("{}_{}.jsonl", file_ts, new_session_id));
1541
1542 let cwd_str = self.cwd.to_string_lossy().to_string();
1543
1544 if self.persist {
1546 let header = SessionHeader {
1547 type_: "session".to_string(),
1548 version: Some(CURRENT_SESSION_VERSION),
1549 id: new_session_id,
1550 timestamp,
1551 cwd: cwd_str,
1552 parent_session: self
1553 .session
1554 .metadata()
1555 .path
1556 .map(|p| p.to_string_lossy().to_string()),
1557 };
1558
1559 if let Some(parent) = new_session_file.parent() {
1560 let _ = std::fs::create_dir_all(parent);
1561 }
1562 let mut content = serde_json::to_string(&header).unwrap_or_default();
1563 content.push('\n');
1564 for entry in &path_clean {
1565 let line = serde_json::to_string(entry).unwrap_or_default();
1566 content.push_str(&line);
1567 content.push('\n');
1568 }
1569 for (target_id, label) in &labels_to_write {
1570 let label_entry = SessionEntry::Label(LabelEntry {
1571 id: uuid::Uuid::new_v4().to_string()[..8].to_string(),
1572 parent_id: path_parent_id.clone(),
1573 timestamp: chrono::Utc::now().to_rfc3339(),
1574 target_id: target_id.clone(),
1575 label: Some(label.clone()),
1576 });
1577 let line = serde_json::to_string(&label_entry).unwrap_or_default();
1578 content.push_str(&line);
1579 content.push('\n');
1580 }
1581 let _ = std::fs::write(&new_session_file, &content);
1582 }
1583
1584 Some(new_session_file)
1585 }
1586
1587 pub fn list_all(session_dir: Option<&Path>) -> Vec<SessionInfo> {
1589 let dir = if let Some(d) = session_dir {
1590 d.to_path_buf()
1591 } else {
1592 directories::BaseDirs::new()
1593 .expect("Could not determine home directory")
1594 .home_dir()
1595 .join(".rab")
1596 .join("sessions")
1597 };
1598
1599 let mut all_sessions: Vec<SessionInfo> = Vec::new();
1600
1601 if let Ok(read_dir) = std::fs::read_dir(&dir) {
1602 for entry in read_dir.flatten() {
1603 let path = entry.path();
1604 if path.is_dir() {
1605 let sessions = list_sessions(&path);
1606 all_sessions.extend(sessions);
1607 }
1608 }
1609 }
1610
1611 let root_sessions = list_sessions(&dir);
1613 all_sessions.extend(root_sessions);
1614
1615 all_sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1616 all_sessions
1617 }
1618}
1619
1620pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
1622 let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
1623 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1624
1625 let entries = std::fs::read_dir(session_dir).ok()?;
1626 for entry in entries.flatten() {
1627 let path = entry.path();
1628 if path.extension().is_some_and(|ext| ext == "jsonl") {
1629 let header = read_session_header(&path);
1630 if let Some(ref h) = header {
1631 if let Some(ref rcwd) = resolved_cwd
1632 && h.cwd != rcwd.to_string_lossy().as_ref()
1633 {
1634 continue;
1635 }
1636 } else {
1637 continue;
1638 }
1639 if let Ok(meta) = path.metadata()
1640 && let Ok(mtime) = meta.modified()
1641 {
1642 files.push((path, mtime));
1643 }
1644 }
1645 }
1646
1647 files.sort_by_key(|b| std::cmp::Reverse(b.1));
1648 files.into_iter().next().map(|(path, _)| path)
1649}
1650
1651pub fn list_sessions(session_dir: &Path) -> Vec<SessionInfo> {
1656 let mut sessions: Vec<SessionInfo> = Vec::new();
1657 let dir = match std::fs::read_dir(session_dir) {
1658 Ok(d) => d,
1659 Err(_) => return sessions,
1660 };
1661 for entry in dir.flatten() {
1662 let path = entry.path();
1663 if path.extension().is_some_and(|ext| ext == "jsonl")
1664 && let Some(info) = load_session_info(&path)
1665 {
1666 sessions.push(info);
1667 }
1668 }
1669 sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1670 sessions
1671}
1672
1673pub fn load_session_info(path: &Path) -> Option<SessionInfo> {
1675 let header = read_session_header(path)?;
1676 let created = DateTime::parse_from_rfc3339(&header.timestamp)
1677 .ok()?
1678 .with_timezone(&Utc);
1679 let modified = path.metadata().ok()?.modified().ok()?;
1680 let modified_dt: DateTime<Utc> = modified.into();
1681 let entries = load_entries_from_file(path);
1682 let name = entries.iter().rev().find_map(|e| {
1683 if let SessionEntry::SessionInfo(si) = e {
1684 let n = si.name.trim();
1685 if n.is_empty() {
1686 None
1687 } else {
1688 Some(n.to_string())
1689 }
1690 } else {
1691 None
1692 }
1693 });
1694 let message_count = entries
1695 .iter()
1696 .filter(|e| matches!(e, SessionEntry::Message(_)))
1697 .count();
1698 let first_message = entries
1699 .iter()
1700 .find_map(|e| {
1701 if let SessionEntry::Message(m) = e {
1702 Some(crate::agent::types::message_text(&m.message))
1703 } else {
1704 None
1705 }
1706 })
1707 .unwrap_or_default();
1708 let all_messages_text = entries
1709 .iter()
1710 .filter_map(|e| {
1711 if let SessionEntry::Message(m) = e {
1712 Some(crate::agent::types::message_text(&m.message))
1713 } else {
1714 None
1715 }
1716 })
1717 .collect::<Vec<_>>()
1718 .join("\n");
1719
1720 Some(SessionInfo {
1721 path: path.to_path_buf(),
1722 id: header.id,
1723 cwd: header.cwd,
1724 name,
1725 parent_session_path: header.parent_session,
1726 created,
1727 modified: modified_dt,
1728 message_count,
1729 first_message,
1730 all_messages_text,
1731 })
1732}
1733
1734pub fn delete_session(path: &Path) -> std::io::Result<()> {
1736 if path.exists() {
1737 std::fs::remove_file(path)?;
1738 }
1739 Ok(())
1740}
1741
1742pub fn fork_session(
1748 source_path: &Path,
1749 target_dir: &Path,
1750 entry_id: Option<&str>,
1751 position: Option<&str>,
1752) -> std::io::Result<String> {
1753 let header = read_session_header(source_path).ok_or_else(|| {
1754 std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing session header")
1755 })?;
1756 let entries = load_entries_from_file(source_path);
1757
1758 let by_id: HashMap<String, &SessionEntry> =
1760 entries.iter().map(|e| (e.id().to_string(), e)).collect();
1761
1762 let forked_entries: Vec<SessionEntry> = if let Some(target_id) = entry_id {
1763 let target = by_id.get(target_id).ok_or_else(|| {
1765 std::io::Error::new(std::io::ErrorKind::InvalidInput, "Entry not found")
1766 })?;
1767
1768 let effective_leaf_id = match position.unwrap_or("before") {
1770 "at" => Some(target.id().to_string()),
1771 _ => {
1772 if !matches!(target, SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message))
1773 {
1774 return Err(std::io::Error::new(
1775 std::io::ErrorKind::InvalidInput,
1776 "Entry is not a user message",
1777 ));
1778 }
1779 target.parent_id().map(|s| s.to_string())
1780 }
1781 };
1782
1783 let mut path: Vec<&SessionEntry> = Vec::new();
1785 let mut current = effective_leaf_id.as_ref().and_then(|id| by_id.get(id));
1786 while let Some(entry) = current {
1787 path.push(entry);
1788 current = entry.parent_id().and_then(|pid| by_id.get(pid));
1789 }
1790 path.reverse();
1791 path.into_iter().cloned().collect()
1792 } else {
1793 entries.clone()
1794 };
1795
1796 let session_id = uuid::Uuid::new_v4().to_string();
1798 let timestamp = chrono::Utc::now().to_rfc3339();
1799 let file_ts = timestamp.replace([':', '.'], "-");
1800 let file_name = format!("{}_{}.jsonl", file_ts, session_id);
1801 let target_path = target_dir.join(&file_name);
1802
1803 std::fs::create_dir_all(target_dir)?;
1804
1805 let new_header = SessionHeader {
1806 type_: "session".to_string(),
1807 version: Some(CURRENT_SESSION_VERSION),
1808 id: session_id.clone(),
1809 timestamp,
1810 cwd: header.cwd.clone(),
1811 parent_session: Some(source_path.to_string_lossy().to_string()),
1812 };
1813 write_entries_to_file(&target_path, &new_header, &forked_entries)?;
1814
1815 Ok(session_id)
1816}
1817
1818#[cfg(test)]
1821mod tests {
1822 use super::*;
1823 use crate::agent::types::user_message;
1824 use tempfile::TempDir;
1825
1826 fn make_user_msg(content: &str) -> AgentMessage {
1827 user_message(content)
1828 }
1829
1830 fn make_asst_msg(content: &str) -> AgentMessage {
1831 crate::agent::types::assistant_message(content)
1832 }
1833
1834 #[test]
1837 fn test_build_context_tracks_metadata() {
1838 let tmp = TempDir::new().unwrap();
1839 let sessions_dir = tmp.path().join("sessions");
1840 let cwd = tmp.path().join("project");
1841 std::fs::create_dir_all(&cwd).unwrap();
1842
1843 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1844 sm.append_thinking_level_change("high");
1845 sm.append_model_change("opencode_go", "deepseek-v4-pro");
1846 sm.append_active_tools_change(&["read".to_string(), "write".to_string()]);
1847 sm.append_message(&make_user_msg("hello"));
1848 sm.append_message(&make_asst_msg("hi"));
1849
1850 let context = sm.build_session_context();
1851 assert_eq!(context.thinking_level, "high");
1852 assert_eq!(
1853 context.model,
1854 Some(("opencode_go".to_string(), "deepseek-v4-pro".to_string()))
1855 );
1856 assert_eq!(
1857 context.active_tool_names,
1858 Some(vec!["read".to_string(), "write".to_string()])
1859 );
1860 assert_eq!(context.messages.len(), 2);
1861 }
1862
1863 #[test]
1864 fn test_build_context_defaults_when_no_metadata() {
1865 let cwd = Path::new("/tmp/test");
1866 let sm = SessionManager::in_memory(cwd);
1867 let context = sm.build_session_context();
1868 assert_eq!(context.thinking_level, "off");
1869 assert!(context.model.is_none());
1870 assert!(context.active_tool_names.is_none());
1871 assert!(context.messages.is_empty());
1872 }
1873
1874 #[test]
1877 fn test_find_entries_by_type() {
1878 let cwd = Path::new("/tmp/test");
1879 let mut sm = SessionManager::in_memory(cwd);
1880 sm.append_message(&make_user_msg("hello"));
1881 sm.append_thinking_level_change("high");
1882 sm.append_model_change("p", "m");
1883 sm.append_session_info("test session");
1884
1885 let messages = sm.find_entries_by_type("message");
1886 assert_eq!(messages.len(), 1);
1887
1888 let thinking = sm.find_entries_by_type("thinking_level_change");
1889 assert_eq!(thinking.len(), 1);
1890
1891 let models = sm.find_entries_by_type("model_change");
1892 assert_eq!(models.len(), 1);
1893
1894 let infos = sm.find_entries_by_type("session_info");
1895 assert_eq!(infos.len(), 1);
1896 }
1897
1898 #[test]
1901 fn test_list_sessions_empty_dir() {
1902 let tmp = TempDir::new().unwrap();
1903 let sessions = list_sessions(tmp.path());
1904 assert!(sessions.is_empty());
1905 }
1906
1907 #[test]
1908 fn test_list_sessions() {
1909 let tmp = TempDir::new().unwrap();
1910 let sessions_dir = tmp.path().join("sessions");
1911 let cwd = tmp.path().join("project");
1912 std::fs::create_dir_all(&cwd).unwrap();
1913
1914 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1915 sm.append_message(&make_user_msg("first"));
1916 sm.append_message(&make_asst_msg("response"));
1917 let path = sm.session_file().unwrap().to_path_buf();
1918 drop(sm);
1919
1920 let sessions = list_sessions(&sessions_dir);
1921 assert_eq!(sessions.len(), 1);
1922 assert_eq!(sessions[0].path, path);
1923 assert_eq!(sessions[0].message_count, 2);
1924 }
1925
1926 #[test]
1927 fn test_fork_session_all_entries() {
1928 let tmp = TempDir::new().unwrap();
1929 let sessions_dir = tmp.path().join("sessions");
1930 let cwd = tmp.path().join("project");
1931 std::fs::create_dir_all(&cwd).unwrap();
1932
1933 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1934 sm.append_message(&make_user_msg("hello"));
1935 sm.append_message(&make_asst_msg("world"));
1936 let source_path = sm.session_file().unwrap().to_path_buf();
1937 drop(sm);
1938
1939 let target_dir = tmp.path().join("forked");
1940 let new_id = fork_session(&source_path, &target_dir, None, None).unwrap();
1941 assert!(!new_id.is_empty());
1942
1943 let sessions = list_sessions(&target_dir);
1944 assert_eq!(sessions.len(), 1);
1945 assert_eq!(sessions[0].message_count, 2);
1946 }
1947
1948 #[test]
1949 fn test_delete_session() {
1950 let tmp = TempDir::new().unwrap();
1951 let path = tmp.path().join("test.jsonl");
1952 std::fs::write(&path, "{\"type\":\"session\",\"id\":\"test\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":\"/\"}\n").unwrap();
1953 assert!(path.exists());
1954 delete_session(&path).unwrap();
1955 assert!(!path.exists());
1956 delete_session(&path).unwrap();
1958 }
1959
1960 #[test]
1961 fn test_parse_session_entry_line() {
1962 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1963 id: "abc12345".to_string(),
1964 parent_id: None,
1965 timestamp: "2026-06-19T12:00:00Z".to_string(),
1966 name: "Test session".to_string(),
1967 });
1968 let json = serde_json::to_string(&entry).unwrap();
1969 let parsed = parse_session_entry_line(&json);
1970 assert!(parsed.is_some());
1971 }
1972
1973 #[test]
1974 fn test_parse_session_entry_line_empty() {
1975 assert!(parse_session_entry_line("").is_none());
1976 assert!(parse_session_entry_line(" ").is_none());
1977 }
1978
1979 #[test]
1980 fn test_parse_session_entry_line_malformed() {
1981 assert!(parse_session_entry_line("not valid json").is_none());
1982 }
1983
1984 #[test]
1985 fn test_parse_session_header_line() {
1986 let header = SessionHeader {
1987 type_: "session".to_string(),
1988 version: Some(3),
1989 id: "session123".to_string(),
1990 timestamp: "2026-06-19T12:00:00Z".to_string(),
1991 cwd: "/home/user/project".to_string(),
1992 parent_session: None,
1993 };
1994 let json = serde_json::to_string(&header).unwrap();
1995 let parsed = parse_session_header_line(&json);
1996 assert!(parsed.is_some());
1997 assert_eq!(parsed.unwrap().id, "session123");
1998 }
1999
2000 #[test]
2001 fn test_parse_session_header_line_wrong_type() {
2002 let json =
2004 r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
2005 let result = parse_session_header_line(json);
2006 assert!(result.is_none());
2007 }
2008
2009 #[test]
2010 fn test_write_and_read_entries() {
2011 let tmp = TempDir::new().unwrap();
2012 let file_path = tmp.path().join("test.jsonl");
2013
2014 let header = SessionHeader {
2015 type_: "session".to_string(),
2016 version: Some(3),
2017 id: "session1".to_string(),
2018 timestamp: "2026-06-19T12:00:00Z".to_string(),
2019 cwd: "/home/user/project".to_string(),
2020 parent_session: None,
2021 };
2022
2023 let entries: Vec<SessionEntry> = vec![
2024 SessionEntry::Message(MessageEntry {
2025 id: "msg1".to_string(),
2026 parent_id: None,
2027 timestamp: "2026-06-19T12:00:01Z".to_string(),
2028 message: make_user_msg("hello"),
2029 }),
2030 SessionEntry::Message(MessageEntry {
2031 id: "msg2".to_string(),
2032 parent_id: Some("msg1".to_string()),
2033 timestamp: "2026-06-19T12:00:02Z".to_string(),
2034 message: AgentMessage::Llm(yoagent::types::Message::Assistant {
2035 content: vec![yoagent::types::Content::Text {
2036 text: "hi there".to_string(),
2037 }],
2038 stop_reason: yoagent::types::StopReason::Stop,
2039 model: String::new(),
2040 provider: String::new(),
2041 usage: yoagent::types::Usage {
2042 input: 10,
2043 output: 5,
2044 ..Default::default()
2045 },
2046 timestamp: 0,
2047 error_message: None,
2048 }),
2049 }),
2050 ];
2051
2052 write_entries_to_file(&file_path, &header, &entries).unwrap();
2053
2054 let read_header = read_session_header(&file_path).unwrap();
2056 assert_eq!(read_header.id, "session1");
2057
2058 let read_entries = load_entries_from_file(&file_path);
2060 assert_eq!(read_entries.len(), 2);
2061
2062 match &read_entries[0] {
2063 SessionEntry::Message(e) => {
2064 assert_eq!(e.id, "msg1");
2065 assert!(crate::agent::types::message_is_user(&e.message));
2066 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2067 }
2068 _ => panic!("Expected Message"),
2069 }
2070
2071 match &read_entries[1] {
2072 SessionEntry::Message(e) => {
2073 assert_eq!(e.id, "msg2");
2074 assert!(crate::agent::types::message_is_assistant(&e.message));
2075 assert_eq!(crate::agent::types::message_text(&e.message), "hi there");
2076 assert!(crate::agent::types::message_usage(&e.message).is_some());
2077 }
2078 _ => panic!("Expected Message"),
2079 }
2080 }
2081
2082 #[test]
2083 fn test_append_entry_to_file() {
2084 let tmp = TempDir::new().unwrap();
2085 let file_path = tmp.path().join("append_test.jsonl");
2086
2087 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2088 id: "abc12345".to_string(),
2089 parent_id: None,
2090 timestamp: "2026-06-19T12:00:00Z".to_string(),
2091 name: "Test".to_string(),
2092 });
2093
2094 append_entry_to_file(&file_path, &entry).unwrap();
2095
2096 let content = fs::read_to_string(&file_path).unwrap();
2097 assert!(content.contains("Test"));
2098 assert!(content.contains("abc12345"));
2099 }
2100
2101 #[test]
2102 fn test_load_entries_missing_file() {
2103 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2104 assert!(entries.is_empty());
2105 }
2106
2107 #[test]
2108 fn test_read_session_header_missing_file() {
2109 let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
2110 assert!(header.is_none());
2111 }
2112
2113 #[test]
2116 fn test_encode_cwd() {
2117 assert_eq!(
2118 encode_cwd_for_dir(Path::new("/home/user/project")),
2119 "--home-user-project--"
2120 );
2121 }
2122
2123 #[test]
2124 fn test_encode_cwd_windows_style() {
2125 assert_eq!(
2126 encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
2127 "--C--Users-user-project--"
2128 );
2129 }
2130
2131 #[test]
2132 fn test_encode_cwd_no_leading_slash() {
2133 assert_eq!(
2134 encode_cwd_for_dir(Path::new("home/user/project")),
2135 "--home-user-project--"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_encode_cwd_special_chars() {
2141 assert_eq!(
2142 encode_cwd_for_dir(Path::new("/home/user/my:project")),
2143 "--home-user-my-project--"
2144 );
2145 }
2146
2147 #[test]
2150 fn test_entry_id_accessor() {
2151 let entry = SessionEntry::Message(MessageEntry {
2152 id: "myid".to_string(),
2153 parent_id: None,
2154 timestamp: "2026-06-19T12:00:00Z".to_string(),
2155 message: make_user_msg("hello"),
2156 });
2157 assert_eq!(entry.id(), "myid");
2158 }
2159
2160 #[test]
2161 fn test_entry_parent_id_accessor() {
2162 let entry = SessionEntry::Message(MessageEntry {
2163 id: "myid".to_string(),
2164 parent_id: Some("parent".to_string()),
2165 timestamp: "2026-06-19T12:00:00Z".to_string(),
2166 message: make_user_msg("hello"),
2167 });
2168 assert_eq!(entry.parent_id(), Some("parent"));
2169 }
2170
2171 #[test]
2172 fn test_entry_timestamp_accessor() {
2173 let entry = SessionEntry::Message(MessageEntry {
2174 id: "myid".to_string(),
2175 parent_id: None,
2176 timestamp: "2026-06-19T12:00:00Z".to_string(),
2177 message: make_user_msg("hello"),
2178 });
2179 assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
2180 }
2181
2182 #[test]
2185 fn test_generate_entry_id_length() {
2186 let map = HashMap::new();
2187 let id = generate_entry_id(&map);
2188 assert_eq!(id.len(), 8);
2189 }
2190
2191 #[test]
2192 fn test_generate_entry_id_hex() {
2193 let map = HashMap::new();
2194 let id = generate_entry_id(&map);
2195 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
2196 }
2197
2198 #[test]
2199 fn test_generate_entry_id_collision_fallback() {
2200 let map = HashMap::new();
2205 let id1 = generate_entry_id(&map);
2206 assert!(!id1.is_empty());
2207 }
2208
2209 #[test]
2212 fn test_deserialize_pi_format_message() {
2213 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}}"#;
2216 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2217 match entry {
2218 SessionEntry::Message(e) => {
2219 assert_eq!(e.id, "abc12345");
2220 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2221 }
2222 _ => panic!("Expected Message"),
2223 }
2224 }
2225
2226 #[test]
2227 fn test_deserialize_pi_format_thinking_level() {
2228 let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
2229 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2230 match entry {
2231 SessionEntry::ThinkingLevelChange(e) => {
2232 assert_eq!(e.thinking_level, "high");
2233 }
2234 _ => panic!("Expected ThinkingLevelChange"),
2235 }
2236 }
2237
2238 #[test]
2239 fn test_deserialize_pi_format_model_change() {
2240 let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
2241 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2242 match entry {
2243 SessionEntry::ModelChange(e) => {
2244 assert_eq!(e.provider, "opencode_go");
2245 assert_eq!(e.model_id, "deepseek-v4-pro");
2246 }
2247 _ => panic!("Expected ModelChange"),
2248 }
2249 }
2250
2251 #[test]
2252 fn test_deserialize_pi_format_compaction() {
2253 let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
2254 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2255 match entry {
2256 SessionEntry::Compaction(e) => {
2257 assert_eq!(e.summary, "Earlier conversation summarized");
2258 assert_eq!(e.first_kept_entry_id, "entry123");
2259 assert_eq!(e.tokens_before, 5000);
2260 }
2261 _ => panic!("Expected Compaction"),
2262 }
2263 }
2264
2265 #[test]
2266 fn test_deserialize_pi_format_session_info() {
2267 let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
2268 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2269 match entry {
2270 SessionEntry::SessionInfo(e) => {
2271 assert_eq!(e.name, "My session");
2272 }
2273 _ => panic!("Expected SessionInfo"),
2274 }
2275 }
2276
2277 #[test]
2280 fn test_session_create_in_memory() {
2281 let cwd = Path::new("/tmp/test-project");
2282 let sm = SessionManager::in_memory(cwd);
2283 assert!(!sm.is_persisted());
2284 assert!(!sm.session_id().is_empty());
2285 assert_eq!(sm.cwd(), cwd);
2286 assert!(sm.leaf_id().is_none());
2287 assert!(sm.entries().is_empty());
2288 }
2289
2290 #[test]
2291 fn test_session_create_persisted() {
2292 let tmp = TempDir::new().unwrap();
2293 let sessions_dir = tmp.path().join("sessions");
2294 let cwd = tmp.path().join("project");
2295 std::fs::create_dir_all(&cwd).unwrap();
2296
2297 let sm = SessionManager::create(&cwd, Some(&sessions_dir));
2298 assert!(sm.is_persisted());
2299 assert!(!sm.session_id().is_empty());
2300 assert!(
2302 sm.session_file().is_none(),
2303 "session file should not be created until first assistant message (lazy write)"
2304 );
2305 assert!(!sm.flushed);
2306 }
2307
2308 #[test]
2309 fn test_session_append_and_build_context() {
2310 let tmp = TempDir::new().unwrap();
2311 let sessions_dir = tmp.path().join("sessions");
2312 let cwd = tmp.path().join("project");
2313 std::fs::create_dir_all(&cwd).unwrap();
2314
2315 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2316
2317 let user_msg = make_user_msg("hello");
2318 let user_id = sm.append_message(&user_msg);
2319 assert_eq!(sm.leaf_id().as_deref(), Some(user_id.as_str()));
2320
2321 assert_eq!(sm.entries().len(), 1);
2323
2324 let assistant_msg = make_asst_msg("hi there");
2325 sm.append_message(&assistant_msg);
2326 assert_eq!(sm.entries().len(), 2);
2327
2328 assert!(
2330 sm.session_file().unwrap().exists(),
2331 "session file should exist after first assistant message"
2332 );
2333
2334 let context = sm.build_session_context();
2335 assert_eq!(context.messages.len(), 2);
2336 assert_eq!(
2337 crate::agent::types::message_text(&context.messages[0]),
2338 "hello"
2339 );
2340 assert_eq!(
2341 crate::agent::types::message_text(&context.messages[1]),
2342 "hi there"
2343 );
2344 }
2345
2346 #[test]
2347 fn test_session_open_existing() {
2348 let tmp = TempDir::new().unwrap();
2349 let sessions_dir = tmp.path().join("sessions");
2350 let cwd = tmp.path().join("project");
2351 std::fs::create_dir_all(&cwd).unwrap();
2352
2353 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2355 sm.append_message(&make_user_msg("first"));
2356 sm.append_message(&make_asst_msg("response"));
2357
2358 let file_path = sm.session_file().unwrap().to_path_buf();
2359 let session_id = sm.session_id().to_string();
2360 drop(sm);
2361
2362 let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
2364 assert_eq!(sm2.session_id(), session_id);
2365 let context = sm2.build_session_context();
2366 assert_eq!(context.messages.len(), 2);
2367 assert_eq!(
2368 crate::agent::types::message_text(&context.messages[0]),
2369 "first"
2370 );
2371 assert_eq!(
2372 crate::agent::types::message_text(&context.messages[1]),
2373 "response"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_session_continue_recent() {
2379 let tmp = TempDir::new().unwrap();
2380 let sessions_dir = tmp.path().join("sessions");
2381 let cwd = tmp.path().join("project");
2382 std::fs::create_dir_all(&cwd).unwrap();
2383
2384 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2386 sm1.append_message(&make_user_msg("old session"));
2387 sm1.append_message(&make_asst_msg("old response"));
2388 let _old_id = sm1.session_id().to_string();
2389 drop(sm1);
2390
2391 std::thread::sleep(std::time::Duration::from_millis(10));
2393
2394 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2396 sm2.append_message(&make_user_msg("new session"));
2397 sm2.append_message(&make_asst_msg("new response"));
2398 let new_id = sm2.session_id().to_string();
2399 drop(sm2);
2400
2401 let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2403 assert_eq!(sm3.session_id(), new_id);
2404 let context = sm3.build_session_context();
2405 assert_eq!(
2406 crate::agent::types::message_text(&context.messages[0]),
2407 "new session"
2408 );
2409 }
2410
2411 #[test]
2412 fn test_session_continue_recent_none_exist() {
2413 let tmp = TempDir::new().unwrap();
2414 let sessions_dir = tmp.path().join("sessions");
2415 let cwd = tmp.path().join("project");
2416 std::fs::create_dir_all(&sessions_dir).unwrap();
2417 std::fs::create_dir_all(&cwd).unwrap();
2418
2419 let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2421 assert!(!sm.session_id().is_empty());
2422 assert!(sm.entries().is_empty());
2423 }
2424
2425 #[test]
2426 fn test_session_name() {
2427 let tmp = TempDir::new().unwrap();
2428 let sessions_dir = tmp.path().join("sessions");
2429 let cwd = tmp.path().join("project");
2430 std::fs::create_dir_all(&cwd).unwrap();
2431
2432 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2433 assert!(sm.session_name().is_none());
2434
2435 sm.append_session_info("My Task");
2436 sm.append_message(&make_user_msg("hello"));
2437 sm.append_message(&make_asst_msg("hi"));
2438 assert_eq!(sm.session_name().as_deref(), Some("My Task"));
2439
2440 sm.append_session_info("");
2442 assert!(sm.session_name().is_none());
2443 }
2444
2445 #[test]
2446 fn test_session_thinking_level_change() {
2447 let tmp = TempDir::new().unwrap();
2448 let sessions_dir = tmp.path().join("sessions");
2449 let cwd = tmp.path().join("project");
2450 std::fs::create_dir_all(&cwd).unwrap();
2451
2452 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2453 sm.append_thinking_level_change("high");
2454
2455 assert_eq!(sm.entries().len(), 1);
2456 match &sm.entries()[0] {
2457 SessionEntry::ThinkingLevelChange(e) => {
2458 assert_eq!(e.thinking_level, "high");
2459 }
2460 _ => panic!("Expected ThinkingLevelChange"),
2461 }
2462 }
2463
2464 #[test]
2465 fn test_session_model_change() {
2466 let tmp = TempDir::new().unwrap();
2467 let sessions_dir = tmp.path().join("sessions");
2468 let cwd = tmp.path().join("project");
2469 std::fs::create_dir_all(&cwd).unwrap();
2470
2471 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2472 sm.append_model_change("opencode_go", "deepseek-v4-pro");
2473
2474 assert_eq!(sm.entries().len(), 1);
2475 match &sm.entries()[0] {
2476 SessionEntry::ModelChange(e) => {
2477 assert_eq!(e.provider, "opencode_go");
2478 assert_eq!(e.model_id, "deepseek-v4-pro");
2479 }
2480 _ => panic!("Expected ModelChange"),
2481 }
2482 }
2483
2484 #[test]
2485 fn test_session_compaction() {
2486 let tmp = TempDir::new().unwrap();
2487 let sessions_dir = tmp.path().join("sessions");
2488 let cwd = tmp.path().join("project");
2489 std::fs::create_dir_all(&cwd).unwrap();
2490
2491 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2492 sm.append_compaction("Earlier work summarized", "entry_kept", 5000, None, None);
2493
2494 match &sm.entries()[0] {
2495 SessionEntry::Compaction(e) => {
2496 assert_eq!(e.summary, "Earlier work summarized");
2497 assert_eq!(e.first_kept_entry_id, "entry_kept");
2498 assert_eq!(e.tokens_before, 5000);
2499 }
2500 _ => panic!("Expected Compaction"),
2501 }
2502 }
2503
2504 #[test]
2505 fn test_session_label() {
2506 let tmp = TempDir::new().unwrap();
2507 let sessions_dir = tmp.path().join("sessions");
2508 let cwd = tmp.path().join("project");
2509 std::fs::create_dir_all(&cwd).unwrap();
2510
2511 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2512 let msg_id = sm.append_message(&make_user_msg("important message"));
2513 sm.append_message(&make_asst_msg("ok"));
2514
2515 sm.append_label_change(&msg_id, Some("important"));
2517 assert_eq!(sm.label(&msg_id).as_deref(), Some("important"));
2518
2519 sm.append_label_change(&msg_id, None);
2521 assert_eq!(sm.label(&msg_id), None);
2522 }
2523
2524 #[test]
2525 fn test_session_branch_navigation() {
2526 let tmp = TempDir::new().unwrap();
2527 let sessions_dir = tmp.path().join("sessions");
2528 let cwd = tmp.path().join("project");
2529 std::fs::create_dir_all(&cwd).unwrap();
2530
2531 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2532 let m1 = sm.append_message(&make_user_msg("one"));
2533 sm.append_message(&make_asst_msg("response one"));
2534 let _m2 = sm.append_message(&make_user_msg("two"));
2535 sm.append_message(&make_asst_msg("response two"));
2536
2537 assert_eq!(sm.entries().len(), 4);
2539
2540 sm.set_branch(&m1).unwrap();
2542 assert_eq!(sm.entries().len(), 5); assert_eq!(sm.leaf_id().as_deref(), Some(m1.as_str()));
2544
2545 sm.append_message(&make_asst_msg("alternate response"));
2547 assert_eq!(sm.entries().len(), 6);
2549
2550 let context = sm.build_session_context();
2552 assert_eq!(context.messages.len(), 2); assert_eq!(context.thinking_level, "off");
2555 assert!(context.model.is_none());
2556 assert!(context.active_tool_names.is_none());
2557 }
2558
2559 #[test]
2560 fn test_session_reset_leaf() {
2561 let tmp = TempDir::new().unwrap();
2562 let sessions_dir = tmp.path().join("sessions");
2563 let cwd = tmp.path().join("project");
2564 std::fs::create_dir_all(&cwd).unwrap();
2565
2566 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2567 sm.append_message(&make_user_msg("one"));
2568 sm.append_message(&make_asst_msg("response"));
2569 assert_eq!(sm.entries().len(), 2);
2570
2571 sm.reset_leaf();
2573 assert_eq!(sm.entries().len(), 3);
2575 assert!(sm.leaf_id().is_none());
2576
2577 sm.append_message(&make_user_msg("fresh start"));
2579 assert_eq!(sm.entries().len(), 4);
2580 match &sm.entries()[3] {
2582 SessionEntry::Message(m) => {
2583 assert!(m.parent_id.is_none());
2584 }
2585 _ => panic!("Expected Message"),
2586 }
2587 }
2588
2589 #[test]
2590 fn test_session_branch_summary() {
2591 let tmp = TempDir::new().unwrap();
2592 let sessions_dir = tmp.path().join("sessions");
2593 let cwd = tmp.path().join("project");
2594 std::fs::create_dir_all(&cwd).unwrap();
2595
2596 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2597 sm.append_message(&make_user_msg("one"));
2598 sm.append_message(&make_asst_msg("response"));
2599
2600 sm.append_branch_summary("root", "Abandoned path summary", None, None);
2601
2602 match &sm.entries()[2] {
2603 SessionEntry::BranchSummary(e) => {
2604 assert_eq!(e.summary, "Abandoned path summary");
2605 assert_eq!(e.from_id, "root");
2606 }
2607 _ => panic!("Expected BranchSummary"),
2608 }
2609 }
2610
2611 #[test]
2612 fn test_session_children() {
2613 let tmp = TempDir::new().unwrap();
2614 let sessions_dir = tmp.path().join("sessions");
2615 let cwd = tmp.path().join("project");
2616 std::fs::create_dir_all(&cwd).unwrap();
2617
2618 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2619 let m1 = sm.append_message(&make_user_msg("one"));
2620 sm.append_message(&make_asst_msg("response"));
2621
2622 let children = sm.children(&m1);
2624 assert_eq!(children.len(), 1);
2625 }
2626
2627 #[test]
2628 fn test_session_custom_entry() {
2629 let tmp = TempDir::new().unwrap();
2630 let sessions_dir = tmp.path().join("sessions");
2631 let cwd = tmp.path().join("project");
2632 std::fs::create_dir_all(&cwd).unwrap();
2633
2634 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2635 sm.append_message(&make_user_msg("one"));
2636 sm.append_message(&make_asst_msg("ok"));
2637 sm.append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
2638
2639 match &sm.entries()[2] {
2640 SessionEntry::Custom(e) => {
2641 assert_eq!(e.custom_type, "my_ext");
2642 assert_eq!(e.data["key"], "value");
2643 }
2644 _ => panic!("Expected Custom"),
2645 }
2646 }
2647
2648 #[test]
2649 fn test_find_most_recent_session() {
2650 let tmp = TempDir::new().unwrap();
2651 let sessions_dir = tmp.path().join("sessions");
2652 let cwd = tmp.path().join("project");
2653 std::fs::create_dir_all(&sessions_dir).unwrap();
2654 std::fs::create_dir_all(&cwd).unwrap();
2655
2656 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2658 sm1.append_message(&make_user_msg("old"));
2659 sm1.append_message(&make_asst_msg("old"));
2660 let _path1 = sm1.session_file().unwrap().to_path_buf();
2661 drop(sm1);
2662
2663 std::thread::sleep(std::time::Duration::from_millis(10));
2664
2665 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2667 sm2.append_message(&make_user_msg("new"));
2668 sm2.append_message(&make_asst_msg("new"));
2669 let path2 = sm2.session_file().unwrap().to_path_buf();
2670 drop(sm2);
2671
2672 let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
2673 assert_eq!(most_recent, path2);
2674 }
2675
2676 #[test]
2679 fn test_corrupt_empty_file_is_recovered() {
2680 let tmp = TempDir::new().unwrap();
2681 let sessions_dir = tmp.path().join("sessions");
2682 let cwd = tmp.path().join("project");
2683 std::fs::create_dir_all(&sessions_dir).unwrap();
2684 std::fs::create_dir_all(&cwd).unwrap();
2685
2686 let file_path = sessions_dir.join("empty.jsonl");
2688 std::fs::write(&file_path, "").unwrap();
2689
2690 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2692 assert!(!sm.session_id().is_empty());
2693 assert!(sm.entries().is_empty());
2694 assert_eq!(sm.session_file().unwrap(), file_path);
2695 }
2696
2697 #[test]
2698 fn test_corrupt_garbage_file_is_recovered() {
2699 let tmp = TempDir::new().unwrap();
2700 let sessions_dir = tmp.path().join("sessions");
2701 let cwd = tmp.path().join("project");
2702 std::fs::create_dir_all(&sessions_dir).unwrap();
2703 std::fs::create_dir_all(&cwd).unwrap();
2704
2705 let file_path = sessions_dir.join("garbage.jsonl");
2707 std::fs::write(
2708 &file_path,
2709 "this is not json\nneither is this\n{half-json\n",
2710 )
2711 .unwrap();
2712
2713 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2715 assert!(!sm.session_id().is_empty());
2716 assert!(sm.entries().is_empty());
2717 }
2718
2719 #[test]
2720 fn test_corrupt_header_only_file_is_kept() {
2721 let tmp = TempDir::new().unwrap();
2722 let sessions_dir = tmp.path().join("sessions");
2723 let cwd = tmp.path().join("project");
2724 std::fs::create_dir_all(&sessions_dir).unwrap();
2725 std::fs::create_dir_all(&cwd).unwrap();
2726
2727 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2729 sm.append_message(&make_user_msg("test"));
2730 sm.append_message(&make_asst_msg("ok"));
2731 let original_id = sm.session_id().to_string();
2732 let file_path = sm.session_file().unwrap().to_path_buf();
2733 drop(sm);
2734
2735 let content = std::fs::read_to_string(&file_path).unwrap();
2737 let header_line = content.lines().next().unwrap();
2738 std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
2739
2740 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2742 assert_eq!(sm.session_id(), original_id);
2743 assert!(sm.entries().is_empty());
2744 }
2745
2746 #[test]
2747 fn test_corrupt_malformed_lines_are_skipped() {
2748 let tmp = TempDir::new().unwrap();
2749 let sessions_dir = tmp.path().join("sessions");
2750 let cwd = tmp.path().join("project");
2751 std::fs::create_dir_all(&sessions_dir).unwrap();
2752 std::fs::create_dir_all(&cwd).unwrap();
2753
2754 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2756 sm.append_message(&make_user_msg("valid message"));
2757 sm.append_message(&make_asst_msg("valid response"));
2758 let file_path = sm.session_file().unwrap().to_path_buf();
2759 drop(sm);
2760
2761 let mut content = std::fs::read_to_string(&file_path).unwrap();
2763 content.push_str("this is garbage\n");
2764 content.push_str("{incomplete json\n");
2765 content.push('\n'); std::fs::write(&file_path, &content).unwrap();
2767
2768 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2770 let ctx = sm.build_session_context();
2771 assert_eq!(ctx.messages.len(), 2);
2772 assert_eq!(
2773 crate::agent::types::message_text(&ctx.messages[0]),
2774 "valid message"
2775 );
2776 assert_eq!(
2777 crate::agent::types::message_text(&ctx.messages[1]),
2778 "valid response"
2779 );
2780 }
2781
2782 #[test]
2783 fn test_corrupt_missing_header_uses_new_id() {
2784 let tmp = TempDir::new().unwrap();
2785 let sessions_dir = tmp.path().join("sessions");
2786 let cwd = tmp.path().join("project");
2787 std::fs::create_dir_all(&sessions_dir).unwrap();
2788 std::fs::create_dir_all(&cwd).unwrap();
2789
2790 let entry = SessionEntry::Message(MessageEntry {
2792 id: "msg1".to_string(),
2793 parent_id: None,
2794 timestamp: "2026-01-01T00:00:00Z".to_string(),
2795 message: make_user_msg("orphan message"),
2796 });
2797 let json = serde_json::to_string(&entry).unwrap();
2798 let file_path = sessions_dir.join("no_header.jsonl");
2799 std::fs::write(&file_path, format!("{}\n", json)).unwrap();
2800
2801 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2804 assert!(!sm.session_id().is_empty());
2805 assert_eq!(sm.entries().len(), 0);
2806 }
2807
2808 #[test]
2809 fn test_corrupt_file_then_append_works() {
2810 let tmp = TempDir::new().unwrap();
2811 let sessions_dir = tmp.path().join("sessions");
2812 let cwd = tmp.path().join("project");
2813 std::fs::create_dir_all(&sessions_dir).unwrap();
2814 std::fs::create_dir_all(&cwd).unwrap();
2815
2816 let file_path = sessions_dir.join("recovered.jsonl");
2818 std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
2819
2820 let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2822 assert!(sm.entries().is_empty());
2823
2824 sm.append_message(&make_user_msg("fresh start"));
2826 sm.append_message(&make_asst_msg("fresh response"));
2827
2828 let ctx = sm.build_session_context();
2829 assert_eq!(ctx.messages.len(), 2);
2830 assert_eq!(
2831 crate::agent::types::message_text(&ctx.messages[0]),
2832 "fresh start"
2833 );
2834
2835 let content = std::fs::read_to_string(&file_path).unwrap();
2837 assert!(content.contains("fresh start"));
2838 assert!(!content.contains("garbage"));
2839 }
2840
2841 #[test]
2842 fn test_corrupt_all_lines_malformed_is_empty() {
2843 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2844 assert!(entries.is_empty());
2845 }
2846
2847 #[test]
2848 fn test_corrupt_malformed_line_returns_none() {
2849 let result = parse_session_entry_line("not valid json");
2850 assert!(result.is_none());
2851 }
2852
2853 #[test]
2854 fn test_corrupt_blank_lines_are_skipped() {
2855 let result = parse_session_entry_line("");
2856 assert!(result.is_none());
2857 let result = parse_session_entry_line(" ");
2858 assert!(result.is_none());
2859 }
2860
2861 #[test]
2862 fn test_corrupt_header_line_malformed_returns_none() {
2863 let result = read_session_header(Path::new("/nonexistent"));
2864 assert!(result.is_none());
2865 }
2866}