1use super::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;
19
20#[derive(Debug, Clone)]
25pub enum SessionError {
26 NotFound(String),
28 InvalidSession(String),
30 InvalidEntry(String),
32 InvalidForkTarget(String),
34 Storage(String),
36}
37
38impl std::fmt::Display for SessionError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 SessionError::NotFound(msg) => write!(f, "not found: {}", msg),
42 SessionError::InvalidSession(msg) => write!(f, "invalid session: {}", msg),
43 SessionError::InvalidEntry(msg) => write!(f, "invalid entry: {}", msg),
44 SessionError::InvalidForkTarget(msg) => write!(f, "invalid fork target: {}", msg),
45 SessionError::Storage(msg) => write!(f, "storage error: {}", msg),
46 }
47 }
48}
49
50impl std::error::Error for SessionError {}
51
52impl From<std::io::Error> for SessionError {
53 fn from(e: std::io::Error) -> Self {
54 SessionError::Storage(e.to_string())
55 }
56}
57
58impl From<serde_json::Error> for SessionError {
59 fn from(e: serde_json::Error) -> Self {
60 SessionError::InvalidEntry(e.to_string())
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct SessionHeader {
70 #[serde(rename = "type")]
71 pub type_: String, #[serde(default)]
73 pub version: Option<u32>,
74 pub id: String,
75 pub timestamp: String,
76 pub cwd: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub parent_session: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(tag = "type")]
88pub enum SessionEntry {
89 #[serde(rename = "message")]
90 Message(MessageEntry),
91 #[serde(rename = "thinking_level_change")]
92 ThinkingLevelChange(ThinkingLevelChangeEntry),
93 #[serde(rename = "model_change")]
94 ModelChange(ModelChangeEntry),
95 #[serde(rename = "active_tools_change")]
96 ActiveToolsChange(ActiveToolsChangeEntry),
97 #[serde(rename = "compaction")]
98 Compaction(CompactionEntry),
99 #[serde(rename = "branch_summary")]
100 BranchSummary(BranchSummaryEntry),
101 #[serde(rename = "session_info")]
102 SessionInfo(SessionInfoEntry),
103 #[serde(rename = "label")]
104 Label(LabelEntry),
105 #[serde(rename = "custom")]
106 Custom(CustomEntry),
107 #[serde(rename = "custom_message")]
108 CustomMessage(CustomMessageEntry),
109 #[serde(rename = "leaf")]
110 Leaf(LeafEntry),
111}
112
113impl SessionEntry {
114 pub fn id(&self) -> &str {
115 match self {
116 SessionEntry::Message(e) => &e.id,
117 SessionEntry::ThinkingLevelChange(e) => &e.id,
118 SessionEntry::ModelChange(e) => &e.id,
119 SessionEntry::ActiveToolsChange(e) => &e.id,
120 SessionEntry::Compaction(e) => &e.id,
121 SessionEntry::BranchSummary(e) => &e.id,
122 SessionEntry::SessionInfo(e) => &e.id,
123 SessionEntry::Label(e) => &e.id,
124 SessionEntry::Custom(e) => &e.id,
125 SessionEntry::CustomMessage(e) => &e.id,
126 SessionEntry::Leaf(e) => &e.id,
127 }
128 }
129
130 pub fn parent_id(&self) -> Option<&str> {
131 match self {
132 SessionEntry::Message(e) => e.parent_id.as_deref(),
133 SessionEntry::ThinkingLevelChange(e) => e.parent_id.as_deref(),
134 SessionEntry::ModelChange(e) => e.parent_id.as_deref(),
135 SessionEntry::ActiveToolsChange(e) => e.parent_id.as_deref(),
136 SessionEntry::Compaction(e) => e.parent_id.as_deref(),
137 SessionEntry::BranchSummary(e) => e.parent_id.as_deref(),
138 SessionEntry::SessionInfo(e) => e.parent_id.as_deref(),
139 SessionEntry::Label(e) => e.parent_id.as_deref(),
140 SessionEntry::Custom(e) => e.parent_id.as_deref(),
141 SessionEntry::CustomMessage(e) => e.parent_id.as_deref(),
142 SessionEntry::Leaf(e) => e.parent_id.as_deref(),
143 }
144 }
145
146 pub fn timestamp(&self) -> &str {
147 match self {
148 SessionEntry::Message(e) => &e.timestamp,
149 SessionEntry::ThinkingLevelChange(e) => &e.timestamp,
150 SessionEntry::ModelChange(e) => &e.timestamp,
151 SessionEntry::ActiveToolsChange(e) => &e.timestamp,
152 SessionEntry::Compaction(e) => &e.timestamp,
153 SessionEntry::BranchSummary(e) => &e.timestamp,
154 SessionEntry::SessionInfo(e) => &e.timestamp,
155 SessionEntry::Label(e) => &e.timestamp,
156 SessionEntry::Custom(e) => &e.timestamp,
157 SessionEntry::CustomMessage(e) => &e.timestamp,
158 SessionEntry::Leaf(e) => &e.timestamp,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct MessageEntry {
167 pub id: String,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub parent_id: Option<String>,
170 pub timestamp: String,
171 pub message: AgentMessage,
172 #[serde(default)]
176 pub cost: f64,
177}
178
179impl MessageEntry {
180 pub fn new(
182 id: String,
183 parent_id: Option<String>,
184 timestamp: String,
185 message: AgentMessage,
186 cost: f64,
187 ) -> Self {
188 Self {
189 id,
190 parent_id,
191 timestamp,
192 message,
193 cost,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct ThinkingLevelChangeEntry {
201 pub id: String,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub parent_id: Option<String>,
204 pub timestamp: String,
205 pub thinking_level: String,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct ModelChangeEntry {
211 pub id: String,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub parent_id: Option<String>,
214 pub timestamp: String,
215 pub provider: String,
216 pub model_id: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct ActiveToolsChangeEntry {
222 pub id: String,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub parent_id: Option<String>,
225 pub timestamp: String,
226 pub active_tool_names: Vec<String>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(rename_all = "camelCase")]
231pub struct CompactionEntry {
232 pub id: String,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub parent_id: Option<String>,
235 pub timestamp: String,
236 pub summary: String,
237 pub first_kept_entry_id: String,
238 pub tokens_before: u64,
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub details: Option<serde_json::Value>,
241 #[serde(skip_serializing_if = "Option::is_none")]
242 pub from_hook: Option<bool>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct BranchSummaryEntry {
248 pub id: String,
249 #[serde(skip_serializing_if = "Option::is_none")]
250 pub parent_id: Option<String>,
251 pub timestamp: String,
252 pub from_id: String,
253 pub summary: String,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub details: Option<serde_json::Value>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub from_hook: Option<bool>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct SessionInfoEntry {
263 pub id: String,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub parent_id: Option<String>,
266 pub timestamp: String,
267 pub name: String,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct LabelEntry {
273 pub id: String,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub parent_id: Option<String>,
276 pub timestamp: String,
277 pub target_id: String,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 pub label: Option<String>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct CustomEntry {
285 pub id: String,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub parent_id: Option<String>,
288 pub timestamp: String,
289 pub custom_type: String,
290 pub data: serde_json::Value,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct CustomMessageEntry {
296 pub id: String,
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub parent_id: Option<String>,
299 pub timestamp: String,
300 pub custom_type: String,
301 pub content: serde_json::Value,
302 #[serde(default)]
303 pub display: bool,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub details: Option<serde_json::Value>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct LeafEntry {
311 pub id: String,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub parent_id: Option<String>,
314 pub timestamp: String,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 pub target_id: Option<String>,
317}
318
319#[derive(Debug, Clone)]
323pub struct SessionInfo {
324 pub path: PathBuf,
325 pub id: String,
326 pub cwd: String,
327 pub name: Option<String>,
328 pub parent_session_path: Option<String>,
329 pub created: DateTime<Utc>,
330 pub modified: DateTime<Utc>,
331 pub message_count: usize,
332 pub first_message: String,
333 pub all_messages_text: String,
335}
336
337#[derive(Debug, Clone)]
341pub struct SessionTreeNode {
342 pub entry: SessionEntry,
343 pub children: Vec<SessionTreeNode>,
344 pub label: Option<String>,
345 pub label_timestamp: Option<String>,
346}
347
348#[derive(Debug, Clone, Default)]
352pub struct NewSessionOptions {
353 pub id: Option<String>,
354 pub parent_session: Option<String>,
355}
356
357#[derive(Debug, Clone)]
362pub struct SessionContext {
363 pub messages: Vec<AgentMessage>,
364 pub thinking_level: String,
365 pub model: Option<(String, String)>,
366 pub active_tool_names: Option<Vec<String>>,
367}
368
369pub fn parse_session_entry_line(line: &str) -> Option<SessionEntry> {
373 let line = line.trim();
374 if line.is_empty() {
375 return None;
376 }
377 serde_json::from_str(line).ok()
378}
379
380pub fn parse_session_header_line(line: &str) -> Option<SessionHeader> {
384 let line = line.trim();
385 if line.is_empty() {
386 return None;
387 }
388 let header: SessionHeader = serde_json::from_str(line).ok()?;
389 if header.type_ != "session" {
390 return None;
391 }
392 if header.version != Some(CURRENT_SESSION_VERSION) {
394 return None;
395 }
396 if header.id.is_empty() || header.timestamp.is_empty() || header.cwd.is_empty() {
398 return None;
399 }
400 Some(header)
402}
403
404pub fn read_session_header(path: &Path) -> Option<SessionHeader> {
406 let content = fs::read_to_string(path).ok()?;
407 let first_line = content.lines().next()?;
408 parse_session_header_line(first_line)
409}
410
411const SESSION_READ_BUFFER_SIZE: usize = 1024 * 1024; pub fn load_session_from_file(path: &Path) -> (Option<SessionHeader>, Vec<SessionEntry>) {
423 let file = match std::fs::File::open(path) {
424 Ok(f) => f,
425 Err(_) => return (None, vec![]),
426 };
427
428 use std::io::Read;
429 let mut reader = std::io::BufReader::with_capacity(SESSION_READ_BUFFER_SIZE, file);
430 let mut content = String::new();
431 if reader.read_to_string(&mut content).is_err() {
432 return (None, vec![]);
433 }
434
435 let mut header: Option<SessionHeader> = None;
436 let mut entries: Vec<SessionEntry> = Vec::new();
437
438 for (i, line_str) in content.lines().enumerate() {
439 let line = line_str.trim();
440 if line.is_empty() {
441 continue;
442 }
443
444 if i == 0 {
445 header = parse_session_header_line(line);
447 if header.is_none() {
448 return (None, vec![]);
450 }
451 continue;
452 }
453
454 if let Some(entry) = parse_session_entry_line(line) {
455 entries.push(entry);
456 }
457 }
459
460 (header, entries)
461}
462
463pub fn load_entries_from_file(path: &Path) -> Vec<SessionEntry> {
465 load_session_from_file(path).1
466}
467
468pub fn write_entries_to_file(
471 path: &Path,
472 header: &SessionHeader,
473 entries: &[SessionEntry],
474) -> std::io::Result<()> {
475 if let Some(parent) = path.parent() {
476 fs::create_dir_all(parent)?;
477 }
478 let mut content = serde_json::to_string(header).map_err(std::io::Error::from)?;
479 content.push('\n');
480 for entry in entries {
481 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
482 content.push_str(&line);
483 content.push('\n');
484 }
485 fs::write(path, &content)
486}
487
488pub fn append_entry_to_file(path: &Path, entry: &SessionEntry) -> std::io::Result<()> {
490 let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
491 let content = format!("{}\n", line);
492 std::fs::OpenOptions::new()
493 .create(true)
494 .append(true)
495 .open(path)?
496 .write_all(content.as_bytes())
497}
498
499pub fn encode_cwd_for_dir(cwd: &Path) -> String {
504 let s = cwd.to_string_lossy();
505 let cleaned = s
506 .trim_start_matches('/')
507 .trim_start_matches('\\')
508 .replace(['/', '\\', ':'], "-");
509 format!("--{}--", cleaned)
510}
511
512pub fn get_default_session_dir(cwd: &Path) -> PathBuf {
514 let rab_dir = directories::BaseDirs::new()
515 .expect("Could not determine home directory")
516 .home_dir()
517 .join(".rab");
518 rab_dir.join("sessions").join(encode_cwd_for_dir(cwd))
519}
520
521pub fn generate_entry_id(by_id: &HashMap<String, SessionEntry>) -> String {
523 for _ in 0..100 {
524 let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
525 if !by_id.contains_key(&id) {
526 return id;
527 }
528 }
529 uuid::Uuid::new_v4().to_string()
531}
532
533use super::storage::SessionMetadata;
536
537pub struct Session {
543 storage: Box<dyn SessionStorage>,
544}
545
546impl Session {
547 pub fn new(storage: Box<dyn SessionStorage>) -> Self {
549 Self { storage }
550 }
551
552 pub fn get_storage(&self) -> &dyn SessionStorage {
554 self.storage.as_ref()
555 }
556
557 pub fn get_storage_mut(&mut self) -> &mut dyn SessionStorage {
559 self.storage.as_mut()
560 }
561
562 pub fn into_storage(self) -> Box<dyn SessionStorage> {
564 self.storage
565 }
566
567 pub fn metadata(&self) -> SessionMetadata {
570 self.storage.metadata()
571 }
572
573 pub fn get_leaf_id(&self) -> Option<String> {
574 self.storage.get_leaf_id()
575 }
576
577 pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
578 self.storage.get_entry(id)
579 }
580
581 pub fn get_entries(&self) -> Vec<SessionEntry> {
582 self.storage.get_entries()
583 }
584
585 pub fn find_entries(&self, type_name: &str) -> Vec<SessionEntry> {
586 self.storage.find_entries(type_name)
587 }
588
589 pub fn get_label(&self, id: &str) -> Option<String> {
590 self.storage.get_label(id)
591 }
592
593 pub fn get_label_timestamp(&self, id: &str) -> Option<String> {
596 self.storage.get_label_timestamp(id)
597 }
598
599 pub fn get_branch(&self, from_id: Option<&str>) -> Result<Vec<SessionEntry>, String> {
602 self.storage.get_path_to_root(from_id)
603 }
604
605 pub fn build_context(&self) -> SessionContext {
608 let path = self.get_branch(None).unwrap_or_default();
609 build_session_context(&path)
610 }
611
612 pub fn build_session_context(&self) -> SessionContext {
614 self.build_context()
615 }
616
617 pub fn session_id(&self) -> String {
619 self.metadata().id
620 }
621
622 pub fn session_file(&self) -> Option<PathBuf> {
624 self.metadata().path
625 }
626
627 pub fn session_name(&self) -> Option<String> {
629 self.get_session_name()
630 }
631
632 pub fn get_session_name(&self) -> Option<String> {
634 let entries = self.find_entries("session_info");
635 let last = entries.last()?;
636 if let SessionEntry::SessionInfo(e) = last {
637 let name = e.name.trim();
638 if name.is_empty() {
639 None
640 } else {
641 Some(name.to_string())
642 }
643 } else {
644 None
645 }
646 }
647
648 pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
652 self.append_message_with_cost(message, 0.0)
653 }
654
655 pub fn append_message_with_cost(
658 &mut self,
659 message: &yoagent::types::AgentMessage,
660 cost: f64,
661 ) -> String {
662 let entry = SessionEntry::Message(MessageEntry::new(
663 self.storage.create_entry_id(),
664 self.storage.get_leaf_id(),
665 chrono::Utc::now().to_rfc3339(),
666 message.clone(),
667 cost,
668 ));
669 let id = entry.id().to_string();
670 self.storage.append_entry(entry).unwrap_or_else(|e| {
671 eprintln!("Warning: failed to append message: {}", e);
672 });
673 id
674 }
675
676 pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
678 let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
679 id: self.storage.create_entry_id(),
680 parent_id: self.storage.get_leaf_id(),
681 timestamp: chrono::Utc::now().to_rfc3339(),
682 thinking_level: thinking_level.to_string(),
683 });
684 let id = entry.id().to_string();
685 self.storage.append_entry(entry).unwrap_or_else(|e| {
686 eprintln!("Warning: failed to append thinking level change: {}", e);
687 });
688 id
689 }
690
691 pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
693 let entry = SessionEntry::ModelChange(ModelChangeEntry {
694 id: self.storage.create_entry_id(),
695 parent_id: self.storage.get_leaf_id(),
696 timestamp: chrono::Utc::now().to_rfc3339(),
697 provider: provider.to_string(),
698 model_id: model_id.to_string(),
699 });
700 let id = entry.id().to_string();
701 self.storage.append_entry(entry).unwrap_or_else(|e| {
702 eprintln!("Warning: failed to append model change: {}", e);
703 });
704 id
705 }
706
707 pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
709 let entry = SessionEntry::ActiveToolsChange(ActiveToolsChangeEntry {
710 id: self.storage.create_entry_id(),
711 parent_id: self.storage.get_leaf_id(),
712 timestamp: chrono::Utc::now().to_rfc3339(),
713 active_tool_names: active_tool_names.to_vec(),
714 });
715 let id = entry.id().to_string();
716 self.storage.append_entry(entry).unwrap_or_else(|e| {
717 eprintln!("Warning: failed to append active tools change: {}", e);
718 });
719 id
720 }
721
722 pub fn append_compaction(
724 &mut self,
725 summary: &str,
726 first_kept_entry_id: &str,
727 tokens_before: u64,
728 details: Option<serde_json::Value>,
729 from_hook: Option<bool>,
730 ) -> String {
731 let entry = SessionEntry::Compaction(CompactionEntry {
732 id: self.storage.create_entry_id(),
733 parent_id: self.storage.get_leaf_id(),
734 timestamp: chrono::Utc::now().to_rfc3339(),
735 summary: summary.to_string(),
736 first_kept_entry_id: first_kept_entry_id.to_string(),
737 tokens_before,
738 details,
739 from_hook,
740 });
741 let id = entry.id().to_string();
742 self.storage.append_entry(entry).unwrap_or_else(|e| {
743 eprintln!("Warning: failed to append compaction: {}", e);
744 });
745 id
746 }
747
748 pub fn append_session_info(&mut self, name: &str) -> String {
751 let sanitized = name.replace(['\r', '\n'], " ").trim().to_string();
752 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
753 id: self.storage.create_entry_id(),
754 parent_id: self.storage.get_leaf_id(),
755 timestamp: chrono::Utc::now().to_rfc3339(),
756 name: sanitized,
757 });
758 let id = entry.id().to_string();
759 self.storage.append_entry(entry).unwrap_or_else(|e| {
760 eprintln!("Warning: failed to append session info: {}", e);
761 });
762 id
763 }
764
765 pub fn append_branch_summary(
767 &mut self,
768 from_id: &str,
769 summary: &str,
770 details: Option<serde_json::Value>,
771 from_hook: Option<bool>,
772 ) -> String {
773 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
774 id: self.storage.create_entry_id(),
775 parent_id: self.storage.get_leaf_id(),
776 timestamp: chrono::Utc::now().to_rfc3339(),
777 from_id: from_id.to_string(),
778 summary: summary.to_string(),
779 details,
780 from_hook,
781 });
782 let id = entry.id().to_string();
783 self.storage.append_entry(entry).unwrap_or_else(|e| {
784 eprintln!("Warning: failed to append branch summary: {}", e);
785 });
786 id
787 }
788
789 pub fn append_label_change(
792 &mut self,
793 target_id: &str,
794 label: Option<&str>,
795 ) -> Result<String, SessionError> {
796 if self.storage.get_entry(target_id).is_none() {
797 return Err(SessionError::NotFound(format!(
798 "Entry {} not found",
799 target_id
800 )));
801 }
802 let entry = SessionEntry::Label(LabelEntry {
803 id: self.storage.create_entry_id(),
804 parent_id: self.storage.get_leaf_id(),
805 timestamp: chrono::Utc::now().to_rfc3339(),
806 target_id: target_id.to_string(),
807 label: label.map(|s| s.to_string()),
808 });
809 let id = entry.id().to_string();
810 self.storage
811 .append_entry(entry)
812 .map_err(SessionError::Storage)?;
813 Ok(id)
814 }
815
816 pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
818 let entry = SessionEntry::Custom(CustomEntry {
819 id: self.storage.create_entry_id(),
820 parent_id: self.storage.get_leaf_id(),
821 timestamp: chrono::Utc::now().to_rfc3339(),
822 custom_type: custom_type.to_string(),
823 data,
824 });
825 let id = entry.id().to_string();
826 self.storage.append_entry(entry).unwrap_or_else(|e| {
827 eprintln!("Warning: failed to append custom entry: {}", e);
828 });
829 id
830 }
831
832 pub fn append_custom_message_entry(
834 &mut self,
835 custom_type: &str,
836 content: serde_json::Value,
837 display: bool,
838 details: Option<serde_json::Value>,
839 ) -> String {
840 let entry = SessionEntry::CustomMessage(CustomMessageEntry {
841 id: self.storage.create_entry_id(),
842 parent_id: self.storage.get_leaf_id(),
843 timestamp: chrono::Utc::now().to_rfc3339(),
844 custom_type: custom_type.to_string(),
845 content,
846 display,
847 details,
848 });
849 let id = entry.id().to_string();
850 self.storage.append_entry(entry).unwrap_or_else(|e| {
851 eprintln!("Warning: failed to append custom message: {}", e);
852 });
853 id
854 }
855
856 pub fn move_to(
862 &mut self,
863 entry_id: Option<&str>,
864 summary: Option<(String, Option<serde_json::Value>, Option<bool>)>,
865 ) -> Result<Option<String>, String> {
866 if let Some(ref id) = entry_id
868 && self.get_entry(id).is_none()
869 {
870 return Err(format!("Entry {} not found", id));
871 }
872 self.storage.set_leaf_id(entry_id)?;
874
875 if let Some((summary_text, details, from_hook)) = summary {
877 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
878 id: self.storage.create_entry_id(),
879 parent_id: entry_id.map(|s| s.to_string()),
880 timestamp: chrono::Utc::now().to_rfc3339(),
881 from_id: entry_id.unwrap_or("root").to_string(),
882 summary: summary_text,
883 details,
884 from_hook,
885 });
886 let id = entry.id().to_string();
887 self.storage.append_entry(entry).unwrap_or_else(|e| {
888 eprintln!("Warning: failed to append branch summary: {}", e);
889 });
890 Ok(Some(id))
891 } else {
892 Ok(None)
893 }
894 }
895
896 pub fn set_leaf_id(&mut self, leaf_id: Option<&str>) -> Result<(), String> {
899 self.storage.set_leaf_id(leaf_id)
900 }
901
902 pub fn reset_leaf(&mut self) -> Result<(), String> {
904 self.storage.set_leaf_id(None)
905 }
906}
907
908pub fn build_session_context(path: &[SessionEntry]) -> SessionContext {
913 let mut thinking_level = "off".to_string();
914 let mut model: Option<(String, String)> = None;
915 let mut active_tool_names: Option<Vec<String>> = None;
916 let mut compaction_entry: Option<&CompactionEntry> = None;
917
918 for entry in path {
919 match entry {
920 SessionEntry::ThinkingLevelChange(e) => {
921 thinking_level = e.thinking_level.clone();
922 }
923 SessionEntry::ModelChange(e) => {
924 model = Some((e.provider.clone(), e.model_id.clone()));
925 }
926 SessionEntry::ActiveToolsChange(e) => {
927 active_tool_names = Some(e.active_tool_names.clone());
928 }
929 SessionEntry::Compaction(e) => {
930 compaction_entry = Some(e);
931 }
932 _ => {}
933 }
934 }
935
936 if model.is_none() {
938 for entry in path {
939 if let SessionEntry::Message(e) = entry
940 && let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
941 model: ref m,
942 provider: ref p,
943 ..
944 }) = e.message
945 && !m.is_empty()
946 && !p.is_empty()
947 {
948 model = Some((p.clone(), m.clone()));
949 break;
950 }
951 }
952 }
953
954 let messages = if let Some(compaction) = compaction_entry {
955 let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
956
957 let comp_text = format!(
959 "The conversation history before this point was compacted into the following summary:\n\n<summary>\n{}\n</summary>",
960 compaction.summary
961 );
962 msgs.push(yoagent::types::AgentMessage::Llm(
963 yoagent::types::Message::User {
964 content: vec![yoagent::types::Content::Text { text: comp_text }],
965 timestamp: chrono::Utc::now().timestamp_millis() as u64,
966 },
967 ));
968
969 let compaction_idx = path
971 .iter()
972 .position(|e| matches!(e, SessionEntry::Compaction(ce) if ce.id == compaction.id));
973
974 if let Some(cidx) = compaction_idx {
975 let mut found_first_kept = false;
977 for entry in path.iter().take(cidx) {
978 if entry.id() == compaction.first_kept_entry_id {
979 found_first_kept = true;
980 }
981 if found_first_kept {
982 append_entry_to_message_list(entry, &mut msgs);
983 }
984 }
985
986 for entry in path.iter().skip(cidx + 1) {
988 append_entry_to_message_list(entry, &mut msgs);
989 }
990 } else {
991 for entry in path {
993 append_entry_to_message_list(entry, &mut msgs);
994 }
995 }
996
997 msgs
998 } else {
999 let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
1001 for entry in path {
1002 append_entry_to_message_list(entry, &mut msgs);
1003 }
1004 msgs
1005 };
1006
1007 SessionContext {
1008 messages,
1009 thinking_level,
1010 model,
1011 active_tool_names,
1012 }
1013}
1014
1015fn append_entry_to_message_list(
1020 entry: &SessionEntry,
1021 msgs: &mut Vec<yoagent::types::AgentMessage>,
1022) {
1023 match entry {
1024 SessionEntry::Message(e) => {
1025 if crate::agent::types::message_error(&e.message).is_some() {
1027 return;
1028 }
1029 msgs.push(e.message.clone());
1030 }
1031 SessionEntry::CustomMessage(e) => {
1032 msgs.push(yoagent::types::AgentMessage::Extension(
1033 yoagent::types::ExtensionMessage::new(
1034 &e.custom_type,
1035 serde_json::json!({ "text": e.content.get("text").and_then(|v| v.as_str()).unwrap_or(""), "display": e.display }),
1036 ),
1037 ));
1038 }
1039 SessionEntry::BranchSummary(e) if !e.summary.is_empty() => {
1040 let bs_text = format!(
1042 "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n{}\n</summary>",
1043 e.summary
1044 );
1045 msgs.push(yoagent::types::AgentMessage::Llm(
1046 yoagent::types::Message::User {
1047 content: vec![yoagent::types::Content::Text { text: bs_text }],
1048 timestamp: chrono::Utc::now().timestamp_millis() as u64,
1049 },
1050 ));
1051 }
1052 _ => {}
1053 }
1054}
1055
1056pub struct SessionManager {
1064 session: Session,
1066 session_dir: PathBuf,
1068 cwd: PathBuf,
1070 persist: bool,
1072 flushed: bool,
1074}
1075
1076impl SessionManager {
1077 pub fn with_session(
1081 session: Session,
1082 session_dir: PathBuf,
1083 cwd: PathBuf,
1084 persist: bool,
1085 ) -> Self {
1086 Self {
1087 session,
1088 session_dir,
1089 cwd,
1090 persist,
1091 flushed: false,
1092 }
1093 }
1094
1095 fn create_persisted(
1098 cwd: &Path,
1099 session_dir: &Path,
1100 options: Option<&NewSessionOptions>,
1101 ) -> Self {
1102 let id = options
1103 .and_then(|o| o.id.as_deref())
1104 .map(|s| s.to_string())
1105 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1106 let created_at = chrono::Utc::now().to_rfc3339();
1107
1108 let meta = super::storage::SessionMetadata {
1110 id: id.clone(),
1111 created_at: created_at.clone(),
1112 cwd: cwd.to_string_lossy().to_string(),
1113 path: None, parent_session_path: options.and_then(|o| o.parent_session.clone()),
1115 };
1116 let storage = InMemorySessionStorage::new(meta);
1117 let session = Session::new(Box::new(storage));
1118 Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), true)
1119 }
1120
1121 fn open_session(path: &Path, session_dir: &Path, cwd_override: Option<&Path>) -> Self {
1123 let cwd = cwd_override
1124 .map(|p| p.to_path_buf())
1125 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
1126
1127 let storage: Box<dyn SessionStorage> = match JsonlSessionStorage::open(path.to_path_buf()) {
1128 Ok(s) => Box::new(s),
1129 Err(e) => {
1130 eprintln!("Warning: failed to open session: {}, creating new", e);
1131 let id = uuid::Uuid::new_v4().to_string();
1133 match JsonlSessionStorage::create(
1134 path.to_path_buf(),
1135 &cwd.to_string_lossy(),
1136 &id,
1137 None,
1138 ) {
1139 Ok(s) => Box::new(s),
1140 Err(e2) => {
1141 eprintln!("Warning: failed to create session file: {}", e2);
1142 Box::new(InMemorySessionStorage::new(
1143 super::storage::SessionMetadata {
1144 id,
1145 created_at: chrono::Utc::now().to_rfc3339(),
1146 cwd: cwd.to_string_lossy().to_string(),
1147 path: Some(path.to_path_buf()),
1148 parent_session_path: None,
1149 },
1150 ))
1151 }
1152 }
1153 }
1154 };
1155 let cwd = cwd_override
1156 .map(|p| p.to_path_buf())
1157 .unwrap_or_else(|| PathBuf::from(storage.metadata().cwd));
1158 let session = Session::new(storage);
1159 let mut sm = Self::with_session(session, session_dir.to_path_buf(), cwd, true);
1160 sm.flushed = true;
1162 sm
1163 }
1164
1165 fn create_in_memory(cwd: &Path, session_dir: &Path) -> Self {
1167 let meta = super::storage::SessionMetadata {
1168 id: uuid::Uuid::new_v4().to_string(),
1169 created_at: chrono::Utc::now().to_rfc3339(),
1170 cwd: cwd.to_string_lossy().to_string(),
1171 path: None,
1172 parent_session_path: None,
1173 };
1174 let storage = InMemorySessionStorage::new(meta);
1175 let session = Session::new(Box::new(storage));
1176 Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), false)
1177 }
1178
1179 pub fn new_session(&mut self, options: Option<&NewSessionOptions>) {
1182 let id = options
1183 .and_then(|o| o.id.as_deref())
1184 .map(|s| s.to_string())
1185 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1186 let created_at = chrono::Utc::now().to_rfc3339();
1187
1188 let meta = super::storage::SessionMetadata {
1191 id,
1192 created_at,
1193 cwd: self.cwd.to_string_lossy().to_string(),
1194 path: None,
1195 parent_session_path: options.and_then(|o| o.parent_session.clone()),
1196 };
1197 let storage = InMemorySessionStorage::new(meta);
1198 self.session = Session::new(Box::new(storage));
1199 self.flushed = false;
1200 }
1201
1202 pub fn ensure_flushed(&mut self) {
1206 if self.flushed || !self.persist {
1207 return;
1208 }
1209
1210 let id = self.session.metadata().id;
1211 let created_at = self.session.metadata().created_at.clone();
1212 let cwd_str = self.cwd.to_string_lossy().to_string();
1213 let parent_session = self.session.metadata().parent_session_path.clone();
1214 let file_ts = created_at.replace([':', '.'], "-");
1215 let file_path = self.session_dir.join(format!("{}_{}.jsonl", file_ts, id));
1216
1217 let existing_entries = self.session.get_entries();
1219
1220 match JsonlSessionStorage::create(file_path.clone(), &cwd_str, &id, parent_session) {
1222 Ok(mut file_storage) => {
1223 for entry in &existing_entries {
1225 if let Err(e) = file_storage.append_entry(entry.clone()) {
1226 eprintln!("Warning: failed to write entry to session file: {}", e);
1227 }
1228 }
1229 self.session = Session::new(Box::new(file_storage));
1230 self.flushed = true;
1231 }
1232 Err(e) => {
1233 eprintln!("Warning: failed to create session file: {}", e);
1234 self.flushed = true;
1236 }
1237 }
1238 }
1239
1240 pub fn is_persisted(&self) -> bool {
1243 self.persist
1244 }
1245
1246 pub fn cwd(&self) -> &Path {
1247 &self.cwd
1248 }
1249
1250 pub fn session_dir(&self) -> &Path {
1251 &self.session_dir
1252 }
1253
1254 pub fn uses_default_session_dir(&self) -> bool {
1256 self.session_dir == get_default_session_dir(&self.cwd)
1257 }
1258
1259 pub fn session(&self) -> &Session {
1262 &self.session
1263 }
1264
1265 pub fn session_mut(&mut self) -> &mut Session {
1267 &mut self.session
1268 }
1269
1270 pub fn into_session(self) -> Session {
1272 self.session
1273 }
1274
1275 pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1279 self.session
1280 .get_leaf_id()
1281 .as_ref()
1282 .and_then(|id| self.session.get_entry(id.as_str()))
1283 }
1284
1285 pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1287 self.session
1288 .get_entries()
1289 .iter()
1290 .filter(|e| e.parent_id() == Some(parent_id))
1291 .cloned()
1292 .collect()
1293 }
1294
1295 pub fn get_header(&self) -> Option<SessionHeader> {
1297 let meta = self.session.metadata();
1300 Some(SessionHeader {
1301 type_: "session".to_string(),
1302 version: Some(CURRENT_SESSION_VERSION),
1303 id: meta.id,
1304 timestamp: meta.created_at,
1305 cwd: meta.cwd,
1306 parent_session: meta.parent_session_path,
1307 })
1308 }
1309
1310 pub fn get_tree(&self) -> Vec<SessionTreeNode> {
1312 let entries = self.session.get_entries();
1313 let mut node_map: HashMap<String, SessionTreeNode> = HashMap::new();
1314
1315 for entry in &entries {
1316 let label = self.session.get_label(entry.id());
1317 let label_timestamp = self.session.get_label_timestamp(entry.id());
1318 node_map.insert(
1319 entry.id().to_string(),
1320 SessionTreeNode {
1321 entry: entry.clone(),
1322 children: Vec::new(),
1323 label,
1324 label_timestamp,
1325 },
1326 );
1327 }
1328
1329 let child_edges: Vec<(Option<String>, String)> = entries
1330 .iter()
1331 .map(|e| (e.parent_id().map(|s| s.to_string()), e.id().to_string()))
1332 .collect();
1333
1334 let mut child_additions: Vec<(String, SessionTreeNode)> = Vec::new();
1335 let mut roots: Vec<String> = Vec::new();
1336 for (parent_id, child_id) in &child_edges {
1337 if let Some(pid) = parent_id {
1338 if !node_map.contains_key(pid) {
1339 roots.push(child_id.clone());
1340 } else if let Some(child) = node_map.get(child_id) {
1341 child_additions.push((pid.clone(), child.clone()));
1342 }
1343 } else {
1344 roots.push(child_id.clone());
1345 }
1346 }
1347 for (pid, child) in child_additions {
1348 if let Some(parent) = node_map.get_mut(&pid) {
1349 parent.children.push(child);
1350 }
1351 }
1352
1353 fn sort_tree(node: &mut SessionTreeNode) {
1354 node.children
1355 .sort_by_key(|c| c.entry.timestamp().to_string());
1356 for child in &mut node.children {
1357 sort_tree(child);
1358 }
1359 }
1360
1361 let mut result: Vec<SessionTreeNode> =
1362 roots.iter().filter_map(|id| node_map.remove(id)).collect();
1363 for root in &mut result {
1364 sort_tree(root);
1365 }
1366
1367 result
1368 }
1369
1370 pub fn get_entries(&self) -> Vec<SessionEntry> {
1372 self.session.get_entries()
1373 }
1374
1375 fn has_assistant_message(&self) -> bool {
1379 self.session.get_entries().iter().any(|e| {
1380 matches!(
1381 e,
1382 SessionEntry::Message(m) if matches!(&m.message, yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant { .. }))
1383 )
1384 })
1385 }
1386
1387 pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
1388 if !self.flushed && self.persist {
1391 let is_assistant = matches!(
1392 message,
1393 yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant { .. })
1394 );
1395 if is_assistant || self.has_assistant_message() {
1396 self.ensure_flushed();
1397 }
1398 }
1399 self.session.append_message(message)
1400 }
1401
1402 pub fn append_message_with_cost(
1405 &mut self,
1406 message: &yoagent::types::AgentMessage,
1407 cost: f64,
1408 ) -> String {
1409 if !self.flushed && self.persist {
1410 let is_assistant = matches!(
1411 message,
1412 yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant { .. })
1413 );
1414 if is_assistant || self.has_assistant_message() {
1415 self.ensure_flushed();
1416 }
1417 }
1418 self.session.append_message_with_cost(message, cost)
1419 }
1420
1421 pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1426 self.session.set_leaf_id(Some(branch_from_id))
1427 }
1428
1429 pub fn reset_leaf(&mut self) {
1431 let _ = self.session.reset_leaf();
1432 }
1433
1434 pub fn branch_with_summary(
1437 &mut self,
1438 branch_from_id: Option<&str>,
1439 summary: &str,
1440 details: Option<serde_json::Value>,
1441 from_hook: Option<bool>,
1442 ) -> Result<String, String> {
1443 let summary_tuple = Some((summary.to_string(), details, from_hook));
1444 self.session
1445 .move_to(branch_from_id, summary_tuple)
1446 .map(|opt| opt.unwrap_or_default())
1447 }
1448
1449 pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
1453 let dir = session_dir
1454 .map(|p| p.to_path_buf())
1455 .unwrap_or_else(|| get_default_session_dir(cwd));
1456 Self::create_persisted(cwd, &dir, None)
1457 }
1458
1459 pub fn create_with_options(
1461 cwd: &Path,
1462 session_dir: Option<&Path>,
1463 options: Option<&NewSessionOptions>,
1464 ) -> Self {
1465 let dir = session_dir
1466 .map(|p| p.to_path_buf())
1467 .unwrap_or_else(|| get_default_session_dir(cwd));
1468 Self::create_persisted(cwd, &dir, options)
1469 }
1470
1471 pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
1473 let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1474 path.parent()
1475 .map(|p| p.to_path_buf())
1476 .unwrap_or_else(|| get_default_session_dir(&PathBuf::from("/")))
1477 });
1478 Self::open_session(path, &dir, cwd_override)
1479 }
1480
1481 pub fn in_memory(cwd: &Path) -> Self {
1483 let dir = get_default_session_dir(cwd);
1484 Self::create_in_memory(cwd, &dir)
1485 }
1486
1487 pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
1489 let dir = session_dir
1490 .map(|p| p.to_path_buf())
1491 .unwrap_or_else(|| get_default_session_dir(cwd));
1492 let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
1493 let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
1494 if let Some(path) = most_recent {
1495 Self::open_session(&path, &dir, Some(cwd))
1496 } else {
1497 Self::create_persisted(cwd, &dir, None)
1498 }
1499 }
1500
1501 pub fn fork_from(
1504 source_path: &Path,
1505 target_cwd: &Path,
1506 session_dir: Option<&Path>,
1507 options: Option<&NewSessionOptions>,
1508 ) -> std::io::Result<Self> {
1509 let resolved_source = source_path;
1510 let resolved_target = target_cwd.to_path_buf();
1511 let dir = session_dir
1512 .map(|p| p.to_path_buf())
1513 .unwrap_or_else(|| get_default_session_dir(&resolved_target));
1514
1515 let source_entries = load_entries_from_file(resolved_source);
1516 if source_entries.is_empty() {
1517 return Err(std::io::Error::new(
1518 std::io::ErrorKind::InvalidData,
1519 "Cannot fork: source session is empty or invalid",
1520 ));
1521 }
1522
1523 let _source_header = read_session_header(resolved_source).ok_or_else(|| {
1524 std::io::Error::new(
1525 std::io::ErrorKind::InvalidData,
1526 "Cannot fork: source session has no header",
1527 )
1528 })?;
1529
1530 let id = options
1532 .and_then(|o| o.id.clone())
1533 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1534 let timestamp = chrono::Utc::now().to_rfc3339();
1535 let file_ts = timestamp.replace([':', '.'], "-");
1536 let file_name = format!("{}_{}.jsonl", file_ts, id);
1537 let target_path = dir.join(&file_name);
1538
1539 let mut storage = JsonlSessionStorage::create(
1541 target_path.clone(),
1542 &resolved_target.to_string_lossy(),
1543 &id,
1544 Some(resolved_source.to_string_lossy().to_string()),
1545 )
1546 .map_err(std::io::Error::other)?;
1547
1548 for entry in &source_entries {
1550 storage
1551 .append_entry(entry.clone())
1552 .map_err(std::io::Error::other)?;
1553 }
1554
1555 let session = Session::new(Box::new(storage));
1556 Ok(Self::with_session(session, dir, resolved_target, true))
1557 }
1558
1559 pub fn create_branched_session(&mut self, leaf_id: &str) -> Option<PathBuf> {
1563 let path = self.session.get_branch(Some(leaf_id)).unwrap_or_default();
1564 if path.is_empty() {
1565 return None;
1566 }
1567
1568 let mut path_clean: Vec<SessionEntry> = Vec::new();
1570 let mut path_parent_id: Option<String> = None;
1571 for entry in &path {
1572 if matches!(entry, SessionEntry::Label(_) | SessionEntry::Leaf(_)) {
1573 continue;
1574 }
1575 let mut e = entry.clone();
1576 match &mut e {
1577 SessionEntry::Message(m) => m.parent_id = path_parent_id.clone(),
1578 SessionEntry::ThinkingLevelChange(m) => m.parent_id = path_parent_id.clone(),
1579 SessionEntry::ModelChange(m) => m.parent_id = path_parent_id.clone(),
1580 SessionEntry::ActiveToolsChange(m) => m.parent_id = path_parent_id.clone(),
1581 SessionEntry::Compaction(m) => m.parent_id = path_parent_id.clone(),
1582 SessionEntry::BranchSummary(m) => m.parent_id = path_parent_id.clone(),
1583 SessionEntry::SessionInfo(m) => m.parent_id = path_parent_id.clone(),
1584 SessionEntry::Custom(m) => m.parent_id = path_parent_id.clone(),
1585 SessionEntry::CustomMessage(m) => m.parent_id = path_parent_id.clone(),
1586 _ => {}
1587 }
1588 path_parent_id = Some(e.id().to_string());
1589 path_clean.push(e);
1590 }
1591
1592 let path_entry_ids: std::collections::HashSet<String> =
1594 path_clean.iter().map(|e| e.id().to_string()).collect();
1595 let mut labels_to_write: Vec<(String, String)> = Vec::new();
1596 for id in &path_entry_ids {
1597 if let Some(label) = self.session.get_label(id) {
1598 labels_to_write.push((id.clone(), label));
1599 }
1600 }
1601
1602 let new_session_id = uuid::Uuid::new_v4().to_string();
1603 let timestamp = chrono::Utc::now().to_rfc3339();
1604 let file_ts = timestamp.replace([':', '.'], "-");
1605 let new_session_file = self
1606 .session_dir
1607 .join(format!("{}_{}.jsonl", file_ts, new_session_id));
1608
1609 let cwd_str = self.cwd.to_string_lossy().to_string();
1610
1611 if self.persist {
1613 let header = SessionHeader {
1614 type_: "session".to_string(),
1615 version: Some(CURRENT_SESSION_VERSION),
1616 id: new_session_id,
1617 timestamp,
1618 cwd: cwd_str,
1619 parent_session: self
1620 .session
1621 .metadata()
1622 .path
1623 .map(|p| p.to_string_lossy().to_string()),
1624 };
1625
1626 if let Some(parent) = new_session_file.parent() {
1627 let _ = std::fs::create_dir_all(parent);
1628 }
1629 let mut content = serde_json::to_string(&header).unwrap_or_default();
1630 content.push('\n');
1631 for entry in &path_clean {
1632 let line = serde_json::to_string(entry).unwrap_or_default();
1633 content.push_str(&line);
1634 content.push('\n');
1635 }
1636 for (target_id, label) in &labels_to_write {
1637 let label_entry = SessionEntry::Label(LabelEntry {
1638 id: uuid::Uuid::new_v4().to_string()[..8].to_string(),
1639 parent_id: path_parent_id.clone(),
1640 timestamp: chrono::Utc::now().to_rfc3339(),
1641 target_id: target_id.clone(),
1642 label: Some(label.clone()),
1643 });
1644 let line = serde_json::to_string(&label_entry).unwrap_or_default();
1645 content.push_str(&line);
1646 content.push('\n');
1647 }
1648 let _ = std::fs::write(&new_session_file, &content);
1649 }
1650
1651 Some(new_session_file)
1652 }
1653}
1654
1655pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
1657 let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
1658 let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1659
1660 let entries = std::fs::read_dir(session_dir).ok()?;
1661 for entry in entries.flatten() {
1662 let path = entry.path();
1663 if path.extension().is_some_and(|ext| ext == "jsonl") {
1664 let header = read_session_header(&path);
1665 if let Some(ref h) = header {
1666 if let Some(ref rcwd) = resolved_cwd
1667 && h.cwd != rcwd.to_string_lossy().as_ref()
1668 {
1669 continue;
1670 }
1671 } else {
1672 continue;
1673 }
1674 if let Ok(meta) = path.metadata()
1675 && let Ok(mtime) = meta.modified()
1676 {
1677 files.push((path, mtime));
1678 }
1679 }
1680 }
1681
1682 files.sort_by_key(|b| std::cmp::Reverse(b.1));
1683 files.into_iter().next().map(|(path, _)| path)
1684}
1685
1686pub fn list_sessions(session_dir: &Path) -> Vec<SessionInfo> {
1691 let mut sessions: Vec<SessionInfo> = Vec::new();
1692 let dir = match std::fs::read_dir(session_dir) {
1693 Ok(d) => d,
1694 Err(_) => return sessions,
1695 };
1696 for entry in dir.flatten() {
1697 let path = entry.path();
1698 if path.extension().is_some_and(|ext| ext == "jsonl")
1699 && let Some(info) = load_session_info(&path)
1700 {
1701 sessions.push(info);
1702 }
1703 }
1704 sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1705 sessions
1706}
1707
1708pub fn load_session_info(path: &Path) -> Option<SessionInfo> {
1710 let header = read_session_header(path)?;
1711 let created = DateTime::parse_from_rfc3339(&header.timestamp)
1712 .ok()?
1713 .with_timezone(&Utc);
1714 let modified = path.metadata().ok()?.modified().ok()?;
1715 let modified_dt: DateTime<Utc> = modified.into();
1716 let entries = load_entries_from_file(path);
1717 let name = entries.iter().rev().find_map(|e| {
1718 if let SessionEntry::SessionInfo(si) = e {
1719 let n = si.name.trim();
1720 if n.is_empty() {
1721 None
1722 } else {
1723 Some(n.to_string())
1724 }
1725 } else {
1726 None
1727 }
1728 });
1729 let message_count = entries
1730 .iter()
1731 .filter(|e| matches!(e, SessionEntry::Message(_)))
1732 .count();
1733 let first_message = entries
1734 .iter()
1735 .find_map(|e| {
1736 if let SessionEntry::Message(m) = e {
1737 Some(crate::agent::types::message_text(&m.message))
1738 } else {
1739 None
1740 }
1741 })
1742 .unwrap_or_default();
1743 let all_messages_text = entries
1744 .iter()
1745 .filter_map(|e| {
1746 if let SessionEntry::Message(m) = e {
1747 Some(crate::agent::types::message_text(&m.message))
1748 } else {
1749 None
1750 }
1751 })
1752 .collect::<Vec<_>>()
1753 .join("\n");
1754
1755 Some(SessionInfo {
1756 path: path.to_path_buf(),
1757 id: header.id,
1758 cwd: header.cwd,
1759 name,
1760 parent_session_path: header.parent_session,
1761 created,
1762 modified: modified_dt,
1763 message_count,
1764 first_message,
1765 all_messages_text,
1766 })
1767}
1768
1769pub fn delete_session(path: &Path) -> std::io::Result<()> {
1771 if path.exists() {
1772 std::fs::remove_file(path)?;
1773 }
1774 Ok(())
1775}
1776
1777pub fn fork_session(
1783 source_path: &Path,
1784 target_dir: &Path,
1785 entry_id: Option<&str>,
1786 position: Option<&str>,
1787) -> std::io::Result<String> {
1788 let header = read_session_header(source_path).ok_or_else(|| {
1789 std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing session header")
1790 })?;
1791 let entries = load_entries_from_file(source_path);
1792
1793 let by_id: HashMap<String, &SessionEntry> =
1795 entries.iter().map(|e| (e.id().to_string(), e)).collect();
1796
1797 let forked_entries: Vec<SessionEntry> = if let Some(target_id) = entry_id {
1798 let target = by_id.get(target_id).ok_or_else(|| {
1800 std::io::Error::new(std::io::ErrorKind::InvalidInput, "Entry not found")
1801 })?;
1802
1803 let effective_leaf_id = match position.unwrap_or("before") {
1805 "at" => Some(target.id().to_string()),
1806 _ => {
1807 if !matches!(target, SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message))
1808 {
1809 return Err(std::io::Error::new(
1810 std::io::ErrorKind::InvalidInput,
1811 "Entry is not a user message",
1812 ));
1813 }
1814 target.parent_id().map(|s| s.to_string())
1815 }
1816 };
1817
1818 let mut path: Vec<&SessionEntry> = Vec::new();
1820 let mut current = effective_leaf_id.as_ref().and_then(|id| by_id.get(id));
1821 while let Some(entry) = current {
1822 path.push(entry);
1823 current = entry.parent_id().and_then(|pid| by_id.get(pid));
1824 }
1825 path.reverse();
1826 path.into_iter().cloned().collect()
1827 } else {
1828 entries.clone()
1829 };
1830
1831 let session_id = uuid::Uuid::new_v4().to_string();
1833 let timestamp = chrono::Utc::now().to_rfc3339();
1834 let file_ts = timestamp.replace([':', '.'], "-");
1835 let file_name = format!("{}_{}.jsonl", file_ts, session_id);
1836 let target_path = target_dir.join(&file_name);
1837
1838 std::fs::create_dir_all(target_dir)?;
1839
1840 let new_header = SessionHeader {
1841 type_: "session".to_string(),
1842 version: Some(CURRENT_SESSION_VERSION),
1843 id: session_id.clone(),
1844 timestamp,
1845 cwd: header.cwd.clone(),
1846 parent_session: Some(source_path.to_string_lossy().to_string()),
1847 };
1848 write_entries_to_file(&target_path, &new_header, &forked_entries)?;
1849
1850 Ok(session_id)
1851}
1852
1853#[cfg(test)]
1856mod tests {
1857 use super::*;
1858 use crate::agent::types::user_message;
1859 use tempfile::TempDir;
1860
1861 fn make_user_msg(content: &str) -> AgentMessage {
1862 user_message(content)
1863 }
1864
1865 fn make_asst_msg(content: &str) -> AgentMessage {
1866 crate::agent::types::assistant_message(content)
1867 }
1868
1869 #[test]
1872 fn test_build_context_tracks_metadata() {
1873 let tmp = TempDir::new().unwrap();
1874 let sessions_dir = tmp.path().join("sessions");
1875 let cwd = tmp.path().join("project");
1876 std::fs::create_dir_all(&cwd).unwrap();
1877
1878 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1879 sm.session_mut().append_thinking_level_change("high");
1880 sm.session_mut()
1881 .append_model_change("opencode_go", "deepseek-v4-pro");
1882 sm.session_mut()
1883 .append_active_tools_change(&["read".to_string(), "write".to_string()]);
1884 sm.append_message(&make_user_msg("hello"));
1885 sm.append_message(&make_asst_msg("hi"));
1886
1887 let context = sm.session().build_context();
1888 assert_eq!(context.thinking_level, "high");
1889 assert_eq!(
1890 context.model,
1891 Some(("opencode_go".to_string(), "deepseek-v4-pro".to_string()))
1892 );
1893 assert_eq!(
1894 context.active_tool_names,
1895 Some(vec!["read".to_string(), "write".to_string()])
1896 );
1897 assert_eq!(context.messages.len(), 2);
1898 }
1899
1900 #[test]
1901 fn test_build_context_defaults_when_no_metadata() {
1902 let cwd = Path::new("/tmp/test");
1903 let sm = SessionManager::in_memory(cwd);
1904 let context = sm.session().build_context();
1905 assert_eq!(context.thinking_level, "off");
1906 assert!(context.model.is_none());
1907 assert!(context.active_tool_names.is_none());
1908 assert!(context.messages.is_empty());
1909 }
1910
1911 #[test]
1914 fn test_find_entries_by_type() {
1915 let cwd = Path::new("/tmp/test");
1916 let mut sm = SessionManager::in_memory(cwd);
1917 sm.append_message(&make_user_msg("hello"));
1918 sm.session_mut().append_thinking_level_change("high");
1919 sm.session_mut().append_model_change("p", "m");
1920 sm.session_mut().append_session_info("test session");
1921
1922 let messages = sm.session().find_entries("message");
1923 assert_eq!(messages.len(), 1);
1924
1925 let thinking = sm.session().find_entries("thinking_level_change");
1926 assert_eq!(thinking.len(), 1);
1927
1928 let models = sm.session().find_entries("model_change");
1929 assert_eq!(models.len(), 1);
1930
1931 let infos = sm.session().find_entries("session_info");
1932 assert_eq!(infos.len(), 1);
1933 }
1934
1935 #[test]
1938 fn test_list_sessions_empty_dir() {
1939 let tmp = TempDir::new().unwrap();
1940 let sessions = list_sessions(tmp.path());
1941 assert!(sessions.is_empty());
1942 }
1943
1944 #[test]
1945 fn test_list_sessions() {
1946 let tmp = TempDir::new().unwrap();
1947 let sessions_dir = tmp.path().join("sessions");
1948 let cwd = tmp.path().join("project");
1949 std::fs::create_dir_all(&cwd).unwrap();
1950
1951 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1952 sm.append_message(&make_user_msg("first"));
1953 sm.append_message(&make_asst_msg("response"));
1954 let path = sm.session().session_file().unwrap().to_path_buf();
1955 drop(sm);
1956
1957 let sessions = list_sessions(&sessions_dir);
1958 assert_eq!(sessions.len(), 1);
1959 assert_eq!(sessions[0].path, path);
1960 assert_eq!(sessions[0].message_count, 2);
1961 }
1962
1963 #[test]
1964 fn test_fork_session_all_entries() {
1965 let tmp = TempDir::new().unwrap();
1966 let sessions_dir = tmp.path().join("sessions");
1967 let cwd = tmp.path().join("project");
1968 std::fs::create_dir_all(&cwd).unwrap();
1969
1970 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1971 sm.append_message(&make_user_msg("hello"));
1972 sm.append_message(&make_asst_msg("world"));
1973 let source_path = sm.session().session_file().unwrap().to_path_buf();
1974 drop(sm);
1975
1976 let target_dir = tmp.path().join("forked");
1977 let new_id = fork_session(&source_path, &target_dir, None, None).unwrap();
1978 assert!(!new_id.is_empty());
1979
1980 let sessions = list_sessions(&target_dir);
1981 assert_eq!(sessions.len(), 1);
1982 assert_eq!(sessions[0].message_count, 2);
1983 }
1984
1985 #[test]
1986 fn test_delete_session() {
1987 let tmp = TempDir::new().unwrap();
1988 let path = tmp.path().join("test.jsonl");
1989 std::fs::write(&path, "{\"type\":\"session\",\"id\":\"test\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":\"/\"}\n").unwrap();
1990 assert!(path.exists());
1991 delete_session(&path).unwrap();
1992 assert!(!path.exists());
1993 delete_session(&path).unwrap();
1995 }
1996
1997 #[test]
1998 fn test_parse_session_entry_line() {
1999 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2000 id: "abc12345".to_string(),
2001 parent_id: None,
2002 timestamp: "2026-06-19T12:00:00Z".to_string(),
2003 name: "Test session".to_string(),
2004 });
2005 let json = serde_json::to_string(&entry).unwrap();
2006 let parsed = parse_session_entry_line(&json);
2007 assert!(parsed.is_some());
2008 }
2009
2010 #[test]
2011 fn test_parse_session_entry_line_empty() {
2012 assert!(parse_session_entry_line("").is_none());
2013 assert!(parse_session_entry_line(" ").is_none());
2014 }
2015
2016 #[test]
2017 fn test_parse_session_entry_line_malformed() {
2018 assert!(parse_session_entry_line("not valid json").is_none());
2019 }
2020
2021 #[test]
2022 fn test_parse_session_header_line() {
2023 let header = SessionHeader {
2024 type_: "session".to_string(),
2025 version: Some(3),
2026 id: "session123".to_string(),
2027 timestamp: "2026-06-19T12:00:00Z".to_string(),
2028 cwd: "/home/user/project".to_string(),
2029 parent_session: None,
2030 };
2031 let json = serde_json::to_string(&header).unwrap();
2032 let parsed = parse_session_header_line(&json);
2033 assert!(parsed.is_some());
2034 assert_eq!(parsed.unwrap().id, "session123");
2035 }
2036
2037 #[test]
2038 fn test_parse_session_header_line_wrong_type() {
2039 let json =
2041 r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
2042 let result = parse_session_header_line(json);
2043 assert!(result.is_none());
2044 }
2045
2046 #[test]
2047 fn test_write_and_read_entries() {
2048 let tmp = TempDir::new().unwrap();
2049 let file_path = tmp.path().join("test.jsonl");
2050
2051 let header = SessionHeader {
2052 type_: "session".to_string(),
2053 version: Some(3),
2054 id: "session1".to_string(),
2055 timestamp: "2026-06-19T12:00:00Z".to_string(),
2056 cwd: "/home/user/project".to_string(),
2057 parent_session: None,
2058 };
2059
2060 let entries: Vec<SessionEntry> = vec![
2061 SessionEntry::Message(MessageEntry::new(
2062 "msg1".to_string(),
2063 None,
2064 "2026-06-19T12:00:01Z".to_string(),
2065 make_user_msg("hello"),
2066 0.0,
2067 )),
2068 SessionEntry::Message(MessageEntry {
2069 cost: 0.0,
2070 id: "msg2".to_string(),
2071 parent_id: Some("msg1".to_string()),
2072 timestamp: "2026-06-19T12:00:02Z".to_string(),
2073 message: AgentMessage::Llm(yoagent::types::Message::Assistant {
2074 content: vec![yoagent::types::Content::Text {
2075 text: "hi there".to_string(),
2076 }],
2077 stop_reason: yoagent::types::StopReason::Stop,
2078 model: String::new(),
2079 provider: String::new(),
2080 usage: yoagent::types::Usage {
2081 input: 10,
2082 output: 5,
2083 ..Default::default()
2084 },
2085 timestamp: 0,
2086 error_message: None,
2087 }),
2088 }),
2089 ];
2090
2091 write_entries_to_file(&file_path, &header, &entries).unwrap();
2092
2093 let read_header = read_session_header(&file_path).unwrap();
2095 assert_eq!(read_header.id, "session1");
2096
2097 let read_entries = load_entries_from_file(&file_path);
2099 assert_eq!(read_entries.len(), 2);
2100
2101 match &read_entries[0] {
2102 SessionEntry::Message(e) => {
2103 assert_eq!(e.id, "msg1");
2104 assert!(crate::agent::types::message_is_user(&e.message));
2105 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2106 }
2107 _ => panic!("Expected Message"),
2108 }
2109
2110 match &read_entries[1] {
2111 SessionEntry::Message(e) => {
2112 assert_eq!(e.id, "msg2");
2113 assert!(crate::agent::types::message_is_assistant(&e.message));
2114 assert_eq!(crate::agent::types::message_text(&e.message), "hi there");
2115 assert!(crate::agent::types::message_usage(&e.message).is_some());
2116 }
2117 _ => panic!("Expected Message"),
2118 }
2119 }
2120
2121 #[test]
2122 fn test_append_entry_to_file() {
2123 let tmp = TempDir::new().unwrap();
2124 let file_path = tmp.path().join("append_test.jsonl");
2125
2126 let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2127 id: "abc12345".to_string(),
2128 parent_id: None,
2129 timestamp: "2026-06-19T12:00:00Z".to_string(),
2130 name: "Test".to_string(),
2131 });
2132
2133 append_entry_to_file(&file_path, &entry).unwrap();
2134
2135 let content = fs::read_to_string(&file_path).unwrap();
2136 assert!(content.contains("Test"));
2137 assert!(content.contains("abc12345"));
2138 }
2139
2140 #[test]
2141 fn test_load_entries_missing_file() {
2142 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2143 assert!(entries.is_empty());
2144 }
2145
2146 #[test]
2147 fn test_read_session_header_missing_file() {
2148 let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
2149 assert!(header.is_none());
2150 }
2151
2152 #[test]
2155 fn test_encode_cwd() {
2156 assert_eq!(
2157 encode_cwd_for_dir(Path::new("/home/user/project")),
2158 "--home-user-project--"
2159 );
2160 }
2161
2162 #[test]
2163 fn test_encode_cwd_windows_style() {
2164 assert_eq!(
2165 encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
2166 "--C--Users-user-project--"
2167 );
2168 }
2169
2170 #[test]
2171 fn test_encode_cwd_no_leading_slash() {
2172 assert_eq!(
2173 encode_cwd_for_dir(Path::new("home/user/project")),
2174 "--home-user-project--"
2175 );
2176 }
2177
2178 #[test]
2179 fn test_encode_cwd_special_chars() {
2180 assert_eq!(
2181 encode_cwd_for_dir(Path::new("/home/user/my:project")),
2182 "--home-user-my-project--"
2183 );
2184 }
2185
2186 #[test]
2189 fn test_entry_id_accessor() {
2190 let entry = SessionEntry::Message(MessageEntry::new(
2191 "myid".to_string(),
2192 None,
2193 "2026-06-19T12:00:00Z".to_string(),
2194 make_user_msg("hello"),
2195 0.0,
2196 ));
2197 assert_eq!(entry.id(), "myid");
2198 }
2199
2200 #[test]
2201 fn test_entry_parent_id_accessor() {
2202 let entry = SessionEntry::Message(MessageEntry::new(
2203 "myid".to_string(),
2204 Some("parent".to_string()),
2205 "2026-06-19T12:00:00Z".to_string(),
2206 make_user_msg("hello"),
2207 0.0,
2208 ));
2209 assert_eq!(entry.parent_id(), Some("parent"));
2210 }
2211
2212 #[test]
2213 fn test_entry_timestamp_accessor() {
2214 let entry = SessionEntry::Message(MessageEntry::new(
2215 "myid".to_string(),
2216 None,
2217 "2026-06-19T12:00:00Z".to_string(),
2218 make_user_msg("hello"),
2219 0.0,
2220 ));
2221 assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
2222 }
2223
2224 #[test]
2227 fn test_generate_entry_id_length() {
2228 let map = HashMap::new();
2229 let id = generate_entry_id(&map);
2230 assert_eq!(id.len(), 8);
2231 }
2232
2233 #[test]
2234 fn test_generate_entry_id_hex() {
2235 let map = HashMap::new();
2236 let id = generate_entry_id(&map);
2237 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
2238 }
2239
2240 #[test]
2241 fn test_generate_entry_id_collision_fallback() {
2242 let map = HashMap::new();
2247 let id1 = generate_entry_id(&map);
2248 assert!(!id1.is_empty());
2249 }
2250
2251 #[test]
2254 fn test_deserialize_pi_format_message() {
2255 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}}"#;
2258 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2259 match entry {
2260 SessionEntry::Message(e) => {
2261 assert_eq!(e.id, "abc12345");
2262 assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2263 }
2264 _ => panic!("Expected Message"),
2265 }
2266 }
2267
2268 #[test]
2269 fn test_deserialize_pi_format_thinking_level() {
2270 let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
2271 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2272 match entry {
2273 SessionEntry::ThinkingLevelChange(e) => {
2274 assert_eq!(e.thinking_level, "high");
2275 }
2276 _ => panic!("Expected ThinkingLevelChange"),
2277 }
2278 }
2279
2280 #[test]
2281 fn test_deserialize_pi_format_model_change() {
2282 let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
2283 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2284 match entry {
2285 SessionEntry::ModelChange(e) => {
2286 assert_eq!(e.provider, "opencode_go");
2287 assert_eq!(e.model_id, "deepseek-v4-pro");
2288 }
2289 _ => panic!("Expected ModelChange"),
2290 }
2291 }
2292
2293 #[test]
2294 fn test_deserialize_pi_format_compaction() {
2295 let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
2296 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2297 match entry {
2298 SessionEntry::Compaction(e) => {
2299 assert_eq!(e.summary, "Earlier conversation summarized");
2300 assert_eq!(e.first_kept_entry_id, "entry123");
2301 assert_eq!(e.tokens_before, 5000);
2302 }
2303 _ => panic!("Expected Compaction"),
2304 }
2305 }
2306
2307 #[test]
2308 fn test_deserialize_pi_format_session_info() {
2309 let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
2310 let entry: SessionEntry = serde_json::from_str(json).unwrap();
2311 match entry {
2312 SessionEntry::SessionInfo(e) => {
2313 assert_eq!(e.name, "My session");
2314 }
2315 _ => panic!("Expected SessionInfo"),
2316 }
2317 }
2318
2319 #[test]
2322 fn test_session_create_in_memory() {
2323 let cwd = Path::new("/tmp/test-project");
2324 let sm = SessionManager::in_memory(cwd);
2325 assert!(!sm.is_persisted());
2326 assert!(!sm.session().session_id().is_empty());
2327 assert_eq!(sm.cwd(), cwd);
2328 assert!(sm.session().get_leaf_id().is_none());
2329 assert!(sm.session().get_entries().is_empty());
2330 }
2331
2332 #[test]
2333 fn test_session_create_persisted() {
2334 let tmp = TempDir::new().unwrap();
2335 let sessions_dir = tmp.path().join("sessions");
2336 let cwd = tmp.path().join("project");
2337 std::fs::create_dir_all(&cwd).unwrap();
2338
2339 let sm = SessionManager::create(&cwd, Some(&sessions_dir));
2340 assert!(sm.is_persisted());
2341 assert!(!sm.session().session_id().is_empty());
2342 assert!(
2344 sm.session().session_file().is_none(),
2345 "session file should not be created until first assistant message (lazy write)"
2346 );
2347 assert!(!sm.flushed);
2348 }
2349
2350 #[test]
2351 fn test_session_append_and_build_context() {
2352 let tmp = TempDir::new().unwrap();
2353 let sessions_dir = tmp.path().join("sessions");
2354 let cwd = tmp.path().join("project");
2355 std::fs::create_dir_all(&cwd).unwrap();
2356
2357 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2358
2359 let user_msg = make_user_msg("hello");
2360 let user_id = sm.append_message(&user_msg);
2361 assert_eq!(
2362 sm.session().get_leaf_id().as_deref(),
2363 Some(user_id.as_str())
2364 );
2365
2366 assert_eq!(sm.session().get_entries().len(), 1);
2368
2369 let assistant_msg = make_asst_msg("hi there");
2370 sm.append_message(&assistant_msg);
2371 assert_eq!(sm.session().get_entries().len(), 2);
2372
2373 assert!(
2375 sm.session().session_file().unwrap().exists(),
2376 "session file should exist after first assistant message"
2377 );
2378
2379 let context = sm.session().build_context();
2380 assert_eq!(context.messages.len(), 2);
2381 assert_eq!(
2382 crate::agent::types::message_text(&context.messages[0]),
2383 "hello"
2384 );
2385 assert_eq!(
2386 crate::agent::types::message_text(&context.messages[1]),
2387 "hi there"
2388 );
2389 }
2390
2391 #[test]
2392 fn test_session_open_existing() {
2393 let tmp = TempDir::new().unwrap();
2394 let sessions_dir = tmp.path().join("sessions");
2395 let cwd = tmp.path().join("project");
2396 std::fs::create_dir_all(&cwd).unwrap();
2397
2398 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2400 sm.append_message(&make_user_msg("first"));
2401 sm.append_message(&make_asst_msg("response"));
2402
2403 let file_path = sm.session().session_file().unwrap().to_path_buf();
2404 let session_id = sm.session().session_id().to_string();
2405 drop(sm);
2406
2407 let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
2409 assert_eq!(sm2.session().session_id(), session_id);
2410 let context = sm2.session().build_context();
2411 assert_eq!(context.messages.len(), 2);
2412 assert_eq!(
2413 crate::agent::types::message_text(&context.messages[0]),
2414 "first"
2415 );
2416 assert_eq!(
2417 crate::agent::types::message_text(&context.messages[1]),
2418 "response"
2419 );
2420 }
2421
2422 #[test]
2423 fn test_session_continue_recent() {
2424 let tmp = TempDir::new().unwrap();
2425 let sessions_dir = tmp.path().join("sessions");
2426 let cwd = tmp.path().join("project");
2427 std::fs::create_dir_all(&cwd).unwrap();
2428
2429 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2431 sm1.append_message(&make_user_msg("old session"));
2432 sm1.append_message(&make_asst_msg("old response"));
2433 let _old_id = sm1.session().session_id().to_string();
2434 drop(sm1);
2435
2436 std::thread::sleep(std::time::Duration::from_millis(10));
2438
2439 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2441 sm2.append_message(&make_user_msg("new session"));
2442 sm2.append_message(&make_asst_msg("new response"));
2443 let new_id = sm2.session().session_id().to_string();
2444 drop(sm2);
2445
2446 let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2448 assert_eq!(sm3.session().session_id(), new_id);
2449 let context = sm3.session().build_context();
2450 assert_eq!(
2451 crate::agent::types::message_text(&context.messages[0]),
2452 "new session"
2453 );
2454 }
2455
2456 #[test]
2457 fn test_session_continue_recent_none_exist() {
2458 let tmp = TempDir::new().unwrap();
2459 let sessions_dir = tmp.path().join("sessions");
2460 let cwd = tmp.path().join("project");
2461 std::fs::create_dir_all(&sessions_dir).unwrap();
2462 std::fs::create_dir_all(&cwd).unwrap();
2463
2464 let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2466 assert!(!sm.session().session_id().is_empty());
2467 assert!(sm.session().get_entries().is_empty());
2468 }
2469
2470 #[test]
2471 fn test_session_name() {
2472 let tmp = TempDir::new().unwrap();
2473 let sessions_dir = tmp.path().join("sessions");
2474 let cwd = tmp.path().join("project");
2475 std::fs::create_dir_all(&cwd).unwrap();
2476
2477 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2478 assert!(sm.session().session_name().is_none());
2479
2480 sm.session_mut().append_session_info("My Task");
2481 sm.append_message(&make_user_msg("hello"));
2482 sm.append_message(&make_asst_msg("hi"));
2483 assert_eq!(sm.session().session_name().as_deref(), Some("My Task"));
2484
2485 sm.session_mut().append_session_info("");
2487 assert!(sm.session().session_name().is_none());
2488 }
2489
2490 #[test]
2491 fn test_session_thinking_level_change() {
2492 let tmp = TempDir::new().unwrap();
2493 let sessions_dir = tmp.path().join("sessions");
2494 let cwd = tmp.path().join("project");
2495 std::fs::create_dir_all(&cwd).unwrap();
2496
2497 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2498 sm.session_mut().append_thinking_level_change("high");
2499
2500 assert_eq!(sm.session().get_entries().len(), 1);
2501 match &sm.session().get_entries()[0] {
2502 SessionEntry::ThinkingLevelChange(e) => {
2503 assert_eq!(e.thinking_level, "high");
2504 }
2505 _ => panic!("Expected ThinkingLevelChange"),
2506 }
2507 }
2508
2509 #[test]
2510 fn test_session_model_change() {
2511 let tmp = TempDir::new().unwrap();
2512 let sessions_dir = tmp.path().join("sessions");
2513 let cwd = tmp.path().join("project");
2514 std::fs::create_dir_all(&cwd).unwrap();
2515
2516 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2517 sm.session_mut()
2518 .append_model_change("opencode_go", "deepseek-v4-pro");
2519
2520 assert_eq!(sm.session().get_entries().len(), 1);
2521 match &sm.session().get_entries()[0] {
2522 SessionEntry::ModelChange(e) => {
2523 assert_eq!(e.provider, "opencode_go");
2524 assert_eq!(e.model_id, "deepseek-v4-pro");
2525 }
2526 _ => panic!("Expected ModelChange"),
2527 }
2528 }
2529
2530 #[test]
2531 fn test_session_compaction() {
2532 let tmp = TempDir::new().unwrap();
2533 let sessions_dir = tmp.path().join("sessions");
2534 let cwd = tmp.path().join("project");
2535 std::fs::create_dir_all(&cwd).unwrap();
2536
2537 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2538 sm.session_mut().append_compaction(
2539 "Earlier work summarized",
2540 "entry_kept",
2541 5000,
2542 None,
2543 None,
2544 );
2545
2546 match &sm.session().get_entries()[0] {
2547 SessionEntry::Compaction(e) => {
2548 assert_eq!(e.summary, "Earlier work summarized");
2549 assert_eq!(e.first_kept_entry_id, "entry_kept");
2550 assert_eq!(e.tokens_before, 5000);
2551 }
2552 _ => panic!("Expected Compaction"),
2553 }
2554 }
2555
2556 #[test]
2557 fn test_session_label() {
2558 let tmp = TempDir::new().unwrap();
2559 let sessions_dir = tmp.path().join("sessions");
2560 let cwd = tmp.path().join("project");
2561 std::fs::create_dir_all(&cwd).unwrap();
2562
2563 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2564 let msg_id = sm.append_message(&make_user_msg("important message"));
2565 sm.append_message(&make_asst_msg("ok"));
2566
2567 sm.session_mut()
2569 .append_label_change(&msg_id, Some("important"))
2570 .unwrap();
2571 assert_eq!(
2572 sm.session().get_label(&msg_id).as_deref(),
2573 Some("important")
2574 );
2575
2576 sm.session_mut().append_label_change(&msg_id, None).unwrap();
2578 assert_eq!(sm.session().get_label(&msg_id), None);
2579 }
2580
2581 #[test]
2582 fn test_session_branch_navigation() {
2583 let tmp = TempDir::new().unwrap();
2584 let sessions_dir = tmp.path().join("sessions");
2585 let cwd = tmp.path().join("project");
2586 std::fs::create_dir_all(&cwd).unwrap();
2587
2588 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2589 let m1 = sm.append_message(&make_user_msg("one"));
2590 sm.append_message(&make_asst_msg("response one"));
2591 let _m2 = sm.append_message(&make_user_msg("two"));
2592 sm.append_message(&make_asst_msg("response two"));
2593
2594 assert_eq!(sm.session().get_entries().len(), 4);
2596
2597 sm.set_branch(&m1).unwrap();
2599 assert_eq!(sm.session().get_entries().len(), 4);
2601 assert_eq!(sm.session().get_leaf_id().as_deref(), Some(m1.as_str()));
2602
2603 sm.append_message(&make_asst_msg("alternate response"));
2605 assert_eq!(sm.session().get_entries().len(), 5);
2607
2608 let context = sm.session().build_context();
2610 assert_eq!(context.messages.len(), 2); assert_eq!(context.thinking_level, "off");
2613 assert!(context.model.is_none());
2614 assert!(context.active_tool_names.is_none());
2615 }
2616
2617 #[test]
2618 fn test_session_reset_leaf() {
2619 let tmp = TempDir::new().unwrap();
2620 let sessions_dir = tmp.path().join("sessions");
2621 let cwd = tmp.path().join("project");
2622 std::fs::create_dir_all(&cwd).unwrap();
2623
2624 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2625 sm.append_message(&make_user_msg("one"));
2626 sm.append_message(&make_asst_msg("response"));
2627 assert_eq!(sm.session().get_entries().len(), 2);
2628
2629 sm.reset_leaf();
2631 assert_eq!(sm.session().get_entries().len(), 2);
2633 assert!(sm.session().get_leaf_id().is_none());
2634
2635 sm.append_message(&make_user_msg("fresh start"));
2637 assert_eq!(sm.session().get_entries().len(), 3);
2638 match &sm.session().get_entries()[2] {
2640 SessionEntry::Message(m) => {
2641 assert!(m.parent_id.is_none());
2642 }
2643 _ => panic!("Expected Message"),
2644 }
2645 }
2646
2647 #[test]
2648 fn test_session_branch_summary() {
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(&cwd).unwrap();
2653
2654 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2655 sm.append_message(&make_user_msg("one"));
2656 sm.append_message(&make_asst_msg("response"));
2657
2658 sm.session_mut()
2659 .append_branch_summary("root", "Abandoned path summary", None, None);
2660
2661 match &sm.session().get_entries()[2] {
2662 SessionEntry::BranchSummary(e) => {
2663 assert_eq!(e.summary, "Abandoned path summary");
2664 assert_eq!(e.from_id, "root");
2665 }
2666 _ => panic!("Expected BranchSummary"),
2667 }
2668 }
2669
2670 #[test]
2671 fn test_session_children() {
2672 let tmp = TempDir::new().unwrap();
2673 let sessions_dir = tmp.path().join("sessions");
2674 let cwd = tmp.path().join("project");
2675 std::fs::create_dir_all(&cwd).unwrap();
2676
2677 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2678 let m1 = sm.append_message(&make_user_msg("one"));
2679 sm.append_message(&make_asst_msg("response"));
2680
2681 let children = sm.get_children(&m1);
2683 assert_eq!(children.len(), 1);
2684 }
2685
2686 #[test]
2687 fn test_session_custom_entry() {
2688 let tmp = TempDir::new().unwrap();
2689 let sessions_dir = tmp.path().join("sessions");
2690 let cwd = tmp.path().join("project");
2691 std::fs::create_dir_all(&cwd).unwrap();
2692
2693 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2694 sm.append_message(&make_user_msg("one"));
2695 sm.append_message(&make_asst_msg("ok"));
2696 sm.session_mut()
2697 .append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
2698
2699 match &sm.session().get_entries()[2] {
2700 SessionEntry::Custom(e) => {
2701 assert_eq!(e.custom_type, "my_ext");
2702 assert_eq!(e.data["key"], "value");
2703 }
2704 _ => panic!("Expected Custom"),
2705 }
2706 }
2707
2708 #[test]
2709 fn test_find_most_recent_session() {
2710 let tmp = TempDir::new().unwrap();
2711 let sessions_dir = tmp.path().join("sessions");
2712 let cwd = tmp.path().join("project");
2713 std::fs::create_dir_all(&sessions_dir).unwrap();
2714 std::fs::create_dir_all(&cwd).unwrap();
2715
2716 let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2718 sm1.append_message(&make_user_msg("old"));
2719 sm1.append_message(&make_asst_msg("old"));
2720 let _path1 = sm1.session().session_file().unwrap().to_path_buf();
2721 drop(sm1);
2722
2723 std::thread::sleep(std::time::Duration::from_millis(10));
2724
2725 let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2727 sm2.append_message(&make_user_msg("new"));
2728 sm2.append_message(&make_asst_msg("new"));
2729 let path2 = sm2.session().session_file().unwrap().to_path_buf();
2730 drop(sm2);
2731
2732 let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
2733 assert_eq!(most_recent, path2);
2734 }
2735
2736 #[test]
2739 fn test_corrupt_empty_file_is_recovered() {
2740 let tmp = TempDir::new().unwrap();
2741 let sessions_dir = tmp.path().join("sessions");
2742 let cwd = tmp.path().join("project");
2743 std::fs::create_dir_all(&sessions_dir).unwrap();
2744 std::fs::create_dir_all(&cwd).unwrap();
2745
2746 let file_path = sessions_dir.join("empty.jsonl");
2748 std::fs::write(&file_path, "").unwrap();
2749
2750 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2752 assert!(!sm.session().session_id().is_empty());
2753 assert!(sm.session().get_entries().is_empty());
2754 assert_eq!(sm.session().session_file().unwrap(), file_path);
2755 }
2756
2757 #[test]
2758 fn test_corrupt_garbage_file_is_recovered() {
2759 let tmp = TempDir::new().unwrap();
2760 let sessions_dir = tmp.path().join("sessions");
2761 let cwd = tmp.path().join("project");
2762 std::fs::create_dir_all(&sessions_dir).unwrap();
2763 std::fs::create_dir_all(&cwd).unwrap();
2764
2765 let file_path = sessions_dir.join("garbage.jsonl");
2767 std::fs::write(
2768 &file_path,
2769 "this is not json\nneither is this\n{half-json\n",
2770 )
2771 .unwrap();
2772
2773 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2775 assert!(!sm.session().session_id().is_empty());
2776 assert!(sm.session().get_entries().is_empty());
2777 }
2778
2779 #[test]
2780 fn test_corrupt_header_only_file_is_kept() {
2781 let tmp = TempDir::new().unwrap();
2782 let sessions_dir = tmp.path().join("sessions");
2783 let cwd = tmp.path().join("project");
2784 std::fs::create_dir_all(&sessions_dir).unwrap();
2785 std::fs::create_dir_all(&cwd).unwrap();
2786
2787 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2789 sm.append_message(&make_user_msg("test"));
2790 sm.append_message(&make_asst_msg("ok"));
2791 let original_id = sm.session().session_id().to_string();
2792 let file_path = sm.session().session_file().unwrap().to_path_buf();
2793 drop(sm);
2794
2795 let content = std::fs::read_to_string(&file_path).unwrap();
2797 let header_line = content.lines().next().unwrap();
2798 std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
2799
2800 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2802 assert_eq!(sm.session().session_id(), original_id);
2803 assert!(sm.session().get_entries().is_empty());
2804 }
2805
2806 #[test]
2807 fn test_corrupt_malformed_lines_are_skipped() {
2808 let tmp = TempDir::new().unwrap();
2809 let sessions_dir = tmp.path().join("sessions");
2810 let cwd = tmp.path().join("project");
2811 std::fs::create_dir_all(&sessions_dir).unwrap();
2812 std::fs::create_dir_all(&cwd).unwrap();
2813
2814 let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2816 sm.append_message(&make_user_msg("valid message"));
2817 sm.append_message(&make_asst_msg("valid response"));
2818 let file_path = sm.session().session_file().unwrap().to_path_buf();
2819 drop(sm);
2820
2821 let mut content = std::fs::read_to_string(&file_path).unwrap();
2823 content.push_str("this is garbage\n");
2824 content.push_str("{incomplete json\n");
2825 content.push('\n'); std::fs::write(&file_path, &content).unwrap();
2827
2828 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2830 let ctx = sm.session().build_context();
2831 assert_eq!(ctx.messages.len(), 2);
2832 assert_eq!(
2833 crate::agent::types::message_text(&ctx.messages[0]),
2834 "valid message"
2835 );
2836 assert_eq!(
2837 crate::agent::types::message_text(&ctx.messages[1]),
2838 "valid response"
2839 );
2840 }
2841
2842 #[test]
2843 fn test_corrupt_missing_header_uses_new_id() {
2844 let tmp = TempDir::new().unwrap();
2845 let sessions_dir = tmp.path().join("sessions");
2846 let cwd = tmp.path().join("project");
2847 std::fs::create_dir_all(&sessions_dir).unwrap();
2848 std::fs::create_dir_all(&cwd).unwrap();
2849
2850 let entry = SessionEntry::Message(MessageEntry::new(
2852 "msg1".to_string(),
2853 None,
2854 "2026-01-01T00:00:00Z".to_string(),
2855 make_user_msg("orphan message"),
2856 0.0,
2857 ));
2858 let json = serde_json::to_string(&entry).unwrap();
2859 let file_path = sessions_dir.join("no_header.jsonl");
2860 std::fs::write(&file_path, format!("{}\n", json)).unwrap();
2861
2862 let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2865 assert!(!sm.session().session_id().is_empty());
2866 assert_eq!(sm.session().get_entries().len(), 0);
2867 }
2868
2869 #[test]
2870 fn test_corrupt_file_then_append_works() {
2871 let tmp = TempDir::new().unwrap();
2872 let sessions_dir = tmp.path().join("sessions");
2873 let cwd = tmp.path().join("project");
2874 std::fs::create_dir_all(&sessions_dir).unwrap();
2875 std::fs::create_dir_all(&cwd).unwrap();
2876
2877 let file_path = sessions_dir.join("recovered.jsonl");
2879 std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
2880
2881 let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2883 assert!(sm.session().get_entries().is_empty());
2884
2885 sm.append_message(&make_user_msg("fresh start"));
2887 sm.append_message(&make_asst_msg("fresh response"));
2888
2889 let ctx = sm.session().build_context();
2890 assert_eq!(ctx.messages.len(), 2);
2891 assert_eq!(
2892 crate::agent::types::message_text(&ctx.messages[0]),
2893 "fresh start"
2894 );
2895
2896 let content = std::fs::read_to_string(&file_path).unwrap();
2898 assert!(content.contains("fresh start"));
2899 assert!(!content.contains("garbage"));
2900 }
2901
2902 #[test]
2903 fn test_corrupt_all_lines_malformed_is_empty() {
2904 let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2905 assert!(entries.is_empty());
2906 }
2907
2908 #[test]
2909 fn test_corrupt_malformed_line_returns_none() {
2910 let result = parse_session_entry_line("not valid json");
2911 assert!(result.is_none());
2912 }
2913
2914 #[test]
2915 fn test_corrupt_blank_lines_are_skipped() {
2916 let result = parse_session_entry_line("");
2917 assert!(result.is_none());
2918 let result = parse_session_entry_line(" ");
2919 assert!(result.is_none());
2920 }
2921
2922 #[test]
2923 fn test_corrupt_header_line_malformed_returns_none() {
2924 let result = read_session_header(Path::new("/nonexistent"));
2925 assert!(result.is_none());
2926 }
2927
2928 #[test]
2931 fn test_session_name_sanitizes_newlines() {
2932 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
2933 sm.session_mut()
2934 .append_session_info("My\nTask\rWith\r\nNewlines");
2935 assert_eq!(
2936 sm.session().session_name().as_deref(),
2937 Some("My Task With Newlines")
2938 );
2939 }
2940
2941 #[test]
2944 fn test_append_label_nonexistent_target_returns_error() {
2945 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
2946 let result = sm
2947 .session_mut()
2948 .append_label_change("nonexistent", Some("label"));
2949 assert!(result.is_err());
2950 match result {
2951 Err(SessionError::NotFound(msg)) => {
2952 assert!(msg.contains("nonexistent"));
2953 }
2954 _ => panic!("Expected SessionError::NotFound"),
2955 }
2956 }
2957
2958 #[test]
2961 fn test_session_label_timestamp() {
2962 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
2963 let msg_id = sm.append_message(&make_user_msg("important"));
2964 sm.append_message(&make_asst_msg("ok"));
2965
2966 assert!(sm.session().get_label_timestamp(&msg_id).is_none());
2968
2969 sm.session_mut()
2971 .append_label_change(&msg_id, Some("important"))
2972 .unwrap();
2973 let ts = sm.session().get_label_timestamp(&msg_id);
2974 assert!(ts.is_some());
2975 chrono::DateTime::parse_from_rfc3339(&ts.unwrap()).unwrap();
2977
2978 sm.session_mut().append_label_change(&msg_id, None).unwrap();
2980 assert!(sm.session().get_label_timestamp(&msg_id).is_none());
2981 }
2982
2983 #[test]
2984 fn test_get_tree_includes_label_timestamp() {
2985 let mut sm = SessionManager::in_memory(Path::new("/tmp/test"));
2986 let msg_id = sm.append_message(&make_user_msg("mark this"));
2987 sm.session_mut()
2988 .append_label_change(&msg_id, Some("bookmark"))
2989 .unwrap();
2990
2991 let tree = sm.get_tree();
2992 let node = tree.iter().find(|n| n.entry.id() == msg_id);
2994 assert!(node.is_some());
2995 let node = node.unwrap();
2996 assert_eq!(node.label.as_deref(), Some("bookmark"));
2997 assert!(
2998 node.label_timestamp.is_some(),
2999 "label_timestamp should be populated in get_tree()"
3000 );
3001 }
3002
3003 #[test]
3006 fn test_parse_session_header_line_wrong_version() {
3007 let json = r#"{"type":"session","version":2,"id":"abc","timestamp":"2026-01-01T00:00:00Z","cwd":"/home"}"#;
3009 let result = parse_session_header_line(json);
3010 assert!(result.is_none());
3011 }
3012
3013 #[test]
3014 fn test_parse_session_header_line_empty_id() {
3015 let json = r#"{"type":"session","version":3,"id":"","timestamp":"2026-01-01T00:00:00Z","cwd":"/home"}"#;
3016 let result = parse_session_header_line(json);
3017 assert!(result.is_none());
3018 }
3019
3020 #[test]
3021 fn test_parse_session_header_line_empty_timestamp() {
3022 let json = r#"{"type":"session","version":3,"id":"abc","timestamp":"","cwd":"/home"}"#;
3023 let result = parse_session_header_line(json);
3024 assert!(result.is_none());
3025 }
3026
3027 #[test]
3028 fn test_parse_session_header_line_empty_cwd() {
3029 let json = r#"{"type":"session","version":3,"id":"abc","timestamp":"2026-01-01T00:00:00Z","cwd":""}"#;
3030 let result = parse_session_header_line(json);
3031 assert!(result.is_none());
3032 }
3033
3034 #[test]
3037 fn test_session_error_display() {
3038 assert_eq!(
3039 SessionError::NotFound("entry x".to_string()).to_string(),
3040 "not found: entry x"
3041 );
3042 assert_eq!(
3043 SessionError::InvalidSession("bad file".to_string()).to_string(),
3044 "invalid session: bad file"
3045 );
3046 assert_eq!(
3047 SessionError::InvalidEntry("bad line".to_string()).to_string(),
3048 "invalid entry: bad line"
3049 );
3050 assert_eq!(
3051 SessionError::InvalidForkTarget("wrong position".to_string()).to_string(),
3052 "invalid fork target: wrong position"
3053 );
3054 assert_eq!(
3055 SessionError::Storage("io error".to_string()).to_string(),
3056 "storage error: io error"
3057 );
3058 }
3059
3060 #[test]
3061 fn test_session_error_from_io_error() {
3062 let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk full");
3063 let session_err: SessionError = io_err.into();
3064 assert!(matches!(session_err, SessionError::Storage(_)));
3065 assert_eq!(session_err.to_string(), "storage error: disk full");
3066 }
3067
3068 #[test]
3069 fn test_session_error_from_json_error() {
3070 let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
3071 let session_err: SessionError = json_err.into();
3072 assert!(matches!(session_err, SessionError::InvalidEntry(_)));
3073 }
3074}